@gmickel/gno 1.45.1 → 2.0.0

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 (236) hide show
  1. package/README.md +1 -1
  2. package/THIRD_PARTY_NOTICES.md +46 -0
  3. package/assets/skill/SKILL.md +7 -6
  4. package/assets/skill/cli-reference.md +14 -6
  5. package/assets/skill/mcp-reference.md +4 -1
  6. package/assets/spa-production.json.gz +0 -0
  7. package/browser-extension/artifacts/gno-browser-clipper-v2.0.0.zip +0 -0
  8. package/browser-extension/artifacts/gno-browser-clipper-v2.0.0.zip.sha256 +1 -0
  9. package/browser-extension/dist/chunk-4tc9v0ja.js +74 -0
  10. package/browser-extension/dist/manifest.json +1 -1
  11. package/browser-extension/dist/preview.html +1 -1
  12. package/browser-extension/dist/service-worker.js +32 -33
  13. package/bunfig.toml +2 -0
  14. package/package.json +40 -26
  15. package/spec/cli.md +30 -11
  16. package/spec/db/schema.sql +146 -1
  17. package/spec/mcp.md +26 -0
  18. package/src/app/context-runtime-types.ts +3 -0
  19. package/src/app/context-runtime.ts +2 -0
  20. package/src/cli/commands/ask.ts +6 -1
  21. package/src/cli/commands/daemon.ts +21 -8
  22. package/src/cli/commands/embed.ts +77 -41
  23. package/src/cli/commands/mcp/install.ts +20 -0
  24. package/src/cli/commands/mcp/paths.ts +25 -0
  25. package/src/cli/commands/mcp/status.ts +6 -0
  26. package/src/cli/detach.ts +3 -2
  27. package/src/cli/program.ts +6 -0
  28. package/src/config/types.ts +3 -3
  29. package/src/converters/adapters/markitdownTs/adapter.ts +1 -2
  30. package/src/converters/adapters/officeparser/adapter.ts +1 -2
  31. package/src/converters/versions.ts +6 -8
  32. package/src/core/context-evidence.ts +8 -4
  33. package/src/core/job-manager.ts +95 -13
  34. package/src/core/network-boundary-inventory.ts +10 -0
  35. package/src/core/shutdown-budget.ts +45 -0
  36. package/src/embed/backlog.ts +107 -4
  37. package/src/embed/batch.ts +42 -2
  38. package/src/embed/fingerprint.ts +16 -0
  39. package/src/embed/retry.ts +113 -5
  40. package/src/embed/variant-backlog.ts +105 -0
  41. package/src/embed/variant-plan.ts +62 -0
  42. package/src/embed/variant-retry.ts +113 -0
  43. package/src/ingestion/graph-reconciliation.ts +327 -0
  44. package/src/ingestion/sync.ts +9 -272
  45. package/src/llm/http-inference.ts +6 -0
  46. package/src/llm/httpEmbedding.ts +37 -6
  47. package/src/llm/httpGeneration.ts +18 -3
  48. package/src/llm/httpRerank.ts +23 -5
  49. package/src/llm/inference-cancellation.ts +168 -0
  50. package/src/llm/inference-scope.ts +202 -0
  51. package/src/llm/lazy-ports.ts +115 -0
  52. package/src/llm/native-worker/client.ts +541 -0
  53. package/src/llm/native-worker/dispatcher.ts +228 -0
  54. package/src/llm/native-worker/embedding-identity.ts +33 -0
  55. package/src/llm/native-worker/entry.ts +173 -0
  56. package/src/llm/native-worker/errors.ts +32 -0
  57. package/src/llm/native-worker/evaluation.ts +16 -0
  58. package/src/llm/native-worker/owned-exit.ts +108 -0
  59. package/src/llm/native-worker/owner.ts +141 -0
  60. package/src/llm/native-worker/ports.ts +317 -0
  61. package/src/llm/native-worker/protocol.ts +442 -0
  62. package/src/llm/native-worker/runtime-config.ts +92 -0
  63. package/src/llm/nodeLlamaCpp/adapter.ts +77 -20
  64. package/src/llm/nodeLlamaCpp/embedding.ts +130 -46
  65. package/src/llm/nodeLlamaCpp/generation.ts +34 -5
  66. package/src/llm/nodeLlamaCpp/lifecycle-options.ts +99 -0
  67. package/src/llm/nodeLlamaCpp/lifecycle.ts +209 -204
  68. package/src/llm/nodeLlamaCpp/rerank-capacity.ts +111 -0
  69. package/src/llm/nodeLlamaCpp/rerank.ts +118 -27
  70. package/src/llm/nodeLlamaCpp/simulator-handle.ts +73 -0
  71. package/src/llm/nodeLlamaCpp/simulator-install.ts +124 -0
  72. package/src/llm/nodeLlamaCpp/simulator-session.ts +240 -0
  73. package/src/llm/nodeLlamaCpp/simulator-types.ts +80 -0
  74. package/src/llm/types.ts +35 -5
  75. package/src/mcp/context.ts +27 -0
  76. package/src/mcp/http-transport.ts +12 -10
  77. package/src/mcp/server.ts +3 -0
  78. package/src/mcp/tool-profile.ts +30 -8
  79. package/src/mcp/tools/context.ts +8 -11
  80. package/src/mcp/tools/embed.ts +1 -1
  81. package/src/mcp/tools/index-cmd.ts +1 -1
  82. package/src/mcp/tools/index.ts +10 -8
  83. package/src/mcp/tools/query.ts +14 -30
  84. package/src/mcp/tools/vsearch.ts +1 -1
  85. package/src/pipeline/answer.ts +23 -3
  86. package/src/pipeline/claim-verifier.ts +6 -0
  87. package/src/pipeline/expansion.ts +43 -40
  88. package/src/pipeline/explain.ts +6 -2
  89. package/src/pipeline/filters.ts +63 -0
  90. package/src/pipeline/fusion.ts +29 -9
  91. package/src/pipeline/graph-retrieval.ts +29 -9
  92. package/src/pipeline/hybrid.ts +198 -55
  93. package/src/pipeline/hydration.ts +161 -0
  94. package/src/pipeline/owner-fusion.ts +87 -0
  95. package/src/pipeline/rerank.ts +35 -11
  96. package/src/pipeline/search.ts +13 -2
  97. package/src/pipeline/types.ts +5 -3
  98. package/src/pipeline/vsearch.ts +87 -7
  99. package/src/sdk/client.ts +47 -3
  100. package/src/sdk/embed.ts +63 -39
  101. package/src/serve/background-runtime.ts +1 -1
  102. package/src/serve/context.ts +41 -56
  103. package/src/serve/embed-scheduler.ts +58 -35
  104. package/src/serve/public/components/IndexingProgress.tsx +46 -60
  105. package/src/serve/public/globals.built.css +1 -1
  106. package/src/serve/public/lib/shiki-language-ids.ts +14 -0
  107. package/src/serve/resident-admission.ts +36 -36
  108. package/src/serve/resident-background-work.ts +20 -2
  109. package/src/serve/resident-request.ts +11 -5
  110. package/src/serve/resident-runtime.ts +97 -61
  111. package/src/serve/resident-shutdown.ts +153 -0
  112. package/src/serve/routes/api.ts +3 -1
  113. package/src/serve/server.ts +47 -26
  114. package/src/store/migrations/028-vector-variants.ts +54 -0
  115. package/src/store/migrations/029-graph-reference-state.ts +77 -0
  116. package/src/store/migrations/index.ts +4 -0
  117. package/src/store/sqlite/adapter.ts +251 -183
  118. package/src/store/sqlite/eligibility.ts +174 -0
  119. package/src/store/sqlite/graph-edge-application.ts +66 -0
  120. package/src/store/sqlite/graph-reference-state.ts +194 -0
  121. package/src/store/sqlite/legacy-vector-ownership.ts +79 -0
  122. package/src/store/types.ts +80 -12
  123. package/src/store/vector/eligibility.ts +36 -0
  124. package/src/store/vector/freshness.ts +33 -6
  125. package/src/store/vector/lazy.ts +81 -0
  126. package/src/store/vector/sqlite-vec.ts +106 -54
  127. package/src/store/vector/stats.ts +14 -3
  128. package/src/store/vector/types.ts +35 -2
  129. package/src/store/vector/variant-search.ts +192 -0
  130. package/src/store/vector/variants.ts +451 -0
  131. package/vendor/converters/markitdown-ts/LICENSE +21 -0
  132. package/vendor/converters/markitdown-ts/dist/index.cjs +1180 -0
  133. package/vendor/converters/markitdown-ts/dist/index.d.cts +46 -0
  134. package/vendor/converters/markitdown-ts/dist/index.d.mts +46 -0
  135. package/vendor/converters/markitdown-ts/dist/index.d.ts +46 -0
  136. package/vendor/converters/markitdown-ts/dist/index.mjs +1152 -0
  137. package/vendor/converters/markitdown-ts/package.json +77 -0
  138. package/vendor/converters/officeparser/LICENSE +21 -0
  139. package/vendor/converters/officeparser/dist/OfficeConverter.d.ts +47 -0
  140. package/vendor/converters/officeparser/dist/OfficeConverter.js +76 -0
  141. package/vendor/converters/officeparser/dist/OfficeGenerator.d.ts +23 -0
  142. package/vendor/converters/officeparser/dist/OfficeGenerator.js +73 -0
  143. package/vendor/converters/officeparser/dist/OfficeParser.d.ts +106 -0
  144. package/vendor/converters/officeparser/dist/OfficeParser.js +332 -0
  145. package/vendor/converters/officeparser/dist/cli.d.ts +28 -0
  146. package/vendor/converters/officeparser/dist/cli.js +381 -0
  147. package/vendor/converters/officeparser/dist/defaults.d.ts +41 -0
  148. package/vendor/converters/officeparser/dist/defaults.js +218 -0
  149. package/vendor/converters/officeparser/dist/generators/BaseGenerator.d.ts +107 -0
  150. package/vendor/converters/officeparser/dist/generators/BaseGenerator.js +248 -0
  151. package/vendor/converters/officeparser/dist/generators/ChunkingGenerator.d.ts +82 -0
  152. package/vendor/converters/officeparser/dist/generators/ChunkingGenerator.js +797 -0
  153. package/vendor/converters/officeparser/dist/generators/CsvGenerator.d.ts +38 -0
  154. package/vendor/converters/officeparser/dist/generators/CsvGenerator.js +245 -0
  155. package/vendor/converters/officeparser/dist/generators/EpubGenerator.d.ts +43 -0
  156. package/vendor/converters/officeparser/dist/generators/EpubGenerator.js +315 -0
  157. package/vendor/converters/officeparser/dist/generators/HtmlGenerator.d.ts +59 -0
  158. package/vendor/converters/officeparser/dist/generators/HtmlGenerator.js +1942 -0
  159. package/vendor/converters/officeparser/dist/generators/MarkdownGenerator.d.ts +96 -0
  160. package/vendor/converters/officeparser/dist/generators/MarkdownGenerator.js +1175 -0
  161. package/vendor/converters/officeparser/dist/generators/PdfGenerator.d.ts +22 -0
  162. package/vendor/converters/officeparser/dist/generators/PdfGenerator.js +194 -0
  163. package/vendor/converters/officeparser/dist/generators/RtfGenerator.d.ts +29 -0
  164. package/vendor/converters/officeparser/dist/generators/RtfGenerator.js +316 -0
  165. package/vendor/converters/officeparser/dist/generators/TextGenerator.d.ts +13 -0
  166. package/vendor/converters/officeparser/dist/generators/TextGenerator.js +201 -0
  167. package/vendor/converters/officeparser/dist/index.d.ts +60 -0
  168. package/vendor/converters/officeparser/dist/index.js +72 -0
  169. package/vendor/converters/officeparser/dist/index.mjs +18 -0
  170. package/vendor/converters/officeparser/dist/officeparser.browser.d.ts +2621 -0
  171. package/vendor/converters/officeparser/dist/officeparser.browser.iife.js +1336 -0
  172. package/vendor/converters/officeparser/dist/officeparser.browser.mjs +1335 -0
  173. package/vendor/converters/officeparser/dist/officeparser.browser.slim.d.ts +2621 -0
  174. package/vendor/converters/officeparser/dist/officeparser.browser.slim.iife.js +1336 -0
  175. package/vendor/converters/officeparser/dist/officeparser.browser.slim.mjs +1335 -0
  176. package/vendor/converters/officeparser/dist/parsers/CsvParser.d.ts +9 -0
  177. package/vendor/converters/officeparser/dist/parsers/CsvParser.js +115 -0
  178. package/vendor/converters/officeparser/dist/parsers/EpubParser.d.ts +8 -0
  179. package/vendor/converters/officeparser/dist/parsers/EpubParser.js +217 -0
  180. package/vendor/converters/officeparser/dist/parsers/ExcelParser.d.ts +32 -0
  181. package/vendor/converters/officeparser/dist/parsers/ExcelParser.js +736 -0
  182. package/vendor/converters/officeparser/dist/parsers/HtmlParser.d.ts +2 -0
  183. package/vendor/converters/officeparser/dist/parsers/HtmlParser.js +1287 -0
  184. package/vendor/converters/officeparser/dist/parsers/MarkdownParser.d.ts +2 -0
  185. package/vendor/converters/officeparser/dist/parsers/MarkdownParser.js +1272 -0
  186. package/vendor/converters/officeparser/dist/parsers/OpenOfficeParser.d.ts +31 -0
  187. package/vendor/converters/officeparser/dist/parsers/OpenOfficeParser.js +1819 -0
  188. package/vendor/converters/officeparser/dist/parsers/PdfParser.d.ts +67 -0
  189. package/vendor/converters/officeparser/dist/parsers/PdfParser.js +848 -0
  190. package/vendor/converters/officeparser/dist/parsers/PowerPointParser.d.ts +32 -0
  191. package/vendor/converters/officeparser/dist/parsers/PowerPointParser.js +950 -0
  192. package/vendor/converters/officeparser/dist/parsers/RtfParser.d.ts +187 -0
  193. package/vendor/converters/officeparser/dist/parsers/RtfParser.js +1801 -0
  194. package/vendor/converters/officeparser/dist/parsers/WordParser.d.ts +79 -0
  195. package/vendor/converters/officeparser/dist/parsers/WordParser.js +1177 -0
  196. package/vendor/converters/officeparser/dist/sbom.cdx.json +1763 -0
  197. package/vendor/converters/officeparser/dist/types.d.ts +2507 -0
  198. package/vendor/converters/officeparser/dist/types.js +107 -0
  199. package/vendor/converters/officeparser/dist/utils/astUtils.d.ts +16 -0
  200. package/vendor/converters/officeparser/dist/utils/astUtils.js +33 -0
  201. package/vendor/converters/officeparser/dist/utils/chartUtils.d.ts +6 -0
  202. package/vendor/converters/officeparser/dist/utils/chartUtils.js +257 -0
  203. package/vendor/converters/officeparser/dist/utils/configUtils.d.ts +44 -0
  204. package/vendor/converters/officeparser/dist/utils/configUtils.js +315 -0
  205. package/vendor/converters/officeparser/dist/utils/dateUtils.d.ts +17 -0
  206. package/vendor/converters/officeparser/dist/utils/dateUtils.js +69 -0
  207. package/vendor/converters/officeparser/dist/utils/envUtils.d.ts +29 -0
  208. package/vendor/converters/officeparser/dist/utils/envUtils.js +152 -0
  209. package/vendor/converters/officeparser/dist/utils/errorUtils.d.ts +72 -0
  210. package/vendor/converters/officeparser/dist/utils/errorUtils.js +245 -0
  211. package/vendor/converters/officeparser/dist/utils/imageUtils.d.ts +66 -0
  212. package/vendor/converters/officeparser/dist/utils/imageUtils.js +133 -0
  213. package/vendor/converters/officeparser/dist/utils/mathUtils.d.ts +42 -0
  214. package/vendor/converters/officeparser/dist/utils/mathUtils.js +385 -0
  215. package/vendor/converters/officeparser/dist/utils/moduleLoader.d.ts +18 -0
  216. package/vendor/converters/officeparser/dist/utils/moduleLoader.js +106 -0
  217. package/vendor/converters/officeparser/dist/utils/ocrUtils.d.ts +42 -0
  218. package/vendor/converters/officeparser/dist/utils/ocrUtils.js +428 -0
  219. package/vendor/converters/officeparser/dist/utils/sanitize.d.ts +148 -0
  220. package/vendor/converters/officeparser/dist/utils/sanitize.js +344 -0
  221. package/vendor/converters/officeparser/dist/utils/sheetUtils.d.ts +7 -0
  222. package/vendor/converters/officeparser/dist/utils/sheetUtils.js +35 -0
  223. package/vendor/converters/officeparser/dist/utils/styleMapper.d.ts +36 -0
  224. package/vendor/converters/officeparser/dist/utils/styleMapper.js +224 -0
  225. package/vendor/converters/officeparser/dist/utils/xmlUtils.d.ts +163 -0
  226. package/vendor/converters/officeparser/dist/utils/xmlUtils.js +461 -0
  227. package/vendor/converters/officeparser/dist/utils/zipUtils.d.ts +134 -0
  228. package/vendor/converters/officeparser/dist/utils/zipUtils.js +337 -0
  229. package/vendor/converters/officeparser/package.json +147 -0
  230. package/vendor/converters/upstream-manifest.json +124 -0
  231. package/vendor/dependency-fixes/README.md +77 -0
  232. package/vendor/dependency-fixes/vendor-converters.py +83 -0
  233. package/browser-extension/artifacts/gno-browser-clipper-v1.45.1.zip +0 -0
  234. package/browser-extension/artifacts/gno-browser-clipper-v1.45.1.zip.sha256 +0 -1
  235. package/browser-extension/dist/chunk-627emwpj.js +0 -75
  236. /package/browser-extension/dist/{chunk-ydfx5d7p.css → chunk-z74y8n8c.css} +0 -0
@@ -0,0 +1,1272 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseMarkdown = void 0;
4
+ const astUtils_js_1 = require("../utils/astUtils.js");
5
+ const errorUtils_js_1 = require("../utils/errorUtils.js");
6
+ const sanitize_js_1 = require("../utils/sanitize.js");
7
+ // Sentinel node type for a standalone bookmark-anchor block (e.g. `<a id="x"></a>` on its
8
+ // own line). A post-parse pass folds these into the following node's anchorIds so they
9
+ // round-trip as real anchors rather than being escaped to visible text on regeneration.
10
+ const ANCHOR_PLACEHOLDER = '__anchorPlaceholder__';
11
+ /**
12
+ * Splits the inner content of a YAML flow array (`a, "b, c", d`) on top-level commas,
13
+ * ignoring commas inside single- or double-quoted items.
14
+ */
15
+ const splitFlowArrayItems = (inner) => {
16
+ const items = [];
17
+ let current = '';
18
+ let quote = null;
19
+ for (const ch of inner) {
20
+ if (quote) {
21
+ current += ch;
22
+ if (ch === quote)
23
+ quote = null;
24
+ }
25
+ else if (ch === '"' || ch === '\'') {
26
+ quote = ch;
27
+ current += ch;
28
+ }
29
+ else if (ch === ',') {
30
+ items.push(current.trim());
31
+ current = '';
32
+ }
33
+ else {
34
+ current += ch;
35
+ }
36
+ }
37
+ if (current.trim() !== '')
38
+ items.push(current.trim());
39
+ return items;
40
+ };
41
+ /**
42
+ * Maps every accepted-on-import admonition type spelling (GitHub's five plus GLFM's
43
+ * `danger`) to the canonical AdmonitionMetadata type. Per MARKDOWN_DIALECT.md's
44
+ * Decisions, `danger` folds into `caution` - there is no separate danger type.
45
+ */
46
+ const ADMONITION_TYPE_MAP = {
47
+ note: 'note',
48
+ tip: 'tip',
49
+ important: 'important',
50
+ warning: 'warning',
51
+ caution: 'caution',
52
+ danger: 'caution'
53
+ };
54
+ const parseMarkdown = async (buffer, config) => {
55
+ // Honour cancellation requests before the line-by-line Markdown scanning loop begins.
56
+ // Markdown parsing is entirely synchronous and CPU-bound, so failing fast avoids
57
+ // processing content whose result will be discarded anyway.
58
+ (0, errorUtils_js_1.checkAbortSignal)(config.abortSignal);
59
+ let textStr = buffer.toString('utf-8');
60
+ textStr = textStr.replace(/\r\n/g, '\n');
61
+ const content = [];
62
+ const metadata = {};
63
+ const attachments = [];
64
+ // Parse YAML Front Matter
65
+ if (/^---\n---[ \t]*(?:\n|$)/.test(textStr)) {
66
+ // Empty frontmatter block: strip it so `---\n---` isn't misread as a setext `## ---`
67
+ // heading (empty metadata used to emit exactly this shape, and other producers do too).
68
+ textStr = textStr.replace(/^---\n---[ \t]*(?:\n|$)/, '');
69
+ }
70
+ else if (textStr.startsWith('---\n')) {
71
+ const endIdx = textStr.indexOf('\n---\n', 4);
72
+ if (endIdx !== -1) {
73
+ const frontMatter = textStr.substring(4, endIdx);
74
+ textStr = textStr.substring(endIdx + 5);
75
+ const lines = frontMatter.split('\n');
76
+ const customProps = {};
77
+ const nativeProps = {};
78
+ for (const line of lines) {
79
+ const match = line.match(/^([^:]+):\s*(.*)$/);
80
+ if (match) {
81
+ const key = match[1].trim();
82
+ const rawVal = match[2].trim();
83
+ // A quoted scalar is explicitly a string in YAML: strip the quotes but never
84
+ // coerce it, so `version: "123"` / `flag: "true"` keep their string-ness across
85
+ // a save/reload cycle instead of silently degrading to a number/boolean on the
86
+ // next parse (which the generator would then re-emit unquoted, losing the type
87
+ // permanently). Only bare, unquoted scalars coerce.
88
+ const isQuoted = /^"(.*)"$/.test(rawVal) || /^'(.*)'$/.test(rawVal);
89
+ const val = rawVal.replace(/^"(.*)"$/, '$1').replace(/^'(.*)'$/, '$1');
90
+ let parsedVal = val;
91
+ if (!isQuoted && rawVal.startsWith('[') && rawVal.endsWith(']')) {
92
+ // Flow-array (`tags: [a, b]`) or JSON-array (`tags: ["a","b"]`) value -
93
+ // parse into a real array instead of storing the literal bracket string,
94
+ // so it round-trips symmetrically with MarkdownGenerator's frontmatter output.
95
+ try {
96
+ const jsonParsed = JSON.parse(rawVal);
97
+ parsedVal = Array.isArray(jsonParsed) ? jsonParsed : val;
98
+ }
99
+ catch {
100
+ const inner = rawVal.slice(1, -1).trim();
101
+ parsedVal = inner === '' ? [] : splitFlowArrayItems(inner).map(item => item.replace(/^['"](.*)['"]$/, '$1'));
102
+ }
103
+ }
104
+ else if (isQuoted)
105
+ parsedVal = val;
106
+ else if (val === 'true')
107
+ parsedVal = true;
108
+ else if (val === 'false')
109
+ parsedVal = false;
110
+ else if (!isNaN(Number(val)) && val !== '')
111
+ parsedVal = Number(val);
112
+ nativeProps[key] = parsedVal;
113
+ if (key === 'title')
114
+ metadata.title = val;
115
+ else if (key === 'author')
116
+ metadata.author = val;
117
+ else if (key === 'created')
118
+ metadata.created = new Date(val);
119
+ else if (key === 'modified')
120
+ metadata.modified = new Date(val);
121
+ else if (key === 'description')
122
+ metadata.description = val;
123
+ else {
124
+ customProps[key] = parsedVal;
125
+ }
126
+ }
127
+ }
128
+ if (Object.keys(customProps).length > 0)
129
+ metadata.customProperties = customProps;
130
+ if (Object.keys(nativeProps).length > 0)
131
+ metadata.nativeProperties = nativeProps;
132
+ }
133
+ }
134
+ // Strip MDX/JSX component tags (parse-only - we never author MDX). Components are
135
+ // distinguished from plain HTML by an uppercase-leading tag name, matching React/MDX
136
+ // convention. Self-closing components are removed entirely; paired components keep
137
+ // their inner Markdown content. Iterate to a fixed point so nested components (of
138
+ // different names) are all unwrapped, not just the outermost one.
139
+ // Cap the passes: each iteration unwraps one nesting level, so a pathologically
140
+ // deep `<A><A>...</A></A>` input would otherwise be O(depth * n). Real documents
141
+ // nest only a handful of levels; anything past the cap is left as-is.
142
+ let previousTextStr;
143
+ let mdxPasses = 0;
144
+ const MAX_MDX_PASSES = 100;
145
+ do {
146
+ previousTextStr = textStr;
147
+ textStr = textStr.replace(/<[A-Z][A-Za-z0-9]*(?:\s+[^>]*?)?\/>/g, '');
148
+ textStr = textStr.replace(/<([A-Z][A-Za-z0-9]*)(?:\s+[^>]*?)?>([\s\S]*?)<\/\1>/g, (_m, _name, inner) => inner);
149
+ } while (textStr !== previousTextStr && ++mdxPasses < MAX_MDX_PASSES);
150
+ // Extract code blocks first to protect their contents. Accepts both backtick and
151
+ // tilde fences (CommonMark's two fence characters); the backreference on the fence
152
+ // run means a `~~~`-fenced block isn't closed early by a stray ``` inside it, and
153
+ // vice versa.
154
+ const codeBlocks = [];
155
+ textStr = textStr.replace(/^(`{3,}|~{3,})(\w*)\n([\s\S]*?)\n\1$/gm, (match, _fence, lang, code) => {
156
+ const id = `__CODE_BLOCK_${codeBlocks.length}__`;
157
+ codeBlocks.push(JSON.stringify({ lang, code }));
158
+ return `\n\n${id}\n\n`;
159
+ });
160
+ // Extract block math ($$\n...\n$$) before block splitting, mirroring the code-block
161
+ // pre-pass above - its body may contain blank lines that would otherwise fragment it.
162
+ // Inline math ($...$) is handled directly in parseInline below.
163
+ const mathBlocks = [];
164
+ textStr = textStr.replace(/^\$\$\n([\s\S]*?)\n\$\$$/gm, (_match, latex) => {
165
+ const id = `__MATH_BLOCK_${mathBlocks.length}__`;
166
+ mathBlocks.push(latex);
167
+ return `\n\n${id}\n\n`;
168
+ });
169
+ // Single-line `$$...$$` occupying its own line is display (block) math too. Without this it
170
+ // falls through to the inline `$...$` tokenizer, which matches the INNER `$\int$` and leaks
171
+ // the outer pair as two stray literal `$`. Runs after the multi-line pass, whose placeholders
172
+ // carry no `$$` and so can't be re-matched. `(?!\$)` rejects `$$$...`/empty `$$$$`.
173
+ textStr = textStr.replace(/^\$\$(?!\$)([^\n]+?)\$\$[ \t]*$/gm, (_match, latex) => {
174
+ const id = `__MATH_BLOCK_${mathBlocks.length}__`;
175
+ mathBlocks.push(latex);
176
+ return `\n\n${id}\n\n`;
177
+ });
178
+ // Extract GLFM-style fenced-div admonitions (`:::note ... :::`) before block splitting,
179
+ // since their body may itself contain blank lines that would otherwise fragment them.
180
+ // The `> [!NOTE]` GitHub form doesn't need this - it's detected inline in the blockquote
181
+ // branch below, since a `>`-prefixed block never contains a real blank line.
182
+ const admonitionBlocks = [];
183
+ textStr = textStr.replace(/^:::(\w+)[ \t]*\n([\s\S]*?)\n:::[ \t]*$/gm, (match, type, body) => {
184
+ const admonitionType = ADMONITION_TYPE_MAP[type.toLowerCase()];
185
+ if (!admonitionType)
186
+ return match; // Unrecognised type - leave as literal text.
187
+ const id = `__ADMONITION_${admonitionBlocks.length}__`;
188
+ admonitionBlocks.push(JSON.stringify({ admonitionType, body }));
189
+ return `\n\n${id}\n\n`;
190
+ });
191
+ // Extract footnote definitions (`[^id]: text`) before block splitting, since
192
+ // definitions conventionally live at the end of the document, after every place
193
+ // they're referenced - inline parsing below needs the full map upfront. The first line
194
+ // may be followed by continuation lines indented one level (4 spaces or a tab), which are
195
+ // dedented and joined onto the definition (Pandoc/GFM). A 4-space-indented block right after
196
+ // a definition is therefore read as its continuation rather than as a standalone code block.
197
+ // Supported (lossless) shape: contiguous continuation - the indented lines follow the
198
+ // definition with no blank line between them. Known limitation (6.E.1): a continuation
199
+ // separated from the definition by a BLANK line is not folded in - the regex below stops at
200
+ // the blank line, and the indented block after it re-parses as a fenced/indented code block on
201
+ // save. Multi-paragraph footnotes should therefore use the contiguous form.
202
+ const footnoteDefinitions = new Map();
203
+ // Every id a `[^id]` reference consumes, so definitions that are never referenced can be
204
+ // detected at the end and preserved rather than silently dropped (see the orphan sweep below).
205
+ const referencedFootnoteIds = new Set();
206
+ // One reused note node per referenced id. Repeated `[^id]` references are a single shared
207
+ // footnote in Markdown, so they must not each materialise a full copy of the body - the
208
+ // generators would otherwise renumber them to [^1]/[^2] and duplicate the definition. Office
209
+ // notes reach the generators as distinct objects even when they share a numeric id, so those
210
+ // stay separate; only genuinely shared Markdown references collapse.
211
+ const footnoteNodesById = new Map();
212
+ textStr = textStr.replace(/^\[\^([^\]]+)\]:[ \t]*(.*(?:\n(?: {4}|\t).*)*)$/gm, (_match, id, definition) => {
213
+ const dedented = String(definition)
214
+ .split('\n')
215
+ .map((line, i) => i === 0 ? line : line.replace(/^(?: {4}|\t)/, ''))
216
+ .join('\n')
217
+ .trim();
218
+ footnoteDefinitions.set(id, dedented);
219
+ return '';
220
+ });
221
+ // Extract Markdown Extra abbreviation definitions (`*[HTML]: Hypertext Markup Language`)
222
+ // before block splitting, for the same reason as footnotes: they conventionally live
223
+ // at the end of the document.
224
+ const abbreviationDefinitions = new Map();
225
+ textStr = textStr.replace(/^\*\[([^\]]+)\]:[ \t]*(.*)$/gm, (_match, abbr, definition) => {
226
+ abbreviationDefinitions.set(abbr, definition.trim());
227
+ return '';
228
+ });
229
+ // Extract link/image reference definitions (`[ref]: /url "title"`) before block
230
+ // splitting, for the same reason as footnotes/abbreviations: they conventionally
231
+ // live at the end of the document, after every place they're referenced. Keyed by
232
+ // trimmed/lowercased label, matching CommonMark's case-insensitive reference matching.
233
+ const linkDefinitions = new Map();
234
+ textStr = textStr.replace(/^\[([^\]]+)\]:[ \t]*(\S+)(?:[ \t]+"([^"]*)")?[ \t]*$/gm, (_match, label, url, title) => {
235
+ linkDefinitions.set(label.trim().toLowerCase(), { url, title });
236
+ return '';
237
+ });
238
+ // Parses a Pandoc-style attribute list body (the part inside `{...}`), e.g.
239
+ // `width=50% .centered` or `align=right`. Per MARKDOWN_DIALECT.md §15's Decisions,
240
+ // the vocabulary matches ImageMetadata/TableMetadata's own width/align fields;
241
+ // several class-name spellings are accepted on import for compatibility with
242
+ // hand-written content, but the generator only ever emits canonical `align=value`.
243
+ const parseAttributeList = (attrStr) => {
244
+ const result = {};
245
+ for (const token of attrStr.trim().split(/\s+/).filter(Boolean)) {
246
+ const kv = token.match(/^([a-zA-Z-]+)=(.+)$/);
247
+ if (kv) {
248
+ if (kv[1] === 'width')
249
+ result.width = kv[2];
250
+ else if (kv[1] === 'align' && ['left', 'center', 'right'].includes(kv[2]))
251
+ result.align = kv[2];
252
+ }
253
+ else if (token.startsWith('.')) {
254
+ const cls = token.slice(1).toLowerCase();
255
+ if (cls === 'left' || cls === 'align-left')
256
+ result.align = 'left';
257
+ else if (cls === 'center' || cls === 'centered' || cls === 'align-center')
258
+ result.align = 'center';
259
+ else if (cls === 'right' || cls === 'align-right')
260
+ result.align = 'right';
261
+ }
262
+ }
263
+ return result;
264
+ };
265
+ // Attribute list for an embed leaf directive `{id=... src=... width=... height=... align=...}`.
266
+ // Superset of parseAttributeList (adds id/src/height); space-separated `k=v` tokens.
267
+ const parseEmbedDirectiveAttrs = (attrStr) => {
268
+ const result = {};
269
+ for (const token of attrStr.trim().split(/\s+/).filter(Boolean)) {
270
+ const kv = token.match(/^([a-zA-Z-]+)=(.+)$/);
271
+ if (!kv)
272
+ continue;
273
+ const [, key, val] = kv;
274
+ if (key === 'id')
275
+ result.id = val;
276
+ else if (key === 'src')
277
+ result.src = val;
278
+ else if (key === 'width')
279
+ result.width = val;
280
+ else if (key === 'height')
281
+ result.height = val;
282
+ else if (key === 'align' && ['left', 'center', 'right'].includes(val))
283
+ result.align = val;
284
+ }
285
+ return result;
286
+ };
287
+ // Extracts a YouTube video id from any of its URL shapes (watch?v=, youtu.be/, /embed/,
288
+ // img.youtube.com/vi/). Returns undefined for a non-YouTube URL. Used only by the opt-in
289
+ // folk-form import (embedFolkForms).
290
+ const extractYoutubeId = (url) => {
291
+ if (!url || !/(?:youtu\.be|youtube(?:-nocookie)?\.com)/.test(url))
292
+ return undefined;
293
+ const m = url.match(/(?:youtu\.be\/|\/embed\/|[?&]v=|\/vi\/)([A-Za-z0-9_-]+)/);
294
+ return m ? m[1] : undefined;
295
+ };
296
+ const parseInline = (text, currentFormatting = {}) => {
297
+ const nodes = [];
298
+ const plainText = (t) => ({ type: 'text', text: t, formatting: Object.keys(currentFormatting).length > 0 ? { ...currentFormatting } : undefined });
299
+ // Builds the same image/link node shape regardless of whether the URL came from
300
+ // an inline `(url)` or a resolved reference definition - shared by the inline
301
+ // image/link branch and the two reference-style branches below.
302
+ // Split a Markdown inline destination `url "title"` (also `'title'` / `(title)`) into its
303
+ // URL and optional title. The inline parser previously kept the whole thing as the URL, so
304
+ // `[t](u "T")` produced href `u "T"`; reference-style `[t][id]` already split it correctly.
305
+ const splitUrlTitle = (raw) => {
306
+ const m = raw.trim().match(/^(.*?)\s+(?:"([^"]*)"|'([^']*)'|\(([^)]*)\))\s*$/);
307
+ return m ? { url: m[1].trim(), title: m[2] ?? m[3] ?? m[4] } : { url: raw };
308
+ };
309
+ const buildLinkOrImageNodes = (isImage, altText, rawUrl, attrsStr) => {
310
+ const { url, title } = splitUrlTitle(rawUrl);
311
+ if (isImage) {
312
+ // Pandoc-style attribute list immediately after an image, e.g. {width=50% .centered}
313
+ const attrs = attrsStr !== undefined ? parseAttributeList(attrsStr) : undefined;
314
+ if (url.startsWith('data:')) {
315
+ const dataMatch = url.match(/^data:([^;]+);base64,(.*)$/);
316
+ if (dataMatch && config.extractAttachments) {
317
+ const mimeType = dataMatch[1];
318
+ const data = dataMatch[2];
319
+ const name = `image_${attachments.length + 1}.${mimeType.split('/')[1]}`;
320
+ attachments.push({
321
+ type: 'image',
322
+ mimeType,
323
+ data,
324
+ name,
325
+ extension: mimeType.split('/')[1]
326
+ });
327
+ return [{ type: 'image', metadata: { attachmentName: name, altText, title, ...attrs } }];
328
+ }
329
+ }
330
+ return [{ type: 'image', metadata: { url, altText, title, ...attrs } }];
331
+ }
332
+ const linkNodes = parseInline(altText, currentFormatting);
333
+ linkNodes.forEach(n => {
334
+ if (n.type === 'text') {
335
+ n.metadata = { link: url, linkType: 'external', title };
336
+ }
337
+ });
338
+ return linkNodes;
339
+ };
340
+ // Regex matches (named groups): esc=escaped punctuation char | imgBang/imgAlt/imgUrl/imgAttrs=inline
341
+ // image or link | boldStar/boldUnderscore=bold | italicStar/italicUnderscore=italic | strike=strikethrough |
342
+ // codeFence/codeContent=inline code (backreferenced fence run, so a shorter embedded backtick run
343
+ // doesn't close the span early) | underline/subscript/superscript=HTML tag formatting |
344
+ // footnoteId | citationKey | wikiPage/wikiAlias | refBang/refText/refId=explicit or collapsed
345
+ // reference link/image `[text][ref]`/`[text][]` | shortBang/shortText=shortcut reference `[text]`
346
+ // (deliberately the most generic bracket pattern, so it must stay last among `[`-starting
347
+ // alternatives) | autolinkUrl=`<url>` autolink | mathInline.
348
+ //
349
+ // Named groups (rather than positional match[N] indices) mean adding a new alternative never
350
+ // requires renumbering every existing dispatch arm.
351
+ //
352
+ // Escape must be listed first since only a literal backslash can start that alternative, so it
353
+ // never shadows another branch; but a code span's match consumes its whole span atomically (the
354
+ // exec loop's lastIndex jumps past the entire matched span), so a backslash *inside* a code span
355
+ // is never independently offered to the escape branch regardless of listing order - CommonMark's
356
+ // "backslashes are not special inside code spans" rule holds by construction, not extra logic.
357
+ //
358
+ // Underscore emphasis has no CommonMark flanking-delimiter-run detection, so an intraword
359
+ // underscore (e.g. "foo_bar_baz") will incorrectly italicize - an accepted, documented
360
+ // simplification, not something this pass attempts to fix.
361
+ //
362
+ // Inline math requires no whitespace right after the opening $ or right before the
363
+ // closing $, the common heuristic (matching Pandoc/KaTeX) for avoiding false
364
+ // positives on currency like "$5 and $10".
365
+ const regex = /\\(?<esc>[!-\/:-@\[-`{-~])|(?<imgBang>!?)\[(?<imgAlt>.*?)\]\((?<imgUrl>.*?)\)(?:\{(?<imgAttrs>[^}]*)\})?|\*\*(?<boldStar>.+?)\*\*|__(?<boldUnderscore>.+?)__|\*(?<italicStar>.+?)\*|_(?<italicUnderscore>.+?)_|~~(?<strike>.+?)~~|==(?<highlight>.+?)==|(?<codeFence>`+)(?<codeContent>(?:(?!\k<codeFence>)[\s\S])+?)\k<codeFence>(?!`)|<u>(?<underline>.+?)<\/u>|<sub>(?<subscript>.+?)<\/sub>|<sup>(?<superscript>.+?)<\/sup>|(?<lineBreak><br\s*\/?>)|<span\s+style="(?<spanStyle>[^"]*)">(?<spanContent>.+?)<\/span>|\[\^(?<footnoteId>[^\]]+)\]|\[@(?<citationKey>[a-zA-Z0-9_:.-]+)\]|\[\[(?<wikiPage>[^\]|]+)(?:\|(?<wikiAlias>[^\]]+))?\]\]|(?<refBang>!?)\[(?<refText>[^\]]*)\]\[(?<refId>[^\]]*)\]|(?<shortBang>!?)\[(?<shortText>[^\]]+)\]|<(?<autolinkUrl>(?:https?|mailto):[^\s<>]+)>|\$(?!\s)(?<mathInline>[^$\n]+?)(?<!\s)\$/g;
366
+ let lastIndex = 0;
367
+ let match;
368
+ while ((match = regex.exec(text)) !== null) {
369
+ if (match.index > lastIndex) {
370
+ nodes.push(plainText(text.substring(lastIndex, match.index)));
371
+ }
372
+ const g = match.groups;
373
+ if (g.esc !== undefined) { // Backslash-escaped punctuation
374
+ nodes.push(plainText(g.esc));
375
+ }
376
+ else if (g.imgAlt !== undefined) { // Image or Link
377
+ nodes.push(...buildLinkOrImageNodes(g.imgBang === '!', g.imgAlt, g.imgUrl, g.imgAttrs));
378
+ }
379
+ else if (g.boldStar !== undefined) { // Bold (**)
380
+ nodes.push(...parseInline(g.boldStar, { ...currentFormatting, bold: true }));
381
+ }
382
+ else if (g.boldUnderscore !== undefined) { // Bold (__)
383
+ nodes.push(...parseInline(g.boldUnderscore, { ...currentFormatting, bold: true }));
384
+ }
385
+ else if (g.italicStar !== undefined) { // Italic (*)
386
+ nodes.push(...parseInline(g.italicStar, { ...currentFormatting, italic: true }));
387
+ }
388
+ else if (g.italicUnderscore !== undefined) { // Italic (_)
389
+ nodes.push(...parseInline(g.italicUnderscore, { ...currentFormatting, italic: true }));
390
+ }
391
+ else if (g.strike !== undefined) { // Strikethrough
392
+ nodes.push(...parseInline(g.strike, { ...currentFormatting, strikethrough: true }));
393
+ }
394
+ else if (g.highlight !== undefined) { // ==highlight== (Obsidian/extended); additive on import
395
+ nodes.push(...parseInline(g.highlight, { ...currentFormatting, backgroundColor: '#ffff00' }));
396
+ }
397
+ else if (g.codeContent !== undefined) { // Inline code (any matching backtick-run length)
398
+ nodes.push({ type: 'text', text: g.codeContent, formatting: { ...currentFormatting, font: 'monospace' } });
399
+ }
400
+ else if (g.underline !== undefined) { // Underline
401
+ nodes.push(...parseInline(g.underline, { ...currentFormatting, underline: true }));
402
+ }
403
+ else if (g.subscript !== undefined) { // Subscript
404
+ nodes.push(...parseInline(g.subscript, { ...currentFormatting, subscript: true }));
405
+ }
406
+ else if (g.superscript !== undefined) { // Superscript
407
+ nodes.push(...parseInline(g.superscript, { ...currentFormatting, superscript: true }));
408
+ }
409
+ else if (g.lineBreak !== undefined) { // Raw inline <br>/<br/>/<br /> - a hard line break.
410
+ // MarkdownGenerator emits a raw <br> for a line break inside a table cell (a GFM pipe
411
+ // cell can't hold a newline), so the parser must read it back symmetrically as a break
412
+ // node instead of escaping it to literal `&lt;br&gt;` text and destroying it.
413
+ nodes.push({ type: 'break', metadata: { breakType: 'carriageReturn' } });
414
+ }
415
+ else if (g.spanContent !== undefined) { // Inline styled span: color / highlight / font-size
416
+ const style = g.spanStyle || '';
417
+ const styled = { ...currentFormatting };
418
+ // Anchor each property to a declaration boundary so `color` doesn't match inside
419
+ // `background-color`.
420
+ const prop = (name) => {
421
+ const m = style.match(new RegExp(`(?:^|;)\\s*${name}\\s*:\\s*([^;]+)`, 'i'));
422
+ return m ? m[1].trim() : undefined;
423
+ };
424
+ const color = prop('color');
425
+ if (color)
426
+ styled.color = color;
427
+ const background = prop('background-color');
428
+ if (background)
429
+ styled.backgroundColor = background;
430
+ const size = prop('font-size');
431
+ if (size)
432
+ styled.size = size;
433
+ nodes.push(...parseInline(g.spanContent, styled));
434
+ }
435
+ else if (g.footnoteId !== undefined) { // Footnote reference
436
+ const noteId = g.footnoteId;
437
+ referencedFootnoteIds.add(noteId);
438
+ // Reuse the same note object across every reference to this id (see the map's
439
+ // declaration): the first reference builds the body, the rest share it, so the
440
+ // generators assign one key and emit one definition.
441
+ let noteNode = footnoteNodesById.get(noteId);
442
+ if (!noteNode) {
443
+ const definition = footnoteDefinitions.get(noteId);
444
+ const noteChildren = definition !== undefined ? parseInline(definition) : [];
445
+ noteNode = {
446
+ type: 'note',
447
+ text: noteChildren.map(c => c.text || '').join(''),
448
+ children: noteChildren,
449
+ metadata: { noteType: 'footnote', noteId }
450
+ };
451
+ footnoteNodesById.set(noteId, noteNode);
452
+ }
453
+ // Notes attach to the preceding text node (matches WordParser's convention);
454
+ // fall back to an empty text node if the reference opens the inline run.
455
+ if (nodes.length > 0) {
456
+ const target = nodes[nodes.length - 1];
457
+ if (!target.notes)
458
+ target.notes = [];
459
+ target.notes.push(noteNode);
460
+ }
461
+ else {
462
+ nodes.push({ type: 'text', text: '', notes: [noteNode] });
463
+ }
464
+ }
465
+ else if (g.citationKey !== undefined) { // Citation reference
466
+ nodes.push({ type: 'text', text: g.citationKey, metadata: { citationKey: g.citationKey } });
467
+ }
468
+ else if (g.wikiPage !== undefined) { // Wikilink
469
+ const page = g.wikiPage.trim();
470
+ const alias = g.wikiAlias?.trim();
471
+ nodes.push({ type: 'text', text: alias || page, metadata: { link: page, linkType: 'internal', wikilink: true } });
472
+ }
473
+ else if (g.refText !== undefined) { // Explicit/collapsed reference link or image: [text][ref] / [text][]
474
+ const isImage = g.refBang === '!';
475
+ const label = g.refText;
476
+ const refId = (g.refId || label).trim().toLowerCase();
477
+ const def = linkDefinitions.get(refId);
478
+ if (def) {
479
+ nodes.push(...buildLinkOrImageNodes(isImage, label, def.url));
480
+ }
481
+ else {
482
+ // Not a known reference - preserve the literal bracketed text unchanged.
483
+ nodes.push(plainText(text.substring(match.index, match.index + match[0].length)));
484
+ }
485
+ }
486
+ else if (g.shortText !== undefined) { // Shortcut reference: [text]
487
+ const isImage = g.shortBang === '!';
488
+ const label = g.shortText;
489
+ const def = linkDefinitions.get(label.trim().toLowerCase());
490
+ if (def) {
491
+ nodes.push(...buildLinkOrImageNodes(isImage, label, def.url));
492
+ }
493
+ else {
494
+ // Not a known reference - ordinary bracketed prose, preserve unchanged.
495
+ nodes.push(plainText(`${g.shortBang}[${label}]`));
496
+ }
497
+ }
498
+ else if (g.autolinkUrl !== undefined) { // <url> autolink
499
+ const url = g.autolinkUrl;
500
+ nodes.push({ type: 'text', text: url, formatting: Object.keys(currentFormatting).length > 0 ? { ...currentFormatting } : undefined, metadata: { link: url, linkType: 'external' } });
501
+ }
502
+ else if (g.mathInline !== undefined) { // Inline math
503
+ nodes.push({ type: 'code', text: g.mathInline, metadata: { math: 'inline' } });
504
+ }
505
+ lastIndex = regex.lastIndex;
506
+ }
507
+ if (lastIndex < text.length) {
508
+ nodes.push(plainText(text.substring(lastIndex)));
509
+ }
510
+ return applyAbbreviations(decodeHtmlEntities(nodes));
511
+ };
512
+ const escapeRegExpChars = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
513
+ // A deliberately small, common-entity lookup (not the full HTML5 named-character-
514
+ // reference table) - keeps this a plain object rather than needing a dependency.
515
+ const NAMED_HTML_ENTITIES = {
516
+ amp: '&', lt: '<', gt: '>', quot: '"', apos: '\'', nbsp: ' ',
517
+ copy: '©', reg: '®', mdash: '—', ndash: '–', hellip: '…'
518
+ };
519
+ // Decodes HTML named entities and numeric/hex character references (&#NN;/&#xHH;)
520
+ // in plain text nodes, skipping monospace (inline code) nodes since CommonMark does
521
+ // not decode entities inside code spans. The regex only ever matches syntactically
522
+ // well-formed &name;/&#NN;/&#xHH; tokens to begin with, so ordinary text containing
523
+ // a bare "&" (e.g. "Q&A", "Fish & Chips") never matches at all; an unrecognized-but-
524
+ // well-formed token (e.g. "&foo;") is left untouched on a lookup miss - no risk of
525
+ // double-decoding or corrupting text that merely resembles an entity.
526
+ const decodeHtmlEntities = (nodes) => {
527
+ return nodes.map(node => {
528
+ if (node.type !== 'text' || !node.text || node.formatting?.font === 'monospace')
529
+ return node;
530
+ const text = node.text.replace(/&(#\d+|#[xX][0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/g, (full, ref) => {
531
+ if (ref[0] === '#') {
532
+ const codePoint = ref[1].toLowerCase() === 'x' ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1), 10);
533
+ return (isNaN(codePoint) || codePoint < 0 || codePoint > 0x10FFFF) ? full : String.fromCodePoint(codePoint);
534
+ }
535
+ return NAMED_HTML_ENTITIES[ref] ?? full;
536
+ });
537
+ return text === node.text ? node : { ...node, text };
538
+ });
539
+ };
540
+ // Splits abbreviation occurrences out of plain text nodes so they carry
541
+ // TextMetadata.abbreviationTitle, rendered as <abbr title> in HTML/editor output.
542
+ const applyAbbreviations = (nodes) => {
543
+ if (abbreviationDefinitions.size === 0)
544
+ return nodes;
545
+ const pattern = new RegExp(`\\b(${[...abbreviationDefinitions.keys()].map(escapeRegExpChars).join('|')})\\b`, 'g');
546
+ const result = [];
547
+ for (const node of nodes) {
548
+ if (node.type !== 'text' || !node.text || node.metadata) {
549
+ result.push(node);
550
+ continue;
551
+ }
552
+ let lastIndex = 0;
553
+ let match;
554
+ let matched = false;
555
+ pattern.lastIndex = 0;
556
+ while ((match = pattern.exec(node.text)) !== null) {
557
+ matched = true;
558
+ if (match.index > lastIndex) {
559
+ result.push({ type: 'text', text: node.text.substring(lastIndex, match.index), formatting: node.formatting });
560
+ }
561
+ result.push({
562
+ type: 'text',
563
+ text: match[0],
564
+ formatting: node.formatting,
565
+ metadata: { abbreviationTitle: abbreviationDefinitions.get(match[0]) }
566
+ });
567
+ lastIndex = pattern.lastIndex;
568
+ }
569
+ if (!matched) {
570
+ result.push(node);
571
+ continue;
572
+ }
573
+ if (lastIndex < node.text.length) {
574
+ result.push({ type: 'text', text: node.text.substring(lastIndex), formatting: node.formatting });
575
+ }
576
+ }
577
+ return result;
578
+ };
579
+ // Splits a paragraph-shaped block's internal lines into inline-parsed content,
580
+ // inserting a real 'break' node for a hard line break (a line ending in 2+ trailing
581
+ // spaces or a trailing backslash) instead of collapsing it to a space. A plain single
582
+ // newline with no such marker is still a soft break and collapses to a space,
583
+ // unchanged from before - CommonMark itself renders a soft break as a space/newline.
584
+ const splitParagraphLines = (block) => {
585
+ const lines = block.split('\n');
586
+ const children = [];
587
+ lines.forEach((line, i) => {
588
+ const hardBreak = /(?: {2,}|\\)$/.test(line);
589
+ children.push(...parseInline(line.replace(/(?: {2,}|\\)$/, '')));
590
+ if (i < lines.length - 1) {
591
+ if (hardBreak) {
592
+ children.push({ type: 'break', metadata: { breakType: 'carriageReturn' } });
593
+ }
594
+ else {
595
+ children.push({ type: 'text', text: ' ' });
596
+ }
597
+ }
598
+ });
599
+ return children;
600
+ };
601
+ // Builds an admonition node from its raw body text, splitting on blank lines into
602
+ // paragraph children. v1 only supports inline content inside admonitions (no nested
603
+ // lists/headings/code) - acceptable per the roadmap's first cut.
604
+ const buildAdmonitionNode = (admonitionType, body, sourceSyntax) => {
605
+ const paragraphs = body.split(/\n\n+/).map(p => p.trim()).filter(Boolean);
606
+ const children = paragraphs.map(p => ({
607
+ type: 'paragraph',
608
+ children: splitParagraphLines(p)
609
+ }));
610
+ return {
611
+ type: 'admonition',
612
+ metadata: { admonitionType, sourceSyntax },
613
+ children
614
+ };
615
+ };
616
+ const rawBlocks = textStr.split(/\n\n+/);
617
+ const blocks = [];
618
+ // Sub-split blocks that contain headings or lists without double newlines
619
+ for (const rawBlock of rawBlocks) {
620
+ if (!rawBlock.trim())
621
+ continue;
622
+ // Match headings or lists that might be joined with other text via single newline
623
+ const lines = rawBlock.split('\n');
624
+ let currentSubBlock = [];
625
+ // Tracks whether we're currently "inside" a list (a list-item line, or an
626
+ // indented continuation line right after one) so a continuation line doesn't
627
+ // itself get treated as the boundary that splits the list into a new block -
628
+ // see the "Lists" block dispatch below, which merges such a line into the
629
+ // previous item's content instead of dropping it.
630
+ let inList = false;
631
+ for (const line of lines) {
632
+ (0, errorUtils_js_1.checkAbortSignal)(config.abortSignal);
633
+ const isHeading = !!line.match(/^(?:<a[^>]*><\/a>)*\s*#{1,6}\s+/);
634
+ const isList = !!line.match(/^(\s*)([-*+]|\d+[.)])\s+/);
635
+ const isHtmlTag = !!line.match(/^<\/?div[^>]*>$/i);
636
+ // A non-list, non-blank, indented (>=2 columns or a tab) line encountered
637
+ // while already inside a list is a continuation of the current item, not a
638
+ // new construct. Scoped to a single such line at a time (no nested
639
+ // code/blockquote/sub-list/multi-paragraph items - those require un-splitting
640
+ // already-separated raw blocks, out of scope here).
641
+ const isContinuation = !isList && inList && /^(?: {2,}|\t)/.test(line) && line.trim().length > 0;
642
+ const staysInListMode = isList || isContinuation;
643
+ // Split if:
644
+ // 1. Current line is a heading
645
+ // 2. Current line enters or leaves "list mode" relative to the previous line
646
+ // 3. Current line is an HTML tag (div)
647
+ if ((isHeading || isHtmlTag || (staysInListMode !== inList)) && currentSubBlock.length > 0) {
648
+ blocks.push(currentSubBlock.join('\n'));
649
+ currentSubBlock = [];
650
+ }
651
+ currentSubBlock.push(line);
652
+ inList = staysInListMode;
653
+ // Headings and HTML tags are single-line blocks for our state machine
654
+ if (isHeading || isHtmlTag) {
655
+ blocks.push(currentSubBlock.join('\n'));
656
+ currentSubBlock = [];
657
+ inList = false;
658
+ }
659
+ }
660
+ if (currentSubBlock.length > 0) {
661
+ blocks.push(currentSubBlock.join('\n'));
662
+ }
663
+ }
664
+ // Re-join a list block that a blank line tore away from its parent. The generator's older
665
+ // loose output (`- a\n\n\n - a1`) and foreign editors both split a nested item into its
666
+ // own block; left apart, the child's leading indent is stripped by the per-block `trim()`
667
+ // below and it reparses as a flat top-level item under a fresh listId. Merge a block back
668
+ // into the preceding one only when the previous block is itself a list (its FIRST line is a
669
+ // marker - the sub-splitter guarantees such a block holds only marker/continuation lines) and
670
+ // the current block OPENS with an INDENTED marker. An unindented `- b` after a blank line is
671
+ // deliberately left split (a flat loose list keeps its own listId), and anything that is not
672
+ // an indented marker (continuation text, indented code, placeholders) never triggers a merge.
673
+ const listMarkerStart = /^(\s*)([-*+]|\d+[.)])\s+/;
674
+ const indentedMarkerStart = /^(?: {2,}|\t)\s*(?:[-*+]|\d+[.)])\s+/;
675
+ const mergedBlocks = [];
676
+ for (const block of blocks) {
677
+ const prev = mergedBlocks[mergedBlocks.length - 1];
678
+ if (prev !== undefined
679
+ && listMarkerStart.test(prev.split('\n', 1)[0])
680
+ && indentedMarkerStart.test(block.split('\n', 1)[0])) {
681
+ mergedBlocks[mergedBlocks.length - 1] = `${prev}\n${block}`;
682
+ }
683
+ else {
684
+ mergedBlocks.push(block);
685
+ }
686
+ }
687
+ blocks.length = 0;
688
+ blocks.push(...mergedBlocks);
689
+ let listIdCounter = 1;
690
+ let currentAlignment = undefined;
691
+ for (let block of blocks) {
692
+ // Preserved before the generic trim() below, which strips the first line's
693
+ // leading indentation - the indented-code-block check further down needs every
694
+ // line's original indentation, including the first.
695
+ const untrimmedBlock = block;
696
+ block = block.trim();
697
+ if (!block)
698
+ continue;
699
+ // Standalone anchor-only block: one or more empty `<a name|id="…"></a>` tags on their
700
+ // own line (bookmark targets the MarkdownGenerator emits just before a heading/paragraph).
701
+ // Capture them as a placeholder so the post-loop pass can re-attach them to the following
702
+ // node's anchorIds — otherwise the tag-opening `<` is escaped and they render as visible text.
703
+ if (/^(?:\s*<a\s[^>]*>\s*<\/a>\s*)+$/i.test(block)) {
704
+ const anchorIds = [];
705
+ for (const m of block.matchAll(/<a\s[^>]*\b(?:name|id)="([^"]*)"/gi)) {
706
+ if (m[1])
707
+ anchorIds.push(m[1]);
708
+ }
709
+ if (anchorIds.length > 0) {
710
+ content.push({ type: ANCHOR_PLACEHOLDER, metadata: { anchorIds }, children: [] });
711
+ continue;
712
+ }
713
+ }
714
+ // Check for alignment wrapper start/end
715
+ const alignStartMatch = block.match(/^<div\s+(?:style="text-align:\s*(left|center|right|justify);?"|align="(left|center|right|justify)")>$/i);
716
+ if (alignStartMatch) {
717
+ currentAlignment = (alignStartMatch[1] || alignStartMatch[2]).toLowerCase();
718
+ continue;
719
+ }
720
+ if (block.match(/^<\/div>$/i)) {
721
+ currentAlignment = undefined;
722
+ continue;
723
+ }
724
+ let alignment = currentAlignment;
725
+ // Check for single-line alignment wrapper (for compatibility)
726
+ const alignMatch = block.match(/^<div\s+(?:style="text-align:\s*(left|center|right|justify);?"|align="(left|center|right|justify)")>\s*([\s\S]*?)\s*<\/div>$/i);
727
+ if (alignMatch) {
728
+ alignment = (alignMatch[1] || alignMatch[2]).toLowerCase();
729
+ block = alignMatch[3];
730
+ }
731
+ // Embed leaf directive (remark-directive family): `::youtube[Label]{id=... width=... align=...}`
732
+ // or `::embed[Label]{src=... width=... height=... align=...}`. Only these two names are
733
+ // recognised; any other `::name` stays literal text (no catch-all). `::youtube` renders from
734
+ // a validated id via a fixed template, so it is unconditional; `::embed` carries an arbitrary
735
+ // src, so it is gated behind `preserveIframes` (the trust input) exactly like a raw <iframe>,
736
+ // and stays literal text otherwise. New input only; nothing that parsed before changes.
737
+ const embedDirectiveMatch = block.match(/^::(youtube|embed)(?:\[([^\]]*)\])?\{([^}]*)\}$/);
738
+ if (embedDirectiveMatch) {
739
+ const kind = embedDirectiveMatch[1];
740
+ const label = (embedDirectiveMatch[2] || '').trim() || undefined;
741
+ const attrs = parseEmbedDirectiveAttrs(embedDirectiveMatch[3]);
742
+ if (kind === 'youtube' && attrs.id) {
743
+ const embedUrl = `https://www.youtube.com/watch?v=${attrs.id}`;
744
+ content.push({
745
+ type: 'embed',
746
+ text: embedUrl,
747
+ metadata: { embedType: 'youtube', videoId: attrs.id, url: embedUrl, width: attrs.width, align: attrs.align, label }
748
+ });
749
+ continue;
750
+ }
751
+ if (kind === 'embed' && attrs.src && (0, sanitize_js_1.iframeAllowed)(attrs.src, config.htmlParserConfig?.preserveIframes)) {
752
+ content.push({
753
+ type: 'embed',
754
+ text: attrs.src,
755
+ metadata: { embedType: 'iframe', url: attrs.src, width: attrs.width, height: attrs.height, align: attrs.align, label }
756
+ });
757
+ continue;
758
+ }
759
+ // Recognised name but not a usable/allowed directive: fall through so the line becomes
760
+ // ordinary text rather than being dropped.
761
+ }
762
+ // Ambiguous "folk" embed forms, imported only under the opt-in (embedFolkForms), since
763
+ // auto-upgrading an image/link to an embed is a heuristic that could mangle a genuine image
764
+ // link. Both become a safe youtube embed (rendered from the validated id). A standalone line
765
+ // only; anything not matching falls through to ordinary image/link parsing.
766
+ if (config.htmlParserConfig?.embedFolkForms) {
767
+ // Clickable thumbnail: [![alt](thumb)](watch), youtube when either URL is a youtube link.
768
+ const thumbMatch = block.match(/^\[!\[([^\]]*)\]\(([^)\s]+)\)\]\(([^)\s]+)\)$/);
769
+ if (thumbMatch) {
770
+ const fid = extractYoutubeId(thumbMatch[2]) || extractYoutubeId(thumbMatch[3]);
771
+ if (fid) {
772
+ const embedUrl = `https://www.youtube.com/watch?v=${fid}`;
773
+ content.push({ type: 'embed', text: embedUrl, metadata: { embedType: 'youtube', videoId: fid, url: embedUrl, label: thumbMatch[1].trim() || undefined } });
774
+ continue;
775
+ }
776
+ }
777
+ // Obsidian-style: a standalone image whose URL is a youtube link.
778
+ const obsMatch = block.match(/^!\[([^\]]*)\]\(([^)\s]+)\)$/);
779
+ if (obsMatch) {
780
+ const fid = extractYoutubeId(obsMatch[2]);
781
+ if (fid) {
782
+ const embedUrl = `https://www.youtube.com/watch?v=${fid}`;
783
+ content.push({ type: 'embed', text: embedUrl, metadata: { embedType: 'youtube', videoId: fid, url: embedUrl, label: obsMatch[1].trim() || undefined } });
784
+ continue;
785
+ }
786
+ }
787
+ }
788
+ // YouTube embed fallback: MarkdownGenerator's 'embed' case emits a single-line
789
+ // <div data-youtube-video="ID" data-width="…" data-align="…"></div> when fallbackToHtml
790
+ // is on; recognise it here so a saved-then-reopened .md keeps the video.
791
+ const youtubeMatch = block.match(/^<div\s+data-youtube-video="([^"]*)"([^>]*)>\s*<\/div>$/i);
792
+ if (youtubeMatch) {
793
+ const videoId = youtubeMatch[1];
794
+ const attrsStr = youtubeMatch[2];
795
+ const widthMatch = attrsStr.match(/data-width="([^"]*)"/i);
796
+ const youtubeAlignMatch = attrsStr.match(/data-align="([^"]*)"/i);
797
+ const youtubeLabelMatch = attrsStr.match(/data-embed-label="([^"]*)"/i);
798
+ const embedAlign = youtubeAlignMatch && ['left', 'center', 'right'].includes(youtubeAlignMatch[1]) ? youtubeAlignMatch[1] : undefined;
799
+ const embedUrl = videoId ? `https://www.youtube.com/watch?v=${videoId}` : undefined;
800
+ content.push({
801
+ type: 'embed',
802
+ // Childless nodes need .text so generic AST consumers (toText, chunking)
803
+ // don't silently drop them.
804
+ text: embedUrl,
805
+ metadata: {
806
+ embedType: 'youtube',
807
+ videoId,
808
+ url: embedUrl,
809
+ width: widthMatch?.[1],
810
+ align: embedAlign,
811
+ label: youtubeLabelMatch?.[1]
812
+ }
813
+ });
814
+ continue;
815
+ }
816
+ // Generic iframe fallback: MarkdownGenerator's 'embed' case emits a single-line
817
+ // <iframe src="…"></iframe> for a preserved iframe when fallbackToHtml is on. Recognise
818
+ // it only when the caller opted into iframe preservation, so default parsing is unchanged.
819
+ const iframeMatch = block.match(/^<iframe\s+([^>]*?)\/?>(?:\s*<\/iframe>)?$/i);
820
+ if (iframeMatch) {
821
+ const attrsStr = iframeMatch[1];
822
+ // The emitted <iframe> HTML-escapes its attribute values (sanitizeUrl -> escapeHtml), so
823
+ // decode them back; otherwise the src double-escapes (`&amp;` -> `&amp;amp;`) and its
824
+ // query string is corrupted a little more on every save/reload cycle. `&amp;` is decoded
825
+ // last so a genuinely double-escaped value only unwinds one level per parse.
826
+ const decodeAttr = (s) => (s || '')
827
+ .replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"')
828
+ .replace(/&#39;/g, '\'').replace(/&amp;/g, '&');
829
+ const src = decodeAttr(attrsStr.match(/\bsrc="([^"]*)"/i)?.[1]);
830
+ const width = attrsStr.match(/\bwidth="([^"]*)"/i)?.[1];
831
+ const height = attrsStr.match(/\bheight="([^"]*)"/i)?.[1];
832
+ // A YouTube iframe is recognised the same way the HTML parser does it (host + id
833
+ // capture), BEFORE and INDEPENDENT of the preserveIframes gate, so the same iframe
834
+ // yields the same 'youtube' embed whichever parser sees it. Only a generic (non-YouTube)
835
+ // iframe is gated behind preserveIframes and kept as an 'iframe' embed.
836
+ const ytMatch = src && /youtube(?:-nocookie)?\.com/.test(src) ? src.match(/(?:embed\/|v=)([^&?/\s]+)/) : null;
837
+ if (ytMatch) {
838
+ const embedUrl = `https://www.youtube.com/watch?v=${ytMatch[1]}`;
839
+ content.push({
840
+ type: 'embed',
841
+ text: embedUrl,
842
+ metadata: {
843
+ embedType: 'youtube',
844
+ videoId: ytMatch[1],
845
+ url: embedUrl,
846
+ width: width !== undefined ? decodeAttr(width) : undefined,
847
+ height: height !== undefined ? decodeAttr(height) : undefined
848
+ }
849
+ });
850
+ continue;
851
+ }
852
+ if (src && (0, sanitize_js_1.iframeAllowed)(src, config.htmlParserConfig?.preserveIframes)) {
853
+ content.push({
854
+ type: 'embed',
855
+ text: src,
856
+ metadata: {
857
+ embedType: 'iframe',
858
+ url: src,
859
+ width: width !== undefined ? decodeAttr(width) : undefined,
860
+ height: height !== undefined ? decodeAttr(height) : undefined
861
+ }
862
+ });
863
+ continue;
864
+ }
865
+ }
866
+ // Code Block
867
+ const codeMatch = block.match(/^__CODE_BLOCK_(\d+)__$/);
868
+ if (codeMatch) {
869
+ const data = JSON.parse(codeBlocks[parseInt(codeMatch[1])]);
870
+ content.push({
871
+ type: 'code',
872
+ text: data.code,
873
+ metadata: { language: data.lang }
874
+ });
875
+ continue;
876
+ }
877
+ // GLFM-style fenced-div admonition, extracted to a placeholder above
878
+ const admonitionBlockMatch = block.match(/^__ADMONITION_(\d+)__$/);
879
+ if (admonitionBlockMatch) {
880
+ const data = JSON.parse(admonitionBlocks[parseInt(admonitionBlockMatch[1])]);
881
+ content.push(buildAdmonitionNode(data.admonitionType, data.body, 'gitlab'));
882
+ continue;
883
+ }
884
+ // Block math ($$...$$), extracted to a placeholder above
885
+ const mathBlockMatch = block.match(/^__MATH_BLOCK_(\d+)__$/);
886
+ if (mathBlockMatch) {
887
+ content.push({
888
+ type: 'code',
889
+ text: mathBlocks[parseInt(mathBlockMatch[1])],
890
+ metadata: { math: 'block' }
891
+ });
892
+ continue;
893
+ }
894
+ // Heading (allowing for leading HTML anchors and trailing {#anchor})
895
+ const headingMatch = block.match(/^((?:<a[^>]*><\/a>)*)\s*(#{1,6})\s+(.*?)(?:\s+\{#([^}]+)\})?\s*$/s);
896
+ if (headingMatch) {
897
+ const leadingAnchorsRaw = headingMatch[1];
898
+ const rawText = headingMatch[3];
899
+ const explicitAnchor = headingMatch[4];
900
+ const anchorIds = [];
901
+ if (leadingAnchorsRaw) {
902
+ const idMatches = leadingAnchorsRaw.matchAll(/<a\s[^>]*\b(?:name|id)="([^"]+)"/gi);
903
+ for (const m of idMatches)
904
+ anchorIds.push(m[1]);
905
+ }
906
+ if (explicitAnchor)
907
+ anchorIds.push(explicitAnchor);
908
+ const children = parseInline(rawText);
909
+ content.push({
910
+ type: 'heading',
911
+ text: children.map(c => c.text || '').join(''),
912
+ metadata: {
913
+ level: headingMatch[2].length,
914
+ alignment,
915
+ anchorIds: anchorIds.length > 0 ? anchorIds : undefined
916
+ },
917
+ children
918
+ });
919
+ continue;
920
+ }
921
+ // Setext heading (Text\n=== or Text\n---): a line of text immediately followed
922
+ // by a lone `=`/`-` underline with no blank line between them. By the time a
923
+ // block reaches this point, the sub-splitter above has already separated out any
924
+ // genuinely blank-line-preceded thematic break into its own isolated block (which
925
+ // has no preceding text line to combine with here), so this only fires for the
926
+ // ambiguous "text directly above a dash/equals-only line" shape setext needs.
927
+ // Scoped to a single line immediately above the underline becoming the heading
928
+ // text; multi-line setext text (CommonMark's "Foo\nbar\n===" merging into one
929
+ // heading) is an explicitly out-of-scope simplification - any earlier lines in
930
+ // the block are pushed as a separate paragraph first.
931
+ const setextMatch = block.match(/^([\s\S]*)\n([=]+|-+)[ \t]*$/);
932
+ if (setextMatch) {
933
+ const lines = setextMatch[1].split('\n');
934
+ const headingLine = lines[lines.length - 1];
935
+ const earlierLines = lines.slice(0, -1).join('\n').trim();
936
+ if (earlierLines) {
937
+ content.push({
938
+ type: 'paragraph',
939
+ metadata: { alignment },
940
+ children: splitParagraphLines(earlierLines)
941
+ });
942
+ }
943
+ const children = parseInline(headingLine);
944
+ content.push({
945
+ type: 'heading',
946
+ text: children.map(c => c.text || '').join(''),
947
+ metadata: { level: setextMatch[2][0] === '=' ? 1 : 2, alignment },
948
+ children
949
+ });
950
+ continue;
951
+ }
952
+ // Blockquote
953
+ const quoteMatch = block.match(/^>\s+(.*)$/s);
954
+ if (quoteMatch) {
955
+ // [ \t]? (not \s+) so a bare ">" paragraph-separator line (used between
956
+ // multi-paragraph admonition bodies) also dequotes to an empty line. Repeat
957
+ // until no line still starts with ">" so arbitrarily-nested blockquotes
958
+ // (`> > quoted`, `> > > quoted`, ...) are fully unwrapped rather than only
959
+ // stripping one level.
960
+ let dequoted = quoteMatch[1];
961
+ while (/^>/m.test(dequoted)) {
962
+ dequoted = dequoted.replace(/^>[ \t]?/gm, '');
963
+ }
964
+ // GitHub-style admonition: `> [!NOTE]` on the first quoted line.
965
+ const admonitionHeaderMatch = dequoted.match(/^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*\n?([\s\S]*)$/i);
966
+ if (admonitionHeaderMatch) {
967
+ const admonitionType = admonitionHeaderMatch[1].toLowerCase();
968
+ content.push(buildAdmonitionNode(admonitionType, admonitionHeaderMatch[2], 'github'));
969
+ continue;
970
+ }
971
+ content.push({
972
+ type: 'paragraph',
973
+ metadata: { style: 'Quote' },
974
+ children: parseInline(dequoted)
975
+ });
976
+ continue;
977
+ }
978
+ // Definition list (Markdown Extra / Pandoc / Kramdown): a term line followed by
979
+ // one or more ": definition" lines, e.g.:
980
+ // Term
981
+ // : Definition of the term.
982
+ const definitionListMatch = block.match(/^([^\n:][^\n]*)\n((?::[ \t]+.+(?:\n:[ \t]+.+)*))$/);
983
+ if (definitionListMatch) {
984
+ const term = definitionListMatch[1];
985
+ const definitions = definitionListMatch[2].split('\n').map(line => line.replace(/^:[ \t]+/, ''));
986
+ content.push({
987
+ type: 'definitionList',
988
+ children: [
989
+ { type: 'definitionTerm', children: parseInline(term) },
990
+ ...definitions.map(def => ({ type: 'definitionDescription', children: parseInline(def) }))
991
+ ]
992
+ });
993
+ continue;
994
+ }
995
+ // Lists
996
+ if (block.match(/^(\s*)([-*+]|\d+[.)])\s+/)) {
997
+ const lines = block.split('\n');
998
+ const listId = `md-list-${listIdCounter++}`;
999
+ const listCounters = new Map();
1000
+ // Relative indent stack (not a fixed-width divisor) so nesting level is
1001
+ // computed from what indentation actually appeared in this block, rather
1002
+ // than assuming a specific indent width. This makes the parser agnostic to
1003
+ // 2-space (hand-written), 4-space (this generator's own output), or
1004
+ // tab-indented (normalized to a 4-column stop) nested lists.
1005
+ const indentStack = [];
1006
+ // The most recently pushed list-item node, so a following indented
1007
+ // continuation line (see the sub-splitter above) can be merged into it
1008
+ // instead of being silently dropped.
1009
+ let lastListNode;
1010
+ for (const line of lines) {
1011
+ const match = line.match(/^(\s*)([-*+]|\d+[.)])\s+(.*)$/);
1012
+ if (match) {
1013
+ const rawIndent = match[1].replace(/\t/g, ' ').length;
1014
+ while (indentStack.length > 0 && rawIndent <= indentStack[indentStack.length - 1]) {
1015
+ indentStack.pop();
1016
+ }
1017
+ const level = indentStack.length;
1018
+ indentStack.push(rawIndent);
1019
+ // Purge any deeper levels' counters now that we're back at this
1020
+ // level - otherwise a nested sub-list under a later sibling item
1021
+ // would incorrectly continue a previous sibling's child numbering
1022
+ // instead of restarting at 0.
1023
+ for (const key of [...listCounters.keys()]) {
1024
+ if (key > level)
1025
+ listCounters.delete(key);
1026
+ }
1027
+ const marker = match[2];
1028
+ const isOrdered = !!marker.match(/\d+[.)]/);
1029
+ const listType = isOrdered ? 'ordered' : 'unordered';
1030
+ if (listCounters.get(level) === undefined) {
1031
+ if (isOrdered) {
1032
+ const startNum = parseInt(marker, 10);
1033
+ listCounters.set(level, isNaN(startNum) ? 0 : startNum - 1);
1034
+ }
1035
+ else {
1036
+ listCounters.set(level, 0);
1037
+ }
1038
+ }
1039
+ else {
1040
+ listCounters.set(level, listCounters.get(level) + 1);
1041
+ }
1042
+ let itemText = match[3];
1043
+ let isTask;
1044
+ let checked;
1045
+ const taskMatch = itemText.match(/^\[([ xX])\]\s+(.*)$/);
1046
+ if (taskMatch) {
1047
+ isTask = true;
1048
+ checked = taskMatch[1].toLowerCase() === 'x';
1049
+ itemText = taskMatch[2];
1050
+ }
1051
+ const children = parseInline(itemText);
1052
+ const listNode = {
1053
+ type: 'list',
1054
+ text: children.map(c => c.text || '').join(''),
1055
+ metadata: {
1056
+ listType,
1057
+ indentation: level,
1058
+ alignment: alignment || 'left',
1059
+ listId,
1060
+ itemIndex: listCounters.get(level),
1061
+ isTask,
1062
+ checked
1063
+ },
1064
+ children
1065
+ };
1066
+ content.push(listNode);
1067
+ lastListNode = listNode;
1068
+ }
1069
+ else if (lastListNode && line.trim().length > 0 && /^(?: {2,}|\t)/.test(line)) {
1070
+ // Indented continuation line: merge its inline content into the
1071
+ // previous item rather than dropping it. Scoped to a single such
1072
+ // line (no nested code/blockquote/sub-list/multi-paragraph items).
1073
+ const continuationChildren = parseInline(line.trim());
1074
+ lastListNode.children = [...(lastListNode.children || []), { type: 'text', text: ' ' }, ...continuationChildren];
1075
+ lastListNode.text = (lastListNode.children || []).map(c => c.text || '').join('');
1076
+ }
1077
+ }
1078
+ continue;
1079
+ }
1080
+ // Table (Simple Pipe or HTML)
1081
+ if ((block.includes('|') && block.match(/\n\s*\|?[-:| ]+\|?\s*\n/)) || block.includes('<table')) {
1082
+ // Pandoc-style trailing attribute list (`{align=right}`) immediately after the
1083
+ // table, or Kramdown's `{: align=right}` on its own following line - both land
1084
+ // in this same raw block since there's no blank line separating them.
1085
+ let tableAlign;
1086
+ const tableAttrLineMatch = block.match(/\n\{:?\s*([^}]*)\}\s*$/);
1087
+ if (tableAttrLineMatch) {
1088
+ tableAlign = parseAttributeList(tableAttrLineMatch[1]).align;
1089
+ block = block.slice(0, tableAttrLineMatch.index);
1090
+ }
1091
+ if (block.includes('<table')) {
1092
+ // Basic HTML table recognition (extracting rows/cells)
1093
+ const tableTagMatch = block.match(/<table([^>]*)>/i);
1094
+ const tableAlignMatch = tableTagMatch?.[1]?.match(/data-align=["']?(left|center|right)["']?/i);
1095
+ const rows = [];
1096
+ const trRegex = /<tr[^>]*>([\s\S]*?)<\/tr>/gi;
1097
+ let trMatch;
1098
+ while ((trMatch = trRegex.exec(block)) !== null) {
1099
+ const tdRegex = /<(?:td|th)([^>]*)>([\s\S]*?)<\/(?:td|th)>/gi;
1100
+ let tdMatch;
1101
+ const cells = [];
1102
+ while ((tdMatch = tdRegex.exec(trMatch[1])) !== null) {
1103
+ const attrs = tdMatch[1];
1104
+ const contentStr = tdMatch[2].trim();
1105
+ const colSpanMatch = attrs.match(/colspan=["']?(\d+)["']?/i);
1106
+ const rowSpanMatch = attrs.match(/rowspan=["']?(\d+)["']?/i);
1107
+ cells.push({
1108
+ type: 'cell',
1109
+ metadata: {
1110
+ colSpan: colSpanMatch ? parseInt(colSpanMatch[1]) : undefined,
1111
+ rowSpan: rowSpanMatch ? parseInt(rowSpanMatch[1]) : undefined
1112
+ },
1113
+ children: parseInline(contentStr.replace(/<[^>]*>/g, ''))
1114
+ });
1115
+ }
1116
+ if (cells.length > 0)
1117
+ rows.push({ type: 'row', children: cells });
1118
+ }
1119
+ const resolvedAlign = tableAlign || (tableAlignMatch ? tableAlignMatch[1].toLowerCase() : undefined);
1120
+ if (rows.length > 0) {
1121
+ content.push({
1122
+ type: 'table',
1123
+ metadata: resolvedAlign ? { align: resolvedAlign } : undefined,
1124
+ children: rows
1125
+ });
1126
+ continue;
1127
+ }
1128
+ }
1129
+ else {
1130
+ const lines = block.trim().split('\n');
1131
+ const rows = [];
1132
+ // Pre-scan the separator row for per-column GFM alignment (`:--` left, `:-:` center,
1133
+ // `--:` right; a bare `--` column has none), so every cell can carry its column's
1134
+ // alignment on CellMetadata.align (the header row precedes the separator, so a
1135
+ // per-cell pass alone could not see it).
1136
+ const sepLine = lines.find(l => l.match(/^\|?\s*:?-+:?\s*(?:\|\s*:?-+:?\s*)*\|?$/));
1137
+ const columnAligns = sepLine
1138
+ ? sepLine.replace(/^\||\|$/g, '').split('|').map(c => {
1139
+ const t = c.trim();
1140
+ const l = t.startsWith(':'), r = t.endsWith(':');
1141
+ return (l && r) ? 'center' : r ? 'right' : l ? 'left' : null;
1142
+ })
1143
+ : [];
1144
+ for (let i = 0; i < lines.length; i++) {
1145
+ if (lines[i].match(/^\|?\s*:?-+:?\s*(?:\|\s*:?-+:?\s*)*\|?$/))
1146
+ continue; // Separator row (per-cell `:?-+:?`, GFM-style; accepts short cells like `|-|-|`)
1147
+ const cellsStr = lines[i].replace(/^\||\|$/g, '').split('|');
1148
+ const cells = cellsStr.map((c, colIdx) => {
1149
+ // Recognize the MarkdownGenerator's own cell-alignment fallback,
1150
+ // `<div style="text-align: X">…</div>`, and lift it into an aligned
1151
+ // paragraph so it round-trips as alignment instead of being escaped to
1152
+ // visible text on regeneration. Unwrap wherever it sits (e.g. inside **…**).
1153
+ let cellText = c.trim();
1154
+ let cellAlign;
1155
+ cellText = cellText.replace(/<div\s+style="text-align:\s*(left|center|right|justify);?"\s*>([\s\S]*?)<\/div>/gi, (_m, a, inner) => { cellAlign = a.toLowerCase(); return inner; });
1156
+ const inline = parseInline(cellText, i === 0 ? { bold: true } : {});
1157
+ const colAlign = columnAligns[colIdx] ?? undefined;
1158
+ const cellMeta = colAlign ? { col: colIdx, align: colAlign } : undefined;
1159
+ if (cellAlign && cellAlign !== 'left') {
1160
+ return {
1161
+ type: 'cell',
1162
+ metadata: cellMeta,
1163
+ children: [{ type: 'paragraph', metadata: { alignment: cellAlign }, children: inline }]
1164
+ };
1165
+ }
1166
+ return { type: 'cell', metadata: cellMeta, children: inline };
1167
+ });
1168
+ rows.push({ type: 'row', children: cells });
1169
+ }
1170
+ // If every explicitly-aligned column agrees, also expose it as the table-level align,
1171
+ // so an editor that models one alignment per table (and HTML data-align) round-trips.
1172
+ const explicitAligns = columnAligns.filter((a) => a !== null);
1173
+ const uniformAlign = explicitAligns.length > 0 && explicitAligns.every(a => a === explicitAligns[0]) ? explicitAligns[0] : undefined;
1174
+ const resolvedTableAlign = tableAlign || uniformAlign;
1175
+ content.push({ type: 'table', metadata: resolvedTableAlign ? { align: resolvedTableAlign } : undefined, children: rows });
1176
+ continue;
1177
+ }
1178
+ }
1179
+ // Indented code block (4-space or tab indent on every non-blank line). Only
1180
+ // reaches this point once heading/blockquote/definition-list/list/table have
1181
+ // already failed to claim the block; since list continuation lines are now
1182
+ // handled inside the "Lists" branch above and the sub-splitter already isolates
1183
+ // list/heading content into their own blocks, a block that's uniformly indented
1184
+ // here is not a list by construction. A partially-indented block (some lines
1185
+ // indented, some not) falls through to Paragraph unchanged.
1186
+ {
1187
+ const codeLines = untrimmedBlock.split('\n');
1188
+ const nonBlankLines = codeLines.filter(l => l.trim().length > 0);
1189
+ if (nonBlankLines.length > 0 && nonBlankLines.every(l => /^(?: {4}|\t)/.test(l))) {
1190
+ const stripped = codeLines.map(l => l.replace(/^(?: {4}|\t)/, '')).join('\n');
1191
+ content.push({ type: 'code', text: stripped });
1192
+ continue;
1193
+ }
1194
+ }
1195
+ // Hr - a thematic break (horizontal rule), not a page break, so it survives a save as
1196
+ // `---` rather than collapsing to a bare newline.
1197
+ if (block.match(/^---+$|^\*\*\*+$|^___+$/)) {
1198
+ content.push({ type: 'break', metadata: { breakType: 'thematic' } });
1199
+ continue;
1200
+ }
1201
+ // Paragraph
1202
+ content.push({
1203
+ type: 'paragraph',
1204
+ metadata: { alignment },
1205
+ children: splitParagraphLines(block)
1206
+ });
1207
+ }
1208
+ // Fold standalone anchor placeholders into the following content node's anchorIds so a
1209
+ // bookmark target emitted on its own line round-trips as a real anchor. A trailing placeholder
1210
+ // with no following node attaches to the previous node instead; if the document is nothing but
1211
+ // anchors, they are dropped (there is no node to host them).
1212
+ if (content.some(n => n.type === ANCHOR_PLACEHOLDER)) {
1213
+ const merged = [];
1214
+ let carried = [];
1215
+ for (const node of content) {
1216
+ if (node.type === ANCHOR_PLACEHOLDER) {
1217
+ carried.push(...(node.metadata?.anchorIds || []));
1218
+ continue;
1219
+ }
1220
+ if (carried.length > 0) {
1221
+ const meta = node.metadata || (node.metadata = {});
1222
+ meta.anchorIds = [...carried, ...(meta.anchorIds || [])];
1223
+ carried = [];
1224
+ }
1225
+ merged.push(node);
1226
+ }
1227
+ if (carried.length > 0 && merged.length > 0) {
1228
+ const last = merged[merged.length - 1].metadata || (merged[merged.length - 1].metadata = {});
1229
+ last.anchorIds = [...(last.anchorIds || []), ...carried];
1230
+ }
1231
+ content.length = 0;
1232
+ content.push(...merged);
1233
+ }
1234
+ // Orphan footnote definitions (defined but never referenced) would otherwise vanish entirely -
1235
+ // a user who deletes a `[^x]` reference but keeps its `[^x]: ...` definition loses the
1236
+ // definition on the next save. Preserve them as trailing note nodes, marked `unreferenced` so
1237
+ // the generators route them into their footnotes section (not inline) and emit no citation
1238
+ // marker or dangling back-link. Both generators still emit the definition (md: a `[^x]:` line;
1239
+ // html: a `div[data-footnote-id]` inside `section[data-footnotes]`, which re-parses on import).
1240
+ for (const [id, definition] of footnoteDefinitions) {
1241
+ if (referencedFootnoteIds.has(id))
1242
+ continue;
1243
+ const noteChildren = parseInline(definition);
1244
+ content.push({
1245
+ type: 'note',
1246
+ text: noteChildren.map(c => c.text || '').join(''),
1247
+ children: noteChildren,
1248
+ metadata: { noteType: 'footnote', noteId: id, unreferenced: true },
1249
+ });
1250
+ }
1251
+ const toTextSync = () => content.map(n => {
1252
+ const getText = (node) => {
1253
+ if (node.type === 'text' || node.type === 'code')
1254
+ return node.text || '';
1255
+ if (node.type === 'break')
1256
+ return '\n';
1257
+ // Childless nodes still carry meaningful text - fall back to it instead of
1258
+ // silently vanishing from plain-text/RAG-chunk output.
1259
+ if (node.type === 'embed')
1260
+ return node.metadata?.url || '';
1261
+ if (node.children) {
1262
+ const isBlock = ['table', 'row', 'list', 'sheet', 'slide', 'admonition', 'definitionList'].includes(node.type);
1263
+ return node.children.map(getText).join(isBlock ? config.newlineDelimiter : '');
1264
+ }
1265
+ return '';
1266
+ };
1267
+ return getText(n);
1268
+ }).join(config.newlineDelimiter)
1269
+ .replace(/\n{3,}/g, '\n\n'); // Normalize excessive whitespace
1270
+ return (0, astUtils_js_1.createAST)('md', metadata, content, attachments, config, undefined, toTextSync);
1271
+ };
1272
+ exports.parseMarkdown = parseMarkdown;