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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ko.md CHANGED
@@ -38,6 +38,8 @@ bun run blog [build|dev|clean] [options]
38
38
  - `--vault <path>`: Markdown 루트 디렉터리 (기본 `.`)
39
39
  - `--out <path>`: 출력 디렉터리 (기본 `dist`)
40
40
  - `--exclude <glob>`: 제외 패턴 추가 (반복 가능)
41
+ - `--new-within-days <n>`: NEW 배지 기준 일수 (정수 `>= 0`, 기본 `7`)
42
+ - `--recent-limit <n>`: Recent 폴더 문서 수 제한 (정수 `>= 1`, 기본 `5`)
41
43
  - `--port <n>`: 개발 서버 포트 (기본 `3000`)
42
44
 
43
45
  ## 설정 파일 (`blog.config.ts`)
@@ -51,6 +53,7 @@ const config = {
51
53
  staticPaths: ["assets", "public/favicon.ico"],
52
54
  seo: {
53
55
  siteUrl: "https://example.com",
56
+ pathBase: "/blog",
54
57
  defaultOgImage: "/assets/og.png",
55
58
  },
56
59
  };
@@ -65,6 +68,12 @@ export default config;
65
68
  - 지정한 경로의 파일들을 `dist`에 같은 상대 경로로 복사
66
69
  - 예: 볼트 `assets/og.png` -> `dist/assets/og.png`
67
70
 
71
+ `seo.pathBase`:
72
+
73
+ - 서브패스 배포(예: `/blog`)를 정식 지원합니다.
74
+ - 내부 라우팅/본문 fetch 링크에 동일한 base path가 적용됩니다.
75
+ - 루트 배포는 빈 문자열(`""`)을 사용합니다.
76
+
68
77
  예시:
69
78
 
70
79
  ```bash
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@limcpf/everything-is-a-markdown",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
package/src/build.ts CHANGED
@@ -866,6 +866,7 @@ function buildManifest(docs: DocRecord[], tree: TreeNode[], options: BuildOption
866
866
  return {
867
867
  generatedAt: new Date().toISOString(),
868
868
  siteTitle: resolveSiteTitle(options),
869
+ pathBase: options.seo?.pathBase ?? "",
869
870
  defaultBranch: DEFAULT_BRANCH,
870
871
  branches,
871
872
  ui: {
@@ -1206,7 +1207,23 @@ function renderInitialMeta(doc: DocRecord): string {
1206
1207
  return items.join("");
1207
1208
  }
1208
1209
 
1209
- function renderInitialNav(docs: DocRecord[], currentId: string): string {
1210
+ function toPathWithBase(pathname: string, pathBase: string): string {
1211
+ const cleanBase = pathBase.trim().replace(/\\/g, "/");
1212
+ const normalizedBase = !cleanBase || cleanBase === "/"
1213
+ ? ""
1214
+ : `/${cleanBase.replace(/^\/+/, "").replace(/\/+$/, "")}`;
1215
+ if (!normalizedBase) {
1216
+ return pathname;
1217
+ }
1218
+
1219
+ if (pathname === "/") {
1220
+ return `${normalizedBase}/`;
1221
+ }
1222
+ const normalizedPathname = pathname.startsWith("/") ? pathname : `/${pathname}`;
1223
+ return `${normalizedBase}${normalizedPathname}`;
1224
+ }
1225
+
1226
+ function renderInitialNav(docs: DocRecord[], currentId: string, pathBase: string): string {
1210
1227
  const currentIndex = docs.findIndex((doc) => doc.id === currentId);
1211
1228
  if (currentIndex === -1) {
1212
1229
  return "";
@@ -1217,16 +1234,16 @@ function renderInitialNav(docs: DocRecord[], currentId: string): string {
1217
1234
 
1218
1235
  let html = "";
1219
1236
  if (prev) {
1220
- 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>`;
1221
1238
  }
1222
1239
  if (next) {
1223
- 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>`;
1224
1241
  }
1225
1242
 
1226
1243
  return html;
1227
1244
  }
1228
1245
 
1229
- function renderInitialBacklinks(backlinks: Manifest["docs"][number]["backlinks"]): string {
1246
+ function renderInitialBacklinks(backlinks: Manifest["docs"][number]["backlinks"], pathBase: string): string {
1230
1247
  if (backlinks.length === 0) {
1231
1248
  return "";
1232
1249
  }
@@ -1236,7 +1253,7 @@ function renderInitialBacklinks(backlinks: Manifest["docs"][number]["backlinks"]
1236
1253
  const prefixHtml = backlink.prefix
1237
1254
  ? `<span class="backlink-prefix">${escapeHtmlAttribute(backlink.prefix)}</span>`
1238
1255
  : "";
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>`;
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>`;
1240
1257
  }
1241
1258
  html += "</ul>";
1242
1259
  return html;
@@ -1247,6 +1264,7 @@ function buildInitialView(
1247
1264
  docs: DocRecord[],
1248
1265
  contentHtml: string,
1249
1266
  manifestDocById: Map<string, Manifest["docs"][number]>,
1267
+ pathBase: string,
1250
1268
  ): AppShellInitialView {
1251
1269
  const manifestDoc = manifestDocById.get(doc.id);
1252
1270
  return {
@@ -1256,8 +1274,8 @@ function buildInitialView(
1256
1274
  breadcrumbHtml: renderInitialBreadcrumb(doc.route),
1257
1275
  metaHtml: renderInitialMeta(doc),
1258
1276
  contentHtml,
1259
- backlinksHtml: renderInitialBacklinks(manifestDoc?.backlinks ?? []),
1260
- navHtml: renderInitialNav(docs, doc.id),
1277
+ backlinksHtml: renderInitialBacklinks(manifestDoc?.backlinks ?? [], pathBase),
1278
+ navHtml: renderInitialNav(docs, doc.id, pathBase),
1261
1279
  };
1262
1280
  }
1263
1281
 
@@ -1270,10 +1288,11 @@ async function writeShellPages(
1270
1288
  contentByDocId: Map<string, string>,
1271
1289
  ): Promise<void> {
1272
1290
  const manifestDocById = new Map(manifest.docs.map((doc) => [doc.id, doc]));
1291
+ const pathBase = options.seo?.pathBase ?? "";
1273
1292
  const indexDoc = pickHomeDoc(docs);
1274
1293
  const indexOutputPath = "index.html";
1275
1294
  const indexInitialView = indexDoc
1276
- ? buildInitialView(indexDoc, docs, contentByDocId.get(indexDoc.id) ?? "", manifestDocById)
1295
+ ? buildInitialView(indexDoc, docs, contentByDocId.get(indexDoc.id) ?? "", manifestDocById, pathBase)
1277
1296
  : null;
1278
1297
  const shell = renderAppShellHtml(
1279
1298
  buildShellMeta("/", null, options),
@@ -1291,7 +1310,7 @@ async function writeShellPages(
1291
1310
 
1292
1311
  for (const doc of docs) {
1293
1312
  const routeOutputPath = toRouteOutputPath(doc.route);
1294
- const initialView = buildInitialView(doc, docs, contentByDocId.get(doc.id) ?? "", manifestDocById);
1313
+ const initialView = buildInitialView(doc, docs, contentByDocId.get(doc.id) ?? "", manifestDocById, pathBase);
1295
1314
  await writeOutputIfChanged(
1296
1315
  context,
1297
1316
  routeOutputPath,
@@ -1495,16 +1514,12 @@ export async function buildSite(options: BuildOptions): Promise<BuildResult> {
1495
1514
  outputContext.nextHashes[contentRelPath] = sourceHash;
1496
1515
 
1497
1516
  if (unchanged) {
1498
- skippedDocs += 1;
1499
1517
  const outputFile = Bun.file(outputPath);
1500
1518
  if (await outputFile.exists()) {
1519
+ skippedDocs += 1;
1501
1520
  contentByDocId.set(doc.id, await outputFile.text());
1502
- } else {
1503
- const resolver = createWikiResolver(wikiLookup, doc);
1504
- const renderResult = await markdownRenderer.render(doc.body, resolver);
1505
- contentByDocId.set(doc.id, renderResult.html);
1521
+ continue;
1506
1522
  }
1507
- continue;
1508
1523
  }
1509
1524
 
1510
1525
  const resolver = createWikiResolver(wikiLookup, doc);
package/src/config.ts CHANGED
@@ -199,6 +199,13 @@ export async function loadPinnedMenuConfig(
199
199
  return normalizePinnedMenu((parsed as Record<string, unknown>).pinnedMenu, "[menu-config]");
200
200
  }
201
201
 
202
+ function ensureIntegerOption(value: unknown, optionLabel: string, min: number): number {
203
+ if (typeof value !== "number" || !Number.isFinite(value) || !Number.isInteger(value) || value < min) {
204
+ throw new Error(`[config] "${optionLabel}" must be an integer >= ${min}`);
205
+ }
206
+ return value;
207
+ }
208
+
202
209
  export function resolveBuildOptions(
203
210
  cli: CliArgs,
204
211
  userConfig: UserConfig,
@@ -219,14 +226,24 @@ export function resolveBuildOptions(
219
226
  : undefined;
220
227
  const configPinnedMenu = normalizePinnedMenu(userConfig.pinnedMenu, "[config]");
221
228
  const resolvedPinnedMenu = pinnedMenu ?? configPinnedMenu;
229
+ const newWithinDays = ensureIntegerOption(
230
+ cli.newWithinDays ?? userConfig.ui?.newWithinDays ?? DEFAULTS.newWithinDays,
231
+ "--new-within-days (or ui.newWithinDays)",
232
+ 0,
233
+ );
234
+ const recentLimit = ensureIntegerOption(
235
+ cli.recentLimit ?? userConfig.ui?.recentLimit ?? DEFAULTS.recentLimit,
236
+ "--recent-limit (or ui.recentLimit)",
237
+ 1,
238
+ );
222
239
 
223
240
  return {
224
241
  vaultDir,
225
242
  outDir,
226
243
  exclude: mergedExclude,
227
244
  staticPaths,
228
- newWithinDays: cli.newWithinDays ?? userConfig.ui?.newWithinDays ?? DEFAULTS.newWithinDays,
229
- recentLimit: cli.recentLimit ?? userConfig.ui?.recentLimit ?? DEFAULTS.recentLimit,
245
+ newWithinDays,
246
+ recentLimit,
230
247
  siteTitle,
231
248
  pinnedMenu: resolvedPinnedMenu,
232
249
  wikilinks: userConfig.markdown?.wikilinks ?? DEFAULTS.wikilinks,
@@ -37,15 +37,63 @@ function toSafeUrlPath(input) {
37
37
  .join("/");
38
38
  }
39
39
 
40
+ function normalizePathname(pathname) {
41
+ let normalized = "/";
42
+ try {
43
+ normalized = decodeURIComponent(pathname || "/");
44
+ } catch {
45
+ normalized = String(pathname || "/");
46
+ }
47
+ if (!normalized.startsWith("/")) {
48
+ normalized = `/${normalized}`;
49
+ }
50
+ return normalized.replace(/\/+/g, "/") || "/";
51
+ }
52
+
40
53
  function normalizeRoute(pathname) {
41
- let route = decodeURIComponent(pathname || "/");
42
- if (!route.startsWith("/")) {
43
- route = `/${route}`;
54
+ const normalized = normalizePathname(pathname);
55
+ if (normalized.endsWith("/")) {
56
+ return normalized;
57
+ }
58
+ return `${normalized}/`;
59
+ }
60
+
61
+ function normalizePathBase(pathBase) {
62
+ if (typeof pathBase !== "string") {
63
+ return "";
44
64
  }
45
- if (!route.endsWith("/")) {
46
- route = `${route}/`;
65
+
66
+ const cleaned = pathBase.trim().replace(/\\/g, "/");
67
+ if (!cleaned || cleaned === "/") {
68
+ return "";
47
69
  }
48
- return route;
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);
92
+ }
93
+ if (normalizedPath === "/") {
94
+ return toSafeUrlPath(`${pathBase}/`);
95
+ }
96
+ return toSafeUrlPath(`${pathBase}${normalizedPath}`);
49
97
  }
50
98
 
51
99
  function loadInitialViewData() {
@@ -114,21 +162,9 @@ function loadInitialManifestData() {
114
162
  }
115
163
  }
116
164
 
117
- function resolveRouteFromLocation(routeMap) {
118
- const direct = normalizeRoute(location.pathname);
119
- if (routeMap[direct]) {
120
- return direct;
121
- }
122
-
123
- if (location.search.length > 1) {
124
- const recovered = normalizeRoute(`${location.pathname}?${location.search.slice(1)}`);
125
- if (routeMap[recovered]) {
126
- history.replaceState(null, "", toSafeUrlPath(recovered));
127
- return recovered;
128
- }
129
- }
130
-
131
- return direct;
165
+ function resolveRouteFromLocation(routeMap, pathBase) {
166
+ const direct = normalizeRoute(stripPathBase(location.pathname, pathBase));
167
+ return routeMap[direct] ? direct : direct;
132
168
  }
133
169
 
134
170
  function resolveSiteTitle(manifest) {
@@ -612,7 +648,7 @@ function initializeTreeLabelTooltip(treeRoot, tooltipEl) {
612
648
  };
613
649
  }
614
650
 
615
- function createFolderNode(node, expandedSet, fileRowsById, depth = 0) {
651
+ function createFolderNode(node, expandedSet, fileRowsById, pathBase, depth = 0) {
616
652
  const wrapper = document.createElement("div");
617
653
  wrapper.className = node.virtual ? "tree-folder virtual" : "tree-folder";
618
654
  wrapper.style.setProperty("--tree-depth", String(depth));
@@ -627,7 +663,7 @@ function createFolderNode(node, expandedSet, fileRowsById, depth = 0) {
627
663
  const isExpanded = node.virtual ? true : expandedSet.has(node.path);
628
664
  const iconName = isExpanded ? "folder_open" : "folder";
629
665
 
630
- 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>`;
631
667
  row.setAttribute("aria-expanded", String(isExpanded));
632
668
 
633
669
  const children = document.createElement("div");
@@ -638,9 +674,9 @@ function createFolderNode(node, expandedSet, fileRowsById, depth = 0) {
638
674
 
639
675
  for (const child of node.children) {
640
676
  if (child.type === "folder") {
641
- children.appendChild(createFolderNode(child, expandedSet, fileRowsById, depth + 1));
677
+ children.appendChild(createFolderNode(child, expandedSet, fileRowsById, pathBase, depth + 1));
642
678
  } else {
643
- children.appendChild(createFileNode(child, fileRowsById, depth + 1));
679
+ children.appendChild(createFileNode(child, fileRowsById, pathBase, depth + 1));
644
680
  }
645
681
  }
646
682
 
@@ -649,9 +685,9 @@ function createFolderNode(node, expandedSet, fileRowsById, depth = 0) {
649
685
  return wrapper;
650
686
  }
651
687
 
652
- function createFileNode(node, fileRowsById, depth = 0) {
688
+ function createFileNode(node, fileRowsById, pathBase, depth = 0) {
653
689
  const row = document.createElement("a");
654
- row.href = node.route;
690
+ row.href = toPathWithBase(node.route, pathBase);
655
691
  row.className = "tree-row tree-file-row";
656
692
  row.dataset.rowType = "file";
657
693
  row.dataset.route = node.route;
@@ -751,7 +787,7 @@ function renderMeta(doc) {
751
787
  return items.join("");
752
788
  }
753
789
 
754
- function renderNav(docs, docIndexById, currentId) {
790
+ function renderNav(docs, docIndexById, currentId, pathBase) {
755
791
  const currentIndex = docIndexById.get(currentId) ?? -1;
756
792
  if (currentIndex === -1) return "";
757
793
 
@@ -761,23 +797,23 @@ function renderNav(docs, docIndexById, currentId) {
761
797
  let html = "";
762
798
 
763
799
  if (prev) {
764
- html += `<a href="${toSafeUrlPath(prev.route)}" class="nav-link nav-link-prev" data-route="${escapeHtmlAttr(prev.route)}">
800
+ html += `<a href="${toPathWithBase(prev.route, pathBase)}" class="nav-link nav-link-prev" data-route="${escapeHtmlAttr(prev.route)}">
765
801
  <div class="nav-link-label"><span class="material-symbols-outlined">arrow_back</span>Previous</div>
766
- <div class="nav-link-title">${prev.title}</div>
802
+ <div class="nav-link-title">${escapeHtmlAttr(prev.title)}</div>
767
803
  </a>`;
768
804
  }
769
805
 
770
806
  if (next) {
771
- html += `<a href="${toSafeUrlPath(next.route)}" class="nav-link nav-link-next" data-route="${escapeHtmlAttr(next.route)}">
807
+ html += `<a href="${toPathWithBase(next.route, pathBase)}" class="nav-link nav-link-next" data-route="${escapeHtmlAttr(next.route)}">
772
808
  <div class="nav-link-label">Next<span class="material-symbols-outlined">arrow_forward</span></div>
773
- <div class="nav-link-title">${next.title}</div>
809
+ <div class="nav-link-title">${escapeHtmlAttr(next.title)}</div>
774
810
  </a>`;
775
811
  }
776
812
 
777
813
  return html;
778
814
  }
779
815
 
780
- function renderBacklinks(doc) {
816
+ function renderBacklinks(doc, pathBase) {
781
817
  const backlinks = Array.isArray(doc.backlinks) ? doc.backlinks : [];
782
818
  if (backlinks.length === 0) {
783
819
  return "";
@@ -790,7 +826,7 @@ function renderBacklinks(doc) {
790
826
  : "";
791
827
  const route = typeof backlink.route === "string" ? normalizeRoute(backlink.route) : "/";
792
828
  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>`;
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>`;
794
830
  }
795
831
  html += "</ul>";
796
832
  return html;
@@ -1209,13 +1245,15 @@ async function start() {
1209
1245
  });
1210
1246
 
1211
1247
  let manifest = loadInitialManifestData();
1248
+ const initialPathBase = normalizePathBase(manifest?.pathBase);
1212
1249
  if (!manifest) {
1213
- const manifestRes = await fetch("/manifest.json");
1250
+ const manifestRes = await fetch(toPathWithBase("/manifest.json", initialPathBase));
1214
1251
  if (!manifestRes.ok) {
1215
1252
  throw new Error(`Failed to load manifest: ${manifestRes.status}`);
1216
1253
  }
1217
1254
  manifest = await manifestRes.json();
1218
1255
  }
1256
+ const pathBase = normalizePathBase(manifest.pathBase);
1219
1257
  const siteTitle = resolveSiteTitle(manifest);
1220
1258
  const defaultBranch = normalizeBranch(manifest.defaultBranch) || DEFAULT_BRANCH;
1221
1259
  const availableBranchSet = new Set([defaultBranch]);
@@ -1312,9 +1350,9 @@ async function start() {
1312
1350
 
1313
1351
  for (const node of view.tree) {
1314
1352
  if (node.type === "folder") {
1315
- treeRoot.appendChild(createFolderNode(node, state.expanded, treeFileRowsById));
1353
+ treeRoot.appendChild(createFolderNode(node, state.expanded, treeFileRowsById, pathBase));
1316
1354
  } else {
1317
- treeRoot.appendChild(createFileNode(node, treeFileRowsById));
1355
+ treeRoot.appendChild(createFileNode(node, treeFileRowsById, pathBase));
1318
1356
  }
1319
1357
  }
1320
1358
 
@@ -1333,7 +1371,7 @@ async function start() {
1333
1371
  backlinksEl.hidden = true;
1334
1372
  return;
1335
1373
  }
1336
- const html = renderBacklinks(doc);
1374
+ const html = renderBacklinks(doc, pathBase);
1337
1375
  backlinksEl.innerHTML = html;
1338
1376
  backlinksEl.hidden = html.length === 0;
1339
1377
  };
@@ -1377,7 +1415,7 @@ async function start() {
1377
1415
  markActive(treeFileRowsById, activeFileState, "");
1378
1416
  announceA11yStatus("탐색 실패: 요청한 문서를 찾을 수 없습니다.");
1379
1417
  if (push) {
1380
- history.pushState(null, "", toSafeUrlPath(route));
1418
+ history.pushState(null, "", toPathWithBase(route, pathBase));
1381
1419
  }
1382
1420
  return;
1383
1421
  }
@@ -1388,7 +1426,7 @@ async function start() {
1388
1426
  }
1389
1427
 
1390
1428
  if (push) {
1391
- history.pushState(null, "", toSafeUrlPath(route));
1429
+ history.pushState(null, "", toPathWithBase(route, pathBase));
1392
1430
  }
1393
1431
 
1394
1432
  state.currentDocId = id;
@@ -1406,7 +1444,7 @@ async function start() {
1406
1444
  titleEl.textContent = doc.title;
1407
1445
  metaEl.innerHTML = renderMeta(doc);
1408
1446
  updateBacklinks(doc);
1409
- navEl.innerHTML = renderNav(view.docs, view.docIndexById, id);
1447
+ navEl.innerHTML = renderNav(view.docs, view.docIndexById, id, pathBase);
1410
1448
  document.title = composeDocumentTitle(doc.title, siteTitle);
1411
1449
  if (viewerEl instanceof HTMLElement) {
1412
1450
  viewerEl.scrollTo(0, 0);
@@ -1419,7 +1457,7 @@ async function start() {
1419
1457
  titleEl.textContent = doc.title;
1420
1458
  metaEl.innerHTML = renderMeta(doc);
1421
1459
 
1422
- const res = await fetch(toSafeUrlPath(doc.contentUrl));
1460
+ const res = await fetch(toPathWithBase(doc.contentUrl, pathBase));
1423
1461
  if (!res.ok) {
1424
1462
  contentEl.innerHTML = '<p class="placeholder">본문을 불러오지 못했습니다.</p>';
1425
1463
  updateBacklinks(null);
@@ -1431,7 +1469,7 @@ async function start() {
1431
1469
  contentEl.innerHTML = await res.text();
1432
1470
 
1433
1471
  updateBacklinks(doc);
1434
- navEl.innerHTML = renderNav(view.docs, view.docIndexById, id);
1472
+ navEl.innerHTML = renderNav(view.docs, view.docIndexById, id, pathBase);
1435
1473
 
1436
1474
  document.title = composeDocumentTitle(doc.title, siteTitle);
1437
1475
  if (viewerEl instanceof HTMLElement) {
@@ -1594,7 +1632,7 @@ async function start() {
1594
1632
  updateBranchInfo();
1595
1633
  renderTree(state);
1596
1634
 
1597
- const currentRoute = resolveRouteFromLocation(view.routeMap);
1635
+ const currentRoute = resolveRouteFromLocation(view.routeMap, pathBase);
1598
1636
  if (view.routeMap[currentRoute]) {
1599
1637
  await state.navigate(currentRoute, false);
1600
1638
  return;
@@ -1608,20 +1646,25 @@ async function start() {
1608
1646
  updateBranchInfo();
1609
1647
  renderTree(state);
1610
1648
 
1611
- const currentRoute = resolveRouteFromLocation(view.routeMap);
1649
+ const currentRoute = resolveRouteFromLocation(view.routeMap, pathBase);
1612
1650
  const initialRoute = currentRoute === "/" ? pickHomeRoute(view) : currentRoute;
1613
1651
  handleLayoutChange();
1614
1652
  await state.navigate(initialRoute, currentRoute === "/" && initialRoute !== "/");
1615
1653
 
1616
1654
  window.addEventListener("popstate", async () => {
1617
- await state.navigate(resolveRouteFromLocation(view.routeMap), false);
1655
+ await state.navigate(resolveRouteFromLocation(view.routeMap, pathBase), false);
1618
1656
  });
1619
1657
  }
1620
1658
 
1621
1659
  start().catch((error) => {
1622
1660
  const contentEl = document.getElementById("viewer-content");
1623
1661
  if (contentEl) {
1624
- contentEl.innerHTML = `<p class="placeholder">초기화 실패: ${error.message}</p>`;
1662
+ const message = error instanceof Error ? error.message : String(error);
1663
+ contentEl.innerHTML = "";
1664
+ const placeholder = document.createElement("p");
1665
+ placeholder.className = "placeholder";
1666
+ placeholder.textContent = `초기화 실패: ${message}`;
1667
+ contentEl.appendChild(placeholder);
1625
1668
  }
1626
1669
  console.error(error);
1627
1670
  });
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: {