@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 +1 -1
- package/src/build.ts +381 -104
- package/src/markdown.ts +4 -3
- package/src/runtime/app.js +267 -99
- package/src/types.ts +21 -1
package/package.json
CHANGED
package/src/build.ts
CHANGED
|
@@ -20,13 +20,31 @@ import {
|
|
|
20
20
|
toRoute,
|
|
21
21
|
} from "./utils";
|
|
22
22
|
|
|
23
|
-
const CACHE_VERSION =
|
|
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
|
|
169
|
+
return createEmptyCache();
|
|
48
170
|
}
|
|
49
171
|
|
|
50
172
|
try {
|
|
51
|
-
const parsed = (await file.json()) as
|
|
52
|
-
if (parsed
|
|
53
|
-
return
|
|
173
|
+
const parsed = (await file.json()) as unknown;
|
|
174
|
+
if (!isRecord(parsed) || parsed.version !== CACHE_VERSION) {
|
|
175
|
+
return createEmptyCache();
|
|
54
176
|
}
|
|
55
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
277
|
-
|
|
278
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
549
|
+
const direct = lookup.byPath.get(normalized);
|
|
550
|
+
if (direct) {
|
|
551
|
+
return { route: direct.route, label: direct.title };
|
|
552
|
+
}
|
|
345
553
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
}
|
|
554
|
+
if (normalized.includes("/")) {
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
350
557
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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
|
-
|
|
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
|
-
|
|
603
|
-
const
|
|
604
|
-
|
|
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
|
|
608
|
-
await
|
|
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(
|
|
877
|
+
async function writeShellPages(context: OutputWriteContext, docs: DocRecord[], options: BuildOptions): Promise<void> {
|
|
637
878
|
const shell = renderAppShellHtml(buildShellMeta("/", null, options));
|
|
638
|
-
await
|
|
639
|
-
await
|
|
640
|
-
await
|
|
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
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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(
|
|
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
|
|
689
|
-
await
|
|
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
|
|
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
|
-
|
|
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
|
|
1005
|
+
await writeOutputIfChanged(outputContext, "manifest.json", `${JSON.stringify(manifest, null, 2)}\n`);
|
|
741
1006
|
|
|
742
|
-
await writeShellPages(
|
|
743
|
-
await writeSeoArtifacts(
|
|
1007
|
+
await writeShellPages(outputContext, docs, options);
|
|
1008
|
+
await writeSeoArtifacts(outputContext, docs, options);
|
|
744
1009
|
|
|
745
1010
|
const markdownRenderer = await createMarkdownRenderer(options);
|
|
746
|
-
const
|
|
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
|
-
[
|
|
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 =
|
|
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 &&
|
|
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(
|
|
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
|
},
|
package/src/runtime/app.js
CHANGED
|
@@ -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
|
-
|
|
416
|
-
if (!(
|
|
417
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
const onFocus = () => show(row);
|
|
422
|
-
const onBlur = () => hide();
|
|
437
|
+
return row;
|
|
438
|
+
};
|
|
423
439
|
|
|
424
|
-
|
|
425
|
-
row.
|
|
426
|
-
row
|
|
427
|
-
|
|
440
|
+
const onMouseOver = (event) => {
|
|
441
|
+
const row = getTreeRow(event.target);
|
|
442
|
+
if (!row) {
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
show(row);
|
|
446
|
+
};
|
|
428
447
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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,
|
|
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 :
|
|
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,
|
|
531
|
+
children.appendChild(createFolderNode(child, expandedSet, fileRowsById, depth + 1));
|
|
491
532
|
} else {
|
|
492
|
-
children.appendChild(createFileNode(child,
|
|
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,
|
|
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
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 {
|