@plebeiantech/lat.md 0.13.4-fork.1 → 0.13.4-fork.2
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/src/cli/index.js +31 -0
- package/dist/src/cli/wiki.d.ts +12 -0
- package/dist/src/cli/wiki.js +85 -0
- package/dist/src/wiki/catalog.d.ts +21 -0
- package/dist/src/wiki/catalog.js +256 -0
- package/dist/src/wiki/publish.d.ts +27 -0
- package/dist/src/wiki/publish.js +265 -0
- package/dist/src/wiki/render.d.ts +20 -0
- package/dist/src/wiki/render.js +163 -0
- package/dist/src/wiki/transform.d.ts +7 -0
- package/dist/src/wiki/transform.js +264 -0
- package/package.json +3 -3
package/dist/src/cli/index.js
CHANGED
|
@@ -406,4 +406,35 @@ program
|
|
|
406
406
|
const { graphCommand } = await import('./graph.js');
|
|
407
407
|
handleResult(await graphCommand(ctx, opts));
|
|
408
408
|
});
|
|
409
|
+
const wikiCmd = program
|
|
410
|
+
.command('wiki')
|
|
411
|
+
.description('Publish Diátaxis documentation to GitHub Wiki');
|
|
412
|
+
wikiCmd
|
|
413
|
+
.command('build [output]')
|
|
414
|
+
.description('Build GitHub Wiki pages into a directory')
|
|
415
|
+
.option('--repo <owner/repo-or-url>', 'GitHub repository to resolve blob links against')
|
|
416
|
+
.option('--ref <ref>', 'Git revision for source links')
|
|
417
|
+
.action(async (output, opts) => {
|
|
418
|
+
const ctx = resolveContext(program.opts());
|
|
419
|
+
const { wikiBuildCommand } = await import('./wiki.js');
|
|
420
|
+
try {
|
|
421
|
+
handleResult(await wikiBuildCommand(ctx, output, opts));
|
|
422
|
+
}
|
|
423
|
+
catch (err) {
|
|
424
|
+
handleResult({ output: err.message, isError: true });
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
wikiCmd
|
|
428
|
+
.command('publish')
|
|
429
|
+
.description('Publish GitHub Wiki pages to companion .wiki.git repository')
|
|
430
|
+
.option('--remote <remote-or-url>', 'Git remote or URL (default: origin)')
|
|
431
|
+
.option('--repo <owner/repo-or-url>', 'GitHub repository for blob links')
|
|
432
|
+
.option('--ref <ref>', 'Git revision for source links')
|
|
433
|
+
.option('--message <message>', 'Commit message for the wiki push')
|
|
434
|
+
.option('--dry-run', 'Commit locally in the clone without pushing')
|
|
435
|
+
.action(async (opts) => {
|
|
436
|
+
const ctx = resolveContext(program.opts());
|
|
437
|
+
const { wikiPublishCommand } = await import('./wiki.js');
|
|
438
|
+
handleResult(await wikiPublishCommand(ctx, opts));
|
|
439
|
+
});
|
|
409
440
|
await program.parseAsync(checkTargetArgs.args, { from: 'user' });
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { CmdContext, CmdResult } from '@plebeiantech/lat.md-core/context';
|
|
2
|
+
export declare function wikiBuildCommand(ctx: CmdContext, output?: string, opts?: {
|
|
3
|
+
repo?: string;
|
|
4
|
+
ref?: string;
|
|
5
|
+
}): Promise<CmdResult>;
|
|
6
|
+
export declare function wikiPublishCommand(ctx: CmdContext, opts?: {
|
|
7
|
+
remote?: string;
|
|
8
|
+
repo?: string;
|
|
9
|
+
ref?: string;
|
|
10
|
+
message?: string;
|
|
11
|
+
dryRun?: boolean;
|
|
12
|
+
}): Promise<CmdResult>;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve } from 'node:path';
|
|
2
|
+
import { buildWikiCatalog } from '../wiki/catalog.js';
|
|
3
|
+
import { transformWikiCatalog } from '../wiki/transform.js';
|
|
4
|
+
import { renderWikiBundle, writeWikiBundle } from '../wiki/render.js';
|
|
5
|
+
import { publishWiki, resolveWikiRemote, } from '../wiki/publish.js';
|
|
6
|
+
// @lat: [[cli#CLI#wiki]]
|
|
7
|
+
export async function wikiBuildCommand(ctx, output, opts) {
|
|
8
|
+
const resolvedOutput = output
|
|
9
|
+
? resolve(ctx.projectRoot, output)
|
|
10
|
+
: resolve(ctx.projectRoot, '.lat-build/wiki');
|
|
11
|
+
const relToLat = relative(ctx.latDir, resolvedOutput);
|
|
12
|
+
if (resolvedOutput === ctx.projectRoot ||
|
|
13
|
+
resolvedOutput === ctx.latDir ||
|
|
14
|
+
(!relToLat.startsWith('..') && !isAbsolute(relToLat))) {
|
|
15
|
+
throw new Error(`Refusing to write wiki build output to source directory (${resolvedOutput}).`);
|
|
16
|
+
}
|
|
17
|
+
let remoteInfo;
|
|
18
|
+
if (opts?.repo) {
|
|
19
|
+
try {
|
|
20
|
+
remoteInfo = resolveWikiRemote(ctx.projectRoot, opts.repo);
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
return {
|
|
24
|
+
output: err.message,
|
|
25
|
+
isError: true,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
try {
|
|
31
|
+
remoteInfo = resolveWikiRemote(ctx.projectRoot);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
remoteInfo = undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const rawCatalog = await buildWikiCatalog(ctx.latDir, ctx.projectRoot);
|
|
38
|
+
if (rawCatalog.docs.length === 0) {
|
|
39
|
+
return {
|
|
40
|
+
output: `No Diátaxis documents found in ${ctx.latDir}.`,
|
|
41
|
+
isError: true,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const catalog = await transformWikiCatalog(rawCatalog, {
|
|
45
|
+
repoUrl: remoteInfo?.repoUrl,
|
|
46
|
+
ref: opts?.ref,
|
|
47
|
+
});
|
|
48
|
+
const bundle = renderWikiBundle(catalog, { repoName: remoteInfo?.repoName });
|
|
49
|
+
const { writtenFiles } = await writeWikiBundle(resolvedOutput, bundle, {
|
|
50
|
+
clean: true,
|
|
51
|
+
});
|
|
52
|
+
return {
|
|
53
|
+
output: `Built ${catalog.docs.length} Diátaxis page(s) (${writtenFiles.length} file(s)) to ${resolvedOutput}.`,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export async function wikiPublishCommand(ctx, opts) {
|
|
57
|
+
try {
|
|
58
|
+
const result = await publishWiki({
|
|
59
|
+
latDir: ctx.latDir,
|
|
60
|
+
projectRoot: ctx.projectRoot,
|
|
61
|
+
remote: opts?.remote,
|
|
62
|
+
repoUrl: opts?.repo,
|
|
63
|
+
ref: opts?.ref,
|
|
64
|
+
message: opts?.message,
|
|
65
|
+
dryRun: opts?.dryRun,
|
|
66
|
+
});
|
|
67
|
+
if (!result.changed) {
|
|
68
|
+
return {
|
|
69
|
+
output: `Wiki is already up to date (${result.pages} page(s)).`,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const action = result.pushed
|
|
73
|
+
? 'Published'
|
|
74
|
+
: 'Built and committed (dry-run)';
|
|
75
|
+
return {
|
|
76
|
+
output: `${action} ${result.pages} Diátaxis page(s) to ${result.wikiRemoteUrl} (${result.commitSha?.slice(0, 7)}).`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
return {
|
|
81
|
+
output: err.message,
|
|
82
|
+
isError: true,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type Section } from '@plebeiantech/lat.md-core/lattice';
|
|
2
|
+
import { type DiataxisMode } from '@plebeiantech/lat.md-core/cli/check-mode';
|
|
3
|
+
export type WikiCatalogDoc = {
|
|
4
|
+
mode: DiataxisMode;
|
|
5
|
+
slug: string;
|
|
6
|
+
title: string;
|
|
7
|
+
firstParagraph: string;
|
|
8
|
+
relPath: string;
|
|
9
|
+
filePath: string;
|
|
10
|
+
body: string;
|
|
11
|
+
sections: Section[];
|
|
12
|
+
};
|
|
13
|
+
export type WikiCatalog = {
|
|
14
|
+
latDir: string;
|
|
15
|
+
projectRoot: string;
|
|
16
|
+
rootTitle?: string;
|
|
17
|
+
rootOverview?: string;
|
|
18
|
+
docs: WikiCatalogDoc[];
|
|
19
|
+
allSections: Section[];
|
|
20
|
+
};
|
|
21
|
+
export declare function buildWikiCatalog(latDir: string, projectRoot?: string): Promise<WikiCatalog>;
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { basename, dirname, relative } from 'node:path';
|
|
3
|
+
import { listLatticeFiles, parseFrontmatter, parseSections, } from '@plebeiantech/lat.md-core/lattice';
|
|
4
|
+
import { parse } from '@plebeiantech/lat.md-core/parser';
|
|
5
|
+
import { toPosix } from '@plebeiantech/lat.md-core/path';
|
|
6
|
+
import { DIATAXIS_MODES, MODE_DIRS, indexNameFor, } from '@plebeiantech/lat.md-core/cli/check-mode';
|
|
7
|
+
const DIR_TO_MODE = Object.create(null);
|
|
8
|
+
for (const mode of DIATAXIS_MODES) {
|
|
9
|
+
DIR_TO_MODE[MODE_DIRS[mode]] = mode;
|
|
10
|
+
}
|
|
11
|
+
const RESERVED_SLUGS = new Set(['home', '_sidebar', '_footer']);
|
|
12
|
+
function headingToText(node) {
|
|
13
|
+
function collect(children) {
|
|
14
|
+
let result = '';
|
|
15
|
+
for (const c of children) {
|
|
16
|
+
if ('value' in c && typeof c.value === 'string') {
|
|
17
|
+
result += c.value;
|
|
18
|
+
}
|
|
19
|
+
else if ('children' in c &&
|
|
20
|
+
Array.isArray(c.children)) {
|
|
21
|
+
result += collect(c.children);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
return collect(node.children).trim();
|
|
27
|
+
}
|
|
28
|
+
function isBadgeOrImageParagraph(node) {
|
|
29
|
+
if (!node.children || node.children.length === 0)
|
|
30
|
+
return true;
|
|
31
|
+
for (const child of node.children) {
|
|
32
|
+
if (child.type === 'image')
|
|
33
|
+
continue;
|
|
34
|
+
if (child.type === 'link') {
|
|
35
|
+
const link = child;
|
|
36
|
+
const allImageOrEmpty = link.children?.every((c) => c.type === 'image' ||
|
|
37
|
+
(c.type === 'text' &&
|
|
38
|
+
typeof c.value === 'string' &&
|
|
39
|
+
c.value.trim() === '')) ?? false;
|
|
40
|
+
if (allImageOrEmpty)
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
if (child.type === 'text' &&
|
|
44
|
+
typeof child.value === 'string' &&
|
|
45
|
+
child.value.trim() === '') {
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
function extractParagraphText(content, node) {
|
|
53
|
+
if (node.position?.start?.offset != null &&
|
|
54
|
+
node.position?.end?.offset != null) {
|
|
55
|
+
return content
|
|
56
|
+
.slice(node.position.start.offset, node.position.end.offset)
|
|
57
|
+
.replace(/\r?\n\s*/g, ' ')
|
|
58
|
+
.trim();
|
|
59
|
+
}
|
|
60
|
+
return '';
|
|
61
|
+
}
|
|
62
|
+
export async function buildWikiCatalog(latDir, projectRoot = dirname(latDir)) {
|
|
63
|
+
const files = await listLatticeFiles(latDir);
|
|
64
|
+
const allSections = [];
|
|
65
|
+
const rawDocs = [];
|
|
66
|
+
let rootTitle;
|
|
67
|
+
let rootOverview;
|
|
68
|
+
for (const file of files) {
|
|
69
|
+
const content = await readFile(file, 'utf-8');
|
|
70
|
+
const tree = parse(content);
|
|
71
|
+
const sections = parseSections(file, content, projectRoot, tree);
|
|
72
|
+
allSections.push(...sections);
|
|
73
|
+
const relPath = toPosix(relative(latDir, file));
|
|
74
|
+
const filePath = toPosix(relative(projectRoot, file));
|
|
75
|
+
const segments = relPath.split('/');
|
|
76
|
+
const fileName = segments.pop();
|
|
77
|
+
const holder = segments.length === 0 ? basename(latDir) : segments.at(-1);
|
|
78
|
+
const isIndex = fileName === indexNameFor(holder);
|
|
79
|
+
// Root index extraction
|
|
80
|
+
if (isIndex && segments.length === 0) {
|
|
81
|
+
// Find first H1 heading
|
|
82
|
+
let h1Node;
|
|
83
|
+
for (const node of tree.children) {
|
|
84
|
+
if (node.type === 'heading' && node.depth === 1) {
|
|
85
|
+
h1Node = node;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (h1Node) {
|
|
90
|
+
rootTitle = headingToText(h1Node);
|
|
91
|
+
const h1Index = tree.children.indexOf(h1Node);
|
|
92
|
+
for (let i = h1Index + 1; i < tree.children.length; i++) {
|
|
93
|
+
const node = tree.children[i];
|
|
94
|
+
if (node.type === 'heading')
|
|
95
|
+
break;
|
|
96
|
+
if (node.type === 'paragraph' && !isBadgeOrImageParagraph(node)) {
|
|
97
|
+
rootOverview = extractParagraphText(content, node);
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
rootTitle = undefined;
|
|
104
|
+
for (const node of tree.children) {
|
|
105
|
+
if (node.type === 'heading')
|
|
106
|
+
break;
|
|
107
|
+
if (node.type === 'paragraph' && !isBadgeOrImageParagraph(node)) {
|
|
108
|
+
rootOverview = extractParagraphText(content, node);
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
// Exclude other directory indexes
|
|
116
|
+
if (isIndex)
|
|
117
|
+
continue;
|
|
118
|
+
const fm = parseFrontmatter(content);
|
|
119
|
+
if (fm.requireCodeMention === true ||
|
|
120
|
+
fm.raw['require-code-mention'] === true) {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const firstSegment = relPath.split('/')[0];
|
|
124
|
+
const dirMode = firstSegment && firstSegment !== relPath
|
|
125
|
+
? DIR_TO_MODE[firstSegment]
|
|
126
|
+
: undefined;
|
|
127
|
+
const declaredRaw = fm.raw['mode'];
|
|
128
|
+
const hasDeclared = declaredRaw !== null && declaredRaw !== undefined;
|
|
129
|
+
const declaredValid = typeof declaredRaw === 'string' &&
|
|
130
|
+
DIATAXIS_MODES.includes(declaredRaw);
|
|
131
|
+
if (hasDeclared && !declaredValid) {
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const declared = declaredValid ? declaredRaw : undefined;
|
|
135
|
+
if (declared && dirMode && declared !== dirMode) {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
const resolvedMode = declared ?? dirMode;
|
|
139
|
+
if (!resolvedMode)
|
|
140
|
+
continue;
|
|
141
|
+
// Body with stripped frontmatter
|
|
142
|
+
const body = content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
|
|
143
|
+
// Title: first H1 heading text, falling back to file stem
|
|
144
|
+
let title;
|
|
145
|
+
for (const s of sections) {
|
|
146
|
+
if (s.depth === 1) {
|
|
147
|
+
title = s.heading;
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const stem = basename(file, '.md');
|
|
152
|
+
if (!title)
|
|
153
|
+
title = stem;
|
|
154
|
+
// First paragraph
|
|
155
|
+
let firstParagraph = '';
|
|
156
|
+
const firstHeadingIndex = tree.children.findIndex((n) => n.type === 'heading');
|
|
157
|
+
if (firstHeadingIndex >= 0) {
|
|
158
|
+
for (let i = firstHeadingIndex + 1; i < tree.children.length; i++) {
|
|
159
|
+
const node = tree.children[i];
|
|
160
|
+
if (node.type === 'heading')
|
|
161
|
+
break;
|
|
162
|
+
if (node.type === 'paragraph') {
|
|
163
|
+
firstParagraph = extractParagraphText(content, node);
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
const firstP = tree.children.find((n) => n.type === 'paragraph');
|
|
170
|
+
if (firstP) {
|
|
171
|
+
firstParagraph = extractParagraphText(content, firstP);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (!firstParagraph && sections.length > 0 && sections[0].firstParagraph) {
|
|
175
|
+
firstParagraph = sections[0].firstParagraph;
|
|
176
|
+
}
|
|
177
|
+
rawDocs.push({
|
|
178
|
+
file,
|
|
179
|
+
relPath,
|
|
180
|
+
filePath,
|
|
181
|
+
content,
|
|
182
|
+
tree,
|
|
183
|
+
sections,
|
|
184
|
+
mode: resolvedMode,
|
|
185
|
+
body,
|
|
186
|
+
title,
|
|
187
|
+
firstParagraph,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
// Sort docs deterministically by relPath
|
|
191
|
+
rawDocs.sort((a, b) => a.relPath.localeCompare(b.relPath));
|
|
192
|
+
// Slug assignment with collision and reserved name handling
|
|
193
|
+
const stemCounts = new Map();
|
|
194
|
+
for (const doc of rawDocs) {
|
|
195
|
+
const stem = basename(doc.relPath, '.md').toLowerCase();
|
|
196
|
+
stemCounts.set(stem, (stemCounts.get(stem) ?? 0) + 1);
|
|
197
|
+
}
|
|
198
|
+
const candidates = rawDocs.map((doc) => {
|
|
199
|
+
const stem = basename(doc.relPath, '.md');
|
|
200
|
+
const modeSlug = `${doc.mode}-${stem}`;
|
|
201
|
+
const relDir = dirname(doc.relPath);
|
|
202
|
+
const dirPrefix = relDir === '.' ? '' : relDir.replace(/\//g, '-');
|
|
203
|
+
const dirSlug = dirPrefix ? `${dirPrefix}-${stem}` : modeSlug;
|
|
204
|
+
return { doc, stem, modeSlug, dirSlug };
|
|
205
|
+
});
|
|
206
|
+
// Check which initial slugs collide or are reserved
|
|
207
|
+
const candidateModeCounts = new Map();
|
|
208
|
+
for (const c of candidates) {
|
|
209
|
+
candidateModeCounts.set(c.modeSlug.toLowerCase(), (candidateModeCounts.get(c.modeSlug.toLowerCase()) ?? 0) + 1);
|
|
210
|
+
}
|
|
211
|
+
const assignedDocs = [];
|
|
212
|
+
const usedSlugs = new Set();
|
|
213
|
+
for (const c of candidates) {
|
|
214
|
+
const lowerStem = c.stem.toLowerCase();
|
|
215
|
+
const isReserved = RESERVED_SLUGS.has(lowerStem);
|
|
216
|
+
const hasStemCollision = (stemCounts.get(lowerStem) ?? 0) > 1;
|
|
217
|
+
let slug;
|
|
218
|
+
if (isReserved || hasStemCollision) {
|
|
219
|
+
const modeCollides = (candidateModeCounts.get(c.modeSlug.toLowerCase()) ?? 0) > 1;
|
|
220
|
+
if (modeCollides && c.dirSlug !== c.modeSlug) {
|
|
221
|
+
slug = c.dirSlug;
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
slug = c.modeSlug;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
slug = c.stem;
|
|
229
|
+
}
|
|
230
|
+
// Ensure no accidental collision with already assigned slug
|
|
231
|
+
let finalSlug = slug;
|
|
232
|
+
let suffix = 2;
|
|
233
|
+
while (usedSlugs.has(finalSlug.toLowerCase())) {
|
|
234
|
+
finalSlug = `${slug}-${suffix++}`;
|
|
235
|
+
}
|
|
236
|
+
usedSlugs.add(finalSlug.toLowerCase());
|
|
237
|
+
assignedDocs.push({
|
|
238
|
+
mode: c.doc.mode,
|
|
239
|
+
slug: finalSlug,
|
|
240
|
+
title: c.doc.title,
|
|
241
|
+
firstParagraph: c.doc.firstParagraph,
|
|
242
|
+
relPath: c.doc.relPath,
|
|
243
|
+
filePath: c.doc.filePath,
|
|
244
|
+
body: c.doc.body,
|
|
245
|
+
sections: c.doc.sections,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
return {
|
|
249
|
+
latDir,
|
|
250
|
+
projectRoot,
|
|
251
|
+
rootTitle,
|
|
252
|
+
rootOverview,
|
|
253
|
+
docs: assignedDocs,
|
|
254
|
+
allSections,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type WikiRemoteInfo = {
|
|
2
|
+
repoName: string;
|
|
3
|
+
repoUrl?: string;
|
|
4
|
+
wikiRemoteUrl: string;
|
|
5
|
+
};
|
|
6
|
+
export type PublishWikiOptions = {
|
|
7
|
+
latDir: string;
|
|
8
|
+
projectRoot?: string;
|
|
9
|
+
remote?: string;
|
|
10
|
+
repoUrl?: string;
|
|
11
|
+
ref?: string;
|
|
12
|
+
message?: string;
|
|
13
|
+
dryRun?: boolean;
|
|
14
|
+
};
|
|
15
|
+
export type PublishWikiResult = {
|
|
16
|
+
wikiRemoteUrl: string;
|
|
17
|
+
repoUrl?: string;
|
|
18
|
+
ref: string;
|
|
19
|
+
pages: number;
|
|
20
|
+
writtenFiles: string[];
|
|
21
|
+
removedFiles: string[];
|
|
22
|
+
changed: boolean;
|
|
23
|
+
pushed: boolean;
|
|
24
|
+
commitSha?: string;
|
|
25
|
+
};
|
|
26
|
+
export declare function resolveWikiRemote(projectRoot: string, remoteOrUrl?: string): WikiRemoteInfo;
|
|
27
|
+
export declare function publishWiki(options: PublishWikiOptions): Promise<PublishWikiResult>;
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { execFile, execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { basename, dirname, isAbsolute, join } from 'node:path';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { buildWikiCatalog } from './catalog.js';
|
|
8
|
+
import { transformWikiCatalog } from './transform.js';
|
|
9
|
+
import { renderWikiBundle, writeWikiBundle } from './render.js';
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
const GITHUB_HTTPS_RE = /^https:\/\/github\.com\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/;
|
|
12
|
+
const GITHUB_SSH_RE = /^(?:git@github\.com:|ssh:\/\/git@github\.com\/)([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?\/?$/;
|
|
13
|
+
const GITHUB_SLUG_RE = /^([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?$/;
|
|
14
|
+
function redactCredentials(url) {
|
|
15
|
+
return url.replace(/:\/\/[^@/]+@/, '://***@');
|
|
16
|
+
}
|
|
17
|
+
function assertNotUpstream(target) {
|
|
18
|
+
const lower = target.trim().toLowerCase();
|
|
19
|
+
if (lower === 'upstream' || lower.includes('vercel-labs/lat.md')) {
|
|
20
|
+
throw new Error(`Refusing to target upstream repository (vercel-labs/lat.md). Specify your fork repository.`);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function parseGitHubUrlOrSlug(target) {
|
|
24
|
+
const httpsMatch = target.match(GITHUB_HTTPS_RE);
|
|
25
|
+
if (httpsMatch) {
|
|
26
|
+
return { owner: httpsMatch[1], repo: httpsMatch[2], isSsh: false };
|
|
27
|
+
}
|
|
28
|
+
const sshMatch = target.match(GITHUB_SSH_RE);
|
|
29
|
+
if (sshMatch) {
|
|
30
|
+
return { owner: sshMatch[1], repo: sshMatch[2], isSsh: true };
|
|
31
|
+
}
|
|
32
|
+
if (!isAbsolute(target) && !existsSync(target)) {
|
|
33
|
+
const slugMatch = target.match(GITHUB_SLUG_RE);
|
|
34
|
+
if (slugMatch) {
|
|
35
|
+
return { owner: slugMatch[1], repo: slugMatch[2], isSsh: false };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
export function resolveWikiRemote(projectRoot, remoteOrUrl) {
|
|
41
|
+
if (remoteOrUrl) {
|
|
42
|
+
if (remoteOrUrl.trim().startsWith('-')) {
|
|
43
|
+
throw new Error(`Invalid git remote or URL: ${remoteOrUrl}`);
|
|
44
|
+
}
|
|
45
|
+
assertNotUpstream(remoteOrUrl);
|
|
46
|
+
}
|
|
47
|
+
let resolved = remoteOrUrl;
|
|
48
|
+
// If omitted, default to origin
|
|
49
|
+
if (!resolved) {
|
|
50
|
+
try {
|
|
51
|
+
const originUrl = execFileSync('git', ['-C', projectRoot, 'remote', 'get-url', '--', 'origin'], {
|
|
52
|
+
encoding: 'utf-8',
|
|
53
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
54
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
55
|
+
}).trim();
|
|
56
|
+
assertNotUpstream(originUrl);
|
|
57
|
+
resolved = originUrl;
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
if (err.message.includes('Refusing to target upstream')) {
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
throw new Error(`No remote named 'origin' found in git repository. Specify --remote or --repo.`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
else if (!resolved.includes('/') &&
|
|
67
|
+
!resolved.includes(':') &&
|
|
68
|
+
!resolved.endsWith('.git')) {
|
|
69
|
+
// Single remote name token
|
|
70
|
+
try {
|
|
71
|
+
const namedUrl = execFileSync('git', ['-C', projectRoot, 'remote', 'get-url', '--', resolved], {
|
|
72
|
+
encoding: 'utf-8',
|
|
73
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
74
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
75
|
+
}).trim();
|
|
76
|
+
assertNotUpstream(namedUrl);
|
|
77
|
+
resolved = namedUrl;
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
if (err.message.includes('Refusing to target upstream')) {
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
// If remote get-url failed, fall through with the raw token
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
assertNotUpstream(resolved);
|
|
87
|
+
const gh = parseGitHubUrlOrSlug(resolved);
|
|
88
|
+
if (gh) {
|
|
89
|
+
const repoName = gh.repo;
|
|
90
|
+
const repoUrl = `https://github.com/${gh.owner}/${gh.repo}`;
|
|
91
|
+
const wikiRemoteUrl = gh.isSsh
|
|
92
|
+
? `git@github.com:${gh.owner}/${gh.repo}.wiki.git`
|
|
93
|
+
: `https://github.com/${gh.owner}/${gh.repo}.wiki.git`;
|
|
94
|
+
if (wikiRemoteUrl.trim().startsWith('-')) {
|
|
95
|
+
throw new Error(`Invalid wiki remote URL: ${wikiRemoteUrl}`);
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
repoName,
|
|
99
|
+
repoUrl,
|
|
100
|
+
wikiRemoteUrl,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
// Treat as local filesystem path
|
|
104
|
+
let wikiRemoteUrl = resolved;
|
|
105
|
+
if (wikiRemoteUrl.endsWith('.wiki.git')) {
|
|
106
|
+
// already ends with .wiki.git
|
|
107
|
+
}
|
|
108
|
+
else if (wikiRemoteUrl.endsWith('.git')) {
|
|
109
|
+
wikiRemoteUrl = wikiRemoteUrl.slice(0, -4) + '.wiki.git';
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
wikiRemoteUrl = `${wikiRemoteUrl}.wiki.git`;
|
|
113
|
+
}
|
|
114
|
+
if (wikiRemoteUrl.trim().startsWith('-')) {
|
|
115
|
+
throw new Error(`Invalid wiki remote URL: ${wikiRemoteUrl}`);
|
|
116
|
+
}
|
|
117
|
+
const base = basename(resolved);
|
|
118
|
+
const repoName = base.replace(/\.wiki\.git$/, '').replace(/\.git$/, '');
|
|
119
|
+
return {
|
|
120
|
+
repoName,
|
|
121
|
+
repoUrl: undefined,
|
|
122
|
+
wikiRemoteUrl,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
function resolveGitRef(projectRoot, explicitRef) {
|
|
126
|
+
if (explicitRef)
|
|
127
|
+
return explicitRef;
|
|
128
|
+
try {
|
|
129
|
+
const stdout = execFileSync('git', ['-C', projectRoot, 'rev-parse', 'HEAD'], {
|
|
130
|
+
encoding: 'utf-8',
|
|
131
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
132
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
133
|
+
}).trim();
|
|
134
|
+
if (stdout)
|
|
135
|
+
return stdout;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
// ignore
|
|
139
|
+
}
|
|
140
|
+
return 'HEAD';
|
|
141
|
+
}
|
|
142
|
+
export async function publishWiki(options) {
|
|
143
|
+
const projectRoot = options.projectRoot ?? dirname(options.latDir);
|
|
144
|
+
const remoteInfo = resolveWikiRemote(projectRoot, options.remote);
|
|
145
|
+
if (options.repoUrl) {
|
|
146
|
+
if (options.repoUrl.trim().startsWith('-')) {
|
|
147
|
+
throw new Error(`Invalid repo URL: ${options.repoUrl}`);
|
|
148
|
+
}
|
|
149
|
+
assertNotUpstream(options.repoUrl);
|
|
150
|
+
const gh = parseGitHubUrlOrSlug(options.repoUrl);
|
|
151
|
+
if (gh) {
|
|
152
|
+
remoteInfo.repoUrl = `https://github.com/${gh.owner}/${gh.repo}`;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (remoteInfo.wikiRemoteUrl.trim().startsWith('-')) {
|
|
156
|
+
throw new Error(`Invalid wiki remote URL: ${remoteInfo.wikiRemoteUrl}`);
|
|
157
|
+
}
|
|
158
|
+
const ref = resolveGitRef(projectRoot, options.ref);
|
|
159
|
+
const shortSha = ref.slice(0, 7);
|
|
160
|
+
const rawCatalog = await buildWikiCatalog(options.latDir, projectRoot);
|
|
161
|
+
if (rawCatalog.docs.length === 0) {
|
|
162
|
+
throw new Error(`No Diataxis documents found in ${options.latDir} to publish.`);
|
|
163
|
+
}
|
|
164
|
+
const catalog = await transformWikiCatalog(rawCatalog, {
|
|
165
|
+
repoUrl: remoteInfo.repoUrl,
|
|
166
|
+
ref,
|
|
167
|
+
});
|
|
168
|
+
const cloneDir = await mkdtemp(join(tmpdir(), 'lat-wiki-clone-'));
|
|
169
|
+
try {
|
|
170
|
+
try {
|
|
171
|
+
await execFileAsync('git', ['clone', '--', remoteInfo.wikiRemoteUrl, cloneDir], {
|
|
172
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
const safeUrl = redactCredentials(remoteInfo.wikiRemoteUrl);
|
|
177
|
+
const safeMsg = redactCredentials(err.message);
|
|
178
|
+
throw new Error(`Failed to clone wiki repository (${safeUrl}). ` +
|
|
179
|
+
`GitHub requires enabling Wikis in repository settings and creating the initial page ` +
|
|
180
|
+
`in the repository's Wiki tab before the wiki repository can be cloned or pushed.\n` +
|
|
181
|
+
`${safeMsg}`);
|
|
182
|
+
}
|
|
183
|
+
const bundle = renderWikiBundle(catalog, { repoName: remoteInfo.repoName });
|
|
184
|
+
const { writtenFiles, removedFiles } = await writeWikiBundle(cloneDir, bundle, {
|
|
185
|
+
clean: true,
|
|
186
|
+
});
|
|
187
|
+
const statusOutput = (await execFileAsync('git', ['status', '--porcelain'], {
|
|
188
|
+
cwd: cloneDir,
|
|
189
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
190
|
+
})).stdout.trim();
|
|
191
|
+
if (!statusOutput) {
|
|
192
|
+
return {
|
|
193
|
+
wikiRemoteUrl: remoteInfo.wikiRemoteUrl,
|
|
194
|
+
repoUrl: remoteInfo.repoUrl,
|
|
195
|
+
ref,
|
|
196
|
+
pages: catalog.docs.length,
|
|
197
|
+
writtenFiles,
|
|
198
|
+
removedFiles,
|
|
199
|
+
changed: false,
|
|
200
|
+
pushed: false,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
await execFileAsync('git', ['add', '-A'], {
|
|
204
|
+
cwd: cloneDir,
|
|
205
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
206
|
+
});
|
|
207
|
+
const message = options.message ?? `Publish Diataxis documentation (${shortSha})`;
|
|
208
|
+
await execFileAsync('git', [
|
|
209
|
+
'-c',
|
|
210
|
+
'user.name=lat.md',
|
|
211
|
+
'-c',
|
|
212
|
+
'user.email=lat@users.noreply.github.com',
|
|
213
|
+
'-c',
|
|
214
|
+
'commit.gpgsign=false',
|
|
215
|
+
'commit',
|
|
216
|
+
'-m',
|
|
217
|
+
message,
|
|
218
|
+
], {
|
|
219
|
+
cwd: cloneDir,
|
|
220
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
221
|
+
});
|
|
222
|
+
const commitSha = (await execFileAsync('git', ['rev-parse', 'HEAD'], {
|
|
223
|
+
cwd: cloneDir,
|
|
224
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
225
|
+
})).stdout.trim();
|
|
226
|
+
if (options.dryRun) {
|
|
227
|
+
return {
|
|
228
|
+
wikiRemoteUrl: remoteInfo.wikiRemoteUrl,
|
|
229
|
+
repoUrl: remoteInfo.repoUrl,
|
|
230
|
+
ref,
|
|
231
|
+
pages: catalog.docs.length,
|
|
232
|
+
writtenFiles,
|
|
233
|
+
removedFiles,
|
|
234
|
+
changed: true,
|
|
235
|
+
pushed: false,
|
|
236
|
+
commitSha,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
await execFileAsync('git', ['push', 'origin', 'HEAD'], {
|
|
241
|
+
cwd: cloneDir,
|
|
242
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
const safeUrl = redactCredentials(remoteInfo.wikiRemoteUrl);
|
|
247
|
+
const safeMsg = redactCredentials(err.message);
|
|
248
|
+
throw new Error(`Failed to push to wiki repository (${safeUrl}): ${safeMsg}`);
|
|
249
|
+
}
|
|
250
|
+
return {
|
|
251
|
+
wikiRemoteUrl: remoteInfo.wikiRemoteUrl,
|
|
252
|
+
repoUrl: remoteInfo.repoUrl,
|
|
253
|
+
ref,
|
|
254
|
+
pages: catalog.docs.length,
|
|
255
|
+
writtenFiles,
|
|
256
|
+
removedFiles,
|
|
257
|
+
changed: true,
|
|
258
|
+
pushed: true,
|
|
259
|
+
commitSha,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
await rm(cloneDir, { recursive: true, force: true });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { WikiCatalog } from './catalog.js';
|
|
2
|
+
export type WikiBundlePage = {
|
|
3
|
+
slug: string;
|
|
4
|
+
fileName: string;
|
|
5
|
+
content: string;
|
|
6
|
+
};
|
|
7
|
+
export type WikiBundle = {
|
|
8
|
+
home: string;
|
|
9
|
+
sidebar: string;
|
|
10
|
+
pages: WikiBundlePage[];
|
|
11
|
+
};
|
|
12
|
+
export declare function renderWikiBundle(catalog: WikiCatalog, options?: {
|
|
13
|
+
repoName?: string;
|
|
14
|
+
}): WikiBundle;
|
|
15
|
+
export declare function writeWikiBundle(outputDir: string, bundle: WikiBundle, options?: {
|
|
16
|
+
clean?: boolean;
|
|
17
|
+
}): Promise<{
|
|
18
|
+
writtenFiles: string[];
|
|
19
|
+
removedFiles: string[];
|
|
20
|
+
}>;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { lstat, mkdir, readdir, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
3
|
+
const MODE_ORDER = [
|
|
4
|
+
'tutorial',
|
|
5
|
+
'how-to',
|
|
6
|
+
'reference',
|
|
7
|
+
'explanation',
|
|
8
|
+
];
|
|
9
|
+
const MODE_HEADINGS = {
|
|
10
|
+
tutorial: { h2: '## Tutorials', h3: '### Tutorials' },
|
|
11
|
+
'how-to': { h2: '## How-To Guides', h3: '### How-To Guides' },
|
|
12
|
+
reference: { h2: '## Reference', h3: '### Reference' },
|
|
13
|
+
explanation: { h2: '## Explanation', h3: '### Explanation' },
|
|
14
|
+
};
|
|
15
|
+
function normalizeContent(content) {
|
|
16
|
+
return content.endsWith('\n') ? content : content + '\n';
|
|
17
|
+
}
|
|
18
|
+
export function renderWikiBundle(catalog, options) {
|
|
19
|
+
const rootTitle = catalog.rootTitle ?? options?.repoName ?? 'Documentation';
|
|
20
|
+
// Group docs by mode
|
|
21
|
+
const docsByMode = new Map();
|
|
22
|
+
for (const mode of MODE_ORDER) {
|
|
23
|
+
docsByMode.set(mode, []);
|
|
24
|
+
}
|
|
25
|
+
for (const doc of catalog.docs) {
|
|
26
|
+
const list = docsByMode.get(doc.mode);
|
|
27
|
+
if (list) {
|
|
28
|
+
list.push(doc);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
// Sort docs within each mode by slug
|
|
32
|
+
for (const list of docsByMode.values()) {
|
|
33
|
+
list.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
34
|
+
}
|
|
35
|
+
// 1. Home.md
|
|
36
|
+
const homeParts = [];
|
|
37
|
+
homeParts.push(`# ${rootTitle}`);
|
|
38
|
+
if (catalog.rootOverview) {
|
|
39
|
+
homeParts.push(catalog.rootOverview);
|
|
40
|
+
}
|
|
41
|
+
for (const mode of MODE_ORDER) {
|
|
42
|
+
const docs = docsByMode.get(mode);
|
|
43
|
+
if (docs.length === 0)
|
|
44
|
+
continue;
|
|
45
|
+
homeParts.push(MODE_HEADINGS[mode].h2);
|
|
46
|
+
const lines = docs.map((doc) => {
|
|
47
|
+
const summary = doc.firstParagraph ? `: ${doc.firstParagraph}` : '';
|
|
48
|
+
return `- [${doc.title}](${doc.slug})${summary}`;
|
|
49
|
+
});
|
|
50
|
+
homeParts.push(lines.join('\n'));
|
|
51
|
+
}
|
|
52
|
+
const home = normalizeContent(homeParts.join('\n\n'));
|
|
53
|
+
// 2. _Sidebar.md
|
|
54
|
+
const sidebarParts = ['- [Home](Home)'];
|
|
55
|
+
for (const mode of MODE_ORDER) {
|
|
56
|
+
const docs = docsByMode.get(mode);
|
|
57
|
+
if (docs.length === 0)
|
|
58
|
+
continue;
|
|
59
|
+
const lines = docs.map((doc) => `- [${doc.title}](${doc.slug})`);
|
|
60
|
+
sidebarParts.push(`${MODE_HEADINGS[mode].h3}\n${lines.join('\n')}`);
|
|
61
|
+
}
|
|
62
|
+
const sidebar = normalizeContent(sidebarParts.join('\n\n'));
|
|
63
|
+
// 3. Pages ordered by slug
|
|
64
|
+
const sortedDocs = [...catalog.docs].sort((a, b) => a.slug.localeCompare(b.slug));
|
|
65
|
+
const pages = sortedDocs.map((doc) => ({
|
|
66
|
+
slug: doc.slug,
|
|
67
|
+
fileName: `${doc.slug}.md`,
|
|
68
|
+
content: normalizeContent(doc.body),
|
|
69
|
+
}));
|
|
70
|
+
return {
|
|
71
|
+
home,
|
|
72
|
+
sidebar,
|
|
73
|
+
pages,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
async function verifyNoSymlinksInPath(targetPath) {
|
|
77
|
+
const absPath = resolve(targetPath);
|
|
78
|
+
const root = isAbsolute(absPath) ? resolve(sep) : '';
|
|
79
|
+
const parts = absPath.slice(root.length).split(sep);
|
|
80
|
+
let current = root;
|
|
81
|
+
for (const part of parts) {
|
|
82
|
+
if (!part)
|
|
83
|
+
continue;
|
|
84
|
+
current = join(current, part);
|
|
85
|
+
try {
|
|
86
|
+
const stat = await lstat(current);
|
|
87
|
+
if (stat.isSymbolicLink()) {
|
|
88
|
+
if (process.platform === 'darwin' &&
|
|
89
|
+
(current === '/var' || current === '/tmp' || current === '/etc')) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
throw new Error(`Refusing to write to symbolic link path: ${current}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
if (err.code === 'ENOENT') {
|
|
97
|
+
break; // remaining path does not exist yet
|
|
98
|
+
}
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
export async function writeWikiBundle(outputDir, bundle, options) {
|
|
104
|
+
const absOutputDir = resolve(outputDir);
|
|
105
|
+
await verifyNoSymlinksInPath(absOutputDir);
|
|
106
|
+
await mkdir(absOutputDir, { recursive: true });
|
|
107
|
+
const filesToWrite = [
|
|
108
|
+
{ fileName: 'Home.md', content: bundle.home },
|
|
109
|
+
{ fileName: '_Sidebar.md', content: bundle.sidebar },
|
|
110
|
+
];
|
|
111
|
+
for (const page of bundle.pages) {
|
|
112
|
+
if (page.fileName.includes('/') ||
|
|
113
|
+
page.fileName.includes('\\') ||
|
|
114
|
+
page.fileName.includes('..') ||
|
|
115
|
+
page.slug.includes('/') ||
|
|
116
|
+
page.slug.includes('\\') ||
|
|
117
|
+
page.slug.includes('..')) {
|
|
118
|
+
throw new Error(`Invalid page slug or fileName: ${page.fileName}`);
|
|
119
|
+
}
|
|
120
|
+
filesToWrite.push({ fileName: page.fileName, content: page.content });
|
|
121
|
+
}
|
|
122
|
+
const writtenSet = new Set();
|
|
123
|
+
const writtenFiles = [];
|
|
124
|
+
for (const item of filesToWrite) {
|
|
125
|
+
const filePath = resolve(absOutputDir, item.fileName);
|
|
126
|
+
if (dirname(filePath) !== absOutputDir) {
|
|
127
|
+
throw new Error(`Path traversal attempted: ${item.fileName}`);
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
const stat = await lstat(filePath);
|
|
131
|
+
if (stat.isSymbolicLink()) {
|
|
132
|
+
throw new Error(`Refusing to overwrite symbolic link: ${filePath}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
if (err.code !== 'ENOENT') {
|
|
137
|
+
throw err;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
await writeFile(filePath, item.content, 'utf-8');
|
|
141
|
+
writtenSet.add(item.fileName);
|
|
142
|
+
writtenFiles.push(item.fileName);
|
|
143
|
+
}
|
|
144
|
+
const removedFiles = [];
|
|
145
|
+
if (options?.clean) {
|
|
146
|
+
const writtenLower = new Set([...writtenSet].map((f) => f.toLowerCase()));
|
|
147
|
+
const entries = await readdir(absOutputDir);
|
|
148
|
+
for (const entry of entries) {
|
|
149
|
+
if (entry.toLowerCase().endsWith('.md') &&
|
|
150
|
+
!writtenLower.has(entry.toLowerCase())) {
|
|
151
|
+
const entryPath = join(absOutputDir, entry);
|
|
152
|
+
await rm(entryPath, { force: true });
|
|
153
|
+
removedFiles.push(entry);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
writtenFiles.sort();
|
|
158
|
+
removedFiles.sort();
|
|
159
|
+
return {
|
|
160
|
+
writtenFiles,
|
|
161
|
+
removedFiles,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { WikiCatalog, WikiCatalogDoc } from './catalog.js';
|
|
2
|
+
export type WikiTransformOptions = {
|
|
3
|
+
repoUrl?: string;
|
|
4
|
+
ref?: string;
|
|
5
|
+
};
|
|
6
|
+
export declare function transformWikiDocument(doc: WikiCatalogDoc, catalog: WikiCatalog, options?: WikiTransformOptions): Promise<WikiCatalogDoc>;
|
|
7
|
+
export declare function transformWikiCatalog(catalog: WikiCatalog, options?: WikiTransformOptions): Promise<WikiCatalog>;
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { existsSync, statSync } from 'node:fs';
|
|
2
|
+
import { dirname, isAbsolute, relative, resolve } from 'node:path';
|
|
3
|
+
import { buildFileIndex, buildSectionSlugIndex, flattenSections, resolveRef, } from '@plebeiantech/lat.md-core/lattice';
|
|
4
|
+
import { inspectRepositoryPath, normalizeRepositoryPath, } from '@plebeiantech/lat.md-core/repository-path';
|
|
5
|
+
import { resolveSourceSymbol, } from '@plebeiantech/lat.md-core/source-parser';
|
|
6
|
+
import { isSourceFilePath } from '@plebeiantech/lat.md-core/source-formats';
|
|
7
|
+
import { parse } from '@plebeiantech/lat.md-core/parser';
|
|
8
|
+
import { toPosix } from '@plebeiantech/lat.md-core/path';
|
|
9
|
+
function collectTargetNodes(node, list) {
|
|
10
|
+
if ((node.type === 'wikiLink' || node.type === 'link') &&
|
|
11
|
+
node.position?.start?.offset != null &&
|
|
12
|
+
node.position?.end?.offset != null) {
|
|
13
|
+
list.push(node);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
if ('children' in node &&
|
|
17
|
+
Array.isArray(node.children)) {
|
|
18
|
+
for (const child of node.children) {
|
|
19
|
+
collectTargetNodes(child, list);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
async function rewriteText(text, baseDir, ctx) {
|
|
24
|
+
if (!text)
|
|
25
|
+
return text;
|
|
26
|
+
const tree = parse(text);
|
|
27
|
+
const nodes = [];
|
|
28
|
+
collectTargetNodes(tree, nodes);
|
|
29
|
+
if (nodes.length === 0)
|
|
30
|
+
return text;
|
|
31
|
+
const repoUrl = ctx.options?.repoUrl?.replace(/\/+$/, '');
|
|
32
|
+
const ref = ctx.options?.ref ?? 'HEAD';
|
|
33
|
+
const replacements = [];
|
|
34
|
+
for (const node of nodes) {
|
|
35
|
+
const start = node.position.start.offset;
|
|
36
|
+
const end = node.position.end.offset;
|
|
37
|
+
if (node.type === 'wikiLink') {
|
|
38
|
+
const target = (node.value ?? '').trim();
|
|
39
|
+
const alias = node.data?.alias ? node.data.alias.trim() : undefined;
|
|
40
|
+
// 1. Try to resolve as Markdown section
|
|
41
|
+
const refResult = resolveRef(target, ctx.sectionIds, ctx.fileIndex, ctx.slugIndex);
|
|
42
|
+
const section = refResult?.resolved
|
|
43
|
+
? ctx.sectionByLowerId.get(refResult.resolved.toLowerCase())
|
|
44
|
+
: undefined;
|
|
45
|
+
if (section) {
|
|
46
|
+
const doc = ctx.docByFilePath.get(section.filePath);
|
|
47
|
+
const label = alias ?? section.heading;
|
|
48
|
+
if (doc) {
|
|
49
|
+
// Exported Diataxis section link
|
|
50
|
+
const dest = section.depth === 1
|
|
51
|
+
? doc.slug
|
|
52
|
+
: `${doc.slug}#${section.githubSlug}`;
|
|
53
|
+
replacements.push({ start, end, replacement: `[${label}](${dest})` });
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
// Non-exported lat.md section link
|
|
57
|
+
if (repoUrl) {
|
|
58
|
+
const frag = section.githubSlug ? `#${section.githubSlug}` : '';
|
|
59
|
+
replacements.push({
|
|
60
|
+
start,
|
|
61
|
+
end,
|
|
62
|
+
replacement: `[${label}](${repoUrl}/blob/${ref}/${section.filePath}${frag})`,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
replacements.push({ start, end, replacement: label });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
// 2. Source code and repository path link
|
|
72
|
+
const hashIdx = target.indexOf('#');
|
|
73
|
+
const filePart = hashIdx >= 0 ? target.slice(0, hashIdx) : target;
|
|
74
|
+
const symbolPath = hashIdx >= 0 ? target.slice(hashIdx + 1) : undefined;
|
|
75
|
+
const normalizedPath = normalizeRepositoryPath(filePart);
|
|
76
|
+
if (!normalizedPath) {
|
|
77
|
+
replacements.push({ start, end, replacement: alias ?? target });
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const inspected = await inspectRepositoryPath(ctx.catalog.projectRoot, normalizedPath);
|
|
81
|
+
if (inspected.kind !== 'file' && inspected.kind !== 'directory') {
|
|
82
|
+
replacements.push({ start, end, replacement: alias ?? target });
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const label = alias ?? (symbolPath || normalizedPath);
|
|
86
|
+
if (symbolPath && isSourceFilePath(normalizedPath)) {
|
|
87
|
+
const symbolResult = await resolveSourceSymbol(normalizedPath, symbolPath, ctx.catalog.projectRoot, { latDir: ctx.catalog.latDir });
|
|
88
|
+
const parts = symbolPath.split('#');
|
|
89
|
+
let symbol;
|
|
90
|
+
if (parts.length === 1) {
|
|
91
|
+
symbol = symbolResult.symbols.find((s) => s.name === parts[0] && !s.parent);
|
|
92
|
+
}
|
|
93
|
+
else if (parts.length === 2) {
|
|
94
|
+
symbol = symbolResult.symbols.find((s) => s.name === parts[1] && s.parent === parts[0]);
|
|
95
|
+
}
|
|
96
|
+
if (symbol) {
|
|
97
|
+
const lineFrag = symbol.startLine === symbol.endLine
|
|
98
|
+
? `#L${symbol.startLine}`
|
|
99
|
+
: `#L${symbol.startLine}-L${symbol.endLine}`;
|
|
100
|
+
if (repoUrl) {
|
|
101
|
+
replacements.push({
|
|
102
|
+
start,
|
|
103
|
+
end,
|
|
104
|
+
replacement: `[${label}](${repoUrl}/blob/${ref}/${normalizedPath}${lineFrag})`,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
replacements.push({ start, end, replacement: label });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
if (repoUrl) {
|
|
113
|
+
replacements.push({
|
|
114
|
+
start,
|
|
115
|
+
end,
|
|
116
|
+
replacement: `[${label}](${repoUrl}/blob/${ref}/${normalizedPath})`,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
replacements.push({ start, end, replacement: label });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
const action = inspected.kind === 'directory' ? 'tree' : 'blob';
|
|
126
|
+
if (repoUrl) {
|
|
127
|
+
replacements.push({
|
|
128
|
+
start,
|
|
129
|
+
end,
|
|
130
|
+
replacement: `[${label}](${repoUrl}/${action}/${ref}/${normalizedPath})`,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
replacements.push({ start, end, replacement: label });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
else if (node.type === 'link') {
|
|
139
|
+
const url = node.url ?? '';
|
|
140
|
+
// Skip same-page anchors and external URLs
|
|
141
|
+
if (url.startsWith('#') ||
|
|
142
|
+
url.startsWith('http://') ||
|
|
143
|
+
url.startsWith('https://') ||
|
|
144
|
+
url.startsWith('mailto:') ||
|
|
145
|
+
url.startsWith('tel:')) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
// Extract inner label text
|
|
149
|
+
let label = '';
|
|
150
|
+
if (node.children && node.children.length > 0) {
|
|
151
|
+
const first = node.children[0];
|
|
152
|
+
const last = node.children[node.children.length - 1];
|
|
153
|
+
if (first.position?.start?.offset != null &&
|
|
154
|
+
last.position?.end?.offset != null) {
|
|
155
|
+
label = text.slice(first.position.start.offset, last.position.end.offset);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const hashIdx = url.indexOf('#');
|
|
159
|
+
const pathPart = hashIdx >= 0 ? url.slice(0, hashIdx) : url;
|
|
160
|
+
const frag = hashIdx >= 0 ? url.slice(hashIdx) : '';
|
|
161
|
+
const resolvedPath = resolve(baseDir, pathPart);
|
|
162
|
+
const relToRoot = relative(ctx.catalog.projectRoot, resolvedPath);
|
|
163
|
+
const isInsideRoot = !relToRoot.startsWith('..') && !isAbsolute(relToRoot);
|
|
164
|
+
if (!isInsideRoot) {
|
|
165
|
+
// Prevent path traversal escape: do not rewrite links escaping project root
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const repoRelPath = toPosix(relToRoot);
|
|
169
|
+
const doc = ctx.docByFilePath.get(repoRelPath) ??
|
|
170
|
+
ctx.docByFilePath.get(repoRelPath + '.md');
|
|
171
|
+
if (doc) {
|
|
172
|
+
replacements.push({
|
|
173
|
+
start,
|
|
174
|
+
end,
|
|
175
|
+
replacement: `[${label}](${doc.slug}${frag})`,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
else if (existsSync(resolvedPath)) {
|
|
179
|
+
let isDir = false;
|
|
180
|
+
try {
|
|
181
|
+
isDir = statSync(resolvedPath).isDirectory();
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// ignore
|
|
185
|
+
}
|
|
186
|
+
const action = isDir ? 'tree' : 'blob';
|
|
187
|
+
if (repoUrl) {
|
|
188
|
+
replacements.push({
|
|
189
|
+
start,
|
|
190
|
+
end,
|
|
191
|
+
replacement: `[${label}](${repoUrl}/${action}/${ref}/${repoRelPath}${frag})`,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
replacements.push({ start, end, replacement: label });
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// Apply replacements in reverse order of appearance
|
|
201
|
+
replacements.sort((a, b) => b.start - a.start);
|
|
202
|
+
let result = text;
|
|
203
|
+
for (const rep of replacements) {
|
|
204
|
+
result =
|
|
205
|
+
result.slice(0, rep.start) + rep.replacement + result.slice(rep.end);
|
|
206
|
+
}
|
|
207
|
+
return result;
|
|
208
|
+
}
|
|
209
|
+
function createTransformContext(catalog, options) {
|
|
210
|
+
const flat = flattenSections(catalog.allSections);
|
|
211
|
+
const sectionIds = new Set(flat.map((s) => s.id.toLowerCase()));
|
|
212
|
+
const sectionByLowerId = new Map(flat.map((s) => [s.id.toLowerCase(), s]));
|
|
213
|
+
const fileIndex = buildFileIndex(catalog.allSections);
|
|
214
|
+
const slugIndex = buildSectionSlugIndex(catalog.allSections);
|
|
215
|
+
const docByFilePath = new Map(catalog.docs.map((d) => [d.filePath, d]));
|
|
216
|
+
const docByRelPath = new Map(catalog.docs.map((d) => [d.relPath, d]));
|
|
217
|
+
return {
|
|
218
|
+
catalog,
|
|
219
|
+
options,
|
|
220
|
+
sectionIds,
|
|
221
|
+
sectionByLowerId,
|
|
222
|
+
fileIndex,
|
|
223
|
+
slugIndex,
|
|
224
|
+
docByFilePath,
|
|
225
|
+
docByRelPath,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
export async function transformWikiDocument(doc, catalog, options) {
|
|
229
|
+
const ctx = createTransformContext(catalog, options);
|
|
230
|
+
const baseDir = resolve(catalog.projectRoot, dirname(doc.filePath));
|
|
231
|
+
const [body, firstParagraph] = await Promise.all([
|
|
232
|
+
rewriteText(doc.body, baseDir, ctx),
|
|
233
|
+
rewriteText(doc.firstParagraph, baseDir, ctx),
|
|
234
|
+
]);
|
|
235
|
+
return {
|
|
236
|
+
...doc,
|
|
237
|
+
body,
|
|
238
|
+
firstParagraph,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
export async function transformWikiCatalog(catalog, options) {
|
|
242
|
+
const ctx = createTransformContext(catalog, options);
|
|
243
|
+
const transformedDocs = await Promise.all(catalog.docs.map(async (doc) => {
|
|
244
|
+
const baseDir = resolve(catalog.projectRoot, dirname(doc.filePath));
|
|
245
|
+
const [body, firstParagraph] = await Promise.all([
|
|
246
|
+
rewriteText(doc.body, baseDir, ctx),
|
|
247
|
+
rewriteText(doc.firstParagraph, baseDir, ctx),
|
|
248
|
+
]);
|
|
249
|
+
return {
|
|
250
|
+
...doc,
|
|
251
|
+
body,
|
|
252
|
+
firstParagraph,
|
|
253
|
+
};
|
|
254
|
+
}));
|
|
255
|
+
let rootOverview = catalog.rootOverview;
|
|
256
|
+
if (rootOverview) {
|
|
257
|
+
rootOverview = await rewriteText(rootOverview, catalog.latDir, ctx);
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
...catalog,
|
|
261
|
+
rootOverview,
|
|
262
|
+
docs: transformedDocs,
|
|
263
|
+
};
|
|
264
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plebeiantech/lat.md",
|
|
3
|
-
"version": "0.13.4-fork.
|
|
3
|
+
"version": "0.13.4-fork.2",
|
|
4
4
|
"description": "A knowledge graph for your codebase, written in markdown",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -104,8 +104,8 @@
|
|
|
104
104
|
"zod": "^4.3.6",
|
|
105
105
|
"@lat.md/embed": "0.2.2",
|
|
106
106
|
"@lat.md/embed-minilm-fp16": "0.1.1",
|
|
107
|
-
"@
|
|
108
|
-
"@lat.md
|
|
107
|
+
"@lat.md/stemmer": "0.1.0",
|
|
108
|
+
"@plebeiantech/lat.md-core": "0.13.4-fork.2"
|
|
109
109
|
},
|
|
110
110
|
"scripts": {
|
|
111
111
|
"build": "tsc && tsx scripts/fork-vendor-ui-server.ts && pnpm build:view",
|