@cudoment/cudoc 0.4.0 → 0.6.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 (102) hide show
  1. package/README.md +4 -3
  2. package/dist/document.d.ts +36 -1
  3. package/dist/document.d.ts.map +1 -1
  4. package/dist/document.js +54 -3
  5. package/dist/document.js.map +1 -1
  6. package/dist/internal/core/mdx/imports.d.ts +21 -0
  7. package/dist/internal/core/mdx/imports.d.ts.map +1 -0
  8. package/dist/internal/core/mdx/imports.js +80 -0
  9. package/dist/internal/core/mdx/imports.js.map +1 -0
  10. package/dist/internal/core/mdx/index.d.ts +1 -0
  11. package/dist/internal/core/mdx/index.d.ts.map +1 -1
  12. package/dist/internal/core/mdx/index.js +1 -0
  13. package/dist/internal/core/mdx/index.js.map +1 -1
  14. package/dist/internal/core/query/sections.d.ts +6 -0
  15. package/dist/internal/core/query/sections.d.ts.map +1 -1
  16. package/dist/internal/core/query/sections.js +8 -2
  17. package/dist/internal/core/query/sections.js.map +1 -1
  18. package/dist/internal/transforms/table-column-layout/create-table.d.ts.map +1 -1
  19. package/dist/internal/transforms/table-column-layout/create-table.js +40 -17
  20. package/dist/internal/transforms/table-column-layout/create-table.js.map +1 -1
  21. package/dist/internal/transforms/table-column-layout/index.d.ts +32 -0
  22. package/dist/internal/transforms/table-column-layout/index.d.ts.map +1 -1
  23. package/dist/internal/transforms/table-column-layout/index.js +66 -0
  24. package/dist/internal/transforms/table-column-layout/index.js.map +1 -1
  25. package/dist/markdown.d.ts +8 -0
  26. package/dist/markdown.d.ts.map +1 -1
  27. package/dist/markdown.js +9 -1
  28. package/dist/markdown.js.map +1 -1
  29. package/dist/node/check.d.ts +12 -6
  30. package/dist/node/check.d.ts.map +1 -1
  31. package/dist/node/check.js +460 -79
  32. package/dist/node/check.js.map +1 -1
  33. package/dist/node/cli.js +2 -56
  34. package/dist/node/cli.js.map +1 -1
  35. package/dist/node/collect.d.ts +41 -0
  36. package/dist/node/collect.d.ts.map +1 -0
  37. package/dist/node/collect.js +351 -0
  38. package/dist/node/collect.js.map +1 -0
  39. package/dist/node/command.d.ts +23 -0
  40. package/dist/node/command.d.ts.map +1 -0
  41. package/dist/node/command.js +116 -0
  42. package/dist/node/command.js.map +1 -0
  43. package/dist/node/dataset.d.ts +14 -1
  44. package/dist/node/dataset.d.ts.map +1 -1
  45. package/dist/node/dataset.js +44 -5
  46. package/dist/node/dataset.js.map +1 -1
  47. package/dist/node/glob.d.ts +21 -0
  48. package/dist/node/glob.d.ts.map +1 -0
  49. package/dist/node/glob.js +61 -0
  50. package/dist/node/glob.js.map +1 -0
  51. package/dist/node/library.d.ts +76 -6
  52. package/dist/node/library.d.ts.map +1 -1
  53. package/dist/node/library.js +63 -137
  54. package/dist/node/library.js.map +1 -1
  55. package/dist/node/load-ast.d.ts.map +1 -1
  56. package/dist/node/load-ast.js +4 -1
  57. package/dist/node/load-ast.js.map +1 -1
  58. package/dist/node/local-target.d.ts +39 -8
  59. package/dist/node/local-target.d.ts.map +1 -1
  60. package/dist/node/local-target.js +94 -13
  61. package/dist/node/local-target.js.map +1 -1
  62. package/dist/node/prepare-embeds.d.ts +52 -3
  63. package/dist/node/prepare-embeds.d.ts.map +1 -1
  64. package/dist/node/prepare-embeds.js +134 -8
  65. package/dist/node/prepare-embeds.js.map +1 -1
  66. package/dist/node/references.d.ts +39 -0
  67. package/dist/node/references.d.ts.map +1 -0
  68. package/dist/node/references.js +101 -0
  69. package/dist/node/references.js.map +1 -0
  70. package/dist/node/replace.d.ts +65 -0
  71. package/dist/node/replace.d.ts.map +1 -0
  72. package/dist/node/replace.js +285 -0
  73. package/dist/node/replace.js.map +1 -0
  74. package/dist/node/resolve-embed.d.ts +79 -1
  75. package/dist/node/resolve-embed.d.ts.map +1 -1
  76. package/dist/node/resolve-embed.js +480 -143
  77. package/dist/node/resolve-embed.js.map +1 -1
  78. package/dist/node/roots.d.ts +78 -0
  79. package/dist/node/roots.d.ts.map +1 -0
  80. package/dist/node/roots.js +170 -0
  81. package/dist/node/roots.js.map +1 -0
  82. package/dist/node/storage.d.ts +21 -3
  83. package/dist/node/storage.d.ts.map +1 -1
  84. package/dist/node/storage.js +242 -20
  85. package/dist/node/storage.js.map +1 -1
  86. package/dist/node/watch.d.ts +86 -0
  87. package/dist/node/watch.d.ts.map +1 -0
  88. package/dist/node/watch.js +174 -0
  89. package/dist/node/watch.js.map +1 -0
  90. package/dist/paged.d.ts +34 -0
  91. package/dist/paged.d.ts.map +1 -0
  92. package/dist/paged.js +44 -0
  93. package/dist/paged.js.map +1 -0
  94. package/dist/render.d.ts +1 -1
  95. package/dist/render.d.ts.map +1 -1
  96. package/dist/render.js +14 -0
  97. package/dist/render.js.map +1 -1
  98. package/dist/sections.d.ts.map +1 -1
  99. package/dist/sections.js +7 -3
  100. package/dist/sections.js.map +1 -1
  101. package/package.json +29 -16
  102. package/styles.css +5 -2
@@ -2,11 +2,68 @@ import path from "node:path";
2
2
  import { fromHtml } from "hast-util-from-html";
3
3
  import { parse as parseYaml } from "yaml";
4
4
  import { collectSections } from "../sections.js";
5
- import { nodeText, visibleHeadingText } from "../document.js";
6
- import { compileDocument } from "../markdown.js";
5
+ import { idToken, nodeText, visibleHeadingText, } from "../document.js";
6
+ import { findHeadingByAnchorId, findParentHeading, getHeadingAnchorId, } from "../internal/core/query/sections.js";
7
+ import { sourceFileOf } from "./roots.js";
8
+ import { parseSrcSet } from "./local-target.js";
9
+ import { transformedSection } from "./replace.js";
10
+ import { EXTERNAL_URL, decodeComponent, declaredIds, documentIndex, idsInNode, } from "./references.js";
7
11
  const SPEC_KEYS = ["sources", "select", "render", "replace"];
8
12
  const SELECTION_KEYS = ["anchors", "titles", "depth", "includeChildren"];
9
13
  const REPLACEMENT_KEYS = ["find", "replace", "regex", "flags"];
14
+ const COLUMN_KEYS = ["header", "value", "link", "minWidth"];
15
+ const CELL_KEYS = ["table", "row", "column", "skipTablesWithHeaders"];
16
+ const SHORTHAND_COLUMNS = ["title", "link", "summary"];
17
+ const CELL_LINKS = ["section", "parent", "document"];
18
+ /** A CSS length a header cell can carry: a number and a unit, nothing else. */
19
+ const CSS_LENGTH = /^\d+(?:\.\d+)?(?:px|rem|em|ch|%)$/;
20
+ const validateColumn = (column, at) => {
21
+ if (typeof column === "string") {
22
+ if (!SHORTHAND_COLUMNS.includes(column))
23
+ throw new Error(`cudoc: ${at}: unknown column "${column}". Known columns: ${SHORTHAND_COLUMNS.join(", ")}, or a mapping with value`);
24
+ return;
25
+ }
26
+ if (!column || typeof column !== "object" || Array.isArray(column))
27
+ throw new Error(`cudoc: ${at} must be a column name or a mapping`);
28
+ const entry = column;
29
+ for (const key of Object.keys(entry))
30
+ if (!COLUMN_KEYS.includes(key))
31
+ throw new Error(`cudoc: ${at}: unknown key "${key}". Known keys: ${COLUMN_KEYS.join(", ")}`);
32
+ if (entry.header !== undefined && typeof entry.header !== "string")
33
+ throw new Error(`cudoc: ${at}.header must be a string`);
34
+ if (entry.link !== undefined && !CELL_LINKS.includes(entry.link))
35
+ throw new Error(`cudoc: ${at}.link must be one of ${CELL_LINKS.join(", ")}`);
36
+ if (entry.minWidth !== undefined &&
37
+ (typeof entry.minWidth !== "string" || !CSS_LENGTH.test(entry.minWidth)))
38
+ throw new Error(`cudoc: ${at}.minWidth must be a CSS length such as 120px or 8rem`);
39
+ const value = entry.value;
40
+ if (typeof value === "string") {
41
+ if (!["title", "summary", "parent"].includes(value))
42
+ throw new Error(`cudoc: ${at}.value: unknown value "${value}". Known values: title, summary, parent, a cell coordinate mapping, or { extractor }`);
43
+ return;
44
+ }
45
+ if (!value || typeof value !== "object" || Array.isArray(value))
46
+ throw new Error(`cudoc: ${at}.value is required`);
47
+ const cell = value;
48
+ if ("extractor" in cell) {
49
+ if (Object.keys(cell).length !== 1 || typeof cell.extractor !== "string")
50
+ throw new Error(`cudoc: ${at}.value.extractor must be a name and nothing else`);
51
+ return;
52
+ }
53
+ for (const key of Object.keys(cell))
54
+ if (!CELL_KEYS.includes(key))
55
+ throw new Error(`cudoc: ${at}.value: unknown key "${key}". Known keys: ${CELL_KEYS.join(", ")}, or extractor`);
56
+ for (const key of ["row", "column"])
57
+ if (!Number.isInteger(cell[key]) || cell[key] < 0)
58
+ throw new Error(`cudoc: ${at}.value.${key} must be a non-negative integer`);
59
+ if (cell.table !== undefined &&
60
+ (!Number.isInteger(cell.table) || cell.table < 0))
61
+ throw new Error(`cudoc: ${at}.value.table must be a non-negative integer`);
62
+ if (cell.skipTablesWithHeaders !== undefined &&
63
+ (!Array.isArray(cell.skipTablesWithHeaders) ||
64
+ cell.skipTablesWithHeaders.some((h) => typeof h !== "string")))
65
+ throw new Error(`cudoc: ${at}.value.skipTablesWithHeaders must be strings`);
66
+ };
10
67
  export function parseEmbedSpec(value) {
11
68
  const spec = parseYaml(value, { maxAliasCount: 100 });
12
69
  if (!spec || typeof spec !== "object" || Array.isArray(spec))
@@ -18,14 +75,21 @@ export function parseEmbedSpec(value) {
18
75
  !spec.sources.length ||
19
76
  spec.sources.some((s) => typeof s !== "string" || !s))
20
77
  throw new Error("cudoc: embed sources must be a non-empty string array");
21
- if (spec.render &&
22
- spec.render !== "section" &&
23
- (spec.render.type !== "table" ||
24
- (spec.render.columns !== undefined &&
25
- (!Array.isArray(spec.render.columns) ||
26
- !spec.render.columns.length ||
27
- spec.render.columns.some((c) => !["title", "link", "summary"].includes(c))))))
28
- throw new Error("cudoc: invalid embed render configuration");
78
+ if (spec.render !== undefined && spec.render !== "section") {
79
+ if (!spec.render ||
80
+ typeof spec.render !== "object" ||
81
+ Array.isArray(spec.render) ||
82
+ spec.render.type !== "table")
83
+ throw new Error('cudoc: render must be "section" or a mapping with type: table');
84
+ for (const key of Object.keys(spec.render))
85
+ if (!["type", "columns"].includes(key))
86
+ throw new Error(`cudoc: render: unknown key "${key}". Known keys: type, columns`);
87
+ if (spec.render.columns !== undefined) {
88
+ if (!Array.isArray(spec.render.columns) || !spec.render.columns.length)
89
+ throw new Error("cudoc: render.columns must be a non-empty array");
90
+ spec.render.columns.forEach((column, index) => validateColumn(column, `render.columns[${index}]`));
91
+ }
92
+ }
29
93
  if (spec.select) {
30
94
  if (typeof spec.select !== "object" || Array.isArray(spec.select))
31
95
  throw new Error("cudoc: select must be a mapping");
@@ -104,7 +168,14 @@ export function parseEmbedBlock(value, documentId, number) {
104
168
  export function resolveDocumentReference(library, reference, from) {
105
169
  const hash = reference.indexOf("#");
106
170
  const pathname = hash < 0 ? reference : reference.slice(0, hash);
107
- const anchor = hash < 0 ? undefined : decodeURIComponent(reference.slice(hash + 1));
171
+ let anchor;
172
+ try {
173
+ anchor =
174
+ hash < 0 ? undefined : decodeURIComponent(reference.slice(hash + 1));
175
+ }
176
+ catch {
177
+ throw new Error(`cudoc: embed source has a malformed percent-escape: ${reference}`);
178
+ }
108
179
  if (/^[a-z][\w+.-]*:/i.test(pathname) || pathname.includes("\\"))
109
180
  throw new Error(`cudoc: embed source must be a local document: ${reference}`);
110
181
  const id = (pathname
@@ -114,65 +185,11 @@ export function resolveDocumentReference(library, reference, from) {
114
185
  : from).replace(/\.mdx?$/i, "");
115
186
  if (id === ".." || id.startsWith("../"))
116
187
  throw new Error(`cudoc: embed source escapes root: ${reference}`);
117
- const document = library.documents.find((d) => d.id === id);
188
+ const document = documentIndex(library).byId.get(id);
118
189
  if (!document)
119
190
  throw new Error(`cudoc: missing document ${reference} referenced from ${from}`);
120
191
  return { document, anchor };
121
192
  }
122
- function replaceSource(source, rules, documentId) {
123
- return rules.reduce((value, rule, index) => {
124
- try {
125
- return rule.regex
126
- ? value.replace(new RegExp(rule.find, rule.flags ?? "g"), rule.replace)
127
- : value.split(rule.find).join(rule.replace);
128
- }
129
- catch (cause) {
130
- throw new Error(`cudoc: invalid replacement ${index + 1} in ${documentId}`, { cause });
131
- }
132
- }, source);
133
- }
134
- function transformedSection(document, anchor, tree, rules, library, includeChildren = true) {
135
- if (!rules.length)
136
- return structuredClone(tree);
137
- if (!document.source)
138
- throw new Error(`cudoc: rebuild ${document.id} with source snapshots before replacing Markdown`);
139
- const range = anchor ? document.source.sections[anchor] : undefined;
140
- if (anchor && !range)
141
- throw new Error(`cudoc: missing source range for ${document.id}#${anchor}; rebuild documents`);
142
- const end = range
143
- ? includeChildren
144
- ? range.end
145
- : (range.ownEnd ?? range.end)
146
- : undefined;
147
- const original = range
148
- ? document.source.text.slice(range.start, end)
149
- : document.source.text;
150
- const dependencies = range?.dependencies
151
- .filter(([start, stop]) => start < range.start || stop > end)
152
- .map(([start, end]) => document.source.text.slice(start, end))
153
- .join("\n\n");
154
- const source = replaceSource(original, rules, document.id) +
155
- (dependencies ? `\n\n${dependencies}` : "");
156
- const options = { ...library.options, format: document.source.format };
157
- if (!library.compiler &&
158
- options.host &&
159
- !["markdown", "next", "html"].includes(options.host))
160
- throw new Error(`cudoc: replacing ${options.host} Markdown requires the original host compiler`);
161
- try {
162
- return library.compiler
163
- ? library.compiler(source, {
164
- id: document.id,
165
- filePath: library.sourceRoot
166
- ? path.resolve(library.sourceRoot, document.sourcePath)
167
- : document.sourcePath,
168
- options,
169
- }).tree
170
- : compileDocument(source, options).tree;
171
- }
172
- catch (cause) {
173
- throw new Error(`cudoc: replaced Markdown could not compile in ${document.id} at source offset ${range?.start ?? 0}`, { cause });
174
- }
175
- }
176
193
  const visitNodes = (node, fn) => {
177
194
  fn(node);
178
195
  node.children?.forEach((n) => visitNodes(n, fn));
@@ -186,56 +203,227 @@ const htmlAttribute = (value, quote) => {
186
203
  ? String(element.properties.dataValue ?? value)
187
204
  : value;
188
205
  };
189
- const idsInNode = (node) => {
190
- const ids = [];
191
- const id = node.data?.hProperties?.id;
192
- if (typeof id === "string")
193
- ids.push(id);
194
- if (node.type === "html" && node.value) {
195
- const tree = fromHtml(node.value, { fragment: true });
196
- const walk = (value) => {
197
- if (value.type === "element" && typeof value.properties.id === "string")
198
- ids.push(value.properties.id);
199
- if ("children" in value)
200
- value.children.forEach(walk);
201
- };
202
- walk(tree);
206
+ /**
207
+ * A relative module an attribute's expression requires first thing, after any
208
+ * webpack loaders: Docusaurus writes a Markdown image, and a link to a local
209
+ * file, as `require("<loaders>!./<path from the page's directory>").default`.
210
+ * Only an expression that starts with the call is read, so text that merely
211
+ * looks like one inside an authored string is left alone. The string runs to
212
+ * its own closing quote, so the other quote may be in a name.
213
+ */
214
+ const REQUIRED_MODULE = /^(\s*require\(\s*(["'])(?:(?:(?!\2)[^\\\n]|\\[\s\S])*!)?)(\.{1,2}\/(?:(?!\2)[^\\\n]|\\[\s\S])*)(\2\s*\))/;
215
+ /**
216
+ * The directory a document's file sits in, which is what a relative module
217
+ * path starts from; the library path's own directory when the library was
218
+ * loaded without its roots, which is the file's only where every root's base
219
+ * mirrors its directory.
220
+ */
221
+ const fileDirectory = (library, document) => path.dirname((library.roots?.length
222
+ ? sourceFileOf(library.roots, document.sourcePath)
223
+ : undefined) ?? path.join("/", document.sourcePath));
224
+ /**
225
+ * `to` from `from`, both relative library paths that may climb above the
226
+ * root, worked out on the paths alone: resolved against the working
227
+ * directory, a climb above it would be clamped again.
228
+ */
229
+ const climbingRelative = (from, to) => {
230
+ const depth = [...from.split("/"), ...to.split("/")].filter((part) => part === "..")
231
+ .length + 1;
232
+ const base = `/${Array.from({ length: depth }, (_, i) => `_${i}`).join("/")}`;
233
+ return path.posix.relative(path.posix.join(base, from), path.posix.join(base, to));
234
+ };
235
+ /** What a JavaScript string literal holds between its quotes, escapes read. */
236
+ const stringValue = (written) => written.replace(/\\(?:u\{([0-9a-fA-F]+)\}|u([0-9a-fA-F]{4})|x([0-9a-fA-F]{2})|(\r\n|[\s\S]))/g, (_match, braced, unicode, hex, other = "") => {
237
+ const code = braced ?? unicode ?? hex;
238
+ if (code) {
239
+ const point = parseInt(code, 16);
240
+ // Past the last code point the escape is not JavaScript at all.
241
+ return point > 0x10ffff ? _match : String.fromCodePoint(point);
242
+ }
243
+ // A backslash before a line break continues the string on the next line.
244
+ if (/^(?:\r\n|[\n\r\u2028\u2029])$/.test(other))
245
+ return "";
246
+ const named = {
247
+ n: "\n",
248
+ r: "\r",
249
+ t: "\t",
250
+ b: "\b",
251
+ f: "\f",
252
+ v: "\v",
253
+ "0": "\0",
254
+ };
255
+ return named[other] ?? other;
256
+ });
257
+ /** `value` written between `quote`s as a JavaScript string literal. */
258
+ const stringLiteral = (value, quote) => value.replace(/[\\\n\r\u2028\u2029"']/g, (character) => {
259
+ if (character === "\\")
260
+ return "\\\\";
261
+ if (character === "\n")
262
+ return "\\n";
263
+ if (character === "\r")
264
+ return "\\r";
265
+ if (character === "\u2028")
266
+ return "\\u2028";
267
+ if (character === "\u2029")
268
+ return "\\u2029";
269
+ return character === quote ? `\\${character}` : character;
270
+ });
271
+ /**
272
+ * Re-expresses the modules a JSX element's attributes require from the
273
+ * directory of the document the copy lands in: a page in another directory
274
+ * would resolve the path from its own. The path is read as the string it is
275
+ * and written back as one, so a directory named with a quote or a backslash
276
+ * still gives an expression that parses.
277
+ */
278
+ const moveRequiredModules = (node, from, to) => {
279
+ if (!Array.isArray(node.attributes))
280
+ return;
281
+ for (const attribute of node.attributes) {
282
+ const value = attribute.value;
283
+ if (attribute.type !== "mdxJsxAttribute" ||
284
+ typeof value !== "object" ||
285
+ value?.type !== "mdxJsxAttributeValueExpression" ||
286
+ typeof value.value !== "string")
287
+ continue;
288
+ value.value = value.value.replace(REQUIRED_MODULE, (match, head, quote, module, tail) => {
289
+ // A query the host appended stays as written: it is not part of
290
+ // the path, and joining it would normalize its slashes too.
291
+ const [, file = "", query = ""] = /^([^?]*)(.*)$/s.exec(stringValue(module));
292
+ const relative = path.relative(to, path.join(from, file));
293
+ // Another drive has no relative path; the copy keeps the original.
294
+ if (path.isAbsolute(relative))
295
+ return match;
296
+ const moved = relative.split(path.sep).join("/");
297
+ return `${head}${stringLiteral(`./${moved}${query}`, quote)}${tail}`;
298
+ });
299
+ }
300
+ };
301
+ /** The raw HTML attributes that name a path: a link and every resource. */
302
+ const REBASED_ATTRIBUTES = new Set([
303
+ "href",
304
+ "src",
305
+ "poster",
306
+ "data",
307
+ "xlink:href",
308
+ ]);
309
+ /**
310
+ * One attribute of a tag, read in order after the tag name as HTML reads it:
311
+ * what separates it from the one before, white space or a stray `/` and
312
+ * nothing at all after a quoted value, its name, and a value quoted either
313
+ * way, which may span lines, or bare, which runs to white space or `>`.
314
+ */
315
+ const ATTRIBUTE = /([\s/]*)([^\s"'>/=]+)(?:(\s*=\s*)(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/y;
316
+ /**
317
+ * The tag with the values `rewrite` returns in place of the ones it has, the
318
+ * attributes it returns nothing for left exactly as written. The attributes
319
+ * are walked in order, so text inside one value is never read as another.
320
+ */
321
+ const rewriteAttributes = (tag, rewrite) => {
322
+ const opening = /^<\/?[^\s/>]+/.exec(tag);
323
+ if (!opening)
324
+ return tag;
325
+ let result = opening[0];
326
+ let index = opening[0].length;
327
+ for (;;) {
328
+ ATTRIBUTE.lastIndex = index;
329
+ const match = ATTRIBUTE.exec(tag);
330
+ if (!match)
331
+ break;
332
+ index = ATTRIBUTE.lastIndex;
333
+ const [whole, space, name, equals, double, single, bare] = match;
334
+ const written = double ?? single ?? bare;
335
+ const delimiter = single === undefined ? '"' : "'";
336
+ const replacement = written === undefined
337
+ ? undefined
338
+ : rewrite(name.toLowerCase(), htmlAttribute(written, delimiter));
339
+ result +=
340
+ replacement === undefined
341
+ ? whole
342
+ : `${space}${name}${equals}${delimiter}${replacement
343
+ .replace(/&/g, "&amp;")
344
+ .replace(new RegExp(delimiter, "g"), delimiter === '"' ? "&quot;" : "&#39;")}${delimiter}`;
203
345
  }
204
- return ids;
346
+ return result + tag.slice(index);
205
347
  };
206
- function rebase(tree, document, library, prefix) {
348
+ function rebase(tree, document, library, prefix, destination, moved) {
207
349
  const ids = new Set();
208
350
  visitNodes(tree, (node) => {
209
351
  idsInNode(node).forEach((id) => ids.add(id));
210
352
  });
353
+ // Worked out once, and only when the copy has an element to move.
354
+ let directories;
211
355
  const sourceUrl = (url) => {
212
- if (/^(?:[a-z][\w+.-]*:|\/\/)/i.test(url))
356
+ if (EXTERNAL_URL.test(url))
213
357
  return url;
214
358
  if (url.startsWith("#")) {
215
- let id = url.slice(1);
216
- try {
217
- id = decodeURIComponent(id);
218
- }
219
- catch {
220
- /* Preserve malformed URL. */
221
- }
359
+ const id = decodeComponent(url.slice(1));
222
360
  return ids.has(id) ? `#${prefix}${id}` : `${document.route}${url}`;
223
361
  }
224
362
  const match = url.match(/^([^?#]*)(.*)$/);
225
- const absolute = path.posix.normalize(match[1].startsWith("/")
363
+ // No path means the page the link sits on, which in a copy is the
364
+ // document it was copied from, not the directory that document is in.
365
+ if (!match[1])
366
+ return `${document.route}${match[2]}`;
367
+ const joined = match[1].startsWith("/")
226
368
  ? match[1]
227
- : `/${path.posix.join(path.posix.dirname(document.sourcePath), match[1])}`);
228
- const target = library.documents.find((d) => `/${d.sourcePath}` === absolute ||
229
- `/${d.id}` === absolute ||
230
- `/${d.id}/` === absolute);
369
+ : path.posix.join(path.posix.dirname(document.sourcePath), match[1]);
370
+ // A relative path that climbs out of the collection names no library
371
+ // path, and clamped at the root it would name another file. It is spelled
372
+ // from the page the copy lands on instead, which reaches the same place:
373
+ // from the files' own directories when the library has its roots, and
374
+ // from the library paths otherwise.
375
+ if (joined === ".." || joined.startsWith("../")) {
376
+ if (!destination)
377
+ return url;
378
+ // A directory keeps its trailing slash, which names its index.
379
+ const slash = /(?:^|\/)\.{0,2}$/.test(match[1]) ? "/" : "";
380
+ if (!library.roots?.length)
381
+ return `${climbingRelative(path.posix.dirname(destination.sourcePath), joined) || "."}${slash}${match[2]}`;
382
+ directories ??= {
383
+ from: fileDirectory(library, document),
384
+ to: fileDirectory(library, destination),
385
+ };
386
+ const relative = path.relative(directories.to, path.join(directories.from, decodeComponent(match[1])));
387
+ // Written back as a URL path: only what would end or change one is
388
+ // escaped, as the path was decoded to reach the file.
389
+ return `${relative
390
+ .split(path.sep)
391
+ .map((part) => part.replace(/[%\s#?]/g, encodeURIComponent))
392
+ .join("/") || "."}${slash}${match[2]}`;
393
+ }
394
+ let absolute = path.posix.normalize(joined.startsWith("/") ? joined : `/${joined}`);
395
+ // `.` and `..` name a directory, as a browser reads them.
396
+ if (/(?:^|\/)\.\.?$/.test(match[1]) && !absolute.endsWith("/"))
397
+ absolute += "/";
398
+ const target = documentIndex(library).byPath.get(absolute);
231
399
  return `${target?.route ?? absolute}${match[2]}`;
232
400
  };
401
+ // Every candidate of a responsive image is a path of its own; the width or
402
+ // density after it stays as written.
403
+ const sourceSrcSet = (value) => parseSrcSet(value)
404
+ .map(({ url, descriptor }) => [sourceUrl(url), descriptor].filter(Boolean).join(" "))
405
+ .join(", ");
233
406
  visitNodes(tree, (node) => {
234
407
  const id = node.data?.hProperties?.id;
235
408
  if (typeof id === "string")
236
409
  node.data.hProperties.id = `${prefix}${id}`;
237
410
  if (typeof node.url === "string")
238
411
  node.url = sourceUrl(node.url);
412
+ // An image a host made a component of is still an image of the source.
413
+ const image = node.data?.cudocImage;
414
+ if (image && typeof image.url === "string")
415
+ node.data.cudocImage = { ...image, url: sourceUrl(image.url) };
416
+ // Once per element, however deeply the copy was nested: an inner embed
417
+ // already moved it from its own source to `destination`.
418
+ if (destination && Array.isArray(node.attributes) && !moved.has(node)) {
419
+ moved.add(node);
420
+ directories ??= {
421
+ from: fileDirectory(library, document),
422
+ to: fileDirectory(library, destination),
423
+ };
424
+ if (directories.from !== directories.to)
425
+ moveRequiredModules(node, directories.from, directories.to);
426
+ }
239
427
  if ([
240
428
  "linkReference",
241
429
  "imageReference",
@@ -248,36 +436,207 @@ function rebase(tree, document, library, prefix) {
248
436
  if (node.type === "html" && node.value)
249
437
  node.value = node.value.replace(/<!--[\s\S]*?-->|<(?:[^"'<>]|"[^"]*"|'[^']*')*>/g, (tag) => tag.startsWith("<!--")
250
438
  ? tag
251
- : tag.replace(/(\s(href|src|id)\s*=\s*)(?:(["'])(.*?)\3|([^\s>]+))/gi, (_match, before, attribute, quote, quoted, unquoted) => {
252
- const delimiter = quote ?? '"';
253
- const value = htmlAttribute(quoted ?? unquoted, delimiter);
254
- const replacement = attribute.toLowerCase() === "id"
255
- ? `${prefix}${value}`
256
- : sourceUrl(value);
257
- return `${before}${delimiter}${replacement.replace(/&/g, "&amp;").replace(new RegExp(delimiter, "g"), delimiter === '"' ? "&quot;" : "&#39;")}${delimiter}`;
258
- }));
439
+ : rewriteAttributes(tag, (name, value) => name === "id"
440
+ ? `${prefix}${value}`
441
+ : name === "srcset"
442
+ ? sourceSrcSet(value)
443
+ : REBASED_ATTRIBUTES.has(name)
444
+ ? sourceUrl(value)
445
+ : undefined));
259
446
  const attrs = node.data?.hProperties;
260
- for (const key of ["href", "src"])
447
+ for (const key of ["href", "src", "poster", "data", "xLinkHref"])
261
448
  if (typeof attrs?.[key] === "string")
262
449
  attrs[key] = sourceUrl(attrs[key]);
450
+ for (const key of ["srcSet", "srcset"])
451
+ if (typeof attrs?.[key] === "string")
452
+ attrs[key] = sourceSrcSet(attrs[key]);
263
453
  });
264
454
  }
455
+ /** The default columns of a summary table, as they have always been. */
456
+ export const DEFAULT_TABLE_COLUMNS = ["title", "link", "summary"];
457
+ /** A shorthand column written out as the mapping it stands for. */
458
+ const columnSpec = (column) => {
459
+ if (column === "title")
460
+ return { header: "title", value: "title" };
461
+ if (column === "link")
462
+ return { header: "link", value: "title", link: "section" };
463
+ if (column === "summary")
464
+ return { header: "summary", value: "summary" };
465
+ return {
466
+ header: column.header ?? (typeof column.value === "string" ? column.value : ""),
467
+ value: column.value,
468
+ link: column.link,
469
+ minWidth: column.minWidth,
470
+ };
471
+ };
472
+ /**
473
+ * One row for a selected section: what it is called, where it lives and which
474
+ * heading introduces the part of the document it sits in.
475
+ */
476
+ export function buildEmbedRow(document, anchorId, tree) {
477
+ const heading = tree.children.find((n) => n.type === "heading");
478
+ const title = heading
479
+ ? visibleHeadingText(heading)
480
+ : String(document.frontmatter.title ?? document.id);
481
+ let parent;
482
+ if (anchorId) {
483
+ const location = findHeadingByAnchorId(document.tree, anchorId);
484
+ const above = location &&
485
+ findParentHeading(location.parent, location.index, location.heading.depth);
486
+ if (above)
487
+ parent = {
488
+ title: visibleHeadingText(above),
489
+ anchorId: getHeadingAnchorId(above),
490
+ };
491
+ }
492
+ return {
493
+ document,
494
+ section: { anchorId, title, tree },
495
+ parent,
496
+ url: `${document.route}${anchorId ? `#${anchorId}` : ""}`,
497
+ };
498
+ }
499
+ /** Tables inside a section, in document order, minus those a coordinate skips. */
500
+ const sectionTables = (tree, skipHeaders) => {
501
+ const tables = [];
502
+ visitNodes(tree, (node) => {
503
+ if (node.type === "table")
504
+ tables.push(node);
505
+ });
506
+ if (!skipHeaders?.length)
507
+ return tables;
508
+ const skip = new Set(skipHeaders.map((header) => header.trim()));
509
+ return tables.filter((table) => !(table.children?.[0]?.children ?? []).some((cell) => skip.has(nodeText(cell).trim())));
510
+ };
511
+ /**
512
+ * The text and link one column shows for one row, and why it is empty when it
513
+ * is. The resolver renders the cell either way; the checker reports the
514
+ * problem, which is worded as what the column asked for and what the section
515
+ * has, so an author can tell a wrong coordinate from a missing table.
516
+ */
517
+ export function extractCell(library, column, row, context) {
518
+ const spec = columnSpec(column);
519
+ const where = `${row.document.id}${row.section.anchorId ? `#${row.section.anchorId}` : ""}`;
520
+ const linkTo = () => {
521
+ if (spec.link === "section")
522
+ return row.url;
523
+ if (spec.link === "document")
524
+ return row.document.route;
525
+ if (spec.link === "parent")
526
+ return row.parent
527
+ ? `${row.document.route}${row.parent.anchorId ? `#${row.parent.anchorId}` : ""}`
528
+ : undefined;
529
+ return undefined;
530
+ };
531
+ const finish = (text, problem, url) => ({
532
+ cell: {
533
+ text: text ?? "",
534
+ ...((url ?? linkTo()) ? { url: url ?? linkTo() } : {}),
535
+ },
536
+ ...(problem ? { problem } : {}),
537
+ });
538
+ const value = spec.value;
539
+ if (value === "title")
540
+ return finish(row.section.title);
541
+ if (value === "summary") {
542
+ const paragraph = row.section.tree.children.find((n) => n.type === "paragraph");
543
+ return paragraph
544
+ ? finish(nodeText(paragraph).trim())
545
+ : finish(undefined, `expected a paragraph in ${where}, found none`);
546
+ }
547
+ if (value === "parent")
548
+ return row.parent
549
+ ? finish(row.parent.title)
550
+ : finish(undefined, `expected a heading above ${where}, found none`);
551
+ if ("extractor" in value) {
552
+ const extractor = library.extractors?.[value.extractor];
553
+ if (!extractor)
554
+ throw new Error(`cudoc: extractor "${value.extractor}" is not registered; add it to extractors in the collection options`);
555
+ const result = extractor.extract(row, {
556
+ library,
557
+ documentId: context.documentId,
558
+ column,
559
+ });
560
+ const text = typeof result === "string" ? result : result?.text;
561
+ if (!text)
562
+ return finish(undefined, `extractor "${value.extractor}" returned nothing for ${where}`);
563
+ return finish(text, undefined, typeof result === "object" ? result.url : undefined);
564
+ }
565
+ const tables = sectionTables(row.section.tree, value.skipTablesWithHeaders);
566
+ const index = value.table ?? 0;
567
+ const table = tables[index];
568
+ if (!table)
569
+ return finish(undefined, `expected table ${index} in ${where}, found ${tables.length} table${tables.length === 1 ? "" : "s"}${value.skipTablesWithHeaders?.length ? " after skipping those headed " + value.skipTablesWithHeaders.map((h) => JSON.stringify(h)).join(", ") : ""}`);
570
+ const tableRow = table.children?.[value.row];
571
+ if (!tableRow)
572
+ return finish(undefined, `expected row ${value.row} of table ${index} in ${where}, found ${table.children?.length ?? 0} rows`);
573
+ const cell = tableRow.children?.[value.column];
574
+ if (!cell)
575
+ return finish(undefined, `expected column ${value.column} of row ${value.row} in table ${index} of ${where}, found ${tableRow.children?.length ?? 0} cells`);
576
+ const text = nodeText(cell).trim();
577
+ return text
578
+ ? finish(text)
579
+ : finish(undefined, `cell ${value.row}:${value.column} of table ${index} in ${where} is empty`);
580
+ }
581
+ /** The mdast table the extracted cells make, header cells carrying any width. */
582
+ export function buildEmbedTable(library, columns, rows, context) {
583
+ const cell = (value, style) => ({
584
+ type: "tableCell",
585
+ children: value.url
586
+ ? [
587
+ {
588
+ type: "link",
589
+ url: value.url,
590
+ children: [{ type: "text", value: value.text }],
591
+ },
592
+ ]
593
+ : [{ type: "text", value: value.text }],
594
+ ...(style ? { data: { hProperties: { style } } } : {}),
595
+ });
596
+ return {
597
+ type: "root",
598
+ children: [
599
+ {
600
+ type: "table",
601
+ align: columns.map(() => null),
602
+ children: [
603
+ {
604
+ type: "tableRow",
605
+ children: columns.map((column) => {
606
+ const spec = columnSpec(column);
607
+ return cell({ text: spec.header }, spec.minWidth ? `min-width: ${spec.minWidth}` : undefined);
608
+ }),
609
+ },
610
+ ...rows.map((row) => ({
611
+ type: "tableRow",
612
+ children: columns.map((column) => cell(extractCell(library, column, row, context).cell)),
613
+ })),
614
+ ],
615
+ },
616
+ ],
617
+ };
618
+ }
265
619
  /** Resolve a configured embed from immutable persisted documents. */
266
620
  export function resolveEmbed(library, input, context) {
267
621
  const spec = parseEmbedSpec(JSON.stringify(input));
268
622
  let occurrence = 0;
269
623
  const reserved = new Set();
270
- const destination = library.documents.find((d) => d.id === context.documentId);
624
+ const destination = documentIndex(library).byId.get(context.documentId);
625
+ // Elements whose required modules already start from `destination`.
626
+ const moved = new WeakSet();
271
627
  if (destination)
272
- visitNodes(destination.tree, (node) => {
273
- idsInNode(node).forEach((id) => reserved.add(id));
274
- });
628
+ for (const id of declaredIds(destination.tree))
629
+ reserved.add(id);
275
630
  const active = [];
631
+ // Every document a block read, so a later preparation can tell whether the
632
+ // block is still current; `*` when an extractor ran, which may read anything.
633
+ const dependencies = new Set();
276
634
  const expand = (spec, from) => {
277
635
  const children = [];
278
636
  const rows = [];
279
637
  for (const source of spec.sources) {
280
638
  const { document, anchor } = resolveDocumentReference(library, source, from);
639
+ dependencies.add(document.id);
281
640
  const key = `${document.id}#${anchor ?? "*"}`;
282
641
  if (active.includes(key) || active.length >= 64)
283
642
  throw new Error(`cudoc: cyclic embed: ${[...active, key].join(" -> ")}`);
@@ -310,15 +669,9 @@ export function resolveEmbed(library, input, context) {
310
669
  });
311
670
  };
312
671
  expandNodes(section);
313
- const paragraph = section.children.find((n) => n.type === "paragraph");
314
- const heading = section.children.find((n) => n.type === "heading");
315
- rows.push({
316
- title: heading
317
- ? visibleHeadingText(heading)
318
- : selectedSection.title,
319
- link: `${document.route}${selectedSection.anchorId ? `#${selectedSection.anchorId}` : ""}`,
320
- summary: paragraph ? nodeText(paragraph) : "",
321
- });
672
+ // Rows read the section before its ids are rebased, so a cell's link
673
+ // points into the source document rather than at the copy.
674
+ rows.push(buildEmbedRow(document, selectedSection.anchorId, structuredClone(section)));
322
675
  let prefix;
323
676
  const sectionIds = [];
324
677
  visitNodes(section, (node) => {
@@ -327,7 +680,7 @@ export function resolveEmbed(library, input, context) {
327
680
  do {
328
681
  prefix = `${context.prefix ?? "embed"}-${++occurrence}-`;
329
682
  } while (sectionIds.some((id) => reserved.has(`${prefix}${id}`)));
330
- rebase(section, document, library, prefix);
683
+ rebase(section, document, library, prefix, destination, moved);
331
684
  sectionIds.forEach((id) => reserved.add(`${prefix}${id}`));
332
685
  children.push(...section.children);
333
686
  }
@@ -337,41 +690,25 @@ export function resolveEmbed(library, input, context) {
337
690
  }
338
691
  }
339
692
  if (spec.render && spec.render !== "section") {
340
- const columns = spec.render.columns ?? ["title", "link", "summary"];
341
- const cell = (value, url) => ({
342
- type: "tableCell",
343
- children: url
344
- ? [{ type: "link", url, children: [{ type: "text", value }] }]
345
- : [{ type: "text", value }],
346
- });
347
- return {
348
- type: "root",
349
- children: [
350
- {
351
- type: "table",
352
- align: columns.map(() => null),
353
- children: [
354
- { type: "tableRow", children: columns.map((c) => cell(c)) },
355
- ...rows.map((row) => ({
356
- type: "tableRow",
357
- children: columns.map((c) => cell(c === "link" ? row.title : row[c], c === "link" ? row.link : undefined)),
358
- })),
359
- ],
360
- },
361
- ],
362
- };
693
+ const columns = spec.render.columns ?? DEFAULT_TABLE_COLUMNS;
694
+ if (columns.some((column) => typeof column === "object" &&
695
+ typeof column.value === "object" &&
696
+ "extractor" in column.value))
697
+ dependencies.add("*");
698
+ return buildEmbedTable(library, columns, rows, context);
363
699
  }
364
700
  return { type: "root", children };
365
701
  };
366
702
  const result = expand(spec, context.documentId);
367
703
  result.data = {
368
704
  ...result.data,
369
- cudocEmbedPrefix: `cudoc-${encodeURIComponent(context.documentId)}-${context.prefix ?? "embed"}-`,
705
+ cudocEmbedPrefix: `cudoc-${idToken(context.documentId)}-${context.prefix ?? "embed"}-`,
706
+ cudocDependencies: [...dependencies].sort(),
370
707
  };
371
708
  return result;
372
709
  }
373
710
  export function resolveDocumentEmbeds(library, documentId) {
374
- const document = library.documents.find((d) => d.id === documentId);
711
+ const document = documentIndex(library).byId.get(documentId);
375
712
  if (!document)
376
713
  throw new Error(`cudoc: missing document ${documentId}`);
377
714
  const tree = structuredClone(document.tree);