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

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
@@ -83,6 +83,9 @@ bun run build -- --vault ./test-vault --out ./dist
83
83
 
84
84
  - `publish: true`
85
85
  이 값이 `true`인 문서만 빌드 결과에 포함됩니다.
86
+ - `prefix: "A-01"`
87
+ 문서의 공개 식별자이자 라우트(`/A-01/`) 기준입니다.
88
+ `publish: true`인데 `prefix`가 없으면 빌드 경고를 출력하고 문서를 제외합니다.
86
89
 
87
90
  선택:
88
91
 
@@ -90,8 +93,6 @@ bun run build -- --vault ./test-vault --out ./dist
90
93
  `publish: true`여도 문서를 제외합니다.
91
94
  - `title: "..."`
92
95
  문서 제목. 없으면 파일명을 사용합니다.
93
- - `prefix: "A-01"`
94
- 탐색기 제목 앞과 본문 메타 줄에 표시할 짧은 코드.
95
96
  - `branch: dev`
96
97
  브랜치 필터 분류값.
97
98
  - `description: "..."`
package/README.md CHANGED
@@ -83,6 +83,9 @@ Required:
83
83
 
84
84
  - `publish: true`
85
85
  Only documents with this value are included in build output.
86
+ - `prefix: "A-01"`
87
+ Public identifier for the document route (`/A-01/`).
88
+ If `publish: true` but `prefix` is missing, the document is skipped with a build warning.
86
89
 
87
90
  Optional:
88
91
 
@@ -90,8 +93,6 @@ Optional:
90
93
  Excludes the document even if `publish: true`.
91
94
  - `title: "..."`
92
95
  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
96
  - `branch: dev`
96
97
  Branch filter label.
97
98
  - `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.4",
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]);
@@ -1167,7 +1226,29 @@ function renderInitialNav(docs: DocRecord[], currentId: string): string {
1167
1226
  return html;
1168
1227
  }
1169
1228
 
1170
- function buildInitialView(doc: DocRecord, docs: DocRecord[], contentHtml: string): AppShellInitialView {
1229
+ function renderInitialBacklinks(backlinks: Manifest["docs"][number]["backlinks"]): string {
1230
+ if (backlinks.length === 0) {
1231
+ return "";
1232
+ }
1233
+
1234
+ let html = '<h2 class="backlinks-title">Backlinks</h2><ul class="backlinks-list">';
1235
+ for (const backlink of backlinks) {
1236
+ const prefixHtml = backlink.prefix
1237
+ ? `<span class="backlink-prefix">${escapeHtmlAttribute(backlink.prefix)}</span>`
1238
+ : "";
1239
+ html += `<li class="backlinks-item"><a href="${escapeHtmlAttribute(backlink.route)}" class="backlink-link" data-route="${escapeHtmlAttribute(backlink.route)}">${prefixHtml}<span class="backlink-text">${escapeHtmlAttribute(backlink.title)}</span></a></li>`;
1240
+ }
1241
+ html += "</ul>";
1242
+ return html;
1243
+ }
1244
+
1245
+ function buildInitialView(
1246
+ doc: DocRecord,
1247
+ docs: DocRecord[],
1248
+ contentHtml: string,
1249
+ manifestDocById: Map<string, Manifest["docs"][number]>,
1250
+ ): AppShellInitialView {
1251
+ const manifestDoc = manifestDocById.get(doc.id);
1171
1252
  return {
1172
1253
  route: doc.route,
1173
1254
  docId: doc.id,
@@ -1175,6 +1256,7 @@ function buildInitialView(doc: DocRecord, docs: DocRecord[], contentHtml: string
1175
1256
  breadcrumbHtml: renderInitialBreadcrumb(doc.route),
1176
1257
  metaHtml: renderInitialMeta(doc),
1177
1258
  contentHtml,
1259
+ backlinksHtml: renderInitialBacklinks(manifestDoc?.backlinks ?? []),
1178
1260
  navHtml: renderInitialNav(docs, doc.id),
1179
1261
  };
1180
1262
  }
@@ -1187,9 +1269,12 @@ async function writeShellPages(
1187
1269
  runtimeAssets: RuntimeAssets,
1188
1270
  contentByDocId: Map<string, string>,
1189
1271
  ): Promise<void> {
1272
+ const manifestDocById = new Map(manifest.docs.map((doc) => [doc.id, doc]));
1190
1273
  const indexDoc = pickHomeDoc(docs);
1191
1274
  const indexOutputPath = "index.html";
1192
- const indexInitialView = indexDoc ? buildInitialView(indexDoc, docs, contentByDocId.get(indexDoc.id) ?? "") : null;
1275
+ const indexInitialView = indexDoc
1276
+ ? buildInitialView(indexDoc, docs, contentByDocId.get(indexDoc.id) ?? "", manifestDocById)
1277
+ : null;
1193
1278
  const shell = renderAppShellHtml(
1194
1279
  buildShellMeta("/", null, options),
1195
1280
  buildAppShellAssetsForOutput(indexOutputPath, runtimeAssets),
@@ -1206,7 +1291,7 @@ async function writeShellPages(
1206
1291
 
1207
1292
  for (const doc of docs) {
1208
1293
  const routeOutputPath = toRouteOutputPath(doc.route);
1209
- const initialView = buildInitialView(doc, docs, contentByDocId.get(doc.id) ?? "");
1294
+ const initialView = buildInitialView(doc, docs, contentByDocId.get(doc.id) ?? "", manifestDocById);
1210
1295
  await writeOutputIfChanged(
1211
1296
  context,
1212
1297
  routeOutputPath,
@@ -1311,6 +1396,41 @@ function buildWikiResolutionSignature(doc: DocRecord, lookup: WikiLookup): strin
1311
1396
  return segments.join("|");
1312
1397
  }
1313
1398
 
1399
+ function buildBacklinksByDocId(
1400
+ docs: DocRecord[],
1401
+ lookup: WikiLookup,
1402
+ ): Map<string, Manifest["docs"][number]["backlinks"]> {
1403
+ const buckets = new Map<string, Map<string, Manifest["docs"][number]["backlinks"][number]>>();
1404
+
1405
+ for (const doc of docs) {
1406
+ for (const target of doc.wikiTargets) {
1407
+ const targetDoc = resolveWikiTargetDoc(lookup, target, doc, false);
1408
+ if (!targetDoc || targetDoc.id === doc.id) {
1409
+ continue;
1410
+ }
1411
+
1412
+ const bucket = buckets.get(targetDoc.id) ?? new Map<string, Manifest["docs"][number]["backlinks"][number]>();
1413
+ bucket.set(doc.id, {
1414
+ id: doc.id,
1415
+ route: doc.route,
1416
+ title: doc.title,
1417
+ prefix: doc.prefix,
1418
+ });
1419
+ buckets.set(targetDoc.id, bucket);
1420
+ }
1421
+ }
1422
+
1423
+ const backlinksByDocId = new Map<string, Manifest["docs"][number]["backlinks"]>();
1424
+ for (const doc of docs) {
1425
+ const source = buckets.get(doc.id) ?? new Map<string, Manifest["docs"][number]["backlinks"][number]>();
1426
+ const backlinks = Array.from(source.values()).sort((left, right) =>
1427
+ left.route.localeCompare(right.route, "ko-KR"),
1428
+ );
1429
+ backlinksByDocId.set(doc.id, backlinks);
1430
+ }
1431
+ return backlinksByDocId;
1432
+ }
1433
+
1314
1434
  export async function buildSite(options: BuildOptions): Promise<BuildResult> {
1315
1435
  await ensureDir(options.outDir);
1316
1436
  await ensureDir(path.join(options.outDir, "content"));
@@ -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 {
@@ -229,6 +229,7 @@ function buildBranchView(manifest, branch, defaultBranch) {
229
229
 
230
230
  return {
231
231
  docs,
232
+ visibleDocIds,
232
233
  tree,
233
234
  routeMap,
234
235
  docIndexById,
@@ -342,7 +343,25 @@ function getFocusableElements(container) {
342
343
  if (!(el instanceof HTMLElement)) {
343
344
  return false;
344
345
  }
345
- return !el.hasAttribute("hidden") && el.getAttribute("aria-hidden") !== "true";
346
+
347
+ if (el.hasAttribute("hidden") || el.getAttribute("aria-hidden") === "true") {
348
+ return false;
349
+ }
350
+
351
+ if (el.closest("[hidden], [inert], [aria-hidden='true']")) {
352
+ return false;
353
+ }
354
+
355
+ if (el instanceof HTMLInputElement && el.type === "hidden") {
356
+ return false;
357
+ }
358
+
359
+ const style = window.getComputedStyle(el);
360
+ if (style.display === "none" || style.visibility === "hidden") {
361
+ return false;
362
+ }
363
+
364
+ return el.getClientRects().length > 0;
346
365
  });
347
366
  }
348
367
 
@@ -758,6 +777,25 @@ function renderNav(docs, docIndexById, currentId) {
758
777
  return html;
759
778
  }
760
779
 
780
+ function renderBacklinks(doc) {
781
+ const backlinks = Array.isArray(doc.backlinks) ? doc.backlinks : [];
782
+ if (backlinks.length === 0) {
783
+ return "";
784
+ }
785
+
786
+ let html = '<h2 class="backlinks-title">Backlinks</h2><ul class="backlinks-list">';
787
+ for (const backlink of backlinks) {
788
+ const prefix = typeof backlink.prefix === "string" && backlink.prefix.trim().length > 0
789
+ ? `<span class="backlink-prefix">${escapeHtmlAttr(backlink.prefix.trim())}</span>`
790
+ : "";
791
+ const route = typeof backlink.route === "string" ? normalizeRoute(backlink.route) : "/";
792
+ const title = typeof backlink.title === "string" && backlink.title.trim().length > 0 ? backlink.title : route;
793
+ html += `<li class="backlinks-item"><a href="${toSafeUrlPath(route)}" class="backlink-link" data-route="${escapeHtmlAttr(route)}">${prefix}<span class="backlink-text">${escapeHtmlAttr(title)}</span></a></li>`;
794
+ }
795
+ html += "</ul>";
796
+ return html;
797
+ }
798
+
761
799
  async function start() {
762
800
  const treeRoot = document.getElementById("tree-root");
763
801
  const appRoot = document.querySelector(".app-root");
@@ -778,6 +816,7 @@ async function start() {
778
816
  const titleEl = document.getElementById("viewer-title");
779
817
  const metaEl = document.getElementById("viewer-meta");
780
818
  const contentEl = document.getElementById("viewer-content");
819
+ const backlinksEl = document.getElementById("viewer-backlinks");
781
820
  const navEl = document.getElementById("viewer-nav");
782
821
  const a11yStatusEl = document.getElementById("a11y-status");
783
822
  const viewerEl = document.querySelector(".viewer");
@@ -897,24 +936,56 @@ async function start() {
897
936
  closeSettings();
898
937
  };
899
938
 
939
+ const setViewerInteractiveState = (isInteractive) => {
940
+ if (!(viewerEl instanceof HTMLElement)) {
941
+ return;
942
+ }
943
+
944
+ if (isInteractive) {
945
+ viewerEl.removeAttribute("inert");
946
+ viewerEl.removeAttribute("aria-hidden");
947
+ return;
948
+ }
949
+
950
+ viewerEl.setAttribute("inert", "");
951
+ viewerEl.setAttribute("aria-hidden", "true");
952
+ };
953
+
900
954
  const syncSidebarA11y = (isOpen) => {
901
955
  if (sidebarToggle) {
902
956
  sidebarToggle.setAttribute("aria-expanded", String(isOpen));
903
957
  }
904
958
 
905
- if (!sidebar) {
959
+ if (sidebarOverlay) {
960
+ sidebarOverlay.setAttribute("aria-hidden", String(!isOpen));
961
+ }
962
+
963
+ if (!(sidebar instanceof HTMLElement)) {
964
+ setViewerInteractiveState(true);
906
965
  return;
907
966
  }
908
967
 
909
968
  if (!isCompactLayout()) {
910
969
  sidebar.removeAttribute("inert");
970
+ sidebar.removeAttribute("aria-hidden");
971
+ sidebar.removeAttribute("aria-modal");
972
+ sidebar.setAttribute("role", "complementary");
973
+ setViewerInteractiveState(true);
911
974
  return;
912
975
  }
913
976
 
914
977
  if (isOpen) {
915
978
  sidebar.removeAttribute("inert");
979
+ sidebar.removeAttribute("aria-hidden");
980
+ sidebar.setAttribute("role", "dialog");
981
+ sidebar.setAttribute("aria-modal", "true");
982
+ setViewerInteractiveState(false);
916
983
  } else {
917
984
  sidebar.setAttribute("inert", "");
985
+ sidebar.setAttribute("aria-hidden", "true");
986
+ sidebar.removeAttribute("aria-modal");
987
+ sidebar.setAttribute("role", "complementary");
988
+ setViewerInteractiveState(true);
918
989
  }
919
990
  };
920
991
 
@@ -1253,6 +1324,20 @@ async function start() {
1253
1324
  markActive(treeFileRowsById, activeFileState, state.currentDocId || "");
1254
1325
  };
1255
1326
 
1327
+ const updateBacklinks = (doc) => {
1328
+ if (!(backlinksEl instanceof HTMLElement)) {
1329
+ return;
1330
+ }
1331
+ if (!doc) {
1332
+ backlinksEl.innerHTML = "";
1333
+ backlinksEl.hidden = true;
1334
+ return;
1335
+ }
1336
+ const html = renderBacklinks(doc);
1337
+ backlinksEl.innerHTML = html;
1338
+ backlinksEl.hidden = html.length === 0;
1339
+ };
1340
+
1256
1341
  const state = {
1257
1342
  expanded,
1258
1343
  currentDocId: initialViewData?.docId ?? "",
@@ -1270,8 +1355,9 @@ async function start() {
1270
1355
  const globalId = manifest.routeMap?.[route];
1271
1356
  const globalDoc = globalId ? docsById.get(globalId) : null;
1272
1357
  const globalDocBranch = normalizeBranch(globalDoc?.branch);
1273
- if (globalDoc && globalDocBranch && globalDocBranch !== activeBranch && availableBranchSet.has(globalDocBranch)) {
1274
- activeBranch = globalDocBranch;
1358
+ const targetBranch = globalDocBranch ?? defaultBranch;
1359
+ if (globalDoc && targetBranch !== activeBranch && availableBranchSet.has(targetBranch)) {
1360
+ activeBranch = targetBranch;
1275
1361
  view = getBranchView(activeBranch);
1276
1362
  updateBranchInfo();
1277
1363
  renderTree(state);
@@ -1286,6 +1372,7 @@ async function start() {
1286
1372
  titleEl.textContent = "문서를 찾을 수 없습니다";
1287
1373
  metaEl.innerHTML = "";
1288
1374
  contentEl.innerHTML = '<p class="placeholder">요청한 경로에 해당하는 문서가 없습니다.</p>';
1375
+ updateBacklinks(null);
1289
1376
  navEl.innerHTML = "";
1290
1377
  markActive(treeFileRowsById, activeFileState, "");
1291
1378
  announceA11yStatus("탐색 실패: 요청한 문서를 찾을 수 없습니다.");
@@ -1315,7 +1402,12 @@ async function start() {
1315
1402
 
1316
1403
  if (shouldUseInitialView) {
1317
1404
  hasHydratedInitialView = true;
1318
- document.title = composeDocumentTitle(initialViewData.title, siteTitle);
1405
+ breadcrumbEl.innerHTML = renderBreadcrumb(route);
1406
+ titleEl.textContent = doc.title;
1407
+ metaEl.innerHTML = renderMeta(doc);
1408
+ updateBacklinks(doc);
1409
+ navEl.innerHTML = renderNav(view.docs, view.docIndexById, id);
1410
+ document.title = composeDocumentTitle(doc.title, siteTitle);
1319
1411
  if (viewerEl instanceof HTMLElement) {
1320
1412
  viewerEl.scrollTo(0, 0);
1321
1413
  }
@@ -1330,6 +1422,7 @@ async function start() {
1330
1422
  const res = await fetch(toSafeUrlPath(doc.contentUrl));
1331
1423
  if (!res.ok) {
1332
1424
  contentEl.innerHTML = '<p class="placeholder">본문을 불러오지 못했습니다.</p>';
1425
+ updateBacklinks(null);
1333
1426
  navEl.innerHTML = "";
1334
1427
  announceA11yStatus(`탐색 실패: ${doc.title} 문서를 불러오지 못했습니다.`);
1335
1428
  return;
@@ -1337,6 +1430,7 @@ async function start() {
1337
1430
 
1338
1431
  contentEl.innerHTML = await res.text();
1339
1432
 
1433
+ updateBacklinks(doc);
1340
1434
  navEl.innerHTML = renderNav(view.docs, view.docIndexById, id);
1341
1435
 
1342
1436
  document.title = composeDocumentTitle(doc.title, siteTitle);
@@ -1462,6 +1556,30 @@ async function start() {
1462
1556
  });
1463
1557
  }
1464
1558
 
1559
+ if (backlinksEl instanceof HTMLElement) {
1560
+ backlinksEl.addEventListener("click", (event) => {
1561
+ const target = event.target;
1562
+ if (!(target instanceof Element)) {
1563
+ return;
1564
+ }
1565
+
1566
+ const link = target.closest(".backlink-link");
1567
+ if (!(link instanceof HTMLAnchorElement) || !backlinksEl.contains(link)) {
1568
+ return;
1569
+ }
1570
+
1571
+ event.preventDefault();
1572
+ const route = link.dataset.route;
1573
+ if (!route) {
1574
+ return;
1575
+ }
1576
+ state.navigate(route, true);
1577
+ if (viewerEl instanceof HTMLElement) {
1578
+ viewerEl.scrollTo(0, 0);
1579
+ }
1580
+ });
1581
+ }
1582
+
1465
1583
  initializeTreeTypeahead(treeRoot);
1466
1584
 
1467
1585
  const setActiveBranch = async (nextBranch) => {
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
@@ -147,6 +147,13 @@ export interface Manifest {
147
147
  description?: string;
148
148
  isNew: boolean;
149
149
  branch: string | null;
150
+ wikiTargets: string[];
151
+ backlinks: Array<{
152
+ id: string;
153
+ route: string;
154
+ title: string;
155
+ prefix?: string;
156
+ }>;
150
157
  }>;
151
158
  }
152
159