@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 +12 -2
- package/README.md +12 -2
- package/bun.lock +9 -0
- package/package.json +5 -2
- package/src/build.ts +155 -20
- package/src/config.ts +19 -2
- package/src/runtime/app.css +62 -1
- package/src/runtime/app.js +209 -48
- package/src/template.ts +5 -2
- package/src/types.ts +8 -0
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
|
+
"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:
|
|
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]);
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
229
|
-
recentLimit
|
|
245
|
+
newWithinDays,
|
|
246
|
+
recentLimit,
|
|
230
247
|
siteTitle,
|
|
231
248
|
pinnedMenu: resolvedPinnedMenu,
|
|
232
249
|
wikilinks: userConfig.markdown?.wikilinks ?? DEFAULTS.wikilinks,
|
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
|
@@ -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
|
-
|
|
42
|
-
if (
|
|
43
|
-
|
|
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 (
|
|
46
|
-
|
|
93
|
+
if (normalizedPath === "/") {
|
|
94
|
+
return toSafeUrlPath(`${pathBase}/`);
|
|
47
95
|
}
|
|
48
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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="${
|
|
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="${
|
|
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 (
|
|
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
|
-
|
|
1274
|
-
|
|
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, "",
|
|
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, "",
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|