docusaurus-plugin-docusynx 0.1.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/dist/plugin.js ADDED
@@ -0,0 +1,355 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import matter from 'gray-matter';
4
+ import { AssetCollector } from './assets.js';
5
+ import { canonicalJson, contentHash } from './canonical.js';
6
+ import { discoverDocuments, matchesRoutePattern, normalizeRoute, readRenderedRoute, } from './discover.js';
7
+ import { renderedHtmlLinkSemantics, renderedHtmlToBlocks, } from './html.js';
8
+ import { loadComponentHandlers, markdownToBlocks } from './markdown.js';
9
+ export default function docusynxPlugin(context, options = {}) {
10
+ let allContent = {};
11
+ return {
12
+ name: 'docusaurus-plugin-docusynx',
13
+ allContentLoaded({ allContent: loadedContent }) {
14
+ allContent = loadedContent;
15
+ },
16
+ async postBuild({ outDir, routesPaths = [], siteConfig }) {
17
+ const strict = options.strict !== false;
18
+ const siteBaseUrl = `${siteConfig.url ?? context.siteConfig.url ?? ''}${siteConfig.baseUrl ?? context.siteConfig.baseUrl ?? '/'}`;
19
+ const outputDirectory = path.resolve(outDir, options.outputDirectory ?? 'docusynx');
20
+ await mkdir(outputDirectory, { recursive: true });
21
+ const assets = new AssetCollector(outputDirectory);
22
+ const handlers = await loadComponentHandlers(options.componentHandlers ?? []);
23
+ const candidates = await discoverDocuments({
24
+ allContent,
25
+ routePaths: routesPaths,
26
+ siteDir: context.siteDir,
27
+ outDir,
28
+ options,
29
+ });
30
+ const documents = [];
31
+ const renderedLinks = new Map();
32
+ for (const candidate of candidates) {
33
+ let title = candidate.title;
34
+ let blocks;
35
+ let rendered;
36
+ let renderedHtml;
37
+ if (strict ||
38
+ candidate.forceRendered ||
39
+ (!candidate.syntheticBlocks &&
40
+ !candidate.sourceAbsolutePath)) {
41
+ try {
42
+ renderedHtml = await readRenderedRoute(outDir, candidate.route);
43
+ }
44
+ catch (error) {
45
+ if (!candidate.syntheticBlocks)
46
+ throw error;
47
+ }
48
+ }
49
+ if (strict && renderedHtml)
50
+ renderedLinks.set(candidate.id, renderedHtmlLinkSemantics({
51
+ html: renderedHtml,
52
+ selectors: options.htmlContentSelectors,
53
+ }));
54
+ if (renderedHtml &&
55
+ (candidate.forceRendered ||
56
+ (!candidate.syntheticBlocks &&
57
+ !candidate.sourceAbsolutePath)))
58
+ rendered = await renderedHtmlToBlocks({
59
+ html: renderedHtml,
60
+ outDir,
61
+ baseUrl: siteConfig.baseUrl ?? '/',
62
+ assets,
63
+ selectors: options.htmlContentSelectors,
64
+ route: candidate.route,
65
+ strict,
66
+ });
67
+ if (candidate.syntheticBlocks) {
68
+ blocks = candidate.syntheticBlocks;
69
+ }
70
+ else if (candidate.forceRendered) {
71
+ if (!rendered)
72
+ throw new Error(`no rendered HTML found for route ${candidate.route}`);
73
+ title = rendered.title ?? title;
74
+ blocks = rendered.blocks;
75
+ }
76
+ else if (candidate.sourceAbsolutePath) {
77
+ const parsed = matter(await readFile(candidate.sourceAbsolutePath, 'utf8'));
78
+ blocks = await markdownToBlocks({
79
+ source: parsed.content,
80
+ siteDir: context.siteDir,
81
+ sourcePath: candidate.sourcePath,
82
+ assets,
83
+ handlers,
84
+ strict,
85
+ });
86
+ }
87
+ else {
88
+ if (!rendered)
89
+ throw new Error(`no rendered HTML found for route ${candidate.route}`);
90
+ title = rendered.title ?? title;
91
+ blocks = rendered.blocks;
92
+ }
93
+ const mappedSourcePath = sourcePathMapping(candidate.route, options.sourcePathMappings ?? []);
94
+ const sourcePath = mappedSourcePath ??
95
+ (candidate.sourcePathIsRepositoryRelative
96
+ ? candidate.sourcePath
97
+ .replaceAll('\\', '/')
98
+ .replace(/^\.\//, '')
99
+ : prefixedSourcePath(candidate.sourcePath, options.sourcePathPrefix));
100
+ const source = {
101
+ path: sourcePath,
102
+ ...sourceUrl(sourcePath, options),
103
+ ...(options.sourceCommit
104
+ ? { commit: options.sourceCommit }
105
+ : {}),
106
+ };
107
+ const withoutHash = {
108
+ id: candidate.id,
109
+ title,
110
+ route: normalizeRoute(candidate.route),
111
+ ...(candidate.parentId
112
+ ? { parentId: candidate.parentId }
113
+ : {}),
114
+ order: candidate.order,
115
+ source,
116
+ blocks,
117
+ };
118
+ documents.push({
119
+ ...withoutHash,
120
+ hash: contentHash(withoutHash),
121
+ });
122
+ }
123
+ resolveDocumentLinks(documents, siteBaseUrl);
124
+ if (strict)
125
+ validateRenderedLinkParity({
126
+ documents,
127
+ renderedLinks,
128
+ siteBaseUrl,
129
+ });
130
+ for (const document of documents) {
131
+ const { hash: _oldHash, ...withoutHash } = document;
132
+ document.hash = contentHash(withoutHash);
133
+ }
134
+ documents.sort((left, right) => compareStrings(left.id, right.id));
135
+ const site = {
136
+ name: options.siteName ??
137
+ context.siteConfig.title ??
138
+ 'Docusaurus',
139
+ baseUrl: siteBaseUrl,
140
+ ...(options.sourceBaseUrl
141
+ ? { sourceBaseUrl: options.sourceBaseUrl }
142
+ : {}),
143
+ ...(options.sourceCommit
144
+ ? { sourceCommit: options.sourceCommit }
145
+ : {}),
146
+ };
147
+ const withoutHash = {
148
+ schemaVersion: 1,
149
+ site,
150
+ documents,
151
+ assets: assets.values(),
152
+ };
153
+ const bundle = {
154
+ ...withoutHash,
155
+ hash: contentHash(withoutHash),
156
+ };
157
+ await writeFile(path.join(outputDirectory, 'manifest.json'), `${canonicalJson(bundle)}\n`, 'utf8');
158
+ },
159
+ };
160
+ }
161
+ function compareStrings(left, right) {
162
+ return left < right ? -1 : left > right ? 1 : 0;
163
+ }
164
+ function prefixedSourcePath(sourcePath, prefix) {
165
+ const normalized = sourcePath.replaceAll('\\', '/').replace(/^\.\//, '');
166
+ if (!prefix || path.isAbsolute(normalized))
167
+ return normalized;
168
+ return `${prefix.replace(/^\/+|\/+$/g, '')}/${normalized}`;
169
+ }
170
+ function sourcePathMapping(route, mappings) {
171
+ const normalizedRoute = normalizeRoute(route);
172
+ const matches = mappings.filter((mapping) => matchesRoutePattern(normalizedRoute, mapping.routePattern));
173
+ if (matches.length > 1) {
174
+ throw new Error(`route ${normalizedRoute} matches multiple sourcePathMappings: ${matches.map((mapping) => mapping.routePattern).join(', ')}`);
175
+ }
176
+ const sourcePath = matches[0]?.sourcePath;
177
+ if (!sourcePath)
178
+ return undefined;
179
+ if (path.isAbsolute(sourcePath) || sourcePath.split('/').includes('..')) {
180
+ throw new Error(`sourcePathMappings sourcePath must be repository-relative: ${sourcePath}`);
181
+ }
182
+ return sourcePath.replaceAll('\\', '/').replace(/^\.\//, '');
183
+ }
184
+ function sourceUrl(sourcePath, options) {
185
+ if (sourcePath.startsWith('.docusynx/') ||
186
+ sourcePath.includes('/.docusynx/') ||
187
+ sourcePath.startsWith('.docusaurus/') ||
188
+ sourcePath.includes('/.docusaurus/') ||
189
+ sourcePath.includes('/.generated/'))
190
+ return {};
191
+ const template = options.sourceUrlTemplate ??
192
+ (options.sourceBaseUrl && options.sourceCommit
193
+ ? `${options.sourceBaseUrl.replace(/\/$/, '')}/-/blob/{commit}/{path}`
194
+ : undefined);
195
+ if (!template)
196
+ return {};
197
+ return {
198
+ url: template
199
+ .replaceAll('{commit}', encodeURIComponent(options.sourceCommit ?? ''))
200
+ .replaceAll('{path}', sourcePath.split('/').map(encodeURIComponent).join('/')),
201
+ };
202
+ }
203
+ function resolveDocumentLinks(documents, siteBaseUrl) {
204
+ const index = documentLinkIndex(documents, siteBaseUrl);
205
+ for (const document of documents) {
206
+ visitBlocks(document.blocks, (inline) => {
207
+ if (inline.type !== 'link' || inline.target.kind !== 'url')
208
+ return;
209
+ const raw = inline.target.value;
210
+ const target = documentTarget(raw, document, index);
211
+ if (target) {
212
+ inline.target = target;
213
+ return;
214
+ }
215
+ if (!raw || /^(?:[a-z]+:|#)/i.test(raw))
216
+ return;
217
+ inline.target.value = absoluteDocusaurusUrl(raw, document, index);
218
+ });
219
+ }
220
+ }
221
+ function documentLinkIndex(documents, siteBaseUrl) {
222
+ const byRoute = new Map();
223
+ const bySource = new Map();
224
+ for (const document of documents) {
225
+ byRoute.set(normalizeRoute(document.route), document.id);
226
+ bySource.set(stripExtension(document.source.path), document.id);
227
+ }
228
+ const siteUrl = new URL(siteBaseUrl || '/', 'https://docusynx.invalid/');
229
+ return {
230
+ byRoute,
231
+ bySource,
232
+ siteUrl,
233
+ routePrefix: normalizeRoute(siteUrl.pathname),
234
+ };
235
+ }
236
+ function documentTarget(raw, document, index) {
237
+ if (!raw || raw.startsWith('#'))
238
+ return undefined;
239
+ let resolved;
240
+ try {
241
+ resolved = new URL(raw, documentUrl(document, index));
242
+ }
243
+ catch {
244
+ return undefined;
245
+ }
246
+ if (!['http:', 'https:'].includes(resolved.protocol))
247
+ return undefined;
248
+ if (resolved.origin !== index.siteUrl.origin)
249
+ return undefined;
250
+ for (const route of linkRouteCandidates(resolved.pathname, index)) {
251
+ const targetId = index.byRoute.get(route);
252
+ if (targetId)
253
+ return { kind: 'document', value: targetId };
254
+ }
255
+ if (/^(?:[a-z]+:|\/\/)/i.test(raw))
256
+ return undefined;
257
+ const withoutAnchor = raw.split(/[?#]/)[0] ?? raw;
258
+ const sourcePath = stripExtension(path.posix.normalize(path.posix.join(path.posix.dirname(document.source.path), withoutAnchor)));
259
+ const targetId = index.bySource.get(sourcePath);
260
+ return targetId ? { kind: 'document', value: targetId } : undefined;
261
+ }
262
+ function linkRouteCandidates(pathname, index) {
263
+ const candidates = new Set([normalizeRoute(pathname)]);
264
+ const prefix = index.routePrefix;
265
+ if (prefix !== '/' && normalizeRoute(pathname).startsWith(prefix)) {
266
+ candidates.add(normalizeRoute(normalizeRoute(pathname).slice(prefix.length)));
267
+ }
268
+ return [...candidates];
269
+ }
270
+ function documentUrl(document, index) {
271
+ const prefix = index.routePrefix === '/'
272
+ ? ''
273
+ : index.routePrefix.replace(/\/$/, '');
274
+ return new URL(`${prefix}${normalizeRoute(document.route)}`, index.siteUrl);
275
+ }
276
+ function absoluteDocusaurusUrl(raw, document, index) {
277
+ if (raw.startsWith('//'))
278
+ return new URL(raw, index.siteUrl).href;
279
+ if (raw.startsWith('/') &&
280
+ index.routePrefix !== '/' &&
281
+ !normalizeRoute(raw).startsWith(index.routePrefix)) {
282
+ const prefix = index.routePrefix.replace(/\/$/, '');
283
+ return new URL(`${prefix}${raw}`, index.siteUrl.origin).href;
284
+ }
285
+ return new URL(raw, documentUrl(document, index)).href;
286
+ }
287
+ function validateRenderedLinkParity(input) {
288
+ const index = documentLinkIndex(input.documents, input.siteBaseUrl);
289
+ for (const document of input.documents) {
290
+ const rendered = input.renderedLinks.get(document.id);
291
+ if (!rendered)
292
+ continue;
293
+ const expected = new Map();
294
+ for (const link of rendered) {
295
+ const target = documentTarget(link.href, document, index);
296
+ if (target)
297
+ appendLinkText(expected, target.value, link.text);
298
+ }
299
+ const actual = new Map();
300
+ visitBlocks(document.blocks, (inline) => {
301
+ if (inline.type === 'link' && inline.target.kind === 'document')
302
+ appendLinkText(actual, inline.target.value, inlineText(inline.children));
303
+ });
304
+ const expectedSummary = linkTextSummary(expected);
305
+ const actualSummary = linkTextSummary(actual);
306
+ if (JSON.stringify(expectedSummary) === JSON.stringify(actualSummary))
307
+ continue;
308
+ throw new Error(`rendered internal link parity failed for route ${document.route}: expected ${JSON.stringify(expectedSummary)} but exported ${JSON.stringify(actualSummary)}`);
309
+ }
310
+ }
311
+ function appendLinkText(links, targetId, text) {
312
+ const values = links.get(targetId) ?? [];
313
+ values.push(text);
314
+ links.set(targetId, values);
315
+ }
316
+ function linkTextSummary(links) {
317
+ return [...links.entries()]
318
+ .sort(([left], [right]) => compareStrings(left, right))
319
+ .map(([targetId, text]) => ({
320
+ targetId,
321
+ text: normalizeLinkText(text.join('')),
322
+ }));
323
+ }
324
+ function inlineText(inlines) {
325
+ return inlines
326
+ .map((inline) => inline.type === 'text' ? inline.value : inlineText(inline.children))
327
+ .join('');
328
+ }
329
+ function normalizeLinkText(value) {
330
+ return value.replace(/\s+/g, '');
331
+ }
332
+ function visitBlocks(blocks, callback) {
333
+ const visitInlines = (inlines) => {
334
+ for (const inline of inlines) {
335
+ callback(inline);
336
+ if (inline.type === 'link')
337
+ visitInlines(inline.children);
338
+ }
339
+ };
340
+ for (const block of blocks) {
341
+ if (block.type === 'paragraph' || block.type === 'heading')
342
+ visitInlines(block.inlines);
343
+ else if (block.type === 'table') {
344
+ block.header.forEach(visitInlines);
345
+ block.rows.flat().forEach(visitInlines);
346
+ }
347
+ else if (block.type === 'list')
348
+ block.items.forEach((item) => visitBlocks(item, callback));
349
+ else if (block.type === 'admonition')
350
+ visitBlocks(block.blocks, callback);
351
+ }
352
+ }
353
+ function stripExtension(value) {
354
+ return value.replace(/\.(?:md|mdx)$/i, '').replace(/\/index$/i, '');
355
+ }
@@ -0,0 +1,140 @@
1
+ export interface MdxAstNode {
2
+ type: string;
3
+ name?: string | null;
4
+ value?: string;
5
+ children?: MdxAstNode[];
6
+ attributes?: Array<{
7
+ name?: string;
8
+ type: string;
9
+ value?: unknown;
10
+ }>;
11
+ [key: string]: unknown;
12
+ }
13
+ export type TextMark = 'bold' | 'italic' | 'strikethrough' | 'code';
14
+ export interface TextInline {
15
+ type: 'text';
16
+ value: string;
17
+ marks?: TextMark[];
18
+ }
19
+ export interface LinkInline {
20
+ type: 'link';
21
+ target: {
22
+ kind: 'url' | 'document';
23
+ value: string;
24
+ };
25
+ children: Inline[];
26
+ }
27
+ export type Inline = TextInline | LinkInline;
28
+ export type Block = {
29
+ type: 'paragraph';
30
+ inlines: Inline[];
31
+ } | {
32
+ type: 'heading';
33
+ level: number;
34
+ inlines: Inline[];
35
+ } | {
36
+ type: 'code';
37
+ language?: string;
38
+ title?: string;
39
+ value: string;
40
+ } | {
41
+ type: 'mermaid';
42
+ value: string;
43
+ } | {
44
+ type: 'list';
45
+ ordered: boolean;
46
+ items: Block[][];
47
+ } | {
48
+ type: 'table';
49
+ header: Inline[][];
50
+ rows: Inline[][][];
51
+ } | {
52
+ type: 'admonition';
53
+ kind: string;
54
+ title?: string;
55
+ blocks: Block[];
56
+ } | {
57
+ type: 'image';
58
+ assetId: string;
59
+ alt?: string;
60
+ title?: string;
61
+ } | {
62
+ type: 'thematicBreak';
63
+ } | {
64
+ type: 'extension';
65
+ name: string;
66
+ data: unknown;
67
+ };
68
+ export interface BundleSource {
69
+ path: string;
70
+ url?: string;
71
+ commit?: string;
72
+ }
73
+ export interface BundleDocument {
74
+ id: string;
75
+ title: string;
76
+ route: string;
77
+ parentId?: string;
78
+ order: number;
79
+ source: BundleSource;
80
+ blocks: Block[];
81
+ hash: string;
82
+ }
83
+ export interface BundleAsset {
84
+ id: string;
85
+ path: string;
86
+ mimeType: string;
87
+ hash: string;
88
+ }
89
+ export interface DocumentBundle {
90
+ schemaVersion: 1;
91
+ site: {
92
+ name: string;
93
+ baseUrl: string;
94
+ sourceBaseUrl?: string;
95
+ sourceCommit?: string;
96
+ };
97
+ documents: BundleDocument[];
98
+ assets: BundleAsset[];
99
+ hash: string;
100
+ }
101
+ export interface ComponentHandlerAssetInput {
102
+ path: string;
103
+ mimeType?: string;
104
+ }
105
+ export interface ComponentHandlerContext {
106
+ readonly node: MdxAstNode;
107
+ readonly props: Readonly<Record<string, unknown>>;
108
+ readonly children: readonly MdxAstNode[];
109
+ readonly siteDir: string;
110
+ readonly sourcePath: string;
111
+ readSiteFile(path: string): Promise<string>;
112
+ addAsset(input: ComponentHandlerAssetInput): Promise<string>;
113
+ transformChildren(): Promise<Block[]>;
114
+ }
115
+ export interface ComponentHandler {
116
+ transform(context: ComponentHandlerContext): Block | Block[] | Promise<Block | Block[]>;
117
+ }
118
+ export interface ComponentHandlerRegistration {
119
+ importSource: string;
120
+ exportName: string;
121
+ handler: string | ComponentHandler;
122
+ }
123
+ export interface PluginOptions {
124
+ siteName?: string;
125
+ outputDirectory?: string;
126
+ sourceBaseUrl?: string;
127
+ sourceCommit?: string;
128
+ sourcePathPrefix?: string;
129
+ sourceUrlTemplate?: string;
130
+ sourcePathMappings?: Array<{
131
+ routePattern: string;
132
+ sourcePath: string;
133
+ }>;
134
+ strict?: boolean;
135
+ componentHandlers?: ComponentHandlerRegistration[];
136
+ renderedRoutePatterns?: string[];
137
+ excludeRoutePatterns?: string[];
138
+ htmlContentSelectors?: string[];
139
+ }
140
+ export declare function defineComponentHandler(handler: ComponentHandler): ComponentHandler;
package/dist/types.js ADDED
@@ -0,0 +1,3 @@
1
+ export function defineComponentHandler(handler) {
2
+ return handler;
3
+ }
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "docusaurus-plugin-docusynx",
3
+ "version": "0.1.0",
4
+ "description": "Export a deterministic Docusaurus document bundle for docusynx",
5
+ "license": "Apache-2.0",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/dieend/docusynx.git",
9
+ "directory": "packages/docusaurus-plugin-docusynx"
10
+ },
11
+ "homepage": "https://github.com/dieend/docusynx#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/dieend/docusynx/issues"
14
+ },
15
+ "keywords": [
16
+ "confluence",
17
+ "docusaurus",
18
+ "documentation",
19
+ "wiki"
20
+ ],
21
+ "type": "module",
22
+ "main": "./dist/index.js",
23
+ "types": "./dist/index.d.ts",
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "import": "./dist/index.js",
28
+ "default": "./dist/index.js"
29
+ },
30
+ "./schema": "./schema/document-bundle.schema.json"
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "schema",
35
+ "README.md"
36
+ ],
37
+ "engines": {
38
+ "node": ">=20"
39
+ },
40
+ "peerDependencies": {
41
+ "@docusaurus/core": "^3.10.0"
42
+ },
43
+ "peerDependenciesMeta": {
44
+ "@docusaurus/core": {
45
+ "optional": true
46
+ }
47
+ },
48
+ "publishConfig": {
49
+ "access": "public",
50
+ "registry": "https://registry.npmjs.org/"
51
+ },
52
+ "dependencies": {
53
+ "cheerio": "1.0.0",
54
+ "domhandler": "5.0.3",
55
+ "gray-matter": "4.0.3",
56
+ "mdast-util-to-string": "4.0.0",
57
+ "remark-directive": "4.0.0",
58
+ "remark-frontmatter": "5.0.0",
59
+ "remark-gfm": "4.0.1",
60
+ "remark-mdx": "3.1.1",
61
+ "remark-parse": "11.0.0",
62
+ "unified": "11.0.5"
63
+ },
64
+ "devDependencies": {
65
+ "@types/node": "22.15.3",
66
+ "typescript": "5.8.3",
67
+ "vitest": "3.1.2"
68
+ },
69
+ "scripts": {
70
+ "build": "tsc -p tsconfig.build.json",
71
+ "test": "vitest run",
72
+ "typecheck": "tsc -p tsconfig.json --noEmit"
73
+ }
74
+ }