@cudoment/cudoc 0.6.0 → 0.7.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.
@@ -1,14 +1,28 @@
1
1
  import path from "node:path";
2
2
  import { fromHtml } from "hast-util-from-html";
3
3
  import { parse as parseYaml } from "yaml";
4
- import { collectSections } from "../sections.js";
4
+ import { collectSections, } from "../sections.js";
5
5
  import { idToken, nodeText, visibleHeadingText, } from "../document.js";
6
6
  import { findHeadingByAnchorId, findParentHeading, getHeadingAnchorId, } from "../internal/core/query/sections.js";
7
+ import { TREE_CLASS, TREE_KIND, TREE_PRINT_ATTRIBUTE } from "../paged.js";
7
8
  import { sourceFileOf } from "./roots.js";
9
+ import { compareCodePoints, compareNames, documentName, hierarchyOf, nfc, } from "./tree.js";
8
10
  import { parseSrcSet } from "./local-target.js";
9
11
  import { transformedSection } from "./replace.js";
10
12
  import { EXTERNAL_URL, decodeComponent, declaredIds, documentIndex, idsInNode, } from "./references.js";
11
13
  const SPEC_KEYS = ["sources", "select", "render", "replace"];
14
+ const TABLE_KEYS = ["type", "columns"];
15
+ const TREE_KEYS = [
16
+ "type",
17
+ "open",
18
+ "print",
19
+ "depth",
20
+ "headings",
21
+ "order",
22
+ "columns",
23
+ ];
24
+ /** The `order` entry that stands for every name the list does not give. */
25
+ const REST = "...";
12
26
  const SELECTION_KEYS = ["anchors", "titles", "depth", "includeChildren"];
13
27
  const REPLACEMENT_KEYS = ["find", "replace", "regex", "flags"];
14
28
  const COLUMN_KEYS = ["header", "value", "link", "minWidth"];
@@ -64,6 +78,38 @@ const validateColumn = (column, at) => {
64
78
  cell.skipTablesWithHeaders.some((h) => typeof h !== "string")))
65
79
  throw new Error(`cudoc: ${at}.value.skipTablesWithHeaders must be strings`);
66
80
  };
81
+ const validateTree = (render) => {
82
+ const count = (key, least, most) => {
83
+ const value = render[key];
84
+ if (value === undefined)
85
+ return;
86
+ if (!Number.isInteger(value) ||
87
+ value < least ||
88
+ (most !== undefined && value > most))
89
+ throw new Error(`cudoc: render.${key} must be ${most === undefined
90
+ ? `an integer of at least ${least}`
91
+ : `an integer from ${least} to ${most}`}`);
92
+ };
93
+ count("open", 0);
94
+ count("print", 1);
95
+ count("depth", 1);
96
+ count("headings", 0, 5);
97
+ const order = render.order;
98
+ if (order === undefined)
99
+ return;
100
+ if (!Array.isArray(order) ||
101
+ order.some((name) => typeof name !== "string" || !name.trim()))
102
+ throw new Error("cudoc: render.order must be a list of names");
103
+ const seen = new Set();
104
+ for (const name of order) {
105
+ const key = nfc(name);
106
+ if (seen.has(key))
107
+ throw new Error(name === REST
108
+ ? `cudoc: render.order has ${REST} twice; it stands for every name not listed, once`
109
+ : `cudoc: render.order names "${name}" twice`);
110
+ seen.add(key);
111
+ }
112
+ };
67
113
  export function parseEmbedSpec(value) {
68
114
  const spec = parseYaml(value, { maxAliasCount: 100 });
69
115
  if (!spec || typeof spec !== "object" || Array.isArray(spec))
@@ -79,16 +125,26 @@ export function parseEmbedSpec(value) {
79
125
  if (!spec.render ||
80
126
  typeof spec.render !== "object" ||
81
127
  Array.isArray(spec.render) ||
82
- spec.render.type !== "table")
83
- throw new Error('cudoc: render must be "section" or a mapping with type: table');
128
+ !["table", "tree"].includes(spec.render.type))
129
+ throw new Error('cudoc: render must be "section" or a mapping with type: table or type: tree');
130
+ const known = spec.render.type === "tree" ? TREE_KEYS : TABLE_KEYS;
84
131
  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`);
132
+ if (!known.includes(key))
133
+ throw new Error(`cudoc: render: unknown key "${key}". Known keys: ${known.join(", ")}`);
87
134
  if (spec.render.columns !== undefined) {
88
135
  if (!Array.isArray(spec.render.columns) || !spec.render.columns.length)
89
136
  throw new Error("cudoc: render.columns must be a non-empty array");
90
137
  spec.render.columns.forEach((column, index) => validateColumn(column, `render.columns[${index}]`));
91
138
  }
139
+ if (spec.render.type === "tree") {
140
+ validateTree(spec.render);
141
+ // A tree copies no section text, so neither would change anything,
142
+ // and an author who wrote one expects it to.
143
+ if (spec.select !== undefined)
144
+ throw new Error("cudoc: select does not apply to a tree; its levels come from folders, and render.headings adds sections");
145
+ if (spec.replace !== undefined)
146
+ throw new Error("cudoc: replace does not apply to a tree, which copies no section text");
147
+ }
92
148
  }
93
149
  if (spec.select) {
94
150
  if (typeof spec.select !== "object" || Array.isArray(spec.select))
@@ -165,7 +221,13 @@ export function parseEmbedBlock(value, documentId, number) {
165
221
  throw new Error(`cudoc: ${where}: ${message}`, { cause });
166
222
  }
167
223
  }
168
- export function resolveDocumentReference(library, reference, from) {
224
+ /**
225
+ * What a source names, read without looking anything up: the library path it
226
+ * spells, relative to `from` or from the top with a leading `/`, and its
227
+ * anchor, percent-decoded. A URL, a backslash and a climb out of the library
228
+ * fail here.
229
+ */
230
+ const sourcePath = (reference, from) => {
169
231
  const hash = reference.indexOf("#");
170
232
  const pathname = hash < 0 ? reference : reference.slice(0, hash);
171
233
  let anchor;
@@ -178,18 +240,66 @@ export function resolveDocumentReference(library, reference, from) {
178
240
  }
179
241
  if (/^[a-z][\w+.-]*:/i.test(pathname) || pathname.includes("\\"))
180
242
  throw new Error(`cudoc: embed source must be a local document: ${reference}`);
181
- const id = (pathname
243
+ const target = pathname
182
244
  ? pathname.startsWith("/")
183
245
  ? pathname.slice(1)
184
246
  : path.posix.join(path.posix.dirname(from), pathname)
185
- : from).replace(/\.mdx?$/i, "");
247
+ : from;
248
+ const id = target.replace(/\.mdx?$/i, "");
186
249
  if (id === ".." || id.startsWith("../"))
187
250
  throw new Error(`cudoc: embed source escapes root: ${reference}`);
251
+ return { pathname, target, ...(anchor === undefined ? {} : { anchor }) };
252
+ };
253
+ export function resolveDocumentReference(library, reference, from) {
254
+ const { target, anchor } = sourcePath(reference, from);
255
+ const id = target.replace(/\.mdx?$/i, "");
188
256
  const document = documentIndex(library).byId.get(id);
189
257
  if (!document)
190
258
  throw new Error(`cudoc: missing document ${reference} referenced from ${from}`);
191
259
  return { document, anchor };
192
260
  }
261
+ /**
262
+ * Whether a tree on `page` lists private documents: only when the page is
263
+ * private itself. One that names no collected document counts as public.
264
+ */
265
+ const listsPrivate = (library, page) => documentIndex(library).byId.get(page)?.private === true;
266
+ /**
267
+ * Reads one source of a tree. A path ending in `/` names a folder, and the
268
+ * documents directly below it become the tree's first level; any other path
269
+ * names a document, or with `#anchor` one of its sections, as for any embed.
270
+ * Ids are matched in NFC, so a source names a document whose file name is
271
+ * stored in NFD. A folder holding no document fails, and so does a document
272
+ * path that is really a folder, with a hint to add the `/`. Given `page`,
273
+ * the document the tree lands on, a folder whose documents are all private
274
+ * fails too unless that page is private, since the tree leaves them out.
275
+ */
276
+ export function resolveTreeSource(library, reference, from, page) {
277
+ const { pathname, target, anchor } = sourcePath(reference, from);
278
+ const hierarchy = hierarchyOf(library);
279
+ if (pathname.endsWith("/")) {
280
+ if (anchor !== undefined)
281
+ throw new Error(`cudoc: a folder source takes no anchor: ${reference}`);
282
+ const normal = path.posix.normalize(target || ".").replace(/\/+$/, "");
283
+ if (normal === ".." || normal.startsWith("../"))
284
+ throw new Error(`cudoc: embed source escapes root: ${reference}`);
285
+ const folder = normal === "." ? "" : normal;
286
+ const documents = hierarchy.below(folder);
287
+ if (!documents.length)
288
+ throw new Error(`cudoc: no documents in folder ${reference} referenced from ${from}`);
289
+ if (page !== undefined &&
290
+ !listsPrivate(library, page) &&
291
+ documents.every((document) => document.private))
292
+ throw new Error(`cudoc: folder ${reference} referenced from ${from} holds only private documents, which a tree on ${page} leaves out`);
293
+ return { folder, documents };
294
+ }
295
+ const id = target.replace(/\.mdx?$/i, "");
296
+ const document = documentIndex(library).byId.get(id) ?? hierarchy.document(id);
297
+ if (document)
298
+ return { document, ...(anchor === undefined ? {} : { anchor }) };
299
+ throw new Error(`cudoc: missing document ${reference} referenced from ${from}${hierarchy.below(path.posix.normalize(id)).length
300
+ ? `; a folder source ends with /, as in ${pathname}/`
301
+ : ""}`);
302
+ }
193
303
  const visitNodes = (node, fn) => {
194
304
  fn(node);
195
305
  node.children?.forEach((n) => visitNodes(n, fn));
@@ -345,7 +455,7 @@ const rewriteAttributes = (tag, rewrite) => {
345
455
  }
346
456
  return result + tag.slice(index);
347
457
  };
348
- function rebase(tree, document, library, prefix, destination, moved) {
458
+ function rebase(tree, document, library, prefix, destination, placed) {
349
459
  const ids = new Set();
350
460
  visitNodes(tree, (node) => {
351
461
  idsInNode(node).forEach((id) => ids.add(id));
@@ -398,25 +508,35 @@ function rebase(tree, document, library, prefix, destination, moved) {
398
508
  const target = documentIndex(library).byPath.get(absolute);
399
509
  return `${target?.route ?? absolute}${match[2]}`;
400
510
  };
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(", ");
511
+ // A node an inner copy already placed holds the page's addresses, and read
512
+ // as source paths again they could name another document, or climb from
513
+ // the wrong directory. Only a fragment naming an id this copy renames
514
+ // follows the new name.
515
+ const pageUrl = (url) => {
516
+ if (!url.startsWith("#"))
517
+ return url;
518
+ const id = decodeComponent(url.slice(1));
519
+ return ids.has(id) ? `#${prefix}${id}` : url;
520
+ };
406
521
  visitNodes(tree, (node) => {
522
+ const url = placed.has(node) ? pageUrl : sourceUrl;
523
+ // Every candidate of a responsive image is a path of its own; the width
524
+ // or density after it stays as written.
525
+ const srcSet = (value) => parseSrcSet(value)
526
+ .map(({ url: candidate, descriptor }) => [url(candidate), descriptor].filter(Boolean).join(" "))
527
+ .join(", ");
407
528
  const id = node.data?.hProperties?.id;
408
529
  if (typeof id === "string")
409
530
  node.data.hProperties.id = `${prefix}${id}`;
410
531
  if (typeof node.url === "string")
411
- node.url = sourceUrl(node.url);
532
+ node.url = url(node.url);
412
533
  // An image a host made a component of is still an image of the source.
413
534
  const image = node.data?.cudocImage;
414
535
  if (image && typeof image.url === "string")
415
- node.data.cudocImage = { ...image, url: sourceUrl(image.url) };
536
+ node.data.cudocImage = { ...image, url: url(image.url) };
416
537
  // Once per element, however deeply the copy was nested: an inner embed
417
538
  // already moved it from its own source to `destination`.
418
- if (destination && Array.isArray(node.attributes) && !moved.has(node)) {
419
- moved.add(node);
539
+ if (destination && Array.isArray(node.attributes) && !placed.has(node)) {
420
540
  directories ??= {
421
541
  from: fileDirectory(library, document),
422
542
  to: fileDirectory(library, destination),
@@ -439,17 +559,18 @@ function rebase(tree, document, library, prefix, destination, moved) {
439
559
  : rewriteAttributes(tag, (name, value) => name === "id"
440
560
  ? `${prefix}${value}`
441
561
  : name === "srcset"
442
- ? sourceSrcSet(value)
562
+ ? srcSet(value)
443
563
  : REBASED_ATTRIBUTES.has(name)
444
- ? sourceUrl(value)
564
+ ? url(value)
445
565
  : undefined));
446
566
  const attrs = node.data?.hProperties;
447
567
  for (const key of ["href", "src", "poster", "data", "xLinkHref"])
448
568
  if (typeof attrs?.[key] === "string")
449
- attrs[key] = sourceUrl(attrs[key]);
569
+ attrs[key] = url(attrs[key]);
450
570
  for (const key of ["srcSet", "srcset"])
451
571
  if (typeof attrs?.[key] === "string")
452
- attrs[key] = sourceSrcSet(attrs[key]);
572
+ attrs[key] = srcSet(attrs[key]);
573
+ placed.add(node);
453
574
  });
454
575
  }
455
576
  /** The default columns of a summary table, as they have always been. */
@@ -514,7 +635,9 @@ const sectionTables = (tree, skipHeaders) => {
514
635
  * problem, which is worded as what the column asked for and what the section
515
636
  * has, so an author can tell a wrong coordinate from a missing table.
516
637
  */
517
- export function extractCell(library, column, row, context) {
638
+ export function extractCell(library, column, row, context,
639
+ /** The tree line the cell is for, handed to an extractor. */
640
+ node) {
518
641
  const spec = columnSpec(column);
519
642
  const where = `${row.document.id}${row.section.anchorId ? `#${row.section.anchorId}` : ""}`;
520
643
  const linkTo = () => {
@@ -556,6 +679,7 @@ export function extractCell(library, column, row, context) {
556
679
  library,
557
680
  documentId: context.documentId,
558
681
  column,
682
+ ...(node ? { node } : {}),
559
683
  });
560
684
  const text = typeof result === "string" ? result : result?.text;
561
685
  if (!text)
@@ -616,14 +740,292 @@ export function buildEmbedTable(library, columns, rows, context) {
616
740
  ],
617
741
  };
618
742
  }
743
+ /** The default line of a tree: the title linked to its node, then the summary. */
744
+ export const DEFAULT_TREE_COLUMNS = ["link", "summary"];
745
+ /** Whether a column is computed by a registered function, which may read anything. */
746
+ const usesExtractor = (columns) => columns.some((column) => typeof column === "object" &&
747
+ typeof column.value === "object" &&
748
+ "extractor" in column.value);
749
+ /** Whether an `order` entry names a node: its name, or its title, in NFC. */
750
+ export const namesTreeNode = (entry, node) => {
751
+ const name = nfc(entry);
752
+ return node.name === name || nfc(node.title) === name;
753
+ };
754
+ /**
755
+ * A document's `#` title, from the top-level block at `from` on: a `#`
756
+ * heading there, or one inside a top-level `header` element, where Docusaurus
757
+ * puts the heading it reads the page title from.
758
+ */
759
+ const titleHeading = (tree, from = 0) => {
760
+ for (let index = from; index < tree.children.length; index++) {
761
+ const node = tree.children[index];
762
+ const heading = node.type === "heading"
763
+ ? node
764
+ : node.data?.hName === "header"
765
+ ? node.children?.find((child) => child.type === "heading")
766
+ : undefined;
767
+ if (heading?.depth === 1)
768
+ return {
769
+ heading: heading,
770
+ index,
771
+ };
772
+ }
773
+ return undefined;
774
+ };
775
+ /** Heading depths from `from` to the deepest `headings` reaches, `##` being 2. */
776
+ const headingDepths = (from, headings) => {
777
+ const depths = [];
778
+ for (let depth = Math.max(from, 2); depth <= headings + 1; depth++)
779
+ depths.push(depth);
780
+ return depths;
781
+ };
782
+ /**
783
+ * The lines of a tree, without cells when `cells` is false, and every
784
+ * document they were read from.
785
+ */
786
+ function buildTree(library, sources, render, from, context, cells = true) {
787
+ const hierarchy = hierarchyOf(library);
788
+ const depth = render.depth ?? Infinity;
789
+ const headings = render.headings ?? 0;
790
+ const columns = render.columns ?? DEFAULT_TREE_COLUMNS;
791
+ const rows = new Map();
792
+ const documents = new Set();
793
+ // A page that is not private lists no private document it was not asked
794
+ // for by name: a folder or a parent would otherwise put one on it, and the
795
+ // export, which leaves private documents out, could not link to it.
796
+ const privateListed = listsPrivate(library, context.documentId);
797
+ const listed = (document) => !document.private || privateListed;
798
+ const create = (row, kind, name, level) => {
799
+ documents.add(row.document.id);
800
+ const node = {
801
+ id: `${row.document.id}${row.section.anchorId ? `#${row.section.anchorId}` : ""}`,
802
+ kind,
803
+ documentId: row.document.id,
804
+ ...(row.section.anchorId ? { anchorId: row.section.anchorId } : {}),
805
+ name,
806
+ title: row.section.title,
807
+ url: row.url,
808
+ sourcePath: row.document.sourcePath,
809
+ level,
810
+ cells: [],
811
+ children: [],
812
+ };
813
+ rows.set(node, row);
814
+ return node;
815
+ };
816
+ /** The headings of `sections` nested by depth under a line at `level`. */
817
+ const nest = (document, sections, level) => {
818
+ const top = [];
819
+ // A heading past `depth` is still pushed, without a node, so the ones
820
+ // under it are left out with it rather than moved up a level.
821
+ const stack = [];
822
+ for (const section of sections) {
823
+ while (stack.length && stack.at(-1).depth >= section.heading.depth)
824
+ stack.pop();
825
+ const above = stack.at(-1);
826
+ const at = (above?.level ?? level) + 1;
827
+ const node = at <= depth && (!above || above.node)
828
+ ? headingNode(document, section, at)
829
+ : undefined;
830
+ stack.push({ depth: section.heading.depth, level: at, node });
831
+ if (node)
832
+ (above ? above.node.children : top).push(node);
833
+ }
834
+ return top;
835
+ };
836
+ const headingNode = (document, section, level) => {
837
+ const row = buildEmbedRow(document, section.anchorId, section.tree);
838
+ return create(row, "heading", nfc(row.section.title), level);
839
+ };
840
+ /** A section a source names, with the headings of its own below it. */
841
+ const sectionNode = (document, section) => {
842
+ const node = headingNode(document, section, 1);
843
+ const depths = headingDepths(section.heading.depth + 1, headings);
844
+ if (1 < depth && depths.length)
845
+ node.children = nest(document, collectSections(section.tree, { depth: depths }), 1);
846
+ return node;
847
+ };
848
+ const documentNode = (document, level) => {
849
+ // The `#` title is the line's title, and what follows it, up to the next
850
+ // `#`, is what the line summarizes; anything above it, such as an
851
+ // outliner's property lines, is not.
852
+ const tree = document.tree;
853
+ const found = titleHeading(tree);
854
+ const name = documentName(document.id);
855
+ const named = typeof document.frontmatter.title === "string"
856
+ ? document.frontmatter.title.trim()
857
+ : "";
858
+ const title = (found && visibleHeadingText(found.heading)) ||
859
+ named ||
860
+ name;
861
+ const node = create({
862
+ document,
863
+ section: {
864
+ title,
865
+ tree: found
866
+ ? {
867
+ ...tree,
868
+ children: tree.children.slice(found.index, titleHeading(tree, found.index + 1)?.index),
869
+ }
870
+ : tree,
871
+ },
872
+ url: document.route,
873
+ }, "document", name, level);
874
+ if (level >= depth)
875
+ return node;
876
+ const sections = headings
877
+ ? nest(document, collectSections(tree, { depth: headingDepths(2, headings) }), level)
878
+ : [];
879
+ node.children = [
880
+ ...sections,
881
+ ...sorted(hierarchy
882
+ .children(document)
883
+ .filter(listed)
884
+ .map((child) => documentNode(child, level + 1))),
885
+ ];
886
+ return node;
887
+ };
888
+ const first = [];
889
+ for (const reference of sources) {
890
+ const source = resolveTreeSource(library, reference, from, context.documentId);
891
+ if ("folder" in source)
892
+ first.push(...sorted(source.documents
893
+ .filter(listed)
894
+ .map((document) => documentNode(document, 1))));
895
+ else if (source.anchor === undefined)
896
+ first.push(documentNode(source.document, 1));
897
+ else
898
+ first.push(...collectSections(source.document.tree, {
899
+ anchors: [source.anchor],
900
+ }).map((section) => sectionNode(source.document, section)));
901
+ }
902
+ let nodes = first;
903
+ if (render.order?.length) {
904
+ const order = render.order;
905
+ const rest = order.indexOf(REST);
906
+ const rank = (node) => {
907
+ const at = order.findIndex((entry, index) => index !== rest && namesTreeNode(entry, node));
908
+ return at >= 0 ? at : rest >= 0 ? rest : order.length;
909
+ };
910
+ nodes = first
911
+ .map((node, index) => ({ node, index, rank: rank(node) }))
912
+ .sort((a, b) => a.rank - b.rank || a.index - b.index)
913
+ .map(({ node }) => node);
914
+ }
915
+ if (cells) {
916
+ // Deepest first, so an extractor finds the lines below complete.
917
+ const fill = (node) => {
918
+ node.children.forEach(fill);
919
+ const row = rows.get(node);
920
+ node.cells = columns.map((column) => extractCell(library, column, row, context, node).cell);
921
+ };
922
+ nodes.forEach(fill);
923
+ }
924
+ return { nodes, documents };
925
+ }
926
+ /** Documents in the order a tree lists them: by title, then name, then id. */
927
+ const sorted = (nodes) => nodes.sort((a, b) => compareNames(a.title, b.title) ||
928
+ compareNames(a.name, b.name) ||
929
+ compareCodePoints(a.id, b.id));
930
+ /**
931
+ * The lines of a tree embed as data, the same lines the renderer draws, for
932
+ * a program that writes the tree in a form of its own, such as an outliner's
933
+ * blocks. `spec` is an embed whose render is a tree; sources resolve from
934
+ * `context.documentId`, which extractors also receive. Nodes come in the
935
+ * order the tree shows them, every level down to `depth`, whatever `open`
936
+ * and `print` say; equal input gives equal output, order and all.
937
+ */
938
+ export function resolveTree(library, input, context) {
939
+ const spec = parseEmbedSpec(JSON.stringify(input));
940
+ if (typeof spec.render !== "object" || spec.render.type !== "tree")
941
+ throw new Error("cudoc: resolveTree needs an embed whose render is a tree");
942
+ return buildTree(library, spec.sources, spec.render, context.documentId, context).nodes;
943
+ }
944
+ /**
945
+ * The mdast a tree renders to: nested lists, an item with children wrapped
946
+ * in a `details` element whose `summary` is the item's line, open down to
947
+ * `open` levels. Portable elements only, so it works without a script on
948
+ * every host. The outer list carries the `cudoc-tree` class, `data.cudoc.kind`
949
+ * `tree` and, when `print` is set, `data-cudoc-print`, which the print HTML
950
+ * and Word read.
951
+ */
952
+ export function buildEmbedTree(nodes, render) {
953
+ const open = render.open ?? 1;
954
+ const text = (value) => ({ type: "text", value });
955
+ const line = (node) => {
956
+ const shown = node.cells.filter((cell) => cell.text);
957
+ // A line has to say something: with every column empty, the title.
958
+ return {
959
+ type: "paragraph",
960
+ children: (shown.length ? shown : [{ text: node.title }]).flatMap((cell, index) => [
961
+ ...(index ? [text(" · ")] : []),
962
+ cell.url
963
+ ? { type: "link", url: cell.url, children: [text(cell.text)] }
964
+ : text(cell.text),
965
+ ]),
966
+ };
967
+ };
968
+ const list = (items, outer) => ({
969
+ type: "list",
970
+ ordered: false,
971
+ spread: false,
972
+ ...(outer
973
+ ? {
974
+ data: {
975
+ hProperties: {
976
+ className: [TREE_CLASS],
977
+ ...(render.print ? { [TREE_PRINT_ATTRIBUTE]: render.print } : {}),
978
+ },
979
+ cudoc: { kind: TREE_KIND },
980
+ },
981
+ }
982
+ : {}),
983
+ children: items.map((node) => node.children.length
984
+ ? {
985
+ type: "listItem",
986
+ spread: false,
987
+ children: [
988
+ {
989
+ type: "blockquote",
990
+ data: {
991
+ hName: "details",
992
+ ...(node.level <= open
993
+ ? { hProperties: { open: true } }
994
+ : {}),
995
+ },
996
+ children: [
997
+ { ...line(node), data: { hName: "summary" } },
998
+ list(node.children, false),
999
+ ],
1000
+ },
1001
+ ],
1002
+ }
1003
+ : {
1004
+ type: "listItem",
1005
+ spread: false,
1006
+ data: { hProperties: { className: [`${TREE_CLASS}-leaf`] } },
1007
+ children: [line(node)],
1008
+ }),
1009
+ });
1010
+ return { type: "root", children: [list(nodes, true)] };
1011
+ }
619
1012
  /** Resolve a configured embed from immutable persisted documents. */
620
1013
  export function resolveEmbed(library, input, context) {
621
1014
  const spec = parseEmbedSpec(JSON.stringify(input));
622
1015
  let occurrence = 0;
623
1016
  const reserved = new Set();
624
1017
  const destination = documentIndex(library).byId.get(context.documentId);
625
- // Elements whose required modules already start from `destination`.
626
- const moved = new WeakSet();
1018
+ // Nodes already on the page: those a copy rebased, whose paths and
1019
+ // required modules now start from `destination`, and those a table or a
1020
+ // tree wrote there. A copy around them renames their ids, and leaves the
1021
+ // rest as it is.
1022
+ const placed = new WeakSet();
1023
+ const place = (root) => {
1024
+ visitNodes(root, (node) => {
1025
+ placed.add(node);
1026
+ });
1027
+ return root;
1028
+ };
627
1029
  if (destination)
628
1030
  for (const id of declaredIds(destination.tree))
629
1031
  reserved.add(id);
@@ -632,6 +1034,15 @@ export function resolveEmbed(library, input, context) {
632
1034
  // block is still current; `*` when an extractor ran, which may read anything.
633
1035
  const dependencies = new Set();
634
1036
  const expand = (spec, from) => {
1037
+ // A tree copies no section, so nothing in it expands or cycles; what it
1038
+ // depends on is every document a line was read from.
1039
+ if (typeof spec.render === "object" && spec.render.type === "tree") {
1040
+ const tree = buildTree(library, spec.sources, spec.render, from, context);
1041
+ tree.documents.forEach((id) => dependencies.add(id));
1042
+ if (usesExtractor(spec.render.columns ?? DEFAULT_TREE_COLUMNS))
1043
+ dependencies.add("*");
1044
+ return place(buildEmbedTree(tree.nodes, spec.render));
1045
+ }
635
1046
  const children = [];
636
1047
  const rows = [];
637
1048
  for (const source of spec.sources) {
@@ -680,7 +1091,7 @@ export function resolveEmbed(library, input, context) {
680
1091
  do {
681
1092
  prefix = `${context.prefix ?? "embed"}-${++occurrence}-`;
682
1093
  } while (sectionIds.some((id) => reserved.has(`${prefix}${id}`)));
683
- rebase(section, document, library, prefix, destination, moved);
1094
+ rebase(section, document, library, prefix, destination, placed);
684
1095
  sectionIds.forEach((id) => reserved.add(`${prefix}${id}`));
685
1096
  children.push(...section.children);
686
1097
  }
@@ -691,11 +1102,9 @@ export function resolveEmbed(library, input, context) {
691
1102
  }
692
1103
  if (spec.render && spec.render !== "section") {
693
1104
  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))
1105
+ if (usesExtractor(columns))
697
1106
  dependencies.add("*");
698
- return buildEmbedTable(library, columns, rows, context);
1107
+ return place(buildEmbedTable(library, columns, rows, context));
699
1108
  }
700
1109
  return { type: "root", children };
701
1110
  };