@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 +29 -14
- package/bun.lock +1 -0
- package/package.json +1 -1
- package/src/build.ts +207 -34
- package/src/config.ts +9 -7
- package/src/markdown.ts +37 -13
- package/src/runtime/app.css +10 -0
- package/src/runtime/app.js +55 -1
- package/src/seo.ts +26 -0
- package/src/template.ts +115 -19
- package/src/types.ts +24 -0
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>`: 상단 고정
|
|
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
package/package.json
CHANGED
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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.
|
|
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(
|
|
808
|
-
og: toAbsoluteImage(
|
|
809
|
-
twitter: toAbsoluteImage(
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
874
|
-
|
|
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 ??
|
|
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 ??
|
|
893
|
-
twitterCard: "summary",
|
|
935
|
+
ogDescription: description ?? defaultDescription,
|
|
936
|
+
twitterCard: options.seo?.twitterCard ?? "summary",
|
|
894
937
|
twitterTitle: title,
|
|
895
|
-
twitterDescription: 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
|
-
|
|
903
|
-
const
|
|
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,
|
|
906
|
-
await writeOutputIfChanged(
|
|
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
|
-
|
|
912
|
-
renderAppShellHtml(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
|
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, """).replace(/</g, "<").replace(/>/g, ">");
|
|
117
129
|
}
|
|
118
130
|
|
|
119
|
-
function createMarkdownIt
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
md.renderer.rules.link_open
|
|
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
|
}
|
package/src/runtime/app.css
CHANGED
|
@@ -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;
|
package/src/runtime/app.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
<link rel="stylesheet" href="
|
|
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="경로"
|
|
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"
|
|
206
|
-
<div id="viewer-meta" class="viewer-meta"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
<link rel="stylesheet" href="
|
|
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;
|