@limcpf/everything-is-a-markdown 0.5.3 → 0.5.5

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/README.ko.md CHANGED
@@ -38,6 +38,8 @@ bun run blog [build|dev|clean] [options]
38
38
  - `--vault <path>`: Markdown 루트 디렉터리 (기본 `.`)
39
39
  - `--out <path>`: 출력 디렉터리 (기본 `dist`)
40
40
  - `--exclude <glob>`: 제외 패턴 추가 (반복 가능)
41
+ - `--new-within-days <n>`: NEW 배지 기준 일수 (정수 `>= 0`, 기본 `7`)
42
+ - `--recent-limit <n>`: Recent 폴더 문서 수 제한 (정수 `>= 1`, 기본 `5`)
41
43
  - `--port <n>`: 개발 서버 포트 (기본 `3000`)
42
44
 
43
45
  ## 설정 파일 (`blog.config.ts`)
@@ -51,6 +53,7 @@ const config = {
51
53
  staticPaths: ["assets", "public/favicon.ico"],
52
54
  seo: {
53
55
  siteUrl: "https://example.com",
56
+ pathBase: "/blog",
54
57
  defaultOgImage: "/assets/og.png",
55
58
  },
56
59
  };
@@ -65,6 +68,12 @@ export default config;
65
68
  - 지정한 경로의 파일들을 `dist`에 같은 상대 경로로 복사
66
69
  - 예: 볼트 `assets/og.png` -> `dist/assets/og.png`
67
70
 
71
+ `seo.pathBase`:
72
+
73
+ - 서브패스 배포(예: `/blog`)를 정식 지원합니다.
74
+ - 내부 라우팅/본문 fetch 링크에 동일한 base path가 적용됩니다.
75
+ - 루트 배포는 빈 문자열(`""`)을 사용합니다.
76
+
68
77
  예시:
69
78
 
70
79
  ```bash
@@ -83,6 +92,9 @@ bun run build -- --vault ./test-vault --out ./dist
83
92
 
84
93
  - `publish: true`
85
94
  이 값이 `true`인 문서만 빌드 결과에 포함됩니다.
95
+ - `prefix: "A-01"`
96
+ 문서의 공개 식별자이자 라우트(`/A-01/`) 기준입니다.
97
+ `publish: true`인데 `prefix`가 없으면 빌드 경고를 출력하고 문서를 제외합니다.
86
98
 
87
99
  선택:
88
100
 
@@ -90,8 +102,6 @@ bun run build -- --vault ./test-vault --out ./dist
90
102
  `publish: true`여도 문서를 제외합니다.
91
103
  - `title: "..."`
92
104
  문서 제목. 없으면 파일명을 사용합니다.
93
- - `prefix: "A-01"`
94
- 탐색기 제목 앞과 본문 메타 줄에 표시할 짧은 코드.
95
105
  - `branch: dev`
96
106
  브랜치 필터 분류값.
97
107
  - `description: "..."`
package/README.md CHANGED
@@ -38,6 +38,8 @@ Common options:
38
38
  - `--vault <path>`: Markdown root directory (default `.`)
39
39
  - `--out <path>`: Output directory (default `dist`)
40
40
  - `--exclude <glob>`: Add exclude pattern (repeatable)
41
+ - `--new-within-days <n>`: NEW badge threshold days (integer `>= 0`, default `7`)
42
+ - `--recent-limit <n>`: Recent folder item limit (integer `>= 1`, default `5`)
41
43
  - `--port <n>`: Dev server port (default `3000`)
42
44
 
43
45
  ## Config File (`blog.config.ts`)
@@ -51,6 +53,7 @@ const config = {
51
53
  staticPaths: ["assets", "public/favicon.ico"],
52
54
  seo: {
53
55
  siteUrl: "https://example.com",
56
+ pathBase: "/blog",
54
57
  defaultOgImage: "/assets/og.png",
55
58
  },
56
59
  };
@@ -65,6 +68,12 @@ export default config;
65
68
  - Copies all matched files into the same relative location in `dist`
66
69
  - Example: `assets/og.png` in vault becomes `dist/assets/og.png`
67
70
 
71
+ `seo.pathBase`:
72
+
73
+ - Deploy under a subpath (for example `/blog`)
74
+ - Internal routing/content fetch links are generated with this base path
75
+ - Keep empty (`""`) for root deployment
76
+
68
77
  Examples:
69
78
 
70
79
  ```bash
@@ -83,6 +92,9 @@ Required:
83
92
 
84
93
  - `publish: true`
85
94
  Only documents with this value are included in build output.
95
+ - `prefix: "A-01"`
96
+ Public identifier for the document route (`/A-01/`).
97
+ If `publish: true` but `prefix` is missing, the document is skipped with a build warning.
86
98
 
87
99
  Optional:
88
100
 
@@ -90,8 +102,6 @@ Optional:
90
102
  Excludes the document even if `publish: true`.
91
103
  - `title: "..."`
92
104
  Display title. If missing, file name is used.
93
- - `prefix: "A-01"`
94
- Short code shown before the title in the explorer and meta line.
95
105
  - `branch: dev`
96
106
  Branch filter label.
97
107
  - `description: "..."`
package/bun.lock CHANGED
@@ -12,6 +12,7 @@
12
12
  "shiki": "^3.2.1",
13
13
  },
14
14
  "devDependencies": {
15
+ "@playwright/test": "^1.58.2",
15
16
  "@types/markdown-it": "^14.1.2",
16
17
  "@types/picomatch": "^4.0.2",
17
18
  "bun-types": "^1.3.8",
@@ -19,6 +20,8 @@
19
20
  },
20
21
  },
21
22
  "packages": {
23
+ "@playwright/test": ["@playwright/test@1.58.2", "", { "dependencies": { "playwright": "1.58.2" }, "bin": { "playwright": "cli.js" } }, "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA=="],
24
+
22
25
  "@shikijs/core": ["@shikijs/core@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-iAlTtSDDbJiRpvgL5ugKEATDtHdUVkqgHDm/gbD2ZS9c88mx7G1zSYjjOxp5Qa0eaW0MAQosFRmJSk354PRoQA=="],
23
26
 
24
27
  "@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.22.0", "", { "dependencies": { "@shikijs/types": "3.22.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-jdKhfgW9CRtj3Tor0L7+yPwdG3CgP7W+ZEqSsojrMzCjD1e0IxIbwUMDDpYlVBlC08TACg4puwFGkZfLS+56Tw=="],
@@ -75,6 +78,8 @@
75
78
 
76
79
  "extend-shallow": ["extend-shallow@2.0.1", "", { "dependencies": { "is-extendable": "^0.1.0" } }, "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug=="],
77
80
 
81
+ "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
82
+
78
83
  "gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="],
79
84
 
80
85
  "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
@@ -113,6 +118,10 @@
113
118
 
114
119
  "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
115
120
 
121
+ "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
122
+
123
+ "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
124
+
116
125
  "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
117
126
 
118
127
  "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@limcpf/everything-is-a-markdown",
3
- "version": "0.5.3",
3
+ "version": "0.5.5",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -20,7 +20,9 @@
20
20
  "blog": "bun run src/cli.ts",
21
21
  "build": "bun run src/cli.ts build",
22
22
  "dev": "bun run src/cli.ts dev",
23
- "clean": "bun run src/cli.ts clean"
23
+ "clean": "bun run src/cli.ts clean",
24
+ "test:e2e": "playwright test",
25
+ "test:e2e:focus-trap": "playwright test tests/e2e/mobile-sidebar-focus-trap.spec.ts"
24
26
  },
25
27
  "dependencies": {
26
28
  "chokidar": "^4.0.3",
@@ -30,6 +32,7 @@
30
32
  "shiki": "^3.2.1"
31
33
  },
32
34
  "devDependencies": {
35
+ "@playwright/test": "^1.58.2",
33
36
  "@types/markdown-it": "^14.1.2",
34
37
  "@types/picomatch": "^4.0.2",
35
38
  "bun-types": "^1.3.8"
package/src/build.ts CHANGED
@@ -19,7 +19,6 @@ import {
19
19
  stripMdExt,
20
20
  toPosixPath,
21
21
  toDocId,
22
- toRoute,
23
22
  } from "./utils";
24
23
 
25
24
  const CACHE_VERSION = 3;
@@ -44,6 +43,7 @@ interface RuntimeAssets {
44
43
 
45
44
  interface WikiLookup {
46
45
  byPath: Map<string, DocRecord>;
46
+ byPrefix: Map<string, DocRecord[]>;
47
47
  byStem: Map<string, DocRecord[]>;
48
48
  }
49
49
 
@@ -364,6 +364,20 @@ function appendRouteSuffix(route: string, suffix: string): string {
364
364
  return `/${segments.join("/")}/`;
365
365
  }
366
366
 
367
+ function toPrefixRoute(prefix: string): string {
368
+ const normalized = prefix
369
+ .normalize("NFKC")
370
+ .trim()
371
+ .replace(/['’]/g, "")
372
+ .replace(/[\s_]+/g, "-")
373
+ .replace(/\//g, "-")
374
+ .replace(/[^\p{Letter}\p{Number}-]+/gu, "-")
375
+ .replace(/-+/g, "-")
376
+ .replace(/^-+|-+$/g, "");
377
+
378
+ return `/${normalized || "untitled"}/`;
379
+ }
380
+
367
381
  function ensureUniqueRoutes(docs: DocRecord[]): void {
368
382
  const initialBuckets = new Map<string, DocRecord[]>();
369
383
  for (const doc of docs) {
@@ -480,7 +494,7 @@ function toDocRecord(
480
494
  relPath,
481
495
  relNoExt,
482
496
  id,
483
- route: toRoute(relNoExt),
497
+ route: toPrefixRoute(entry.prefix ?? ""),
484
498
  contentUrl: `/content/${toContentFileName(id)}`,
485
499
  fileName,
486
500
  title: entry.title ?? makeTitleFromFileName(fileName),
@@ -544,6 +558,11 @@ async function readPublishedDocs(options: BuildOptions, previousSources: BuildCa
544
558
  if (!completeEntry.publish || completeEntry.draft) {
545
559
  continue;
546
560
  }
561
+
562
+ if (!completeEntry.prefix) {
563
+ console.warn(`[publish] Skipped published doc without prefix: ${relPath}`);
564
+ continue;
565
+ }
547
566
  docs.push(toDocRecord(sourcePath, relPath, completeEntry, newThreshold));
548
567
  }
549
568
 
@@ -553,25 +572,32 @@ async function readPublishedDocs(options: BuildOptions, previousSources: BuildCa
553
572
 
554
573
  function createWikiLookup(docs: DocRecord[]): WikiLookup {
555
574
  const byPath = new Map<string, DocRecord>();
575
+ const byPrefix = new Map<string, DocRecord[]>();
556
576
  const byStem = new Map<string, DocRecord[]>();
557
577
 
558
578
  for (const doc of docs) {
559
579
  byPath.set(doc.relNoExt.toLowerCase(), doc);
580
+ if (doc.prefix) {
581
+ const prefixKey = normalizeWikiTarget(doc.prefix);
582
+ const prefixBucket = byPrefix.get(prefixKey) ?? [];
583
+ prefixBucket.push(doc);
584
+ byPrefix.set(prefixKey, prefixBucket);
585
+ }
560
586
  const stem = path.basename(doc.relNoExt).toLowerCase();
561
587
  const bucket = byStem.get(stem) ?? [];
562
588
  bucket.push(doc);
563
589
  byStem.set(stem, bucket);
564
590
  }
565
591
 
566
- return { byPath, byStem };
592
+ return { byPath, byPrefix, byStem };
567
593
  }
568
594
 
569
- function resolveWikiTarget(
595
+ function resolveWikiTargetDoc(
570
596
  lookup: WikiLookup,
571
597
  input: string,
572
598
  currentDoc: DocRecord,
573
599
  warnOnDuplicate: boolean,
574
- ): { route: string; label: string } | null {
600
+ ): DocRecord | null {
575
601
  const normalized = normalizeWikiTarget(input);
576
602
  if (!normalized) {
577
603
  return null;
@@ -579,7 +605,21 @@ function resolveWikiTarget(
579
605
 
580
606
  const direct = lookup.byPath.get(normalized);
581
607
  if (direct) {
582
- return { route: direct.route, label: direct.title };
608
+ return direct;
609
+ }
610
+
611
+ const prefixMatches = lookup.byPrefix.get(normalized) ?? [];
612
+ if (prefixMatches.length === 1) {
613
+ return prefixMatches[0];
614
+ }
615
+
616
+ if (warnOnDuplicate && prefixMatches.length > 1) {
617
+ console.warn(
618
+ `[wikilink] Duplicate prefix target "${input}" in ${currentDoc.relPath}. Candidates: ${prefixMatches
619
+ .map((item) => item.relPath)
620
+ .join(", ")}`,
621
+ );
622
+ return null;
583
623
  }
584
624
 
585
625
  if (normalized.includes("/")) {
@@ -588,7 +628,7 @@ function resolveWikiTarget(
588
628
 
589
629
  const stemMatches = lookup.byStem.get(normalized) ?? [];
590
630
  if (stemMatches.length === 1) {
591
- return { route: stemMatches[0].route, label: stemMatches[0].title };
631
+ return stemMatches[0];
592
632
  }
593
633
 
594
634
  if (warnOnDuplicate && stemMatches.length > 1) {
@@ -600,6 +640,20 @@ function resolveWikiTarget(
600
640
  return null;
601
641
  }
602
642
 
643
+ function resolveWikiTarget(
644
+ lookup: WikiLookup,
645
+ input: string,
646
+ currentDoc: DocRecord,
647
+ warnOnDuplicate: boolean,
648
+ ): { route: string; label: string } | null {
649
+ const resolved = resolveWikiTargetDoc(lookup, input, currentDoc, warnOnDuplicate);
650
+ if (!resolved) {
651
+ return null;
652
+ }
653
+
654
+ return { route: resolved.route, label: resolved.title };
655
+ }
656
+
603
657
  function createWikiResolver(lookup: WikiLookup, currentDoc: DocRecord): WikiResolver {
604
658
  return {
605
659
  resolve(input: string) {
@@ -773,6 +827,9 @@ function buildManifest(docs: DocRecord[], tree: TreeNode[], options: BuildOption
773
827
  routeMap[doc.route] = doc.id;
774
828
  }
775
829
 
830
+ const wikiLookup = createWikiLookup(docs);
831
+ const backlinksByDocId = buildBacklinksByDocId(docs, wikiLookup);
832
+
776
833
  const docsForManifest = docs.map((doc) => ({
777
834
  id: doc.id,
778
835
  route: doc.route,
@@ -785,6 +842,8 @@ function buildManifest(docs: DocRecord[], tree: TreeNode[], options: BuildOption
785
842
  isNew: doc.isNew,
786
843
  contentUrl: doc.contentUrl,
787
844
  branch: doc.branch,
845
+ wikiTargets: doc.wikiTargets,
846
+ backlinks: backlinksByDocId.get(doc.id) ?? [],
788
847
  }));
789
848
 
790
849
  const branchSet = new Set<string>([DEFAULT_BRANCH]);
@@ -807,6 +866,7 @@ function buildManifest(docs: DocRecord[], tree: TreeNode[], options: BuildOption
807
866
  return {
808
867
  generatedAt: new Date().toISOString(),
809
868
  siteTitle: resolveSiteTitle(options),
869
+ pathBase: options.seo?.pathBase ?? "",
810
870
  defaultBranch: DEFAULT_BRANCH,
811
871
  branches,
812
872
  ui: {
@@ -1147,7 +1207,23 @@ function renderInitialMeta(doc: DocRecord): string {
1147
1207
  return items.join("");
1148
1208
  }
1149
1209
 
1150
- function renderInitialNav(docs: DocRecord[], currentId: string): string {
1210
+ function toPathWithBase(pathname: string, pathBase: string): string {
1211
+ const cleanBase = pathBase.trim().replace(/\\/g, "/");
1212
+ const normalizedBase = !cleanBase || cleanBase === "/"
1213
+ ? ""
1214
+ : `/${cleanBase.replace(/^\/+/, "").replace(/\/+$/, "")}`;
1215
+ if (!normalizedBase) {
1216
+ return pathname;
1217
+ }
1218
+
1219
+ if (pathname === "/") {
1220
+ return `${normalizedBase}/`;
1221
+ }
1222
+ const normalizedPathname = pathname.startsWith("/") ? pathname : `/${pathname}`;
1223
+ return `${normalizedBase}${normalizedPathname}`;
1224
+ }
1225
+
1226
+ function renderInitialNav(docs: DocRecord[], currentId: string, pathBase: string): string {
1151
1227
  const currentIndex = docs.findIndex((doc) => doc.id === currentId);
1152
1228
  if (currentIndex === -1) {
1153
1229
  return "";
@@ -1158,16 +1234,39 @@ function renderInitialNav(docs: DocRecord[], currentId: string): string {
1158
1234
 
1159
1235
  let html = "";
1160
1236
  if (prev) {
1161
- html += `<a href="${escapeHtmlAttribute(prev.route)}" class="nav-link nav-link-prev" data-route="${escapeHtmlAttribute(prev.route)}"><div class="nav-link-label"><span class="material-symbols-outlined">arrow_back</span>Previous</div><div class="nav-link-title">${escapeHtmlAttribute(prev.title)}</div></a>`;
1237
+ html += `<a href="${escapeHtmlAttribute(toPathWithBase(prev.route, pathBase))}" class="nav-link nav-link-prev" data-route="${escapeHtmlAttribute(prev.route)}"><div class="nav-link-label"><span class="material-symbols-outlined">arrow_back</span>Previous</div><div class="nav-link-title">${escapeHtmlAttribute(prev.title)}</div></a>`;
1162
1238
  }
1163
1239
  if (next) {
1164
- html += `<a href="${escapeHtmlAttribute(next.route)}" class="nav-link nav-link-next" data-route="${escapeHtmlAttribute(next.route)}"><div class="nav-link-label">Next<span class="material-symbols-outlined">arrow_forward</span></div><div class="nav-link-title">${escapeHtmlAttribute(next.title)}</div></a>`;
1240
+ html += `<a href="${escapeHtmlAttribute(toPathWithBase(next.route, pathBase))}" class="nav-link nav-link-next" data-route="${escapeHtmlAttribute(next.route)}"><div class="nav-link-label">Next<span class="material-symbols-outlined">arrow_forward</span></div><div class="nav-link-title">${escapeHtmlAttribute(next.title)}</div></a>`;
1165
1241
  }
1166
1242
 
1167
1243
  return html;
1168
1244
  }
1169
1245
 
1170
- function buildInitialView(doc: DocRecord, docs: DocRecord[], contentHtml: string): AppShellInitialView {
1246
+ function renderInitialBacklinks(backlinks: Manifest["docs"][number]["backlinks"], pathBase: string): string {
1247
+ if (backlinks.length === 0) {
1248
+ return "";
1249
+ }
1250
+
1251
+ let html = '<h2 class="backlinks-title">Backlinks</h2><ul class="backlinks-list">';
1252
+ for (const backlink of backlinks) {
1253
+ const prefixHtml = backlink.prefix
1254
+ ? `<span class="backlink-prefix">${escapeHtmlAttribute(backlink.prefix)}</span>`
1255
+ : "";
1256
+ html += `<li class="backlinks-item"><a href="${escapeHtmlAttribute(toPathWithBase(backlink.route, pathBase))}" class="backlink-link" data-route="${escapeHtmlAttribute(backlink.route)}">${prefixHtml}<span class="backlink-text">${escapeHtmlAttribute(backlink.title)}</span></a></li>`;
1257
+ }
1258
+ html += "</ul>";
1259
+ return html;
1260
+ }
1261
+
1262
+ function buildInitialView(
1263
+ doc: DocRecord,
1264
+ docs: DocRecord[],
1265
+ contentHtml: string,
1266
+ manifestDocById: Map<string, Manifest["docs"][number]>,
1267
+ pathBase: string,
1268
+ ): AppShellInitialView {
1269
+ const manifestDoc = manifestDocById.get(doc.id);
1171
1270
  return {
1172
1271
  route: doc.route,
1173
1272
  docId: doc.id,
@@ -1175,7 +1274,8 @@ function buildInitialView(doc: DocRecord, docs: DocRecord[], contentHtml: string
1175
1274
  breadcrumbHtml: renderInitialBreadcrumb(doc.route),
1176
1275
  metaHtml: renderInitialMeta(doc),
1177
1276
  contentHtml,
1178
- navHtml: renderInitialNav(docs, doc.id),
1277
+ backlinksHtml: renderInitialBacklinks(manifestDoc?.backlinks ?? [], pathBase),
1278
+ navHtml: renderInitialNav(docs, doc.id, pathBase),
1179
1279
  };
1180
1280
  }
1181
1281
 
@@ -1187,9 +1287,13 @@ async function writeShellPages(
1187
1287
  runtimeAssets: RuntimeAssets,
1188
1288
  contentByDocId: Map<string, string>,
1189
1289
  ): Promise<void> {
1290
+ const manifestDocById = new Map(manifest.docs.map((doc) => [doc.id, doc]));
1291
+ const pathBase = options.seo?.pathBase ?? "";
1190
1292
  const indexDoc = pickHomeDoc(docs);
1191
1293
  const indexOutputPath = "index.html";
1192
- const indexInitialView = indexDoc ? buildInitialView(indexDoc, docs, contentByDocId.get(indexDoc.id) ?? "") : null;
1294
+ const indexInitialView = indexDoc
1295
+ ? buildInitialView(indexDoc, docs, contentByDocId.get(indexDoc.id) ?? "", manifestDocById, pathBase)
1296
+ : null;
1193
1297
  const shell = renderAppShellHtml(
1194
1298
  buildShellMeta("/", null, options),
1195
1299
  buildAppShellAssetsForOutput(indexOutputPath, runtimeAssets),
@@ -1206,7 +1310,7 @@ async function writeShellPages(
1206
1310
 
1207
1311
  for (const doc of docs) {
1208
1312
  const routeOutputPath = toRouteOutputPath(doc.route);
1209
- const initialView = buildInitialView(doc, docs, contentByDocId.get(doc.id) ?? "");
1313
+ const initialView = buildInitialView(doc, docs, contentByDocId.get(doc.id) ?? "", manifestDocById, pathBase);
1210
1314
  await writeOutputIfChanged(
1211
1315
  context,
1212
1316
  routeOutputPath,
@@ -1311,6 +1415,41 @@ function buildWikiResolutionSignature(doc: DocRecord, lookup: WikiLookup): strin
1311
1415
  return segments.join("|");
1312
1416
  }
1313
1417
 
1418
+ function buildBacklinksByDocId(
1419
+ docs: DocRecord[],
1420
+ lookup: WikiLookup,
1421
+ ): Map<string, Manifest["docs"][number]["backlinks"]> {
1422
+ const buckets = new Map<string, Map<string, Manifest["docs"][number]["backlinks"][number]>>();
1423
+
1424
+ for (const doc of docs) {
1425
+ for (const target of doc.wikiTargets) {
1426
+ const targetDoc = resolveWikiTargetDoc(lookup, target, doc, false);
1427
+ if (!targetDoc || targetDoc.id === doc.id) {
1428
+ continue;
1429
+ }
1430
+
1431
+ const bucket = buckets.get(targetDoc.id) ?? new Map<string, Manifest["docs"][number]["backlinks"][number]>();
1432
+ bucket.set(doc.id, {
1433
+ id: doc.id,
1434
+ route: doc.route,
1435
+ title: doc.title,
1436
+ prefix: doc.prefix,
1437
+ });
1438
+ buckets.set(targetDoc.id, bucket);
1439
+ }
1440
+ }
1441
+
1442
+ const backlinksByDocId = new Map<string, Manifest["docs"][number]["backlinks"]>();
1443
+ for (const doc of docs) {
1444
+ const source = buckets.get(doc.id) ?? new Map<string, Manifest["docs"][number]["backlinks"][number]>();
1445
+ const backlinks = Array.from(source.values()).sort((left, right) =>
1446
+ left.route.localeCompare(right.route, "ko-KR"),
1447
+ );
1448
+ backlinksByDocId.set(doc.id, backlinks);
1449
+ }
1450
+ return backlinksByDocId;
1451
+ }
1452
+
1314
1453
  export async function buildSite(options: BuildOptions): Promise<BuildResult> {
1315
1454
  await ensureDir(options.outDir);
1316
1455
  await ensureDir(path.join(options.outDir, "content"));
@@ -1375,16 +1514,12 @@ export async function buildSite(options: BuildOptions): Promise<BuildResult> {
1375
1514
  outputContext.nextHashes[contentRelPath] = sourceHash;
1376
1515
 
1377
1516
  if (unchanged) {
1378
- skippedDocs += 1;
1379
1517
  const outputFile = Bun.file(outputPath);
1380
1518
  if (await outputFile.exists()) {
1519
+ skippedDocs += 1;
1381
1520
  contentByDocId.set(doc.id, await outputFile.text());
1382
- } else {
1383
- const resolver = createWikiResolver(wikiLookup, doc);
1384
- const renderResult = await markdownRenderer.render(doc.body, resolver);
1385
- contentByDocId.set(doc.id, renderResult.html);
1521
+ continue;
1386
1522
  }
1387
- continue;
1388
1523
  }
1389
1524
 
1390
1525
  const resolver = createWikiResolver(wikiLookup, doc);
package/src/config.ts CHANGED
@@ -199,6 +199,13 @@ export async function loadPinnedMenuConfig(
199
199
  return normalizePinnedMenu((parsed as Record<string, unknown>).pinnedMenu, "[menu-config]");
200
200
  }
201
201
 
202
+ function ensureIntegerOption(value: unknown, optionLabel: string, min: number): number {
203
+ if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value < min) {
204
+ throw new Error(`[config] "${optionLabel}" must be an integer >= ${min}`);
205
+ }
206
+ return value;
207
+ }
208
+
202
209
  export function resolveBuildOptions(
203
210
  cli: CliArgs,
204
211
  userConfig: UserConfig,
@@ -219,14 +226,24 @@ export function resolveBuildOptions(
219
226
  : undefined;
220
227
  const configPinnedMenu = normalizePinnedMenu(userConfig.pinnedMenu, "[config]");
221
228
  const resolvedPinnedMenu = pinnedMenu ?? configPinnedMenu;
229
+ const newWithinDays = ensureIntegerOption(
230
+ cli.newWithinDays ?? userConfig.ui?.newWithinDays ?? DEFAULTS.newWithinDays,
231
+ "--new-within-days (or ui.newWithinDays)",
232
+ 0,
233
+ );
234
+ const recentLimit = ensureIntegerOption(
235
+ cli.recentLimit ?? userConfig.ui?.recentLimit ?? DEFAULTS.recentLimit,
236
+ "--recent-limit (or ui.recentLimit)",
237
+ 1,
238
+ );
222
239
 
223
240
  return {
224
241
  vaultDir,
225
242
  outDir,
226
243
  exclude: mergedExclude,
227
244
  staticPaths,
228
- newWithinDays: cli.newWithinDays ?? userConfig.ui?.newWithinDays ?? DEFAULTS.newWithinDays,
229
- recentLimit: cli.recentLimit ?? userConfig.ui?.recentLimit ?? DEFAULTS.recentLimit,
245
+ newWithinDays,
246
+ recentLimit,
230
247
  siteTitle,
231
248
  pinnedMenu: resolvedPinnedMenu,
232
249
  wikilinks: userConfig.markdown?.wikilinks ?? DEFAULTS.wikilinks,
@@ -1168,6 +1168,62 @@ body.mobile-toggle-left .mobile-menu-toggle {
1168
1168
  border-top: 1px solid var(--latte-surface0);
1169
1169
  }
1170
1170
 
1171
+ .viewer-backlinks {
1172
+ margin-top: 44px;
1173
+ padding-top: 28px;
1174
+ border-top: 1px solid var(--latte-surface0);
1175
+ }
1176
+
1177
+ .backlinks-title {
1178
+ margin: 0 0 14px;
1179
+ font-size: 1.05rem;
1180
+ color: var(--latte-subtext0);
1181
+ }
1182
+
1183
+ .backlinks-list {
1184
+ list-style: none;
1185
+ margin: 0;
1186
+ padding: 0;
1187
+ display: grid;
1188
+ gap: 10px;
1189
+ }
1190
+
1191
+ .backlinks-item {
1192
+ margin: 0;
1193
+ }
1194
+
1195
+ .backlink-link {
1196
+ display: inline-flex;
1197
+ align-items: center;
1198
+ gap: 8px;
1199
+ text-decoration: none;
1200
+ color: var(--latte-text);
1201
+ padding: 8px 10px;
1202
+ border: 1px solid var(--latte-surface0);
1203
+ border-radius: 8px;
1204
+ background: var(--active-surface-bg);
1205
+ transition: border-color 0.15s ease, color 0.15s ease;
1206
+ }
1207
+
1208
+ .backlink-link:hover {
1209
+ border-color: var(--latte-mauve);
1210
+ color: var(--latte-mauve);
1211
+ }
1212
+
1213
+ .backlink-prefix {
1214
+ font-size: 0.72rem;
1215
+ font-weight: 700;
1216
+ letter-spacing: 0.03em;
1217
+ color: var(--latte-overlay0);
1218
+ padding: 1px 7px;
1219
+ border-radius: 999px;
1220
+ border: 1px solid var(--latte-surface0);
1221
+ }
1222
+
1223
+ .backlink-text {
1224
+ font-size: 0.9rem;
1225
+ }
1226
+
1171
1227
  .nav-link {
1172
1228
  flex: 1;
1173
1229
  padding: 16px;
@@ -1295,12 +1351,17 @@ body.mobile-toggle-left .mobile-menu-toggle {
1295
1351
  border-bottom: 0;
1296
1352
  box-shadow: var(--sidebar-mobile-shadow);
1297
1353
  transform: translateX(-102%);
1298
- transition: transform 0.2s ease;
1354
+ transition: transform 0.2s ease, visibility 0.2s step-end;
1355
+ visibility: hidden;
1356
+ pointer-events: none;
1299
1357
  z-index: 100;
1300
1358
  }
1301
1359
 
1302
1360
  .app-root.sidebar-open .sidebar {
1303
1361
  transform: translateX(0);
1362
+ visibility: visible;
1363
+ pointer-events: auto;
1364
+ transition: transform 0.2s ease, visibility 0s;
1304
1365
  }
1305
1366
 
1306
1367
  .mobile-menu-toggle {
@@ -37,15 +37,63 @@ function toSafeUrlPath(input) {
37
37
  .join("/");
38
38
  }
39
39
 
40
+ function normalizePathname(pathname) {
41
+ let normalized = "/";
42
+ try {
43
+ normalized = decodeURIComponent(pathname || "/");
44
+ } catch {
45
+ normalized = String(pathname || "/");
46
+ }
47
+ if (!normalized.startsWith("/")) {
48
+ normalized = `/${normalized}`;
49
+ }
50
+ return normalized.replace(/\/+/g, "/") || "/";
51
+ }
52
+
40
53
  function normalizeRoute(pathname) {
41
- let route = decodeURIComponent(pathname || "/");
42
- if (!route.startsWith("/")) {
43
- route = `/${route}`;
54
+ const normalized = normalizePathname(pathname);
55
+ if (normalized.endsWith("/")) {
56
+ return normalized;
57
+ }
58
+ return `${normalized}/`;
59
+ }
60
+
61
+ function normalizePathBase(pathBase) {
62
+ if (typeof pathBase !== "string") {
63
+ return "";
64
+ }
65
+
66
+ const cleaned = pathBase.trim().replace(/\\/g, "/");
67
+ if (!cleaned || cleaned === "/") {
68
+ return "";
69
+ }
70
+
71
+ return `/${cleaned.replace(/^\/+/, "").replace(/\/+$/, "")}`;
72
+ }
73
+
74
+ function stripPathBase(pathname, pathBase) {
75
+ const normalizedPath = normalizePathname(pathname);
76
+ if (!pathBase) {
77
+ return normalizedPath;
78
+ }
79
+ if (normalizedPath === pathBase) {
80
+ return "/";
81
+ }
82
+ if (normalizedPath.startsWith(`${pathBase}/`)) {
83
+ return normalizedPath.slice(pathBase.length) || "/";
84
+ }
85
+ return normalizedPath;
86
+ }
87
+
88
+ function toPathWithBase(pathname, pathBase) {
89
+ const normalizedPath = normalizePathname(pathname);
90
+ if (!pathBase) {
91
+ return toSafeUrlPath(normalizedPath);
44
92
  }
45
- if (!route.endsWith("/")) {
46
- route = `${route}/`;
93
+ if (normalizedPath === "/") {
94
+ return toSafeUrlPath(`${pathBase}/`);
47
95
  }
48
- return route;
96
+ return toSafeUrlPath(`${pathBase}${normalizedPath}`);
49
97
  }
50
98
 
51
99
  function loadInitialViewData() {
@@ -114,21 +162,9 @@ function loadInitialManifestData() {
114
162
  }
115
163
  }
116
164
 
117
- function resolveRouteFromLocation(routeMap) {
118
- const direct = normalizeRoute(location.pathname);
119
- if (routeMap[direct]) {
120
- return direct;
121
- }
122
-
123
- if (location.search.length > 1) {
124
- const recovered = normalizeRoute(`${location.pathname}?${location.search.slice(1)}`);
125
- if (routeMap[recovered]) {
126
- history.replaceState(null, "", toSafeUrlPath(recovered));
127
- return recovered;
128
- }
129
- }
130
-
131
- return direct;
165
+ function resolveRouteFromLocation(routeMap, pathBase) {
166
+ const direct = normalizeRoute(stripPathBase(location.pathname, pathBase));
167
+ return routeMap[direct] ? direct : direct;
132
168
  }
133
169
 
134
170
  function resolveSiteTitle(manifest) {
@@ -229,6 +265,7 @@ function buildBranchView(manifest, branch, defaultBranch) {
229
265
 
230
266
  return {
231
267
  docs,
268
+ visibleDocIds,
232
269
  tree,
233
270
  routeMap,
234
271
  docIndexById,
@@ -342,7 +379,25 @@ function getFocusableElements(container) {
342
379
  if (!(el instanceof HTMLElement)) {
343
380
  return false;
344
381
  }
345
- return !el.hasAttribute("hidden") && el.getAttribute("aria-hidden") !== "true";
382
+
383
+ if (el.hasAttribute("hidden") || el.getAttribute("aria-hidden") === "true") {
384
+ return false;
385
+ }
386
+
387
+ if (el.closest("[hidden], [inert], [aria-hidden='true']")) {
388
+ return false;
389
+ }
390
+
391
+ if (el instanceof HTMLInputElement && el.type === "hidden") {
392
+ return false;
393
+ }
394
+
395
+ const style = window.getComputedStyle(el);
396
+ if (style.display === "none" || style.visibility === "hidden") {
397
+ return false;
398
+ }
399
+
400
+ return el.getClientRects().length > 0;
346
401
  });
347
402
  }
348
403
 
@@ -593,7 +648,7 @@ function initializeTreeLabelTooltip(treeRoot, tooltipEl) {
593
648
  };
594
649
  }
595
650
 
596
- function createFolderNode(node, expandedSet, fileRowsById, depth = 0) {
651
+ function createFolderNode(node, expandedSet, fileRowsById, pathBase, depth = 0) {
597
652
  const wrapper = document.createElement("div");
598
653
  wrapper.className = node.virtual ? "tree-folder virtual" : "tree-folder";
599
654
  wrapper.style.setProperty("--tree-depth", String(depth));
@@ -608,7 +663,7 @@ function createFolderNode(node, expandedSet, fileRowsById, depth = 0) {
608
663
  const isExpanded = node.virtual ? true : expandedSet.has(node.path);
609
664
  const iconName = isExpanded ? "folder_open" : "folder";
610
665
 
611
- row.innerHTML = `<span class="material-symbols-outlined">${iconName}</span><span class="tree-label">${node.name}</span>`;
666
+ row.innerHTML = `<span class="material-symbols-outlined">${iconName}</span><span class="tree-label">${escapeHtmlAttr(node.name)}</span>`;
612
667
  row.setAttribute("aria-expanded", String(isExpanded));
613
668
 
614
669
  const children = document.createElement("div");
@@ -619,9 +674,9 @@ function createFolderNode(node, expandedSet, fileRowsById, depth = 0) {
619
674
 
620
675
  for (const child of node.children) {
621
676
  if (child.type === "folder") {
622
- children.appendChild(createFolderNode(child, expandedSet, fileRowsById, depth + 1));
677
+ children.appendChild(createFolderNode(child, expandedSet, fileRowsById, pathBase, depth + 1));
623
678
  } else {
624
- children.appendChild(createFileNode(child, fileRowsById, depth + 1));
679
+ children.appendChild(createFileNode(child, fileRowsById, pathBase, depth + 1));
625
680
  }
626
681
  }
627
682
 
@@ -630,9 +685,9 @@ function createFolderNode(node, expandedSet, fileRowsById, depth = 0) {
630
685
  return wrapper;
631
686
  }
632
687
 
633
- function createFileNode(node, fileRowsById, depth = 0) {
688
+ function createFileNode(node, fileRowsById, pathBase, depth = 0) {
634
689
  const row = document.createElement("a");
635
- row.href = node.route;
690
+ row.href = toPathWithBase(node.route, pathBase);
636
691
  row.className = "tree-row tree-file-row";
637
692
  row.dataset.rowType = "file";
638
693
  row.dataset.route = node.route;
@@ -732,7 +787,7 @@ function renderMeta(doc) {
732
787
  return items.join("");
733
788
  }
734
789
 
735
- function renderNav(docs, docIndexById, currentId) {
790
+ function renderNav(docs, docIndexById, currentId, pathBase) {
736
791
  const currentIndex = docIndexById.get(currentId) ?? -1;
737
792
  if (currentIndex === -1) return "";
738
793
 
@@ -742,22 +797,41 @@ function renderNav(docs, docIndexById, currentId) {
742
797
  let html = "";
743
798
 
744
799
  if (prev) {
745
- html += `<a href="${toSafeUrlPath(prev.route)}" class="nav-link nav-link-prev" data-route="${escapeHtmlAttr(prev.route)}">
800
+ html += `<a href="${toPathWithBase(prev.route, pathBase)}" class="nav-link nav-link-prev" data-route="${escapeHtmlAttr(prev.route)}">
746
801
  <div class="nav-link-label"><span class="material-symbols-outlined">arrow_back</span>Previous</div>
747
- <div class="nav-link-title">${prev.title}</div>
802
+ <div class="nav-link-title">${escapeHtmlAttr(prev.title)}</div>
748
803
  </a>`;
749
804
  }
750
805
 
751
806
  if (next) {
752
- html += `<a href="${toSafeUrlPath(next.route)}" class="nav-link nav-link-next" data-route="${escapeHtmlAttr(next.route)}">
807
+ html += `<a href="${toPathWithBase(next.route, pathBase)}" class="nav-link nav-link-next" data-route="${escapeHtmlAttr(next.route)}">
753
808
  <div class="nav-link-label">Next<span class="material-symbols-outlined">arrow_forward</span></div>
754
- <div class="nav-link-title">${next.title}</div>
809
+ <div class="nav-link-title">${escapeHtmlAttr(next.title)}</div>
755
810
  </a>`;
756
811
  }
757
812
 
758
813
  return html;
759
814
  }
760
815
 
816
+ function renderBacklinks(doc, pathBase) {
817
+ const backlinks = Array.isArray(doc.backlinks) ? doc.backlinks : [];
818
+ if (backlinks.length === 0) {
819
+ return "";
820
+ }
821
+
822
+ let html = '<h2 class="backlinks-title">Backlinks</h2><ul class="backlinks-list">';
823
+ for (const backlink of backlinks) {
824
+ const prefix = typeof backlink.prefix === "string" && backlink.prefix.trim().length > 0
825
+ ? `<span class="backlink-prefix">${escapeHtmlAttr(backlink.prefix.trim())}</span>`
826
+ : "";
827
+ const route = typeof backlink.route === "string" ? normalizeRoute(backlink.route) : "/";
828
+ const title = typeof backlink.title === "string" && backlink.title.trim().length > 0 ? backlink.title : route;
829
+ html += `<li class="backlinks-item"><a href="${toPathWithBase(route, pathBase)}" class="backlink-link" data-route="${escapeHtmlAttr(route)}">${prefix}<span class="backlink-text">${escapeHtmlAttr(title)}</span></a></li>`;
830
+ }
831
+ html += "</ul>";
832
+ return html;
833
+ }
834
+
761
835
  async function start() {
762
836
  const treeRoot = document.getElementById("tree-root");
763
837
  const appRoot = document.querySelector(".app-root");
@@ -778,6 +852,7 @@ async function start() {
778
852
  const titleEl = document.getElementById("viewer-title");
779
853
  const metaEl = document.getElementById("viewer-meta");
780
854
  const contentEl = document.getElementById("viewer-content");
855
+ const backlinksEl = document.getElementById("viewer-backlinks");
781
856
  const navEl = document.getElementById("viewer-nav");
782
857
  const a11yStatusEl = document.getElementById("a11y-status");
783
858
  const viewerEl = document.querySelector(".viewer");
@@ -897,24 +972,56 @@ async function start() {
897
972
  closeSettings();
898
973
  };
899
974
 
975
+ const setViewerInteractiveState = (isInteractive) => {
976
+ if (!(viewerEl instanceof HTMLElement)) {
977
+ return;
978
+ }
979
+
980
+ if (isInteractive) {
981
+ viewerEl.removeAttribute("inert");
982
+ viewerEl.removeAttribute("aria-hidden");
983
+ return;
984
+ }
985
+
986
+ viewerEl.setAttribute("inert", "");
987
+ viewerEl.setAttribute("aria-hidden", "true");
988
+ };
989
+
900
990
  const syncSidebarA11y = (isOpen) => {
901
991
  if (sidebarToggle) {
902
992
  sidebarToggle.setAttribute("aria-expanded", String(isOpen));
903
993
  }
904
994
 
905
- if (!sidebar) {
995
+ if (sidebarOverlay) {
996
+ sidebarOverlay.setAttribute("aria-hidden", String(!isOpen));
997
+ }
998
+
999
+ if (!(sidebar instanceof HTMLElement)) {
1000
+ setViewerInteractiveState(true);
906
1001
  return;
907
1002
  }
908
1003
 
909
1004
  if (!isCompactLayout()) {
910
1005
  sidebar.removeAttribute("inert");
1006
+ sidebar.removeAttribute("aria-hidden");
1007
+ sidebar.removeAttribute("aria-modal");
1008
+ sidebar.setAttribute("role", "complementary");
1009
+ setViewerInteractiveState(true);
911
1010
  return;
912
1011
  }
913
1012
 
914
1013
  if (isOpen) {
915
1014
  sidebar.removeAttribute("inert");
1015
+ sidebar.removeAttribute("aria-hidden");
1016
+ sidebar.setAttribute("role", "dialog");
1017
+ sidebar.setAttribute("aria-modal", "true");
1018
+ setViewerInteractiveState(false);
916
1019
  } else {
917
1020
  sidebar.setAttribute("inert", "");
1021
+ sidebar.setAttribute("aria-hidden", "true");
1022
+ sidebar.removeAttribute("aria-modal");
1023
+ sidebar.setAttribute("role", "complementary");
1024
+ setViewerInteractiveState(true);
918
1025
  }
919
1026
  };
920
1027
 
@@ -1138,13 +1245,15 @@ async function start() {
1138
1245
  });
1139
1246
 
1140
1247
  let manifest = loadInitialManifestData();
1248
+ const initialPathBase = normalizePathBase(manifest?.pathBase);
1141
1249
  if (!manifest) {
1142
- const manifestRes = await fetch("/manifest.json");
1250
+ const manifestRes = await fetch(toPathWithBase("/manifest.json", initialPathBase));
1143
1251
  if (!manifestRes.ok) {
1144
1252
  throw new Error(`Failed to load manifest: ${manifestRes.status}`);
1145
1253
  }
1146
1254
  manifest = await manifestRes.json();
1147
1255
  }
1256
+ const pathBase = normalizePathBase(manifest.pathBase);
1148
1257
  const siteTitle = resolveSiteTitle(manifest);
1149
1258
  const defaultBranch = normalizeBranch(manifest.defaultBranch) || DEFAULT_BRANCH;
1150
1259
  const availableBranchSet = new Set([defaultBranch]);
@@ -1241,9 +1350,9 @@ async function start() {
1241
1350
 
1242
1351
  for (const node of view.tree) {
1243
1352
  if (node.type === "folder") {
1244
- treeRoot.appendChild(createFolderNode(node, state.expanded, treeFileRowsById));
1353
+ treeRoot.appendChild(createFolderNode(node, state.expanded, treeFileRowsById, pathBase));
1245
1354
  } else {
1246
- treeRoot.appendChild(createFileNode(node, treeFileRowsById));
1355
+ treeRoot.appendChild(createFileNode(node, treeFileRowsById, pathBase));
1247
1356
  }
1248
1357
  }
1249
1358
 
@@ -1253,6 +1362,20 @@ async function start() {
1253
1362
  markActive(treeFileRowsById, activeFileState, state.currentDocId || "");
1254
1363
  };
1255
1364
 
1365
+ const updateBacklinks = (doc) => {
1366
+ if (!(backlinksEl instanceof HTMLElement)) {
1367
+ return;
1368
+ }
1369
+ if (!doc) {
1370
+ backlinksEl.innerHTML = "";
1371
+ backlinksEl.hidden = true;
1372
+ return;
1373
+ }
1374
+ const html = renderBacklinks(doc, pathBase);
1375
+ backlinksEl.innerHTML = html;
1376
+ backlinksEl.hidden = html.length === 0;
1377
+ };
1378
+
1256
1379
  const state = {
1257
1380
  expanded,
1258
1381
  currentDocId: initialViewData?.docId ?? "",
@@ -1270,8 +1393,9 @@ async function start() {
1270
1393
  const globalId = manifest.routeMap?.[route];
1271
1394
  const globalDoc = globalId ? docsById.get(globalId) : null;
1272
1395
  const globalDocBranch = normalizeBranch(globalDoc?.branch);
1273
- if (globalDoc && globalDocBranch && globalDocBranch !== activeBranch && availableBranchSet.has(globalDocBranch)) {
1274
- activeBranch = globalDocBranch;
1396
+ const targetBranch = globalDocBranch ?? defaultBranch;
1397
+ if (globalDoc && targetBranch !== activeBranch && availableBranchSet.has(targetBranch)) {
1398
+ activeBranch = targetBranch;
1275
1399
  view = getBranchView(activeBranch);
1276
1400
  updateBranchInfo();
1277
1401
  renderTree(state);
@@ -1286,11 +1410,12 @@ async function start() {
1286
1410
  titleEl.textContent = "문서를 찾을 수 없습니다";
1287
1411
  metaEl.innerHTML = "";
1288
1412
  contentEl.innerHTML = '<p class="placeholder">요청한 경로에 해당하는 문서가 없습니다.</p>';
1413
+ updateBacklinks(null);
1289
1414
  navEl.innerHTML = "";
1290
1415
  markActive(treeFileRowsById, activeFileState, "");
1291
1416
  announceA11yStatus("탐색 실패: 요청한 문서를 찾을 수 없습니다.");
1292
1417
  if (push) {
1293
- history.pushState(null, "", toSafeUrlPath(route));
1418
+ history.pushState(null, "", toPathWithBase(route, pathBase));
1294
1419
  }
1295
1420
  return;
1296
1421
  }
@@ -1301,7 +1426,7 @@ async function start() {
1301
1426
  }
1302
1427
 
1303
1428
  if (push) {
1304
- history.pushState(null, "", toSafeUrlPath(route));
1429
+ history.pushState(null, "", toPathWithBase(route, pathBase));
1305
1430
  }
1306
1431
 
1307
1432
  state.currentDocId = id;
@@ -1315,7 +1440,12 @@ async function start() {
1315
1440
 
1316
1441
  if (shouldUseInitialView) {
1317
1442
  hasHydratedInitialView = true;
1318
- document.title = composeDocumentTitle(initialViewData.title, siteTitle);
1443
+ breadcrumbEl.innerHTML = renderBreadcrumb(route);
1444
+ titleEl.textContent = doc.title;
1445
+ metaEl.innerHTML = renderMeta(doc);
1446
+ updateBacklinks(doc);
1447
+ navEl.innerHTML = renderNav(view.docs, view.docIndexById, id, pathBase);
1448
+ document.title = composeDocumentTitle(doc.title, siteTitle);
1319
1449
  if (viewerEl instanceof HTMLElement) {
1320
1450
  viewerEl.scrollTo(0, 0);
1321
1451
  }
@@ -1327,9 +1457,10 @@ async function start() {
1327
1457
  titleEl.textContent = doc.title;
1328
1458
  metaEl.innerHTML = renderMeta(doc);
1329
1459
 
1330
- const res = await fetch(toSafeUrlPath(doc.contentUrl));
1460
+ const res = await fetch(toPathWithBase(doc.contentUrl, pathBase));
1331
1461
  if (!res.ok) {
1332
1462
  contentEl.innerHTML = '<p class="placeholder">본문을 불러오지 못했습니다.</p>';
1463
+ updateBacklinks(null);
1333
1464
  navEl.innerHTML = "";
1334
1465
  announceA11yStatus(`탐색 실패: ${doc.title} 문서를 불러오지 못했습니다.`);
1335
1466
  return;
@@ -1337,7 +1468,8 @@ async function start() {
1337
1468
 
1338
1469
  contentEl.innerHTML = await res.text();
1339
1470
 
1340
- navEl.innerHTML = renderNav(view.docs, view.docIndexById, id);
1471
+ updateBacklinks(doc);
1472
+ navEl.innerHTML = renderNav(view.docs, view.docIndexById, id, pathBase);
1341
1473
 
1342
1474
  document.title = composeDocumentTitle(doc.title, siteTitle);
1343
1475
  if (viewerEl instanceof HTMLElement) {
@@ -1462,6 +1594,30 @@ async function start() {
1462
1594
  });
1463
1595
  }
1464
1596
 
1597
+ if (backlinksEl instanceof HTMLElement) {
1598
+ backlinksEl.addEventListener("click", (event) => {
1599
+ const target = event.target;
1600
+ if (!(target instanceof Element)) {
1601
+ return;
1602
+ }
1603
+
1604
+ const link = target.closest(".backlink-link");
1605
+ if (!(link instanceof HTMLAnchorElement) || !backlinksEl.contains(link)) {
1606
+ return;
1607
+ }
1608
+
1609
+ event.preventDefault();
1610
+ const route = link.dataset.route;
1611
+ if (!route) {
1612
+ return;
1613
+ }
1614
+ state.navigate(route, true);
1615
+ if (viewerEl instanceof HTMLElement) {
1616
+ viewerEl.scrollTo(0, 0);
1617
+ }
1618
+ });
1619
+ }
1620
+
1465
1621
  initializeTreeTypeahead(treeRoot);
1466
1622
 
1467
1623
  const setActiveBranch = async (nextBranch) => {
@@ -1476,7 +1632,7 @@ async function start() {
1476
1632
  updateBranchInfo();
1477
1633
  renderTree(state);
1478
1634
 
1479
- const currentRoute = resolveRouteFromLocation(view.routeMap);
1635
+ const currentRoute = resolveRouteFromLocation(view.routeMap, pathBase);
1480
1636
  if (view.routeMap[currentRoute]) {
1481
1637
  await state.navigate(currentRoute, false);
1482
1638
  return;
@@ -1490,20 +1646,25 @@ async function start() {
1490
1646
  updateBranchInfo();
1491
1647
  renderTree(state);
1492
1648
 
1493
- const currentRoute = resolveRouteFromLocation(view.routeMap);
1649
+ const currentRoute = resolveRouteFromLocation(view.routeMap, pathBase);
1494
1650
  const initialRoute = currentRoute === "/" ? pickHomeRoute(view) : currentRoute;
1495
1651
  handleLayoutChange();
1496
1652
  await state.navigate(initialRoute, currentRoute === "/" && initialRoute !== "/");
1497
1653
 
1498
1654
  window.addEventListener("popstate", async () => {
1499
- await state.navigate(resolveRouteFromLocation(view.routeMap), false);
1655
+ await state.navigate(resolveRouteFromLocation(view.routeMap, pathBase), false);
1500
1656
  });
1501
1657
  }
1502
1658
 
1503
1659
  start().catch((error) => {
1504
1660
  const contentEl = document.getElementById("viewer-content");
1505
1661
  if (contentEl) {
1506
- contentEl.innerHTML = `<p class="placeholder">초기화 실패: ${error.message}</p>`;
1662
+ const message = error instanceof Error ? error.message : String(error);
1663
+ contentEl.innerHTML = "";
1664
+ const placeholder = document.createElement("p");
1665
+ placeholder.className = "placeholder";
1666
+ placeholder.textContent = `초기화 실패: ${message}`;
1667
+ contentEl.appendChild(placeholder);
1507
1668
  }
1508
1669
  console.error(error);
1509
1670
  });
package/src/template.ts CHANGED
@@ -36,6 +36,7 @@ export interface AppShellInitialView {
36
36
  breadcrumbHtml: string;
37
37
  metaHtml: string;
38
38
  contentHtml: string;
39
+ backlinksHtml: string;
39
40
  navHtml: string;
40
41
  }
41
42
 
@@ -192,6 +193,7 @@ export function renderAppShellHtml(
192
193
  const initialContent = initialView
193
194
  ? initialView.contentHtml
194
195
  : '<p class="placeholder">좌측 탐색기에서 문서를 선택하세요.</p>';
196
+ const initialBacklinks = initialView ? initialView.backlinksHtml : "";
195
197
  const initialNav = initialView ? initialView.navHtml : "";
196
198
 
197
199
  return `<!doctype html>
@@ -211,8 +213,8 @@ ${headMeta}
211
213
  <a class="skip-link" href="#viewer-panel">본문으로 건너뛰기</a>
212
214
  <div id="a11y-status" class="sr-only" aria-live="polite" aria-atomic="true"></div>
213
215
  <div class="app-root">
214
- <div id="sidebar-overlay" class="sidebar-overlay" hidden></div>
215
- <aside id="sidebar-panel" class="sidebar" aria-label="문서 탐색기 패널">
216
+ <div id="sidebar-overlay" class="sidebar-overlay" aria-hidden="true" hidden></div>
217
+ <aside id="sidebar-panel" class="sidebar" role="complementary" aria-label="문서 탐색기 패널">
216
218
  <div class="sidebar-header">
217
219
  <h1 class="sidebar-title">
218
220
  <span class="material-symbols-outlined icon-terminal">terminal</span>
@@ -310,6 +312,7 @@ ${headMeta}
310
312
  <div id="viewer-meta" class="viewer-meta">${initialMeta}</div>
311
313
  </header>
312
314
  <article id="viewer-content" class="viewer-content">${initialContent}</article>
315
+ <section id="viewer-backlinks" class="viewer-backlinks" aria-label="문서를 참조한 링크" ${initialBacklinks ? "" : "hidden"}>${initialBacklinks}</section>
313
316
  <nav id="viewer-nav" class="viewer-nav" aria-label="문서 이전/다음 탐색">${initialNav}</nav>
314
317
  </div>
315
318
  </main>
package/src/types.ts CHANGED
@@ -127,6 +127,7 @@ export type TreeNode = FolderNode | FileNode;
127
127
  export interface Manifest {
128
128
  generatedAt: string;
129
129
  siteTitle: string;
130
+ pathBase: string;
130
131
  defaultBranch: string;
131
132
  branches: string[];
132
133
  ui: {
@@ -147,6 +148,13 @@ export interface Manifest {
147
148
  description?: string;
148
149
  isNew: boolean;
149
150
  branch: string | null;
151
+ wikiTargets: string[];
152
+ backlinks: Array<{
153
+ id: string;
154
+ route: string;
155
+ title: string;
156
+ prefix?: string;
157
+ }>;
150
158
  }>;
151
159
  }
152
160