@limcpf/everything-is-a-markdown 0.2.1 → 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 +233 -35
- 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
|
}
|
|
@@ -612,6 +618,24 @@ function sortTree(nodes: TreeNode[]): TreeNode[] {
|
|
|
612
618
|
return nodes;
|
|
613
619
|
}
|
|
614
620
|
|
|
621
|
+
function parseDateToEpochMs(value: string | undefined): number | null {
|
|
622
|
+
if (!value) {
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const parsed = Date.parse(value);
|
|
627
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function isNewByFrontmatterDate(date: string | undefined, newThreshold: number): boolean {
|
|
631
|
+
const publishedAt = parseDateToEpochMs(date);
|
|
632
|
+
return publishedAt != null && publishedAt >= newThreshold;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function getRecentSortEpochMs(doc: DocRecord): number {
|
|
636
|
+
return parseDateToEpochMs(doc.date) ?? doc.mtimeMs;
|
|
637
|
+
}
|
|
638
|
+
|
|
615
639
|
function buildPinnedMenuFolder(docs: DocRecord[], options: BuildOptions): FolderNode | null {
|
|
616
640
|
if (!options.pinnedMenu) {
|
|
617
641
|
return null;
|
|
@@ -673,7 +697,19 @@ function buildTree(docs: DocRecord[], options: BuildOptions): TreeNode[] {
|
|
|
673
697
|
sortTree(root.children);
|
|
674
698
|
|
|
675
699
|
const recentChildren = [...docs]
|
|
676
|
-
.sort((
|
|
700
|
+
.sort((left, right) => {
|
|
701
|
+
const byDate = getRecentSortEpochMs(right) - getRecentSortEpochMs(left);
|
|
702
|
+
if (byDate !== 0) {
|
|
703
|
+
return byDate;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
const byMtime = right.mtimeMs - left.mtimeMs;
|
|
707
|
+
if (byMtime !== 0) {
|
|
708
|
+
return byMtime;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
return left.relNoExt.localeCompare(right.relNoExt, "ko-KR");
|
|
712
|
+
})
|
|
677
713
|
.slice(0, options.recentLimit)
|
|
678
714
|
.map((doc) => fileNodeFromDoc(doc));
|
|
679
715
|
|
|
@@ -753,12 +789,6 @@ function pickSeoImageDefaults(
|
|
|
753
789
|
return { social: null, og: null, twitter: null };
|
|
754
790
|
}
|
|
755
791
|
|
|
756
|
-
const raw = seo as BuildOptions["seo"] & {
|
|
757
|
-
defaultSocialImage?: unknown;
|
|
758
|
-
defaultOgImage?: unknown;
|
|
759
|
-
defaultTwitterImage?: unknown;
|
|
760
|
-
};
|
|
761
|
-
|
|
762
792
|
const toAbsoluteImage = (value: unknown): string | null => {
|
|
763
793
|
if (typeof value !== "string") {
|
|
764
794
|
return null;
|
|
@@ -779,20 +809,21 @@ function pickSeoImageDefaults(
|
|
|
779
809
|
};
|
|
780
810
|
|
|
781
811
|
return {
|
|
782
|
-
social: toAbsoluteImage(
|
|
783
|
-
og: toAbsoluteImage(
|
|
784
|
-
twitter: toAbsoluteImage(
|
|
812
|
+
social: toAbsoluteImage(seo.defaultSocialImage),
|
|
813
|
+
og: toAbsoluteImage(seo.defaultOgImage),
|
|
814
|
+
twitter: toAbsoluteImage(seo.defaultTwitterImage),
|
|
785
815
|
};
|
|
786
816
|
}
|
|
787
817
|
|
|
788
818
|
function buildStructuredData(route: string, doc: DocRecord | null, options: BuildOptions): unknown[] {
|
|
789
819
|
const canonicalUrl = options.seo ? buildCanonicalUrl(route, options.seo) : undefined;
|
|
820
|
+
const siteName = options.seo?.siteName ?? options.seo?.defaultTitle ?? DEFAULT_SITE_TITLE;
|
|
790
821
|
|
|
791
822
|
if (!doc) {
|
|
792
823
|
const websiteSchema: Record<string, string> = {
|
|
793
824
|
"@context": "https://schema.org",
|
|
794
825
|
"@type": "WebSite",
|
|
795
|
-
name:
|
|
826
|
+
name: siteName,
|
|
796
827
|
};
|
|
797
828
|
if (canonicalUrl) {
|
|
798
829
|
websiteSchema.url = canonicalUrl;
|
|
@@ -840,19 +871,54 @@ async function writeOutputIfChanged(
|
|
|
840
871
|
await Bun.write(outputPath, content);
|
|
841
872
|
}
|
|
842
873
|
|
|
843
|
-
|
|
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> {
|
|
844
888
|
const runtimeDir = path.join(import.meta.dir, "runtime");
|
|
845
889
|
const runtimeJs = await Bun.file(path.join(runtimeDir, "app.js")).text();
|
|
846
890
|
const runtimeCss = await Bun.file(path.join(runtimeDir, "app.css")).text();
|
|
847
891
|
|
|
848
|
-
|
|
849
|
-
|
|
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
|
+
};
|
|
850
914
|
}
|
|
851
915
|
|
|
852
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;
|
|
853
919
|
const description = typeof doc?.description === "string" && doc.description.trim().length > 0 ? doc.description.trim() : undefined;
|
|
854
920
|
const canonicalUrl = options.seo ? buildCanonicalUrl(route, options.seo) : undefined;
|
|
855
|
-
const title = doc?.title ??
|
|
921
|
+
const title = doc?.title ?? defaultTitle;
|
|
856
922
|
const imageDefaults = pickSeoImageDefaults(options.seo);
|
|
857
923
|
const ogImage = imageDefaults.og ?? imageDefaults.social ?? undefined;
|
|
858
924
|
const twitterImage = imageDefaults.twitter ?? imageDefaults.social ?? undefined;
|
|
@@ -863,28 +929,150 @@ function buildShellMeta(route: string, doc: DocRecord | null, options: BuildOpti
|
|
|
863
929
|
canonicalUrl,
|
|
864
930
|
ogTitle: title,
|
|
865
931
|
ogType: doc ? "article" : "website",
|
|
932
|
+
ogSiteName: options.seo?.siteName,
|
|
933
|
+
ogLocale: options.seo?.locale,
|
|
866
934
|
ogUrl: canonicalUrl,
|
|
867
|
-
ogDescription: description ??
|
|
868
|
-
twitterCard: "summary",
|
|
935
|
+
ogDescription: description ?? defaultDescription,
|
|
936
|
+
twitterCard: options.seo?.twitterCard ?? "summary",
|
|
869
937
|
twitterTitle: title,
|
|
870
|
-
twitterDescription: description ??
|
|
938
|
+
twitterDescription: description ?? defaultDescription,
|
|
939
|
+
twitterSite: options.seo?.twitterSite,
|
|
940
|
+
twitterCreator: options.seo?.twitterCreator,
|
|
871
941
|
ogImage,
|
|
872
942
|
twitterImage,
|
|
873
943
|
jsonLd: buildStructuredData(route, doc, options),
|
|
874
944
|
};
|
|
875
945
|
}
|
|
876
946
|
|
|
877
|
-
|
|
878
|
-
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
|
+
);
|
|
879
1057
|
await writeOutputIfChanged(context, "_app/index.html", shell);
|
|
880
|
-
await writeOutputIfChanged(context,
|
|
881
|
-
await writeOutputIfChanged(
|
|
1058
|
+
await writeOutputIfChanged(context, indexOutputPath, shell);
|
|
1059
|
+
await writeOutputIfChanged(
|
|
1060
|
+
context,
|
|
1061
|
+
"404.html",
|
|
1062
|
+
render404Html(buildAppShellAssetsForOutput("404.html", runtimeAssets)),
|
|
1063
|
+
);
|
|
882
1064
|
|
|
883
1065
|
for (const doc of docs) {
|
|
1066
|
+
const routeOutputPath = toRouteOutputPath(doc.route);
|
|
1067
|
+
const initialView = buildInitialView(doc, docs, contentByDocId.get(doc.id) ?? "");
|
|
884
1068
|
await writeOutputIfChanged(
|
|
885
1069
|
context,
|
|
886
|
-
|
|
887
|
-
renderAppShellHtml(
|
|
1070
|
+
routeOutputPath,
|
|
1071
|
+
renderAppShellHtml(
|
|
1072
|
+
buildShellMeta(doc.route, doc, options),
|
|
1073
|
+
buildAppShellAssetsForOutput(routeOutputPath, runtimeAssets),
|
|
1074
|
+
initialView,
|
|
1075
|
+
),
|
|
888
1076
|
);
|
|
889
1077
|
}
|
|
890
1078
|
}
|
|
@@ -998,17 +1186,15 @@ export async function buildSite(options: BuildOptions): Promise<BuildResult> {
|
|
|
998
1186
|
previousHashes: previousOutputHashes,
|
|
999
1187
|
nextHashes: {},
|
|
1000
1188
|
};
|
|
1001
|
-
await writeRuntimeAssets(outputContext);
|
|
1189
|
+
const runtimeAssets = await writeRuntimeAssets(outputContext);
|
|
1002
1190
|
|
|
1003
1191
|
const tree = buildTree(docs, options);
|
|
1004
1192
|
const manifest = buildManifest(docs, tree, options);
|
|
1005
1193
|
await writeOutputIfChanged(outputContext, "manifest.json", `${JSON.stringify(manifest, null, 2)}\n`);
|
|
1006
1194
|
|
|
1007
|
-
await writeShellPages(outputContext, docs, options);
|
|
1008
|
-
await writeSeoArtifacts(outputContext, docs, options);
|
|
1009
|
-
|
|
1010
1195
|
const markdownRenderer = await createMarkdownRenderer(options);
|
|
1011
1196
|
const wikiLookup = createWikiLookup(docs);
|
|
1197
|
+
const contentByDocId = new Map<string, string>();
|
|
1012
1198
|
|
|
1013
1199
|
let renderedDocs = 0;
|
|
1014
1200
|
let skippedDocs = 0;
|
|
@@ -1046,6 +1232,14 @@ export async function buildSite(options: BuildOptions): Promise<BuildResult> {
|
|
|
1046
1232
|
|
|
1047
1233
|
if (unchanged) {
|
|
1048
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
|
+
}
|
|
1049
1243
|
continue;
|
|
1050
1244
|
}
|
|
1051
1245
|
|
|
@@ -1058,9 +1252,13 @@ export async function buildSite(options: BuildOptions): Promise<BuildResult> {
|
|
|
1058
1252
|
}
|
|
1059
1253
|
|
|
1060
1254
|
await Bun.write(outputPath, renderResult.html);
|
|
1255
|
+
contentByDocId.set(doc.id, renderResult.html);
|
|
1061
1256
|
renderedDocs += 1;
|
|
1062
1257
|
}
|
|
1063
1258
|
|
|
1259
|
+
await writeShellPages(outputContext, docs, options, runtimeAssets, contentByDocId);
|
|
1260
|
+
await writeSeoArtifacts(outputContext, docs, options);
|
|
1261
|
+
|
|
1064
1262
|
await writeCache(cachePath, nextCache);
|
|
1065
1263
|
|
|
1066
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;
|