@limcpf/everything-is-a-markdown 0.2.0 → 0.2.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@limcpf/everything-is-a-markdown",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
package/src/build.ts CHANGED
@@ -20,13 +20,31 @@ import {
20
20
  toRoute,
21
21
  } from "./utils";
22
22
 
23
- const CACHE_VERSION = 1;
23
+ const CACHE_VERSION = 2;
24
24
  const CACHE_DIR_NAME = ".cache";
25
25
  const CACHE_FILE_NAME = "build-index.json";
26
26
  const DEFAULT_BRANCH = "dev";
27
27
  const DEFAULT_SITE_DESCRIPTION = "File-system style static blog with markdown explorer UI.";
28
28
  const DEFAULT_SITE_TITLE = "File-System Blog";
29
29
 
30
+ type CachedSourceEntry = BuildCache["sources"][string];
31
+
32
+ interface OutputWriteContext {
33
+ outDir: string;
34
+ previousHashes: Record<string, string>;
35
+ nextHashes: Record<string, string>;
36
+ }
37
+
38
+ interface WikiLookup {
39
+ byPath: Map<string, DocRecord>;
40
+ byStem: Map<string, DocRecord[]>;
41
+ }
42
+
43
+ interface ReadDocsResult {
44
+ docs: DocRecord[];
45
+ nextSources: BuildCache["sources"];
46
+ }
47
+
30
48
  interface BuildResult {
31
49
  totalDocs: number;
32
50
  renderedDocs: number;
@@ -41,20 +59,130 @@ function toCachePath(): string {
41
59
  return path.join(process.cwd(), CACHE_DIR_NAME, CACHE_FILE_NAME);
42
60
  }
43
61
 
62
+ function createEmptyCache(): BuildCache {
63
+ return { version: CACHE_VERSION, sources: {}, docs: {}, outputHashes: {} };
64
+ }
65
+
66
+ function isRecord(value: unknown): value is Record<string, unknown> {
67
+ return typeof value === "object" && value !== null;
68
+ }
69
+
70
+ function normalizeCachedDocIndex(value: unknown): BuildCache["docs"] {
71
+ if (!isRecord(value)) {
72
+ return {};
73
+ }
74
+
75
+ const normalized: BuildCache["docs"] = {};
76
+ for (const [id, rawEntry] of Object.entries(value)) {
77
+ if (!isRecord(rawEntry)) {
78
+ continue;
79
+ }
80
+
81
+ const hash = typeof rawEntry.hash === "string" ? rawEntry.hash : "";
82
+ const route = typeof rawEntry.route === "string" ? rawEntry.route : "";
83
+ const relPath = typeof rawEntry.relPath === "string" ? rawEntry.relPath : "";
84
+ if (!hash || !route || !relPath) {
85
+ continue;
86
+ }
87
+
88
+ normalized[id] = { hash, route, relPath };
89
+ }
90
+
91
+ return normalized;
92
+ }
93
+
94
+ function normalizeCachedOutputHashes(value: unknown): BuildCache["outputHashes"] {
95
+ if (!isRecord(value)) {
96
+ return {};
97
+ }
98
+
99
+ const normalized: BuildCache["outputHashes"] = {};
100
+ for (const [outputPath, hash] of Object.entries(value)) {
101
+ if (typeof hash === "string" && hash.length > 0) {
102
+ normalized[outputPath] = hash;
103
+ }
104
+ }
105
+ return normalized;
106
+ }
107
+
108
+ function normalizeCachedSourceEntry(value: unknown): CachedSourceEntry | null {
109
+ if (!isRecord(value)) {
110
+ return null;
111
+ }
112
+
113
+ const mtimeMs = typeof value.mtimeMs === "number" && Number.isFinite(value.mtimeMs) ? value.mtimeMs : null;
114
+ const size = typeof value.size === "number" && Number.isFinite(value.size) ? value.size : null;
115
+ const rawHash = typeof value.rawHash === "string" ? value.rawHash : "";
116
+ const publish = value.publish === true;
117
+ const draft = value.draft === true;
118
+ const title = typeof value.title === "string" && value.title.trim().length > 0 ? value.title.trim() : undefined;
119
+ const date = typeof value.date === "string" && value.date.trim().length > 0 ? value.date.trim() : undefined;
120
+ const updatedDate =
121
+ typeof value.updatedDate === "string" && value.updatedDate.trim().length > 0 ? value.updatedDate.trim() : undefined;
122
+ const description =
123
+ typeof value.description === "string" && value.description.trim().length > 0 ? value.description.trim() : undefined;
124
+ const tags = parseStringArray(value.tags);
125
+ const branch = parseBranch(value.branch);
126
+ const body = typeof value.body === "string" ? value.body : null;
127
+ const wikiTargets = parseStringArray(value.wikiTargets);
128
+
129
+ if (mtimeMs === null || size === null || !rawHash || body == null) {
130
+ return null;
131
+ }
132
+
133
+ return {
134
+ mtimeMs,
135
+ size,
136
+ rawHash,
137
+ publish,
138
+ draft,
139
+ title,
140
+ date,
141
+ updatedDate,
142
+ description,
143
+ tags,
144
+ branch,
145
+ body,
146
+ wikiTargets,
147
+ };
148
+ }
149
+
150
+ function normalizeCachedSources(value: unknown): BuildCache["sources"] {
151
+ if (!isRecord(value)) {
152
+ return {};
153
+ }
154
+
155
+ const normalized: BuildCache["sources"] = {};
156
+ for (const [relPath, rawEntry] of Object.entries(value)) {
157
+ const entry = normalizeCachedSourceEntry(rawEntry);
158
+ if (!entry) {
159
+ continue;
160
+ }
161
+ normalized[relPath] = entry;
162
+ }
163
+ return normalized;
164
+ }
165
+
44
166
  async function readCache(cachePath: string): Promise<BuildCache> {
45
167
  const file = Bun.file(cachePath);
46
168
  if (!(await file.exists())) {
47
- return { version: CACHE_VERSION, docs: {} };
169
+ return createEmptyCache();
48
170
  }
49
171
 
50
172
  try {
51
- const parsed = (await file.json()) as BuildCache;
52
- if (parsed.version !== CACHE_VERSION || typeof parsed.docs !== "object" || !parsed.docs) {
53
- return { version: CACHE_VERSION, docs: {} };
173
+ const parsed = (await file.json()) as unknown;
174
+ if (!isRecord(parsed) || parsed.version !== CACHE_VERSION) {
175
+ return createEmptyCache();
54
176
  }
55
- return parsed;
177
+
178
+ return {
179
+ version: CACHE_VERSION,
180
+ sources: normalizeCachedSources(parsed.sources),
181
+ docs: normalizeCachedDocIndex(parsed.docs),
182
+ outputHashes: normalizeCachedOutputHashes(parsed.outputHashes),
183
+ };
56
184
  } catch {
57
- return { version: CACHE_VERSION, docs: {} };
185
+ return createEmptyCache();
58
186
  }
59
187
  }
60
188
 
@@ -252,64 +380,147 @@ function ensureUniqueRoutes(docs: DocRecord[]): void {
252
380
  }
253
381
  }
254
382
 
255
- async function readPublishedDocs(options: BuildOptions): Promise<DocRecord[]> {
383
+ function normalizeWikiTarget(input: string): string {
384
+ return input
385
+ .trim()
386
+ .replace(/\\/g, "/")
387
+ .replace(/^\.\//, "")
388
+ .replace(/^\//, "")
389
+ .replace(/\.md$/i, "")
390
+ .toLowerCase();
391
+ }
392
+
393
+ function extractWikiTargets(markdown: string): string[] {
394
+ const targets = new Set<string>();
395
+ const re = /\[\[([^\]]+)\]\]/g;
396
+ let match: RegExpExecArray | null;
397
+
398
+ while ((match = re.exec(markdown)) !== null) {
399
+ if (match.index > 0 && markdown.charAt(match.index - 1) === "!") {
400
+ continue;
401
+ }
402
+
403
+ const inner = (match[1] ?? "").trim();
404
+ if (!inner) {
405
+ continue;
406
+ }
407
+
408
+ const [rawTarget] = inner.split("|");
409
+ const normalized = normalizeWikiTarget(rawTarget ?? "");
410
+ if (!normalized) {
411
+ continue;
412
+ }
413
+
414
+ targets.add(normalized);
415
+ }
416
+
417
+ return Array.from(targets).sort((left, right) => left.localeCompare(right, "ko-KR"));
418
+ }
419
+
420
+ function toCachedSourceEntry(raw: string, parsed: matter.GrayMatterFile<string>): CachedSourceEntry {
421
+ return {
422
+ mtimeMs: 0,
423
+ size: 0,
424
+ rawHash: makeHash(raw),
425
+ publish: parsed.data.publish === true,
426
+ draft: parsed.data.draft === true,
427
+ title: typeof parsed.data.title === "string" && parsed.data.title.trim().length > 0 ? parsed.data.title.trim() : undefined,
428
+ date: pickDocDate(parsed.data as Record<string, unknown>, raw),
429
+ updatedDate: pickDocUpdatedDate(parsed.data as Record<string, unknown>, raw),
430
+ description: typeof parsed.data.description === "string" ? parsed.data.description.trim() || undefined : undefined,
431
+ tags: parseStringArray(parsed.data.tags),
432
+ branch: parseBranch(parsed.data.branch),
433
+ body: parsed.content,
434
+ wikiTargets: extractWikiTargets(parsed.content),
435
+ };
436
+ }
437
+
438
+ function toDocRecord(
439
+ sourcePath: string,
440
+ relPath: string,
441
+ entry: CachedSourceEntry,
442
+ newThreshold: number,
443
+ ): DocRecord {
444
+ const relNoExt = stripMdExt(relPath);
445
+ const fileName = path.basename(relPath);
446
+ const id = toDocId(relNoExt);
447
+
448
+ return {
449
+ sourcePath,
450
+ relPath,
451
+ relNoExt,
452
+ id,
453
+ route: toRoute(relNoExt),
454
+ contentUrl: `/content/${toContentFileName(id)}`,
455
+ fileName,
456
+ title: entry.title ?? makeTitleFromFileName(fileName),
457
+ date: entry.date,
458
+ updatedDate: entry.updatedDate,
459
+ description: entry.description,
460
+ tags: entry.tags,
461
+ mtimeMs: entry.mtimeMs,
462
+ body: entry.body,
463
+ rawHash: entry.rawHash,
464
+ wikiTargets: entry.wikiTargets,
465
+ isNew: entry.mtimeMs >= newThreshold,
466
+ branch: entry.branch,
467
+ };
468
+ }
469
+
470
+ async function readPublishedDocs(options: BuildOptions, previousSources: BuildCache["sources"]): Promise<ReadDocsResult> {
256
471
  const isExcluded = buildExcluder(options.exclude);
257
472
  const mdFiles = await walkMarkdownFiles(options.vaultDir, options.vaultDir, isExcluded);
473
+ const fileEntries = await Promise.all(
474
+ mdFiles.map(async (sourcePath) => ({
475
+ sourcePath,
476
+ relPath: relativePosix(options.vaultDir, sourcePath),
477
+ stat: await fs.stat(sourcePath),
478
+ })),
479
+ );
258
480
  const now = Date.now();
259
481
  const newThreshold = now - options.newWithinDays * 24 * 60 * 60 * 1000;
260
482
 
261
483
  const docs: DocRecord[] = [];
484
+ const nextSources: BuildCache["sources"] = {};
485
+
486
+ for (const { sourcePath, relPath, stat } of fileEntries) {
487
+ const prev = previousSources[relPath];
488
+
489
+ let entry: CachedSourceEntry;
490
+ const canReuse = !!prev && prev.mtimeMs === stat.mtimeMs && prev.size === stat.size;
491
+ if (canReuse) {
492
+ entry = prev;
493
+ } else {
494
+ const raw = await Bun.file(sourcePath).text();
495
+
496
+ let parsed: matter.GrayMatterFile<string>;
497
+ try {
498
+ parsed = matter(raw);
499
+ } catch (error) {
500
+ throw new Error(`Frontmatter parse failed: ${relPath}\n${(error as Error).message}`);
501
+ }
262
502
 
263
- for (const sourcePath of mdFiles) {
264
- const relPath = relativePosix(options.vaultDir, sourcePath);
265
- const relNoExt = stripMdExt(relPath);
266
- const stat = await fs.stat(sourcePath);
267
- const raw = await Bun.file(sourcePath).text();
268
-
269
- let parsed: matter.GrayMatterFile<string>;
270
- try {
271
- parsed = matter(raw);
272
- } catch (error) {
273
- throw new Error(`Frontmatter parse failed: ${relPath}\n${(error as Error).message}`);
503
+ entry = toCachedSourceEntry(raw, parsed);
274
504
  }
275
505
 
276
- const publish = parsed.data.publish === true;
277
- const draft = parsed.data.draft === true;
278
- if (!publish || draft) {
506
+ const completeEntry: CachedSourceEntry = {
507
+ ...entry,
508
+ mtimeMs: stat.mtimeMs,
509
+ size: stat.size,
510
+ };
511
+ nextSources[relPath] = completeEntry;
512
+
513
+ if (!completeEntry.publish || completeEntry.draft) {
279
514
  continue;
280
515
  }
281
-
282
- const fileName = path.basename(relPath);
283
- const id = toDocId(relNoExt);
284
- const route = toRoute(relNoExt);
285
- const mtimeMs = stat.mtimeMs;
286
-
287
- docs.push({
288
- sourcePath,
289
- relPath,
290
- relNoExt,
291
- id,
292
- route,
293
- contentUrl: `/content/${toContentFileName(id)}`,
294
- fileName,
295
- title: typeof parsed.data.title === "string" && parsed.data.title.trim().length > 0 ? parsed.data.title.trim() : makeTitleFromFileName(fileName),
296
- date: pickDocDate(parsed.data as Record<string, unknown>, raw),
297
- updatedDate: pickDocUpdatedDate(parsed.data as Record<string, unknown>, raw),
298
- description: typeof parsed.data.description === "string" ? parsed.data.description : undefined,
299
- tags: parseStringArray(parsed.data.tags),
300
- mtimeMs,
301
- body: parsed.content,
302
- raw,
303
- isNew: mtimeMs >= newThreshold,
304
- branch: parseBranch(parsed.data.branch),
305
- });
516
+ docs.push(toDocRecord(sourcePath, relPath, completeEntry, newThreshold));
306
517
  }
307
518
 
308
519
  ensureUniqueRoutes(docs);
309
- return docs;
520
+ return { docs, nextSources };
310
521
  }
311
522
 
312
- function createWikiResolver(docs: DocRecord[], currentDoc: DocRecord): WikiResolver {
523
+ function createWikiLookup(docs: DocRecord[]): WikiLookup {
313
524
  const byPath = new Map<string, DocRecord>();
314
525
  const byStem = new Map<string, DocRecord[]>();
315
526
 
@@ -321,40 +532,47 @@ function createWikiResolver(docs: DocRecord[], currentDoc: DocRecord): WikiResol
321
532
  byStem.set(stem, bucket);
322
533
  }
323
534
 
324
- return {
325
- resolve(input: string) {
326
- const normalized = input
327
- .trim()
328
- .replace(/\\/g, "/")
329
- .replace(/^\.\//, "")
330
- .replace(/^\//, "")
331
- .replace(/\.md$/i, "");
332
-
333
- if (!normalized) {
334
- return null;
335
- }
535
+ return { byPath, byStem };
536
+ }
336
537
 
337
- const direct = byPath.get(normalized.toLowerCase());
338
- if (direct) {
339
- return { route: direct.route, label: direct.title };
340
- }
538
+ function resolveWikiTarget(
539
+ lookup: WikiLookup,
540
+ input: string,
541
+ currentDoc: DocRecord,
542
+ warnOnDuplicate: boolean,
543
+ ): { route: string; label: string } | null {
544
+ const normalized = normalizeWikiTarget(input);
545
+ if (!normalized) {
546
+ return null;
547
+ }
341
548
 
342
- if (normalized.includes("/")) {
343
- return null;
344
- }
549
+ const direct = lookup.byPath.get(normalized);
550
+ if (direct) {
551
+ return { route: direct.route, label: direct.title };
552
+ }
345
553
 
346
- const stemMatches = byStem.get(normalized.toLowerCase()) ?? [];
347
- if (stemMatches.length === 1) {
348
- return { route: stemMatches[0].route, label: stemMatches[0].title };
349
- }
554
+ if (normalized.includes("/")) {
555
+ return null;
556
+ }
350
557
 
351
- if (stemMatches.length > 1) {
352
- console.warn(
353
- `[wikilink] Duplicate target "${input}" in ${currentDoc.relPath}. Candidates: ${stemMatches.map((item) => item.relPath).join(", ")}`,
354
- );
355
- }
558
+ const stemMatches = lookup.byStem.get(normalized) ?? [];
559
+ if (stemMatches.length === 1) {
560
+ return { route: stemMatches[0].route, label: stemMatches[0].title };
561
+ }
356
562
 
357
- return null;
563
+ if (warnOnDuplicate && stemMatches.length > 1) {
564
+ console.warn(
565
+ `[wikilink] Duplicate target "${input}" in ${currentDoc.relPath}. Candidates: ${stemMatches.map((item) => item.relPath).join(", ")}`,
566
+ );
567
+ }
568
+
569
+ return null;
570
+ }
571
+
572
+ function createWikiResolver(lookup: WikiLookup, currentDoc: DocRecord): WikiResolver {
573
+ return {
574
+ resolve(input: string) {
575
+ return resolveWikiTarget(lookup, input, currentDoc, true);
358
576
  },
359
577
  };
360
578
  }
@@ -599,13 +817,36 @@ function buildStructuredData(route: string, doc: DocRecord | null, options: Buil
599
817
  return [articleSchema];
600
818
  }
601
819
 
602
- async function writeRuntimeAssets(outDir: string): Promise<void> {
603
- const assetsDir = path.join(outDir, "assets");
604
- await ensureDir(assetsDir);
820
+ function toRouteOutputPath(route: string): string {
821
+ const clean = route.replace(/^\/+/, "").replace(/\/+$/, "");
822
+ return clean ? `${clean}/index.html` : "index.html";
823
+ }
824
+
825
+ async function writeOutputIfChanged(
826
+ context: OutputWriteContext,
827
+ relOutputPath: string,
828
+ content: string,
829
+ ): Promise<void> {
830
+ const outputHash = makeHash(content);
831
+ context.nextHashes[relOutputPath] = outputHash;
832
+
833
+ const outputPath = path.join(context.outDir, relOutputPath);
834
+ const unchanged = context.previousHashes[relOutputPath] === outputHash;
835
+ if (unchanged) {
836
+ return;
837
+ }
838
+
839
+ await ensureDir(path.dirname(outputPath));
840
+ await Bun.write(outputPath, content);
841
+ }
605
842
 
843
+ async function writeRuntimeAssets(context: OutputWriteContext): Promise<void> {
606
844
  const runtimeDir = path.join(import.meta.dir, "runtime");
607
- await fs.copyFile(path.join(runtimeDir, "app.js"), path.join(assetsDir, "app.js"));
608
- await fs.copyFile(path.join(runtimeDir, "app.css"), path.join(assetsDir, "app.css"));
845
+ const runtimeJs = await Bun.file(path.join(runtimeDir, "app.js")).text();
846
+ const runtimeCss = await Bun.file(path.join(runtimeDir, "app.css")).text();
847
+
848
+ await writeOutputIfChanged(context, "assets/app.js", runtimeJs);
849
+ await writeOutputIfChanged(context, "assets/app.css", runtimeCss);
609
850
  }
610
851
 
611
852
  function buildShellMeta(route: string, doc: DocRecord | null, options: BuildOptions): AppShellMeta {
@@ -633,17 +874,18 @@ function buildShellMeta(route: string, doc: DocRecord | null, options: BuildOpti
633
874
  };
634
875
  }
635
876
 
636
- async function writeShellPages(outDir: string, docs: DocRecord[], options: BuildOptions): Promise<void> {
877
+ async function writeShellPages(context: OutputWriteContext, docs: DocRecord[], options: BuildOptions): Promise<void> {
637
878
  const shell = renderAppShellHtml(buildShellMeta("/", null, options));
638
- await ensureDir(path.join(outDir, "_app"));
639
- await Bun.write(path.join(outDir, "_app", "index.html"), shell);
640
- await Bun.write(path.join(outDir, "index.html"), shell);
641
- await Bun.write(path.join(outDir, "404.html"), render404Html());
879
+ await writeOutputIfChanged(context, "_app/index.html", shell);
880
+ await writeOutputIfChanged(context, "index.html", shell);
881
+ await writeOutputIfChanged(context, "404.html", render404Html());
642
882
 
643
883
  for (const doc of docs) {
644
- const routeDir = path.join(outDir, doc.route.replace(/^\/+/, "").replace(/\/+$/, ""));
645
- await ensureDir(routeDir);
646
- await Bun.write(path.join(routeDir, "index.html"), renderAppShellHtml(buildShellMeta(doc.route, doc, options)));
884
+ await writeOutputIfChanged(
885
+ context,
886
+ toRouteOutputPath(doc.route),
887
+ renderAppShellHtml(buildShellMeta(doc.route, doc, options)),
888
+ );
647
889
  }
648
890
  }
649
891
 
@@ -667,8 +909,10 @@ function buildSitemapXml(urls: string[]): string {
667
909
  ].join("\n");
668
910
  }
669
911
 
670
- async function writeSeoArtifacts(outDir: string, docs: DocRecord[], options: BuildOptions): Promise<void> {
912
+ async function writeSeoArtifacts(context: OutputWriteContext, docs: DocRecord[], options: BuildOptions): Promise<void> {
671
913
  if (!options.seo) {
914
+ await removeFileIfExists(path.join(context.outDir, "robots.txt"));
915
+ await removeFileIfExists(path.join(context.outDir, "sitemap.xml"));
672
916
  console.warn('[seo] Skipping robots.txt and sitemap.xml generation. Add "seo.siteUrl" to blog.config.* to enable SEO artifacts.');
673
917
  return;
674
918
  }
@@ -685,8 +929,8 @@ async function writeSeoArtifacts(outDir: string, docs: DocRecord[], options: Bui
685
929
  const sitemapUrl = buildCanonicalUrl("/sitemap.xml", seo);
686
930
  const robotsTxt = ["User-agent: *", "Allow: /", `Sitemap: ${sitemapUrl}`, ""].join("\n");
687
931
 
688
- await Bun.write(path.join(outDir, "robots.txt"), robotsTxt);
689
- await Bun.write(path.join(outDir, "sitemap.xml"), buildSitemapXml(urls));
932
+ await writeOutputIfChanged(context, "robots.txt", robotsTxt);
933
+ await writeOutputIfChanged(context, "sitemap.xml", buildSitemapXml(urls));
690
934
  }
691
935
 
692
936
  async function cleanRemovedOutputs(outDir: string, oldCache: BuildCache, currentDocs: DocRecord[]): Promise<void> {
@@ -723,56 +967,89 @@ export async function cleanBuildArtifacts(outDir: string): Promise<void> {
723
967
  await fs.rm(path.dirname(cachePath), { recursive: true, force: true });
724
968
  }
725
969
 
970
+ function buildWikiResolutionSignature(doc: DocRecord, lookup: WikiLookup): string {
971
+ if (doc.wikiTargets.length === 0) {
972
+ return "";
973
+ }
974
+
975
+ const segments: string[] = [];
976
+ for (const target of doc.wikiTargets) {
977
+ const resolved = resolveWikiTarget(lookup, target, doc, false);
978
+ segments.push(`${target}->${resolved?.route ?? "null"}`);
979
+ }
980
+ return segments.join("|");
981
+ }
982
+
726
983
  export async function buildSite(options: BuildOptions): Promise<BuildResult> {
727
984
  await ensureDir(options.outDir);
728
985
  await ensureDir(path.join(options.outDir, "content"));
729
986
 
730
987
  const cachePath = toCachePath();
731
988
  const previousCache = await readCache(cachePath);
732
- const docs = await readPublishedDocs(options);
989
+ const canReuseOutputs = await fileExists(path.join(options.outDir, "manifest.json"));
990
+ const previousDocs = canReuseOutputs ? previousCache.docs : {};
991
+ const previousOutputHashes = canReuseOutputs ? previousCache.outputHashes : {};
992
+ const { docs, nextSources } = await readPublishedDocs(options, previousCache.sources);
733
993
  docs.sort((a, b) => a.relNoExt.localeCompare(b.relNoExt, "ko-KR"));
734
994
 
735
995
  await cleanRemovedOutputs(options.outDir, previousCache, docs);
736
- await writeRuntimeAssets(options.outDir);
996
+ const outputContext: OutputWriteContext = {
997
+ outDir: options.outDir,
998
+ previousHashes: previousOutputHashes,
999
+ nextHashes: {},
1000
+ };
1001
+ await writeRuntimeAssets(outputContext);
737
1002
 
738
1003
  const tree = buildTree(docs, options);
739
1004
  const manifest = buildManifest(docs, tree, options);
740
- await Bun.write(path.join(options.outDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
1005
+ await writeOutputIfChanged(outputContext, "manifest.json", `${JSON.stringify(manifest, null, 2)}\n`);
741
1006
 
742
- await writeShellPages(options.outDir, docs, options);
743
- await writeSeoArtifacts(options.outDir, docs, options);
1007
+ await writeShellPages(outputContext, docs, options);
1008
+ await writeSeoArtifacts(outputContext, docs, options);
744
1009
 
745
1010
  const markdownRenderer = await createMarkdownRenderer(options);
746
- const globalFingerprint = makeHash(docs.map((doc) => doc.relNoExt).sort().join("|"));
1011
+ const wikiLookup = createWikiLookup(docs);
747
1012
 
748
1013
  let renderedDocs = 0;
749
1014
  let skippedDocs = 0;
750
1015
 
751
1016
  const nextCache: BuildCache = {
752
1017
  version: CACHE_VERSION,
1018
+ sources: nextSources,
753
1019
  docs: {},
1020
+ outputHashes: outputContext.nextHashes,
754
1021
  };
755
1022
 
756
1023
  for (const doc of docs) {
1024
+ const wikiSignature = options.wikilinks ? buildWikiResolutionSignature(doc, wikiLookup) : "";
757
1025
  const sourceHash = makeHash(
758
- [doc.raw, options.shikiTheme, options.imagePolicy, options.wikilinks ? "wikilinks-on" : "wikilinks-off", globalFingerprint].join("::"),
1026
+ [
1027
+ doc.rawHash,
1028
+ doc.route,
1029
+ options.shikiTheme,
1030
+ options.imagePolicy,
1031
+ options.wikilinks ? "wikilinks-on" : "wikilinks-off",
1032
+ wikiSignature,
1033
+ ].join("::"),
759
1034
  );
760
- const previous = previousCache.docs[doc.id];
1035
+ const previous = previousDocs[doc.id];
1036
+ const contentRelPath = `content/${toContentFileName(doc.id)}`;
761
1037
  const outputPath = path.join(options.outDir, "content", toContentFileName(doc.id));
762
- const unchanged = previous?.hash === sourceHash && (await fileExists(outputPath));
1038
+ const unchanged = previous?.hash === sourceHash && outputContext.previousHashes[contentRelPath] === sourceHash;
763
1039
 
764
1040
  nextCache.docs[doc.id] = {
765
1041
  hash: sourceHash,
766
1042
  route: doc.route,
767
1043
  relPath: doc.relPath,
768
1044
  };
1045
+ outputContext.nextHashes[contentRelPath] = sourceHash;
769
1046
 
770
1047
  if (unchanged) {
771
1048
  skippedDocs += 1;
772
1049
  continue;
773
1050
  }
774
1051
 
775
- const resolver = createWikiResolver(docs, doc);
1052
+ const resolver = createWikiResolver(wikiLookup, doc);
776
1053
  const renderResult = await markdownRenderer.render(doc.body, resolver);
777
1054
  if (renderResult.warnings.length > 0) {
778
1055
  for (const warning of renderResult.warnings) {
package/src/markdown.ts CHANGED
@@ -89,8 +89,9 @@ function preprocessMarkdown(
89
89
 
90
90
  type Highlighter = HighlighterGeneric<string, string>;
91
91
 
92
- async function loadFenceLanguages(highlighter: Highlighter, markdown: string): Promise<void> {
92
+ async function loadFenceLanguages(highlighter: Highlighter, loaded: Set<string>, markdown: string): Promise<void> {
93
93
  const langs = new Set<string>();
94
+ FENCE_LANG_RE.lastIndex = 0;
94
95
  let match: RegExpExecArray | null;
95
96
  while ((match = FENCE_LANG_RE.exec(markdown)) !== null) {
96
97
  if (match[1]) {
@@ -98,7 +99,6 @@ async function loadFenceLanguages(highlighter: Highlighter, markdown: string): P
98
99
  }
99
100
  }
100
101
 
101
- const loaded = new Set(highlighter.getLoadedLanguages().map(String));
102
102
  for (const lang of langs) {
103
103
  if (loaded.has(lang)) {
104
104
  continue;
@@ -195,13 +195,14 @@ export async function createMarkdownRenderer(options: BuildOptions): Promise<Mar
195
195
  themes: [options.shikiTheme],
196
196
  langs: ["text", "plaintext", "markdown", "bash", "json", "typescript", "javascript"],
197
197
  });
198
+ const loadedLanguages = new Set(highlighter.getLoadedLanguages().map(String));
198
199
 
199
200
  const md = createMarkdownIt(highlighter, options.shikiTheme, options.gfm);
200
201
 
201
202
  return {
202
203
  async render(markdown: string, resolver: WikiResolver): Promise<RenderResult> {
203
204
  const { markdown: preprocessed, warnings } = preprocessMarkdown(markdown, resolver, options.imagePolicy, options.wikilinks);
204
- await loadFenceLanguages(highlighter, preprocessed);
205
+ await loadFenceLanguages(highlighter, loadedLanguages, preprocessed);
205
206
  const html = md.render(preprocessed);
206
207
  return { html, warnings };
207
208
  },
@@ -135,14 +135,19 @@ function buildBranchView(manifest, branch, defaultBranch) {
135
135
  const visibleDocIds = new Set(docs.map((doc) => doc.id));
136
136
  const tree = cloneFilteredTree(manifest.tree, visibleDocIds);
137
137
  const routeMap = {};
138
+ const docIndexById = new Map();
138
139
  for (const doc of docs) {
139
140
  routeMap[doc.route] = doc.id;
140
141
  }
142
+ for (let i = 0; i < docs.length; i += 1) {
143
+ docIndexById.set(docs[i].id, i);
144
+ }
141
145
 
142
146
  return {
143
147
  docs,
144
148
  tree,
145
149
  routeMap,
150
+ docIndexById,
146
151
  };
147
152
  }
148
153
 
@@ -363,6 +368,15 @@ function initializeTreeLabelTooltip(treeRoot, tooltipEl) {
363
368
  };
364
369
 
365
370
  const show = (row) => {
371
+ if (!(row instanceof HTMLElement)) {
372
+ hide();
373
+ return;
374
+ }
375
+
376
+ if (activeRow === row && !tooltipEl.hidden) {
377
+ return;
378
+ }
379
+
366
380
  const labelEl = row.querySelector(".tree-label");
367
381
  if (!(labelEl instanceof HTMLElement)) {
368
382
  hide();
@@ -412,25 +426,62 @@ function initializeTreeLabelTooltip(treeRoot, tooltipEl) {
412
426
  row.setAttribute("aria-describedby", tooltipEl.id);
413
427
  };
414
428
 
415
- for (const row of treeRoot.querySelectorAll(".tree-row")) {
416
- if (!(row instanceof HTMLElement)) {
417
- continue;
429
+ const getTreeRow = (target) => {
430
+ if (!(target instanceof Element)) {
431
+ return null;
432
+ }
433
+ const row = target.closest(".tree-row");
434
+ if (!(row instanceof HTMLElement) || !treeRoot.contains(row)) {
435
+ return null;
418
436
  }
419
- const onMouseEnter = () => show(row);
420
- const onMouseLeave = () => hide();
421
- const onFocus = () => show(row);
422
- const onBlur = () => hide();
437
+ return row;
438
+ };
423
439
 
424
- row.addEventListener("mouseenter", onMouseEnter);
425
- row.addEventListener("mouseleave", onMouseLeave);
426
- row.addEventListener("focus", onFocus);
427
- row.addEventListener("blur", onBlur);
440
+ const onMouseOver = (event) => {
441
+ const row = getTreeRow(event.target);
442
+ if (!row) {
443
+ return;
444
+ }
445
+ show(row);
446
+ };
428
447
 
429
- cleanups.push(() => row.removeEventListener("mouseenter", onMouseEnter));
430
- cleanups.push(() => row.removeEventListener("mouseleave", onMouseLeave));
431
- cleanups.push(() => row.removeEventListener("focus", onFocus));
432
- cleanups.push(() => row.removeEventListener("blur", onBlur));
433
- }
448
+ const onMouseOut = (event) => {
449
+ const fromRow = getTreeRow(event.target);
450
+ if (!fromRow) {
451
+ return;
452
+ }
453
+ const toRow = getTreeRow(event.relatedTarget);
454
+ if (toRow === fromRow) {
455
+ return;
456
+ }
457
+ hide();
458
+ };
459
+
460
+ const onFocusIn = (event) => {
461
+ const row = getTreeRow(event.target);
462
+ if (!row) {
463
+ return;
464
+ }
465
+ show(row);
466
+ };
467
+
468
+ const onFocusOut = (event) => {
469
+ const toRow = getTreeRow(event.relatedTarget);
470
+ if (toRow) {
471
+ return;
472
+ }
473
+ hide();
474
+ };
475
+
476
+ treeRoot.addEventListener("mouseover", onMouseOver);
477
+ treeRoot.addEventListener("mouseout", onMouseOut);
478
+ treeRoot.addEventListener("focusin", onFocusIn);
479
+ treeRoot.addEventListener("focusout", onFocusOut);
480
+
481
+ cleanups.push(() => treeRoot.removeEventListener("mouseover", onMouseOver));
482
+ cleanups.push(() => treeRoot.removeEventListener("mouseout", onMouseOut));
483
+ cleanups.push(() => treeRoot.removeEventListener("focusin", onFocusIn));
484
+ cleanups.push(() => treeRoot.removeEventListener("focusout", onFocusOut));
434
485
 
435
486
  treeRoot.addEventListener("scroll", hide);
436
487
  window.addEventListener("resize", hide);
@@ -451,7 +502,7 @@ function initializeTreeLabelTooltip(treeRoot, tooltipEl) {
451
502
  };
452
503
  }
453
504
 
454
- function createFolderNode(node, state, depth = 0) {
505
+ function createFolderNode(node, expandedSet, fileRowsById, depth = 0) {
455
506
  const wrapper = document.createElement("div");
456
507
  wrapper.className = node.virtual ? "tree-folder virtual" : "tree-folder";
457
508
  wrapper.style.setProperty("--tree-depth", String(depth));
@@ -459,11 +510,15 @@ function createFolderNode(node, state, depth = 0) {
459
510
  const row = document.createElement("button");
460
511
  row.type = "button";
461
512
  row.className = "tree-folder-row tree-row";
513
+ row.dataset.rowType = "folder";
514
+ row.dataset.folderPath = node.path;
515
+ row.dataset.virtual = String(Boolean(node.virtual));
462
516
 
463
- const isExpanded = node.virtual ? true : state.expanded.has(node.path);
517
+ const isExpanded = node.virtual ? true : expandedSet.has(node.path);
464
518
  const iconName = isExpanded ? "folder_open" : "folder";
465
519
 
466
520
  row.innerHTML = `<span class="material-symbols-outlined">${iconName}</span><span class="tree-label">${node.name}</span>`;
521
+ row.setAttribute("aria-expanded", String(isExpanded));
467
522
 
468
523
  const children = document.createElement("div");
469
524
  children.className = "tree-children";
@@ -471,25 +526,11 @@ function createFolderNode(node, state, depth = 0) {
471
526
  children.hidden = true;
472
527
  }
473
528
 
474
- if (!node.virtual) {
475
- row.addEventListener("click", () => {
476
- const currentlyExpanded = !children.hidden;
477
- children.hidden = currentlyExpanded;
478
- row.querySelector(".material-symbols-outlined").textContent = currentlyExpanded ? "folder" : "folder_open";
479
- if (currentlyExpanded) {
480
- state.expanded.delete(node.path);
481
- } else {
482
- state.expanded.add(node.path);
483
- }
484
- persistExpandedSet(state.expanded);
485
- });
486
- }
487
-
488
529
  for (const child of node.children) {
489
530
  if (child.type === "folder") {
490
- children.appendChild(createFolderNode(child, state, depth + 1));
531
+ children.appendChild(createFolderNode(child, expandedSet, fileRowsById, depth + 1));
491
532
  } else {
492
- children.appendChild(createFileNode(child, state, depth + 1));
533
+ children.appendChild(createFileNode(child, fileRowsById, depth + 1));
493
534
  }
494
535
  }
495
536
 
@@ -498,46 +539,65 @@ function createFolderNode(node, state, depth = 0) {
498
539
  return wrapper;
499
540
  }
500
541
 
501
- function createFileNode(node, state, depth = 0) {
542
+ function createFileNode(node, fileRowsById, depth = 0) {
502
543
  const row = document.createElement("a");
503
544
  row.href = node.route;
504
545
  row.className = "tree-row tree-file-row";
546
+ row.dataset.rowType = "file";
547
+ row.dataset.route = node.route;
505
548
  row.dataset.fileId = node.id;
506
549
  row.style.setProperty("--tree-depth", String(depth));
507
550
 
508
551
  const newBadge = node.isNew ? `<span class="badge-new">NEW</span>` : "";
509
552
  row.innerHTML = `<span class="material-symbols-outlined">article</span><span class="tree-label">${node.title || node.name}</span>${newBadge}`;
510
-
511
- row.addEventListener("click", (event) => {
512
- event.preventDefault();
513
- state.navigate(node.route, true);
514
- });
553
+ fileRowsById.set(node.id, row);
515
554
 
516
555
  return row;
517
556
  }
518
557
 
519
- function markActive(id) {
520
- for (const el of document.querySelectorAll("[data-file-id]")) {
521
- if (!(el instanceof HTMLElement)) {
522
- continue;
523
- }
524
- const badge = el.querySelector(".badge-active");
525
- if (badge) {
526
- badge.remove();
527
- }
528
-
529
- if (el.dataset.fileId === id) {
530
- el.classList.add("is-active");
531
- el.setAttribute("aria-current", "page");
532
- const activeBadge = document.createElement("span");
533
- activeBadge.className = "badge-active";
534
- activeBadge.textContent = "active";
535
- el.appendChild(activeBadge);
536
- } else {
537
- el.classList.remove("is-active");
538
- el.removeAttribute("aria-current");
539
- }
558
+ function setFileRowActive(row, active) {
559
+ if (!(row instanceof HTMLElement)) {
560
+ return;
561
+ }
562
+
563
+ const badge = row.querySelector(".badge-active");
564
+ if (badge) {
565
+ badge.remove();
540
566
  }
567
+
568
+ if (active) {
569
+ row.classList.add("is-active");
570
+ row.setAttribute("aria-current", "page");
571
+ const activeBadge = document.createElement("span");
572
+ activeBadge.className = "badge-active";
573
+ activeBadge.textContent = "active";
574
+ row.appendChild(activeBadge);
575
+ return;
576
+ }
577
+
578
+ row.classList.remove("is-active");
579
+ row.removeAttribute("aria-current");
580
+ }
581
+
582
+ function markActive(fileRowsById, activeState, id) {
583
+ if (activeState.currentId === id) {
584
+ return;
585
+ }
586
+
587
+ if (activeState.current instanceof HTMLElement) {
588
+ setFileRowActive(activeState.current, false);
589
+ }
590
+
591
+ const next = id ? fileRowsById.get(id) : null;
592
+ if (next instanceof HTMLElement) {
593
+ setFileRowActive(next, true);
594
+ activeState.current = next;
595
+ activeState.currentId = id;
596
+ return;
597
+ }
598
+
599
+ activeState.current = null;
600
+ activeState.currentId = "";
541
601
  }
542
602
 
543
603
  function renderBreadcrumb(route) {
@@ -581,8 +641,8 @@ function renderMeta(doc) {
581
641
  return items.join("");
582
642
  }
583
643
 
584
- function renderNav(docs, currentId) {
585
- const currentIndex = docs.findIndex(d => d.id === currentId);
644
+ function renderNav(docs, docIndexById, currentId) {
645
+ const currentIndex = docIndexById.get(currentId) ?? -1;
586
646
  if (currentIndex === -1) return "";
587
647
 
588
648
  const prev = currentIndex > 0 ? docs[currentIndex - 1] : null;
@@ -629,6 +689,7 @@ async function start() {
629
689
  const contentEl = document.getElementById("viewer-content");
630
690
  const navEl = document.getElementById("viewer-nav");
631
691
  const a11yStatusEl = document.getElementById("a11y-status");
692
+ const viewerEl = document.querySelector(".viewer");
632
693
 
633
694
  let hideTreeTooltip = () => {};
634
695
  let disposeTreeTooltip = () => {};
@@ -636,6 +697,11 @@ async function start() {
636
697
  let activeResizePointerId = null;
637
698
  let resizeStartX = 0;
638
699
  let resizeStartWidth = desktopSidebarWidth;
700
+ let treeFileRowsById = new Map();
701
+ const activeFileState = {
702
+ current: null,
703
+ currentId: "",
704
+ };
639
705
 
640
706
  const announceA11yStatus = (message) => {
641
707
  if (!(a11yStatusEl instanceof HTMLElement)) {
@@ -1029,7 +1095,17 @@ async function start() {
1029
1095
 
1030
1096
  const savedBranch = normalizeBranch(localStorage.getItem(BRANCH_KEY));
1031
1097
  let activeBranch = savedBranch && availableBranchSet.has(savedBranch) ? savedBranch : defaultBranch;
1032
- let view = buildBranchView(manifest, activeBranch, defaultBranch);
1098
+ const branchViewCache = new Map();
1099
+ const getBranchView = (branch) => {
1100
+ const cached = branchViewCache.get(branch);
1101
+ if (cached) {
1102
+ return cached;
1103
+ }
1104
+ const nextView = buildBranchView(manifest, branch, defaultBranch);
1105
+ branchViewCache.set(branch, nextView);
1106
+ return nextView;
1107
+ };
1108
+ let view = getBranchView(activeBranch);
1033
1109
 
1034
1110
  const docsById = new Map(manifest.docs.map((doc) => [doc.id, doc]));
1035
1111
  const expanded = loadExpandedSet();
@@ -1061,19 +1137,20 @@ async function start() {
1061
1137
  hideTreeTooltip();
1062
1138
  disposeTreeTooltip();
1063
1139
  treeRoot.innerHTML = "";
1140
+ treeFileRowsById = new Map();
1064
1141
 
1065
1142
  for (const node of view.tree) {
1066
1143
  if (node.type === "folder") {
1067
- treeRoot.appendChild(createFolderNode(node, state));
1144
+ treeRoot.appendChild(createFolderNode(node, state.expanded, treeFileRowsById));
1068
1145
  } else {
1069
- treeRoot.appendChild(createFileNode(node, state));
1146
+ treeRoot.appendChild(createFileNode(node, treeFileRowsById));
1070
1147
  }
1071
1148
  }
1072
1149
 
1073
1150
  const tooltipController = initializeTreeLabelTooltip(treeRoot, treeLabelTooltip);
1074
1151
  hideTreeTooltip = tooltipController.hide;
1075
1152
  disposeTreeTooltip = tooltipController.dispose;
1076
- markActive(state.currentDocId || "");
1153
+ markActive(treeFileRowsById, activeFileState, state.currentDocId || "");
1077
1154
  };
1078
1155
 
1079
1156
  const state = {
@@ -1095,7 +1172,7 @@ async function start() {
1095
1172
  const globalDocBranch = normalizeBranch(globalDoc?.branch);
1096
1173
  if (globalDoc && globalDocBranch && globalDocBranch !== activeBranch && availableBranchSet.has(globalDocBranch)) {
1097
1174
  activeBranch = globalDocBranch;
1098
- view = buildBranchView(manifest, activeBranch, defaultBranch);
1175
+ view = getBranchView(activeBranch);
1099
1176
  updateBranchInfo();
1100
1177
  renderTree(state);
1101
1178
  localStorage.setItem(BRANCH_KEY, activeBranch);
@@ -1110,7 +1187,7 @@ async function start() {
1110
1187
  metaEl.innerHTML = "";
1111
1188
  contentEl.innerHTML = '<p class="placeholder">요청한 경로에 해당하는 문서가 없습니다.</p>';
1112
1189
  navEl.innerHTML = "";
1113
- markActive("");
1190
+ markActive(treeFileRowsById, activeFileState, "");
1114
1191
  announceA11yStatus("탐색 실패: 요청한 문서를 찾을 수 없습니다.");
1115
1192
  if (push) {
1116
1193
  history.pushState(null, "", toSafeUrlPath(route));
@@ -1128,7 +1205,7 @@ async function start() {
1128
1205
  }
1129
1206
 
1130
1207
  state.currentDocId = id;
1131
- markActive(id);
1208
+ markActive(treeFileRowsById, activeFileState, id);
1132
1209
  breadcrumbEl.innerHTML = renderBreadcrumb(route);
1133
1210
  titleEl.textContent = doc.title;
1134
1211
  metaEl.innerHTML = renderMeta(doc);
@@ -1142,41 +1219,132 @@ async function start() {
1142
1219
  }
1143
1220
 
1144
1221
  contentEl.innerHTML = await res.text();
1145
-
1146
- for (const btn of contentEl.querySelectorAll(".code-copy")) {
1147
- btn.addEventListener("click", async () => {
1148
- const code = btn.dataset.code;
1149
- if (!code) return;
1150
- try {
1151
- await navigator.clipboard.writeText(code);
1152
- btn.classList.add("copied");
1153
- btn.querySelector(".material-symbols-outlined").textContent = "check";
1154
- setTimeout(() => {
1155
- btn.classList.remove("copied");
1156
- btn.querySelector(".material-symbols-outlined").textContent = "content_copy";
1157
- }, 2000);
1158
- } catch (err) {
1159
- console.error("Copy failed:", err);
1160
- }
1161
- });
1162
- }
1163
1222
 
1164
- navEl.innerHTML = renderNav(view.docs, id);
1165
-
1166
- for (const link of navEl.querySelectorAll(".nav-link")) {
1167
- link.addEventListener("click", (e) => {
1168
- e.preventDefault();
1169
- state.navigate(link.dataset.route, true);
1170
- document.querySelector(".viewer").scrollTo(0, 0);
1171
- });
1172
- }
1223
+ navEl.innerHTML = renderNav(view.docs, view.docIndexById, id);
1173
1224
 
1174
1225
  document.title = `${doc.title} - File-System Blog`;
1175
- document.querySelector(".viewer").scrollTo(0, 0);
1226
+ if (viewerEl instanceof HTMLElement) {
1227
+ viewerEl.scrollTo(0, 0);
1228
+ }
1176
1229
  announceA11yStatus(`탐색 완료: ${doc.title} 문서를 열었습니다.`);
1177
1230
  },
1178
1231
  };
1179
1232
 
1233
+ if (treeRoot instanceof HTMLElement) {
1234
+ treeRoot.addEventListener("click", (event) => {
1235
+ const target = event.target;
1236
+ if (!(target instanceof Element)) {
1237
+ return;
1238
+ }
1239
+
1240
+ const row = target.closest(".tree-row");
1241
+ if (!(row instanceof HTMLElement) || !treeRoot.contains(row)) {
1242
+ return;
1243
+ }
1244
+
1245
+ if (row.dataset.rowType === "folder") {
1246
+ if (row.dataset.virtual === "true") {
1247
+ return;
1248
+ }
1249
+
1250
+ const children = row.nextElementSibling;
1251
+ if (!(children instanceof HTMLElement) || !children.classList.contains("tree-children")) {
1252
+ return;
1253
+ }
1254
+
1255
+ const currentlyExpanded = !children.hidden;
1256
+ children.hidden = currentlyExpanded;
1257
+ row.setAttribute("aria-expanded", String(!currentlyExpanded));
1258
+ const icon = row.querySelector(".material-symbols-outlined");
1259
+ if (icon instanceof HTMLElement) {
1260
+ icon.textContent = currentlyExpanded ? "folder" : "folder_open";
1261
+ }
1262
+
1263
+ const folderPath = row.dataset.folderPath;
1264
+ if (!folderPath) {
1265
+ return;
1266
+ }
1267
+ if (currentlyExpanded) {
1268
+ state.expanded.delete(folderPath);
1269
+ } else {
1270
+ state.expanded.add(folderPath);
1271
+ }
1272
+ persistExpandedSet(state.expanded);
1273
+ return;
1274
+ }
1275
+
1276
+ if (row.dataset.rowType === "file") {
1277
+ event.preventDefault();
1278
+ const route = row.dataset.route;
1279
+ if (!route) {
1280
+ return;
1281
+ }
1282
+ state.navigate(route, true);
1283
+ }
1284
+ });
1285
+ }
1286
+
1287
+ if (contentEl instanceof HTMLElement) {
1288
+ contentEl.addEventListener("click", async (event) => {
1289
+ const target = event.target;
1290
+ if (!(target instanceof Element)) {
1291
+ return;
1292
+ }
1293
+
1294
+ const button = target.closest(".code-copy");
1295
+ if (!(button instanceof HTMLButtonElement) || !contentEl.contains(button)) {
1296
+ return;
1297
+ }
1298
+
1299
+ const code = button.dataset.code;
1300
+ if (!code) {
1301
+ return;
1302
+ }
1303
+
1304
+ try {
1305
+ await navigator.clipboard.writeText(code);
1306
+ button.classList.add("copied");
1307
+ const icon = button.querySelector(".material-symbols-outlined");
1308
+ if (icon instanceof HTMLElement) {
1309
+ icon.textContent = "check";
1310
+ }
1311
+ setTimeout(() => {
1312
+ button.classList.remove("copied");
1313
+ const nextIcon = button.querySelector(".material-symbols-outlined");
1314
+ if (nextIcon instanceof HTMLElement) {
1315
+ nextIcon.textContent = "content_copy";
1316
+ }
1317
+ }, 2000);
1318
+ } catch (err) {
1319
+ console.error("Copy failed:", err);
1320
+ }
1321
+ });
1322
+ }
1323
+
1324
+ if (navEl instanceof HTMLElement) {
1325
+ navEl.addEventListener("click", (event) => {
1326
+ const target = event.target;
1327
+ if (!(target instanceof Element)) {
1328
+ return;
1329
+ }
1330
+
1331
+ const link = target.closest(".nav-link");
1332
+ if (!(link instanceof HTMLAnchorElement) || !navEl.contains(link)) {
1333
+ return;
1334
+ }
1335
+
1336
+ event.preventDefault();
1337
+ const route = link.dataset.route;
1338
+ if (!route) {
1339
+ return;
1340
+ }
1341
+ state.navigate(route, true);
1342
+ if (viewerEl instanceof HTMLElement) {
1343
+ viewerEl.scrollTo(0, 0);
1344
+ }
1345
+ });
1346
+ }
1347
+
1180
1348
  initializeTreeTypeahead(treeRoot);
1181
1349
 
1182
1350
  const setActiveBranch = async (nextBranch) => {
@@ -1186,7 +1354,7 @@ async function start() {
1186
1354
  }
1187
1355
 
1188
1356
  activeBranch = normalized;
1189
- view = buildBranchView(manifest, activeBranch, defaultBranch);
1357
+ view = getBranchView(activeBranch);
1190
1358
  localStorage.setItem(BRANCH_KEY, activeBranch);
1191
1359
  updateBranchInfo();
1192
1360
  renderTree(state);
package/src/types.ts CHANGED
@@ -64,7 +64,8 @@ export interface DocRecord {
64
64
  tags: string[];
65
65
  mtimeMs: number;
66
66
  body: string;
67
- raw: string;
67
+ rawHash: string;
68
+ wikiTargets: string[];
68
69
  isNew: boolean;
69
70
  branch: string | null;
70
71
  }
@@ -122,6 +123,24 @@ export interface Manifest {
122
123
 
123
124
  export interface BuildCache {
124
125
  version: number;
126
+ sources: Record<
127
+ string,
128
+ {
129
+ mtimeMs: number;
130
+ size: number;
131
+ rawHash: string;
132
+ publish: boolean;
133
+ draft: boolean;
134
+ title?: string;
135
+ date?: string;
136
+ updatedDate?: string;
137
+ description?: string;
138
+ tags: string[];
139
+ branch: string | null;
140
+ body: string;
141
+ wikiTargets: string[];
142
+ }
143
+ >;
125
144
  docs: Record<
126
145
  string,
127
146
  {
@@ -130,6 +149,7 @@ export interface BuildCache {
130
149
  relPath: string;
131
150
  }
132
151
  >;
152
+ outputHashes: Record<string, string>;
133
153
  }
134
154
 
135
155
  export interface WikiResolver {