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,229 @@
|
|
|
1
|
+
// format-extract.mjs - read a pointed-at HTML or Markdown document and return the facts a
|
|
2
|
+
// design-spec.md draft needs (render with design-spec.renderDesignSpec). Nothing is guessed:
|
|
3
|
+
// a value the document does not state is left out, and the renderer writes `TODO: ask`.
|
|
4
|
+
//
|
|
5
|
+
// Static input only. fetchHtml downloads exactly the given URL with plain fetch; external
|
|
6
|
+
// stylesheets are never followed (each is recorded as a note). A page that renders its
|
|
7
|
+
// content client-side is the orchestrator's job: render it with the browser skill, save the
|
|
8
|
+
// HTML, and pass the saved file. PDF input is rejected with UnsupportedFormatError.
|
|
9
|
+
// Plain Node ESM, node: builtins and siblings only.
|
|
10
|
+
|
|
11
|
+
import { readFileSync } from "node:fs"
|
|
12
|
+
import { extname } from "node:path"
|
|
13
|
+
|
|
14
|
+
import { cssFacts } from "./format-extract-css.mjs"
|
|
15
|
+
import { parseHtml, proseText } from "./html-lite.mjs"
|
|
16
|
+
|
|
17
|
+
export const UNSUPPORTED_MESSAGE = "unsupported: point at the HTML or Markdown source"
|
|
18
|
+
export const FETCH_TIMEOUT_MS = 15_000
|
|
19
|
+
|
|
20
|
+
const S_MARKER = /\[S\d+\]/
|
|
21
|
+
const SOURCE_ANCHOR = /\[(?:\uCD9C\uCC98|source(?:\s*\d+)?)\]/i
|
|
22
|
+
const LINEAGE_TOKEN = /\b(?:MEASURED|ASSUMED|DERIVED)\b/
|
|
23
|
+
const MD_FOOTNOTE = /\[\^[^\]\s]+\]/
|
|
24
|
+
const HEADING_NUMBER = /^(\d{1,3}(?:\.\d{1,3})*)\.?(?=\s|$)/
|
|
25
|
+
const FENCE = /^\s{0,3}(`{3,}|~{3,})/
|
|
26
|
+
const MD_HEADING = /^(#{1,6})\s+(.*?)\s*#*\s*$/
|
|
27
|
+
/**
|
|
28
|
+
* @typedef {{ hex: string, count: number, roles: string[] }} ExtractColor
|
|
29
|
+
* @typedef {{
|
|
30
|
+
* origin: string | undefined, bytes: number,
|
|
31
|
+
* tokens: { light: Map<string, string>, dark: Map<string, string> },
|
|
32
|
+
* fonts: { body?: string, heading?: string, mono?: string }, colors: ExtractColor[], breakpoints: number[],
|
|
33
|
+
* headingNumbering: "double-digit" | "decimal" | "none" | "unknown",
|
|
34
|
+
* citationStyle: "bracket-S" | "bracket-source" | "footnote" | "unknown",
|
|
35
|
+
* lineageMode: "inline" | "section" | "unknown", figureContainers: string[],
|
|
36
|
+
* skeleton: { level: number, text: string }[],
|
|
37
|
+
* measure: { fontSize?: string, lineHeight?: string, maxWidth?: string }, notes: string[],
|
|
38
|
+
* }} Extract
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
const HTML_EXTENSIONS = new Set([".html", ".htm", ".xhtml"])
|
|
42
|
+
const MD_EXTENSIONS = new Set([".md", ".markdown"])
|
|
43
|
+
|
|
44
|
+
export class UnsupportedFormatError extends Error {
|
|
45
|
+
constructor(detail) {
|
|
46
|
+
super(UNSUPPORTED_MESSAGE)
|
|
47
|
+
this.name = "UnsupportedFormatError"
|
|
48
|
+
this.code = "unsupported"
|
|
49
|
+
this.detail = detail
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function rejectPdf(source, origin) {
|
|
54
|
+
if (/\.pdf$/i.test(String(origin ?? ""))) throw new UnsupportedFormatError(`pdf origin: ${origin}`)
|
|
55
|
+
if (String(source).trimStart().startsWith("%PDF-")) throw new UnsupportedFormatError("pdf bytes")
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** @returns {Extract} */
|
|
59
|
+
function baseExtract(source, origin) {
|
|
60
|
+
return {
|
|
61
|
+
origin,
|
|
62
|
+
bytes: Buffer.byteLength(String(source), "utf8"),
|
|
63
|
+
tokens: { light: new Map(), dark: new Map() },
|
|
64
|
+
fonts: {},
|
|
65
|
+
colors: [],
|
|
66
|
+
breakpoints: [],
|
|
67
|
+
headingNumbering: "unknown",
|
|
68
|
+
citationStyle: "unknown",
|
|
69
|
+
lineageMode: "unknown",
|
|
70
|
+
figureContainers: [],
|
|
71
|
+
skeleton: [],
|
|
72
|
+
measure: {},
|
|
73
|
+
notes: [],
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** h2 numbers ("01", "1.2", or null) -> double-digit when every numbered h2 uses two digits. */
|
|
78
|
+
function classifyNumbering(numbers) {
|
|
79
|
+
if (!numbers.length) return "unknown"
|
|
80
|
+
const numbered = numbers.filter(Boolean)
|
|
81
|
+
if (numbered.length * 2 < numbers.length) return "none"
|
|
82
|
+
return numbered.every((number) => /^\d{2}$/.test(number)) ? "double-digit" : "decimal"
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function headingNumber(text) {
|
|
86
|
+
return HEADING_NUMBER.exec(text)?.[1] ?? null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function citationFacts(text, hasFootnotes, hasLineageAttribute) {
|
|
90
|
+
const style = S_MARKER.test(text) ? "bracket-S" : SOURCE_ANCHOR.test(text) ? "bracket-source" : hasFootnotes ? "footnote" : "unknown"
|
|
91
|
+
let lineage = "unknown"
|
|
92
|
+
if (hasLineageAttribute || LINEAGE_TOKEN.test(text) || S_MARKER.test(text)) lineage = "inline"
|
|
93
|
+
else if (SOURCE_ANCHOR.test(text)) lineage = "section"
|
|
94
|
+
return { style, lineage }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function walk(node, visit) {
|
|
98
|
+
for (const child of node.children) {
|
|
99
|
+
visit(child)
|
|
100
|
+
walk(child, visit)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function classesOf(node) {
|
|
105
|
+
return String(node.attrs.class ?? "").split(/\s+/).filter(Boolean)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Previous element sibling whose whole text is a heading number (a `.sec-no` style eyebrow). */
|
|
109
|
+
function eyebrowNumber(heading) {
|
|
110
|
+
const siblings = heading.parent.children.filter((child) => child.tag !== "#text" || child.text.trim())
|
|
111
|
+
const previous = siblings[siblings.indexOf(heading) - 1]
|
|
112
|
+
if (!previous || previous.tag === "#text" || /^h[1-6]$/.test(previous.tag)) return null
|
|
113
|
+
const text = proseText(previous)
|
|
114
|
+
return HEADING_NUMBER.test(text) && headingNumber(text) === text.replace(/\.$/, "") ? headingNumber(text) : null
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Nearest `<figure>` or fig/chart-classed ancestor of an img or svg. */
|
|
118
|
+
function figureContainer(node) {
|
|
119
|
+
for (let cur = node.parent; cur && cur.tag !== "body"; cur = cur.parent) {
|
|
120
|
+
const classes = classesOf(cur)
|
|
121
|
+
if (cur.tag === "figure" || classes.some((name) => /fig|chart/i.test(name))) return cur
|
|
122
|
+
}
|
|
123
|
+
return null
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function rankByCount(names) {
|
|
127
|
+
const counts = new Map()
|
|
128
|
+
for (const name of names) counts.set(name, (counts.get(name) ?? 0) + 1)
|
|
129
|
+
return [...counts].sort((a, b) => b[1] - a[1]).map(([name]) => name)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** @returns {Extract} throws UnsupportedFormatError on PDF input. */
|
|
133
|
+
export function extractFromHtml(source, { origin } = {}) {
|
|
134
|
+
rejectPdf(source, origin)
|
|
135
|
+
const extract = baseExtract(source, origin)
|
|
136
|
+
const root = parseHtml(source)
|
|
137
|
+
const styles = []
|
|
138
|
+
const headings = []
|
|
139
|
+
const containers = []
|
|
140
|
+
let hasFootnotes = false
|
|
141
|
+
let hasLineageAttribute = false
|
|
142
|
+
let scripts = 0
|
|
143
|
+
walk(root, (node) => {
|
|
144
|
+
if (node.tag === "style") styles.push(node.raw ?? "")
|
|
145
|
+
else if (node.tag === "script") scripts++
|
|
146
|
+
else if (node.tag === "link" && /(?:^|\s)stylesheet(?:\s|$)/i.test(node.attrs.rel ?? "")) {
|
|
147
|
+
extract.notes.push(`skipped external stylesheet: ${node.attrs.href ?? "(no href)"}`)
|
|
148
|
+
} else if (/^h[1-6]$/.test(node.tag)) headings.push(node)
|
|
149
|
+
else if (node.tag === "img" || node.tag === "svg") {
|
|
150
|
+
const container = figureContainer(node)
|
|
151
|
+
if (container) containers.push(...classesOf(container))
|
|
152
|
+
} else if (node.tag === "sup" && node.children.some((child) => child.tag === "a" && /^#/.test(child.attrs.href ?? ""))) hasFootnotes = true
|
|
153
|
+
else if (node.attrs?.role === "doc-noteref") hasFootnotes = true
|
|
154
|
+
if (node.attrs && Object.hasOwn(node.attrs, "data-lineage")) hasLineageAttribute = true
|
|
155
|
+
})
|
|
156
|
+
const css = styles.join("\n")
|
|
157
|
+
for (const match of css.matchAll(/@import\s+(?:url\()?\s*["']?([^"')\s;]+)/gi)) extract.notes.push(`skipped @import: ${match[1]}`)
|
|
158
|
+
if (styles.length > 1) extract.notes.push(`${styles.length} <style> blocks concatenated`)
|
|
159
|
+
if (css.trim()) Object.assign(extract, cssFacts(css))
|
|
160
|
+
if (!extract.fonts.heading && extract.fonts.body) extract.notes.push("no heading font-family rule; headings inherit the body stack")
|
|
161
|
+
|
|
162
|
+
const body = root.children.find((child) => child.tag === "html")?.children.find((child) => child.tag === "body")
|
|
163
|
+
?? root.children.find((child) => child.tag === "body")
|
|
164
|
+
?? root
|
|
165
|
+
const text = proseText(body)
|
|
166
|
+
if (scripts && text.length < 200) {
|
|
167
|
+
extract.notes.push(`little static text with ${scripts} <script> tags: if the page renders client-side, render it with the browser skill and pass the saved HTML`)
|
|
168
|
+
}
|
|
169
|
+
const citation = citationFacts(text, hasFootnotes, hasLineageAttribute)
|
|
170
|
+
extract.citationStyle = citation.style
|
|
171
|
+
extract.lineageMode = citation.lineage
|
|
172
|
+
const h2 = headings.filter((node) => node.tag === "h2")
|
|
173
|
+
extract.headingNumbering = classifyNumbering(h2.map((node) => headingNumber(proseText(node)) ?? eyebrowNumber(node)))
|
|
174
|
+
extract.skeleton = headings
|
|
175
|
+
.filter((node) => node.tag === "h1" || node.tag === "h2")
|
|
176
|
+
.map((node) => ({ level: Number(node.tag[1]), text: proseText(node) }))
|
|
177
|
+
extract.figureContainers = rankByCount(containers)
|
|
178
|
+
return extract
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Markdown carries structure only: skeleton, heading numbering, citation style. @returns {Extract} */
|
|
182
|
+
export function extractFromMarkdown(source, { origin } = {}) {
|
|
183
|
+
rejectPdf(source, origin)
|
|
184
|
+
const extract = baseExtract(source, origin)
|
|
185
|
+
const prose = []
|
|
186
|
+
let fence = null
|
|
187
|
+
for (const line of String(source).split(/\r?\n/)) {
|
|
188
|
+
const fenceMatch = FENCE.exec(line)
|
|
189
|
+
if (fence) {
|
|
190
|
+
if (fenceMatch && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length && line.trim() === fenceMatch[1]) fence = null
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
if (fenceMatch) {
|
|
194
|
+
fence = fenceMatch[1]
|
|
195
|
+
continue
|
|
196
|
+
}
|
|
197
|
+
prose.push(line.replace(/`[^`]*`/g, ""))
|
|
198
|
+
const heading = MD_HEADING.exec(line)
|
|
199
|
+
if (heading && heading[1].length <= 2) extract.skeleton.push({ level: heading[1].length, text: heading[2] })
|
|
200
|
+
}
|
|
201
|
+
const text = prose.join("\n")
|
|
202
|
+
extract.citationStyle = citationFacts(text, MD_FOOTNOTE.test(text), false).style
|
|
203
|
+
extract.headingNumbering = classifyNumbering(extract.skeleton.filter((entry) => entry.level === 2).map((entry) => headingNumber(entry.text)))
|
|
204
|
+
extract.notes.push("markdown source: tokens, fonts, colors, breakpoints, figures, and measure are not carried by Markdown")
|
|
205
|
+
return extract
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Dispatch on the file extension: .html/.htm/.xhtml, .md/.markdown; anything else (PDF included) is unsupported. @returns {Extract} */
|
|
209
|
+
export function extractFromPath(filePath) {
|
|
210
|
+
const extension = extname(filePath).toLowerCase()
|
|
211
|
+
if (HTML_EXTENSIONS.has(extension)) return extractFromHtml(readFileSync(filePath, "utf8"), { origin: filePath })
|
|
212
|
+
if (MD_EXTENSIONS.has(extension)) return extractFromMarkdown(readFileSync(filePath, "utf8"), { origin: filePath })
|
|
213
|
+
throw new UnsupportedFormatError(`extension ${extension || "(none)"}: ${filePath}`)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** GET exactly `url` (plain fetch, 15 s timeout, no browser) and return the body text. */
|
|
217
|
+
export async function fetchHtml(url) {
|
|
218
|
+
if (/\.pdf$/i.test(new URL(url).pathname)) throw new UnsupportedFormatError(`pdf url: ${url}`)
|
|
219
|
+
const response = await fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) })
|
|
220
|
+
const type = response.headers.get("content-type") ?? ""
|
|
221
|
+
if (!response.ok || /pdf/i.test(type)) {
|
|
222
|
+
await response.body?.cancel()
|
|
223
|
+
if (!response.ok) throw new Error(`fetch failed: HTTP ${response.status} for ${url}`)
|
|
224
|
+
throw new UnsupportedFormatError(`pdf response: ${url}`)
|
|
225
|
+
}
|
|
226
|
+
const text = await response.text()
|
|
227
|
+
if (text.trimStart().startsWith("%PDF-")) throw new UnsupportedFormatError(`pdf bytes: ${url}`)
|
|
228
|
+
return text
|
|
229
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test"
|
|
2
|
+
import { readFileSync } from "node:fs"
|
|
3
|
+
import { createServer, type Server } from "node:http"
|
|
4
|
+
import type { AddressInfo } from "node:net"
|
|
5
|
+
import { once } from "node:events"
|
|
6
|
+
import { join } from "node:path"
|
|
7
|
+
|
|
8
|
+
import { DESIGN_SPEC_SECTIONS } from "./contracts.mjs"
|
|
9
|
+
import { renderDesignSpec } from "./design-spec.mjs"
|
|
10
|
+
import {
|
|
11
|
+
UnsupportedFormatError,
|
|
12
|
+
extractFromHtml,
|
|
13
|
+
extractFromMarkdown,
|
|
14
|
+
extractFromPath,
|
|
15
|
+
fetchHtml,
|
|
16
|
+
} from "./format-extract.mjs"
|
|
17
|
+
|
|
18
|
+
const FIXTURES = join(import.meta.dir, "tests", "fixtures")
|
|
19
|
+
const HTML_PATH = join(FIXTURES, "reference-report.html")
|
|
20
|
+
const MD_PATH = join(FIXTURES, "reference.md")
|
|
21
|
+
const html = readFileSync(HTML_PATH, "utf8")
|
|
22
|
+
const markdown = readFileSync(MD_PATH, "utf8")
|
|
23
|
+
const UNSUPPORTED = "unsupported: point at the HTML or Markdown source"
|
|
24
|
+
|
|
25
|
+
function sectionBody(spec: string, heading: string): string {
|
|
26
|
+
const start = spec.indexOf(`## ${heading}\n`)
|
|
27
|
+
const rest = spec.slice(start + heading.length + 4)
|
|
28
|
+
const next = rest.search(/^## /m)
|
|
29
|
+
return next === -1 ? rest : rest.slice(0, next)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function bulletLines(body: string): string[] {
|
|
33
|
+
return body.split("\n").filter((line) => line.startsWith("- "))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("extractFromHtml on the reference report", () => {
|
|
37
|
+
const extract = extractFromHtml(html, { origin: HTML_PATH })
|
|
38
|
+
|
|
39
|
+
test("#given light and dark token blocks #when extracted #then both maps carry at least 8 tokens", () => {
|
|
40
|
+
expect(extract.tokens.light.size).toBeGreaterThanOrEqual(8)
|
|
41
|
+
expect(extract.tokens.dark.size).toBeGreaterThanOrEqual(8)
|
|
42
|
+
expect(extract.tokens.light.get("--bg")).toBe("#faf6ef")
|
|
43
|
+
expect(extract.tokens.dark.get("--accent")).toBe("#f8b878")
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test("#given body, heading, and code font rules #when extracted #then the three stacks resolve through var()", () => {
|
|
47
|
+
expect(extract.fonts).toEqual({
|
|
48
|
+
body: '"Inter Display", "Noto Sans KR", sans-serif',
|
|
49
|
+
heading: "Fraunces, Georgia, serif",
|
|
50
|
+
mono: '"Iosevka Term", ui-monospace, monospace',
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test("#given two min-width media queries #when extracted #then breakpoints are [560, 640]", () => {
|
|
55
|
+
expect(extract.breakpoints).toEqual([560, 640])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test("#given 01-style h2 numbering and [S1] markers #when extracted #then numbering, citation, and lineage are detected", () => {
|
|
59
|
+
expect(extract.headingNumbering).toBe("double-digit")
|
|
60
|
+
expect(extract.citationStyle).toBe("bracket-S")
|
|
61
|
+
expect(extract.lineageMode).toBe("inline")
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test("#given img and svg inside .fig containers #when extracted #then figureContainers contains fig", () => {
|
|
65
|
+
expect(extract.figureContainers).toContain("fig")
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test("#given the fixture headings #when extracted #then the skeleton has one entry per h1 and h2", () => {
|
|
69
|
+
const count = (html.match(/<h[12][\s>]/g) ?? []).length
|
|
70
|
+
expect(extract.skeleton).toHaveLength(count)
|
|
71
|
+
expect(extract.skeleton[0]).toEqual({ level: 1, text: "How a small team ships every week" })
|
|
72
|
+
expect(extract.skeleton[1]).toEqual({ level: 2, text: "01 The release cadence" })
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test("#given body size, line height, and a measure token #when extracted #then measure is resolved", () => {
|
|
76
|
+
expect(extract.measure).toEqual({ fontSize: "17px", lineHeight: "1.7", maxWidth: "720px" })
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
test("#given repeated hex colors #when extracted #then at most 8 colors are ranked by count with selector roles", () => {
|
|
80
|
+
expect(extract.colors.length).toBeLessThanOrEqual(8)
|
|
81
|
+
const counts = extract.colors.map((color: { count: number }) => color.count)
|
|
82
|
+
expect(counts).toEqual([...counts].sort((a, b) => b - a))
|
|
83
|
+
const accent = extract.colors.find((color: { hex: string }) => color.hex === "#c2410c")
|
|
84
|
+
expect(accent?.roles).toEqual(expect.arrayContaining(["accent", "link"]))
|
|
85
|
+
const background = extract.colors.find((color: { hex: string }) => color.hex === "#faf6ef")
|
|
86
|
+
expect(background?.roles).toContain("background")
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test("#given the source #when extracted #then origin and utf-8 byte count are recorded", () => {
|
|
90
|
+
expect(extract.origin).toBe(HTML_PATH)
|
|
91
|
+
expect(extract.bytes).toBe(Buffer.byteLength(html, "utf8"))
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test("#given the extract #when rendered #then the 8 design-spec sections appear in contract order", () => {
|
|
95
|
+
const spec = renderDesignSpec(extract)
|
|
96
|
+
const positions = DESIGN_SPEC_SECTIONS.map((heading: string) => spec.indexOf(`\n## ${heading}\n`))
|
|
97
|
+
expect(positions.every((at: number) => at > 0)).toBe(true)
|
|
98
|
+
expect(positions).toEqual([...positions].sort((a, b) => a - b))
|
|
99
|
+
expect(sectionBody(spec, "Tokens")).toContain("--bg: #faf6ef;")
|
|
100
|
+
expect(sectionBody(spec, "Tokens")).not.toContain("TODO: ask")
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe("extractFromMarkdown on the reference markdown", () => {
|
|
105
|
+
const extract = extractFromMarkdown(markdown, { origin: MD_PATH })
|
|
106
|
+
|
|
107
|
+
test("#given h1, h2, h3, and a fenced pseudo-heading #when extracted #then the skeleton keeps h1 and h2 outside fences", () => {
|
|
108
|
+
expect(extract.skeleton).toEqual([
|
|
109
|
+
{ level: 1, text: "작은 팀은 어떻게 매주 배포하는가" },
|
|
110
|
+
{ level: 2, text: "01 배포 주기" },
|
|
111
|
+
{ level: 2, text: "02 리뷰 없이 일하는 방식" },
|
|
112
|
+
{ level: 2, text: "03 출처" },
|
|
113
|
+
])
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
test("#given [S1] markers and 01-style headings #when extracted #then citation style and numbering are detected", () => {
|
|
117
|
+
expect(extract.citationStyle).toBe("bracket-S")
|
|
118
|
+
expect(extract.headingNumbering).toBe("double-digit")
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test("#given markdown input #when rendered #then every CSS-derived line reads TODO: ask", () => {
|
|
122
|
+
const spec = renderDesignSpec(extract)
|
|
123
|
+
for (const heading of ["Tokens", "Typography", "Layout"]) {
|
|
124
|
+
const lines = bulletLines(sectionBody(spec, heading))
|
|
125
|
+
expect(lines.length).toBeGreaterThan(0)
|
|
126
|
+
for (const line of lines) expect(line).toEndWith("TODO: ask")
|
|
127
|
+
}
|
|
128
|
+
expect(sectionBody(spec, "Figures")).toContain("- Containers: TODO: ask")
|
|
129
|
+
expect(extract.tokens.light.size + extract.tokens.dark.size).toBe(0)
|
|
130
|
+
})
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
describe("unsupported inputs", () => {
|
|
134
|
+
test("#given a .pdf path #when extracted from the path #then the typed unsupported error is thrown", () => {
|
|
135
|
+
const attempt = () => extractFromPath(join(FIXTURES, "reference.pdf"))
|
|
136
|
+
expect(attempt).toThrow(UnsupportedFormatError)
|
|
137
|
+
expect(attempt).toThrow(UNSUPPORTED)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
test("#given PDF bytes passed as HTML #when extracted #then the typed unsupported error is thrown", () => {
|
|
141
|
+
expect(() => extractFromHtml("%PDF-1.7\n1 0 obj", { origin: "report" })).toThrow(UnsupportedFormatError)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
test("#given an html path #when extracted from the path #then it dispatches to the HTML extractor", () => {
|
|
145
|
+
const extract = extractFromPath(HTML_PATH)
|
|
146
|
+
expect(extract.origin).toBe(HTML_PATH)
|
|
147
|
+
expect(extract.breakpoints).toEqual([560, 640])
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe("extractFromHtml edge cases", () => {
|
|
152
|
+
test("#given only external stylesheets #when rendered #then Tokens reads TODO: ask and a note names the skipped href", () => {
|
|
153
|
+
const source = '<html><head><link rel="stylesheet" href="css/site.css"></head><body><h1>Only links</h1></body></html>'
|
|
154
|
+
const extract = extractFromHtml(source, { origin: "external.html" })
|
|
155
|
+
const spec = renderDesignSpec(extract)
|
|
156
|
+
const tokens = bulletLines(sectionBody(spec, "Tokens"))
|
|
157
|
+
expect(tokens.length).toBeGreaterThan(0)
|
|
158
|
+
for (const line of tokens) expect(line).toEndWith("TODO: ask")
|
|
159
|
+
expect(extract.notes.some((note: string) => note.includes("css/site.css"))).toBe(true)
|
|
160
|
+
expect(sectionBody(spec, "Extracted from")).toContain("css/site.css")
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test("#given a numeric eyebrow sibling before each h2 #when extracted #then numbering is double-digit", () => {
|
|
164
|
+
const source = '<body><h1>T</h1><div><span class="sec-no">01</span><h2>First</h2></div><div><span class="sec-no">02</span><h2>Second</h2></div></body>'
|
|
165
|
+
expect(extractFromHtml(source, { origin: "x.html" }).headingNumbering).toBe("double-digit")
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test("#given decimal, bare, and absent h2 headings #when extracted #then numbering is decimal, none, and unknown", () => {
|
|
169
|
+
const numbering = (body: string) => extractFromHtml(`<body>${body}</body>`, { origin: "x.html" }).headingNumbering
|
|
170
|
+
expect(numbering("<h2>1. Intro</h2><h2>2. Method</h2>")).toBe("decimal")
|
|
171
|
+
expect(numbering("<h2>Intro</h2><h2>Method</h2>")).toBe("none")
|
|
172
|
+
expect(numbering("<h1>Only a title</h1>")).toBe("unknown")
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test("#given [출처] anchors and no lineage tokens #when extracted #then citation is bracket-source and lineage is section", () => {
|
|
176
|
+
const source = '<body><h2>A</h2><p>Fact one. <a class="src" href="https://example.org/a">[출처]</a></p><p>Fact two <a href="https://example.org/b">[Source 2]</a></p></body>'
|
|
177
|
+
const extract = extractFromHtml(source, { origin: "x.html" })
|
|
178
|
+
expect(extract.citationStyle).toBe("bracket-source")
|
|
179
|
+
expect(extract.lineageMode).toBe("section")
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test("#given a MEASURED token with source anchors #when extracted #then lineage is inline", () => {
|
|
183
|
+
const source = '<body><p>Latency 42 ms MEASURED <a href="https://example.org/a">[출처]</a></p></body>'
|
|
184
|
+
expect(extractFromHtml(source, { origin: "x.html" }).lineageMode).toBe("inline")
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
test("#given sup footnote references #when extracted #then citation style is footnote", () => {
|
|
188
|
+
const source = '<body><p>Claim<sup><a href="#fn1">1</a></sup></p><ol><li id="fn1">Note</li></ol></body>'
|
|
189
|
+
expect(extractFromHtml(source, { origin: "x.html" }).citationStyle).toBe("footnote")
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
test("#given no citations, fonts, or headings #when rendered #then unknown fields read TODO: ask", () => {
|
|
193
|
+
const extract = extractFromHtml("<body><p>Plain text only.</p></body>", { origin: "x.html" })
|
|
194
|
+
expect(extract.citationStyle).toBe("unknown")
|
|
195
|
+
expect(extract.lineageMode).toBe("unknown")
|
|
196
|
+
expect(extract.fonts).toEqual({})
|
|
197
|
+
const spec = renderDesignSpec(extract)
|
|
198
|
+
expect(sectionBody(spec, "Citations")).toContain("- Style: TODO: ask")
|
|
199
|
+
expect(sectionBody(spec, "Citations")).toContain("- Lineage: TODO: ask")
|
|
200
|
+
expect(sectionBody(spec, "Typography")).toContain("- Heading font: TODO: ask")
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
describe("fetchHtml", () => {
|
|
205
|
+
let server: Server
|
|
206
|
+
let base = ""
|
|
207
|
+
|
|
208
|
+
beforeAll(async () => {
|
|
209
|
+
server = createServer((request, response) => {
|
|
210
|
+
if (request.url === "/report.html") {
|
|
211
|
+
response.writeHead(200, { "content-type": "text/html; charset=utf-8" })
|
|
212
|
+
response.end(html)
|
|
213
|
+
} else if (request.url === "/paper") {
|
|
214
|
+
response.writeHead(200, { "content-type": "application/pdf" })
|
|
215
|
+
response.end("%PDF-1.7")
|
|
216
|
+
} else {
|
|
217
|
+
response.writeHead(404, { "content-type": "text/plain" })
|
|
218
|
+
response.end("missing")
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
server.listen(0, "127.0.0.1")
|
|
222
|
+
await once(server, "listening")
|
|
223
|
+
base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
afterAll(async () => {
|
|
227
|
+
server.close()
|
|
228
|
+
server.closeAllConnections()
|
|
229
|
+
await once(server, "close")
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test("#given an html url #when fetched #then the body text is returned", async () => {
|
|
233
|
+
expect(await fetchHtml(`${base}/report.html`)).toBe(html)
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
test("#given a pdf response #when fetched #then the typed unsupported error is thrown", async () => {
|
|
237
|
+
await expect(fetchHtml(`${base}/paper`)).rejects.toThrow(UnsupportedFormatError)
|
|
238
|
+
})
|
|
239
|
+
|
|
240
|
+
test("#given a 404 response #when fetched #then the error names the status", async () => {
|
|
241
|
+
await expect(fetchHtml(`${base}/missing`)).rejects.toThrow("404")
|
|
242
|
+
})
|
|
243
|
+
})
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// gates-figures.mjs - visual gates: G2 palette, G8 chart without figure, G9 chart svg text,
|
|
2
|
+
// G10 Korean serif/brush fonts.
|
|
3
|
+
//
|
|
4
|
+
// Each gate is { codes, skip?(ctx), run(ctx) -> Defect[] } over the context built by
|
|
5
|
+
// gates-static.mjs. Plain Node ESM, sibling imports only.
|
|
6
|
+
|
|
7
|
+
import { fontFamilies, hexColors, normalizeHex, resolveVar } from "./css-lite.mjs"
|
|
8
|
+
import { decodeEntities, proseText } from "./html-lite.mjs"
|
|
9
|
+
|
|
10
|
+
const ALWAYS_ALLOWED = new Set(["#ffffff", "#000000"])
|
|
11
|
+
const MAX_PALETTE = 20
|
|
12
|
+
const SVG_PAINT = /(?:fill|stroke|stop-color|flood-color|lighting-color)\s*(?:=\s*["']?|:\s*)(#[0-9a-f]{3,8})(?![\w-])/gi
|
|
13
|
+
const SVG_FONT = /font-family\s*(?:=\s*("[^"]*"|'[^']*')|:\s*([^;"'>]+))/gi
|
|
14
|
+
const ICON_MAX = 32
|
|
15
|
+
const CHART_MIN = 200
|
|
16
|
+
const ICON_NAME = /icon|logo/i
|
|
17
|
+
const ICON_HOSTS = new Set(["a", "button", "nav", "header", "footer", "h1", "h2", "h3", "h4", "h5", "h6"])
|
|
18
|
+
const CAPTION_CLASS = /^(?:caption|fig-?cap(?:tion)?)$/
|
|
19
|
+
const DEFAULT_FONT_SIZE = 16
|
|
20
|
+
const MIN_TITLE_CHARS = 4
|
|
21
|
+
const MIN_TICKS = 3
|
|
22
|
+
const NUMERIC = /^[-+\u2212]?[$\u20AC\u00A3\u00A5\u20A9]?\d[\d,.:]*\s?(?:%|[a-z\uAC00-\uD7A3]{0,3})$/i
|
|
23
|
+
const SERIF_FAMILY = /nanummyeongjo|applemyungjo|gungsuh|batang|nanumbrush|nanumpen|\uAD81\uC11C|\uBC14\uD0D5|\uBA85\uC870/
|
|
24
|
+
|
|
25
|
+
function pixels(value) {
|
|
26
|
+
const match = /^\s*(\d+(?:\.\d+)?)\s*(?:px)?\s*$/i.exec(value ?? "")
|
|
27
|
+
return match ? Number(match[1]) : null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Largest declared dimension: width/height attributes or style, else the viewBox. */
|
|
31
|
+
function declaredSize(node) {
|
|
32
|
+
const style = node.attrs.style ?? ""
|
|
33
|
+
const fromStyle = (prop) => pixels(new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*([^;]+)`, "i").exec(style)?.[1])
|
|
34
|
+
const sizes = [pixels(node.attrs.width), pixels(node.attrs.height), fromStyle("width"), fromStyle("height")].filter((n) => n !== null)
|
|
35
|
+
if (sizes.length) return Math.max(...sizes)
|
|
36
|
+
const box = String(node.attrs.viewbox ?? "").trim().split(/[\s,]+/).map(Number)
|
|
37
|
+
return box.length === 4 && box.every(Number.isFinite) ? Math.max(box[2], box[3]) : null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isIcon(ctx, node) {
|
|
41
|
+
const size = declaredSize(node)
|
|
42
|
+
if (size !== null && size <= ICON_MAX) return true
|
|
43
|
+
if (ICON_NAME.test(`${node.attrs.class ?? ""} ${node.attrs.id ?? ""}`)) return true
|
|
44
|
+
return Boolean(ctx.util.closest(node, (cur) => ICON_HOSTS.has(cur.tag)))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const isFigure = (ctx) => (cur) => cur.tag === "figure" || ctx.util.classTokens(cur).includes("fig")
|
|
48
|
+
|
|
49
|
+
function hasCaption(ctx, figure) {
|
|
50
|
+
return ctx.util.descendants(figure).some((node) => (node.tag === "figcaption" || ctx.util.classTokens(node).some((token) => CAPTION_CLASS.test(token))) && proseText(node).length > 0)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function visuals(ctx) {
|
|
54
|
+
return ctx.elements.map(({ node }) => node).filter((node) => (node.tag === "img" || node.tag === "svg") && !isIcon(ctx, node))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function label(node) {
|
|
58
|
+
if (node.tag === "img") {
|
|
59
|
+
const src = node.attrs.src ?? ""
|
|
60
|
+
return `<img src="${src.length > 48 ? `${src.slice(0, 48)}...` : src}">`
|
|
61
|
+
}
|
|
62
|
+
return `<svg${node.attrs.width ? ` width="${node.attrs.width}"` : ""}${node.attrs.height ? ` height="${node.attrs.height}"` : ""}>`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function svgTexts(raw) {
|
|
66
|
+
return [...raw.matchAll(/<text\b([^>]*)>([\s\S]*?)<\/text\s*>/gi)]
|
|
67
|
+
.map((match) => ({
|
|
68
|
+
size: Number(/font-size\s*(?:=\s*["']?|:\s*)(\d+(?:\.\d+)?)/i.exec(match[1])?.[1] ?? DEFAULT_FONT_SIZE),
|
|
69
|
+
value: decodeEntities(match[2].replace(/<[^>]*>/g, "")).replace(/\s+/g, " ").trim(),
|
|
70
|
+
}))
|
|
71
|
+
.filter((text) => text.value)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function missingChartLabels(raw) {
|
|
75
|
+
const texts = svgTexts(raw)
|
|
76
|
+
const title = decodeEntities(/<title\b[^>]*>([\s\S]*?)<\/title\s*>/i.exec(raw)?.[1] ?? "").trim()
|
|
77
|
+
if (!texts.length && !title) return null
|
|
78
|
+
const maxSize = Math.max(0, ...texts.map((text) => text.size))
|
|
79
|
+
const missing = []
|
|
80
|
+
if (!title && !texts.some((text) => text.size === maxSize && [...text.value].length >= MIN_TITLE_CHARS)) missing.push("title")
|
|
81
|
+
const ticks = texts.filter((text) => NUMERIC.test(text.value)).length
|
|
82
|
+
if (ticks < MIN_TICKS) missing.push(`numeric ticks (${ticks} < ${MIN_TICKS})`)
|
|
83
|
+
if (!texts.some((text) => /\p{L}/u.test(text.value) && !NUMERIC.test(text.value))) missing.push("text label")
|
|
84
|
+
return missing
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function colorUses(ctx) {
|
|
88
|
+
const uses = new Map()
|
|
89
|
+
const add = (hex, node) => {
|
|
90
|
+
const color = normalizeHex(hex)
|
|
91
|
+
if (!color) return
|
|
92
|
+
const entry = uses.get(color) ?? { node, count: 0 }
|
|
93
|
+
entry.count++
|
|
94
|
+
uses.set(color, entry)
|
|
95
|
+
}
|
|
96
|
+
for (const { node, css } of [...ctx.styleBlocks, ...ctx.styleAttrs]) for (const hex of hexColors(css)) add(hex, node)
|
|
97
|
+
for (const node of ctx.all) {
|
|
98
|
+
if (node.tag !== "svg") continue
|
|
99
|
+
for (const key of ["fill", "stroke", "stop-color"]) if (node.attrs[key]) add(node.attrs[key], node)
|
|
100
|
+
for (const match of (node.raw ?? "").matchAll(SVG_PAINT)) add(match[1], node)
|
|
101
|
+
}
|
|
102
|
+
return uses
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function fontDeclarations(ctx) {
|
|
106
|
+
const found = []
|
|
107
|
+
for (const { node, css } of ctx.styleBlocks) for (const { families } of fontFamilies(css)) found.push({ node, families })
|
|
108
|
+
for (const { node, css } of ctx.styleAttrs) for (const { families } of fontFamilies(`x{${resolveVar(css, ctx.tokens)}}`)) found.push({ node, families })
|
|
109
|
+
for (const node of ctx.all) {
|
|
110
|
+
if (node.tag !== "svg") continue
|
|
111
|
+
const values = [node.attrs["font-family"], ...[...(node.raw ?? "").matchAll(SVG_FONT)].map((m) => m[1] ?? m[2])].filter(Boolean)
|
|
112
|
+
for (const value of values) for (const { families } of fontFamilies(`x{font-family:${value}}`)) found.push({ node, families })
|
|
113
|
+
}
|
|
114
|
+
return found
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const figureGates = [
|
|
118
|
+
{
|
|
119
|
+
codes: ["palette_off_token"],
|
|
120
|
+
skip: (ctx) => ctx.palette.size === 0,
|
|
121
|
+
run(ctx) {
|
|
122
|
+
const palette = new Set([...ctx.palette].map(normalizeHex))
|
|
123
|
+
const defects = []
|
|
124
|
+
for (const [color, { node, count }] of colorUses(ctx)) {
|
|
125
|
+
if (defects.length >= MAX_PALETTE) break
|
|
126
|
+
if (palette.has(color) || ALWAYS_ALLOWED.has(color)) continue
|
|
127
|
+
defects.push(ctx.defect("palette_off_token", node, `color ${color} is not in the design-spec palette (${count} use${count === 1 ? "" : "s"})`))
|
|
128
|
+
}
|
|
129
|
+
return defects
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
codes: ["chart_without_figure"],
|
|
134
|
+
run(ctx) {
|
|
135
|
+
return visuals(ctx)
|
|
136
|
+
.filter((node) => {
|
|
137
|
+
const figure = ctx.util.closest(node, isFigure(ctx))
|
|
138
|
+
return !figure || !hasCaption(ctx, figure)
|
|
139
|
+
})
|
|
140
|
+
.map((node) => ctx.defect("chart_without_figure", node, `${label(node)} has no figure container with a non-empty caption`))
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
codes: ["chart_svg_no_text", "chart_svg_missing_labels"],
|
|
145
|
+
run(ctx) {
|
|
146
|
+
const defects = []
|
|
147
|
+
for (const node of visuals(ctx)) {
|
|
148
|
+
if (node.tag !== "svg") continue
|
|
149
|
+
if (!((declaredSize(node) ?? 0) >= CHART_MIN || ctx.util.closest(node, isFigure(ctx)))) continue
|
|
150
|
+
const missing = missingChartLabels(node.raw ?? "")
|
|
151
|
+
if (missing === null) defects.push(ctx.defect("chart_svg_no_text", node, `${label(node)} chart has no <text> and no <title>`))
|
|
152
|
+
else if (missing.length) defects.push(ctx.defect("chart_svg_missing_labels", node, `${label(node)} chart is missing: ${missing.join(", ")}`))
|
|
153
|
+
}
|
|
154
|
+
return defects
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
codes: ["korean_serif_font"],
|
|
159
|
+
run(ctx) {
|
|
160
|
+
const defects = []
|
|
161
|
+
const seen = new Set()
|
|
162
|
+
for (const { node, families } of fontDeclarations(ctx)) {
|
|
163
|
+
for (const family of families) {
|
|
164
|
+
const key = family.toLowerCase().replace(/[\s"'_-]/g, "")
|
|
165
|
+
if (!SERIF_FAMILY.test(key) || seen.has(key)) continue
|
|
166
|
+
seen.add(key)
|
|
167
|
+
defects.push(ctx.defect("korean_serif_font", node, `serif or brush Korean font family "${family}"`))
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return defects
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
]
|