@pterodoc/core 0.2.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 (130) hide show
  1. package/LICENCE.md +10 -0
  2. package/README.md +12 -0
  3. package/lib/chunks/capture-BhdvHwrN.js +428 -0
  4. package/lib/chunks/capture-BhdvHwrN.js.map +1 -0
  5. package/lib/chunks/issues-DBsPh6ek.js +55 -0
  6. package/lib/chunks/issues-DBsPh6ek.js.map +1 -0
  7. package/lib/chunks/mime-2SUyOolJ.js +62 -0
  8. package/lib/chunks/mime-2SUyOolJ.js.map +1 -0
  9. package/lib/chunks/paths-Bh6UsRI9.js +177 -0
  10. package/lib/chunks/paths-Bh6UsRI9.js.map +1 -0
  11. package/lib/config/load.d.ts +123 -0
  12. package/lib/config/load.d.ts.map +1 -0
  13. package/lib/config/types.d.ts +136 -0
  14. package/lib/config/types.d.ts.map +1 -0
  15. package/lib/errors.d.ts +53 -0
  16. package/lib/errors.d.ts.map +1 -0
  17. package/lib/index.d.ts +23 -0
  18. package/lib/index.d.ts.map +1 -0
  19. package/lib/index.js +807 -0
  20. package/lib/index.js.map +1 -0
  21. package/lib/model/capture.d.ts +37 -0
  22. package/lib/model/capture.d.ts.map +1 -0
  23. package/lib/model/index.d.ts +13 -0
  24. package/lib/model/index.d.ts.map +1 -0
  25. package/lib/model/index.js +7 -0
  26. package/lib/model/index.js.map +1 -0
  27. package/lib/model/reader.d.ts +25 -0
  28. package/lib/model/reader.d.ts.map +1 -0
  29. package/lib/model/tree.d.ts +81 -0
  30. package/lib/model/tree.d.ts.map +1 -0
  31. package/lib/model/types.d.ts +166 -0
  32. package/lib/model/types.d.ts.map +1 -0
  33. package/lib/render/admonitions.d.ts +26 -0
  34. package/lib/render/admonitions.d.ts.map +1 -0
  35. package/lib/render/blocks.d.ts +39 -0
  36. package/lib/render/blocks.d.ts.map +1 -0
  37. package/lib/render/code.d.ts +37 -0
  38. package/lib/render/code.d.ts.map +1 -0
  39. package/lib/render/components.d.ts +49 -0
  40. package/lib/render/components.d.ts.map +1 -0
  41. package/lib/render/excerpt.d.ts +9 -0
  42. package/lib/render/excerpt.d.ts.map +1 -0
  43. package/lib/render/images.d.ts +58 -0
  44. package/lib/render/images.d.ts.map +1 -0
  45. package/lib/render/index.d.ts +80 -0
  46. package/lib/render/index.d.ts.map +1 -0
  47. package/lib/render/index.js +1336 -0
  48. package/lib/render/index.js.map +1 -0
  49. package/lib/render/inline.d.ts +25 -0
  50. package/lib/render/inline.d.ts.map +1 -0
  51. package/lib/render/links.d.ts +45 -0
  52. package/lib/render/links.d.ts.map +1 -0
  53. package/lib/render/mdx.d.ts +37 -0
  54. package/lib/render/mdx.d.ts.map +1 -0
  55. package/lib/render/page.d.ts +85 -0
  56. package/lib/render/page.d.ts.map +1 -0
  57. package/lib/render/parse.d.ts +27 -0
  58. package/lib/render/parse.d.ts.map +1 -0
  59. package/lib/render/references.d.ts +20 -0
  60. package/lib/render/references.d.ts.map +1 -0
  61. package/lib/render/renderers.d.ts +57 -0
  62. package/lib/render/renderers.d.ts.map +1 -0
  63. package/lib/render/slug.d.ts +34 -0
  64. package/lib/render/slug.d.ts.map +1 -0
  65. package/lib/render/theme.d.ts +62 -0
  66. package/lib/render/theme.d.ts.map +1 -0
  67. package/lib/sync/artifacts.d.ts +46 -0
  68. package/lib/sync/artifacts.d.ts.map +1 -0
  69. package/lib/sync/plan.d.ts +59 -0
  70. package/lib/sync/plan.d.ts.map +1 -0
  71. package/lib/sync/run.d.ts +39 -0
  72. package/lib/sync/run.d.ts.map +1 -0
  73. package/lib/target/index.d.ts +8 -0
  74. package/lib/target/index.d.ts.map +1 -0
  75. package/lib/target/index.js +2 -0
  76. package/lib/target/index.js.map +1 -0
  77. package/lib/target/target.d.ts +152 -0
  78. package/lib/target/target.d.ts.map +1 -0
  79. package/lib/util/hash.d.ts +10 -0
  80. package/lib/util/hash.d.ts.map +1 -0
  81. package/lib/util/index.d.ts +14 -0
  82. package/lib/util/index.d.ts.map +1 -0
  83. package/lib/util/index.js +7 -0
  84. package/lib/util/index.js.map +1 -0
  85. package/lib/util/issues.d.ts +52 -0
  86. package/lib/util/issues.d.ts.map +1 -0
  87. package/lib/util/mime.d.ts +16 -0
  88. package/lib/util/mime.d.ts.map +1 -0
  89. package/lib/util/paths.d.ts +42 -0
  90. package/lib/util/paths.d.ts.map +1 -0
  91. package/lib/version.d.ts +6 -0
  92. package/lib/version.d.ts.map +1 -0
  93. package/package.json +43 -0
  94. package/src/config/load.ts +387 -0
  95. package/src/config/types.ts +137 -0
  96. package/src/errors.ts +77 -0
  97. package/src/globals.d.ts +10 -0
  98. package/src/index.ts +31 -0
  99. package/src/model/capture.ts +92 -0
  100. package/src/model/index.ts +25 -0
  101. package/src/model/reader.ts +37 -0
  102. package/src/model/tree.ts +416 -0
  103. package/src/model/types.ts +167 -0
  104. package/src/render/admonitions.ts +74 -0
  105. package/src/render/blocks.ts +79 -0
  106. package/src/render/code.ts +112 -0
  107. package/src/render/components.ts +230 -0
  108. package/src/render/excerpt.ts +16 -0
  109. package/src/render/images.ts +113 -0
  110. package/src/render/index.ts +129 -0
  111. package/src/render/inline.ts +62 -0
  112. package/src/render/links.ts +94 -0
  113. package/src/render/mdx.ts +178 -0
  114. package/src/render/page.ts +289 -0
  115. package/src/render/parse.ts +65 -0
  116. package/src/render/references.ts +69 -0
  117. package/src/render/renderers.ts +346 -0
  118. package/src/render/slug.ts +63 -0
  119. package/src/render/theme.ts +92 -0
  120. package/src/sync/artifacts.ts +70 -0
  121. package/src/sync/plan.ts +69 -0
  122. package/src/sync/run.ts +641 -0
  123. package/src/target/index.ts +18 -0
  124. package/src/target/target.ts +146 -0
  125. package/src/util/hash.ts +14 -0
  126. package/src/util/index.ts +23 -0
  127. package/src/util/issues.ts +86 -0
  128. package/src/util/mime.ts +47 -0
  129. package/src/util/paths.ts +101 -0
  130. package/src/version.ts +31 -0
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Capturing a site model to disk.
3
+ *
4
+ * A captured model is how the tests avoid needing a Docusaurus install, how a
5
+ * user files a reproducible bug, and how a site whose Docusaurus version this
6
+ * tool cannot load can still be rendered.
7
+ */
8
+
9
+ import fs from 'node:fs/promises';
10
+ import path from 'node:path';
11
+ import { ConfigError, VERSION } from '../util';
12
+ import type { SiteModel } from './types';
13
+ import type { SourceReader } from './reader';
14
+
15
+ /** A model as it is stored, with enough provenance to read it later. */
16
+ export interface CapturedModel {
17
+ /** Format version of this file. */
18
+ capture: 1;
19
+ /** Version of pterodoc that wrote it. */
20
+ pterodoc: string;
21
+ /** When it was written. */
22
+ capturedAt: string;
23
+ /** The model itself. */
24
+ model: SiteModel;
25
+ }
26
+
27
+ /** Serialise a model. */
28
+ export function serializeModel(model: SiteModel): string {
29
+ const captured: CapturedModel = {
30
+ capture: 1,
31
+ pterodoc: VERSION,
32
+ capturedAt: new Date().toISOString(),
33
+ model,
34
+ };
35
+ return `${JSON.stringify(captured, null, 2)}\n`;
36
+ }
37
+
38
+ /** Write a model to a file, creating its directory. */
39
+ export async function writeCapture(file: string, model: SiteModel): Promise<void> {
40
+ await fs.mkdir(path.dirname(path.resolve(file)), { recursive: true });
41
+ await fs.writeFile(file, serializeModel(model), 'utf8');
42
+ }
43
+
44
+ /**
45
+ * Read a captured model.
46
+ *
47
+ * @param file Path of the capture.
48
+ */
49
+ export async function readCapture(file: string): Promise<SiteModel> {
50
+ let raw: string;
51
+ try {
52
+ raw = await fs.readFile(file, 'utf8');
53
+ } catch {
54
+ throw new ConfigError(`Could not read the model at ${file}.`);
55
+ }
56
+
57
+ let parsed: Partial<CapturedModel>;
58
+ try {
59
+ parsed = JSON.parse(raw) as Partial<CapturedModel>;
60
+ } catch {
61
+ throw new ConfigError(`${file} is not valid JSON.`);
62
+ }
63
+
64
+ if (parsed.capture !== 1 || !parsed.model) {
65
+ throw new ConfigError(
66
+ `${file} is not a pterodoc model capture. Produce one with \`pterodoc capture\`.`,
67
+ );
68
+ }
69
+ return parsed.model;
70
+ }
71
+
72
+ /**
73
+ * Read from a model captured earlier.
74
+ *
75
+ * @param file Path to the capture.
76
+ */
77
+ export function createCaptureReader(file: string): SourceReader {
78
+ let cached: SiteModel | undefined;
79
+ const load = async (): Promise<SiteModel> => {
80
+ cached ??= await readCapture(file);
81
+ return cached;
82
+ };
83
+ return {
84
+ kind: 'captured',
85
+ async locales(): Promise<string[]> {
86
+ return [(await load()).locale];
87
+ },
88
+ async read(): Promise<SiteModel> {
89
+ return load();
90
+ },
91
+ };
92
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The site model: what a source produces and what the reconciler consumes.
3
+ *
4
+ * This layer knows nothing about where a model came from or where it is going.
5
+ */
6
+
7
+ export type {
8
+ Doc,
9
+ DocNeighbour,
10
+ DocsInstance,
11
+ DocsVersion,
12
+ SidebarCategoryItem,
13
+ SidebarCategoryLink,
14
+ SidebarDocItem,
15
+ SidebarHtmlItem,
16
+ SidebarItem,
17
+ SidebarLinkItem,
18
+ SiteModel,
19
+ } from './types';
20
+ export { buildPageTree } from './tree';
21
+ export type { BuildPageTreeInput, PageKind, PageNode, PageTree } from './tree';
22
+ export { createMemoryReader } from './reader';
23
+ export type { SourceReader } from './reader';
24
+ export { createCaptureReader, readCapture, serializeModel, writeCapture } from './capture';
25
+ export type { CapturedModel } from './capture';
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Where a site model comes from.
3
+ *
4
+ * The rest of the tool takes a reader rather than calling a source directly,
5
+ * which is what lets it be tested against a captured model, driven by a build,
6
+ * or pointed at a real site. The interface lives here, with the model it
7
+ * produces; each source implements it in its own package.
8
+ */
9
+
10
+ import type { SiteModel } from './types';
11
+
12
+ /** Supplies one site model per locale. */
13
+ export interface SourceReader {
14
+ /** How this reader found the model, for the plan and for messages. */
15
+ readonly kind: 'docusaurus' | 'captured' | 'memory';
16
+ /** Locales this reader can produce, in the order they should be published. */
17
+ locales(): Promise<string[]>;
18
+ /** The model for one locale. */
19
+ read(locale?: string): Promise<SiteModel>;
20
+ }
21
+
22
+ /**
23
+ * Read from a model already in hand, which is what a build-time plugin does.
24
+ *
25
+ * @param model The model to serve.
26
+ */
27
+ export function createMemoryReader(model: SiteModel): SourceReader {
28
+ return {
29
+ kind: 'memory',
30
+ async locales(): Promise<string[]> {
31
+ return [model.locale];
32
+ },
33
+ async read(): Promise<SiteModel> {
34
+ return model;
35
+ },
36
+ };
37
+ }
@@ -0,0 +1,416 @@
1
+ /**
2
+ * The page tree.
3
+ *
4
+ * Docusaurus hands over resolved sidebars — autogenerated ones already
5
+ * expanded — and documents that already know their permalink and their
6
+ * neighbours. This turns that into the tree of pages the target will mirror:
7
+ * one node per page, ordered as the sidebar orders them.
8
+ */
9
+
10
+ import { titleCase, type IssueCollector } from '../util';
11
+ import type { Doc, DocsVersion, SidebarCategoryItem, SidebarItem } from './types';
12
+
13
+ /** What produced a page. */
14
+ export type PageKind = 'root' | 'doc' | 'dir' | 'generated-index';
15
+
16
+ /** One page in the tree. */
17
+ export interface PageNode {
18
+ /** Path relative to the version root; '' is the root itself. */
19
+ path: string;
20
+ /** Last segment of `path`, which becomes the target's slug. */
21
+ slug: string;
22
+ /** What produced this page. */
23
+ kind: PageKind;
24
+ /** Title shown on the page and in navigation. */
25
+ title: string;
26
+ /** Description, for a generated index. */
27
+ description?: string | undefined;
28
+ /** The document backing this page, when one does. */
29
+ doc?: Doc | undefined;
30
+ /** Parent page; absent on the root. */
31
+ parent?: PageNode | undefined;
32
+ /** Children, in sidebar order. */
33
+ children: PageNode[];
34
+ /** Ten times the position among siblings, matching WordPress's convention. */
35
+ menuOrder: number;
36
+ /** Position in the flattened order; -1 on the root. */
37
+ navIndex: number;
38
+ /** Children grouped by the category that named them, for generated indexes. */
39
+ sections: { label?: string | undefined; children: PageNode[] }[];
40
+ /** Tree path of the previous page, from Docusaurus's own pagination. */
41
+ previousPath?: string | undefined;
42
+ /** Tree path of the next page. */
43
+ nextPath?: string | undefined;
44
+ /** Version this page belongs to. */
45
+ versionName: string;
46
+ /** False for a page no sidebar mentions. */
47
+ inSidebar: boolean;
48
+ }
49
+
50
+ /** The tree, plus the indexes callers need to look pages up. */
51
+ export interface PageTree {
52
+ /** The version root. */
53
+ root: PageNode;
54
+ /** Every page except the root, in sidebar order. */
55
+ chain: PageNode[];
56
+ /** Pages by tree path. */
57
+ byPath: Map<string, PageNode>;
58
+ /** Pages by the permalink Docusaurus gave their document. */
59
+ byPermalink: Map<string, PageNode>;
60
+ /** Pages by their source file, relative to the site directory. */
61
+ bySourcePath: Map<string, PageNode>;
62
+ }
63
+
64
+ /** Everything needed to build one version's tree. */
65
+ export interface BuildPageTreeInput {
66
+ /** The version being published. */
67
+ version: DocsVersion;
68
+ /** Sidebar ids to publish, or 'all'. */
69
+ sidebars: string[] | 'all';
70
+ /** Title for the version root when no document claims it. */
71
+ rootTitle: string;
72
+ /** Publish documents Docusaurus marked unlisted. */
73
+ includeUnlisted?: boolean;
74
+ /** Publish documents that belong to no sidebar at all. */
75
+ includeOrphans?: boolean;
76
+ /** Where skipped entries are reported. */
77
+ issues: IssueCollector;
78
+ }
79
+
80
+ /** The longest directory prefix shared by a set of tree paths. */
81
+ function commonDirectory(paths: string[]): string {
82
+ if (paths.length === 0) return '';
83
+ const dirOf = (value: string): string[] => {
84
+ const parts = value.split('/').filter(Boolean);
85
+ return parts.slice(0, -1);
86
+ };
87
+ let common = dirOf(paths[0]!);
88
+ for (const value of paths.slice(1)) {
89
+ const parts = dirOf(value);
90
+ let index = 0;
91
+ while (index < common.length && index < parts.length && common[index] === parts[index]) index += 1;
92
+ common = common.slice(0, index);
93
+ if (common.length === 0) break;
94
+ }
95
+ return common.join('/');
96
+ }
97
+
98
+ /** A category, flattened. */
99
+ interface FlatCategory {
100
+ label: string;
101
+ /** Tree path of the category's own page, when it has one. */
102
+ linkPath?: string | undefined;
103
+ /** Tree paths of every document below this category, excluding its own page. */
104
+ deepPaths: string[];
105
+ /** Tree paths of the documents directly inside it. */
106
+ directPaths: string[];
107
+ }
108
+
109
+ /**
110
+ * Walk resolved sidebars into document order and the categories that can name
111
+ * a directory.
112
+ */
113
+ function flattenSidebars(
114
+ items: SidebarItem[],
115
+ docsById: Map<string, Doc>,
116
+ issues: IssueCollector,
117
+ ): { order: string[]; categories: FlatCategory[] } {
118
+ const order: string[] = [];
119
+ const categories: FlatCategory[] = [];
120
+
121
+ const pathOf = (id: string): string | undefined => docsById.get(id)?.treePath;
122
+
123
+ const walk = (nodes: SidebarItem[], deep: string[] | null, direct: string[] | null): void => {
124
+ for (const node of nodes) {
125
+ if (node.type === 'ref') {
126
+ // A ref points into a different sidebar. It is a cross-reference, not a
127
+ // page of this one, so it never becomes a page here.
128
+ issues.add({
129
+ code: 'sidebar-ref-item',
130
+ severity: 'info',
131
+ message: `The sidebar cross-references "${node.id}", which belongs to another sidebar and was not published here.`,
132
+ });
133
+ continue;
134
+ }
135
+
136
+ if (node.type === 'doc') {
137
+ const treePath = pathOf(node.id);
138
+ if (treePath === undefined) {
139
+ issues.add({
140
+ code: 'sidebar-doc-missing',
141
+ severity: 'warning',
142
+ message: `The sidebar lists "${node.id}", but no such document was loaded.`,
143
+ });
144
+ continue;
145
+ }
146
+ order.push(treePath);
147
+ deep?.push(treePath);
148
+ direct?.push(treePath);
149
+ continue;
150
+ }
151
+
152
+ if (node.type === 'category') {
153
+ const category = node as SidebarCategoryItem;
154
+ const deepPaths: string[] = [];
155
+ const directPaths: string[] = [];
156
+ let linkPath: string | undefined;
157
+
158
+ if (category.link?.type === 'doc') {
159
+ // The category's own page comes before its members. It is deliberately
160
+ // kept out of `deepPaths`: it sits one level above them, and counting
161
+ // it as a member would drag the shared directory up with it.
162
+ linkPath = pathOf(category.link.id);
163
+ if (linkPath !== undefined) order.push(linkPath);
164
+ }
165
+ walk(category.items ?? [], deepPaths, directPaths);
166
+ categories.push({ label: category.label, linkPath, deepPaths, directPaths });
167
+ // The parent does see it, because it really is one of the parent's pages.
168
+ if (linkPath !== undefined) deep?.push(linkPath);
169
+ deep?.push(...deepPaths);
170
+ continue;
171
+ }
172
+
173
+ // `link` and `html` entries are navigation furniture, not pages.
174
+ issues.add({
175
+ code: node.type === 'link' ? 'sidebar-link-item' : 'sidebar-html-item',
176
+ severity: 'info',
177
+ message:
178
+ node.type === 'link'
179
+ ? `The sidebar link "${node.label}" has no page to publish.`
180
+ : 'A raw HTML sidebar entry has no page to publish.',
181
+ });
182
+ }
183
+ };
184
+
185
+ walk(items, null, null);
186
+ return { order, categories };
187
+ }
188
+
189
+ /**
190
+ * Build one version's page tree.
191
+ */
192
+ export function buildPageTree(input: BuildPageTreeInput): PageTree {
193
+ const { version, issues } = input;
194
+ const sidebarNames =
195
+ input.sidebars === 'all' ? Object.keys(version.sidebars) : input.sidebars;
196
+
197
+ const publishable = version.docs.filter((doc) => {
198
+ if (doc.unlisted && input.includeUnlisted !== true) {
199
+ issues.addFor(doc, {
200
+ code: 'doc-unlisted',
201
+ severity: 'info',
202
+ message: `"${doc.id}" is unlisted and was not published.`,
203
+ });
204
+ return false;
205
+ }
206
+ return true;
207
+ });
208
+
209
+ const docsById = new Map(publishable.map((doc) => [doc.id, doc]));
210
+
211
+ const order: string[] = [];
212
+ const categories: FlatCategory[] = [];
213
+ for (const name of sidebarNames) {
214
+ const items = version.sidebars[name];
215
+ if (!items) {
216
+ issues.add({
217
+ code: 'sidebar-missing',
218
+ severity: 'warning',
219
+ message: `This version has no sidebar called "${name}".`,
220
+ });
221
+ continue;
222
+ }
223
+ const flattened = flattenSidebars(items, docsById, issues);
224
+ order.push(...flattened.order);
225
+ categories.push(...flattened.categories);
226
+ }
227
+
228
+ // Outermost category wins a directory; the rest become section labels.
229
+ const claimed = new Map<string, string>();
230
+ const virtual: FlatCategory[] = [];
231
+ for (const category of categories) {
232
+ if (category.deepPaths.length === 0 && category.linkPath === undefined) {
233
+ issues.add({
234
+ code: 'sidebar-category-empty',
235
+ severity: 'info',
236
+ message: `The sidebar category "${category.label}" has no published documents.`,
237
+ });
238
+ continue;
239
+ }
240
+ const directory = category.linkPath ?? commonDirectory(category.deepPaths);
241
+ if (directory && !claimed.has(directory)) claimed.set(directory, category.label);
242
+ else virtual.push(category);
243
+ }
244
+
245
+ const byPath = new Map<string, PageNode>();
246
+ const root: PageNode = {
247
+ path: '',
248
+ slug: '',
249
+ kind: 'root',
250
+ title: input.rootTitle,
251
+ children: [],
252
+ menuOrder: 0,
253
+ navIndex: -1,
254
+ sections: [],
255
+ versionName: version.name,
256
+ inSidebar: true,
257
+ };
258
+ byPath.set('', root);
259
+
260
+ const parentPathOf = (value: string): string => {
261
+ const parts = value.split('/').filter(Boolean);
262
+ return parts.slice(0, -1).join('/');
263
+ };
264
+
265
+ const ensureDir = (dirPath: string): PageNode => {
266
+ if (dirPath === '') return root;
267
+ const existing = byPath.get(dirPath);
268
+ if (existing) return existing;
269
+ const parent = ensureDir(parentPathOf(dirPath));
270
+ const slug = dirPath.slice(dirPath.lastIndexOf('/') + 1);
271
+ const node: PageNode = {
272
+ path: dirPath,
273
+ slug,
274
+ kind: 'dir',
275
+ title: claimed.get(dirPath) ?? titleCase(slug),
276
+ parent,
277
+ children: [],
278
+ menuOrder: 0,
279
+ navIndex: -1,
280
+ sections: [],
281
+ versionName: version.name,
282
+ inSidebar: false,
283
+ };
284
+ byPath.set(dirPath, node);
285
+ return node;
286
+ };
287
+
288
+ // Only what a published sidebar reaches becomes a page. A document that
289
+ // belongs to another sidebar — a generated API reference, say — is not
290
+ // orphaned, it simply is not part of what was asked for.
291
+ const reachable = new Set(order);
292
+ const included = publishable.filter((doc) => {
293
+ if (doc.treePath === '' || reachable.has(doc.treePath)) return true;
294
+ if (doc.sidebarName === undefined && input.includeOrphans === true) return true;
295
+ return false;
296
+ });
297
+
298
+ // The version root can be a document too, when one has the version's own slug.
299
+ const rootDoc = included.find((doc) => doc.treePath === '');
300
+ if (rootDoc) {
301
+ root.doc = rootDoc;
302
+ if (!input.rootTitle) root.title = rootDoc.title;
303
+ }
304
+
305
+ for (const doc of included) {
306
+ if (doc.treePath === '') continue;
307
+ const existing = byPath.get(doc.treePath);
308
+ if (existing) {
309
+ // A directory page and a document at the same path: the document wins,
310
+ // which is how an `index.md` or a front-matter slug takes over a folder.
311
+ existing.doc = doc;
312
+ existing.description = doc.description;
313
+ if (!claimed.has(doc.treePath)) existing.title = doc.title;
314
+ continue;
315
+ }
316
+ const parent = ensureDir(parentPathOf(doc.treePath));
317
+ const slug = doc.treePath.slice(doc.treePath.lastIndexOf('/') + 1);
318
+ byPath.set(doc.treePath, {
319
+ path: doc.treePath,
320
+ slug,
321
+ kind: 'doc',
322
+ // A category that names this page wins the title: on WordPress the page
323
+ // title is also its navigation label, and the sidebar label is what a
324
+ // reader saw in the navigation on Docusaurus.
325
+ title: claimed.get(doc.treePath) ?? doc.title,
326
+ description: doc.description,
327
+ doc,
328
+ parent,
329
+ children: [],
330
+ menuOrder: 0,
331
+ navIndex: -1,
332
+ sections: [],
333
+ versionName: version.name,
334
+ inSidebar: false,
335
+ });
336
+ }
337
+
338
+ // Sidebar order first: placing a node places any ancestor not yet placed, so
339
+ // a directory lands at the position of its first member.
340
+ const placed = new Set<string>(['']);
341
+ const place = (node: PageNode): void => {
342
+ if (placed.has(node.path)) return;
343
+ const parent = node.parent ?? root;
344
+ place(parent);
345
+ parent.children.push(node);
346
+ placed.add(node.path);
347
+ };
348
+
349
+ for (const treePath of order) {
350
+ const node = byPath.get(treePath);
351
+ if (!node) continue;
352
+ node.inSidebar = true;
353
+ if (node !== root) place(node);
354
+ }
355
+
356
+ for (const node of [...byPath.values()].sort((a, b) => a.path.localeCompare(b.path))) {
357
+ if (placed.has(node.path)) continue;
358
+ place(node);
359
+ issues.addFor(node.doc, {
360
+ code: 'doc-not-in-sidebar',
361
+ severity: 'info',
362
+ message: `"${node.path}" is not in a published sidebar; it was placed after the pages that are.`,
363
+ path: node.path,
364
+ });
365
+ }
366
+
367
+ // Grouping for generated indexes.
368
+ const sectionLabelOf = new Map<string, string>();
369
+ for (const category of virtual) {
370
+ for (const treePath of category.directPaths) sectionLabelOf.set(treePath, category.label);
371
+ }
372
+
373
+ const chain: PageNode[] = [];
374
+ const finish = (node: PageNode): void => {
375
+ node.children.forEach((child, index) => {
376
+ child.menuOrder = index * 10;
377
+ });
378
+ const sections: PageNode['sections'] = [];
379
+ for (const child of node.children) {
380
+ const label = sectionLabelOf.get(child.path);
381
+ const last = sections[sections.length - 1];
382
+ if (last && last.label === label) last.children.push(child);
383
+ else sections.push({ label, children: [child] });
384
+ }
385
+ node.sections = sections;
386
+ for (const child of node.children) {
387
+ child.navIndex = chain.length;
388
+ chain.push(child);
389
+ finish(child);
390
+ }
391
+ };
392
+ finish(root);
393
+
394
+ // Pagination walks the pages that exist here.
395
+ //
396
+ // Docusaurus's own previous/next skip a category that has no document of its
397
+ // own, because on that site such a category is not a page. Here it is one:
398
+ // it has a URL a reader can land on, so sending them past it would be wrong.
399
+ chain.forEach((node, index) => {
400
+ const previous = chain[index - 1];
401
+ const next = chain[index + 1];
402
+ if (previous) node.previousPath = previous.path;
403
+ if (next) node.nextPath = next.path;
404
+ });
405
+
406
+ const byPermalink = new Map<string, PageNode>();
407
+ const bySourcePath = new Map<string, PageNode>();
408
+ for (const node of byPath.values()) {
409
+ if (!node.doc) continue;
410
+ byPermalink.set(node.doc.permalink, node);
411
+ byPermalink.set(node.doc.permalink.replace(/\/$/, ''), node);
412
+ bySourcePath.set(node.doc.sourceRelativePath, node);
413
+ }
414
+
415
+ return { root, chain, byPath, byPermalink, bySourcePath };
416
+ }