@limcpf/everything-is-a-markdown 0.3.0 → 0.4.0

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.md CHANGED
@@ -65,7 +65,7 @@ bun run blog [build|dev|clean] [options]
65
65
  - `--exclude <glob>`: 제외 패턴 (반복 가능, 기본 포함: `.obsidian/**`)
66
66
  - `--new-within-days <n>`: NEW 배지 기준 일수 (기본: `7`)
67
67
  - `--recent-limit <n>`: Recent 가상 폴더 노출 개수 (기본: `5`)
68
- - `--menu-config <path>`: 상단 고정 메뉴(JSON) 설정 파일 경로
68
+ - `--menu-config <path>`: 상단 고정 메뉴를 JSON 파일로 임시 덮어쓰기(선택)
69
69
  - `--port <n>`: dev 서버 포트 (기본: `3000`)
70
70
 
71
71
  **릴리즈 단일파일 배포 (GitHub Actions)**
@@ -106,22 +106,10 @@ bun run dev -- --port 4000
106
106
  # 제외 패턴 추가
107
107
  bun run build -- --exclude "private/**" --exclude "**/drafts/**"
108
108
 
109
- # 상단 고정 메뉴 추가
109
+ # 상단 고정 메뉴 임시 덮어쓰기(선택)
110
110
  bun run build -- --menu-config ./menu.config.json
111
111
  ```
112
112
 
113
- 고정 메뉴 설정 파일 예시 (`menu.config.json`)
114
- ```json
115
- {
116
- "pinnedMenu": {
117
- "label": "NOTICE",
118
- "sourceDir": "Log/(Blog)/Notice"
119
- }
120
- }
121
- ```
122
- - `label`: 탐색기에서 Recent 위에 표시할 고정 메뉴 이름
123
- - `sourceDir`: vault 기준 실제 물리 디렉터리 경로 (해당 하위 문서만 고정 메뉴에 노출)
124
-
125
113
  **설정 파일**
126
114
  프로젝트 루트에 `blog.config.ts|js|mjs|cjs`를 두면 CLI 옵션과 병합됩니다.
127
115
 
@@ -134,11 +122,25 @@ export default {
134
122
  seo: {
135
123
  siteUrl: "https://example.com", // origin only (http/https)
136
124
  pathBase: "/blog", // optional, deploy base path
125
+ siteName: "Everything-Is-A-Markdown", // optional, og:site_name / WebSite.name
126
+ defaultTitle: "Dev Knowledge Base", // optional, fallback title
127
+ defaultDescription: "Public docs and engineering notes.", // optional, fallback description
128
+ locale: "ko_KR", // optional, og:locale
129
+ twitterCard: "summary_large_image", // optional: "summary" | "summary_large_image"
130
+ twitterSite: "@my_team", // optional
131
+ twitterCreator: "@author_handle", // optional
132
+ defaultSocialImage: "/assets/social/default.png", // optional, absolute URL or /-relative
133
+ defaultOgImage: "/assets/social/og.png", // optional, overrides defaultSocialImage for og:image
134
+ defaultTwitterImage: "/assets/social/twitter.png", // optional, overrides defaultSocialImage for twitter:image
137
135
  },
138
136
  ui: {
139
137
  newWithinDays: 7,
140
138
  recentLimit: 5,
141
139
  },
140
+ pinnedMenu: {
141
+ label: "NOTICE",
142
+ sourceDir: "Log/(Blog)/Notice",
143
+ },
142
144
  markdown: {
143
145
  wikilinks: true,
144
146
  images: "omit-local", // "keep" | "omit-local"
@@ -150,9 +152,22 @@ export default {
150
152
  };
151
153
  ```
152
154
 
155
+ 고정 메뉴 설정 메모
156
+ - `pinnedMenu.label`: 탐색기에서 Recent 위에 표시할 이름 (미지정 시 `NOTICE`)
157
+ - `pinnedMenu.sourceDir`: vault 기준 실제 물리 디렉터리 경로
158
+ - `--menu-config`를 주면 `blog.config.*`의 `pinnedMenu`를 해당 실행에서만 덮어씁니다.
159
+
153
160
  SEO 설정 메모
154
161
  - `seo.siteUrl`: 필수. 절대 origin만 허용됩니다 (예: `https://example.com`, path/query/hash 불가).
155
162
  - `seo.pathBase`: 선택. `/blog` 같은 배포 base path를 canonical/OG/sitemap URL에 함께 붙입니다.
163
+ - `seo.siteName`: 선택. `og:site_name` 및 루트 JSON-LD(WebSite.name)에 반영됩니다.
164
+ - `seo.defaultTitle`: 선택. 문서 제목이 없을 때 fallback `<title>`로 사용됩니다.
165
+ - `seo.defaultDescription`: 선택. 문서 설명이 없을 때 fallback description/OG/Twitter 설명으로 사용됩니다.
166
+ - `seo.locale`: 선택. `og:locale` 값으로 출력됩니다 (예: `ko_KR`).
167
+ - `seo.twitterCard`: 선택. `summary` 또는 `summary_large_image`.
168
+ - `seo.twitterSite`, `seo.twitterCreator`: 선택. 각각 `twitter:site`, `twitter:creator`로 출력됩니다.
169
+ - `seo.defaultSocialImage`: 선택. OG/Twitter 공통 기본 이미지.
170
+ - `seo.defaultOgImage`, `seo.defaultTwitterImage`: 선택. 채널별 이미지 우선값(없으면 `defaultSocialImage` 사용).
156
171
  - `seo.siteUrl`이 없으면 `robots.txt`, `sitemap.xml`은 생성되지 않습니다.
157
172
 
158
173
  **콘텐츠 작성 규칙**
package/bun.lock CHANGED
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "lockfileVersion": 1,
3
+ "configVersion": 0,
3
4
  "workspaces": {
4
5
  "": {
5
6
  "name": "@limcpf/everything-is-a-markdown",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@limcpf/everything-is-a-markdown",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
package/src/build.ts CHANGED
@@ -3,9 +3,9 @@ import path from "node:path";
3
3
  import matter from "gray-matter";
4
4
  import type { BuildCache, BuildOptions, DocRecord, FileNode, FolderNode, Manifest, TreeNode, WikiResolver } from "./types";
5
5
  import { createMarkdownRenderer } from "./markdown";
6
- import { buildCanonicalUrl } from "./seo";
6
+ import { buildCanonicalUrl, escapeHtmlAttribute } from "./seo";
7
7
  import { render404Html, renderAppShellHtml } from "./template";
8
- import type { AppShellMeta } from "./template";
8
+ import type { AppShellAssets, AppShellInitialView, AppShellMeta } from "./template";
9
9
  import {
10
10
  buildExcluder,
11
11
  ensureDir,
@@ -35,6 +35,11 @@ interface OutputWriteContext {
35
35
  nextHashes: Record<string, string>;
36
36
  }
37
37
 
38
+ interface RuntimeAssets {
39
+ cssRelPath: string;
40
+ jsRelPath: string;
41
+ }
42
+
38
43
  interface WikiLookup {
39
44
  byPath: Map<string, DocRecord>;
40
45
  byStem: Map<string, DocRecord[]>;
@@ -393,9 +398,11 @@ function normalizeWikiTarget(input: string): string {
393
398
  function extractWikiTargets(markdown: string): string[] {
394
399
  const targets = new Set<string>();
395
400
  const re = /\[\[([^\]]+)\]\]/g;
396
- let match: RegExpExecArray | null;
397
-
398
- while ((match = re.exec(markdown)) !== null) {
401
+ while (true) {
402
+ const match = re.exec(markdown);
403
+ if (match === null) {
404
+ break;
405
+ }
399
406
  if (match.index > 0 && markdown.charAt(match.index - 1) === "!") {
400
407
  continue;
401
408
  }
@@ -410,7 +417,6 @@ function extractWikiTargets(markdown: string): string[] {
410
417
  if (!normalized) {
411
418
  continue;
412
419
  }
413
-
414
420
  targets.add(normalized);
415
421
  }
416
422
 
@@ -462,7 +468,7 @@ function toDocRecord(
462
468
  body: entry.body,
463
469
  rawHash: entry.rawHash,
464
470
  wikiTargets: entry.wikiTargets,
465
- isNew: entry.mtimeMs >= newThreshold,
471
+ isNew: isNewByFrontmatterDate(entry.date, newThreshold),
466
472
  branch: entry.branch,
467
473
  };
468
474
  }
@@ -621,6 +627,11 @@ function parseDateToEpochMs(value: string | undefined): number | null {
621
627
  return Number.isFinite(parsed) ? parsed : null;
622
628
  }
623
629
 
630
+ function isNewByFrontmatterDate(date: string | undefined, newThreshold: number): boolean {
631
+ const publishedAt = parseDateToEpochMs(date);
632
+ return publishedAt != null && publishedAt >= newThreshold;
633
+ }
634
+
624
635
  function getRecentSortEpochMs(doc: DocRecord): number {
625
636
  return parseDateToEpochMs(doc.date) ?? doc.mtimeMs;
626
637
  }
@@ -778,12 +789,6 @@ function pickSeoImageDefaults(
778
789
  return { social: null, og: null, twitter: null };
779
790
  }
780
791
 
781
- const raw = seo as BuildOptions["seo"] & {
782
- defaultSocialImage?: unknown;
783
- defaultOgImage?: unknown;
784
- defaultTwitterImage?: unknown;
785
- };
786
-
787
792
  const toAbsoluteImage = (value: unknown): string | null => {
788
793
  if (typeof value !== "string") {
789
794
  return null;
@@ -804,20 +809,21 @@ function pickSeoImageDefaults(
804
809
  };
805
810
 
806
811
  return {
807
- social: toAbsoluteImage(raw.defaultSocialImage),
808
- og: toAbsoluteImage(raw.defaultOgImage),
809
- twitter: toAbsoluteImage(raw.defaultTwitterImage),
812
+ social: toAbsoluteImage(seo.defaultSocialImage),
813
+ og: toAbsoluteImage(seo.defaultOgImage),
814
+ twitter: toAbsoluteImage(seo.defaultTwitterImage),
810
815
  };
811
816
  }
812
817
 
813
818
  function buildStructuredData(route: string, doc: DocRecord | null, options: BuildOptions): unknown[] {
814
819
  const canonicalUrl = options.seo ? buildCanonicalUrl(route, options.seo) : undefined;
820
+ const siteName = options.seo?.siteName ?? options.seo?.defaultTitle ?? DEFAULT_SITE_TITLE;
815
821
 
816
822
  if (!doc) {
817
823
  const websiteSchema: Record<string, string> = {
818
824
  "@context": "https://schema.org",
819
825
  "@type": "WebSite",
820
- name: DEFAULT_SITE_TITLE,
826
+ name: siteName,
821
827
  };
822
828
  if (canonicalUrl) {
823
829
  websiteSchema.url = canonicalUrl;
@@ -865,19 +871,54 @@ async function writeOutputIfChanged(
865
871
  await Bun.write(outputPath, content);
866
872
  }
867
873
 
868
- async function writeRuntimeAssets(context: OutputWriteContext): Promise<void> {
874
+ function toRelativeAssetPath(fromOutputPath: string, assetOutputPath: string): string {
875
+ const fromDir = path.posix.dirname(fromOutputPath);
876
+ const relative = path.posix.relative(fromDir, assetOutputPath);
877
+ return relative.length > 0 ? relative : path.posix.basename(assetOutputPath);
878
+ }
879
+
880
+ function buildAppShellAssetsForOutput(outputPath: string, runtimeAssets: RuntimeAssets): AppShellAssets {
881
+ return {
882
+ cssHref: toRelativeAssetPath(outputPath, runtimeAssets.cssRelPath),
883
+ jsSrc: toRelativeAssetPath(outputPath, runtimeAssets.jsRelPath),
884
+ };
885
+ }
886
+
887
+ async function writeRuntimeAssets(context: OutputWriteContext): Promise<RuntimeAssets> {
869
888
  const runtimeDir = path.join(import.meta.dir, "runtime");
870
889
  const runtimeJs = await Bun.file(path.join(runtimeDir, "app.js")).text();
871
890
  const runtimeCss = await Bun.file(path.join(runtimeDir, "app.css")).text();
872
891
 
873
- await writeOutputIfChanged(context, "assets/app.js", runtimeJs);
874
- await writeOutputIfChanged(context, "assets/app.css", runtimeCss);
892
+ const jsRelPath = `assets/app.${makeHash(runtimeJs).slice(0, 12)}.js`;
893
+ const cssRelPath = `assets/app.${makeHash(runtimeCss).slice(0, 12)}.css`;
894
+
895
+ for (const previousPath of Object.keys(context.previousHashes)) {
896
+ const isLegacyRuntimeAsset =
897
+ previousPath.startsWith("assets/app") &&
898
+ (previousPath.endsWith(".js") || previousPath.endsWith(".css")) &&
899
+ previousPath !== jsRelPath &&
900
+ previousPath !== cssRelPath;
901
+ if (!isLegacyRuntimeAsset) {
902
+ continue;
903
+ }
904
+ await removeFileIfExists(path.join(context.outDir, previousPath));
905
+ }
906
+
907
+ await writeOutputIfChanged(context, jsRelPath, runtimeJs);
908
+ await writeOutputIfChanged(context, cssRelPath, runtimeCss);
909
+
910
+ return {
911
+ cssRelPath,
912
+ jsRelPath,
913
+ };
875
914
  }
876
915
 
877
916
  function buildShellMeta(route: string, doc: DocRecord | null, options: BuildOptions): AppShellMeta {
917
+ const defaultTitle = options.seo?.defaultTitle ?? DEFAULT_SITE_TITLE;
918
+ const defaultDescription = options.seo?.defaultDescription ?? DEFAULT_SITE_DESCRIPTION;
878
919
  const description = typeof doc?.description === "string" && doc.description.trim().length > 0 ? doc.description.trim() : undefined;
879
920
  const canonicalUrl = options.seo ? buildCanonicalUrl(route, options.seo) : undefined;
880
- const title = doc?.title ?? DEFAULT_SITE_TITLE;
921
+ const title = doc?.title ?? defaultTitle;
881
922
  const imageDefaults = pickSeoImageDefaults(options.seo);
882
923
  const ogImage = imageDefaults.og ?? imageDefaults.social ?? undefined;
883
924
  const twitterImage = imageDefaults.twitter ?? imageDefaults.social ?? undefined;
@@ -888,28 +929,150 @@ function buildShellMeta(route: string, doc: DocRecord | null, options: BuildOpti
888
929
  canonicalUrl,
889
930
  ogTitle: title,
890
931
  ogType: doc ? "article" : "website",
932
+ ogSiteName: options.seo?.siteName,
933
+ ogLocale: options.seo?.locale,
891
934
  ogUrl: canonicalUrl,
892
- ogDescription: description ?? DEFAULT_SITE_DESCRIPTION,
893
- twitterCard: "summary",
935
+ ogDescription: description ?? defaultDescription,
936
+ twitterCard: options.seo?.twitterCard ?? "summary",
894
937
  twitterTitle: title,
895
- twitterDescription: description ?? DEFAULT_SITE_DESCRIPTION,
938
+ twitterDescription: description ?? defaultDescription,
939
+ twitterSite: options.seo?.twitterSite,
940
+ twitterCreator: options.seo?.twitterCreator,
896
941
  ogImage,
897
942
  twitterImage,
898
943
  jsonLd: buildStructuredData(route, doc, options),
899
944
  };
900
945
  }
901
946
 
902
- async function writeShellPages(context: OutputWriteContext, docs: DocRecord[], options: BuildOptions): Promise<void> {
903
- const shell = renderAppShellHtml(buildShellMeta("/", null, options));
947
+ function renderInitialBreadcrumb(route: string): string {
948
+ const parts = route.split("/").filter(Boolean);
949
+ const allItems = ["~", ...parts];
950
+ return allItems
951
+ .map((part, index) => {
952
+ const isCurrent = index === allItems.length - 1 && allItems.length > 1;
953
+ const escapedPart = escapeHtmlAttribute(part);
954
+ if (isCurrent) {
955
+ return `<span class="breadcrumb-current" aria-current="page">${escapedPart}</span>`;
956
+ }
957
+ return `<span class="breadcrumb-item">${escapedPart}</span>`;
958
+ })
959
+ .join('<span class="material-symbols-outlined breadcrumb-sep">chevron_right</span>');
960
+ }
961
+
962
+ function formatMetaDateTime(value: string | undefined): string | null {
963
+ if (!value) {
964
+ return null;
965
+ }
966
+
967
+ const parsed = new Date(value);
968
+ if (!Number.isFinite(parsed.getTime())) {
969
+ return null;
970
+ }
971
+
972
+ const yyyy = parsed.getFullYear();
973
+ const mm = String(parsed.getMonth() + 1).padStart(2, "0");
974
+ const dd = String(parsed.getDate()).padStart(2, "0");
975
+ const hh = String(parsed.getHours()).padStart(2, "0");
976
+ const mi = String(parsed.getMinutes()).padStart(2, "0");
977
+ return `${yyyy}-${mm}-${dd} ${hh}:${mi}`;
978
+ }
979
+
980
+ function normalizeTags(tags: string[]): string[] {
981
+ return tags.map((tag) => String(tag).trim().replace(/^#+/, "")).filter(Boolean);
982
+ }
983
+
984
+ function renderInitialMeta(doc: DocRecord): string {
985
+ const items: string[] = [];
986
+
987
+ const createdAt = formatMetaDateTime(doc.date);
988
+ if (createdAt) {
989
+ items.push(
990
+ `<span class="meta-item"><span class="material-symbols-outlined">calendar_today</span>${escapeHtmlAttribute(createdAt)}</span>`,
991
+ );
992
+ }
993
+
994
+ const updatedAt = formatMetaDateTime(doc.updatedDate);
995
+ if (updatedAt) {
996
+ items.push(
997
+ `<span class="meta-item"><span class="material-symbols-outlined">schedule</span>updated ${escapeHtmlAttribute(updatedAt)}</span>`,
998
+ );
999
+ }
1000
+
1001
+ const tags = normalizeTags(doc.tags);
1002
+ if (tags.length > 0) {
1003
+ const tagsStr = tags.map((tag) => `#${escapeHtmlAttribute(tag)}`).join(" ");
1004
+ items.push(`<span class="meta-item meta-tags">${tagsStr}</span>`);
1005
+ }
1006
+
1007
+ return items.join("");
1008
+ }
1009
+
1010
+ function renderInitialNav(docs: DocRecord[], currentId: string): string {
1011
+ const currentIndex = docs.findIndex((doc) => doc.id === currentId);
1012
+ if (currentIndex === -1) {
1013
+ return "";
1014
+ }
1015
+
1016
+ const prev = currentIndex > 0 ? docs[currentIndex - 1] : null;
1017
+ const next = currentIndex < docs.length - 1 ? docs[currentIndex + 1] : null;
1018
+
1019
+ let html = "";
1020
+ if (prev) {
1021
+ 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>`;
1022
+ }
1023
+ if (next) {
1024
+ 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>`;
1025
+ }
1026
+
1027
+ return html;
1028
+ }
1029
+
1030
+ function buildInitialView(doc: DocRecord, docs: DocRecord[], contentHtml: string): AppShellInitialView {
1031
+ return {
1032
+ route: doc.route,
1033
+ docId: doc.id,
1034
+ title: doc.title,
1035
+ breadcrumbHtml: renderInitialBreadcrumb(doc.route),
1036
+ metaHtml: renderInitialMeta(doc),
1037
+ contentHtml,
1038
+ navHtml: renderInitialNav(docs, doc.id),
1039
+ };
1040
+ }
1041
+
1042
+ async function writeShellPages(
1043
+ context: OutputWriteContext,
1044
+ docs: DocRecord[],
1045
+ options: BuildOptions,
1046
+ runtimeAssets: RuntimeAssets,
1047
+ contentByDocId: Map<string, string>,
1048
+ ): Promise<void> {
1049
+ const indexDoc = docs[0] ?? null;
1050
+ const indexOutputPath = "index.html";
1051
+ const indexInitialView = indexDoc ? buildInitialView(indexDoc, docs, contentByDocId.get(indexDoc.id) ?? "") : null;
1052
+ const shell = renderAppShellHtml(
1053
+ buildShellMeta("/", null, options),
1054
+ buildAppShellAssetsForOutput(indexOutputPath, runtimeAssets),
1055
+ indexInitialView,
1056
+ );
904
1057
  await writeOutputIfChanged(context, "_app/index.html", shell);
905
- await writeOutputIfChanged(context, "index.html", shell);
906
- await writeOutputIfChanged(context, "404.html", render404Html());
1058
+ await writeOutputIfChanged(context, indexOutputPath, shell);
1059
+ await writeOutputIfChanged(
1060
+ context,
1061
+ "404.html",
1062
+ render404Html(buildAppShellAssetsForOutput("404.html", runtimeAssets)),
1063
+ );
907
1064
 
908
1065
  for (const doc of docs) {
1066
+ const routeOutputPath = toRouteOutputPath(doc.route);
1067
+ const initialView = buildInitialView(doc, docs, contentByDocId.get(doc.id) ?? "");
909
1068
  await writeOutputIfChanged(
910
1069
  context,
911
- toRouteOutputPath(doc.route),
912
- renderAppShellHtml(buildShellMeta(doc.route, doc, options)),
1070
+ routeOutputPath,
1071
+ renderAppShellHtml(
1072
+ buildShellMeta(doc.route, doc, options),
1073
+ buildAppShellAssetsForOutput(routeOutputPath, runtimeAssets),
1074
+ initialView,
1075
+ ),
913
1076
  );
914
1077
  }
915
1078
  }
@@ -1023,17 +1186,15 @@ export async function buildSite(options: BuildOptions): Promise<BuildResult> {
1023
1186
  previousHashes: previousOutputHashes,
1024
1187
  nextHashes: {},
1025
1188
  };
1026
- await writeRuntimeAssets(outputContext);
1189
+ const runtimeAssets = await writeRuntimeAssets(outputContext);
1027
1190
 
1028
1191
  const tree = buildTree(docs, options);
1029
1192
  const manifest = buildManifest(docs, tree, options);
1030
1193
  await writeOutputIfChanged(outputContext, "manifest.json", `${JSON.stringify(manifest, null, 2)}\n`);
1031
1194
 
1032
- await writeShellPages(outputContext, docs, options);
1033
- await writeSeoArtifacts(outputContext, docs, options);
1034
-
1035
1195
  const markdownRenderer = await createMarkdownRenderer(options);
1036
1196
  const wikiLookup = createWikiLookup(docs);
1197
+ const contentByDocId = new Map<string, string>();
1037
1198
 
1038
1199
  let renderedDocs = 0;
1039
1200
  let skippedDocs = 0;
@@ -1071,6 +1232,14 @@ export async function buildSite(options: BuildOptions): Promise<BuildResult> {
1071
1232
 
1072
1233
  if (unchanged) {
1073
1234
  skippedDocs += 1;
1235
+ const outputFile = Bun.file(outputPath);
1236
+ if (await outputFile.exists()) {
1237
+ contentByDocId.set(doc.id, await outputFile.text());
1238
+ } else {
1239
+ const resolver = createWikiResolver(wikiLookup, doc);
1240
+ const renderResult = await markdownRenderer.render(doc.body, resolver);
1241
+ contentByDocId.set(doc.id, renderResult.html);
1242
+ }
1074
1243
  continue;
1075
1244
  }
1076
1245
 
@@ -1083,9 +1252,13 @@ export async function buildSite(options: BuildOptions): Promise<BuildResult> {
1083
1252
  }
1084
1253
 
1085
1254
  await Bun.write(outputPath, renderResult.html);
1255
+ contentByDocId.set(doc.id, renderResult.html);
1086
1256
  renderedDocs += 1;
1087
1257
  }
1088
1258
 
1259
+ await writeShellPages(outputContext, docs, options, runtimeAssets, contentByDocId);
1260
+ await writeSeoArtifacts(outputContext, docs, options);
1261
+
1089
1262
  await writeCache(cachePath, nextCache);
1090
1263
 
1091
1264
  return {
package/src/config.ts CHANGED
@@ -96,12 +96,12 @@ export async function loadUserConfig(cwd = process.cwd()): Promise<UserConfig> {
96
96
  return {};
97
97
  }
98
98
 
99
- function normalizePinnedMenu(raw: unknown): PinnedMenuOption | null {
99
+ function normalizePinnedMenu(raw: unknown, errorPrefix = "[config]"): PinnedMenuOption | null {
100
100
  if (raw == null) {
101
101
  return null;
102
102
  }
103
103
  if (typeof raw !== "object") {
104
- throw new Error(`[menu-config] "pinnedMenu" must be an object`);
104
+ throw new Error(`${errorPrefix} "pinnedMenu" must be an object`);
105
105
  }
106
106
 
107
107
  const menu = raw as Record<string, unknown>;
@@ -109,7 +109,7 @@ function normalizePinnedMenu(raw: unknown): PinnedMenuOption | null {
109
109
  const labelRaw = menu.label;
110
110
 
111
111
  if (typeof sourceDirRaw !== "string" || sourceDirRaw.trim().length === 0) {
112
- throw new Error(`[menu-config] "pinnedMenu.sourceDir" must be a non-empty string`);
112
+ throw new Error(`${errorPrefix} "pinnedMenu.sourceDir" must be a non-empty string`);
113
113
  }
114
114
 
115
115
  const normalizedSourceDir = sourceDirRaw
@@ -118,7 +118,7 @@ function normalizePinnedMenu(raw: unknown): PinnedMenuOption | null {
118
118
  .replace(/^\/+/, "")
119
119
  .replace(/\/+$/, "");
120
120
  if (!normalizedSourceDir) {
121
- throw new Error(`[menu-config] "pinnedMenu.sourceDir" must not be root`);
121
+ throw new Error(`${errorPrefix} "pinnedMenu.sourceDir" must not be root`);
122
122
  }
123
123
 
124
124
  const label =
@@ -157,7 +157,7 @@ export async function loadPinnedMenuConfig(
157
157
  throw new Error("[menu-config] top-level JSON must be an object");
158
158
  }
159
159
 
160
- return normalizePinnedMenu((parsed as Record<string, unknown>).pinnedMenu);
160
+ return normalizePinnedMenu((parsed as Record<string, unknown>).pinnedMenu, "[menu-config]");
161
161
  }
162
162
 
163
163
  export function resolveBuildOptions(
@@ -170,6 +170,8 @@ export function resolveBuildOptions(
170
170
  const cliExclude = cli.exclude ?? [];
171
171
  const mergedExclude = Array.from(new Set([...DEFAULTS.exclude, ...cfgExclude, ...cliExclude]));
172
172
  const seo = normalizeSeoConfig(userConfig.seo);
173
+ const configPinnedMenu = normalizePinnedMenu(userConfig.pinnedMenu, "[config]");
174
+ const resolvedPinnedMenu = pinnedMenu ?? configPinnedMenu;
173
175
 
174
176
  return {
175
177
  vaultDir: path.resolve(cwd, cli.vaultDir ?? userConfig.vaultDir ?? DEFAULTS.vaultDir),
@@ -177,7 +179,7 @@ export function resolveBuildOptions(
177
179
  exclude: mergedExclude,
178
180
  newWithinDays: cli.newWithinDays ?? userConfig.ui?.newWithinDays ?? DEFAULTS.newWithinDays,
179
181
  recentLimit: cli.recentLimit ?? userConfig.ui?.recentLimit ?? DEFAULTS.recentLimit,
180
- pinnedMenu,
182
+ pinnedMenu: resolvedPinnedMenu,
181
183
  wikilinks: userConfig.markdown?.wikilinks ?? DEFAULTS.wikilinks,
182
184
  imagePolicy: userConfig.markdown?.images ?? DEFAULTS.imagePolicy,
183
185
  gfm: userConfig.markdown?.gfm ?? DEFAULTS.gfm,
@@ -199,7 +201,7 @@ Options:
199
201
  --exclude <glob> Exclude glob pattern (repeatable)
200
202
  --new-within-days <n> NEW badge threshold days (default: 7)
201
203
  --recent-limit <n> Recent virtual folder item count (default: 5)
202
- --menu-config <path> JSON file path for pinned menu config
204
+ --menu-config <path> JSON file path to override pinnedMenu (optional)
203
205
  --port <n> Dev server port (default: 3000)
204
206
  -h, --help Show help
205
207
  `);
package/src/markdown.ts CHANGED
@@ -16,6 +16,13 @@ export interface MarkdownRenderer {
16
16
  }
17
17
 
18
18
  const FENCE_LANG_RE = /^```([\w-+#.]+)/gm;
19
+ type RenderRule = NonNullable<MarkdownIt["renderer"]["rules"]["fence"]>;
20
+ type RenderRuleArgs = Parameters<RenderRule>;
21
+ type RuleTokens = RenderRuleArgs[0];
22
+ type RuleOptions = RenderRuleArgs[2];
23
+ type RuleEnv = RenderRuleArgs[3];
24
+ type RuleSelf = RenderRuleArgs[4];
25
+ type LinkOpenRule = NonNullable<MarkdownIt["renderer"]["rules"]["link_open"]>;
19
26
 
20
27
  function escapeMarkdownLabel(input: string): string {
21
28
  return input.replace(/[\[\]]/g, "");
@@ -87,13 +94,18 @@ function preprocessMarkdown(
87
94
  return { markdown: output, warnings };
88
95
  }
89
96
 
90
- type Highlighter = HighlighterGeneric<string, string>;
91
-
92
- async function loadFenceLanguages(highlighter: Highlighter, loaded: Set<string>, markdown: string): Promise<void> {
97
+ async function loadFenceLanguages<L extends string, T extends string>(
98
+ highlighter: HighlighterGeneric<L, T>,
99
+ loaded: Set<string>,
100
+ markdown: string,
101
+ ): Promise<void> {
93
102
  const langs = new Set<string>();
94
103
  FENCE_LANG_RE.lastIndex = 0;
95
- let match: RegExpExecArray | null;
96
- while ((match = FENCE_LANG_RE.exec(markdown)) !== null) {
104
+ while (true) {
105
+ const match = FENCE_LANG_RE.exec(markdown);
106
+ if (match === null) {
107
+ break;
108
+ }
97
109
  if (match[1]) {
98
110
  langs.add(match[1].toLowerCase());
99
111
  }
@@ -104,7 +116,7 @@ async function loadFenceLanguages(highlighter: Highlighter, loaded: Set<string>,
104
116
  continue;
105
117
  }
106
118
  try {
107
- await highlighter.loadLanguage(lang as never);
119
+ await highlighter.loadLanguage(lang as L);
108
120
  loaded.add(lang);
109
121
  } catch {
110
122
  // Unknown language: fallback to plaintext in fence renderer.
@@ -116,7 +128,11 @@ function escapeHtmlAttr(input: string): string {
116
128
  return input.replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
117
129
  }
118
130
 
119
- function createMarkdownIt(highlighter: Highlighter, theme: string, gfm: boolean): MarkdownIt {
131
+ function createMarkdownIt<L extends string, T extends string>(
132
+ highlighter: HighlighterGeneric<L, T>,
133
+ theme: string,
134
+ gfm: boolean,
135
+ ): MarkdownIt {
120
136
  const md = new MarkdownIt({
121
137
  // Allow raw HTML in markdown (e.g. <img ... />).
122
138
  html: true,
@@ -129,7 +145,7 @@ function createMarkdownIt(highlighter: Highlighter, theme: string, gfm: boolean)
129
145
  md.disable(["table", "strikethrough"]);
130
146
  }
131
147
 
132
- md.renderer.rules.fence = (tokens: any[], idx: number) => {
148
+ const fenceRule: RenderRule = (tokens: RuleTokens, idx: number, _options: RuleOptions, _env: RuleEnv, _self: RuleSelf) => {
133
149
  const token = tokens[idx];
134
150
  const info = token.info.trim();
135
151
  const parts = info.split(/\s+/);
@@ -139,12 +155,12 @@ function createMarkdownIt(highlighter: Highlighter, theme: string, gfm: boolean)
139
155
  let codeHtml: string;
140
156
  try {
141
157
  codeHtml = highlighter.codeToHtml(token.content, {
142
- lang: lang || "text",
158
+ lang: (lang || "text") as never,
143
159
  theme,
144
160
  });
145
161
  } catch {
146
162
  codeHtml = highlighter.codeToHtml(token.content, {
147
- lang: "text",
163
+ lang: "text" as never,
148
164
  theme,
149
165
  });
150
166
  }
@@ -163,9 +179,16 @@ function createMarkdownIt(highlighter: Highlighter, theme: string, gfm: boolean)
163
179
 
164
180
  return `<div class="code-block">${header}${codeHtml}</div>`;
165
181
  };
166
-
167
- const defaultLinkOpen = md.renderer.rules.link_open;
168
- md.renderer.rules.link_open = (tokens: any[], idx: number, options: any, env: any, self: any) => {
182
+ md.renderer.rules.fence = fenceRule;
183
+
184
+ const defaultLinkOpen = md.renderer.rules.link_open as LinkOpenRule | undefined;
185
+ const linkOpenRule: LinkOpenRule = (
186
+ tokens: RuleTokens,
187
+ idx: number,
188
+ options: RuleOptions,
189
+ env: RuleEnv,
190
+ self: RuleSelf,
191
+ ) => {
169
192
  const hrefIdx = tokens[idx].attrIndex("href");
170
193
  if (hrefIdx >= 0) {
171
194
  const href = tokens[idx].attrs?.[hrefIdx]?.[1] ?? "";
@@ -180,6 +203,7 @@ function createMarkdownIt(highlighter: Highlighter, theme: string, gfm: boolean)
180
203
  }
181
204
  return self.renderToken(tokens, idx, options);
182
205
  };
206
+ md.renderer.rules.link_open = linkOpenRule;
183
207
 
184
208
  return md;
185
209
  }
@@ -192,6 +192,12 @@ a:hover {
192
192
  }
193
193
 
194
194
  .material-symbols-outlined {
195
+ display: inline-flex;
196
+ align-items: center;
197
+ justify-content: center;
198
+ width: 1em;
199
+ height: 1em;
200
+ flex: 0 0 auto;
195
201
  font-size: 20px;
196
202
  line-height: 1;
197
203
  vertical-align: middle;
@@ -754,6 +760,7 @@ body.mobile-toggle-left .mobile-menu-toggle {
754
760
  overflow-x: auto;
755
761
  overflow-y: hidden;
756
762
  white-space: nowrap;
763
+ min-height: 24px;
757
764
  }
758
765
 
759
766
  .breadcrumb-sep {
@@ -793,6 +800,7 @@ body.mobile-toggle-left .mobile-menu-toggle {
793
800
  /* Viewer header */
794
801
  .viewer-header {
795
802
  margin-bottom: 32px;
803
+ min-height: 88px;
796
804
  }
797
805
 
798
806
  .viewer-title {
@@ -808,6 +816,7 @@ body.mobile-toggle-left .mobile-menu-toggle {
808
816
  display: flex;
809
817
  align-items: center;
810
818
  gap: 16px;
819
+ min-height: 22px;
811
820
  font-size: 0.85rem;
812
821
  font-family: "JetBrains Mono", monospace;
813
822
  color: var(--latte-subtext0);
@@ -1064,6 +1073,7 @@ body.mobile-toggle-left .mobile-menu-toggle {
1064
1073
 
1065
1074
  /* Images */
1066
1075
  .viewer-content img {
1076
+ display: block;
1067
1077
  max-width: 100%;
1068
1078
  height: auto;
1069
1079
  border-radius: 8px;
@@ -47,6 +47,41 @@ function normalizeRoute(pathname) {
47
47
  return route;
48
48
  }
49
49
 
50
+ function loadInitialViewData() {
51
+ const script = document.getElementById("initial-view-data");
52
+ if (!(script instanceof HTMLScriptElement)) {
53
+ return null;
54
+ }
55
+
56
+ const raw = script.textContent;
57
+ if (!raw) {
58
+ return null;
59
+ }
60
+
61
+ try {
62
+ const parsed = JSON.parse(raw);
63
+ if (!parsed || typeof parsed !== "object") {
64
+ return null;
65
+ }
66
+
67
+ const route = typeof parsed.route === "string" ? normalizeRoute(parsed.route) : null;
68
+ const docId = typeof parsed.docId === "string" ? parsed.docId : null;
69
+ const title = typeof parsed.title === "string" ? parsed.title : null;
70
+
71
+ if (!route || !docId || !title) {
72
+ return null;
73
+ }
74
+
75
+ return {
76
+ route,
77
+ docId,
78
+ title,
79
+ };
80
+ } catch {
81
+ return null;
82
+ }
83
+ }
84
+
50
85
  function resolveRouteFromLocation(routeMap) {
51
86
  const direct = normalizeRoute(location.pathname);
52
87
  if (routeMap[direct]) {
@@ -690,6 +725,8 @@ async function start() {
690
725
  const navEl = document.getElementById("viewer-nav");
691
726
  const a11yStatusEl = document.getElementById("a11y-status");
692
727
  const viewerEl = document.querySelector(".viewer");
728
+ const initialViewData = loadInitialViewData();
729
+ let hasHydratedInitialView = false;
693
730
 
694
731
  let hideTreeTooltip = () => {};
695
732
  let disposeTreeTooltip = () => {};
@@ -1155,7 +1192,7 @@ async function start() {
1155
1192
 
1156
1193
  const state = {
1157
1194
  expanded,
1158
- currentDocId: "",
1195
+ currentDocId: initialViewData?.docId ?? "",
1159
1196
  async navigate(rawRoute, push) {
1160
1197
  hideTreeTooltip();
1161
1198
 
@@ -1206,6 +1243,23 @@ async function start() {
1206
1243
 
1207
1244
  state.currentDocId = id;
1208
1245
  markActive(treeFileRowsById, activeFileState, id);
1246
+
1247
+ const shouldUseInitialView =
1248
+ !hasHydratedInitialView &&
1249
+ initialViewData &&
1250
+ initialViewData.docId === id &&
1251
+ initialViewData.route === route;
1252
+
1253
+ if (shouldUseInitialView) {
1254
+ hasHydratedInitialView = true;
1255
+ document.title = `${initialViewData.title} - File-System Blog`;
1256
+ if (viewerEl instanceof HTMLElement) {
1257
+ viewerEl.scrollTo(0, 0);
1258
+ }
1259
+ announceA11yStatus(`탐색 완료: ${doc.title} 문서를 열었습니다.`);
1260
+ return;
1261
+ }
1262
+
1209
1263
  breadcrumbEl.innerHTML = renderBreadcrumb(route);
1210
1264
  titleEl.textContent = doc.title;
1211
1265
  metaEl.innerHTML = renderMeta(doc);
package/src/seo.ts CHANGED
@@ -57,9 +57,35 @@ export function normalizeSeoConfig(raw: UserSeoConfig | undefined): BuildSeoOpti
57
57
  throw new Error('[config] "seo.pathBase" must be a string when provided (for example: "/blog")');
58
58
  }
59
59
 
60
+ const normalizeOptionalString = (value: unknown, key: string): string | undefined => {
61
+ if (value == null) {
62
+ return undefined;
63
+ }
64
+ if (typeof value !== "string") {
65
+ throw new Error(`[config] "seo.${key}" must be a string when provided`);
66
+ }
67
+ const trimmed = value.trim();
68
+ return trimmed.length > 0 ? trimmed : undefined;
69
+ };
70
+
71
+ const twitterCardRaw = raw.twitterCard;
72
+ if (twitterCardRaw != null && twitterCardRaw !== "summary" && twitterCardRaw !== "summary_large_image") {
73
+ throw new Error('[config] "seo.twitterCard" must be "summary" or "summary_large_image" when provided');
74
+ }
75
+
60
76
  return {
61
77
  siteUrl: parsed.origin,
62
78
  pathBase: normalizePathBase(pathBaseRaw ?? ""),
79
+ siteName: normalizeOptionalString(raw.siteName, "siteName"),
80
+ defaultTitle: normalizeOptionalString(raw.defaultTitle, "defaultTitle"),
81
+ defaultDescription: normalizeOptionalString(raw.defaultDescription, "defaultDescription"),
82
+ locale: normalizeOptionalString(raw.locale, "locale"),
83
+ twitterCard: twitterCardRaw,
84
+ twitterSite: normalizeOptionalString(raw.twitterSite, "twitterSite"),
85
+ twitterCreator: normalizeOptionalString(raw.twitterCreator, "twitterCreator"),
86
+ defaultSocialImage: normalizeOptionalString(raw.defaultSocialImage, "defaultSocialImage"),
87
+ defaultOgImage: normalizeOptionalString(raw.defaultOgImage, "defaultOgImage"),
88
+ defaultTwitterImage: normalizeOptionalString(raw.defaultTwitterImage, "defaultTwitterImage"),
63
89
  };
64
90
  }
65
91
 
package/src/template.ts CHANGED
@@ -9,6 +9,8 @@ export interface AppShellMeta {
9
9
  canonicalUrl?: string;
10
10
  ogTitle?: string;
11
11
  ogType?: string;
12
+ ogSiteName?: string;
13
+ ogLocale?: string;
12
14
  ogUrl?: string;
13
15
  ogDescription?: string;
14
16
  ogImage?: string;
@@ -16,9 +18,37 @@ export interface AppShellMeta {
16
18
  twitterTitle?: string;
17
19
  twitterDescription?: string;
18
20
  twitterImage?: string;
21
+ twitterSite?: string;
22
+ twitterCreator?: string;
19
23
  jsonLd?: unknown | unknown[];
20
24
  }
21
25
 
26
+ export interface AppShellAssets {
27
+ cssHref: string;
28
+ jsSrc: string;
29
+ }
30
+
31
+ export interface AppShellInitialView {
32
+ route: string;
33
+ docId: string;
34
+ title: string;
35
+ breadcrumbHtml: string;
36
+ metaHtml: string;
37
+ contentHtml: string;
38
+ navHtml: string;
39
+ }
40
+
41
+ interface AppShellInitialViewPayload {
42
+ route: string;
43
+ docId: string;
44
+ title: string;
45
+ }
46
+
47
+ const DEFAULT_ASSETS: AppShellAssets = {
48
+ cssHref: "/assets/app.css",
49
+ jsSrc: "/assets/app.js",
50
+ };
51
+
22
52
  function normalizeJsonLd(value: unknown | unknown[] | undefined): unknown[] {
23
53
  if (Array.isArray(value)) {
24
54
  return value.filter((item) => item != null);
@@ -36,10 +66,13 @@ function stringifyJsonLd(value: unknown): string {
36
66
  function renderHeadMeta(meta: AppShellMeta): string {
37
67
  const title = (meta.title ?? DEFAULT_TITLE).trim() || DEFAULT_TITLE;
38
68
  const description = typeof meta.description === "string" ? meta.description.trim() : "";
69
+ const fallbackDescription = description || DEFAULT_DESCRIPTION;
39
70
  const canonicalUrl = typeof meta.canonicalUrl === "string" ? meta.canonicalUrl.trim() : "";
40
71
 
41
72
  const ogTitle = (meta.ogTitle ?? title).trim() || title;
42
73
  const ogType = (meta.ogType ?? "website").trim() || "website";
74
+ const ogSiteName = typeof meta.ogSiteName === "string" ? meta.ogSiteName.trim() : "";
75
+ const ogLocale = typeof meta.ogLocale === "string" ? meta.ogLocale.trim() : "";
43
76
  const ogUrl = typeof meta.ogUrl === "string" ? meta.ogUrl.trim() : "";
44
77
  const ogDescription = (meta.ogDescription ?? (description || DEFAULT_DESCRIPTION)).trim() || DEFAULT_DESCRIPTION;
45
78
  const ogImage = typeof meta.ogImage === "string" ? meta.ogImage.trim() : "";
@@ -48,13 +81,13 @@ function renderHeadMeta(meta: AppShellMeta): string {
48
81
  const twitterTitle = (meta.twitterTitle ?? title).trim() || title;
49
82
  const twitterDescription = (meta.twitterDescription ?? (description || DEFAULT_DESCRIPTION)).trim() || DEFAULT_DESCRIPTION;
50
83
  const twitterImage = typeof meta.twitterImage === "string" ? meta.twitterImage.trim() : "";
84
+ const twitterSite = typeof meta.twitterSite === "string" ? meta.twitterSite.trim() : "";
85
+ const twitterCreator = typeof meta.twitterCreator === "string" ? meta.twitterCreator.trim() : "";
51
86
  const jsonLd = normalizeJsonLd(meta.jsonLd);
52
87
 
53
88
  const headTags: string[] = [` <title>${escapeHtmlAttribute(title)}</title>`];
54
89
 
55
- if (description) {
56
- headTags.push(` <meta name="description" content="${escapeHtmlAttribute(description)}" />`);
57
- }
90
+ headTags.push(` <meta name="description" content="${escapeHtmlAttribute(fallbackDescription)}" />`);
58
91
 
59
92
  if (canonicalUrl) {
60
93
  headTags.push(` <link rel="canonical" href="${escapeHtmlAttribute(canonicalUrl)}" />`);
@@ -67,6 +100,14 @@ function renderHeadMeta(meta: AppShellMeta): string {
67
100
  headTags.push(` <meta property="og:url" content="${escapeHtmlAttribute(ogUrl)}" />`);
68
101
  }
69
102
 
103
+ if (ogSiteName) {
104
+ headTags.push(` <meta property="og:site_name" content="${escapeHtmlAttribute(ogSiteName)}" />`);
105
+ }
106
+
107
+ if (ogLocale) {
108
+ headTags.push(` <meta property="og:locale" content="${escapeHtmlAttribute(ogLocale)}" />`);
109
+ }
110
+
70
111
  headTags.push(` <meta property="og:description" content="${escapeHtmlAttribute(ogDescription)}" />`);
71
112
 
72
113
  if (ogImage) {
@@ -81,6 +122,14 @@ function renderHeadMeta(meta: AppShellMeta): string {
81
122
  headTags.push(` <meta name="twitter:image" content="${escapeHtmlAttribute(twitterImage)}" />`);
82
123
  }
83
124
 
125
+ if (twitterSite) {
126
+ headTags.push(` <meta name="twitter:site" content="${escapeHtmlAttribute(twitterSite)}" />`);
127
+ }
128
+
129
+ if (twitterCreator) {
130
+ headTags.push(` <meta name="twitter:creator" content="${escapeHtmlAttribute(twitterCreator)}" />`);
131
+ }
132
+
84
133
  for (const schema of jsonLd) {
85
134
  headTags.push(` <script type="application/ld+json">${stringifyJsonLd(schema)}</script>`);
86
135
  }
@@ -88,8 +137,51 @@ function renderHeadMeta(meta: AppShellMeta): string {
88
137
  return headTags.join("\n");
89
138
  }
90
139
 
91
- export function renderAppShellHtml(meta: AppShellMeta = {}): string {
140
+ function renderDeferredStylesheet(href: string): string {
141
+ return [
142
+ ` <link rel="preload" href="${escapeHtmlAttribute(href)}" as="style" />`,
143
+ ` <link rel="stylesheet" href="${escapeHtmlAttribute(href)}" media="print" onload="this.media='all'" />`,
144
+ ` <noscript><link rel="stylesheet" href="${escapeHtmlAttribute(href)}" /></noscript>`,
145
+ ].join("\n");
146
+ }
147
+
148
+ function renderInitialViewScript(initialView: AppShellInitialView | null): string {
149
+ if (!initialView) {
150
+ return "";
151
+ }
152
+
153
+ const payloadData: AppShellInitialViewPayload = {
154
+ route: initialView.route,
155
+ docId: initialView.docId,
156
+ title: initialView.title,
157
+ };
158
+
159
+ const payload = JSON.stringify(payloadData)
160
+ .replaceAll("<", "\\u003c")
161
+ .replaceAll("\u2028", "\\u2028")
162
+ .replaceAll("\u2029", "\\u2029");
163
+
164
+ return `\n <script id="initial-view-data" type="application/json">${payload}</script>`;
165
+ }
166
+
167
+ export function renderAppShellHtml(
168
+ meta: AppShellMeta = {},
169
+ assets: AppShellAssets = DEFAULT_ASSETS,
170
+ initialView: AppShellInitialView | null = null,
171
+ ): string {
92
172
  const headMeta = renderHeadMeta(meta);
173
+ const initialViewScript = renderInitialViewScript(initialView);
174
+ const textFontStylesheet =
175
+ "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Noto+Sans+KR:wght@400;500;700&display=optional";
176
+ const symbolFontStylesheet =
177
+ "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=optional";
178
+ const initialTitle = initialView ? escapeHtmlAttribute(initialView.title) : "문서를 선택하세요";
179
+ const initialBreadcrumb = initialView ? initialView.breadcrumbHtml : "";
180
+ const initialMeta = initialView ? initialView.metaHtml : "";
181
+ const initialContent = initialView
182
+ ? initialView.contentHtml
183
+ : '<p class="placeholder">좌측 탐색기에서 문서를 선택하세요.</p>';
184
+ const initialNav = initialView ? initialView.navHtml : "";
93
185
 
94
186
  return `<!doctype html>
95
187
  <html lang="ko">
@@ -99,9 +191,9 @@ export function renderAppShellHtml(meta: AppShellMeta = {}): string {
99
191
  ${headMeta}
100
192
  <link rel="preconnect" href="https://fonts.googleapis.com" />
101
193
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
102
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet" />
103
- <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet" />
104
- <link rel="stylesheet" href="/assets/app.css" />
194
+ ${renderDeferredStylesheet(textFontStylesheet)}
195
+ ${renderDeferredStylesheet(symbolFontStylesheet)}
196
+ <link rel="stylesheet" href="${escapeHtmlAttribute(assets.cssHref)}" />
105
197
  </head>
106
198
  <body>
107
199
  <a class="skip-link" href="#viewer-panel">본문으로 건너뛰기</a>
@@ -200,26 +292,30 @@ ${headMeta}
200
292
  <span>Files</span>
201
293
  </button>
202
294
  <div class="viewer-container">
203
- <nav id="viewer-breadcrumb" class="viewer-breadcrumb" aria-label="경로"></nav>
295
+ <nav id="viewer-breadcrumb" class="viewer-breadcrumb" aria-label="경로">${initialBreadcrumb}</nav>
204
296
  <header id="viewer-header" class="viewer-header">
205
- <h1 id="viewer-title" class="viewer-title">문서를 선택하세요</h1>
206
- <div id="viewer-meta" class="viewer-meta"></div>
297
+ <h1 id="viewer-title" class="viewer-title">${initialTitle}</h1>
298
+ <div id="viewer-meta" class="viewer-meta">${initialMeta}</div>
207
299
  </header>
208
- <article id="viewer-content" class="viewer-content">
209
- <p class="placeholder">좌측 탐색기에서 문서를 선택하세요.</p>
210
- </article>
211
- <nav id="viewer-nav" class="viewer-nav"></nav>
300
+ <article id="viewer-content" class="viewer-content">${initialContent}</article>
301
+ <nav id="viewer-nav" class="viewer-nav">${initialNav}</nav>
212
302
  </div>
213
303
  </main>
214
304
  </div>
215
305
  <div id="tree-label-tooltip" class="tree-label-tooltip" role="tooltip" hidden></div>
216
- <script type="module" src="/assets/app.js"></script>
306
+ ${initialViewScript}
307
+ <script type="module" src="${escapeHtmlAttribute(assets.jsSrc)}"></script>
217
308
  </body>
218
309
  </html>
219
310
  `;
220
311
  }
221
312
 
222
- export function render404Html(): string {
313
+ export function render404Html(assets: AppShellAssets = DEFAULT_ASSETS): string {
314
+ const textFontStylesheet =
315
+ "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Noto+Sans+KR:wght@400;500;700&display=optional";
316
+ const symbolFontStylesheet =
317
+ "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=optional";
318
+
223
319
  return `<!doctype html>
224
320
  <html lang="ko">
225
321
  <head>
@@ -228,9 +324,9 @@ export function render404Html(): string {
228
324
  <title>404 - File-System Blog</title>
229
325
  <link rel="preconnect" href="https://fonts.googleapis.com" />
230
326
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
231
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Noto+Sans+KR:wght@400;500;700&display=swap" rel="stylesheet" />
232
- <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet" />
233
- <link rel="stylesheet" href="/assets/app.css" />
327
+ ${renderDeferredStylesheet(textFontStylesheet)}
328
+ ${renderDeferredStylesheet(symbolFontStylesheet)}
329
+ <link rel="stylesheet" href="${escapeHtmlAttribute(assets.cssHref)}" />
234
330
  </head>
235
331
  <body>
236
332
  <main class="not-found">
package/src/types.ts CHANGED
@@ -3,11 +3,31 @@ export type ImagePolicy = "keep" | "omit-local";
3
3
  export interface UserSeoConfig {
4
4
  siteUrl?: string;
5
5
  pathBase?: string;
6
+ siteName?: string;
7
+ defaultTitle?: string;
8
+ defaultDescription?: string;
9
+ locale?: string;
10
+ twitterCard?: "summary" | "summary_large_image";
11
+ twitterSite?: string;
12
+ twitterCreator?: string;
13
+ defaultSocialImage?: string;
14
+ defaultOgImage?: string;
15
+ defaultTwitterImage?: string;
6
16
  }
7
17
 
8
18
  export interface BuildSeoOptions {
9
19
  siteUrl: string;
10
20
  pathBase: string;
21
+ siteName?: string;
22
+ defaultTitle?: string;
23
+ defaultDescription?: string;
24
+ locale?: string;
25
+ twitterCard?: "summary" | "summary_large_image";
26
+ twitterSite?: string;
27
+ twitterCreator?: string;
28
+ defaultSocialImage?: string;
29
+ defaultOgImage?: string;
30
+ defaultTwitterImage?: string;
11
31
  }
12
32
 
13
33
  export interface PinnedMenuOption {
@@ -19,6 +39,10 @@ export interface UserConfig {
19
39
  vaultDir?: string;
20
40
  outDir?: string;
21
41
  exclude?: string[];
42
+ pinnedMenu?: {
43
+ label?: string;
44
+ sourceDir: string;
45
+ };
22
46
  ui?: {
23
47
  newWithinDays?: number;
24
48
  recentLimit?: number;