@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.
@@ -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.1",
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
- "@plebeiantech/lat.md-core": "0.13.4-fork.1",
108
- "@lat.md/stemmer": "0.1.0"
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",