oh-my-opencode 5.0.0-beta.89 → 5.0.0-beta.90
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.ru.md +1 -1
- package/README.zh-cn.md +1 -1
- package/dist/agents/sisyphus/grok-4.d.ts +3 -2
- package/dist/cli/config-manager/refresh-opencode-plugin-sandbox.d.ts +31 -0
- package/dist/cli/doctor/checks/tui-plugin-config.d.ts +11 -0
- package/dist/cli/index.js +1661 -859
- package/dist/cli/install-native/index.d.ts +9 -1
- package/dist/cli/install-native/legacy-omo-bin.d.ts +27 -0
- package/dist/cli/install-native/offer-native-setup.d.ts +31 -0
- package/dist/cli/install-native/omo-bin-test-fixtures.d.ts +35 -0
- package/dist/cli/install-native/plan.d.ts +8 -0
- package/dist/cli/install-native/repair-legacy-omo-bin.d.ts +26 -0
- package/dist/cli/install-native/run-native-install.d.ts +11 -1
- package/dist/cli/install-native/verify-omo-command.d.ts +22 -0
- package/dist/cli/native-edition-hint.d.ts +2 -2
- package/dist/cli-node/index.js +1661 -859
- package/dist/hooks/auto-update-checker/checker/sandbox-refresh.d.ts +51 -0
- package/dist/hooks/auto-update-checker/checker/semver-compare.d.ts +17 -0
- package/dist/hooks/auto-update-checker/checker.d.ts +3 -0
- package/dist/hooks/auto-update-checker/hook/background-update-check.d.ts +8 -0
- package/dist/hooks/auto-update-checker/hook.d.ts +2 -0
- package/dist/hooks/native-edition-nudge/hook.d.ts +1 -1
- package/dist/index.js +1433 -1055
- package/dist/shared/index.d.ts +1 -0
- package/dist/shared/opencode-plugin-sandbox.d.ts +54 -0
- package/dist/skills/browser/SKILL.md +8 -1
- package/dist/skills/browser/references/install.md +34 -5
- package/dist/skills/browser/runtime/omowright/index.js +2 -2
- package/dist/skills/browser/runtime/omowright/manifest.json +3 -3
- package/dist/skills/browser/scripts/browser-doctor.mjs +14 -3
- package/dist/skills/browser/scripts/browser-install.mjs +13 -1
- package/dist/skills/ulw-research/AGENTS.md +63 -0
- package/dist/skills/ulw-research/SKILL.md +38 -26
- package/dist/skills/ulw-research/references/deliverable-phase.md +187 -0
- package/dist/skills/ulw-research/references/report-gates.md +66 -0
- package/dist/skills/ulw-research/scripts/cli-support.mjs +102 -0
- package/dist/skills/ulw-research/scripts/cli-support.test.ts +88 -0
- package/dist/skills/ulw-research/scripts/contracts.mjs +162 -0
- package/dist/skills/ulw-research/scripts/contracts.test.ts +177 -0
- package/dist/skills/ulw-research/scripts/css-lite.mjs +204 -0
- package/dist/skills/ulw-research/scripts/css-lite.test.ts +159 -0
- package/dist/skills/ulw-research/scripts/design-spec.mjs +161 -0
- package/dist/skills/ulw-research/scripts/design-spec.test.ts +162 -0
- package/dist/skills/ulw-research/scripts/entities.mjs +39 -0
- package/dist/skills/ulw-research/scripts/entry-guard.mjs +21 -0
- package/dist/skills/ulw-research/scripts/format-extract-css.mjs +122 -0
- package/dist/skills/ulw-research/scripts/format-extract.mjs +229 -0
- package/dist/skills/ulw-research/scripts/format-extract.test.ts +243 -0
- package/dist/skills/ulw-research/scripts/gates-figures.mjs +173 -0
- package/dist/skills/ulw-research/scripts/gates-layout.mjs +146 -0
- package/dist/skills/ulw-research/scripts/gates-layout.test.ts +236 -0
- package/dist/skills/ulw-research/scripts/gates-static.mjs +167 -0
- package/dist/skills/ulw-research/scripts/gates-static.test.ts +265 -0
- package/dist/skills/ulw-research/scripts/gates-structure.mjs +118 -0
- package/dist/skills/ulw-research/scripts/gates-text.mjs +190 -0
- package/dist/skills/ulw-research/scripts/glossary-vocabulary.test.ts +43 -0
- package/dist/skills/ulw-research/scripts/html-lite.mjs +239 -0
- package/dist/skills/ulw-research/scripts/html-lite.test.ts +191 -0
- package/dist/skills/ulw-research/scripts/layout-probe.mjs +83 -0
- package/dist/skills/ulw-research/scripts/layout-probe.test.ts +89 -0
- package/dist/skills/ulw-research/scripts/outcome.mjs +250 -0
- package/dist/skills/ulw-research/scripts/outcome.test.ts +281 -0
- package/dist/skills/ulw-research/scripts/repair-tracker.mjs +171 -0
- package/dist/skills/ulw-research/scripts/repair-tracker.test.ts +246 -0
- package/dist/skills/ulw-research/scripts/report-tools-commands.mjs +214 -0
- package/dist/skills/ulw-research/scripts/report-tools.mjs +61 -0
- package/dist/skills/ulw-research/scripts/report-tools.test.ts +213 -0
- package/dist/skills/ulw-research/scripts/tests/fixtures/assets/figure-1.svg +1 -0
- package/dist/skills/ulw-research/scripts/tests/fixtures/bad-report.html +44 -0
- package/dist/skills/ulw-research/scripts/tests/fixtures/boxes.json +90 -0
- package/dist/skills/ulw-research/scripts/tests/fixtures/design-spec.md +29 -0
- package/dist/skills/ulw-research/scripts/tests/fixtures/good-report.html +57 -0
- package/dist/skills/ulw-research/scripts/tests/fixtures/reference-report.html +86 -0
- package/dist/skills/ulw-research/scripts/tests/fixtures/reference.md +23 -0
- package/dist/skills/visual-qa/references/browser-setup.md +3 -1
- package/dist/tui.js +534 -66
- package/package.json +15 -15
- package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
- package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
- package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
- package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
- package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
- package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +2 -2
- package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
- package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-5.6.md +5 -1
- package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-6.md +5 -1
- package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
- package/packages/omo-codex/plugin/components/rules/package.json +1 -1
- package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
- package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/dist/cli.js +7 -3
- package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/src/directive-content.ts +1 -1
- package/packages/omo-codex/plugin/components/ulw-execute-continuation/directive.md +1 -1
- package/packages/omo-codex/plugin/components/ulw-execute-continuation/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/ulw-execute-continuation/package.json +1 -1
- package/packages/omo-codex/plugin/components/ulw-loop/directive.md +7 -3
- package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +5 -5
- package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-recording-spawn-admission.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
- package/packages/omo-codex/plugin/hooks/stop-checking-ulw-execute-continuation.json +1 -1
- package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
- package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
- package/packages/omo-codex/plugin/package-lock.json +12 -12
- package/packages/omo-codex/plugin/package.json +1 -1
- package/packages/omo-codex/plugin/scripts/sync-skills.mjs +2 -2
- package/packages/omo-codex/plugin/skills/browser/SKILL.md +8 -1
- package/packages/omo-codex/plugin/skills/browser/references/install.md +34 -5
- package/packages/omo-codex/plugin/skills/browser/runtime/omowright/index.js +2 -2
- package/packages/omo-codex/plugin/skills/browser/runtime/omowright/manifest.json +3 -3
- package/packages/omo-codex/plugin/skills/browser/scripts/browser-doctor.mjs +14 -3
- package/packages/omo-codex/plugin/skills/browser/scripts/browser-install.mjs +13 -1
- package/packages/omo-codex/plugin/skills/ultrawork/SKILL.md +7 -3
- package/packages/omo-codex/plugin/skills/ulw-research/AGENTS.md +63 -0
- package/packages/omo-codex/plugin/skills/ulw-research/SKILL.md +37 -25
- package/packages/omo-codex/plugin/skills/ulw-research/references/deliverable-phase.md +187 -0
- package/packages/omo-codex/plugin/skills/ulw-research/references/report-gates.md +66 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/cli-support.mjs +102 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/contracts.mjs +162 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/css-lite.mjs +204 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/design-spec.mjs +161 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/entities.mjs +39 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/entry-guard.mjs +21 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/format-extract-css.mjs +122 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/format-extract.mjs +229 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-figures.mjs +173 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-layout.mjs +146 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-static.mjs +167 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-structure.mjs +118 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-text.mjs +190 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/html-lite.mjs +239 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/layout-probe.mjs +83 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/outcome.mjs +250 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/repair-tracker.mjs +171 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/report-tools-commands.mjs +214 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/report-tools.mjs +61 -0
- package/packages/omo-codex/plugin/skills/visual-qa/references/browser-setup.md +3 -1
- package/packages/omo-codex/plugin/test/sync-skills-codex-compatibility.test.mjs +11 -0
- package/packages/omo-codex/plugin/test/sync-skills-test-support.mjs +2 -2
- package/packages/omo-codex/plugin/test/sync-skills.test.mjs +20 -0
- package/packages/omo-codex/scripts/install-dist/install-local.mjs +2 -2
- package/packages/prompts-core/prompts/ultrawork/codex.md +7 -3
- package/packages/shared-skills/skills/browser/SKILL.md +8 -1
- package/packages/shared-skills/skills/browser/references/install.md +34 -5
- package/packages/shared-skills/skills/browser/runtime/omowright/index.js +2 -2
- package/packages/shared-skills/skills/browser/runtime/omowright/manifest.json +3 -3
- package/packages/shared-skills/skills/browser/scripts/browser-doctor.mjs +14 -3
- package/packages/shared-skills/skills/browser/scripts/browser-install.mjs +13 -1
- package/packages/shared-skills/skills/ulw-research/AGENTS.md +63 -0
- package/packages/shared-skills/skills/ulw-research/SKILL.md +38 -26
- package/packages/shared-skills/skills/ulw-research/references/deliverable-phase.md +187 -0
- package/packages/shared-skills/skills/ulw-research/references/report-gates.md +66 -0
- package/packages/shared-skills/skills/ulw-research/scripts/cli-support.mjs +102 -0
- package/packages/shared-skills/skills/ulw-research/scripts/cli-support.test.ts +88 -0
- package/packages/shared-skills/skills/ulw-research/scripts/contracts.mjs +162 -0
- package/packages/shared-skills/skills/ulw-research/scripts/contracts.test.ts +177 -0
- package/packages/shared-skills/skills/ulw-research/scripts/css-lite.mjs +204 -0
- package/packages/shared-skills/skills/ulw-research/scripts/css-lite.test.ts +159 -0
- package/packages/shared-skills/skills/ulw-research/scripts/design-spec.mjs +161 -0
- package/packages/shared-skills/skills/ulw-research/scripts/design-spec.test.ts +162 -0
- package/packages/shared-skills/skills/ulw-research/scripts/entities.mjs +39 -0
- package/packages/shared-skills/skills/ulw-research/scripts/entry-guard.mjs +21 -0
- package/packages/shared-skills/skills/ulw-research/scripts/format-extract-css.mjs +122 -0
- package/packages/shared-skills/skills/ulw-research/scripts/format-extract.mjs +229 -0
- package/packages/shared-skills/skills/ulw-research/scripts/format-extract.test.ts +243 -0
- package/packages/shared-skills/skills/ulw-research/scripts/gates-figures.mjs +173 -0
- package/packages/shared-skills/skills/ulw-research/scripts/gates-layout.mjs +146 -0
- package/packages/shared-skills/skills/ulw-research/scripts/gates-layout.test.ts +236 -0
- package/packages/shared-skills/skills/ulw-research/scripts/gates-static.mjs +167 -0
- package/packages/shared-skills/skills/ulw-research/scripts/gates-static.test.ts +265 -0
- package/packages/shared-skills/skills/ulw-research/scripts/gates-structure.mjs +118 -0
- package/packages/shared-skills/skills/ulw-research/scripts/gates-text.mjs +190 -0
- package/packages/shared-skills/skills/ulw-research/scripts/glossary-vocabulary.test.ts +43 -0
- package/packages/shared-skills/skills/ulw-research/scripts/html-lite.mjs +239 -0
- package/packages/shared-skills/skills/ulw-research/scripts/html-lite.test.ts +191 -0
- package/packages/shared-skills/skills/ulw-research/scripts/layout-probe.mjs +83 -0
- package/packages/shared-skills/skills/ulw-research/scripts/layout-probe.test.ts +89 -0
- package/packages/shared-skills/skills/ulw-research/scripts/outcome.mjs +250 -0
- package/packages/shared-skills/skills/ulw-research/scripts/outcome.test.ts +281 -0
- package/packages/shared-skills/skills/ulw-research/scripts/repair-tracker.mjs +171 -0
- package/packages/shared-skills/skills/ulw-research/scripts/repair-tracker.test.ts +246 -0
- package/packages/shared-skills/skills/ulw-research/scripts/report-tools-commands.mjs +214 -0
- package/packages/shared-skills/skills/ulw-research/scripts/report-tools.mjs +61 -0
- package/packages/shared-skills/skills/ulw-research/scripts/report-tools.test.ts +213 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/assets/figure-1.svg +1 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/bad-report.html +44 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/boxes.json +90 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/design-spec.md +29 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/good-report.html +57 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/reference-report.html +86 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/reference.md +23 -0
- package/packages/shared-skills/skills/visual-qa/references/browser-setup.md +3 -1
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// html-lite.mjs - a dependency-free HTML tokenizer for report checks.
|
|
2
|
+
//
|
|
3
|
+
// Single forward scan with indexOf (no whole-document regex, no backtracking). Produces a
|
|
4
|
+
// LiteNode tree: { tag, attrs, children, parent, line, text, raw }. Text nodes carry
|
|
5
|
+
// tag "#text"; <style>/<script>/<svg> keep their inner source in `raw` and have no parsed
|
|
6
|
+
// children. The "#document" root also carries `ids` (id -> occurrence count) so
|
|
7
|
+
// selectorPath can tell whether an id is unique. Plain Node ESM, no dependencies.
|
|
8
|
+
|
|
9
|
+
import { decodeEntities } from "./entities.mjs"
|
|
10
|
+
|
|
11
|
+
export { decodeEntities }
|
|
12
|
+
|
|
13
|
+
const VOID_TAGS = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"])
|
|
14
|
+
const RAW_TAGS = new Set(["style", "script", "svg"])
|
|
15
|
+
const HEADINGS = ["h1", "h2", "h3", "h4", "h5", "h6"]
|
|
16
|
+
const P_CLOSERS = new Set([
|
|
17
|
+
"address", "article", "blockquote", "details", "div", "dl", "fieldset", "figcaption", "figure", "footer",
|
|
18
|
+
"form", "header", "hr", "main", "menu", "nav", "ol", "p", "pre", "section", "table", "ul", ...HEADINGS,
|
|
19
|
+
])
|
|
20
|
+
const P_SCOPE = new Set(["td", "th", "caption", "button", "body", "html"])
|
|
21
|
+
const LI_SCOPE = new Set(["ul", "ol", "menu", "body", "html"])
|
|
22
|
+
const PROSE_SKIP = new Set(["code", "pre", "kbd", "samp", "script", "style", "svg"])
|
|
23
|
+
const SAFE_ID = /^[A-Za-z_][\w-]*$/
|
|
24
|
+
|
|
25
|
+
export const PROSE_TAGS = new Set(["p", "li", ...HEADINGS, "td", "th", "figcaption", "blockquote", "dd", "dt"])
|
|
26
|
+
const PROSE_BREAKS = new Set([...P_CLOSERS, ...PROSE_TAGS, "br", "tr"])
|
|
27
|
+
|
|
28
|
+
function makeNode(tag, attrs, parent, line) {
|
|
29
|
+
return { tag, attrs, children: [], parent, line, text: "", raw: null }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function lineStarts(source) {
|
|
33
|
+
const starts = [0]
|
|
34
|
+
for (let at = source.indexOf("\n"); at !== -1; at = source.indexOf("\n", at + 1)) starts.push(at + 1)
|
|
35
|
+
return starts
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function lineAt(starts, offset) {
|
|
39
|
+
let lo = 0
|
|
40
|
+
let hi = starts.length - 1
|
|
41
|
+
while (lo < hi) {
|
|
42
|
+
const mid = (lo + hi + 1) >> 1
|
|
43
|
+
if (starts[mid] <= offset) lo = mid
|
|
44
|
+
else hi = mid - 1
|
|
45
|
+
}
|
|
46
|
+
return lo + 1
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const isSpace = (ch) => ch === " " || ch === "\n" || ch === "\t" || ch === "\r" || ch === "\f"
|
|
50
|
+
const isLetter = (ch) => (ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z")
|
|
51
|
+
|
|
52
|
+
function skipSpaces(source, at) {
|
|
53
|
+
while (at < source.length && isSpace(source[at])) at++
|
|
54
|
+
return at
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Read `<name attrs...>` starting just after `<`. */
|
|
58
|
+
function readTag(source, start) {
|
|
59
|
+
const end = source.length
|
|
60
|
+
let at = start
|
|
61
|
+
while (at < end && !isSpace(source[at]) && source[at] !== ">" && source[at] !== "/") at++
|
|
62
|
+
const name = source.slice(start, at).toLowerCase()
|
|
63
|
+
const attrs = {}
|
|
64
|
+
let selfClosing = false
|
|
65
|
+
while (at < end) {
|
|
66
|
+
const ch = source[at]
|
|
67
|
+
if (isSpace(ch)) { at++; continue }
|
|
68
|
+
if (ch === ">") return { name, attrs, end: at + 1, selfClosing }
|
|
69
|
+
selfClosing = ch === "/" && source[at + 1] === ">"
|
|
70
|
+
if (ch === "/") { at++; continue }
|
|
71
|
+
let stop = at + 1
|
|
72
|
+
while (stop < end && !isSpace(source[stop]) && source[stop] !== "/" && source[stop] !== ">" && source[stop] !== "=") stop++
|
|
73
|
+
const key = source.slice(at, stop).toLowerCase()
|
|
74
|
+
at = skipSpaces(source, stop)
|
|
75
|
+
let value = ""
|
|
76
|
+
if (source[at] === "=") {
|
|
77
|
+
at = skipSpaces(source, at + 1)
|
|
78
|
+
const quote = source[at]
|
|
79
|
+
if (quote === '"' || quote === "'") {
|
|
80
|
+
const close = source.indexOf(quote, at + 1)
|
|
81
|
+
const valueEnd = close === -1 ? end : close
|
|
82
|
+
value = source.slice(at + 1, valueEnd)
|
|
83
|
+
at = valueEnd + 1
|
|
84
|
+
} else {
|
|
85
|
+
let valueEnd = at
|
|
86
|
+
while (valueEnd < end && !isSpace(source[valueEnd]) && source[valueEnd] !== ">") valueEnd++
|
|
87
|
+
value = source.slice(at, valueEnd)
|
|
88
|
+
at = valueEnd
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (key !== "__proto__" && !Object.hasOwn(attrs, key)) attrs[key] = decodeEntities(value)
|
|
92
|
+
}
|
|
93
|
+
return { name, attrs, end, selfClosing }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function tagNameAt(source, at, tag) {
|
|
97
|
+
if (source.slice(at, at + tag.length).toLowerCase() !== tag) return false
|
|
98
|
+
const next = source[at + tag.length] ?? ">"
|
|
99
|
+
return isSpace(next) || next === ">" || next === "/"
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Find the close of a raw element; nested <svg> is depth-counted. Returns [innerEnd, resumeAt]. */
|
|
103
|
+
function findRawClose(source, from, tag) {
|
|
104
|
+
let depth = 0
|
|
105
|
+
for (let lt = source.indexOf("<", from); lt !== -1; lt = source.indexOf("<", lt + 1)) {
|
|
106
|
+
if (source[lt + 1] === "/" && tagNameAt(source, lt + 2, tag)) {
|
|
107
|
+
if (depth === 0) {
|
|
108
|
+
const gt = source.indexOf(">", lt)
|
|
109
|
+
return [lt, gt === -1 ? source.length : gt + 1]
|
|
110
|
+
}
|
|
111
|
+
depth--
|
|
112
|
+
} else if (tag === "svg" && tagNameAt(source, lt + 1, tag)) {
|
|
113
|
+
depth++
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return [source.length, source.length]
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Parse an HTML string into a LiteNode tree rooted at "#document"; never throws on malformed input. */
|
|
120
|
+
export function parseHtml(source) {
|
|
121
|
+
const src = String(source)
|
|
122
|
+
const starts = lineStarts(src)
|
|
123
|
+
const root = makeNode("#document", {}, null, 1)
|
|
124
|
+
root.ids = new Map()
|
|
125
|
+
const stack = [root]
|
|
126
|
+
let sawBody = false
|
|
127
|
+
let textStart = 0
|
|
128
|
+
let at = 0
|
|
129
|
+
const top = () => stack[stack.length - 1]
|
|
130
|
+
const flushText = (to) => {
|
|
131
|
+
if (to > textStart) {
|
|
132
|
+
const node = makeNode("#text", {}, top(), lineAt(starts, textStart))
|
|
133
|
+
node.text = decodeEntities(src.slice(textStart, to))
|
|
134
|
+
top().children.push(node)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const closeWithin = (tag, scope) => {
|
|
138
|
+
for (let k = stack.length - 1; k > 0; k--) {
|
|
139
|
+
if (stack[k].tag === tag) { stack.length = k; return }
|
|
140
|
+
if (scope.has(stack[k].tag)) return
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
while (at < src.length) {
|
|
144
|
+
const lt = src.indexOf("<", at)
|
|
145
|
+
if (lt === -1) break
|
|
146
|
+
const next = src[lt + 1] ?? ""
|
|
147
|
+
if (src.startsWith("<!--", lt)) {
|
|
148
|
+
flushText(lt)
|
|
149
|
+
const close = src.indexOf("-->", lt + 4)
|
|
150
|
+
at = textStart = close === -1 ? src.length : close + 3
|
|
151
|
+
} else if (next === "!" || next === "?") {
|
|
152
|
+
flushText(lt)
|
|
153
|
+
const close = src.indexOf(">", lt)
|
|
154
|
+
at = textStart = close === -1 ? src.length : close + 1
|
|
155
|
+
} else if (next === "/" && isLetter(src[lt + 2] ?? "")) {
|
|
156
|
+
flushText(lt)
|
|
157
|
+
const tag = readTag(src, lt + 2)
|
|
158
|
+
if (tag.name !== "body" && tag.name !== "html") {
|
|
159
|
+
for (let k = stack.length - 1; k > 0; k--) {
|
|
160
|
+
if (stack[k].tag === tag.name) { stack.length = k; break }
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
at = textStart = tag.end
|
|
164
|
+
} else if (isLetter(next)) {
|
|
165
|
+
flushText(lt)
|
|
166
|
+
const tag = readTag(src, lt + 1)
|
|
167
|
+
at = textStart = tag.end
|
|
168
|
+
if ((tag.name === "html" || tag.name === "body" || tag.name === "head") && stack.some((n) => n.tag === tag.name)) continue
|
|
169
|
+
if (P_CLOSERS.has(tag.name)) closeWithin("p", P_SCOPE)
|
|
170
|
+
if (tag.name === "li") closeWithin("li", LI_SCOPE)
|
|
171
|
+
const node = makeNode(tag.name, tag.attrs, top(), lineAt(starts, lt))
|
|
172
|
+
top().children.push(node)
|
|
173
|
+
if (tag.name === "body") sawBody = true
|
|
174
|
+
if (tag.attrs.id) root.ids.set(tag.attrs.id, (root.ids.get(tag.attrs.id) ?? 0) + 1)
|
|
175
|
+
if (RAW_TAGS.has(tag.name)) {
|
|
176
|
+
node.raw = ""
|
|
177
|
+
if (!tag.selfClosing) {
|
|
178
|
+
const [innerEnd, resume] = findRawClose(src, tag.end, tag.name)
|
|
179
|
+
node.raw = src.slice(tag.end, innerEnd)
|
|
180
|
+
at = textStart = resume
|
|
181
|
+
}
|
|
182
|
+
} else if (!VOID_TAGS.has(tag.name)) {
|
|
183
|
+
stack.push(node)
|
|
184
|
+
}
|
|
185
|
+
} else {
|
|
186
|
+
at = lt + 1
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
flushText(src.length)
|
|
190
|
+
if (!sawBody) wrapInBody(root)
|
|
191
|
+
return root
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Fragments without <body>: move everything except <head> under a synthesized body. */
|
|
195
|
+
function wrapInBody(root) {
|
|
196
|
+
const host = root.children.find((child) => child.tag === "html") ?? root
|
|
197
|
+
const kept = host.children.filter((child) => child.tag === "head")
|
|
198
|
+
const moved = host.children.filter((child) => child.tag !== "head")
|
|
199
|
+
const body = makeNode("body", {}, host, moved[0]?.line ?? 1)
|
|
200
|
+
for (const child of moved) child.parent = body
|
|
201
|
+
body.children = moved
|
|
202
|
+
host.children = [...kept, body]
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function segment(node) {
|
|
206
|
+
const same = node.parent.children.filter((child) => child.tag === node.tag)
|
|
207
|
+
return same.length > 1 ? `${node.tag}:nth-of-type(${same.indexOf(node) + 1})` : node.tag
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** CSS selector for a node: nearest unique-id anchor, else `body > tag:nth-of-type(k) > ...`. */
|
|
211
|
+
export function selectorPath(node) {
|
|
212
|
+
let root = node
|
|
213
|
+
while (root.parent) root = root.parent
|
|
214
|
+
const ids = root.ids ?? new Map()
|
|
215
|
+
const segments = []
|
|
216
|
+
for (let cur = node.tag === "#text" ? node.parent : node; cur?.parent; cur = cur.parent) {
|
|
217
|
+
const id = cur.attrs.id
|
|
218
|
+
if (id && SAFE_ID.test(id) && ids.get(id) === 1) return [`#${id}`, ...segments].join(" > ")
|
|
219
|
+
if (cur.tag === "body") return ["body", ...segments].join(" > ")
|
|
220
|
+
if (cur.tag === "html") break
|
|
221
|
+
segments.unshift(segment(cur))
|
|
222
|
+
}
|
|
223
|
+
return segments.join(" > ")
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Descendant text minus code/pre/kbd/samp/script/style/svg, whitespace collapsed. */
|
|
227
|
+
export function proseText(node) {
|
|
228
|
+
const parts = []
|
|
229
|
+
const walk = (cur) => {
|
|
230
|
+
if (cur.tag === "#text") { parts.push(cur.text); return }
|
|
231
|
+
if (PROSE_SKIP.has(cur.tag)) return
|
|
232
|
+
const breaks = PROSE_BREAKS.has(cur.tag)
|
|
233
|
+
if (breaks) parts.push(" ")
|
|
234
|
+
for (const child of cur.children) walk(child)
|
|
235
|
+
if (breaks) parts.push(" ")
|
|
236
|
+
}
|
|
237
|
+
for (const child of node.children) walk(child)
|
|
238
|
+
return parts.join("").replace(/\s+/g, " ").trim()
|
|
239
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
|
|
3
|
+
import { decodeEntities, parseHtml, proseText, PROSE_TAGS, selectorPath } from "./html-lite.mjs"
|
|
4
|
+
|
|
5
|
+
type LiteNode = {
|
|
6
|
+
tag: string
|
|
7
|
+
attrs: Record<string, string>
|
|
8
|
+
children: LiteNode[]
|
|
9
|
+
parent: LiteNode | null
|
|
10
|
+
line: number
|
|
11
|
+
text: string
|
|
12
|
+
raw: string | null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function elements(root: LiteNode): LiteNode[] {
|
|
16
|
+
const out: LiteNode[] = []
|
|
17
|
+
const stack = [...root.children].reverse()
|
|
18
|
+
while (stack.length > 0) {
|
|
19
|
+
const node = stack.pop() as LiteNode
|
|
20
|
+
if (node.tag === "#text") continue
|
|
21
|
+
out.push(node)
|
|
22
|
+
for (let i = node.children.length - 1; i >= 0; i--) stack.push(node.children[i] as LiteNode)
|
|
23
|
+
}
|
|
24
|
+
return out
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function byTag(root: LiteNode, tag: string): LiteNode[] {
|
|
28
|
+
return elements(root).filter((node) => node.tag === tag)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function childTags(node: LiteNode): string[] {
|
|
32
|
+
return node.children.filter((child) => child.tag !== "#text").map((child) => child.tag)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe("selectorPath", () => {
|
|
36
|
+
test("#given the 3rd p in the 2nd section under main #when a path is built #then it uses nth-of-type segments under body", () => {
|
|
37
|
+
// given
|
|
38
|
+
const doc = parseHtml(
|
|
39
|
+
"<html><body><main><section><p>x</p></section><section><p>1</p><p>2</p><p>3</p></section></main></body></html>",
|
|
40
|
+
)
|
|
41
|
+
const third = byTag(doc, "p")[3] as LiteNode
|
|
42
|
+
// when
|
|
43
|
+
const path = selectorPath(third)
|
|
44
|
+
// then
|
|
45
|
+
expect(path).toBe("body > main > section:nth-of-type(2) > p:nth-of-type(3)")
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test("#given an ancestor with a unique id #when a path is built #then it anchors at that id", () => {
|
|
49
|
+
// given
|
|
50
|
+
const doc = parseHtml(
|
|
51
|
+
'<body><main><section id="results"><p>1</p><p>2</p><p>3</p></section></main></body>',
|
|
52
|
+
)
|
|
53
|
+
const third = byTag(doc, "p")[2] as LiteNode
|
|
54
|
+
// when
|
|
55
|
+
const path = selectorPath(third)
|
|
56
|
+
// then
|
|
57
|
+
expect(path).toBe("#results > p:nth-of-type(3)")
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test("#given a duplicated id #when a path is built #then the id is not used as an anchor", () => {
|
|
61
|
+
// given
|
|
62
|
+
const doc = parseHtml('<body><div id="dup"><p>a</p></div><div id="dup"><p>b</p></div></body>')
|
|
63
|
+
const second = byTag(doc, "p")[1] as LiteNode
|
|
64
|
+
// when
|
|
65
|
+
const path = selectorPath(second)
|
|
66
|
+
// then
|
|
67
|
+
expect(path).toBe("body > div:nth-of-type(2) > p")
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe("parseHtml", () => {
|
|
72
|
+
test("#given a p followed by an h2 #when parsed #then the p auto-closes and h2 is its sibling", () => {
|
|
73
|
+
// given
|
|
74
|
+
const source = "<body><p>a<h2>b</h2></body>"
|
|
75
|
+
// when
|
|
76
|
+
const doc = parseHtml(source)
|
|
77
|
+
// then
|
|
78
|
+
const body = byTag(doc, "body")[0] as LiteNode
|
|
79
|
+
expect(childTags(body)).toEqual(["p", "h2"])
|
|
80
|
+
expect((byTag(doc, "h2")[0] as LiteNode).parent).toBe(body)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test("#given consecutive li without close tags #when parsed #then each li is a sibling", () => {
|
|
84
|
+
// given
|
|
85
|
+
const source = "<body><ul><li>one<li>two<li>three</ul></body>"
|
|
86
|
+
// when
|
|
87
|
+
const doc = parseHtml(source)
|
|
88
|
+
// then
|
|
89
|
+
const list = byTag(doc, "ul")[0] as LiteNode
|
|
90
|
+
expect(childTags(list)).toEqual(["li", "li", "li"])
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test("#given an element on source line 7 #when parsed #then its line is 7", () => {
|
|
94
|
+
// given
|
|
95
|
+
const source = ["<html>", "<body>", "<main>", "<section>", "<p>one</p>", "<p>two</p>", '<p id="seven">x</p>', "</section>"].join("\n")
|
|
96
|
+
// when
|
|
97
|
+
const doc = parseHtml(source)
|
|
98
|
+
// then
|
|
99
|
+
const seven = byTag(doc, "p").find((node) => node.attrs.id === "seven") as LiteNode
|
|
100
|
+
expect(seven.line).toBe(7)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test("#given comments, void tags and an unmatched close tag #when parsed #then comments vanish, voids stay leaves, the stray close is ignored", () => {
|
|
104
|
+
// given
|
|
105
|
+
const source = "<body><!-- <p>hidden</p> --><div>a<br>b<img src=x.png></span> c</div></body>"
|
|
106
|
+
// when
|
|
107
|
+
const doc = parseHtml(source)
|
|
108
|
+
// then
|
|
109
|
+
expect(byTag(doc, "p")).toHaveLength(0)
|
|
110
|
+
const div = byTag(doc, "div")[0] as LiteNode
|
|
111
|
+
expect(childTags(div)).toEqual(["br", "img"])
|
|
112
|
+
expect((byTag(doc, "br")[0] as LiteNode).children).toHaveLength(0)
|
|
113
|
+
expect((byTag(doc, "img")[0] as LiteNode).attrs.src).toBe("x.png")
|
|
114
|
+
expect(proseText(div)).toBe("a b c")
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
test("#given style, script and svg elements #when parsed #then their inner source is captured raw", () => {
|
|
118
|
+
// given
|
|
119
|
+
const source =
|
|
120
|
+
'<head><style>p > a { color: red }</style></head><body><script>if (a < b) {}</script><svg viewBox="0 0 1 1"><svg><text>t</text></svg></svg><p>after</p></body>'
|
|
121
|
+
// when
|
|
122
|
+
const doc = parseHtml(source)
|
|
123
|
+
// then
|
|
124
|
+
expect((byTag(doc, "style")[0] as LiteNode).raw).toBe("p > a { color: red }")
|
|
125
|
+
expect((byTag(doc, "script")[0] as LiteNode).raw).toBe("if (a < b) {}")
|
|
126
|
+
const svg = byTag(doc, "svg")[0] as LiteNode
|
|
127
|
+
expect(svg.raw).toBe("<svg><text>t</text></svg>")
|
|
128
|
+
expect(svg.attrs.viewbox).toBe("0 0 1 1")
|
|
129
|
+
expect(childTags(svg.parent as LiteNode)).toEqual(["script", "svg", "p"])
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test("#given an unclosed fragment without body #when parsed #then the tree still has body > div > p", () => {
|
|
133
|
+
// given
|
|
134
|
+
const source = "<div><p>unclosed"
|
|
135
|
+
// when
|
|
136
|
+
const doc = parseHtml(source)
|
|
137
|
+
// then
|
|
138
|
+
const p = byTag(doc, "p")[0] as LiteNode
|
|
139
|
+
expect(p.parent?.tag).toBe("div")
|
|
140
|
+
expect(p.parent?.parent?.tag).toBe("body")
|
|
141
|
+
expect(selectorPath(p)).toBe("body > div > p")
|
|
142
|
+
expect(proseText(p)).toBe("unclosed")
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test("#given attributes in every quoting style #when parsed #then values are decoded", () => {
|
|
146
|
+
// given
|
|
147
|
+
const source = `<body><a href="#s1" data-x='y & z' hidden title=bare>k</a></body>`
|
|
148
|
+
// when
|
|
149
|
+
const doc = parseHtml(source)
|
|
150
|
+
// then
|
|
151
|
+
const anchor = byTag(doc, "a")[0] as LiteNode
|
|
152
|
+
expect(anchor.attrs).toEqual({ href: "#s1", "data-x": "y & z", hidden: "", title: "bare" })
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe("decodeEntities", () => {
|
|
157
|
+
test("#given — #when decoded #then it is U+2014", () => {
|
|
158
|
+
expect(decodeEntities("a—b")).toBe("a\u2014b")
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
test("#given named, decimal and hex entities #when decoded #then each maps to its code point", () => {
|
|
162
|
+
// given
|
|
163
|
+
const source = "&<>"' –…©®×·,😀"
|
|
164
|
+
// when
|
|
165
|
+
const decoded = decodeEntities(source)
|
|
166
|
+
// then
|
|
167
|
+
expect(decoded).toBe("&<>\"'\u00a0\u2013\u2026\u00a9\u00ae\u00d7\u00b7,\u{1F600}")
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
test("#given unknown or malformed entities #when decoded #then they are left verbatim", () => {
|
|
171
|
+
expect(decodeEntities("&bogus; & &#xZZ; AT&T")).toBe("&bogus; & &#xZZ; AT&T")
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
describe("proseText", () => {
|
|
176
|
+
test("#given a paragraph with inline code #when prose is extracted #then code is skipped and whitespace collapsed", () => {
|
|
177
|
+
// given
|
|
178
|
+
const doc = parseHtml("<body><p>Run <code>npm i</code>\n then <kbd>Enter</kbd> now</p></body>")
|
|
179
|
+
const p = byTag(doc, "p")[0] as LiteNode
|
|
180
|
+
// when
|
|
181
|
+
const prose = proseText(p)
|
|
182
|
+
// then
|
|
183
|
+
expect(prose).toBe("Run then now")
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test("#given PROSE_TAGS #when read #then it holds the prose-bearing tags", () => {
|
|
187
|
+
expect([...PROSE_TAGS].sort()).toEqual(
|
|
188
|
+
["blockquote", "dd", "dt", "figcaption", "h1", "h2", "h3", "h4", "h5", "h6", "li", "p", "td", "th"].sort(),
|
|
189
|
+
)
|
|
190
|
+
})
|
|
191
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// layout-probe.mjs - the in-page probe the layout gates consume.
|
|
2
|
+
//
|
|
3
|
+
// buildProbeSource() returns ONE async IIFE expression (the engine's evaluate awaits it): it first
|
|
4
|
+
// forces lazy images to load eagerly and awaits their decode (bounded), so natural sizes are real. The orchestrator evaluates it in the rendered
|
|
5
|
+
// report through the browser skill's owned headless engine and writes the result to boxes.json;
|
|
6
|
+
// gates-layout.mjs reads that file. This module never launches a browser.
|
|
7
|
+
//
|
|
8
|
+
// Selector paths use the SAME rule as html-lite.selectorPath (a unique safe #id anchors the path,
|
|
9
|
+
// otherwise tag segments with :nth-of-type(k) only when the parent has more than one child of that
|
|
10
|
+
// tag, prefixed "body"), so a layout defect can be mapped back to the static tree. The rule lives in
|
|
11
|
+
// PATH_FUNCTION_SOURCE, embedded verbatim in the probe and exercised by the parity test.
|
|
12
|
+
|
|
13
|
+
export const PROBE_VERSION = 1
|
|
14
|
+
export const SAMPLED_SELECTOR = "figure, .fig, img, svg, table, h1, h2, h3, p, pre"
|
|
15
|
+
export const EXCLUDED_ANCESTORS = Object.freeze(["svg", "nav", "header", "footer"])
|
|
16
|
+
|
|
17
|
+
/** In-page path rule. idCount(id) returns how many elements carry that id. */
|
|
18
|
+
export const PATH_FUNCTION_SOURCE = `function ulwSelectorPath(el, idCount) {
|
|
19
|
+
const SAFE_ID = /^[A-Za-z_][\\w-]*$/;
|
|
20
|
+
const segments = [];
|
|
21
|
+
for (let cur = el; cur && cur.parentElement; cur = cur.parentElement) {
|
|
22
|
+
const tag = cur.tagName.toLowerCase();
|
|
23
|
+
const id = cur.id;
|
|
24
|
+
if (id && SAFE_ID.test(id) && idCount(id) === 1) return ["#" + id].concat(segments).join(" > ");
|
|
25
|
+
if (tag === "body") return ["body"].concat(segments).join(" > ");
|
|
26
|
+
if (tag === "html") break;
|
|
27
|
+
const same = Array.from(cur.parentElement.children).filter((c) => c.tagName === cur.tagName);
|
|
28
|
+
segments.unshift(same.length > 1 ? tag + ":nth-of-type(" + (same.indexOf(cur) + 1) + ")" : tag);
|
|
29
|
+
}
|
|
30
|
+
return segments.join(" > ");
|
|
31
|
+
}`
|
|
32
|
+
|
|
33
|
+
export class ProbeError extends Error {
|
|
34
|
+
constructor(code, message) {
|
|
35
|
+
super(message)
|
|
36
|
+
this.name = "ProbeError"
|
|
37
|
+
this.code = code
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** @param {{ cap?: number, root?: string }} [options] */
|
|
42
|
+
export function buildProbeSource({ cap = 400, root = "main, article" } = {}) {
|
|
43
|
+
if (!Number.isInteger(cap) || cap <= 0) throw new ProbeError("invalid_cap", `--cap requires a positive integer, got ${cap}`)
|
|
44
|
+
if (typeof root !== "string" || root.trim().length === 0) throw new ProbeError("invalid_root", "--root requires a non-empty CSS selector")
|
|
45
|
+
return `(async () => {
|
|
46
|
+
const CAP = ${cap};
|
|
47
|
+
const IMAGE_WAIT_MS = 5000;
|
|
48
|
+
const ROOT = document.querySelector(${JSON.stringify(root)}) || document.body;
|
|
49
|
+
const EXCLUDED = ${JSON.stringify(EXCLUDED_ANCESTORS)};
|
|
50
|
+
const images = Array.from(document.images);
|
|
51
|
+
for (const img of images) if (img.loading === "lazy") img.loading = "eager";
|
|
52
|
+
const settle = Promise.all(images.map((img) => (img.complete && img.naturalWidth > 0 ? null : img.decode().catch(() => null))));
|
|
53
|
+
await Promise.race([settle, new Promise((resolve) => setTimeout(resolve, IMAGE_WAIT_MS))]);
|
|
54
|
+
${PATH_FUNCTION_SOURCE}
|
|
55
|
+
const idCount = (id) => document.querySelectorAll("#" + CSS.escape(id)).length;
|
|
56
|
+
const path = (el) => ulwSelectorPath(el, idCount);
|
|
57
|
+
const r1 = (n) => Math.round(n * 10) / 10;
|
|
58
|
+
const rect = (el) => { const b = el.getBoundingClientRect(); return { x: r1(b.left + scrollX), y: r1(b.top + scrollY), w: r1(b.width), h: r1(b.height) }; };
|
|
59
|
+
const excluded = (el) => { for (let p = el.parentElement; p; p = p.parentElement) if (EXCLUDED.includes(p.tagName.toLowerCase())) return true; return false; };
|
|
60
|
+
const all = Array.from(ROOT.querySelectorAll(${JSON.stringify(SAMPLED_SELECTOR)})).filter((el) => {
|
|
61
|
+
if (excluded(el)) return false;
|
|
62
|
+
const b = el.getBoundingClientRect();
|
|
63
|
+
return b.width > 0 || b.height > 0;
|
|
64
|
+
});
|
|
65
|
+
const boxes = all.slice(0, CAP).map((el) => {
|
|
66
|
+
const cs = getComputedStyle(el);
|
|
67
|
+
const parent = el.parentElement;
|
|
68
|
+
const box = {
|
|
69
|
+
selector: path(el), tag: el.tagName.toLowerCase(), rect: rect(el),
|
|
70
|
+
parentSelector: parent ? path(parent) : null, parentRect: parent ? rect(parent) : null,
|
|
71
|
+
parentOverflow: parent ? { x: getComputedStyle(parent).overflowX, y: getComputedStyle(parent).overflowY } : null,
|
|
72
|
+
scroll: { sw: el.scrollWidth, cw: el.clientWidth, sh: el.scrollHeight, ch: el.clientHeight },
|
|
73
|
+
overflow: { x: cs.overflowX, y: cs.overflowY }, position: cs.position,
|
|
74
|
+
textLen: (el.textContent || "").trim().length,
|
|
75
|
+
};
|
|
76
|
+
if (el.tagName === "IMG") { box.objectFit = cs.objectFit; box.natural = { w: el.naturalWidth, h: el.naturalHeight }; }
|
|
77
|
+
return box;
|
|
78
|
+
});
|
|
79
|
+
return { probe_version: ${PROBE_VERSION}, viewport: { w: innerWidth, h: innerHeight },
|
|
80
|
+
document: { w: document.documentElement.scrollWidth, h: document.documentElement.scrollHeight },
|
|
81
|
+
count: all.length, truncated: all.length > CAP, boxes };
|
|
82
|
+
})()`
|
|
83
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
|
|
3
|
+
import { parseHtml, selectorPath } from "./html-lite.mjs"
|
|
4
|
+
import { buildProbeSource, PATH_FUNCTION_SOURCE, PROBE_VERSION, ProbeError } from "./layout-probe.mjs"
|
|
5
|
+
|
|
6
|
+
// A minimal element adapter over the html-lite tree: exactly the DOM surface the path rule reads.
|
|
7
|
+
function adapt(node, parentAdapter, cache) {
|
|
8
|
+
if (cache.has(node)) return cache.get(node)
|
|
9
|
+
const el = { tagName: node.tag.toUpperCase(), id: node.attrs.id ?? "", parentElement: parentAdapter, children: [] }
|
|
10
|
+
cache.set(node, el)
|
|
11
|
+
el.children = node.children.filter((c) => c.tag !== "#text").map((c) => adapt(c, el, cache))
|
|
12
|
+
return el
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function pathRule() {
|
|
16
|
+
return new Function(`${PATH_FUNCTION_SOURCE}; return ulwSelectorPath;`)()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const FIXTURE = `<!doctype html><html><head><style>p{}</style></head><body>
|
|
20
|
+
<main id="top"><section><h2>A</h2><p>one</p><p>two</p><figure class="fig"><img src="a.png"><figcaption>c</figcaption></figure></section>
|
|
21
|
+
<section id="results"><h2>B</h2><p>x</p><figure><svg width="300"><text>t</text></svg></figure><figure><img src="b.png"></figure></section>
|
|
22
|
+
<section><div id="dup"></div><div id="dup"><p>y</p></div></section></main></body></html>`
|
|
23
|
+
|
|
24
|
+
describe("PATH_FUNCTION_SOURCE", () => {
|
|
25
|
+
test("#given the html-lite tree of a fixture #when the in-page path rule runs on an adapter of every element #then it equals html-lite selectorPath", () => {
|
|
26
|
+
// given
|
|
27
|
+
const doc = parseHtml(FIXTURE)
|
|
28
|
+
const ids = new Map()
|
|
29
|
+
const cache = new Map()
|
|
30
|
+
const elements = []
|
|
31
|
+
const walk = (node, parentAdapter) => {
|
|
32
|
+
if (node.tag === "#text") return
|
|
33
|
+
const el = adapt(node, parentAdapter, cache)
|
|
34
|
+
if (node.tag !== "#root" && node.parent) elements.push([node, el])
|
|
35
|
+
if (el.id) ids.set(el.id, (ids.get(el.id) ?? 0) + 1)
|
|
36
|
+
for (const child of node.children) walk(child, el)
|
|
37
|
+
}
|
|
38
|
+
walk(doc, null)
|
|
39
|
+
const rule = pathRule()
|
|
40
|
+
// when
|
|
41
|
+
const mismatches = elements
|
|
42
|
+
.filter(([node]) => node.tag !== "html" && node.tag !== "head" && node.tag !== "#root")
|
|
43
|
+
.map(([node, el]) => [selectorPath(node), rule(el, (id) => ids.get(id) ?? 0)])
|
|
44
|
+
.filter(([expected, actual]) => expected !== actual)
|
|
45
|
+
// then
|
|
46
|
+
expect(elements.length).toBeGreaterThan(10)
|
|
47
|
+
expect(mismatches).toEqual([])
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test("#given a duplicated id #when a descendant path is built #then the duplicate is not used as an anchor", () => {
|
|
51
|
+
const doc = parseHtml(FIXTURE)
|
|
52
|
+
const p = []
|
|
53
|
+
const find = (n) => { if (n.tag === "p" && n.parent?.attrs.id === "dup") p.push(n); n.children.forEach(find) }
|
|
54
|
+
find(doc)
|
|
55
|
+
expect(selectorPath(p[0])).toBe("#top > section:nth-of-type(3) > div:nth-of-type(2) > p")
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe("buildProbeSource", () => {
|
|
60
|
+
test("#given defaults #when built #then it is one async IIFE expression that parses and carries the version, cap, and image wait", () => {
|
|
61
|
+
const src = buildProbeSource()
|
|
62
|
+
expect(src).toMatch(/^\(async \(\) => \{[\s\S]*\}\)\(\)\s*$/)
|
|
63
|
+
expect(src.includes('img.loading = "eager"')).toBe(true)
|
|
64
|
+
expect(() => new Function(`return ${src}`)).not.toThrow()
|
|
65
|
+
expect(src.includes("const CAP = 400;")).toBe(true)
|
|
66
|
+
expect(src.includes(`probe_version: ${PROBE_VERSION}`)).toBe(true)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
test("#given cap 50 and a custom root #when built #then both are embedded as literals", () => {
|
|
70
|
+
const src = buildProbeSource({ cap: 50, root: "article.report" })
|
|
71
|
+
expect(src.includes("const CAP = 50;")).toBe(true)
|
|
72
|
+
expect(src.includes('document.querySelector("article.report")')).toBe(true)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test("#given cap 0 or a fractional cap #when built #then a typed ProbeError is thrown", () => {
|
|
76
|
+
expect(() => buildProbeSource({ cap: 0 })).toThrow(ProbeError)
|
|
77
|
+
expect(() => buildProbeSource({ cap: 1.5 })).toThrow(ProbeError)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test("#given an empty root #when built #then a typed ProbeError is thrown", () => {
|
|
81
|
+
expect(() => buildProbeSource({ root: "" })).toThrow(ProbeError)
|
|
82
|
+
expect(() => buildProbeSource({ root: " " })).toThrow(ProbeError)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test("#given a root containing a double quote #when built #then the source still parses", () => {
|
|
86
|
+
const src = buildProbeSource({ root: 'main[data-x="1"]' })
|
|
87
|
+
expect(() => new Function(`return ${src}`)).not.toThrow()
|
|
88
|
+
})
|
|
89
|
+
})
|