@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 +3 -2
- package/README.md +3 -2
- package/bun.lock +9 -0
- package/package.json +5 -2
- package/src/build.ts +130 -10
- package/src/runtime/app.css +62 -1
- package/src/runtime/app.js +123 -5
- package/src/template.ts +5 -2
- package/src/types.ts +7 -0
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
|
+
"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:
|
|
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
|
|
595
|
+
function resolveWikiTargetDoc(
|
|
570
596
|
lookup: WikiLookup,
|
|
571
597
|
input: string,
|
|
572
598
|
currentDoc: DocRecord,
|
|
573
599
|
warnOnDuplicate: boolean,
|
|
574
|
-
):
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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"));
|
package/src/runtime/app.css
CHANGED
|
@@ -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 {
|
package/src/runtime/app.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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
|
-
|
|
1274
|
-
|
|
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
|
-
|
|
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
|
|