@limcpf/everything-is-a-markdown 0.3.0 → 0.4.1
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 +210 -34
- package/src/config.ts +9 -7
- package/src/markdown.ts +37 -13
- package/src/runtime/app.css +31 -11
- package/src/runtime/app.js +93 -6
- package/src/seo.ts +26 -0
- package/src/template.ts +120 -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,153 @@ 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
|
+
manifest: Manifest,
|
|
1046
|
+
options: BuildOptions,
|
|
1047
|
+
runtimeAssets: RuntimeAssets,
|
|
1048
|
+
contentByDocId: Map<string, string>,
|
|
1049
|
+
): Promise<void> {
|
|
1050
|
+
const indexDoc = docs[0] ?? null;
|
|
1051
|
+
const indexOutputPath = "index.html";
|
|
1052
|
+
const indexInitialView = indexDoc ? buildInitialView(indexDoc, docs, contentByDocId.get(indexDoc.id) ?? "") : null;
|
|
1053
|
+
const shell = renderAppShellHtml(
|
|
1054
|
+
buildShellMeta("/", null, options),
|
|
1055
|
+
buildAppShellAssetsForOutput(indexOutputPath, runtimeAssets),
|
|
1056
|
+
indexInitialView,
|
|
1057
|
+
manifest,
|
|
1058
|
+
);
|
|
904
1059
|
await writeOutputIfChanged(context, "_app/index.html", shell);
|
|
905
|
-
await writeOutputIfChanged(context,
|
|
906
|
-
await writeOutputIfChanged(
|
|
1060
|
+
await writeOutputIfChanged(context, indexOutputPath, shell);
|
|
1061
|
+
await writeOutputIfChanged(
|
|
1062
|
+
context,
|
|
1063
|
+
"404.html",
|
|
1064
|
+
render404Html(buildAppShellAssetsForOutput("404.html", runtimeAssets)),
|
|
1065
|
+
);
|
|
907
1066
|
|
|
908
1067
|
for (const doc of docs) {
|
|
1068
|
+
const routeOutputPath = toRouteOutputPath(doc.route);
|
|
1069
|
+
const initialView = buildInitialView(doc, docs, contentByDocId.get(doc.id) ?? "");
|
|
909
1070
|
await writeOutputIfChanged(
|
|
910
1071
|
context,
|
|
911
|
-
|
|
912
|
-
renderAppShellHtml(
|
|
1072
|
+
routeOutputPath,
|
|
1073
|
+
renderAppShellHtml(
|
|
1074
|
+
buildShellMeta(doc.route, doc, options),
|
|
1075
|
+
buildAppShellAssetsForOutput(routeOutputPath, runtimeAssets),
|
|
1076
|
+
initialView,
|
|
1077
|
+
manifest,
|
|
1078
|
+
),
|
|
913
1079
|
);
|
|
914
1080
|
}
|
|
915
1081
|
}
|
|
@@ -1023,17 +1189,15 @@ export async function buildSite(options: BuildOptions): Promise<BuildResult> {
|
|
|
1023
1189
|
previousHashes: previousOutputHashes,
|
|
1024
1190
|
nextHashes: {},
|
|
1025
1191
|
};
|
|
1026
|
-
await writeRuntimeAssets(outputContext);
|
|
1192
|
+
const runtimeAssets = await writeRuntimeAssets(outputContext);
|
|
1027
1193
|
|
|
1028
1194
|
const tree = buildTree(docs, options);
|
|
1029
1195
|
const manifest = buildManifest(docs, tree, options);
|
|
1030
1196
|
await writeOutputIfChanged(outputContext, "manifest.json", `${JSON.stringify(manifest, null, 2)}\n`);
|
|
1031
1197
|
|
|
1032
|
-
await writeShellPages(outputContext, docs, options);
|
|
1033
|
-
await writeSeoArtifacts(outputContext, docs, options);
|
|
1034
|
-
|
|
1035
1198
|
const markdownRenderer = await createMarkdownRenderer(options);
|
|
1036
1199
|
const wikiLookup = createWikiLookup(docs);
|
|
1200
|
+
const contentByDocId = new Map<string, string>();
|
|
1037
1201
|
|
|
1038
1202
|
let renderedDocs = 0;
|
|
1039
1203
|
let skippedDocs = 0;
|
|
@@ -1071,6 +1235,14 @@ export async function buildSite(options: BuildOptions): Promise<BuildResult> {
|
|
|
1071
1235
|
|
|
1072
1236
|
if (unchanged) {
|
|
1073
1237
|
skippedDocs += 1;
|
|
1238
|
+
const outputFile = Bun.file(outputPath);
|
|
1239
|
+
if (await outputFile.exists()) {
|
|
1240
|
+
contentByDocId.set(doc.id, await outputFile.text());
|
|
1241
|
+
} else {
|
|
1242
|
+
const resolver = createWikiResolver(wikiLookup, doc);
|
|
1243
|
+
const renderResult = await markdownRenderer.render(doc.body, resolver);
|
|
1244
|
+
contentByDocId.set(doc.id, renderResult.html);
|
|
1245
|
+
}
|
|
1074
1246
|
continue;
|
|
1075
1247
|
}
|
|
1076
1248
|
|
|
@@ -1083,9 +1255,13 @@ export async function buildSite(options: BuildOptions): Promise<BuildResult> {
|
|
|
1083
1255
|
}
|
|
1084
1256
|
|
|
1085
1257
|
await Bun.write(outputPath, renderResult.html);
|
|
1258
|
+
contentByDocId.set(doc.id, renderResult.html);
|
|
1086
1259
|
renderedDocs += 1;
|
|
1087
1260
|
}
|
|
1088
1261
|
|
|
1262
|
+
await writeShellPages(outputContext, docs, manifest, options, runtimeAssets, contentByDocId);
|
|
1263
|
+
await writeSeoArtifacts(outputContext, docs, options);
|
|
1264
|
+
|
|
1089
1265
|
await writeCache(cachePath, nextCache);
|
|
1090
1266
|
|
|
1091
1267
|
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
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
--latte-crust: #dce0e8;
|
|
6
6
|
--latte-text: #4c4f69;
|
|
7
7
|
--latte-subtext0: #6c6f85;
|
|
8
|
-
--latte-subtext1: #
|
|
9
|
-
--latte-overlay0: #
|
|
8
|
+
--latte-subtext1: #555974;
|
|
9
|
+
--latte-overlay0: #5f637a;
|
|
10
10
|
--latte-surface0: #ccd0da;
|
|
11
11
|
--latte-surface1: #bcc0cc;
|
|
12
12
|
--latte-surface2: #acb0be;
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
--mocha-text: #cdd6f4;
|
|
31
31
|
--mocha-subtext0: #a6adc8;
|
|
32
32
|
--mocha-subtext1: #bac2de;
|
|
33
|
-
--mocha-overlay0: #
|
|
33
|
+
--mocha-overlay0: #a0a7c6;
|
|
34
34
|
--mocha-surface0: #313244;
|
|
35
35
|
--mocha-surface1: #45475a;
|
|
36
36
|
--mocha-surface2: #585b70;
|
|
@@ -121,7 +121,7 @@ body {
|
|
|
121
121
|
body {
|
|
122
122
|
background: var(--latte-base);
|
|
123
123
|
color: var(--latte-text);
|
|
124
|
-
font-family: "
|
|
124
|
+
font-family: "Pretendard Variable", "Pretendard", "Noto Sans KR", "Apple SD Gothic Neo", "Malgun Gothic", "Segoe UI", sans-serif;
|
|
125
125
|
-webkit-font-smoothing: antialiased;
|
|
126
126
|
overflow: hidden;
|
|
127
127
|
}
|
|
@@ -192,6 +192,22 @@ a:hover {
|
|
|
192
192
|
}
|
|
193
193
|
|
|
194
194
|
.material-symbols-outlined {
|
|
195
|
+
font-family: "Material Symbols Outlined", "Material Icons", sans-serif;
|
|
196
|
+
font-style: normal;
|
|
197
|
+
font-weight: 400;
|
|
198
|
+
letter-spacing: normal;
|
|
199
|
+
text-transform: none;
|
|
200
|
+
white-space: nowrap;
|
|
201
|
+
word-wrap: normal;
|
|
202
|
+
direction: ltr;
|
|
203
|
+
font-feature-settings: "liga";
|
|
204
|
+
-webkit-font-feature-settings: "liga";
|
|
205
|
+
display: inline-flex;
|
|
206
|
+
align-items: center;
|
|
207
|
+
justify-content: center;
|
|
208
|
+
min-width: 1em;
|
|
209
|
+
min-height: 1em;
|
|
210
|
+
flex: 0 0 auto;
|
|
195
211
|
font-size: 20px;
|
|
196
212
|
line-height: 1;
|
|
197
213
|
vertical-align: middle;
|
|
@@ -565,7 +581,7 @@ a:hover {
|
|
|
565
581
|
}
|
|
566
582
|
|
|
567
583
|
.status-encoding {
|
|
568
|
-
font-family: "
|
|
584
|
+
font-family: ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
569
585
|
color: var(--latte-overlay0);
|
|
570
586
|
}
|
|
571
587
|
|
|
@@ -746,7 +762,7 @@ body.mobile-toggle-left .mobile-menu-toggle {
|
|
|
746
762
|
align-items: center;
|
|
747
763
|
gap: 4px;
|
|
748
764
|
font-size: 0.85rem;
|
|
749
|
-
font-family: "
|
|
765
|
+
font-family: ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
750
766
|
color: var(--latte-overlay0);
|
|
751
767
|
margin-bottom: 24px;
|
|
752
768
|
flex-wrap: nowrap;
|
|
@@ -754,6 +770,7 @@ body.mobile-toggle-left .mobile-menu-toggle {
|
|
|
754
770
|
overflow-x: auto;
|
|
755
771
|
overflow-y: hidden;
|
|
756
772
|
white-space: nowrap;
|
|
773
|
+
min-height: 24px;
|
|
757
774
|
}
|
|
758
775
|
|
|
759
776
|
.breadcrumb-sep {
|
|
@@ -793,6 +810,7 @@ body.mobile-toggle-left .mobile-menu-toggle {
|
|
|
793
810
|
/* Viewer header */
|
|
794
811
|
.viewer-header {
|
|
795
812
|
margin-bottom: 32px;
|
|
813
|
+
min-height: 88px;
|
|
796
814
|
}
|
|
797
815
|
|
|
798
816
|
.viewer-title {
|
|
@@ -808,8 +826,9 @@ body.mobile-toggle-left .mobile-menu-toggle {
|
|
|
808
826
|
display: flex;
|
|
809
827
|
align-items: center;
|
|
810
828
|
gap: 16px;
|
|
829
|
+
min-height: 22px;
|
|
811
830
|
font-size: 0.85rem;
|
|
812
|
-
font-family: "
|
|
831
|
+
font-family: ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
813
832
|
color: var(--latte-subtext0);
|
|
814
833
|
}
|
|
815
834
|
|
|
@@ -905,7 +924,7 @@ body.mobile-toggle-left .mobile-menu-toggle {
|
|
|
905
924
|
border: 1px solid var(--latte-surface0);
|
|
906
925
|
border-radius: 4px;
|
|
907
926
|
padding: 2px 6px;
|
|
908
|
-
font-family: "
|
|
927
|
+
font-family: ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
909
928
|
font-size: 0.88em;
|
|
910
929
|
color: var(--latte-mauve);
|
|
911
930
|
}
|
|
@@ -946,7 +965,7 @@ body.mobile-toggle-left .mobile-menu-toggle {
|
|
|
946
965
|
|
|
947
966
|
.code-filename {
|
|
948
967
|
flex: 1;
|
|
949
|
-
font-family: "
|
|
968
|
+
font-family: ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
950
969
|
font-size: 0.75rem;
|
|
951
970
|
color: #aeb7c2;
|
|
952
971
|
text-align: center;
|
|
@@ -991,7 +1010,7 @@ body.mobile-toggle-left .mobile-menu-toggle {
|
|
|
991
1010
|
display: block;
|
|
992
1011
|
padding: 18px 20px;
|
|
993
1012
|
overflow-x: auto;
|
|
994
|
-
font-family: "
|
|
1013
|
+
font-family: ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
995
1014
|
font-size: 0.95rem;
|
|
996
1015
|
font-weight: 500;
|
|
997
1016
|
line-height: 1.72;
|
|
@@ -1032,7 +1051,7 @@ body.mobile-toggle-left .mobile-menu-toggle {
|
|
|
1032
1051
|
display: block;
|
|
1033
1052
|
padding: 16px;
|
|
1034
1053
|
overflow-x: auto;
|
|
1035
|
-
font-family: "
|
|
1054
|
+
font-family: ui-monospace, "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
1036
1055
|
font-size: 0.875rem;
|
|
1037
1056
|
line-height: 1.6;
|
|
1038
1057
|
}
|
|
@@ -1064,6 +1083,7 @@ body.mobile-toggle-left .mobile-menu-toggle {
|
|
|
1064
1083
|
|
|
1065
1084
|
/* Images */
|
|
1066
1085
|
.viewer-content img {
|
|
1086
|
+
display: block;
|
|
1067
1087
|
max-width: 100%;
|
|
1068
1088
|
height: auto;
|
|
1069
1089
|
border-radius: 8px;
|
package/src/runtime/app.js
CHANGED
|
@@ -47,6 +47,72 @@ 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
|
+
|
|
85
|
+
function loadInitialManifestData() {
|
|
86
|
+
const script = document.getElementById("initial-manifest-data");
|
|
87
|
+
if (!(script instanceof HTMLScriptElement)) {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const raw = script.textContent;
|
|
92
|
+
if (!raw) {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const parsed = JSON.parse(raw);
|
|
98
|
+
if (!parsed || typeof parsed !== "object") {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (!Array.isArray(parsed.docs) || !Array.isArray(parsed.tree)) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!parsed.routeMap || typeof parsed.routeMap !== "object") {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return parsed;
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
50
116
|
function resolveRouteFromLocation(routeMap) {
|
|
51
117
|
const direct = normalizeRoute(location.pathname);
|
|
52
118
|
if (routeMap[direct]) {
|
|
@@ -690,6 +756,8 @@ async function start() {
|
|
|
690
756
|
const navEl = document.getElementById("viewer-nav");
|
|
691
757
|
const a11yStatusEl = document.getElementById("a11y-status");
|
|
692
758
|
const viewerEl = document.querySelector(".viewer");
|
|
759
|
+
const initialViewData = loadInitialViewData();
|
|
760
|
+
let hasHydratedInitialView = false;
|
|
693
761
|
|
|
694
762
|
let hideTreeTooltip = () => {};
|
|
695
763
|
let disposeTreeTooltip = () => {};
|
|
@@ -1040,12 +1108,14 @@ async function start() {
|
|
|
1040
1108
|
}
|
|
1041
1109
|
});
|
|
1042
1110
|
|
|
1043
|
-
|
|
1044
|
-
if (!
|
|
1045
|
-
|
|
1111
|
+
let manifest = loadInitialManifestData();
|
|
1112
|
+
if (!manifest) {
|
|
1113
|
+
const manifestRes = await fetch("/manifest.json");
|
|
1114
|
+
if (!manifestRes.ok) {
|
|
1115
|
+
throw new Error(`Failed to load manifest: ${manifestRes.status}`);
|
|
1116
|
+
}
|
|
1117
|
+
manifest = await manifestRes.json();
|
|
1046
1118
|
}
|
|
1047
|
-
|
|
1048
|
-
const manifest = await manifestRes.json();
|
|
1049
1119
|
const defaultBranch = normalizeBranch(manifest.defaultBranch) || DEFAULT_BRANCH;
|
|
1050
1120
|
const availableBranchSet = new Set([defaultBranch]);
|
|
1051
1121
|
for (const doc of manifest.docs) {
|
|
@@ -1155,7 +1225,7 @@ async function start() {
|
|
|
1155
1225
|
|
|
1156
1226
|
const state = {
|
|
1157
1227
|
expanded,
|
|
1158
|
-
currentDocId: "",
|
|
1228
|
+
currentDocId: initialViewData?.docId ?? "",
|
|
1159
1229
|
async navigate(rawRoute, push) {
|
|
1160
1230
|
hideTreeTooltip();
|
|
1161
1231
|
|
|
@@ -1206,6 +1276,23 @@ async function start() {
|
|
|
1206
1276
|
|
|
1207
1277
|
state.currentDocId = id;
|
|
1208
1278
|
markActive(treeFileRowsById, activeFileState, id);
|
|
1279
|
+
|
|
1280
|
+
const shouldUseInitialView =
|
|
1281
|
+
!hasHydratedInitialView &&
|
|
1282
|
+
initialViewData &&
|
|
1283
|
+
initialViewData.docId === id &&
|
|
1284
|
+
initialViewData.route === route;
|
|
1285
|
+
|
|
1286
|
+
if (shouldUseInitialView) {
|
|
1287
|
+
hasHydratedInitialView = true;
|
|
1288
|
+
document.title = `${initialViewData.title} - File-System Blog`;
|
|
1289
|
+
if (viewerEl instanceof HTMLElement) {
|
|
1290
|
+
viewerEl.scrollTo(0, 0);
|
|
1291
|
+
}
|
|
1292
|
+
announceA11yStatus(`탐색 완료: ${doc.title} 문서를 열었습니다.`);
|
|
1293
|
+
return;
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1209
1296
|
breadcrumbEl.innerHTML = renderBreadcrumb(route);
|
|
1210
1297
|
titleEl.textContent = doc.title;
|
|
1211
1298
|
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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { escapeHtmlAttribute } from "./seo";
|
|
2
|
+
import type { Manifest } from "./types";
|
|
2
3
|
|
|
3
4
|
const DEFAULT_TITLE = "File-System Blog";
|
|
4
5
|
const DEFAULT_DESCRIPTION = "File-system style static blog with markdown explorer UI.";
|
|
@@ -9,6 +10,8 @@ export interface AppShellMeta {
|
|
|
9
10
|
canonicalUrl?: string;
|
|
10
11
|
ogTitle?: string;
|
|
11
12
|
ogType?: string;
|
|
13
|
+
ogSiteName?: string;
|
|
14
|
+
ogLocale?: string;
|
|
12
15
|
ogUrl?: string;
|
|
13
16
|
ogDescription?: string;
|
|
14
17
|
ogImage?: string;
|
|
@@ -16,9 +19,39 @@ export interface AppShellMeta {
|
|
|
16
19
|
twitterTitle?: string;
|
|
17
20
|
twitterDescription?: string;
|
|
18
21
|
twitterImage?: string;
|
|
22
|
+
twitterSite?: string;
|
|
23
|
+
twitterCreator?: string;
|
|
19
24
|
jsonLd?: unknown | unknown[];
|
|
20
25
|
}
|
|
21
26
|
|
|
27
|
+
export interface AppShellAssets {
|
|
28
|
+
cssHref: string;
|
|
29
|
+
jsSrc: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface AppShellInitialView {
|
|
33
|
+
route: string;
|
|
34
|
+
docId: string;
|
|
35
|
+
title: string;
|
|
36
|
+
breadcrumbHtml: string;
|
|
37
|
+
metaHtml: string;
|
|
38
|
+
contentHtml: string;
|
|
39
|
+
navHtml: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface AppShellInitialViewPayload {
|
|
43
|
+
route: string;
|
|
44
|
+
docId: string;
|
|
45
|
+
title: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface AppShellManifestPayload extends Manifest {}
|
|
49
|
+
|
|
50
|
+
const DEFAULT_ASSETS: AppShellAssets = {
|
|
51
|
+
cssHref: "/assets/app.css",
|
|
52
|
+
jsSrc: "/assets/app.js",
|
|
53
|
+
};
|
|
54
|
+
|
|
22
55
|
function normalizeJsonLd(value: unknown | unknown[] | undefined): unknown[] {
|
|
23
56
|
if (Array.isArray(value)) {
|
|
24
57
|
return value.filter((item) => item != null);
|
|
@@ -36,10 +69,13 @@ function stringifyJsonLd(value: unknown): string {
|
|
|
36
69
|
function renderHeadMeta(meta: AppShellMeta): string {
|
|
37
70
|
const title = (meta.title ?? DEFAULT_TITLE).trim() || DEFAULT_TITLE;
|
|
38
71
|
const description = typeof meta.description === "string" ? meta.description.trim() : "";
|
|
72
|
+
const fallbackDescription = description || DEFAULT_DESCRIPTION;
|
|
39
73
|
const canonicalUrl = typeof meta.canonicalUrl === "string" ? meta.canonicalUrl.trim() : "";
|
|
40
74
|
|
|
41
75
|
const ogTitle = (meta.ogTitle ?? title).trim() || title;
|
|
42
76
|
const ogType = (meta.ogType ?? "website").trim() || "website";
|
|
77
|
+
const ogSiteName = typeof meta.ogSiteName === "string" ? meta.ogSiteName.trim() : "";
|
|
78
|
+
const ogLocale = typeof meta.ogLocale === "string" ? meta.ogLocale.trim() : "";
|
|
43
79
|
const ogUrl = typeof meta.ogUrl === "string" ? meta.ogUrl.trim() : "";
|
|
44
80
|
const ogDescription = (meta.ogDescription ?? (description || DEFAULT_DESCRIPTION)).trim() || DEFAULT_DESCRIPTION;
|
|
45
81
|
const ogImage = typeof meta.ogImage === "string" ? meta.ogImage.trim() : "";
|
|
@@ -48,13 +84,13 @@ function renderHeadMeta(meta: AppShellMeta): string {
|
|
|
48
84
|
const twitterTitle = (meta.twitterTitle ?? title).trim() || title;
|
|
49
85
|
const twitterDescription = (meta.twitterDescription ?? (description || DEFAULT_DESCRIPTION)).trim() || DEFAULT_DESCRIPTION;
|
|
50
86
|
const twitterImage = typeof meta.twitterImage === "string" ? meta.twitterImage.trim() : "";
|
|
87
|
+
const twitterSite = typeof meta.twitterSite === "string" ? meta.twitterSite.trim() : "";
|
|
88
|
+
const twitterCreator = typeof meta.twitterCreator === "string" ? meta.twitterCreator.trim() : "";
|
|
51
89
|
const jsonLd = normalizeJsonLd(meta.jsonLd);
|
|
52
90
|
|
|
53
91
|
const headTags: string[] = [` <title>${escapeHtmlAttribute(title)}</title>`];
|
|
54
92
|
|
|
55
|
-
|
|
56
|
-
headTags.push(` <meta name="description" content="${escapeHtmlAttribute(description)}" />`);
|
|
57
|
-
}
|
|
93
|
+
headTags.push(` <meta name="description" content="${escapeHtmlAttribute(fallbackDescription)}" />`);
|
|
58
94
|
|
|
59
95
|
if (canonicalUrl) {
|
|
60
96
|
headTags.push(` <link rel="canonical" href="${escapeHtmlAttribute(canonicalUrl)}" />`);
|
|
@@ -67,6 +103,14 @@ function renderHeadMeta(meta: AppShellMeta): string {
|
|
|
67
103
|
headTags.push(` <meta property="og:url" content="${escapeHtmlAttribute(ogUrl)}" />`);
|
|
68
104
|
}
|
|
69
105
|
|
|
106
|
+
if (ogSiteName) {
|
|
107
|
+
headTags.push(` <meta property="og:site_name" content="${escapeHtmlAttribute(ogSiteName)}" />`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (ogLocale) {
|
|
111
|
+
headTags.push(` <meta property="og:locale" content="${escapeHtmlAttribute(ogLocale)}" />`);
|
|
112
|
+
}
|
|
113
|
+
|
|
70
114
|
headTags.push(` <meta property="og:description" content="${escapeHtmlAttribute(ogDescription)}" />`);
|
|
71
115
|
|
|
72
116
|
if (ogImage) {
|
|
@@ -81,6 +125,14 @@ function renderHeadMeta(meta: AppShellMeta): string {
|
|
|
81
125
|
headTags.push(` <meta name="twitter:image" content="${escapeHtmlAttribute(twitterImage)}" />`);
|
|
82
126
|
}
|
|
83
127
|
|
|
128
|
+
if (twitterSite) {
|
|
129
|
+
headTags.push(` <meta name="twitter:site" content="${escapeHtmlAttribute(twitterSite)}" />`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (twitterCreator) {
|
|
133
|
+
headTags.push(` <meta name="twitter:creator" content="${escapeHtmlAttribute(twitterCreator)}" />`);
|
|
134
|
+
}
|
|
135
|
+
|
|
84
136
|
for (const schema of jsonLd) {
|
|
85
137
|
headTags.push(` <script type="application/ld+json">${stringifyJsonLd(schema)}</script>`);
|
|
86
138
|
}
|
|
@@ -88,8 +140,56 @@ function renderHeadMeta(meta: AppShellMeta): string {
|
|
|
88
140
|
return headTags.join("\n");
|
|
89
141
|
}
|
|
90
142
|
|
|
91
|
-
|
|
143
|
+
function renderInitialViewScript(initialView: AppShellInitialView | null): string {
|
|
144
|
+
if (!initialView) {
|
|
145
|
+
return "";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const payloadData: AppShellInitialViewPayload = {
|
|
149
|
+
route: initialView.route,
|
|
150
|
+
docId: initialView.docId,
|
|
151
|
+
title: initialView.title,
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const payload = JSON.stringify(payloadData)
|
|
155
|
+
.replaceAll("<", "\\u003c")
|
|
156
|
+
.replaceAll("\u2028", "\\u2028")
|
|
157
|
+
.replaceAll("\u2029", "\\u2029");
|
|
158
|
+
|
|
159
|
+
return `\n <script id="initial-view-data" type="application/json">${payload}</script>`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function renderInitialManifestScript(manifest: AppShellManifestPayload | null): string {
|
|
163
|
+
if (!manifest) {
|
|
164
|
+
return "";
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const payload = JSON.stringify(manifest)
|
|
168
|
+
.replaceAll("<", "\\u003c")
|
|
169
|
+
.replaceAll("\u2028", "\\u2028")
|
|
170
|
+
.replaceAll("\u2029", "\\u2029");
|
|
171
|
+
|
|
172
|
+
return `\n <script id="initial-manifest-data" type="application/json">${payload}</script>`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function renderAppShellHtml(
|
|
176
|
+
meta: AppShellMeta = {},
|
|
177
|
+
assets: AppShellAssets = DEFAULT_ASSETS,
|
|
178
|
+
initialView: AppShellInitialView | null = null,
|
|
179
|
+
manifest: AppShellManifestPayload | null = null,
|
|
180
|
+
): string {
|
|
92
181
|
const headMeta = renderHeadMeta(meta);
|
|
182
|
+
const initialViewScript = renderInitialViewScript(initialView);
|
|
183
|
+
const initialManifestScript = renderInitialManifestScript(manifest);
|
|
184
|
+
const symbolFontStylesheet =
|
|
185
|
+
"https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap";
|
|
186
|
+
const initialTitle = initialView ? escapeHtmlAttribute(initialView.title) : "문서를 선택하세요";
|
|
187
|
+
const initialBreadcrumb = initialView ? initialView.breadcrumbHtml : "";
|
|
188
|
+
const initialMeta = initialView ? initialView.metaHtml : "";
|
|
189
|
+
const initialContent = initialView
|
|
190
|
+
? initialView.contentHtml
|
|
191
|
+
: '<p class="placeholder">좌측 탐색기에서 문서를 선택하세요.</p>';
|
|
192
|
+
const initialNav = initialView ? initialView.navHtml : "";
|
|
93
193
|
|
|
94
194
|
return `<!doctype html>
|
|
95
195
|
<html lang="ko">
|
|
@@ -99,9 +199,8 @@ export function renderAppShellHtml(meta: AppShellMeta = {}): string {
|
|
|
99
199
|
${headMeta}
|
|
100
200
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
101
201
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
102
|
-
<link
|
|
103
|
-
<link
|
|
104
|
-
<link rel="stylesheet" href="/assets/app.css" />
|
|
202
|
+
<link rel="stylesheet" href="${escapeHtmlAttribute(symbolFontStylesheet)}" />
|
|
203
|
+
<link rel="stylesheet" href="${escapeHtmlAttribute(assets.cssHref)}" />
|
|
105
204
|
</head>
|
|
106
205
|
<body>
|
|
107
206
|
<a class="skip-link" href="#viewer-panel">본문으로 건너뛰기</a>
|
|
@@ -200,26 +299,29 @@ ${headMeta}
|
|
|
200
299
|
<span>Files</span>
|
|
201
300
|
</button>
|
|
202
301
|
<div class="viewer-container">
|
|
203
|
-
<nav id="viewer-breadcrumb" class="viewer-breadcrumb" aria-label="경로"
|
|
302
|
+
<nav id="viewer-breadcrumb" class="viewer-breadcrumb" aria-label="경로">${initialBreadcrumb}</nav>
|
|
204
303
|
<header id="viewer-header" class="viewer-header">
|
|
205
|
-
<h1 id="viewer-title" class="viewer-title"
|
|
206
|
-
<div id="viewer-meta" class="viewer-meta"
|
|
304
|
+
<h1 id="viewer-title" class="viewer-title">${initialTitle}</h1>
|
|
305
|
+
<div id="viewer-meta" class="viewer-meta">${initialMeta}</div>
|
|
207
306
|
</header>
|
|
208
|
-
<article id="viewer-content" class="viewer-content">
|
|
209
|
-
|
|
210
|
-
</article>
|
|
211
|
-
<nav id="viewer-nav" class="viewer-nav"></nav>
|
|
307
|
+
<article id="viewer-content" class="viewer-content">${initialContent}</article>
|
|
308
|
+
<nav id="viewer-nav" class="viewer-nav" aria-label="문서 이전/다음 탐색">${initialNav}</nav>
|
|
212
309
|
</div>
|
|
213
310
|
</main>
|
|
214
311
|
</div>
|
|
215
312
|
<div id="tree-label-tooltip" class="tree-label-tooltip" role="tooltip" hidden></div>
|
|
216
|
-
|
|
313
|
+
${initialViewScript}
|
|
314
|
+
${initialManifestScript}
|
|
315
|
+
<script type="module" src="${escapeHtmlAttribute(assets.jsSrc)}"></script>
|
|
217
316
|
</body>
|
|
218
317
|
</html>
|
|
219
318
|
`;
|
|
220
319
|
}
|
|
221
320
|
|
|
222
|
-
export function render404Html(): string {
|
|
321
|
+
export function render404Html(assets: AppShellAssets = DEFAULT_ASSETS): string {
|
|
322
|
+
const symbolFontStylesheet =
|
|
323
|
+
"https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap";
|
|
324
|
+
|
|
223
325
|
return `<!doctype html>
|
|
224
326
|
<html lang="ko">
|
|
225
327
|
<head>
|
|
@@ -228,9 +330,8 @@ export function render404Html(): string {
|
|
|
228
330
|
<title>404 - File-System Blog</title>
|
|
229
331
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
230
332
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
231
|
-
<link
|
|
232
|
-
<link
|
|
233
|
-
<link rel="stylesheet" href="/assets/app.css" />
|
|
333
|
+
<link rel="stylesheet" href="${escapeHtmlAttribute(symbolFontStylesheet)}" />
|
|
334
|
+
<link rel="stylesheet" href="${escapeHtmlAttribute(assets.cssHref)}" />
|
|
234
335
|
</head>
|
|
235
336
|
<body>
|
|
236
337
|
<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;
|