@limcpf/everything-is-a-markdown 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.ko.md CHANGED
@@ -40,6 +40,31 @@ bun run blog [build|dev|clean] [options]
40
40
  - `--exclude <glob>`: 제외 패턴 추가 (반복 가능)
41
41
  - `--port <n>`: 개발 서버 포트 (기본 `3000`)
42
42
 
43
+ ## 설정 파일 (`blog.config.ts`)
44
+
45
+ SEO/UI/정적 파일 설정은 config 파일에서 관리할 수 있습니다.
46
+
47
+ ```ts
48
+ const config = {
49
+ vaultDir: "./vault",
50
+ outDir: "./dist",
51
+ staticPaths: ["assets", "public/favicon.ico"],
52
+ seo: {
53
+ siteUrl: "https://example.com",
54
+ defaultOgImage: "/assets/og.png",
55
+ },
56
+ };
57
+
58
+ export default config;
59
+ ```
60
+
61
+ `staticPaths`:
62
+
63
+ - 볼트 기준 상대 경로 배열
64
+ - 폴더와 파일 모두 지정 가능
65
+ - 지정한 경로의 파일들을 `dist`에 같은 상대 경로로 복사
66
+ - 예: 볼트 `assets/og.png` -> `dist/assets/og.png`
67
+
43
68
  예시:
44
69
 
45
70
  ```bash
package/README.md CHANGED
@@ -40,6 +40,31 @@ Common options:
40
40
  - `--exclude <glob>`: Add exclude pattern (repeatable)
41
41
  - `--port <n>`: Dev server port (default `3000`)
42
42
 
43
+ ## Config File (`blog.config.ts`)
44
+
45
+ Use a config file for SEO/UI/static assets.
46
+
47
+ ```ts
48
+ const config = {
49
+ vaultDir: "./vault",
50
+ outDir: "./dist",
51
+ staticPaths: ["assets", "public/favicon.ico"],
52
+ seo: {
53
+ siteUrl: "https://example.com",
54
+ defaultOgImage: "/assets/og.png",
55
+ },
56
+ };
57
+
58
+ export default config;
59
+ ```
60
+
61
+ `staticPaths`:
62
+
63
+ - Array of vault-relative paths
64
+ - Supports both folders and files
65
+ - Copies all matched files into the same relative location in `dist`
66
+ - Example: `assets/og.png` in vault becomes `dist/assets/og.png`
67
+
43
68
  Examples:
44
69
 
45
70
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@limcpf/everything-is-a-markdown",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
package/src/build.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import crypto from "node:crypto";
1
2
  import fs from "node:fs/promises";
2
3
  import path from "node:path";
3
4
  import matter from "gray-matter";
@@ -16,6 +17,7 @@ import {
16
17
  removeEmptyParents,
17
18
  removeFileIfExists,
18
19
  stripMdExt,
20
+ toPosixPath,
19
21
  toDocId,
20
22
  toRoute,
21
23
  } from "./utils";
@@ -655,7 +657,7 @@ function isNewByFrontmatterDate(date: string | undefined, newThreshold: number):
655
657
  return publishedAt != null && publishedAt >= newThreshold;
656
658
  }
657
659
 
658
- function getRecentSortEpochMs(doc: DocRecord): number {
660
+ function getRecentSortEpochMs(doc: DocRecord): number | null {
659
661
  return parseDateToEpochMs(doc.updatedDate) ?? parseDateToEpochMs(doc.date);
660
662
  }
661
663
 
@@ -804,6 +806,7 @@ function buildManifest(docs: DocRecord[], tree: TreeNode[], options: BuildOption
804
806
 
805
807
  return {
806
808
  generatedAt: new Date().toISOString(),
809
+ siteTitle: resolveSiteTitle(options),
807
810
  defaultBranch: DEFAULT_BRANCH,
808
811
  branches,
809
812
  ui: {
@@ -816,6 +819,24 @@ function buildManifest(docs: DocRecord[], tree: TreeNode[], options: BuildOption
816
819
  };
817
820
  }
818
821
 
822
+ function resolveSiteTitle(options: BuildOptions): string {
823
+ const value = options.siteTitle ?? options.seo?.siteName ?? options.seo?.defaultTitle ?? DEFAULT_SITE_TITLE;
824
+ const trimmed = value.trim();
825
+ return trimmed.length > 0 ? trimmed : DEFAULT_SITE_TITLE;
826
+ }
827
+
828
+ function composeDocumentTitle(pageTitle: string, siteTitle: string): string {
829
+ const left = pageTitle.trim();
830
+ const right = siteTitle.trim();
831
+ if (!left) {
832
+ return right || DEFAULT_SITE_TITLE;
833
+ }
834
+ if (!right || left === right) {
835
+ return left;
836
+ }
837
+ return `${left} - ${right}`;
838
+ }
839
+
819
840
  function pickSeoImageDefaults(
820
841
  seo: BuildOptions["seo"],
821
842
  ): { social: string | null; og: string | null; twitter: string | null } {
@@ -905,6 +926,92 @@ async function writeOutputIfChanged(
905
926
  await Bun.write(outputPath, content);
906
927
  }
907
928
 
929
+ async function copyOutputFileIfChanged(
930
+ context: OutputWriteContext,
931
+ relOutputPath: string,
932
+ sourcePath: string,
933
+ ): Promise<void> {
934
+ const bytes = new Uint8Array(await Bun.file(sourcePath).arrayBuffer());
935
+ const outputHash = crypto.createHash("sha1").update(bytes).digest("hex");
936
+ context.nextHashes[relOutputPath] = outputHash;
937
+
938
+ const outputPath = path.join(context.outDir, relOutputPath);
939
+ const unchanged = context.previousHashes[relOutputPath] === outputHash;
940
+ if (unchanged && (await Bun.file(outputPath).exists())) {
941
+ return;
942
+ }
943
+
944
+ await ensureDir(path.dirname(outputPath));
945
+ await Bun.write(outputPath, bytes);
946
+ }
947
+
948
+ async function listFilesRecursively(baseDir: string): Promise<string[]> {
949
+ const entries = await fs.readdir(baseDir, { withFileTypes: true });
950
+ entries.sort((left, right) => left.name.localeCompare(right.name, "ko-KR"));
951
+
952
+ const files: string[] = [];
953
+ for (const entry of entries) {
954
+ const absolutePath = path.join(baseDir, entry.name);
955
+ if (entry.isDirectory()) {
956
+ const children = await listFilesRecursively(absolutePath);
957
+ for (const child of children) {
958
+ files.push(path.join(entry.name, child));
959
+ }
960
+ continue;
961
+ }
962
+
963
+ if (entry.isFile()) {
964
+ files.push(entry.name);
965
+ }
966
+ }
967
+
968
+ return files;
969
+ }
970
+
971
+ async function copyStaticPaths(context: OutputWriteContext, options: BuildOptions): Promise<void> {
972
+ for (const staticPath of options.staticPaths) {
973
+ const sourcePath = path.resolve(options.vaultDir, staticPath);
974
+
975
+ let sourceStat;
976
+ try {
977
+ sourceStat = await fs.stat(sourcePath);
978
+ } catch {
979
+ console.warn(`[static] path not found: ${staticPath}`);
980
+ continue;
981
+ }
982
+
983
+ if (sourceStat.isDirectory()) {
984
+ const files = await listFilesRecursively(sourcePath);
985
+ for (const file of files) {
986
+ const relFilePath = toPosixPath(file);
987
+ const relOutputPath = path.posix.join(staticPath, relFilePath);
988
+ const filePath = path.join(sourcePath, file);
989
+ await copyOutputFileIfChanged(context, relOutputPath, filePath);
990
+ }
991
+ continue;
992
+ }
993
+
994
+ if (sourceStat.isFile()) {
995
+ await copyOutputFileIfChanged(context, staticPath, sourcePath);
996
+ continue;
997
+ }
998
+
999
+ console.warn(`[static] unsupported path type, skipped: ${staticPath}`);
1000
+ }
1001
+ }
1002
+
1003
+ async function removeStaleOutputs(context: OutputWriteContext): Promise<void> {
1004
+ for (const previousPath of Object.keys(context.previousHashes)) {
1005
+ if (Object.hasOwn(context.nextHashes, previousPath)) {
1006
+ continue;
1007
+ }
1008
+
1009
+ const outputPath = path.join(context.outDir, previousPath);
1010
+ await removeFileIfExists(outputPath);
1011
+ await removeEmptyParents(path.dirname(outputPath), context.outDir);
1012
+ }
1013
+ }
1014
+
908
1015
  function toRelativeAssetPath(fromOutputPath: string, assetOutputPath: string): string {
909
1016
  const fromDir = path.posix.dirname(fromOutputPath);
910
1017
  const relative = path.posix.relative(fromDir, assetOutputPath);
@@ -948,11 +1055,13 @@ async function writeRuntimeAssets(context: OutputWriteContext): Promise<RuntimeA
948
1055
  }
949
1056
 
950
1057
  function buildShellMeta(route: string, doc: DocRecord | null, options: BuildOptions): AppShellMeta {
951
- const defaultTitle = options.seo?.defaultTitle ?? DEFAULT_SITE_TITLE;
1058
+ const defaultTitle = options.seo?.defaultTitle ?? options.siteTitle ?? DEFAULT_SITE_TITLE;
1059
+ const siteTitle = resolveSiteTitle(options);
952
1060
  const defaultDescription = options.seo?.defaultDescription ?? DEFAULT_SITE_DESCRIPTION;
953
1061
  const description = typeof doc?.description === "string" && doc.description.trim().length > 0 ? doc.description.trim() : undefined;
954
1062
  const canonicalUrl = options.seo ? buildCanonicalUrl(route, options.seo) : undefined;
955
- const title = doc?.title ?? defaultTitle;
1063
+ const baseTitle = doc?.title ?? defaultTitle;
1064
+ const title = composeDocumentTitle(baseTitle, siteTitle);
956
1065
  const imageDefaults = pickSeoImageDefaults(options.seo);
957
1066
  const ogImage = imageDefaults.og ?? imageDefaults.social ?? undefined;
958
1067
  const twitterImage = imageDefaults.twitter ?? imageDefaults.social ?? undefined;
@@ -1221,6 +1330,7 @@ export async function buildSite(options: BuildOptions): Promise<BuildResult> {
1221
1330
  nextHashes: {},
1222
1331
  };
1223
1332
  const runtimeAssets = await writeRuntimeAssets(outputContext);
1333
+ await copyStaticPaths(outputContext, options);
1224
1334
 
1225
1335
  const tree = buildTree(docs, options);
1226
1336
  const manifest = buildManifest(docs, tree, options);
@@ -1292,6 +1402,7 @@ export async function buildSite(options: BuildOptions): Promise<BuildResult> {
1292
1402
 
1293
1403
  await writeShellPages(outputContext, docs, manifest, options, runtimeAssets, contentByDocId);
1294
1404
  await writeSeoArtifacts(outputContext, docs, options);
1405
+ await removeStaleOutputs(outputContext);
1295
1406
 
1296
1407
  await writeCache(cachePath, nextCache);
1297
1408
 
package/src/config.ts CHANGED
@@ -132,6 +132,45 @@ function normalizePinnedMenu(raw: unknown, errorPrefix = "[config]"): PinnedMenu
132
132
  };
133
133
  }
134
134
 
135
+ function normalizeStaticPaths(raw: unknown, errorPrefix = "[config]"): string[] {
136
+ if (raw == null) {
137
+ return [];
138
+ }
139
+ if (!Array.isArray(raw)) {
140
+ throw new Error(`${errorPrefix} "staticPaths" must be an array of strings`);
141
+ }
142
+
143
+ const normalized = new Set<string>();
144
+ for (const [index, value] of raw.entries()) {
145
+ if (typeof value !== "string") {
146
+ throw new Error(`${errorPrefix} "staticPaths[${index}]" must be a string`);
147
+ }
148
+
149
+ const cleaned = value
150
+ .trim()
151
+ .replace(/\\/g, "/")
152
+ .replace(/^\.\/+/, "")
153
+ .replace(/\/+$/, "");
154
+
155
+ if (
156
+ !cleaned ||
157
+ cleaned === "." ||
158
+ cleaned === ".." ||
159
+ cleaned.startsWith("../") ||
160
+ cleaned.startsWith("/") ||
161
+ path.isAbsolute(value.trim())
162
+ ) {
163
+ throw new Error(
164
+ `${errorPrefix} "staticPaths[${index}]" must be a non-empty vault-relative path (for example: "assets")`,
165
+ );
166
+ }
167
+
168
+ normalized.add(cleaned);
169
+ }
170
+
171
+ return Array.from(normalized);
172
+ }
173
+
135
174
  export async function loadPinnedMenuConfig(
136
175
  configPath: string | undefined,
137
176
  cwd = process.cwd(),
@@ -166,19 +205,29 @@ export function resolveBuildOptions(
166
205
  pinnedMenu: PinnedMenuOption | null,
167
206
  cwd = process.cwd(),
168
207
  ): BuildOptions {
208
+ const vaultDir = path.resolve(cwd, cli.vaultDir ?? userConfig.vaultDir ?? DEFAULTS.vaultDir);
209
+ const outDir = path.resolve(cwd, cli.outDir ?? userConfig.outDir ?? DEFAULTS.outDir);
169
210
  const cfgExclude = userConfig.exclude ?? [];
170
211
  const cliExclude = cli.exclude ?? [];
171
212
  const mergedExclude = Array.from(new Set([...DEFAULTS.exclude, ...cfgExclude, ...cliExclude]));
213
+ const staticPaths = normalizeStaticPaths(userConfig.staticPaths, "[config]");
172
214
  const seo = normalizeSeoConfig(userConfig.seo);
215
+ const siteTitleRaw = userConfig.seo?.siteName ?? userConfig.seo?.defaultTitle;
216
+ const siteTitle =
217
+ typeof siteTitleRaw === "string" && siteTitleRaw.trim().length > 0
218
+ ? siteTitleRaw.trim()
219
+ : undefined;
173
220
  const configPinnedMenu = normalizePinnedMenu(userConfig.pinnedMenu, "[config]");
174
221
  const resolvedPinnedMenu = pinnedMenu ?? configPinnedMenu;
175
222
 
176
223
  return {
177
- vaultDir: path.resolve(cwd, cli.vaultDir ?? userConfig.vaultDir ?? DEFAULTS.vaultDir),
178
- outDir: path.resolve(cwd, cli.outDir ?? userConfig.outDir ?? DEFAULTS.outDir),
224
+ vaultDir,
225
+ outDir,
179
226
  exclude: mergedExclude,
227
+ staticPaths,
180
228
  newWithinDays: cli.newWithinDays ?? userConfig.ui?.newWithinDays ?? DEFAULTS.newWithinDays,
181
229
  recentLimit: cli.recentLimit ?? userConfig.ui?.recentLimit ?? DEFAULTS.recentLimit,
230
+ siteTitle,
182
231
  pinnedMenu: resolvedPinnedMenu,
183
232
  wikilinks: userConfig.markdown?.wikilinks ?? DEFAULTS.wikilinks,
184
233
  imagePolicy: userConfig.markdown?.images ?? DEFAULTS.imagePolicy,
package/src/dev.ts CHANGED
@@ -8,6 +8,10 @@ interface DevOptions {
8
8
  port: number;
9
9
  }
10
10
 
11
+ function isStaticPathMatch(relPath: string, staticPath: string): boolean {
12
+ return relPath === staticPath || relPath.startsWith(`${staticPath}/`);
13
+ }
14
+
11
15
  async function resolveOutputFile(outDir: string, pathname: string): Promise<string | null> {
12
16
  const decoded = decodeURIComponent(pathname);
13
17
  const clean = decoded.replace(/^\/+/, "");
@@ -89,7 +93,14 @@ export async function runDev(options: BuildOptions, devOptions: DevOptions): Pro
89
93
  };
90
94
 
91
95
  watcher.on("all", (_event, changedPath) => {
92
- if (!/\.md$/i.test(changedPath)) {
96
+ const relPath = path.relative(options.vaultDir, changedPath).split(path.sep).join("/");
97
+ if (!relPath || relPath.startsWith("..")) {
98
+ return;
99
+ }
100
+
101
+ const isMarkdownChange = /\.md$/i.test(changedPath);
102
+ const isStaticChange = options.staticPaths.some((staticPath) => isStaticPathMatch(relPath, staticPath));
103
+ if (!isMarkdownChange && !isStaticChange) {
93
104
  return;
94
105
  }
95
106
  scheduleRebuild();
@@ -11,6 +11,7 @@ const DESKTOP_VIEWER_MIN = 680;
11
11
  const DESKTOP_SPLITTER_WIDTH = 10;
12
12
  const DESKTOP_SPLITTER_STEP = 24;
13
13
  const DEFAULT_BRANCH = "dev";
14
+ const DEFAULT_SITE_TITLE = "File-System Blog";
14
15
  const BRANCH_KEY = "fsblog.branch";
15
16
  const FOCUSABLE_SELECTOR =
16
17
  'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
@@ -130,6 +131,23 @@ function resolveRouteFromLocation(routeMap) {
130
131
  return direct;
131
132
  }
132
133
 
134
+ function resolveSiteTitle(manifest) {
135
+ const value = typeof manifest?.siteTitle === "string" ? manifest.siteTitle.trim() : "";
136
+ return value || DEFAULT_SITE_TITLE;
137
+ }
138
+
139
+ function composeDocumentTitle(pageTitle, siteTitle) {
140
+ const left = String(pageTitle ?? "").trim();
141
+ const right = String(siteTitle ?? "").trim();
142
+ if (!left) {
143
+ return right || DEFAULT_SITE_TITLE;
144
+ }
145
+ if (!right || left === right) {
146
+ return left;
147
+ }
148
+ return `${left} - ${right}`;
149
+ }
150
+
133
151
  function formatMetaDateTime(value) {
134
152
  const parsed = new Date(value);
135
153
  if (!Number.isFinite(parsed.getTime())) {
@@ -1127,6 +1145,7 @@ async function start() {
1127
1145
  }
1128
1146
  manifest = await manifestRes.json();
1129
1147
  }
1148
+ const siteTitle = resolveSiteTitle(manifest);
1130
1149
  const defaultBranch = normalizeBranch(manifest.defaultBranch) || DEFAULT_BRANCH;
1131
1150
  const availableBranchSet = new Set([defaultBranch]);
1132
1151
  for (const doc of manifest.docs) {
@@ -1296,7 +1315,7 @@ async function start() {
1296
1315
 
1297
1316
  if (shouldUseInitialView) {
1298
1317
  hasHydratedInitialView = true;
1299
- document.title = `${initialViewData.title} - File-System Blog`;
1318
+ document.title = composeDocumentTitle(initialViewData.title, siteTitle);
1300
1319
  if (viewerEl instanceof HTMLElement) {
1301
1320
  viewerEl.scrollTo(0, 0);
1302
1321
  }
@@ -1320,7 +1339,7 @@ async function start() {
1320
1339
 
1321
1340
  navEl.innerHTML = renderNav(view.docs, view.docIndexById, id);
1322
1341
 
1323
- document.title = `${doc.title} - File-System Blog`;
1342
+ document.title = composeDocumentTitle(doc.title, siteTitle);
1324
1343
  if (viewerEl instanceof HTMLElement) {
1325
1344
  viewerEl.scrollTo(0, 0);
1326
1345
  }
package/src/template.ts CHANGED
@@ -183,6 +183,9 @@ export function renderAppShellHtml(
183
183
  const initialManifestScript = renderInitialManifestScript(manifest);
184
184
  const symbolFontStylesheet =
185
185
  "https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap";
186
+ const appTitle = typeof manifest?.siteTitle === "string" && manifest.siteTitle.trim().length > 0
187
+ ? manifest.siteTitle.trim()
188
+ : DEFAULT_TITLE;
186
189
  const initialTitle = initialView ? escapeHtmlAttribute(initialView.title) : "문서를 선택하세요";
187
190
  const initialBreadcrumb = initialView ? initialView.breadcrumbHtml : "";
188
191
  const initialMeta = initialView ? initialView.metaHtml : "";
@@ -213,7 +216,7 @@ ${headMeta}
213
216
  <div class="sidebar-header">
214
217
  <h1 class="sidebar-title">
215
218
  <span class="material-symbols-outlined icon-terminal">terminal</span>
216
- ~/dev-blog
219
+ ${escapeHtmlAttribute(appTitle)}
217
220
  </h1>
218
221
  <button id="sidebar-close" class="sidebar-close" type="button" aria-label="탐색기 닫기">
219
222
  <span class="material-symbols-outlined">close</span>
package/src/types.ts CHANGED
@@ -39,6 +39,7 @@ export interface UserConfig {
39
39
  vaultDir?: string;
40
40
  outDir?: string;
41
41
  exclude?: string[];
42
+ staticPaths?: string[];
42
43
  pinnedMenu?: {
43
44
  label?: string;
44
45
  sourceDir: string;
@@ -63,8 +64,10 @@ export interface BuildOptions {
63
64
  vaultDir: string;
64
65
  outDir: string;
65
66
  exclude: string[];
67
+ staticPaths: string[];
66
68
  newWithinDays: number;
67
69
  recentLimit: number;
70
+ siteTitle?: string;
68
71
  pinnedMenu: PinnedMenuOption | null;
69
72
  wikilinks: boolean;
70
73
  imagePolicy: ImagePolicy;
@@ -123,6 +126,7 @@ export type TreeNode = FolderNode | FileNode;
123
126
 
124
127
  export interface Manifest {
125
128
  generatedAt: string;
129
+ siteTitle: string;
126
130
  defaultBranch: string;
127
131
  branches: string[];
128
132
  ui: {