@limcpf/everything-is-a-markdown 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/build.ts +407 -105
- 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
|
}
|
|
@@ -394,6 +612,19 @@ function sortTree(nodes: TreeNode[]): TreeNode[] {
|
|
|
394
612
|
return nodes;
|
|
395
613
|
}
|
|
396
614
|
|
|
615
|
+
function parseDateToEpochMs(value: string | undefined): number | null {
|
|
616
|
+
if (!value) {
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const parsed = Date.parse(value);
|
|
621
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function getRecentSortEpochMs(doc: DocRecord): number {
|
|
625
|
+
return parseDateToEpochMs(doc.date) ?? doc.mtimeMs;
|
|
626
|
+
}
|
|
627
|
+
|
|
397
628
|
function buildPinnedMenuFolder(docs: DocRecord[], options: BuildOptions): FolderNode | null {
|
|
398
629
|
if (!options.pinnedMenu) {
|
|
399
630
|
return null;
|
|
@@ -455,7 +686,19 @@ function buildTree(docs: DocRecord[], options: BuildOptions): TreeNode[] {
|
|
|
455
686
|
sortTree(root.children);
|
|
456
687
|
|
|
457
688
|
const recentChildren = [...docs]
|
|
458
|
-
.sort((
|
|
689
|
+
.sort((left, right) => {
|
|
690
|
+
const byDate = getRecentSortEpochMs(right) - getRecentSortEpochMs(left);
|
|
691
|
+
if (byDate !== 0) {
|
|
692
|
+
return byDate;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
const byMtime = right.mtimeMs - left.mtimeMs;
|
|
696
|
+
if (byMtime !== 0) {
|
|
697
|
+
return byMtime;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
return left.relNoExt.localeCompare(right.relNoExt, "ko-KR");
|
|
701
|
+
})
|
|
459
702
|
.slice(0, options.recentLimit)
|
|
460
703
|
.map((doc) => fileNodeFromDoc(doc));
|
|
461
704
|
|
|
@@ -599,13 +842,36 @@ function buildStructuredData(route: string, doc: DocRecord | null, options: Buil
|
|
|
599
842
|
return [articleSchema];
|
|
600
843
|
}
|
|
601
844
|
|
|
602
|
-
|
|
603
|
-
const
|
|
604
|
-
|
|
845
|
+
function toRouteOutputPath(route: string): string {
|
|
846
|
+
const clean = route.replace(/^\/+/, "").replace(/\/+$/, "");
|
|
847
|
+
return clean ? `${clean}/index.html` : "index.html";
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
async function writeOutputIfChanged(
|
|
851
|
+
context: OutputWriteContext,
|
|
852
|
+
relOutputPath: string,
|
|
853
|
+
content: string,
|
|
854
|
+
): Promise<void> {
|
|
855
|
+
const outputHash = makeHash(content);
|
|
856
|
+
context.nextHashes[relOutputPath] = outputHash;
|
|
857
|
+
|
|
858
|
+
const outputPath = path.join(context.outDir, relOutputPath);
|
|
859
|
+
const unchanged = context.previousHashes[relOutputPath] === outputHash;
|
|
860
|
+
if (unchanged) {
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
await ensureDir(path.dirname(outputPath));
|
|
865
|
+
await Bun.write(outputPath, content);
|
|
866
|
+
}
|
|
605
867
|
|
|
868
|
+
async function writeRuntimeAssets(context: OutputWriteContext): Promise<void> {
|
|
606
869
|
const runtimeDir = path.join(import.meta.dir, "runtime");
|
|
607
|
-
await
|
|
608
|
-
await
|
|
870
|
+
const runtimeJs = await Bun.file(path.join(runtimeDir, "app.js")).text();
|
|
871
|
+
const runtimeCss = await Bun.file(path.join(runtimeDir, "app.css")).text();
|
|
872
|
+
|
|
873
|
+
await writeOutputIfChanged(context, "assets/app.js", runtimeJs);
|
|
874
|
+
await writeOutputIfChanged(context, "assets/app.css", runtimeCss);
|
|
609
875
|
}
|
|
610
876
|
|
|
611
877
|
function buildShellMeta(route: string, doc: DocRecord | null, options: BuildOptions): AppShellMeta {
|
|
@@ -633,17 +899,18 @@ function buildShellMeta(route: string, doc: DocRecord | null, options: BuildOpti
|
|
|
633
899
|
};
|
|
634
900
|
}
|
|
635
901
|
|
|
636
|
-
async function writeShellPages(
|
|
902
|
+
async function writeShellPages(context: OutputWriteContext, docs: DocRecord[], options: BuildOptions): Promise<void> {
|
|
637
903
|
const shell = renderAppShellHtml(buildShellMeta("/", null, options));
|
|
638
|
-
await
|
|
639
|
-
await
|
|
640
|
-
await
|
|
641
|
-
await Bun.write(path.join(outDir, "404.html"), render404Html());
|
|
904
|
+
await writeOutputIfChanged(context, "_app/index.html", shell);
|
|
905
|
+
await writeOutputIfChanged(context, "index.html", shell);
|
|
906
|
+
await writeOutputIfChanged(context, "404.html", render404Html());
|
|
642
907
|
|
|
643
908
|
for (const doc of docs) {
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
909
|
+
await writeOutputIfChanged(
|
|
910
|
+
context,
|
|
911
|
+
toRouteOutputPath(doc.route),
|
|
912
|
+
renderAppShellHtml(buildShellMeta(doc.route, doc, options)),
|
|
913
|
+
);
|
|
647
914
|
}
|
|
648
915
|
}
|
|
649
916
|
|
|
@@ -667,8 +934,10 @@ function buildSitemapXml(urls: string[]): string {
|
|
|
667
934
|
].join("\n");
|
|
668
935
|
}
|
|
669
936
|
|
|
670
|
-
async function writeSeoArtifacts(
|
|
937
|
+
async function writeSeoArtifacts(context: OutputWriteContext, docs: DocRecord[], options: BuildOptions): Promise<void> {
|
|
671
938
|
if (!options.seo) {
|
|
939
|
+
await removeFileIfExists(path.join(context.outDir, "robots.txt"));
|
|
940
|
+
await removeFileIfExists(path.join(context.outDir, "sitemap.xml"));
|
|
672
941
|
console.warn('[seo] Skipping robots.txt and sitemap.xml generation. Add "seo.siteUrl" to blog.config.* to enable SEO artifacts.');
|
|
673
942
|
return;
|
|
674
943
|
}
|
|
@@ -685,8 +954,8 @@ async function writeSeoArtifacts(outDir: string, docs: DocRecord[], options: Bui
|
|
|
685
954
|
const sitemapUrl = buildCanonicalUrl("/sitemap.xml", seo);
|
|
686
955
|
const robotsTxt = ["User-agent: *", "Allow: /", `Sitemap: ${sitemapUrl}`, ""].join("\n");
|
|
687
956
|
|
|
688
|
-
await
|
|
689
|
-
await
|
|
957
|
+
await writeOutputIfChanged(context, "robots.txt", robotsTxt);
|
|
958
|
+
await writeOutputIfChanged(context, "sitemap.xml", buildSitemapXml(urls));
|
|
690
959
|
}
|
|
691
960
|
|
|
692
961
|
async function cleanRemovedOutputs(outDir: string, oldCache: BuildCache, currentDocs: DocRecord[]): Promise<void> {
|
|
@@ -723,56 +992,89 @@ export async function cleanBuildArtifacts(outDir: string): Promise<void> {
|
|
|
723
992
|
await fs.rm(path.dirname(cachePath), { recursive: true, force: true });
|
|
724
993
|
}
|
|
725
994
|
|
|
995
|
+
function buildWikiResolutionSignature(doc: DocRecord, lookup: WikiLookup): string {
|
|
996
|
+
if (doc.wikiTargets.length === 0) {
|
|
997
|
+
return "";
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const segments: string[] = [];
|
|
1001
|
+
for (const target of doc.wikiTargets) {
|
|
1002
|
+
const resolved = resolveWikiTarget(lookup, target, doc, false);
|
|
1003
|
+
segments.push(`${target}->${resolved?.route ?? "null"}`);
|
|
1004
|
+
}
|
|
1005
|
+
return segments.join("|");
|
|
1006
|
+
}
|
|
1007
|
+
|
|
726
1008
|
export async function buildSite(options: BuildOptions): Promise<BuildResult> {
|
|
727
1009
|
await ensureDir(options.outDir);
|
|
728
1010
|
await ensureDir(path.join(options.outDir, "content"));
|
|
729
1011
|
|
|
730
1012
|
const cachePath = toCachePath();
|
|
731
1013
|
const previousCache = await readCache(cachePath);
|
|
732
|
-
const
|
|
1014
|
+
const canReuseOutputs = await fileExists(path.join(options.outDir, "manifest.json"));
|
|
1015
|
+
const previousDocs = canReuseOutputs ? previousCache.docs : {};
|
|
1016
|
+
const previousOutputHashes = canReuseOutputs ? previousCache.outputHashes : {};
|
|
1017
|
+
const { docs, nextSources } = await readPublishedDocs(options, previousCache.sources);
|
|
733
1018
|
docs.sort((a, b) => a.relNoExt.localeCompare(b.relNoExt, "ko-KR"));
|
|
734
1019
|
|
|
735
1020
|
await cleanRemovedOutputs(options.outDir, previousCache, docs);
|
|
736
|
-
|
|
1021
|
+
const outputContext: OutputWriteContext = {
|
|
1022
|
+
outDir: options.outDir,
|
|
1023
|
+
previousHashes: previousOutputHashes,
|
|
1024
|
+
nextHashes: {},
|
|
1025
|
+
};
|
|
1026
|
+
await writeRuntimeAssets(outputContext);
|
|
737
1027
|
|
|
738
1028
|
const tree = buildTree(docs, options);
|
|
739
1029
|
const manifest = buildManifest(docs, tree, options);
|
|
740
|
-
await
|
|
1030
|
+
await writeOutputIfChanged(outputContext, "manifest.json", `${JSON.stringify(manifest, null, 2)}\n`);
|
|
741
1031
|
|
|
742
|
-
await writeShellPages(
|
|
743
|
-
await writeSeoArtifacts(
|
|
1032
|
+
await writeShellPages(outputContext, docs, options);
|
|
1033
|
+
await writeSeoArtifacts(outputContext, docs, options);
|
|
744
1034
|
|
|
745
1035
|
const markdownRenderer = await createMarkdownRenderer(options);
|
|
746
|
-
const
|
|
1036
|
+
const wikiLookup = createWikiLookup(docs);
|
|
747
1037
|
|
|
748
1038
|
let renderedDocs = 0;
|
|
749
1039
|
let skippedDocs = 0;
|
|
750
1040
|
|
|
751
1041
|
const nextCache: BuildCache = {
|
|
752
1042
|
version: CACHE_VERSION,
|
|
1043
|
+
sources: nextSources,
|
|
753
1044
|
docs: {},
|
|
1045
|
+
outputHashes: outputContext.nextHashes,
|
|
754
1046
|
};
|
|
755
1047
|
|
|
756
1048
|
for (const doc of docs) {
|
|
1049
|
+
const wikiSignature = options.wikilinks ? buildWikiResolutionSignature(doc, wikiLookup) : "";
|
|
757
1050
|
const sourceHash = makeHash(
|
|
758
|
-
[
|
|
1051
|
+
[
|
|
1052
|
+
doc.rawHash,
|
|
1053
|
+
doc.route,
|
|
1054
|
+
options.shikiTheme,
|
|
1055
|
+
options.imagePolicy,
|
|
1056
|
+
options.wikilinks ? "wikilinks-on" : "wikilinks-off",
|
|
1057
|
+
wikiSignature,
|
|
1058
|
+
].join("::"),
|
|
759
1059
|
);
|
|
760
|
-
const previous =
|
|
1060
|
+
const previous = previousDocs[doc.id];
|
|
1061
|
+
const contentRelPath = `content/${toContentFileName(doc.id)}`;
|
|
761
1062
|
const outputPath = path.join(options.outDir, "content", toContentFileName(doc.id));
|
|
762
|
-
const unchanged = previous?.hash === sourceHash &&
|
|
1063
|
+
const unchanged = previous?.hash === sourceHash && outputContext.previousHashes[contentRelPath] === sourceHash;
|
|
763
1064
|
|
|
764
1065
|
nextCache.docs[doc.id] = {
|
|
765
1066
|
hash: sourceHash,
|
|
766
1067
|
route: doc.route,
|
|
767
1068
|
relPath: doc.relPath,
|
|
768
1069
|
};
|
|
1070
|
+
outputContext.nextHashes[contentRelPath] = sourceHash;
|
|
769
1071
|
|
|
770
1072
|
if (unchanged) {
|
|
771
1073
|
skippedDocs += 1;
|
|
772
1074
|
continue;
|
|
773
1075
|
}
|
|
774
1076
|
|
|
775
|
-
const resolver = createWikiResolver(
|
|
1077
|
+
const resolver = createWikiResolver(wikiLookup, doc);
|
|
776
1078
|
const renderResult = await markdownRenderer.render(doc.body, resolver);
|
|
777
1079
|
if (renderResult.warnings.length > 0) {
|
|
778
1080
|
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 {
|