@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.
- package/LICENCE.md +10 -0
- package/README.md +12 -0
- package/lib/chunks/capture-BhdvHwrN.js +428 -0
- package/lib/chunks/capture-BhdvHwrN.js.map +1 -0
- package/lib/chunks/issues-DBsPh6ek.js +55 -0
- package/lib/chunks/issues-DBsPh6ek.js.map +1 -0
- package/lib/chunks/mime-2SUyOolJ.js +62 -0
- package/lib/chunks/mime-2SUyOolJ.js.map +1 -0
- package/lib/chunks/paths-Bh6UsRI9.js +177 -0
- package/lib/chunks/paths-Bh6UsRI9.js.map +1 -0
- package/lib/config/load.d.ts +123 -0
- package/lib/config/load.d.ts.map +1 -0
- package/lib/config/types.d.ts +136 -0
- package/lib/config/types.d.ts.map +1 -0
- package/lib/errors.d.ts +53 -0
- package/lib/errors.d.ts.map +1 -0
- package/lib/index.d.ts +23 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +807 -0
- package/lib/index.js.map +1 -0
- package/lib/model/capture.d.ts +37 -0
- package/lib/model/capture.d.ts.map +1 -0
- package/lib/model/index.d.ts +13 -0
- package/lib/model/index.d.ts.map +1 -0
- package/lib/model/index.js +7 -0
- package/lib/model/index.js.map +1 -0
- package/lib/model/reader.d.ts +25 -0
- package/lib/model/reader.d.ts.map +1 -0
- package/lib/model/tree.d.ts +81 -0
- package/lib/model/tree.d.ts.map +1 -0
- package/lib/model/types.d.ts +166 -0
- package/lib/model/types.d.ts.map +1 -0
- package/lib/render/admonitions.d.ts +26 -0
- package/lib/render/admonitions.d.ts.map +1 -0
- package/lib/render/blocks.d.ts +39 -0
- package/lib/render/blocks.d.ts.map +1 -0
- package/lib/render/code.d.ts +37 -0
- package/lib/render/code.d.ts.map +1 -0
- package/lib/render/components.d.ts +49 -0
- package/lib/render/components.d.ts.map +1 -0
- package/lib/render/excerpt.d.ts +9 -0
- package/lib/render/excerpt.d.ts.map +1 -0
- package/lib/render/images.d.ts +58 -0
- package/lib/render/images.d.ts.map +1 -0
- package/lib/render/index.d.ts +80 -0
- package/lib/render/index.d.ts.map +1 -0
- package/lib/render/index.js +1336 -0
- package/lib/render/index.js.map +1 -0
- package/lib/render/inline.d.ts +25 -0
- package/lib/render/inline.d.ts.map +1 -0
- package/lib/render/links.d.ts +45 -0
- package/lib/render/links.d.ts.map +1 -0
- package/lib/render/mdx.d.ts +37 -0
- package/lib/render/mdx.d.ts.map +1 -0
- package/lib/render/page.d.ts +85 -0
- package/lib/render/page.d.ts.map +1 -0
- package/lib/render/parse.d.ts +27 -0
- package/lib/render/parse.d.ts.map +1 -0
- package/lib/render/references.d.ts +20 -0
- package/lib/render/references.d.ts.map +1 -0
- package/lib/render/renderers.d.ts +57 -0
- package/lib/render/renderers.d.ts.map +1 -0
- package/lib/render/slug.d.ts +34 -0
- package/lib/render/slug.d.ts.map +1 -0
- package/lib/render/theme.d.ts +62 -0
- package/lib/render/theme.d.ts.map +1 -0
- package/lib/sync/artifacts.d.ts +46 -0
- package/lib/sync/artifacts.d.ts.map +1 -0
- package/lib/sync/plan.d.ts +59 -0
- package/lib/sync/plan.d.ts.map +1 -0
- package/lib/sync/run.d.ts +39 -0
- package/lib/sync/run.d.ts.map +1 -0
- package/lib/target/index.d.ts +8 -0
- package/lib/target/index.d.ts.map +1 -0
- package/lib/target/index.js +2 -0
- package/lib/target/index.js.map +1 -0
- package/lib/target/target.d.ts +152 -0
- package/lib/target/target.d.ts.map +1 -0
- package/lib/util/hash.d.ts +10 -0
- package/lib/util/hash.d.ts.map +1 -0
- package/lib/util/index.d.ts +14 -0
- package/lib/util/index.d.ts.map +1 -0
- package/lib/util/index.js +7 -0
- package/lib/util/index.js.map +1 -0
- package/lib/util/issues.d.ts +52 -0
- package/lib/util/issues.d.ts.map +1 -0
- package/lib/util/mime.d.ts +16 -0
- package/lib/util/mime.d.ts.map +1 -0
- package/lib/util/paths.d.ts +42 -0
- package/lib/util/paths.d.ts.map +1 -0
- package/lib/version.d.ts +6 -0
- package/lib/version.d.ts.map +1 -0
- package/package.json +43 -0
- package/src/config/load.ts +387 -0
- package/src/config/types.ts +137 -0
- package/src/errors.ts +77 -0
- package/src/globals.d.ts +10 -0
- package/src/index.ts +31 -0
- package/src/model/capture.ts +92 -0
- package/src/model/index.ts +25 -0
- package/src/model/reader.ts +37 -0
- package/src/model/tree.ts +416 -0
- package/src/model/types.ts +167 -0
- package/src/render/admonitions.ts +74 -0
- package/src/render/blocks.ts +79 -0
- package/src/render/code.ts +112 -0
- package/src/render/components.ts +230 -0
- package/src/render/excerpt.ts +16 -0
- package/src/render/images.ts +113 -0
- package/src/render/index.ts +129 -0
- package/src/render/inline.ts +62 -0
- package/src/render/links.ts +94 -0
- package/src/render/mdx.ts +178 -0
- package/src/render/page.ts +289 -0
- package/src/render/parse.ts +65 -0
- package/src/render/references.ts +69 -0
- package/src/render/renderers.ts +346 -0
- package/src/render/slug.ts +63 -0
- package/src/render/theme.ts +92 -0
- package/src/sync/artifacts.ts +70 -0
- package/src/sync/plan.ts +69 -0
- package/src/sync/run.ts +641 -0
- package/src/target/index.ts +18 -0
- package/src/target/target.ts +146 -0
- package/src/util/hash.ts +14 -0
- package/src/util/index.ts +23 -0
- package/src/util/issues.ts +86 -0
- package/src/util/mime.ts +47 -0
- package/src/util/paths.ts +101 -0
- package/src/version.ts +31 -0
package/src/sync/run.ts
ADDED
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reconciler.
|
|
3
|
+
*
|
|
4
|
+
* Two phases, because WordPress's navigation block needs a page id before any
|
|
5
|
+
* body can reference it: first make sure every page exists, then render every
|
|
6
|
+
* body against the real ids and write only what differs.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs/promises';
|
|
10
|
+
import { readFileSync } from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { VERSION } from '../version';
|
|
13
|
+
import { IssueCollector, type Issue } from '../util/issues';
|
|
14
|
+
import { contentHash } from '../util/hash';
|
|
15
|
+
import { mimeTypeFor } from '../util/mime';
|
|
16
|
+
import { titleCase } from '../util/paths';
|
|
17
|
+
import { buildPageTree, type PageNode, type PageTree } from '../model/tree';
|
|
18
|
+
import type { Doc, DocsVersion, SiteModel } from '../model/types';
|
|
19
|
+
import type { SourceReader } from '../model/reader';
|
|
20
|
+
import { renderDoc, excerptFor } from '../render/index';
|
|
21
|
+
import { composePage, renderVersionBanner, type PageLayout } from '../render/page';
|
|
22
|
+
import { createTheme, type Theme } from '../render/theme';
|
|
23
|
+
import { collectImages, resolveImage } from '../render/images';
|
|
24
|
+
import type { LinkResolver } from '../render/links';
|
|
25
|
+
import type { MediaRef, RenderedPage, Target, TargetSession } from '../target/target';
|
|
26
|
+
import type { ResolvedConfig } from '../config/load';
|
|
27
|
+
import { summarise, type Action, type Plan } from './plan';
|
|
28
|
+
import { writeArtifacts, type Artifacts, type ManifestEntry } from './artifacts';
|
|
29
|
+
|
|
30
|
+
/** What a run needs beyond its configuration. */
|
|
31
|
+
export interface RunSyncDeps {
|
|
32
|
+
/** Where the site model comes from. */
|
|
33
|
+
reader: SourceReader;
|
|
34
|
+
/**
|
|
35
|
+
* Where pages are published.
|
|
36
|
+
*
|
|
37
|
+
* Always supplied, even for a run that publishes nothing: the target owns
|
|
38
|
+
* URL policy, so rendering needs it to know what a page's address will be.
|
|
39
|
+
*/
|
|
40
|
+
target?: Target | undefined;
|
|
41
|
+
/** Render only: build no session and contact nothing. */
|
|
42
|
+
renderOnly?: boolean | undefined;
|
|
43
|
+
/** Progress worth seeing under `--verbose`. */
|
|
44
|
+
log?: (message: string) => void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** What a run produced. */
|
|
48
|
+
export interface RunResult {
|
|
49
|
+
plan: Plan;
|
|
50
|
+
artifacts: Artifacts;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** One page, ready to be compared and written. */
|
|
54
|
+
interface PreparedPage {
|
|
55
|
+
node: PageNode;
|
|
56
|
+
page: RenderedPage;
|
|
57
|
+
locale: string;
|
|
58
|
+
versionName: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Run the sync. */
|
|
62
|
+
export async function runSync(config: ResolvedConfig, deps: RunSyncDeps): Promise<RunResult> {
|
|
63
|
+
const log = deps.log ?? ((): void => {});
|
|
64
|
+
const issues = new IssueCollector();
|
|
65
|
+
const actions: Action[] = [];
|
|
66
|
+
const prepared: PreparedPage[] = [];
|
|
67
|
+
const mediaRecords: Artifacts['media'] = [];
|
|
68
|
+
let requests = 0;
|
|
69
|
+
let mediaPending = 0;
|
|
70
|
+
let docusaurusVersion: string | null = null;
|
|
71
|
+
|
|
72
|
+
const locales = await selectLocales(config, deps.reader);
|
|
73
|
+
|
|
74
|
+
for (const locale of locales) {
|
|
75
|
+
const model = await deps.reader.read(locale);
|
|
76
|
+
docusaurusVersion ??= model.docusaurusVersion;
|
|
77
|
+
|
|
78
|
+
const theme = createTheme({
|
|
79
|
+
classPrefix: config.classPrefix,
|
|
80
|
+
blocks: config.blocks,
|
|
81
|
+
strings: { ...config.strings, ...config.localeStrings[model.locale] },
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const session =
|
|
85
|
+
deps.target && !deps.renderOnly && !config.offline
|
|
86
|
+
? await deps.target.open({ locale: model.locale, dryRun: config.dryRun })
|
|
87
|
+
: undefined;
|
|
88
|
+
|
|
89
|
+
for (const instance of model.instances) {
|
|
90
|
+
for (const version of instance.versions) {
|
|
91
|
+
const result = await syncVersion({
|
|
92
|
+
config,
|
|
93
|
+
model,
|
|
94
|
+
instance: { admonitionKeywords: instance.admonitionKeywords },
|
|
95
|
+
version,
|
|
96
|
+
theme,
|
|
97
|
+
target: deps.target,
|
|
98
|
+
session,
|
|
99
|
+
issues,
|
|
100
|
+
log,
|
|
101
|
+
});
|
|
102
|
+
actions.push(...result.actions);
|
|
103
|
+
prepared.push(...result.prepared);
|
|
104
|
+
mediaRecords.push(...result.media);
|
|
105
|
+
mediaPending += result.mediaPending;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (session) requests += session.requestCount();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const rootPath = deps.target?.rootPath ?? '/';
|
|
113
|
+
const plan: Plan = {
|
|
114
|
+
generatedAt: new Date().toISOString(),
|
|
115
|
+
versions: { pterodoc: VERSION, docusaurus: docusaurusVersion, node: process.version },
|
|
116
|
+
dryRun: config.dryRun,
|
|
117
|
+
offline: config.offline,
|
|
118
|
+
site: config.targetUrl || null,
|
|
119
|
+
rootPath,
|
|
120
|
+
locales,
|
|
121
|
+
versionNames: [...new Set(prepared.map((entry) => entry.versionName))],
|
|
122
|
+
actions,
|
|
123
|
+
issues: issues.issues,
|
|
124
|
+
summary: summarise(actions),
|
|
125
|
+
requests,
|
|
126
|
+
mediaPending,
|
|
127
|
+
artifactError: null,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const artifacts: Artifacts = {
|
|
131
|
+
pages: prepared.map(({ page, locale, versionName }) => ({ page, locale, versionName })),
|
|
132
|
+
manifest: prepared.map(({ node, page, locale, versionName }) => ({
|
|
133
|
+
path: page.path,
|
|
134
|
+
title: page.title,
|
|
135
|
+
slug: page.slug,
|
|
136
|
+
parent: node.parent ? node.parent.path : null,
|
|
137
|
+
menuOrder: page.menuOrder,
|
|
138
|
+
locale,
|
|
139
|
+
versionName,
|
|
140
|
+
file: page.file ?? null,
|
|
141
|
+
href: deps.target ? deps.target.hrefFor(page.path, { versionName, locale }) : page.path,
|
|
142
|
+
})) satisfies ManifestEntry[],
|
|
143
|
+
media: mediaRecords,
|
|
144
|
+
plan,
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
await writeArtifacts(config.outDir, artifacts, { writePages: config.writePages });
|
|
149
|
+
} catch (error) {
|
|
150
|
+
plan.artifactError = error instanceof Error ? error.message : String(error);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { plan, artifacts };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Which locales to publish. */
|
|
157
|
+
async function selectLocales(config: ResolvedConfig, reader: SourceReader): Promise<string[]> {
|
|
158
|
+
if (Array.isArray(config.locales)) return config.locales;
|
|
159
|
+
if (config.locales === 'all') return reader.locales();
|
|
160
|
+
const model = await reader.read();
|
|
161
|
+
return [model.defaultLocale ?? model.locale];
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Everything one version's sync needs. */
|
|
165
|
+
interface SyncVersionInput {
|
|
166
|
+
config: ResolvedConfig;
|
|
167
|
+
model: SiteModel;
|
|
168
|
+
instance: { admonitionKeywords: string[] };
|
|
169
|
+
version: DocsVersion;
|
|
170
|
+
theme: Theme;
|
|
171
|
+
target: Target | undefined;
|
|
172
|
+
session: TargetSession | undefined;
|
|
173
|
+
issues: IssueCollector;
|
|
174
|
+
log: (message: string) => void;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Publish one version of one docs instance. */
|
|
178
|
+
async function syncVersion(input: SyncVersionInput): Promise<{
|
|
179
|
+
actions: Action[];
|
|
180
|
+
prepared: PreparedPage[];
|
|
181
|
+
media: Artifacts['media'];
|
|
182
|
+
mediaPending: number;
|
|
183
|
+
}> {
|
|
184
|
+
const { config, model, version, theme, target, session, issues, log } = input;
|
|
185
|
+
const actions: Action[] = [];
|
|
186
|
+
const locale = model.locale;
|
|
187
|
+
|
|
188
|
+
const tree = buildPageTree({
|
|
189
|
+
version,
|
|
190
|
+
sidebars: config.sidebars,
|
|
191
|
+
rootTitle: config.docsTitle,
|
|
192
|
+
includeUnlisted: config.includeUnlisted,
|
|
193
|
+
includeOrphans: config.includeOrphans,
|
|
194
|
+
issues,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const context = { versionName: version.name, locale };
|
|
198
|
+
const href = (treePath: string): string =>
|
|
199
|
+
target ? target.hrefFor(treePath, context) : `/${treePath}`;
|
|
200
|
+
|
|
201
|
+
// Phase 0 and 1: make sure the pages exist, so their ids are known.
|
|
202
|
+
const ids = new Map<string, number | null>();
|
|
203
|
+
const created = new Set<string>();
|
|
204
|
+
let rootParentId: number | null = null;
|
|
205
|
+
|
|
206
|
+
if (session) {
|
|
207
|
+
await session.loadIndex();
|
|
208
|
+
const root = await session.ensureRootParent();
|
|
209
|
+
rootParentId = root.id;
|
|
210
|
+
for (const entry of root.created) {
|
|
211
|
+
actions.push({ op: 'create-root', path: entry.path, id: entry.id, locale });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const inScope = (treePath: string): boolean =>
|
|
216
|
+
!config.only || treePath === config.only || treePath.startsWith(`${config.only}/`);
|
|
217
|
+
const isNeeded = (treePath: string): boolean =>
|
|
218
|
+
inScope(treePath) || treePath === '' || config.only.startsWith(`${treePath}/`);
|
|
219
|
+
|
|
220
|
+
const nodes = [tree.root, ...tree.chain];
|
|
221
|
+
for (const node of nodes) {
|
|
222
|
+
if (!isNeeded(node.path)) continue;
|
|
223
|
+
const parentId = node.parent ? (ids.get(node.parent.path) ?? null) : rootParentId;
|
|
224
|
+
|
|
225
|
+
if (!session) {
|
|
226
|
+
ids.set(node.path, null);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const result = await session.ensurePage({
|
|
231
|
+
path: node.path,
|
|
232
|
+
slug: node.slug,
|
|
233
|
+
parentId,
|
|
234
|
+
title: node.title,
|
|
235
|
+
menuOrder: node.menuOrder,
|
|
236
|
+
isRoot: node.path === '',
|
|
237
|
+
});
|
|
238
|
+
for (const warning of result.warnings) {
|
|
239
|
+
issues.add({ code: 'target-warning', severity: 'warning', message: warning, path: node.path });
|
|
240
|
+
}
|
|
241
|
+
ids.set(node.path, result.id);
|
|
242
|
+
if (result.created) {
|
|
243
|
+
created.add(node.path);
|
|
244
|
+
actions.push({
|
|
245
|
+
op: 'create',
|
|
246
|
+
path: node.path,
|
|
247
|
+
id: result.id,
|
|
248
|
+
locale,
|
|
249
|
+
versionName: version.name,
|
|
250
|
+
file: node.doc?.sourceRelativePath ?? null,
|
|
251
|
+
});
|
|
252
|
+
if (result.id !== null) log(`created ${node.path || '(root)'} (id ${result.id})`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Media: resolve every referenced file, upload what is new, and remember
|
|
257
|
+
// where each one ended up so the renderer can point at it.
|
|
258
|
+
const media = new Map<string, { id: number; url: string }>();
|
|
259
|
+
const mediaRecords: Artifacts['media'] = [];
|
|
260
|
+
let mediaPending = 0;
|
|
261
|
+
|
|
262
|
+
if (config.uploadMedia) {
|
|
263
|
+
const result = await syncMedia({ config, model, version, tree, session, issues });
|
|
264
|
+
for (const [url, ref] of result.byUrl) media.set(url, ref);
|
|
265
|
+
mediaRecords.push(...result.records);
|
|
266
|
+
mediaPending = result.pending;
|
|
267
|
+
actions.push(...result.actions);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Phase 2: render against the real ids, and write what differs.
|
|
271
|
+
const navRootId = ids.get('') ?? null;
|
|
272
|
+
const prepared: PreparedPage[] = [];
|
|
273
|
+
const banner =
|
|
274
|
+
version.banner && !version.isLast
|
|
275
|
+
? renderVersionBanner(theme, version.label, version.banner)
|
|
276
|
+
: undefined;
|
|
277
|
+
|
|
278
|
+
for (const node of nodes) {
|
|
279
|
+
const page = renderPageFor({
|
|
280
|
+
node,
|
|
281
|
+
tree,
|
|
282
|
+
config,
|
|
283
|
+
model,
|
|
284
|
+
version,
|
|
285
|
+
theme,
|
|
286
|
+
href,
|
|
287
|
+
navRootId,
|
|
288
|
+
admonitionKeywords: input.instance.admonitionKeywords,
|
|
289
|
+
media,
|
|
290
|
+
issues,
|
|
291
|
+
...(banner ? { banner } : {}),
|
|
292
|
+
});
|
|
293
|
+
prepared.push({ node, page, locale, versionName: version.name });
|
|
294
|
+
|
|
295
|
+
const id = ids.get(node.path);
|
|
296
|
+
if (!session || id === null || id === undefined) continue;
|
|
297
|
+
if (!inScope(node.path) && !created.has(node.path)) continue;
|
|
298
|
+
|
|
299
|
+
const parentId = node.parent ? (ids.get(node.parent.path) ?? 0) : (rootParentId ?? 0);
|
|
300
|
+
const remote = await session.fetchPage(id);
|
|
301
|
+
const changed = session.diffPage(remote, page, parentId ?? 0);
|
|
302
|
+
|
|
303
|
+
if (changed.length === 0) {
|
|
304
|
+
actions.push({
|
|
305
|
+
op: 'unchanged',
|
|
306
|
+
path: node.path,
|
|
307
|
+
id,
|
|
308
|
+
locale,
|
|
309
|
+
versionName: version.name,
|
|
310
|
+
file: page.file ?? null,
|
|
311
|
+
});
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
actions.push({
|
|
316
|
+
op: 'update',
|
|
317
|
+
path: node.path,
|
|
318
|
+
id,
|
|
319
|
+
changed,
|
|
320
|
+
locale,
|
|
321
|
+
versionName: version.name,
|
|
322
|
+
file: page.file ?? null,
|
|
323
|
+
});
|
|
324
|
+
if (config.dryRun) continue;
|
|
325
|
+
|
|
326
|
+
const { warnings } = await session.writePage(id, page, parentId ?? 0);
|
|
327
|
+
for (const warning of warnings) {
|
|
328
|
+
issues.add({ code: 'target-warning', severity: 'warning', message: warning, path: node.path });
|
|
329
|
+
}
|
|
330
|
+
log(`updated ${node.path || '(root)'} (${changed.join(', ')})`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Anything under this version's root that no document accounts for.
|
|
334
|
+
if (session && navRootId !== null) {
|
|
335
|
+
if (config.only) {
|
|
336
|
+
issues.add({
|
|
337
|
+
code: 'prune-skipped',
|
|
338
|
+
severity: 'info',
|
|
339
|
+
message: 'Pruning was skipped because --only limited the run.',
|
|
340
|
+
});
|
|
341
|
+
} else {
|
|
342
|
+
const index = await session.loadIndex();
|
|
343
|
+
const keep = new Set<number>();
|
|
344
|
+
for (const id of ids.values()) if (typeof id === 'number') keep.add(id);
|
|
345
|
+
for (const page of session.computePrune(index, navRootId, keep)) {
|
|
346
|
+
actions.push({
|
|
347
|
+
op: 'prune',
|
|
348
|
+
path: page.slug,
|
|
349
|
+
id: page.id,
|
|
350
|
+
locale,
|
|
351
|
+
versionName: version.name,
|
|
352
|
+
applied: config.prune && !config.dryRun,
|
|
353
|
+
});
|
|
354
|
+
if (config.prune && !config.dryRun) {
|
|
355
|
+
await session.removePage(page);
|
|
356
|
+
log(`trashed ${page.link}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return { actions, prepared, media: mediaRecords, mediaPending };
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Render one page, body and all. */
|
|
366
|
+
function renderPageFor(input: {
|
|
367
|
+
node: PageNode;
|
|
368
|
+
tree: PageTree;
|
|
369
|
+
config: ResolvedConfig;
|
|
370
|
+
model: SiteModel;
|
|
371
|
+
version: DocsVersion;
|
|
372
|
+
theme: Theme;
|
|
373
|
+
href: (treePath: string) => string;
|
|
374
|
+
navRootId: number | null;
|
|
375
|
+
admonitionKeywords: string[];
|
|
376
|
+
media: Map<string, { id: number; url: string }>;
|
|
377
|
+
issues: IssueCollector;
|
|
378
|
+
banner?: string;
|
|
379
|
+
}): RenderedPage {
|
|
380
|
+
const { node, tree, config, model, theme, issues } = input;
|
|
381
|
+
const doc = node.doc;
|
|
382
|
+
|
|
383
|
+
let body = '';
|
|
384
|
+
let links = new Set<string>();
|
|
385
|
+
let firstParagraph = '';
|
|
386
|
+
|
|
387
|
+
if (doc) {
|
|
388
|
+
const markdown = readDocument(doc, issues);
|
|
389
|
+
if (markdown !== undefined) {
|
|
390
|
+
const rendered = renderDoc({
|
|
391
|
+
markdown,
|
|
392
|
+
file: doc.sourceRelativePath,
|
|
393
|
+
permalink: doc.permalink,
|
|
394
|
+
format: doc.format,
|
|
395
|
+
theme,
|
|
396
|
+
admonitionKeywords: input.admonitionKeywords,
|
|
397
|
+
maintainCase: model.maintainCase,
|
|
398
|
+
dedupeTitle: config.dedupeTitle,
|
|
399
|
+
onUnknownJsx: config.mdxOnUnknown,
|
|
400
|
+
media: input.media,
|
|
401
|
+
issues,
|
|
402
|
+
resolveLink: makeLinkResolver(doc, tree, config, model, input.href),
|
|
403
|
+
});
|
|
404
|
+
body = rendered.body;
|
|
405
|
+
links = rendered.links;
|
|
406
|
+
firstParagraph = rendered.firstParagraph;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const content = composePage({
|
|
411
|
+
node,
|
|
412
|
+
body,
|
|
413
|
+
links,
|
|
414
|
+
href: input.href,
|
|
415
|
+
lookup: (treePath) => tree.byPath.get(treePath),
|
|
416
|
+
theme,
|
|
417
|
+
layout: config.layout satisfies PageLayout,
|
|
418
|
+
navRootId: input.navRootId,
|
|
419
|
+
...(input.banner ? { banner: input.banner } : {}),
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
const excerpt = excerptFor(doc?.description ?? '', firstParagraph, config.excerptLength);
|
|
423
|
+
const meta: Record<string, string> = {};
|
|
424
|
+
if (config.metaDescriptionKey && excerpt) meta[config.metaDescriptionKey] = excerpt;
|
|
425
|
+
|
|
426
|
+
return {
|
|
427
|
+
path: node.path,
|
|
428
|
+
slug: node.slug,
|
|
429
|
+
title: node.title,
|
|
430
|
+
content,
|
|
431
|
+
excerpt,
|
|
432
|
+
menuOrder: node.menuOrder,
|
|
433
|
+
meta,
|
|
434
|
+
file: doc?.sourceRelativePath,
|
|
435
|
+
versionName: node.versionName,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Read a document's source, reporting rather than throwing when it is gone. */
|
|
440
|
+
function readDocument(doc: Doc, issues: IssueCollector): string | undefined {
|
|
441
|
+
try {
|
|
442
|
+
return readFileSync(doc.sourceAbsolutePath, 'utf8');
|
|
443
|
+
} catch (error) {
|
|
444
|
+
issues.addFor(doc, {
|
|
445
|
+
code: 'source-unreadable',
|
|
446
|
+
severity: 'error',
|
|
447
|
+
message: `Could not read ${doc.sourceRelativePath}: ${(error as Error).message}`,
|
|
448
|
+
});
|
|
449
|
+
return undefined;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** Decide where each link in a document should point. */
|
|
454
|
+
function makeLinkResolver(
|
|
455
|
+
doc: Doc,
|
|
456
|
+
tree: PageTree,
|
|
457
|
+
config: ResolvedConfig,
|
|
458
|
+
model: SiteModel,
|
|
459
|
+
href: (treePath: string) => string,
|
|
460
|
+
): LinkResolver {
|
|
461
|
+
const siteUrl = (config.siteUrl || model.url).replace(/\/+$/, '');
|
|
462
|
+
const baseUrl = model.baseUrl.replace(/\/+$/, '');
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* A root-relative markdown link is relative to the site, not to the server.
|
|
466
|
+
* Docusaurus's router prepends the base URL, so a link written as
|
|
467
|
+
* `/docs/api/` on a site served from `/project/` really means
|
|
468
|
+
* `/project/docs/api/`.
|
|
469
|
+
*/
|
|
470
|
+
const withBaseUrl = (urlPath: string): string =>
|
|
471
|
+
!baseUrl || urlPath === baseUrl || urlPath.startsWith(`${baseUrl}/`)
|
|
472
|
+
? urlPath
|
|
473
|
+
: `${baseUrl}${urlPath}`;
|
|
474
|
+
|
|
475
|
+
return (rawHref: string): ReturnType<LinkResolver> => {
|
|
476
|
+
if (!rawHref || rawHref.startsWith('#')) return { href: rawHref };
|
|
477
|
+
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(rawHref) || rawHref.startsWith('//')) {
|
|
478
|
+
return { href: rawHref };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const hashIndex = rawHref.indexOf('#');
|
|
482
|
+
const hash = hashIndex === -1 ? '' : rawHref.slice(hashIndex);
|
|
483
|
+
const target = hashIndex === -1 ? rawHref : rawHref.slice(0, hashIndex);
|
|
484
|
+
|
|
485
|
+
const node = target.startsWith('/')
|
|
486
|
+
? findByUrl(tree, withBaseUrl(target))
|
|
487
|
+
: findBySourcePath(tree, doc, target);
|
|
488
|
+
|
|
489
|
+
if (node) return { href: `${href(node.path)}${hash}`, path: node.path };
|
|
490
|
+
if (config.unpublishedLinks === 'drop') return { href: null };
|
|
491
|
+
|
|
492
|
+
// Nothing here publishes it, so point at the documentation site, using the
|
|
493
|
+
// URL Docusaurus itself would serve.
|
|
494
|
+
const urlPath = target.startsWith('/')
|
|
495
|
+
? withBaseUrl(path.posix.normalize(target))
|
|
496
|
+
: path.posix.normalize(path.posix.join(doc.permalink, '..', target.replace(/\.mdx?$/, '')));
|
|
497
|
+
return { href: `${siteUrl}${urlPath}${hash}` };
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** A page whose document is served at this URL. */
|
|
502
|
+
function findByUrl(tree: PageTree, target: string): PageNode | undefined {
|
|
503
|
+
const urlPath = path.posix.normalize(target).replace(/\.mdx?$/, '');
|
|
504
|
+
return tree.byPermalink.get(urlPath) ?? tree.byPermalink.get(`${urlPath}/`);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* A page whose source file a relative link points at.
|
|
509
|
+
*
|
|
510
|
+
* Relative links are resolved against the file, which is how Docusaurus
|
|
511
|
+
* resolves them. Resolving against the URL instead would break every document
|
|
512
|
+
* whose front matter gives it a slug of its own.
|
|
513
|
+
*/
|
|
514
|
+
function findBySourcePath(tree: PageTree, from: Doc, target: string): PageNode | undefined {
|
|
515
|
+
const base = path.posix.dirname(from.sourceRelativePath);
|
|
516
|
+
const resolved = path.posix.normalize(path.posix.join(base, target));
|
|
517
|
+
const withoutExtension = resolved.replace(/\.mdx?$/, '');
|
|
518
|
+
|
|
519
|
+
const candidates = [
|
|
520
|
+
resolved,
|
|
521
|
+
`${withoutExtension}.md`,
|
|
522
|
+
`${withoutExtension}.mdx`,
|
|
523
|
+
`${withoutExtension}/index.md`,
|
|
524
|
+
`${withoutExtension}/index.mdx`,
|
|
525
|
+
`${withoutExtension}/README.md`,
|
|
526
|
+
];
|
|
527
|
+
for (const candidate of candidates) {
|
|
528
|
+
const node = tree.bySourcePath.get(candidate);
|
|
529
|
+
if (node) return node;
|
|
530
|
+
}
|
|
531
|
+
return undefined;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** Resolve, upload and record every file the documents reference. */
|
|
535
|
+
async function syncMedia(input: {
|
|
536
|
+
config: ResolvedConfig;
|
|
537
|
+
model: SiteModel;
|
|
538
|
+
version: DocsVersion;
|
|
539
|
+
tree: PageTree;
|
|
540
|
+
session: TargetSession | undefined;
|
|
541
|
+
issues: IssueCollector;
|
|
542
|
+
}): Promise<{
|
|
543
|
+
byUrl: Map<string, { id: number; url: string }>;
|
|
544
|
+
records: Artifacts['media'];
|
|
545
|
+
actions: Action[];
|
|
546
|
+
pending: number;
|
|
547
|
+
}> {
|
|
548
|
+
const { config, model, version, tree, session, issues } = input;
|
|
549
|
+
const byUrl = new Map<string, { id: number; url: string }>();
|
|
550
|
+
const records: Artifacts['media'] = [];
|
|
551
|
+
const actions: Action[] = [];
|
|
552
|
+
let pending = 0;
|
|
553
|
+
|
|
554
|
+
const remote: Map<string, MediaRef> = session ? await session.loadMediaIndex() : new Map();
|
|
555
|
+
const uploadedThisRun = new Map<string, MediaRef>();
|
|
556
|
+
|
|
557
|
+
for (const node of [tree.root, ...tree.chain]) {
|
|
558
|
+
const doc = node.doc;
|
|
559
|
+
if (!doc) continue;
|
|
560
|
+
|
|
561
|
+
let markdown: string;
|
|
562
|
+
try {
|
|
563
|
+
markdown = await fs.readFile(doc.sourceAbsolutePath, 'utf8');
|
|
564
|
+
} catch {
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
for (const image of collectImages(markdown, doc.format)) {
|
|
569
|
+
if (byUrl.has(image.url)) continue;
|
|
570
|
+
|
|
571
|
+
const resolved = resolveImage(image.url, {
|
|
572
|
+
sourceAbsolutePath: doc.sourceAbsolutePath,
|
|
573
|
+
contentPath: version.contentPath,
|
|
574
|
+
contentPathLocalized: version.contentPathLocalized,
|
|
575
|
+
staticDirs: model.staticDirs,
|
|
576
|
+
baseUrl: model.baseUrl,
|
|
577
|
+
siteDir: model.siteDir,
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
if (resolved.kind === 'external') continue;
|
|
581
|
+
|
|
582
|
+
if (resolved.kind === 'missing') {
|
|
583
|
+
if (config.mediaOnMissing !== 'ignore') {
|
|
584
|
+
issues.addFor(doc, {
|
|
585
|
+
code: 'asset-missing',
|
|
586
|
+
severity: config.mediaOnMissing,
|
|
587
|
+
message: `The file "${image.url}" was not found, so the reference was left as written.`,
|
|
588
|
+
});
|
|
589
|
+
}
|
|
590
|
+
continue;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const bytes = await fs.readFile(resolved.file);
|
|
594
|
+
const hash = contentHash(bytes);
|
|
595
|
+
const known = uploadedThisRun.get(hash) ?? remote.get(hash);
|
|
596
|
+
|
|
597
|
+
if (known) {
|
|
598
|
+
byUrl.set(image.url, { id: known.id, url: known.url });
|
|
599
|
+
records.push({ hash, file: resolved.file, url: known.url, uploaded: false });
|
|
600
|
+
actions.push({ op: 'reuse-media', path: node.path, id: known.id, file: resolved.file });
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (!session || config.dryRun) {
|
|
605
|
+
pending += 1;
|
|
606
|
+
records.push({ hash, file: resolved.file, url: null, uploaded: false });
|
|
607
|
+
actions.push({ op: 'upload-media', path: node.path, id: null, file: resolved.file });
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
const filename = path.basename(resolved.file);
|
|
612
|
+
const mime = mimeTypeFor(filename);
|
|
613
|
+
if (!mime) {
|
|
614
|
+
issues.addFor(doc, {
|
|
615
|
+
code: 'asset-unknown-type',
|
|
616
|
+
severity: 'warning',
|
|
617
|
+
message: `Not uploading "${image.url}": its file type is not one WordPress accepts.`,
|
|
618
|
+
});
|
|
619
|
+
continue;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
const uploaded = await session.uploadMedia({
|
|
623
|
+
bytes,
|
|
624
|
+
filename,
|
|
625
|
+
hash,
|
|
626
|
+
mime,
|
|
627
|
+
alt: image.alt,
|
|
628
|
+
title: image.title ?? titleCase(path.parse(filename).name),
|
|
629
|
+
});
|
|
630
|
+
uploadedThisRun.set(hash, uploaded);
|
|
631
|
+
byUrl.set(image.url, { id: uploaded.id, url: uploaded.url });
|
|
632
|
+
records.push({ hash, file: resolved.file, url: uploaded.url, uploaded: true });
|
|
633
|
+
actions.push({ op: 'upload-media', path: node.path, id: uploaded.id, file: resolved.file });
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
return { byUrl, records, actions, pending };
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/** Issues, for callers that want them without the plan. */
|
|
641
|
+
export type { Issue };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The target contract: what a place pages are published to must provide.
|
|
3
|
+
*
|
|
4
|
+
* Implementations live beside this, or in their own package; the reconciler
|
|
5
|
+
* only ever sees what is declared here.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type {
|
|
9
|
+
EnsureRequest,
|
|
10
|
+
EnsureResult,
|
|
11
|
+
MediaRef,
|
|
12
|
+
MediaUpload,
|
|
13
|
+
RemotePage,
|
|
14
|
+
RenderedPage,
|
|
15
|
+
Target,
|
|
16
|
+
TargetCapabilities,
|
|
17
|
+
TargetSession,
|
|
18
|
+
} from './target';
|