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.
Files changed (220) hide show
  1. package/README.ja.md +1 -1
  2. package/README.ko.md +1 -1
  3. package/README.md +1 -1
  4. package/README.ru.md +1 -1
  5. package/README.zh-cn.md +1 -1
  6. package/dist/agents/sisyphus/grok-4.d.ts +3 -2
  7. package/dist/cli/config-manager/refresh-opencode-plugin-sandbox.d.ts +31 -0
  8. package/dist/cli/doctor/checks/tui-plugin-config.d.ts +11 -0
  9. package/dist/cli/index.js +1661 -859
  10. package/dist/cli/install-native/index.d.ts +9 -1
  11. package/dist/cli/install-native/legacy-omo-bin.d.ts +27 -0
  12. package/dist/cli/install-native/offer-native-setup.d.ts +31 -0
  13. package/dist/cli/install-native/omo-bin-test-fixtures.d.ts +35 -0
  14. package/dist/cli/install-native/plan.d.ts +8 -0
  15. package/dist/cli/install-native/repair-legacy-omo-bin.d.ts +26 -0
  16. package/dist/cli/install-native/run-native-install.d.ts +11 -1
  17. package/dist/cli/install-native/verify-omo-command.d.ts +22 -0
  18. package/dist/cli/native-edition-hint.d.ts +2 -2
  19. package/dist/cli-node/index.js +1661 -859
  20. package/dist/hooks/auto-update-checker/checker/sandbox-refresh.d.ts +51 -0
  21. package/dist/hooks/auto-update-checker/checker/semver-compare.d.ts +17 -0
  22. package/dist/hooks/auto-update-checker/checker.d.ts +3 -0
  23. package/dist/hooks/auto-update-checker/hook/background-update-check.d.ts +8 -0
  24. package/dist/hooks/auto-update-checker/hook.d.ts +2 -0
  25. package/dist/hooks/native-edition-nudge/hook.d.ts +1 -1
  26. package/dist/index.js +1433 -1055
  27. package/dist/shared/index.d.ts +1 -0
  28. package/dist/shared/opencode-plugin-sandbox.d.ts +54 -0
  29. package/dist/skills/browser/SKILL.md +8 -1
  30. package/dist/skills/browser/references/install.md +34 -5
  31. package/dist/skills/browser/runtime/omowright/index.js +2 -2
  32. package/dist/skills/browser/runtime/omowright/manifest.json +3 -3
  33. package/dist/skills/browser/scripts/browser-doctor.mjs +14 -3
  34. package/dist/skills/browser/scripts/browser-install.mjs +13 -1
  35. package/dist/skills/ulw-research/AGENTS.md +63 -0
  36. package/dist/skills/ulw-research/SKILL.md +38 -26
  37. package/dist/skills/ulw-research/references/deliverable-phase.md +187 -0
  38. package/dist/skills/ulw-research/references/report-gates.md +66 -0
  39. package/dist/skills/ulw-research/scripts/cli-support.mjs +102 -0
  40. package/dist/skills/ulw-research/scripts/cli-support.test.ts +88 -0
  41. package/dist/skills/ulw-research/scripts/contracts.mjs +162 -0
  42. package/dist/skills/ulw-research/scripts/contracts.test.ts +177 -0
  43. package/dist/skills/ulw-research/scripts/css-lite.mjs +204 -0
  44. package/dist/skills/ulw-research/scripts/css-lite.test.ts +159 -0
  45. package/dist/skills/ulw-research/scripts/design-spec.mjs +161 -0
  46. package/dist/skills/ulw-research/scripts/design-spec.test.ts +162 -0
  47. package/dist/skills/ulw-research/scripts/entities.mjs +39 -0
  48. package/dist/skills/ulw-research/scripts/entry-guard.mjs +21 -0
  49. package/dist/skills/ulw-research/scripts/format-extract-css.mjs +122 -0
  50. package/dist/skills/ulw-research/scripts/format-extract.mjs +229 -0
  51. package/dist/skills/ulw-research/scripts/format-extract.test.ts +243 -0
  52. package/dist/skills/ulw-research/scripts/gates-figures.mjs +173 -0
  53. package/dist/skills/ulw-research/scripts/gates-layout.mjs +146 -0
  54. package/dist/skills/ulw-research/scripts/gates-layout.test.ts +236 -0
  55. package/dist/skills/ulw-research/scripts/gates-static.mjs +167 -0
  56. package/dist/skills/ulw-research/scripts/gates-static.test.ts +265 -0
  57. package/dist/skills/ulw-research/scripts/gates-structure.mjs +118 -0
  58. package/dist/skills/ulw-research/scripts/gates-text.mjs +190 -0
  59. package/dist/skills/ulw-research/scripts/glossary-vocabulary.test.ts +43 -0
  60. package/dist/skills/ulw-research/scripts/html-lite.mjs +239 -0
  61. package/dist/skills/ulw-research/scripts/html-lite.test.ts +191 -0
  62. package/dist/skills/ulw-research/scripts/layout-probe.mjs +83 -0
  63. package/dist/skills/ulw-research/scripts/layout-probe.test.ts +89 -0
  64. package/dist/skills/ulw-research/scripts/outcome.mjs +250 -0
  65. package/dist/skills/ulw-research/scripts/outcome.test.ts +281 -0
  66. package/dist/skills/ulw-research/scripts/repair-tracker.mjs +171 -0
  67. package/dist/skills/ulw-research/scripts/repair-tracker.test.ts +246 -0
  68. package/dist/skills/ulw-research/scripts/report-tools-commands.mjs +214 -0
  69. package/dist/skills/ulw-research/scripts/report-tools.mjs +61 -0
  70. package/dist/skills/ulw-research/scripts/report-tools.test.ts +213 -0
  71. package/dist/skills/ulw-research/scripts/tests/fixtures/assets/figure-1.svg +1 -0
  72. package/dist/skills/ulw-research/scripts/tests/fixtures/bad-report.html +44 -0
  73. package/dist/skills/ulw-research/scripts/tests/fixtures/boxes.json +90 -0
  74. package/dist/skills/ulw-research/scripts/tests/fixtures/design-spec.md +29 -0
  75. package/dist/skills/ulw-research/scripts/tests/fixtures/good-report.html +57 -0
  76. package/dist/skills/ulw-research/scripts/tests/fixtures/reference-report.html +86 -0
  77. package/dist/skills/ulw-research/scripts/tests/fixtures/reference.md +23 -0
  78. package/dist/skills/visual-qa/references/browser-setup.md +3 -1
  79. package/dist/tui.js +534 -66
  80. package/package.json +15 -15
  81. package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
  82. package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
  83. package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
  84. package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
  85. package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
  86. package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
  87. package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
  88. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
  89. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
  90. package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +2 -2
  91. package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
  92. package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
  93. package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-5.6.md +5 -1
  94. package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-6.md +5 -1
  95. package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
  96. package/packages/omo-codex/plugin/components/rules/package.json +1 -1
  97. package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
  98. package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
  99. package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
  100. package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
  101. package/packages/omo-codex/plugin/components/ultrawork/dist/cli.js +7 -3
  102. package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
  103. package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
  104. package/packages/omo-codex/plugin/components/ultrawork/src/directive-content.ts +1 -1
  105. package/packages/omo-codex/plugin/components/ulw-execute-continuation/directive.md +1 -1
  106. package/packages/omo-codex/plugin/components/ulw-execute-continuation/hooks/hooks.json +1 -1
  107. package/packages/omo-codex/plugin/components/ulw-execute-continuation/package.json +1 -1
  108. package/packages/omo-codex/plugin/components/ulw-loop/directive.md +7 -3
  109. package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +5 -5
  110. package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
  111. package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
  112. package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
  113. package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
  114. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
  115. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
  116. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
  117. package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
  118. package/packages/omo-codex/plugin/hooks/post-tool-use-recording-spawn-admission.json +1 -1
  119. package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
  120. package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
  121. package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
  122. package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
  123. package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
  124. package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
  125. package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
  126. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-execute-continuation.json +1 -1
  127. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
  128. package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
  129. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
  130. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
  131. package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
  132. package/packages/omo-codex/plugin/package-lock.json +12 -12
  133. package/packages/omo-codex/plugin/package.json +1 -1
  134. package/packages/omo-codex/plugin/scripts/sync-skills.mjs +2 -2
  135. package/packages/omo-codex/plugin/skills/browser/SKILL.md +8 -1
  136. package/packages/omo-codex/plugin/skills/browser/references/install.md +34 -5
  137. package/packages/omo-codex/plugin/skills/browser/runtime/omowright/index.js +2 -2
  138. package/packages/omo-codex/plugin/skills/browser/runtime/omowright/manifest.json +3 -3
  139. package/packages/omo-codex/plugin/skills/browser/scripts/browser-doctor.mjs +14 -3
  140. package/packages/omo-codex/plugin/skills/browser/scripts/browser-install.mjs +13 -1
  141. package/packages/omo-codex/plugin/skills/ultrawork/SKILL.md +7 -3
  142. package/packages/omo-codex/plugin/skills/ulw-research/AGENTS.md +63 -0
  143. package/packages/omo-codex/plugin/skills/ulw-research/SKILL.md +37 -25
  144. package/packages/omo-codex/plugin/skills/ulw-research/references/deliverable-phase.md +187 -0
  145. package/packages/omo-codex/plugin/skills/ulw-research/references/report-gates.md +66 -0
  146. package/packages/omo-codex/plugin/skills/ulw-research/scripts/cli-support.mjs +102 -0
  147. package/packages/omo-codex/plugin/skills/ulw-research/scripts/contracts.mjs +162 -0
  148. package/packages/omo-codex/plugin/skills/ulw-research/scripts/css-lite.mjs +204 -0
  149. package/packages/omo-codex/plugin/skills/ulw-research/scripts/design-spec.mjs +161 -0
  150. package/packages/omo-codex/plugin/skills/ulw-research/scripts/entities.mjs +39 -0
  151. package/packages/omo-codex/plugin/skills/ulw-research/scripts/entry-guard.mjs +21 -0
  152. package/packages/omo-codex/plugin/skills/ulw-research/scripts/format-extract-css.mjs +122 -0
  153. package/packages/omo-codex/plugin/skills/ulw-research/scripts/format-extract.mjs +229 -0
  154. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-figures.mjs +173 -0
  155. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-layout.mjs +146 -0
  156. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-static.mjs +167 -0
  157. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-structure.mjs +118 -0
  158. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-text.mjs +190 -0
  159. package/packages/omo-codex/plugin/skills/ulw-research/scripts/html-lite.mjs +239 -0
  160. package/packages/omo-codex/plugin/skills/ulw-research/scripts/layout-probe.mjs +83 -0
  161. package/packages/omo-codex/plugin/skills/ulw-research/scripts/outcome.mjs +250 -0
  162. package/packages/omo-codex/plugin/skills/ulw-research/scripts/repair-tracker.mjs +171 -0
  163. package/packages/omo-codex/plugin/skills/ulw-research/scripts/report-tools-commands.mjs +214 -0
  164. package/packages/omo-codex/plugin/skills/ulw-research/scripts/report-tools.mjs +61 -0
  165. package/packages/omo-codex/plugin/skills/visual-qa/references/browser-setup.md +3 -1
  166. package/packages/omo-codex/plugin/test/sync-skills-codex-compatibility.test.mjs +11 -0
  167. package/packages/omo-codex/plugin/test/sync-skills-test-support.mjs +2 -2
  168. package/packages/omo-codex/plugin/test/sync-skills.test.mjs +20 -0
  169. package/packages/omo-codex/scripts/install-dist/install-local.mjs +2 -2
  170. package/packages/prompts-core/prompts/ultrawork/codex.md +7 -3
  171. package/packages/shared-skills/skills/browser/SKILL.md +8 -1
  172. package/packages/shared-skills/skills/browser/references/install.md +34 -5
  173. package/packages/shared-skills/skills/browser/runtime/omowright/index.js +2 -2
  174. package/packages/shared-skills/skills/browser/runtime/omowright/manifest.json +3 -3
  175. package/packages/shared-skills/skills/browser/scripts/browser-doctor.mjs +14 -3
  176. package/packages/shared-skills/skills/browser/scripts/browser-install.mjs +13 -1
  177. package/packages/shared-skills/skills/ulw-research/AGENTS.md +63 -0
  178. package/packages/shared-skills/skills/ulw-research/SKILL.md +38 -26
  179. package/packages/shared-skills/skills/ulw-research/references/deliverable-phase.md +187 -0
  180. package/packages/shared-skills/skills/ulw-research/references/report-gates.md +66 -0
  181. package/packages/shared-skills/skills/ulw-research/scripts/cli-support.mjs +102 -0
  182. package/packages/shared-skills/skills/ulw-research/scripts/cli-support.test.ts +88 -0
  183. package/packages/shared-skills/skills/ulw-research/scripts/contracts.mjs +162 -0
  184. package/packages/shared-skills/skills/ulw-research/scripts/contracts.test.ts +177 -0
  185. package/packages/shared-skills/skills/ulw-research/scripts/css-lite.mjs +204 -0
  186. package/packages/shared-skills/skills/ulw-research/scripts/css-lite.test.ts +159 -0
  187. package/packages/shared-skills/skills/ulw-research/scripts/design-spec.mjs +161 -0
  188. package/packages/shared-skills/skills/ulw-research/scripts/design-spec.test.ts +162 -0
  189. package/packages/shared-skills/skills/ulw-research/scripts/entities.mjs +39 -0
  190. package/packages/shared-skills/skills/ulw-research/scripts/entry-guard.mjs +21 -0
  191. package/packages/shared-skills/skills/ulw-research/scripts/format-extract-css.mjs +122 -0
  192. package/packages/shared-skills/skills/ulw-research/scripts/format-extract.mjs +229 -0
  193. package/packages/shared-skills/skills/ulw-research/scripts/format-extract.test.ts +243 -0
  194. package/packages/shared-skills/skills/ulw-research/scripts/gates-figures.mjs +173 -0
  195. package/packages/shared-skills/skills/ulw-research/scripts/gates-layout.mjs +146 -0
  196. package/packages/shared-skills/skills/ulw-research/scripts/gates-layout.test.ts +236 -0
  197. package/packages/shared-skills/skills/ulw-research/scripts/gates-static.mjs +167 -0
  198. package/packages/shared-skills/skills/ulw-research/scripts/gates-static.test.ts +265 -0
  199. package/packages/shared-skills/skills/ulw-research/scripts/gates-structure.mjs +118 -0
  200. package/packages/shared-skills/skills/ulw-research/scripts/gates-text.mjs +190 -0
  201. package/packages/shared-skills/skills/ulw-research/scripts/glossary-vocabulary.test.ts +43 -0
  202. package/packages/shared-skills/skills/ulw-research/scripts/html-lite.mjs +239 -0
  203. package/packages/shared-skills/skills/ulw-research/scripts/html-lite.test.ts +191 -0
  204. package/packages/shared-skills/skills/ulw-research/scripts/layout-probe.mjs +83 -0
  205. package/packages/shared-skills/skills/ulw-research/scripts/layout-probe.test.ts +89 -0
  206. package/packages/shared-skills/skills/ulw-research/scripts/outcome.mjs +250 -0
  207. package/packages/shared-skills/skills/ulw-research/scripts/outcome.test.ts +281 -0
  208. package/packages/shared-skills/skills/ulw-research/scripts/repair-tracker.mjs +171 -0
  209. package/packages/shared-skills/skills/ulw-research/scripts/repair-tracker.test.ts +246 -0
  210. package/packages/shared-skills/skills/ulw-research/scripts/report-tools-commands.mjs +214 -0
  211. package/packages/shared-skills/skills/ulw-research/scripts/report-tools.mjs +61 -0
  212. package/packages/shared-skills/skills/ulw-research/scripts/report-tools.test.ts +213 -0
  213. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/assets/figure-1.svg +1 -0
  214. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/bad-report.html +44 -0
  215. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/boxes.json +90 -0
  216. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/design-spec.md +29 -0
  217. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/good-report.html +57 -0
  218. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/reference-report.html +86 -0
  219. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/reference.md +23 -0
  220. package/packages/shared-skills/skills/visual-qa/references/browser-setup.md +3 -1
@@ -0,0 +1,161 @@
1
+ // design-spec.mjs - read the palette and lineage mode out of design-spec.md, and render a
2
+ // design-spec.md draft from a format extract. Nothing is guessed: every value the extract
3
+ // does not carry is written `TODO: ask` and listed under Open questions.
4
+ // Plain Node ESM, node: builtins and siblings only.
5
+
6
+ import { DESIGN_SPEC_SECTIONS, LINEAGE_MODES } from "./contracts.mjs"
7
+ import { hexColors } from "./css-lite.mjs"
8
+
9
+ const TODO = "TODO: ask"
10
+ // English and Korean section names (the requester writes specs in either language).
11
+ const PALETTE_HEADING = /^(palette|tokens|colou?rs?|\uD1A0\uD070|\uD314\uB808\uD2B8|\uC0C9\uC0C1|\uCEEC\uB7EC)/i
12
+ const FENCE = /^\s{0,3}(`{3,}|~{3,})/
13
+ const HEADING = /^(#{1,6})\s+(.*?)\s*#*\s*$/
14
+ const TOKEN_DECLARATION = /--[\w-]+\s*:\s*([^;]*)/g
15
+ const FIGURE_STANDARD =
16
+ "Each figure sits in a fixed-size container with a caption; the image scales to fit with its aspect ratio preserved (object-fit: contain), never stretched, cropped, or spilling out. Every chart carries a title, axis labels, units, and value labels."
17
+
18
+ /**
19
+ * Walk markdown lines with fence awareness.
20
+ * visit(line, { fenced, heading: { level, text } | null })
21
+ */
22
+ function walkLines(markdown, visit) {
23
+ let fence = null
24
+ for (const line of String(markdown ?? "").split(/\r?\n/)) {
25
+ const fenceMatch = FENCE.exec(line)
26
+ if (fence) {
27
+ const closes = fenceMatch && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length && line.trim() === fenceMatch[1]
28
+ if (closes) fence = null
29
+ else visit(line, { fenced: true, heading: null })
30
+ continue
31
+ }
32
+ if (fenceMatch) {
33
+ fence = fenceMatch[1]
34
+ continue
35
+ }
36
+ const heading = HEADING.exec(line)
37
+ visit(line, { fenced: false, heading: heading ? { level: heading[1].length, text: heading[2].replace(/^[\d.\s]+/, "") } : null })
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Palette = hex values of every `--x: #hex` inside any fenced code block, plus every hex
43
+ * anywhere under a heading named Palette / Tokens / Colors / Colours or their Korean equivalents (subheadings included).
44
+ * @returns {Set<string>} normalized #rrggbb values
45
+ */
46
+ export function parsePalette(specMarkdown) {
47
+ const palette = new Set()
48
+ let paletteLevel = 0
49
+ walkLines(specMarkdown, (line, { fenced, heading }) => {
50
+ if (heading) {
51
+ if (paletteLevel && heading.level > paletteLevel) return
52
+ paletteLevel = PALETTE_HEADING.test(heading.text) ? heading.level : 0
53
+ return
54
+ }
55
+ if (fenced) for (const match of line.matchAll(TOKEN_DECLARATION)) for (const hex of hexColors(match[1])) palette.add(hex)
56
+ if (paletteLevel) for (const hex of hexColors(line)) palette.add(hex)
57
+ })
58
+ return palette
59
+ }
60
+
61
+ /** `Lineage: inline|section` under the Citations heading; absent or unrecognized -> "inline". */
62
+ export function parseLineageMode(specMarkdown) {
63
+ let citationsLevel = 0
64
+ let mode = null
65
+ walkLines(specMarkdown, (line, { fenced, heading }) => {
66
+ if (mode || fenced) return
67
+ if (heading) {
68
+ if (citationsLevel && heading.level > citationsLevel) return
69
+ citationsLevel = /^citations\b/i.test(heading.text) ? heading.level : 0
70
+ return
71
+ }
72
+ const match = citationsLevel ? /^\s*(?:[-*]\s+)?lineage\s*:\s*(\S+)/i.exec(line) : null
73
+ if (match && LINEAGE_MODES.includes(match[1].toLowerCase())) mode = match[1].toLowerCase()
74
+ })
75
+ return mode ?? "inline"
76
+ }
77
+
78
+ function entriesOf(value) {
79
+ if (value instanceof Map) return [...value]
80
+ return value !== null && typeof value === "object" ? Object.entries(value) : []
81
+ }
82
+
83
+ function isKnown(value) {
84
+ if (value === undefined || value === null || value === "" || value === "unknown") return false
85
+ if (Array.isArray(value)) return value.length > 0
86
+ return true
87
+ }
88
+
89
+ function tokenFence(entries, dark) {
90
+ const lines = entries.map(([name, value]) => `${dark ? " " : " "}${name}: ${value};`)
91
+ const body = dark ? ["@media (prefers-color-scheme: dark) {", " :root {", ...lines, " }", "}"] : [":root {", ...lines, "}"]
92
+ return ["```css", ...body, "```"]
93
+ }
94
+
95
+ /**
96
+ * Render a design-spec.md draft. `extract` follows the format-extract shape
97
+ * ({ origin, bytes, date?, tokens: { light, dark }, fonts, colors, breakpoints, headingNumbering,
98
+ * citationStyle, lineageMode, figureContainers, skeleton, measure, notes }); every field is optional.
99
+ * @returns {string}
100
+ */
101
+ export function renderDesignSpec(extract = {}) {
102
+ const source = extract ?? {}
103
+ const open = []
104
+ const field = (label, value, format = String) => {
105
+ if (isKnown(value)) return format(value)
106
+ open.push(label)
107
+ return TODO
108
+ }
109
+ const fonts = source.fonts ?? {}
110
+ const measure = source.measure ?? {}
111
+ const light = entriesOf(source.tokens?.light)
112
+ const dark = entriesOf(source.tokens?.dark)
113
+ const colors = Array.isArray(source.colors) ? source.colors : []
114
+ const skeleton = Array.isArray(source.skeleton) ? source.skeleton : []
115
+ const notes = Array.isArray(source.notes) ? source.notes : []
116
+
117
+ const sections = {
118
+ "Extracted from": [
119
+ `- Source: ${field("Source path or URL", source.origin)}`,
120
+ `- Bytes: ${field("Source byte count", source.bytes)}`,
121
+ `- Date: ${source.date ?? new Date().toISOString().slice(0, 10)}`,
122
+ ...notes.map((note) => `- Note: ${note}`),
123
+ ],
124
+ Tokens: [
125
+ ...(light.length ? tokenFence(light, false) : [`- Light tokens: ${field("Light tokens", null)}`]),
126
+ ...(dark.length ? tokenFence(dark, true) : [`- Dark tokens: ${field("Dark tokens", null)}`]),
127
+ ...(colors.length
128
+ ? ["- Top colors:", ...colors.map((c) => ` - ${c.hex}${c.count ? ` (${c.count} uses${c.roles?.length ? `; ${c.roles.join(", ")}` : ""})` : ""}`)]
129
+ : [`- Top colors: ${field("Top colors", null)}`]),
130
+ ],
131
+ Typography: [
132
+ `- Body font: ${field("Body font", fonts.body)}`,
133
+ `- Heading font: ${field("Heading font", fonts.heading)}`,
134
+ `- Mono font: ${field("Mono font", fonts.mono)}`,
135
+ `- Body size: ${field("Body size", measure.fontSize)}`,
136
+ `- Line height: ${field("Line height", measure.lineHeight)}`,
137
+ ],
138
+ Layout: [
139
+ `- Breakpoints: ${field("Breakpoints", source.breakpoints, (list) => list.map((px) => `${px}px`).join(", "))}`,
140
+ `- Measure (max width): ${field("Measure", measure.maxWidth)}`,
141
+ ],
142
+ Structure: [
143
+ `- Heading numbering: ${field("Heading numbering", source.headingNumbering)}`,
144
+ skeleton.length ? "- Skeleton:" : `- Skeleton: ${field("Section skeleton", null)}`,
145
+ ...skeleton.map((entry) => `${" ".repeat(Math.max(1, entry.level))}- ${entry.text}`),
146
+ ],
147
+ Figures: [
148
+ `- Containers: ${field("Figure containers", source.figureContainers, (list) => list.join(", "))}`,
149
+ `- Standard: ${FIGURE_STANDARD}`,
150
+ ],
151
+ Citations: [
152
+ `- Style: ${field("Citation style", source.citationStyle)}`,
153
+ `- Lineage: ${field("Lineage mode", LINEAGE_MODES.includes(source.lineageMode) ? source.lineageMode : null)}`,
154
+ ],
155
+ }
156
+ sections["Open questions"] = open.length ? open.map((label) => `- ${label}: ${TODO}`) : ["- None"]
157
+
158
+ const out = ["# Design spec", ""]
159
+ for (const heading of DESIGN_SPEC_SECTIONS) out.push(`## ${heading}`, ...(sections[heading] ?? [`- ${TODO}`]), "")
160
+ return out.join("\n")
161
+ }
@@ -0,0 +1,39 @@
1
+ // entities.mjs - HTML character reference decoding for html-lite.mjs.
2
+ //
3
+ // Supports a fixed set of named entities plus decimal and hex numeric references. Unknown or
4
+ // malformed references stay verbatim. Single indexOf scan; plain Node ESM, no dependencies.
5
+
6
+ const NAMED_ENTITIES = {
7
+ amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: "\u00a0", mdash: "\u2014", ndash: "\u2013",
8
+ hellip: "\u2026", copy: "\u00a9", reg: "\u00ae", times: "\u00d7", middot: "\u00b7",
9
+ }
10
+
11
+ function decodeOne(body) {
12
+ if (Object.hasOwn(NAMED_ENTITIES, body)) return NAMED_ENTITIES[body]
13
+ if (body[0] !== "#") return null
14
+ const hex = body[1] === "x" || body[1] === "X"
15
+ const digits = body.slice(hex ? 2 : 1)
16
+ if (!(hex ? /^[0-9a-fA-F]{1,6}$/ : /^[0-9]{1,7}$/).test(digits)) return null
17
+ const cp = Number.parseInt(digits, hex ? 16 : 10)
18
+ return cp > 0 && cp <= 0x10ffff && (cp < 0xd800 || cp > 0xdfff) ? String.fromCodePoint(cp) : "\ufffd"
19
+ }
20
+
21
+ /** Decode the supported named entities plus decimal/hex numeric references; unknown ones stay verbatim. */
22
+ export function decodeEntities(text) {
23
+ const source = String(text)
24
+ let out = ""
25
+ let last = 0
26
+ let at = source.indexOf("&")
27
+ while (at !== -1) {
28
+ const semi = source.indexOf(";", at + 1)
29
+ const decoded = semi !== -1 && semi - at <= 12 ? decodeOne(source.slice(at + 1, semi)) : null
30
+ if (decoded !== null) {
31
+ out += source.slice(last, at) + decoded
32
+ last = semi + 1
33
+ at = source.indexOf("&", last)
34
+ } else {
35
+ at = source.indexOf("&", at + 1)
36
+ }
37
+ }
38
+ return out + source.slice(last)
39
+ }
@@ -0,0 +1,21 @@
1
+ import { realpathSync } from "node:fs"
2
+ import { pathToFileURL } from "node:url"
3
+
4
+ /**
5
+ * True when the module at `importMetaUrl` is the process entrypoint (`node <script>`).
6
+ * Compares against the realpath too: Node resolves import.meta.url through symlinks while
7
+ * process.argv[1] keeps the symlinked spelling, so a plain equality check silently skips the CLI
8
+ * body when the skill is reached through a symlinked plugin directory.
9
+ *
10
+ * @param {string} importMetaUrl the caller's import.meta.url
11
+ */
12
+ export function isCliEntry(importMetaUrl) {
13
+ const argv1 = process.argv[1]
14
+ if (argv1 === undefined) return false
15
+ if (importMetaUrl === pathToFileURL(argv1).href) return true
16
+ try {
17
+ return importMetaUrl === pathToFileURL(realpathSync(argv1)).href
18
+ } catch {
19
+ return false
20
+ }
21
+ }
@@ -0,0 +1,122 @@
1
+ // format-extract-css.mjs - the CSS half of format-extract: tokens, font stacks, ranked colors,
2
+ // breakpoints, and the reading measure. Only values the stylesheet states are returned; a
3
+ // missing value stays undefined so the renderer writes `TODO: ask`.
4
+ // Plain Node ESM, node: builtins and siblings only.
5
+
6
+ import { customProperties, fontFamilies, hexColors, mediaBreakpoints, resolveVar, scanRules } from "./css-lite.mjs"
7
+
8
+ const ROLE_ORDER = ["body", "heading", "link", "background", "border", "accent"]
9
+ const TOP_COLORS = 8
10
+ const ABSOLUTE_LENGTH = /^\d+(?:\.\d+)?(?:px|rem|em|ch)$/i
11
+
12
+ /** Last compound of each comma-separated selector part, pseudo-classes and attributes dropped. */
13
+ function subjects(selector) {
14
+ return selector.split(",").map((part) => {
15
+ const compounds = part.trim().split(/\s*[\s>+~]\s*/)
16
+ return (compounds.at(-1) ?? "").replace(/(?:::?[\w-]+(?:\([^)]*\))?|\[[^\]]*\])+$/g, "").toLowerCase()
17
+ })
18
+ }
19
+
20
+ const isBodySubject = (subject) => subject === "body" || subject === "html" || subject === ":root"
21
+ const isHeadingSubject = (subject) => /^h[1-6](?![\w-])/.test(subject)
22
+ const isMonoSubject = (subject) => /^(?:code|pre|kbd|samp)(?![\w-])/.test(subject)
23
+ const isLinkSubject = (subject) => /^a(?![\w-])/.test(subject)
24
+
25
+ function formatStack(families) {
26
+ return families.map((family) => (/^-?[A-Za-z_][\w-]*$/.test(family) ? family : JSON.stringify(family))).join(", ")
27
+ }
28
+
29
+ /** { body?, heading?, mono? } from font-family / font declarations, var() resolved. */
30
+ export function fontStacks(css) {
31
+ const fonts = {}
32
+ const entries = fontFamilies(css)
33
+ const pick = (test) => entries.find((entry) => subjects(entry.selector).some(test))
34
+ const body = pick(isBodySubject)
35
+ const heading = pick(isHeadingSubject)
36
+ const mono = pick(isMonoSubject) ?? entries.find((entry) => entry.families.at(-1)?.toLowerCase() === "monospace")
37
+ if (body) fonts.body = formatStack(body.families)
38
+ if (heading) fonts.heading = formatStack(heading.families)
39
+ if (mono) fonts.mono = formatStack(mono.families)
40
+ return fonts
41
+ }
42
+
43
+ function tokenRoles(name) {
44
+ const roles = []
45
+ if (/accent|primary|brand/i.test(name)) roles.push("accent")
46
+ if (/(?:^--|-)bg(?:-|$)|background|surface/i.test(name)) roles.push("background")
47
+ if (/border|(?:^--|-)rule(?:-|$)/i.test(name)) roles.push("border")
48
+ return roles
49
+ }
50
+
51
+ function declarationRoles(selector, prop) {
52
+ if (prop.startsWith("--")) return tokenRoles(prop)
53
+ if (prop.startsWith("background")) return ["background"]
54
+ if (prop.startsWith("border") || prop.startsWith("outline")) return ["border"]
55
+ if (prop !== "color") return []
56
+ const parts = subjects(selector)
57
+ const roles = []
58
+ if (parts.some(isLinkSubject)) roles.push("link")
59
+ if (parts.some(isHeadingSubject)) roles.push("heading")
60
+ if (parts.some(isBodySubject)) roles.push("body")
61
+ return roles
62
+ }
63
+
64
+ /**
65
+ * Top hex colors by use count (ties keep first appearance): each token definition counts once,
66
+ * and each other declaration counts once per hex it resolves to (var() against light tokens).
67
+ * Roles are guessed from the selector and property where the color is used.
68
+ */
69
+ export function rankColors(css, lightTokens) {
70
+ const table = new Map()
71
+ for (const rule of scanRules(css)) {
72
+ for (const { prop, value } of rule.declarations) {
73
+ const roles = declarationRoles(rule.selector, prop)
74
+ for (const hex of hexColors(prop.startsWith("--") ? value : resolveVar(value, lightTokens))) {
75
+ const entry = table.get(hex) ?? { hex, count: 0, roles: new Set() }
76
+ entry.count++
77
+ for (const role of roles) entry.roles.add(role)
78
+ table.set(hex, entry)
79
+ }
80
+ }
81
+ }
82
+ return [...table.values()]
83
+ .sort((a, b) => b.count - a.count)
84
+ .slice(0, TOP_COLORS)
85
+ .map((entry) => ({ hex: entry.hex, count: entry.count, roles: ROLE_ORDER.filter((role) => entry.roles.has(role)) }))
86
+ }
87
+
88
+ /** { fontSize?, lineHeight?, maxWidth? }: body size and leading, plus the most used absolute max width. */
89
+ export function readingMeasure(css, lightTokens) {
90
+ const measure = {}
91
+ const rules = scanRules(css).filter((rule) => !rule.atRule)
92
+ for (const rule of rules) {
93
+ if (!subjects(rule.selector).some(isBodySubject)) continue
94
+ for (const { prop, value } of rule.declarations) {
95
+ if (prop === "font-size" && !measure.fontSize) measure.fontSize = resolveVar(value, lightTokens)
96
+ if (prop === "line-height" && !measure.lineHeight) measure.lineHeight = resolveVar(value, lightTokens)
97
+ }
98
+ }
99
+ const widths = new Map()
100
+ for (const rule of rules) {
101
+ for (const { prop, value } of rule.declarations) {
102
+ if (prop !== "max-width" && prop !== "max-inline-size") continue
103
+ const resolved = resolveVar(value, lightTokens).trim()
104
+ if (ABSOLUTE_LENGTH.test(resolved)) widths.set(resolved, (widths.get(resolved) ?? 0) + 1)
105
+ }
106
+ }
107
+ const widest = [...widths].sort((a, b) => b[1] - a[1])[0]
108
+ if (widest) measure.maxWidth = widest[0]
109
+ return measure
110
+ }
111
+
112
+ /** Every CSS-derived Extract field from one concatenated stylesheet. */
113
+ export function cssFacts(css) {
114
+ const tokens = customProperties(css)
115
+ return {
116
+ tokens,
117
+ fonts: fontStacks(css),
118
+ colors: rankColors(css, tokens.light),
119
+ breakpoints: mediaBreakpoints(css),
120
+ measure: readingMeasure(css, tokens.light),
121
+ }
122
+ }
@@ -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
+ }