for-humanity 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/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.4.1 — 2026-10-04
6
+
7
+ - Coalesce consecutive document and asset saves and serialize dev refreshes so bulk AI edits cannot start overlapping full-site renders and exhaust memory.
8
+ - Continue queued and later edits after failed processing, and apply only the latest valid generation.
9
+
10
+ ## 0.4.0 — 2026-10-04
11
+
12
+ - Copy explicitly referenced local images and supported attachments to fingerprinted asset URLs, resolving paths relative to their Markdown files in both build and dev.
13
+ - Support reference-style images and raw HTML image sources, preserve query/hash suffixes, and reject missing files, unsupported local image formats and paths or symlinks outside the document root.
14
+ - Reload referenced image and attachment changes in dev while retaining the previous valid document and resource map after failed processing.
15
+ - Mark only the current table-of-contents anchor as active, with `aria-current="location"`, while retaining the tree path through its ancestors.
16
+ - Add image documentation, purpose-based authoring guidance and fictional design and research examples.
17
+ - Keep legacy `type: document` and `type: blueprint` inputs compatible, recommend omitting `type`, and focus the roadmap on reusable visualization and theme modules rather than a dedicated Blueprint renderer.
18
+
5
19
  ## 0.3.0 — 2026-10-04
6
20
 
7
21
  - Support nested document pages with optional `parent` IDs, preserving existing groups and document URLs.
package/README.md CHANGED
@@ -33,7 +33,7 @@ Write Markdown. Keep the context. Share a static site.
33
33
  Node.js 22 or later and pnpm. Install the CLI in your document project:
34
34
 
35
35
  ```sh
36
- pnpm add -D --save-exact for-humanity@0.3.0
36
+ pnpm add -D --save-exact for-humanity@0.4.1
37
37
  mkdir docs
38
38
  ```
39
39
 
@@ -52,7 +52,7 @@ pnpm exec for-humanity preview docs
52
52
 
53
53
  If your Markdown files are in the project root, use `.` instead of `docs`. The root `README.md` becomes the home page; root `AGENTS.md` and `CLAUDE.md` stay out of the site.
54
54
 
55
- Version `0.3.0` supports `dev`, `build` and `preview`. `init` is planned; see the [Roadmap](https://github.com/minu-ha/for-humanity/blob/main/templates/document/roadmap.md) and [Changelog](https://github.com/minu-ha/for-humanity/blob/main/CHANGELOG.md).
55
+ Version `0.4.1` supports `dev`, `build` and `preview`. `init` is planned; see the [Roadmap](https://github.com/minu-ha/for-humanity/blob/main/templates/document/roadmap.md) and [Changelog](https://github.com/minu-ha/for-humanity/blob/main/CHANGELOG.md).
56
56
  Cloudflare Pages can host the generated site. See [Deployment](https://github.com/minu-ha/for-humanity/blob/main/templates/document/deployment.md) for the build settings.
57
57
 
58
58
  ### Develop the kit
@@ -74,7 +74,7 @@ pnpm preview # Preview the static site
74
74
  pnpm build:pages # Add Cloudflare Pages headers and a 404 page
75
75
  ```
76
76
 
77
- Changes pushed to `main` update the documentation site. A stable version tag such as `v0.3.0` runs checks, publishes the package to npm, then creates a GitHub Release with the same package file.
77
+ Changes pushed to `main` update the documentation site. A stable version tag such as `v0.4.1` runs checks, publishes the package to npm, then creates a GitHub Release with the same package file.
78
78
  The npm README updates with each package release. See [Maintenance](https://github.com/minu-ha/for-humanity/blob/main/templates/document/maintenance.md#packaging-and-release) for the release steps.
79
79
 
80
80
  ## Make it yours
@@ -109,7 +109,7 @@ The contract, code or reasoning behind the visible result.
109
109
  :::
110
110
  ```
111
111
 
112
- [Parts](https://github.com/minu-ha/for-humanity/blob/main/templates/document/parts.md) pairs source examples with their rendered results. The [Blueprint sample](https://github.com/minu-ha/for-humanity/blob/main/templates/document/blueprint.md) uses a fictional reading-list app to show a screen flow, decisions, open questions and illustrative API contracts.
112
+ [Parts](https://github.com/minu-ha/for-humanity/blob/main/templates/document/parts.md) pairs source examples with their rendered results. The [Design example](https://github.com/minu-ha/for-humanity/blob/main/templates/document/blueprint.md) uses a fictional reading-list app to show a screen flow, decisions, open questions and illustrative API contracts.
113
113
 
114
114
  ## Documentation
115
115
 
@@ -119,7 +119,7 @@ The contract, code or reasoning behind the visible result.
119
119
  | Guide | [Writing](https://github.com/minu-ha/for-humanity/blob/main/templates/document/writing.md) · [Parts](https://github.com/minu-ha/for-humanity/blob/main/templates/document/parts.md) · [Settings](https://github.com/minu-ha/for-humanity/blob/main/templates/document/settings.md) |
120
120
  | Reference | [API](https://github.com/minu-ha/for-humanity/blob/main/templates/document/api.md) |
121
121
  | Releases | [Official releases](https://github.com/minu-ha/for-humanity/blob/main/templates/document/releases.md) · [Changelog](https://github.com/minu-ha/for-humanity/blob/main/templates/document/changelog.md) |
122
- | Examples | [Fictional Blueprint](https://github.com/minu-ha/for-humanity/blob/main/templates/document/blueprint.md) |
122
+ | Examples | [Fictional design example](https://github.com/minu-ha/for-humanity/blob/main/templates/document/blueprint.md) |
123
123
  | Development | [Architecture](https://github.com/minu-ha/for-humanity/blob/main/templates/document/architecture.md) · [Design](https://github.com/minu-ha/for-humanity/blob/main/templates/document/design.md) · [Maintenance](https://github.com/minu-ha/for-humanity/blob/main/templates/document/maintenance.md) · [Roadmap](https://github.com/minu-ha/for-humanity/blob/main/templates/document/roadmap.md) |
124
124
 
125
125
  ## License
package/dist/cli.js CHANGED
@@ -2,12 +2,13 @@
2
2
 
3
3
  // src/cli.ts
4
4
  import { EventEmitter } from "node:events";
5
- import { existsSync as existsSync2, watch } from "node:fs";
5
+ import { existsSync as existsSync3, watch } from "node:fs";
6
6
  import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
7
- import { basename as basename2, dirname as dirname3, join as join4, relative as relative3, resolve as resolve2 } from "node:path";
7
+ import { basename as basename2, dirname as dirname4, extname as extname4, join as join4, relative as relative4, resolve as resolve3, sep as sep3 } from "node:path";
8
8
  import { fileURLToPath, pathToFileURL } from "node:url";
9
9
  import { serve } from "@hono/node-server";
10
10
  import { serveStatic } from "@hono/node-server/serve-static";
11
+ import { debounce } from "es-toolkit";
11
12
  import { Hono as Hono3 } from "hono";
12
13
  import { toSSG } from "hono/ssg";
13
14
 
@@ -149,6 +150,7 @@ var copy_error_unknown_directive = "\uBAA8\uB974\uB294 \uBD80\uD488\uC774\uB2E4"
149
150
  var copy_error_directive_syntax = "\uBD80\uD488 \uBB38\uBC95\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uB2E4";
150
151
  var copy_warn_broken_link = "\uC5C6\uB294 \uBB38\uC11C\uB85C \uAC74 \uB9C1\uD06C\uB2E4";
151
152
  var copy_warn_unknown_status = "\uC124\uC815\uC5D0 \uC5C6\uB294 \uC0C1\uD0DC \uBB38\uAD6C\uB2E4";
153
+ var copy_error_asset = "\uC790\uC6D0\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uB2E4";
152
154
 
153
155
  // src/constant/locale.ts
154
156
  var locale_doc_name = "en";
@@ -242,9 +244,21 @@ var asset_content_types = {
242
244
  ".css": "text/css; charset=utf-8",
243
245
  ".js": "text/javascript; charset=utf-8",
244
246
  ".woff2": "font/woff2",
245
- ".svg": "image/svg+xml"
247
+ ".svg": "image/svg+xml",
248
+ ".png": "image/png",
249
+ ".jpg": "image/jpeg",
250
+ ".jpeg": "image/jpeg",
251
+ ".gif": "image/gif",
252
+ ".webp": "image/webp",
253
+ ".avif": "image/avif",
254
+ ".pdf": "application/pdf",
255
+ ".csv": "text/csv; charset=utf-8",
256
+ ".txt": "text/plain; charset=utf-8",
257
+ ".zip": "application/zip"
246
258
  };
247
259
  var asset_content_type_default = "application/octet-stream";
260
+ var asset_media_dir = `/${asset_dir}/media`;
261
+ var asset_media_extensions = /* @__PURE__ */ new Set([".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".pdf", ".csv", ".txt", ".zip"]);
248
262
 
249
263
  // src/component/widget/shell/wg-shell.tsx
250
264
  import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
@@ -336,6 +350,7 @@ var cli_default_command = "dev";
336
350
  var cli_default_docs_dir = ".";
337
351
  var cli_config_file_name = "for-humanity.config.mjs";
338
352
  var cli_dev_port = 4321;
353
+ var cli_reload_delay_ms = 150;
339
354
 
340
355
  // src/constant/font.ts
341
356
  var font_sans_css = "src/asset/font/pretendard/pretendardvariable-dynamic-subset.css";
@@ -968,14 +983,67 @@ var remarkUnknownDirectives = () => (tree, file) => {
968
983
  });
969
984
  };
970
985
 
986
+ // src/content/rehype-assets.ts
987
+ import { existsSync as existsSync2, realpathSync, statSync } from "node:fs";
988
+ import { dirname as dirname2, extname, isAbsolute, relative as relative2, resolve as resolve2, sep } from "node:path";
989
+ import { visit as visit7 } from "unist-util-visit";
990
+
991
+ // src/util/file/to-asset-path.ts
992
+ import { createHash } from "node:crypto";
993
+ import { readFileSync } from "node:fs";
994
+ import { posix as posix2 } from "node:path";
995
+ var toAssetPath = (options) => {
996
+ const extension = posix2.extname(options.path);
997
+ return `${posix2.dirname(options.path)}/${posix2.basename(options.path, extension)}.${createHash("sha256").update(readFileSync(options.file)).digest("hex")}${extension}`;
998
+ };
999
+
1000
+ // src/content/rehype-assets.ts
1001
+ var rehypeAssets = (options) => (tree, file) => {
1002
+ visit7(tree, "element", (node) => {
1003
+ const property = node.tagName === "img" ? "src" : "href";
1004
+ const url = node.properties[property];
1005
+ if (node.tagName !== "img" && node.tagName !== "a" || typeof url !== "string" || /^(?:[a-z][a-z\d+.-]*:|\/|#)/i.test(url)) {
1006
+ return;
1007
+ }
1008
+ const match = /^([^?#]+)([?#].*)?$/.exec(url);
1009
+ if (match === null) {
1010
+ return;
1011
+ }
1012
+ const path = decodeURIComponent(match[1]);
1013
+ const extension = extname(path).toLowerCase();
1014
+ if (!asset_media_extensions.has(extension)) {
1015
+ if (node.tagName === "img") {
1016
+ file.fail(`${copy_error_asset}: \uC9C0\uC6D0\uD558\uC9C0 \uC54A\uB294 \uC774\uBBF8\uC9C0 \uD615\uC2DD \xB7 ${url}`, node);
1017
+ }
1018
+ return;
1019
+ }
1020
+ const target = resolve2(dirname2(file.path), path);
1021
+ const location = relative2(options.root, target);
1022
+ if (location === ".." || location.startsWith(`..${sep}`) || isAbsolute(location)) {
1023
+ file.fail(`${copy_error_asset}: \uBB38\uC11C \uD3F4\uB354 \uBC16 \xB7 ${url}`, node);
1024
+ }
1025
+ if (!existsSync2(target)) {
1026
+ file.fail(`${copy_error_asset}: \uD30C\uC77C\uC774 \uC5C6\uB2E4 \xB7 ${url}`, node);
1027
+ }
1028
+ const actual = realpathSync(target);
1029
+ const actualLocation = relative2(realpathSync(options.root), actual);
1030
+ if (actualLocation === ".." || actualLocation.startsWith(`..${sep}`) || isAbsolute(actualLocation) || !statSync(actual).isFile()) {
1031
+ file.fail(`${copy_error_asset}: \uBB38\uC11C \uD3F4\uB354 \uBC16\uC774\uAC70\uB098 \uC77C\uBC18 \uD30C\uC77C\uC774 \uC544\uB2C8\uB2E4 \xB7 ${url}`, node);
1032
+ }
1033
+ const assetUrl = toAssetPath({ path: `${asset_media_dir}/asset${extension}`, file: actual });
1034
+ options.files.set(assetUrl, actual);
1035
+ node.properties[property] = match[2] === void 0 ? assetUrl : `${assetUrl}${match[2]}`;
1036
+ });
1037
+ };
1038
+
971
1039
  // src/content/rehype-heading-ids.ts
972
1040
  import Slugger from "github-slugger";
973
1041
  import { toString as toPlainText3 } from "hast-util-to-string";
974
- import { visit as visit7 } from "unist-util-visit";
1042
+ import { visit as visit8 } from "unist-util-visit";
975
1043
  var rehypeHeadingIds = () => (tree, file) => {
976
1044
  const slugger = new Slugger();
977
1045
  const headings = [];
978
- visit7(tree, "element", (node) => {
1046
+ visit8(tree, "element", (node) => {
979
1047
  const depth = /^h([1-6])$/.exec(node.tagName)?.[1];
980
1048
  if (depth === void 0) {
981
1049
  return;
@@ -991,9 +1059,9 @@ var rehypeHeadingIds = () => (tree, file) => {
991
1059
  };
992
1060
 
993
1061
  // src/content/remark-report.ts
994
- import { relative as relative2 } from "node:path";
1062
+ import { relative as relative3 } from "node:path";
995
1063
  var remarkReport = (options) => (_tree, file) => {
996
- const path = relative2(options.root, file.path);
1064
+ const path = relative3(options.root, file.path);
997
1065
  for (const message of file.messages) {
998
1066
  console.warn(`${message.line === void 0 ? path : `${path}:${message.line}`} ${message.reason}`);
999
1067
  }
@@ -1001,12 +1069,12 @@ var remarkReport = (options) => (_tree, file) => {
1001
1069
 
1002
1070
  // src/content/create-processor.ts
1003
1071
  var createProcessor = (options) => {
1004
- return unified().use(remarkParse).use(remarkFrontmatter, ["yaml"]).use(remarkGfm).use(remarkSmartypants).use(remarkDirective).use(remarkBlocks).use(remarkParts).use(remarkFlow).use(remarkStatus, { status: options.site.status }).use(remarkSwatch).use(remarkLinks, { root: options.root }).use(remarkUnknownDirectives).use(remarkReport, { root: options.root }).use(remarkRehype, { allowDangerousHtml: true }).use(rehypeShiki, { theme: createCssVariablesTheme({ name: "for-humanity", variablePrefix: "--app-code-" }) }).use(rehypeHead).use(rehypeHeadingIds).use(rehypeSections).use(rehypeTables).use(rehypeRaw).use(rehypeStringify, { allowDangerousHtml: true });
1072
+ return unified().use(remarkParse).use(remarkFrontmatter, ["yaml"]).use(remarkGfm).use(remarkSmartypants).use(remarkDirective).use(remarkBlocks).use(remarkParts).use(remarkFlow).use(remarkStatus, { status: options.site.status }).use(remarkSwatch).use(remarkLinks, { root: options.root }).use(remarkUnknownDirectives).use(remarkReport, { root: options.root }).use(remarkRehype, { allowDangerousHtml: true }).use(rehypeShiki, { theme: createCssVariablesTheme({ name: "for-humanity", variablePrefix: "--app-code-" }) }).use(rehypeHead).use(rehypeHeadingIds).use(rehypeSections).use(rehypeTables).use(rehypeRaw).use(rehypeAssets, { root: options.root, files: options.files ?? /* @__PURE__ */ new Map() }).use(rehypeStringify, { allowDangerousHtml: true });
1005
1073
  };
1006
1074
 
1007
1075
  // src/content/read-docs.ts
1008
1076
  import { readdir, readFile } from "node:fs/promises";
1009
- import { join, posix as posix2, sep } from "node:path";
1077
+ import { join, posix as posix3, sep as sep2 } from "node:path";
1010
1078
  import { VFile } from "vfile";
1011
1079
  import { parse as parseYaml } from "yaml";
1012
1080
 
@@ -1035,7 +1103,7 @@ var docDataSchema = z2.object({
1035
1103
  */
1036
1104
  label: z2.string(),
1037
1105
  /**
1038
- * 문서 종류 · blueprint 전용 표현 미구현
1106
+ * 기존 type 값 호환 · 생략 권장, 렌더링 차이 없음
1039
1107
  */
1040
1108
  type: z2.enum(doc_type).default(doc_type.document),
1041
1109
  /**
@@ -1055,11 +1123,11 @@ var docDataSchema = z2.object({
1055
1123
  // src/content/read-docs.ts
1056
1124
  var readDocs = async (options) => {
1057
1125
  const paths = (await readdir(options.root, { recursive: true })).filter(
1058
- (path) => /\.mdx?$/i.test(path) && !/^(readme|agents|claude)\.md$/i.test(path) && !path.split(sep).includes("node_modules") && !path.startsWith(`dist${sep}`)
1126
+ (path) => /\.mdx?$/i.test(path) && !/^(readme|agents|claude)\.md$/i.test(path) && !path.split(sep2).includes("node_modules") && !path.startsWith(`dist${sep2}`)
1059
1127
  );
1060
1128
  const docs = await Promise.all(
1061
1129
  paths.map(async (path) => {
1062
- const id = path.replace(/\.mdx?$/i, "").split(sep).join("/").toLowerCase();
1130
+ const id = path.replace(/\.mdx?$/i, "").split(sep2).join("/").toLowerCase();
1063
1131
  const segments = id.split("/");
1064
1132
  if (segments.some((segment) => segment === "" || segment === "." || segment === ".." || segment === "index.html") || /[#?%*:\\]/.test(id) || /[\p{Cc}]/u.test(id) || segments[0] === asset_dir || segments[0] === asset_favicon_path.slice(1)) {
1065
1133
  throw new Error(`${copy_error_doc_id}: ${path}`);
@@ -1074,7 +1142,7 @@ var readDocs = async (options) => {
1074
1142
  }
1075
1143
  const data = {
1076
1144
  ...parsed.data,
1077
- parent: parsed.data.parent !== void 0 && /^\.\.?\//.test(parsed.data.parent) ? posix2.join(posix2.dirname(id), parsed.data.parent) : parsed.data.parent
1145
+ parent: parsed.data.parent !== void 0 && /^\.\.?\//.test(parsed.data.parent) ? posix3.join(posix3.dirname(id), parsed.data.parent) : parsed.data.parent
1078
1146
  };
1079
1147
  const fh = { frontmatter: data, headings: [], sections: [] };
1080
1148
  file.data.fh = fh;
@@ -1135,7 +1203,7 @@ var readHome = async (options) => {
1135
1203
 
1136
1204
  // src/dev.ts
1137
1205
  import { readFile as readFile3 } from "node:fs/promises";
1138
- import { extname } from "node:path";
1206
+ import { extname as extname2 } from "node:path";
1139
1207
  import { Hono as Hono2 } from "hono";
1140
1208
  import { streamSSE } from "hono/streaming";
1141
1209
  var createDevApp = (options) => {
@@ -1145,8 +1213,8 @@ var createDevApp = (options) => {
1145
1213
  if (file === void 0) {
1146
1214
  return c.notFound();
1147
1215
  }
1148
- c.header("content-type", asset_content_types[extname(file)] ?? asset_content_type_default);
1149
- if (extname(file) === ".woff2") {
1216
+ c.header("content-type", asset_content_types[extname2(file).toLowerCase()] ?? asset_content_type_default);
1217
+ if (extname2(file) === ".woff2") {
1150
1218
  c.header("Cache-Control", font_cache_control);
1151
1219
  }
1152
1220
  return c.body(await readFile3(file));
@@ -1222,6 +1290,32 @@ var siteConfigSchema = z3.object({
1222
1290
  ).default(status_default_phrases)
1223
1291
  });
1224
1292
 
1293
+ // src/util/async/to-change-queue.ts
1294
+ var toChangeQueue = (options) => {
1295
+ const state = { requested: 0, completed: 0, running: false };
1296
+ return async () => {
1297
+ state.requested += 1;
1298
+ if (state.running) {
1299
+ return;
1300
+ }
1301
+ state.running = true;
1302
+ try {
1303
+ while (state.completed !== state.requested) {
1304
+ const requested = state.requested;
1305
+ try {
1306
+ await options.run();
1307
+ } catch (error) {
1308
+ options.onError(error);
1309
+ } finally {
1310
+ state.completed = requested;
1311
+ }
1312
+ }
1313
+ } finally {
1314
+ state.running = false;
1315
+ }
1316
+ };
1317
+ };
1318
+
1225
1319
  // src/util/error/to-error-message.ts
1226
1320
  var toErrorMessage = (error) => {
1227
1321
  if (error instanceof Error && "file" in error && typeof error.file === "string" && "line" in error && typeof error.line === "number") {
@@ -1230,25 +1324,16 @@ var toErrorMessage = (error) => {
1230
1324
  return error instanceof Error ? error.message : String(error);
1231
1325
  };
1232
1326
 
1233
- // src/util/file/to-asset-path.ts
1234
- import { createHash } from "node:crypto";
1235
- import { readFileSync } from "node:fs";
1236
- import { posix as posix3 } from "node:path";
1237
- var toAssetPath = (options) => {
1238
- const extension = posix3.extname(options.path);
1239
- return `${posix3.dirname(options.path)}/${posix3.basename(options.path, extension)}.${createHash("sha256").update(readFileSync(options.file)).digest("hex")}${extension}`;
1240
- };
1241
-
1242
1327
  // src/util/font/to-font-css.ts
1243
1328
  import { createHash as createHash2 } from "node:crypto";
1244
1329
  import { readFileSync as readFileSync2 } from "node:fs";
1245
- import { basename, dirname as dirname2, extname as extname2, join as join3 } from "node:path";
1330
+ import { basename, dirname as dirname3, extname as extname3, join as join3 } from "node:path";
1246
1331
  var toFontCss = (options) => {
1247
1332
  const files = /* @__PURE__ */ new Map();
1248
1333
  const css = [...readFileSync2(options.css, "utf8").matchAll(/@font-face\s*\{[^}]*\}/g)].map(
1249
1334
  (match) => match[0].replace(/font-display:\s*[^;]+;/g, `font-display: ${font_display_strategy};`).replace(/url\(([^)]+)\)/g, (_match, relativeUrl) => {
1250
- const file = join3(dirname2(options.css), relativeUrl);
1251
- const extension = extname2(file);
1335
+ const file = join3(dirname3(options.css), relativeUrl);
1336
+ const extension = extname3(file);
1252
1337
  const hash = createHash2("sha256").update(readFileSync2(file)).digest("hex").slice(0, font_hash_length);
1253
1338
  const url = `${options.fontDir}/${basename(file, extension)}.${hash}${extension}`;
1254
1339
  files.set(url, file);
@@ -1266,14 +1351,14 @@ var main = async () => {
1266
1351
  throw new Error(`${copy_error_unknown_command}: ${command} (${commands.join(", ")})`);
1267
1352
  }
1268
1353
  const kitRoot = fileURLToPath(new URL("../", import.meta.url));
1269
- const docsRoot = resolve2(docsDir);
1354
+ const docsRoot = resolve3(docsDir);
1270
1355
  const outDir = join4(docsRoot, "dist");
1271
1356
  if (command === "preview") {
1272
1357
  const app2 = new Hono3();
1273
1358
  app2.use(
1274
1359
  "/*",
1275
1360
  serveStatic({
1276
- root: relative3(process.cwd(), outDir),
1361
+ root: relative4(process.cwd(), outDir),
1277
1362
  onFound: (_path, c) => {
1278
1363
  if (c.req.path.startsWith(`${asset_font_dir}/`)) {
1279
1364
  c.header("Cache-Control", font_cache_control);
@@ -1285,7 +1370,7 @@ var main = async () => {
1285
1370
  return;
1286
1371
  }
1287
1372
  const configFile = join4(docsRoot, cli_config_file_name);
1288
- const parsedConfig = siteConfigSchema.safeParse(existsSync2(configFile) ? (await import(pathToFileURL(configFile).href)).default : site_config_absent);
1373
+ const parsedConfig = siteConfigSchema.safeParse(existsSync3(configFile) ? (await import(pathToFileURL(configFile).href)).default : site_config_absent);
1289
1374
  if (!parsedConfig.success) {
1290
1375
  throw new Error(`${copy_error_config}: ${parsedConfig.error.issues.map((issue) => `${issue.path.join(".")} ${issue.message}`).join(", ")}`);
1291
1376
  }
@@ -1300,7 +1385,7 @@ var main = async () => {
1300
1385
  const fontCss = [sans.css, mono.css, brand.css].join("\n");
1301
1386
  const stylePath = toAssetPath({ path: asset_style_path, file: join4(kitRoot, "dist/cli.css") });
1302
1387
  const clientPath = toAssetPath({ path: asset_client_path, file: join4(kitRoot, "dist/client.js") });
1303
- const files = new Map([
1388
+ const kitFiles = new Map([
1304
1389
  [stylePath, join4(kitRoot, "dist/cli.css")],
1305
1390
  [clientPath, join4(kitRoot, "dist/client.js")],
1306
1391
  [asset_favicon_path, join4(kitRoot, "src/asset/favicon.svg")],
@@ -1308,7 +1393,8 @@ var main = async () => {
1308
1393
  ...mono.files,
1309
1394
  ...brand.files
1310
1395
  ]);
1311
- const processor = createProcessor({ site: siteConfig, root: docsRoot });
1396
+ const files = new Map(kitFiles);
1397
+ const processor = createProcessor({ site: siteConfig, root: docsRoot, files });
1312
1398
  const [docs, home] = await Promise.all([readDocs({ root: docsRoot, processor }), readHome({ root: docsRoot, processor })]);
1313
1399
  const store = { docs, home };
1314
1400
  const app = createApp({
@@ -1323,31 +1409,46 @@ var main = async () => {
1323
1409
  throw result.error;
1324
1410
  }
1325
1411
  for (const [url, file] of files) {
1326
- await mkdir(dirname3(join4(outDir, url)), { recursive: true });
1412
+ await mkdir(dirname4(join4(outDir, url)), { recursive: true });
1327
1413
  await copyFile(file, join4(outDir, url));
1328
1414
  }
1329
- console.log(`${result.files.length} pages, ${files.size} assets \u2192 ${relative3(process.cwd(), outDir)}`);
1415
+ console.log(`${result.files.length} pages, ${files.size} assets \u2192 ${relative4(process.cwd(), outDir)}`);
1330
1416
  return;
1331
1417
  }
1332
1418
  const reload = new EventEmitter();
1333
1419
  let generation = 0;
1334
1420
  serve({ fetch: createDevApp({ pages: app, files, reload }).fetch, port: cli_dev_port }, (info) => console.log(`http://localhost:${info.port}/`));
1335
- const handleDocsChange = async (_event, filename) => {
1336
- if (filename !== null && !/\.mdx?$/i.test(filename)) {
1337
- return;
1338
- }
1339
- const currentGeneration = ++generation;
1340
- try {
1341
- const [docs2, home2] = await Promise.all([readDocs({ root: docsRoot, processor }), readHome({ root: docsRoot, processor })]);
1342
- if (currentGeneration !== generation) {
1421
+ const reloadDocs = debounce(
1422
+ toChangeQueue({
1423
+ run: async () => {
1424
+ const currentGeneration = generation;
1425
+ const nextFiles = new Map(kitFiles);
1426
+ const nextProcessor = createProcessor({ site: siteConfig, root: docsRoot, files: nextFiles });
1427
+ const [docs2, home2] = await Promise.all([readDocs({ root: docsRoot, processor: nextProcessor }), readHome({ root: docsRoot, processor: nextProcessor })]);
1428
+ if (currentGeneration !== generation) {
1429
+ return;
1430
+ }
1431
+ files.clear();
1432
+ for (const entry of nextFiles) {
1433
+ files.set(...entry);
1434
+ }
1435
+ store.docs = docs2;
1436
+ store.home = home2;
1437
+ reload.emit("change");
1438
+ },
1439
+ onError: (error) => console.error(`${copy_error_prefix}: ${toErrorMessage(error)}`)
1440
+ }),
1441
+ cli_reload_delay_ms
1442
+ );
1443
+ const handleDocsChange = (_event, filename) => {
1444
+ if (filename !== null) {
1445
+ const segments = filename.split(sep3);
1446
+ if (segments.includes("dist") || segments.includes("node_modules") || !/\.mdx?$/i.test(filename) && !asset_media_extensions.has(extname4(filename).toLowerCase())) {
1343
1447
  return;
1344
1448
  }
1345
- store.docs = docs2;
1346
- store.home = home2;
1347
- reload.emit("change");
1348
- } catch (error) {
1349
- console.error(`${copy_error_prefix}: ${toErrorMessage(error)}`);
1350
1449
  }
1450
+ generation += 1;
1451
+ reloadDocs();
1351
1452
  };
1352
1453
  watch(docsRoot, { recursive: true }, handleDocsChange);
1353
1454
  };
package/dist/client.js CHANGED
@@ -1,5 +1,5 @@
1
- var L="fh-cursor-face";var S="(width < 1024px)";var p=e=>{if(!e)return null;try{return document.getElementById(decodeURIComponent(e.slice(1)))}catch(t){if(t instanceof URIError)return null;throw t}};var m=e=>{let t=p(e);for(let n=t?.closest("details");n;n=n.parentElement?.closest("details"))n.open=!0;return t};var k=()=>{let e=document.querySelector("[data-navigation]"),t=document.querySelector("[data-navigation-drawer]"),n=document.querySelector("[data-navigation-content]"),r=document.querySelector("[data-navigation-open]"),u=document.querySelector("[data-navigation-close]"),d=e?.parentElement;if(!e||!t||!n||!r||!u||!d)return;let s=matchMedia(S),l=()=>{t.open||(document.body.classList.remove("wg_shell__root--drawerOpen"),r.setAttribute("aria-expanded","false"))},h=()=>{t.close(),l()},a=()=>{!s.matches||t.open||(t.showModal(),document.body.classList.add("wg_shell__root--drawerOpen"),r.setAttribute("aria-expanded","true"))},c=o=>{let i=t.getBoundingClientRect();o.target===t&&(o.clientX<i.left||o.clientX>i.right||o.clientY<i.top||o.clientY>i.bottom)&&h()},q=o=>{if(!t.open||o.defaultPrevented||o.button!==0||o.metaKey||o.ctrlKey||o.shiftKey||o.altKey||!(o.target instanceof Element))return;let i=o.target.closest("a[href]");if(!(!(i instanceof HTMLAnchorElement)||i.target!==""&&i.target!=="_self"||i.hasAttribute("download"))&&(h(),i.origin===location.origin&&i.pathname===location.pathname&&i.search===location.search&&i.hash)){let T=m(i.hash);T&&(T.tabIndex=-1,T.focus({preventScroll:!0}))}},H=()=>{let o=document.activeElement;if(s.matches){r.classList.add("wg_shell__menuToggle--visible"),n.append(e),o instanceof HTMLElement&&e.contains(o)&&r.focus({preventScroll:!0});return}h(),d.append(e),o instanceof HTMLElement&&e.contains(o)?o.focus({preventScroll:!0}):(o===r||o instanceof HTMLElement&&t.contains(o))&&d.querySelector(".wg_shell__brand")?.focus({preventScroll:!0}),r.classList.remove("wg_shell__menuToggle--visible")};r.addEventListener("click",a),u.addEventListener("click",h),t.addEventListener("close",l),t.addEventListener("click",c),e.addEventListener("click",q),s.addEventListener("change",H),H(),d.classList.add("wg_shell__sidebar--navigationReady")};k();var g=document.querySelector("[data-cursor-face]");if(g){let e=matchMedia("(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)"),t={x:0,y:0,frame:0},n=()=>{cancelAnimationFrame(t.frame),t.frame=0,g.classList.remove("wg_shell__cursorFace--visible")},r=()=>{e.matches||n()},u=()=>{t.frame===0&&(t.frame=requestAnimationFrame(()=>{t.frame=0,g.style.transform=`translate3d(
1
+ var L="fh-cursor-face";var k="(width < 1024px)";var p=e=>{if(!e)return null;try{return document.getElementById(decodeURIComponent(e.slice(1)))}catch(t){if(t instanceof URIError)return null;throw t}};var m=e=>{let t=p(e);for(let n=t?.closest("details");n;n=n.parentElement?.closest("details"))n.open=!0;return t};var S=()=>{let e=document.querySelector("[data-navigation]"),t=document.querySelector("[data-navigation-drawer]"),n=document.querySelector("[data-navigation-content]"),i=document.querySelector("[data-navigation-open]"),d=document.querySelector("[data-navigation-close]"),c=e?.parentElement;if(!e||!t||!n||!i||!d||!c)return;let l=matchMedia(k),a=()=>{t.open||(document.body.classList.remove("wg_shell__root--drawerOpen"),i.setAttribute("aria-expanded","false"))},u=()=>{t.close(),a()},h=()=>{!l.matches||t.open||(t.showModal(),document.body.classList.add("wg_shell__root--drawerOpen"),i.setAttribute("aria-expanded","true"))},r=o=>{let s=t.getBoundingClientRect();o.target===t&&(o.clientX<s.left||o.clientX>s.right||o.clientY<s.top||o.clientY>s.bottom)&&u()},q=o=>{if(!t.open||o.defaultPrevented||o.button!==0||o.metaKey||o.ctrlKey||o.shiftKey||o.altKey||!(o.target instanceof Element))return;let s=o.target.closest("a[href]");if(!(!(s instanceof HTMLAnchorElement)||s.target!==""&&s.target!=="_self"||s.hasAttribute("download"))&&(u(),s.origin===location.origin&&s.pathname===location.pathname&&s.search===location.search&&s.hash)){let T=m(s.hash);T&&(T.tabIndex=-1,T.focus({preventScroll:!0}))}},H=()=>{let o=document.activeElement;if(l.matches){i.classList.add("wg_shell__menuToggle--visible"),n.append(e),o instanceof HTMLElement&&e.contains(o)&&i.focus({preventScroll:!0});return}u(),c.append(e),o instanceof HTMLElement&&e.contains(o)?o.focus({preventScroll:!0}):(o===i||o instanceof HTMLElement&&t.contains(o))&&c.querySelector(".wg_shell__brand")?.focus({preventScroll:!0}),i.classList.remove("wg_shell__menuToggle--visible")};i.addEventListener("click",h),d.addEventListener("click",u),t.addEventListener("close",a),t.addEventListener("click",r),e.addEventListener("click",q),l.addEventListener("change",H),H(),c.classList.add("wg_shell__sidebar--navigationReady")};S();var g=document.querySelector("[data-cursor-face]");if(g){let e=matchMedia("(hover: hover) and (pointer: fine) and (prefers-reduced-motion: no-preference)"),t={x:0,y:0,frame:0},n=()=>{cancelAnimationFrame(t.frame),t.frame=0,g.classList.remove("wg_shell__cursorFace--visible")},i=()=>{e.matches||n()},d=()=>{t.frame===0&&(t.frame=requestAnimationFrame(()=>{t.frame=0,g.style.transform=`translate3d(
2
2
  ${Math.max(0,Math.min(t.x+16,document.documentElement.clientWidth-g.width-16))}px,
3
3
  ${Math.max(0,Math.min(t.y+16,document.documentElement.clientHeight-g.height-16))}px,
4
4
  0
5
- )`,g.classList.add("wg_shell__cursorFace--visible")}))},d=a=>{if(!(e.matches&&a.pointerType==="mouse")){n();return}t.x=a.clientX,t.y=a.clientY,u()},s=()=>{g.classList.contains("wg_shell__cursorFace--visible")&&u()},l=()=>{if(g.classList.contains("wg_shell__cursorFace--visible"))try{sessionStorage.setItem(L,JSON.stringify({x:t.x,y:t.y}))}catch{}},h=()=>{try{let a=sessionStorage.getItem(L);if(sessionStorage.removeItem(L),a!==null&&e.matches){let c=JSON.parse(a);typeof c=="object"&&c!==null&&"x"in c&&"y"in c&&typeof c.x=="number"&&typeof c.y=="number"&&Number.isFinite(c.x)&&Number.isFinite(c.y)&&(t.x=c.x,t.y=c.y,u())}}catch{}};h(),document.addEventListener("pointermove",d,{passive:!0}),document.addEventListener("pointerdown",d,{passive:!0}),document.documentElement.addEventListener("pointerleave",n),addEventListener("blur",n),addEventListener("resize",s),addEventListener("pagehide",l),addEventListener("pageshow",h),e.addEventListener("change",r)}var v=m(location.hash),_=null,b=0,f=null,x=()=>{clearTimeout(b),f?.abort(),f=null,_?.classList.remove("wg_prose__headingText--highlighted"),_=null},M=e=>{x(),_=e===null?null:e.querySelector(".wg_prose__headingText"),_!==null&&(_.classList.add("wg_prose__headingText--highlighted"),b=window.setTimeout(x,3e3),f=new AbortController,document.addEventListener("scrollend",B,{signal:f.signal}))},B=e=>{e.target===document&&(clearTimeout(b),f?.abort(),f=null,b=window.setTimeout(x,3e3))},N=e=>{if(!(e.defaultPrevented||e.button!==0||e.metaKey||e.ctrlKey||e.shiftKey||e.altKey)&&e.currentTarget instanceof HTMLAnchorElement&&(e.currentTarget.target===""||e.currentTarget.target==="_self")&&!e.currentTarget.hasAttribute("download")&&e.currentTarget.origin===location.origin&&e.currentTarget.pathname===location.pathname&&e.currentTarget.search===location.search&&e.currentTarget.hash){let t=m(e.currentTarget.hash);e.currentTarget.hash===location.hash&&M(t)}},K=()=>{let e=m(location.hash);e?.scrollIntoView(),M(e)};for(let e of document.querySelectorAll("a[href]"))e.addEventListener("click",N);addEventListener("hashchange",K);var y=[...document.querySelectorAll("[data-toc-link]")].flatMap(e=>{let t=p(e.hash),n=e.parentElement;return!t||!n?[]:[{heading:t,link:e,item:n,list:n.parentElement,subs:[...n.querySelectorAll("[data-toc-sub-link]")].flatMap(r=>{let u=p(r.hash),d=r.parentElement;return u&&d?[{heading:u,link:r,item:d}]:[]})}]}),w=y.at(0);if(w){let e=()=>{let t=Number.parseFloat(getComputedStyle(w.heading).scrollMarginTop)+1,n=y.findLast(s=>s.heading.checkVisibility()&&s.heading.getBoundingClientRect().top<=t)??w,r=n.subs.findLast(s=>s.heading.checkVisibility()&&s.heading.getBoundingClientRect().top<=t),u=y.indexOf(n),d=r===void 0?-1:n.subs.indexOf(r);for(let[s,l]of y.entries()){l.link.classList.toggle("wg_shellToc__link--active",l===n),l.item.classList.toggle("wg_shellToc__item--trail",l.list===n.list&&s<u),l.item.classList.toggle("wg_shellToc__item--current",l===n);for(let[h,a]of l.subs.entries())a.link.classList.toggle("wg_shellToc__subLink--active",a===r),a.item.classList.toggle("wg_shellToc__subItem--trail",l===n&&h<d),a.item.classList.toggle("wg_shellToc__subItem--current",a===r)}};addEventListener("scroll",e,{passive:!0}),addEventListener("resize",e),addEventListener("toggle",e,!0),e()}var I=new AbortController;for(let e of["wheel","touchmove","keydown","mousedown"])addEventListener(e,()=>I.abort(),{once:!0,passive:!0});var A=async()=>{await document.fonts.ready,v&&!I.signal.aborted&&(v.scrollIntoView({behavior:"instant"}),M(v)),document.documentElement.style.removeProperty("scroll-behavior")};document.readyState==="complete"?A():addEventListener("load",A,{once:!0});
5
+ )`,g.classList.add("wg_shell__cursorFace--visible")}))},c=h=>{if(!(e.matches&&h.pointerType==="mouse")){n();return}t.x=h.clientX,t.y=h.clientY,d()},l=()=>{g.classList.contains("wg_shell__cursorFace--visible")&&d()},a=()=>{if(g.classList.contains("wg_shell__cursorFace--visible"))try{sessionStorage.setItem(L,JSON.stringify({x:t.x,y:t.y}))}catch{}},u=()=>{try{let h=sessionStorage.getItem(L);if(sessionStorage.removeItem(L),h!==null&&e.matches){let r=JSON.parse(h);typeof r=="object"&&r!==null&&"x"in r&&"y"in r&&typeof r.x=="number"&&typeof r.y=="number"&&Number.isFinite(r.x)&&Number.isFinite(r.y)&&(t.x=r.x,t.y=r.y,d())}}catch{}};u(),document.addEventListener("pointermove",c,{passive:!0}),document.addEventListener("pointerdown",c,{passive:!0}),document.documentElement.addEventListener("pointerleave",n),addEventListener("blur",n),addEventListener("resize",l),addEventListener("pagehide",a),addEventListener("pageshow",u),e.addEventListener("change",i)}var v=m(location.hash),_=null,y=0,f=null,x=()=>{clearTimeout(y),f?.abort(),f=null,_?.classList.remove("wg_prose__headingText--highlighted"),_=null},M=e=>{x(),_=e===null?null:e.querySelector(".wg_prose__headingText"),_!==null&&(_.classList.add("wg_prose__headingText--highlighted"),y=window.setTimeout(x,3e3),f=new AbortController,document.addEventListener("scrollend",B,{signal:f.signal}))},B=e=>{e.target===document&&(clearTimeout(y),f?.abort(),f=null,y=window.setTimeout(x,3e3))},N=e=>{if(!(e.defaultPrevented||e.button!==0||e.metaKey||e.ctrlKey||e.shiftKey||e.altKey)&&e.currentTarget instanceof HTMLAnchorElement&&(e.currentTarget.target===""||e.currentTarget.target==="_self")&&!e.currentTarget.hasAttribute("download")&&e.currentTarget.origin===location.origin&&e.currentTarget.pathname===location.pathname&&e.currentTarget.search===location.search&&e.currentTarget.hash){let t=m(e.currentTarget.hash);e.currentTarget.hash===location.hash&&M(t)}},K=()=>{let e=m(location.hash);e?.scrollIntoView(),M(e)};for(let e of document.querySelectorAll("a[href]"))e.addEventListener("click",N);addEventListener("hashchange",K);var E=[...document.querySelectorAll("[data-toc-link]")].flatMap(e=>{let t=p(e.hash),n=e.parentElement;return!t||!n?[]:[{heading:t,link:e,item:n,list:n.parentElement,subs:[...n.querySelectorAll("[data-toc-sub-link]")].flatMap(i=>{let d=p(i.hash),c=i.parentElement;return d&&c?[{heading:d,link:i,item:c}]:[]})}]}),w=E.at(0);if(w){let e=()=>{let t=Number.parseFloat(getComputedStyle(w.heading).scrollMarginTop)+1,n=E.findLast(l=>l.heading.checkVisibility()&&l.heading.getBoundingClientRect().top<=t)??w,i=n.subs.findLast(l=>l.heading.checkVisibility()&&l.heading.getBoundingClientRect().top<=t),d=E.indexOf(n),c=i===void 0?-1:n.subs.indexOf(i);for(let[l,a]of E.entries()){let u=a===n&&i===void 0;a.link.classList.toggle("wg_shellToc__link--active",u),u?a.link.setAttribute("aria-current","location"):a.link.removeAttribute("aria-current"),a.item.classList.toggle("wg_shellToc__item--trail",a.list===n.list&&l<d),a.item.classList.toggle("wg_shellToc__item--current",a===n);for(let[h,r]of a.subs.entries())r.link.classList.toggle("wg_shellToc__subLink--active",r===i),r===i?r.link.setAttribute("aria-current","location"):r.link.removeAttribute("aria-current"),r.item.classList.toggle("wg_shellToc__subItem--trail",a===n&&h<c),r.item.classList.toggle("wg_shellToc__subItem--current",r===i)}};addEventListener("scroll",e,{passive:!0}),addEventListener("resize",e),addEventListener("toggle",e,!0),e()}var I=new AbortController;for(let e of["wheel","touchmove","keydown","mousedown"])addEventListener(e,()=>I.abort(),{once:!0,passive:!0});var A=async()=>{await document.fonts.ready,v&&!I.signal.aborted&&(v.scrollIntoView({behavior:"instant"}),M(v)),document.documentElement.style.removeProperty("scroll-behavior")};document.readyState==="complete"?A():addEventListener("load",A,{once:!0});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "for-humanity",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Documents written for humans to read: a docs kit with a sidebar, parts, sections and flowcharts, built on Hono and React",
5
5
  "keywords": [
6
6
  "docs",
@@ -41,7 +41,7 @@
41
41
  "build": "pnpm sync:docs && pnpm build:kit && node dist/cli.js build templates/document",
42
42
  "build:pages": "pnpm build && node util/prepare-pages.mjs",
43
43
  "preview": "node dist/cli.js preview templates/document",
44
- "test": "esbuild src/util/font/to-font-css.test.ts src/content/read-docs.test.ts --bundle --platform=node --format=esm --packages=external --outbase=src --outdir=node_modules/.cache/for-humanity-tests --out-extension:.js=.mjs && node --test node_modules/.cache/for-humanity-tests/content/*.test.mjs node_modules/.cache/for-humanity-tests/util/font/*.test.mjs util/*.test.mjs",
44
+ "test": "esbuild src/util/async/to-change-queue.test.ts src/util/font/to-font-css.test.ts src/content/read-docs.test.ts --bundle --platform=node --format=esm --packages=external --outbase=src --outdir=node_modules/.cache/for-humanity-tests --out-extension:.js=.mjs && node --test node_modules/.cache/for-humanity-tests/util/async/*.test.mjs node_modules/.cache/for-humanity-tests/content/*.test.mjs node_modules/.cache/for-humanity-tests/util/font/*.test.mjs util/*.test.mjs",
45
45
  "lint": "biome check && stylelint \"src/**/*.css\"",
46
46
  "lint:fix": "biome check --write && stylelint \"src/**/*.css\" --fix",
47
47
  "typecheck": "tsc --noEmit",
package/src/cli.ts CHANGED
@@ -7,15 +7,16 @@
7
7
  import {EventEmitter} from "node:events";
8
8
  import {existsSync, watch} from "node:fs";
9
9
  import {copyFile, mkdir, rm, writeFile} from "node:fs/promises";
10
- import {basename, dirname, join, relative, resolve} from "node:path";
10
+ import {basename, dirname, extname, join, relative, resolve, sep} from "node:path";
11
11
  import {fileURLToPath, pathToFileURL} from "node:url";
12
12
  import {serve} from "@hono/node-server";
13
13
  import {serveStatic} from "@hono/node-server/serve-static";
14
+ import {debounce} from "es-toolkit";
14
15
  import {Hono} from "hono";
15
16
  import {toSSG} from "hono/ssg";
16
17
  import {createApp} from "@/app";
17
- import {asset_client_path, asset_favicon_path, asset_font_dir, asset_style_path} from "@/constant/asset";
18
- import {cli_config_file_name, cli_default_command, cli_default_docs_dir, cli_dev_port} from "@/constant/cli";
18
+ import {asset_client_path, asset_favicon_path, asset_font_dir, asset_media_extensions, asset_style_path} from "@/constant/asset";
19
+ import {cli_config_file_name, cli_default_command, cli_default_docs_dir, cli_dev_port, cli_reload_delay_ms} from "@/constant/cli";
19
20
  import {copy_error_config, copy_error_font_preload, copy_error_prefix, copy_error_unknown_command} from "@/constant/copy";
20
21
  import {font_brand_css, font_cache_control, font_mono_css, font_sans_css, font_sans_preload_file} from "@/constant/font";
21
22
  import {site_config_absent} from "@/constant/site";
@@ -24,6 +25,7 @@ import {readDocs} from "@/content/read-docs";
24
25
  import {readHome} from "@/content/read-home";
25
26
  import {createDevApp} from "@/dev";
26
27
  import {siteConfigSchema} from "@/type/site-config";
28
+ import {toChangeQueue} from "@/util/async/to-change-queue";
27
29
  import {toErrorMessage} from "@/util/error/to-error-message";
28
30
  import {toAssetPath} from "@/util/file/to-asset-path";
29
31
  import {toFontCss} from "@/util/font/to-font-css";
@@ -85,7 +87,7 @@ const main = async () => {
85
87
  const fontCss = [sans.css, mono.css, brand.css].join("\n");
86
88
  const stylePath = toAssetPath({path: asset_style_path, file: join(kitRoot, "dist/cli.css")});
87
89
  const clientPath = toAssetPath({path: asset_client_path, file: join(kitRoot, "dist/client.js")});
88
- const files = new Map([
90
+ const kitFiles = new Map([
89
91
  [stylePath, join(kitRoot, "dist/cli.css")],
90
92
  [clientPath, join(kitRoot, "dist/client.js")],
91
93
  [asset_favicon_path, join(kitRoot, "src/asset/favicon.svg")],
@@ -93,7 +95,8 @@ const main = async () => {
93
95
  ...mono.files,
94
96
  ...brand.files,
95
97
  ]);
96
- const processor = createProcessor({site: siteConfig, root: docsRoot});
98
+ const files = new Map(kitFiles);
99
+ const processor = createProcessor({site: siteConfig, root: docsRoot, files});
97
100
  const [docs, home] = await Promise.all([readDocs({root: docsRoot, processor}), readHome({root: docsRoot, processor})]);
98
101
  const store = {docs, home};
99
102
  const app = createApp({
@@ -128,30 +131,46 @@ const main = async () => {
128
131
  serve({fetch: createDevApp({pages: app, files, reload}).fetch, port: cli_dev_port}, (info) => console.log(`http://localhost:${info.port}/`));
129
132
 
130
133
  /**
131
- * Markdown 변경 시 전체 재처리와 새로고침
132
- * 재처리 실패 시 직전 정상 문서 유지
134
+ * 갱신은 하나씩 실행 · 연속 저장 합치기 · 최신 변경만 적용
135
+ * 문서 수가 많아도 파일 이벤트마다 전체 렌더링이 동시에 쌓이지 않음
133
136
  */
134
- const handleDocsChange = async (_event: string, filename: string | null) => {
135
- if (filename !== null && !/\.mdx?$/i.test(filename)) {
136
- return;
137
- }
138
-
139
- const currentGeneration = ++generation;
140
-
141
- try {
142
- const [docs, home] = await Promise.all([readDocs({root: docsRoot, processor}), readHome({root: docsRoot, processor})]);
137
+ const reloadDocs = debounce(
138
+ toChangeQueue({
139
+ run: async () => {
140
+ const currentGeneration = generation;
141
+ const nextFiles = new Map(kitFiles);
142
+ const nextProcessor = createProcessor({site: siteConfig, root: docsRoot, files: nextFiles});
143
+ const [docs, home] = await Promise.all([readDocs({root: docsRoot, processor: nextProcessor}), readHome({root: docsRoot, processor: nextProcessor})]);
144
+
145
+ if (currentGeneration !== generation) {
146
+ return;
147
+ }
148
+
149
+ files.clear();
150
+ for (const entry of nextFiles) {
151
+ files.set(...entry);
152
+ }
153
+ store.docs = docs;
154
+ store.home = home;
155
+ reload.emit("change");
156
+ },
157
+ onError: (error) => console.error(`${copy_error_prefix}: ${toErrorMessage(error)}`),
158
+ }),
159
+ cli_reload_delay_ms,
160
+ );
143
161
 
144
- // 최신 변경의 처리 결과만 반영
145
- if (currentGeneration !== generation) {
162
+ /**
163
+ * 입력 변경 감지 · 출력·설치 폴더 제외 · 누락 자원 추가도 다시 확인
164
+ */
165
+ const handleDocsChange = (_event: string, filename: string | null) => {
166
+ if (filename !== null) {
167
+ const segments = filename.split(sep);
168
+ if (segments.includes("dist") || segments.includes("node_modules") || (!/\.mdx?$/i.test(filename) && !asset_media_extensions.has(extname(filename).toLowerCase()))) {
146
169
  return;
147
170
  }
148
-
149
- store.docs = docs;
150
- store.home = home;
151
- reload.emit("change");
152
- } catch (error) {
153
- console.error(`${copy_error_prefix}: ${toErrorMessage(error)}`);
154
171
  }
172
+ generation += 1;
173
+ reloadDocs();
155
174
  };
156
175
 
157
176
  watch(docsRoot, {recursive: true}, handleDocsChange);
package/src/client.ts CHANGED
@@ -275,12 +275,23 @@ if (firstSection) {
275
275
  const currentSubIndex = sub === undefined ? -1 : current.subs.indexOf(sub);
276
276
 
277
277
  for (const [index, section] of tocSections.entries()) {
278
- section.link.classList.toggle("wg_shellToc__link--active", section === current);
278
+ const active = section === current && sub === undefined;
279
+ section.link.classList.toggle("wg_shellToc__link--active", active);
280
+ if (active) {
281
+ section.link.setAttribute("aria-current", "location");
282
+ } else {
283
+ section.link.removeAttribute("aria-current");
284
+ }
279
285
  section.item.classList.toggle("wg_shellToc__item--trail", section.list === current.list && index < currentIndex);
280
286
  section.item.classList.toggle("wg_shellToc__item--current", section === current);
281
287
 
282
288
  for (const [subIndex, item] of section.subs.entries()) {
283
289
  item.link.classList.toggle("wg_shellToc__subLink--active", item === sub);
290
+ if (item === sub) {
291
+ item.link.setAttribute("aria-current", "location");
292
+ } else {
293
+ item.link.removeAttribute("aria-current");
294
+ }
284
295
  item.item.classList.toggle("wg_shellToc__subItem--trail", section === current && subIndex < currentSubIndex);
285
296
  item.item.classList.toggle("wg_shellToc__subItem--current", item === sub);
286
297
  }
@@ -40,9 +40,29 @@ export const asset_content_types: Record<string, string> = {
40
40
  ".js": "text/javascript; charset=utf-8",
41
41
  ".woff2": "font/woff2",
42
42
  ".svg": "image/svg+xml",
43
+ ".png": "image/png",
44
+ ".jpg": "image/jpeg",
45
+ ".jpeg": "image/jpeg",
46
+ ".gif": "image/gif",
47
+ ".webp": "image/webp",
48
+ ".avif": "image/avif",
49
+ ".pdf": "application/pdf",
50
+ ".csv": "text/csv; charset=utf-8",
51
+ ".txt": "text/plain; charset=utf-8",
52
+ ".zip": "application/zip",
43
53
  };
44
54
 
45
55
  /**
46
56
  * 미등록 확장자의 content-type
47
57
  */
48
58
  export const asset_content_type_default = "application/octet-stream";
59
+
60
+ /**
61
+ * 문서에서 참조한 그림·첨부 자원의 예약 경로
62
+ */
63
+ export const asset_media_dir = `/${asset_dir}/media`;
64
+
65
+ /**
66
+ * 명시적 로컬 참조로 배포할 수 있는 파일 형식 · 실행 파일·소스·설정 제외
67
+ */
68
+ export const asset_media_extensions = new Set([".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".pdf", ".csv", ".txt", ".zip"]);
@@ -17,3 +17,8 @@ export const cli_config_file_name = "for-humanity.config.mjs";
17
17
  * dev·preview 공통 포트
18
18
  */
19
19
  export const cli_dev_port = 4321;
20
+
21
+ /**
22
+ * 연속 저장을 하나의 문서 갱신으로 합치는 대기 시간 · ms
23
+ */
24
+ export const cli_reload_delay_ms = 150;
@@ -121,3 +121,8 @@ export const copy_warn_broken_link = "없는 문서로 건 링크다";
121
121
  * 미등록 날짜 상태 문구 경고
122
122
  */
123
123
  export const copy_warn_unknown_status = "설정에 없는 상태 문구다";
124
+
125
+ /**
126
+ * 문서에서 참조한 로컬 자원의 경로·형식 오류
127
+ */
128
+ export const copy_error_asset = "자원이 올바르지 않다";
@@ -1,6 +1,6 @@
1
1
  /**
2
- * 문서 종류 · document 글 정리, blueprint 화면 설계
3
- * blueprint 전용 표현 미구현
2
+ * 기존 frontmatter type의 호환 값 · 모두 같은 Markdown 렌더링
3
+ * 새 문서는 type 생략 권장
4
4
  */
5
5
  export const doc_type = {
6
6
  document: "document",
@@ -19,6 +19,7 @@ import {remarkParts} from "@/component/widget/prose/_function/remark-parts";
19
19
  import {remarkStatus} from "@/component/widget/prose/_function/remark-status";
20
20
  import {remarkSwatch} from "@/component/widget/prose/_function/remark-swatch";
21
21
  import {remarkUnknownDirectives} from "@/component/widget/prose/_function/remark-unknown-directives";
22
+ import {rehypeAssets} from "@/content/rehype-assets";
22
23
  import {rehypeHeadingIds} from "@/content/rehype-heading-ids";
23
24
  import {remarkReport} from "@/content/remark-report";
24
25
  import type {SiteConfig} from "@/type/site-config";
@@ -28,7 +29,7 @@ import type {SiteConfig} from "@/type/site-config";
28
29
  * 플러그인 HTML 조각은 마지막 rehype-raw에서 해석
29
30
  * Shiki inline style: token.css의 --app-code-* 참조
30
31
  */
31
- export const createProcessor = (options: {site: SiteConfig; root: string}) => {
32
+ export const createProcessor = (options: {site: SiteConfig; root: string; files?: Map<string, string>}) => {
32
33
  return unified()
33
34
  .use(remarkParse)
34
35
  .use(remarkFrontmatter, ["yaml"])
@@ -50,6 +51,7 @@ export const createProcessor = (options: {site: SiteConfig; root: string}) => {
50
51
  .use(rehypeSections)
51
52
  .use(rehypeTables)
52
53
  .use(rehypeRaw)
54
+ .use(rehypeAssets, {root: options.root, files: options.files ?? new Map<string, string>()})
53
55
  .use(rehypeStringify, {allowDangerousHtml: true});
54
56
  };
55
57
 
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import {mkdir, mkdtemp, rm, writeFile} from "node:fs/promises";
2
+ import {mkdir, mkdtemp, rm, symlink, writeFile} from "node:fs/promises";
3
3
  import {tmpdir} from "node:os";
4
4
  import {join} from "node:path";
5
5
  import {test} from "node:test";
@@ -416,7 +416,7 @@ test("relative Markdown links preserve query strings and fragments", async (t) =
416
416
  "[Fragment](../api.mdx#details?literal=yes)",
417
417
  "[External](https://example.com/api.md?mode=compact#details)",
418
418
  "[Absolute](/api.md?mode=compact#details)",
419
- "[Asset](diagram.svg?version=2#label)",
419
+ "[Asset](/diagram.svg?version=2#label)",
420
420
  "[Local](#details?literal=yes)",
421
421
  ].join("\n\n"),
422
422
  ),
@@ -433,7 +433,7 @@ test("relative Markdown links preserve query strings and fragments", async (t) =
433
433
  assert.match(setup.html, /href="\/api\/#details\?literal=yes"/);
434
434
  assert.match(setup.html, /href="https:\/\/example\.com\/api\.md\?mode=compact#details"/);
435
435
  assert.match(setup.html, /href="\/api\.md\?mode=compact#details"/);
436
- assert.match(setup.html, /href="diagram\.svg\?version=2#label"/);
436
+ assert.match(setup.html, /href="\/diagram\.svg\?version=2#label"/);
437
437
  assert.match(setup.html, /href="#details\?literal=yes"/);
438
438
  });
439
439
 
@@ -460,3 +460,53 @@ test("headings and contents preserve authored text without automatic numbering",
460
460
  assert.match(html, /wg_shellToc__group">Setup<\/div>/);
461
461
  assert.doesNotMatch(html, /wg_prose__num|wg_shellToc__mark/);
462
462
  });
463
+
464
+ test("local Markdown and raw HTML images share fingerprinted assets with attachments", async (t) => {
465
+ const root = await mkdtemp(join(tmpdir(), "for-humanity-media-"));
466
+ t.after(() => rm(root, {recursive: true, force: true}));
467
+ await mkdir(join(root, "guide"));
468
+ await mkdir(join(root, "img"));
469
+ await writeFile(join(root, "img", "sample.svg"), '<svg xmlns="http://www.w3.org/2000/svg"/>');
470
+ await writeFile(join(root, "guide", "report.csv"), "name,value\nsample,1\n");
471
+ await writeFile(
472
+ join(root, "guide", "page.md"),
473
+ '---\nname: Media\nlabel: Media\ngroup: Guide\n---\n\n![Image](../img/sample.svg)\n\n![Reference][shot]\n\n[shot]: ../img/sample.svg\n\n<img src="../img/sample.svg" alt="Raw" />\n\n[Download](report.csv?download=1#rows)\n\n![External](https://example.com/image.png)\n',
474
+ );
475
+ const files = new Map<string, string>();
476
+ const docs = await readDocs({root, processor: createProcessor({site: siteConfigSchema.parse({}), root, files})});
477
+ assert.equal(files.size, 2);
478
+ assert.equal(docs[0].html.match(/src="\/_fh\/media\/asset\.[a-f0-9]+\.svg"/g)?.length, 3);
479
+ assert.match(docs[0].html, /href="\/_fh\/media\/asset\.[a-f0-9]+\.csv\?download=1#rows"/);
480
+ assert.match(docs[0].html, /src="https:\/\/example.com\/image.png"/);
481
+ });
482
+
483
+ test("missing local images and paths outside the document root fail instead of producing broken output", async (t) => {
484
+ const root = await mkdtemp(join(tmpdir(), "for-humanity-media-"));
485
+ t.after(() => rm(root, {recursive: true, force: true}));
486
+ await writeFile(join(root, "page.md"), "---\nname: Media\nlabel: Media\ngroup: Guide\n---\n\n![Missing](missing.png)\n");
487
+ await assert.rejects(readDocs({root, processor: createProcessor({site: siteConfigSchema.parse({}), root})}), /자원/);
488
+ await writeFile(join(root, "page.md"), "---\nname: Media\nlabel: Media\ngroup: Guide\n---\n\n![Outside](../outside.png)\n");
489
+ await assert.rejects(readDocs({root, processor: createProcessor({site: siteConfigSchema.parse({}), root})}), /문서 폴더 밖/);
490
+ });
491
+
492
+ test("home images use the chosen root and symlink escapes cannot publish outside files", async (t) => {
493
+ const root = await mkdtemp(join(tmpdir(), "for-humanity-media-"));
494
+ const outside = await mkdtemp(join(tmpdir(), "for-humanity-outside-"));
495
+ t.after(() => Promise.all([rm(root, {recursive: true, force: true}), rm(outside, {recursive: true, force: true})]));
496
+ await writeFile(join(root, "local image.png"), "first");
497
+ await writeFile(join(root, "README.md"), "# Home\n\n![Home](local%20image.png)\n");
498
+ const files = new Map<string, string>();
499
+ const site = siteConfigSchema.parse({});
500
+ const home = await readHome({root, processor: createProcessor({site, root, files})});
501
+ assert.equal(files.size, 1);
502
+ const before = [...files.keys()][0];
503
+ assert.ok(home?.html.includes(before));
504
+ await writeFile(join(root, "local image.png"), "changed");
505
+ const updated = new Map<string, string>();
506
+ await readHome({root, processor: createProcessor({site, root, files: updated})});
507
+ assert.notEqual([...updated.keys()][0], before);
508
+ await writeFile(join(outside, "private.png"), "private");
509
+ await symlink(join(outside, "private.png"), join(root, "linked.png"));
510
+ await writeFile(join(root, "README.md"), "# Home\n\n![Private](linked.png)\n");
511
+ await assert.rejects(readHome({root, processor: createProcessor({site, root})}), /문서 폴더 밖/);
512
+ });
@@ -0,0 +1,70 @@
1
+ import {existsSync, realpathSync, statSync} from "node:fs";
2
+ import {dirname, extname, isAbsolute, relative, resolve, sep} from "node:path";
3
+ import type {Root} from "hast";
4
+ import {visit} from "unist-util-visit";
5
+ import type {VFile} from "vfile";
6
+ import {asset_media_dir, asset_media_extensions} from "@/constant/asset";
7
+ import {copy_error_asset} from "@/constant/copy";
8
+ import {toAssetPath} from "@/util/file/to-asset-path";
9
+
10
+ /**
11
+ * 명시적으로 참조한 로컬 이미지·첨부의 수집 계약
12
+ */
13
+ export interface AssetOptions {
14
+ /**
15
+ * 자원을 읽을 수 있는 문서 루트
16
+ */
17
+ root: string;
18
+ /**
19
+ * dev 응답과 정적 복사에서 공유할 URL → 파일 목록
20
+ */
21
+ files: Map<string, string>;
22
+ }
23
+
24
+ /**
25
+ * 파일 위치 기준 이미지·첨부 → 내용 지문 URL · 원시 HTML도 같은 규칙
26
+ * 없는 파일, 폴더 밖 경로와 symlink, 지원하지 않는 이미지 형식은 빌드 실패
27
+ * 절대 URL·외부 URL은 작성자가 제공하며 수집하지 않음
28
+ */
29
+ export const rehypeAssets = (options: AssetOptions) => (tree: Root, file: VFile) => {
30
+ visit(tree, "element", (node) => {
31
+ const property = node.tagName === "img" ? "src" : "href";
32
+ const url = node.properties[property];
33
+
34
+ if ((node.tagName !== "img" && node.tagName !== "a") || typeof url !== "string" || /^(?:[a-z][a-z\d+.-]*:|\/|#)/i.test(url)) {
35
+ return;
36
+ }
37
+
38
+ const match = /^([^?#]+)([?#].*)?$/.exec(url);
39
+ if (match === null) {
40
+ return;
41
+ }
42
+
43
+ const path = decodeURIComponent(match[1]);
44
+ const extension = extname(path).toLowerCase();
45
+ if (!asset_media_extensions.has(extension)) {
46
+ if (node.tagName === "img") {
47
+ file.fail(`${copy_error_asset}: 지원하지 않는 이미지 형식 · ${url}`, node);
48
+ }
49
+ return;
50
+ }
51
+
52
+ const target = resolve(dirname(file.path), path);
53
+ const location = relative(options.root, target);
54
+ if (location === ".." || location.startsWith(`..${sep}`) || isAbsolute(location)) {
55
+ file.fail(`${copy_error_asset}: 문서 폴더 밖 · ${url}`, node);
56
+ }
57
+ if (!existsSync(target)) {
58
+ file.fail(`${copy_error_asset}: 파일이 없다 · ${url}`, node);
59
+ }
60
+ const actual = realpathSync(target);
61
+ const actualLocation = relative(realpathSync(options.root), actual);
62
+ if (actualLocation === ".." || actualLocation.startsWith(`..${sep}`) || isAbsolute(actualLocation) || !statSync(actual).isFile()) {
63
+ file.fail(`${copy_error_asset}: 문서 폴더 밖이거나 일반 파일이 아니다 · ${url}`, node);
64
+ }
65
+
66
+ const assetUrl = toAssetPath({path: `${asset_media_dir}/asset${extension}`, file: actual});
67
+ options.files.set(assetUrl, actual);
68
+ node.properties[property] = match[2] === undefined ? assetUrl : `${assetUrl}${match[2]}`;
69
+ });
70
+ };
package/src/dev.ts CHANGED
@@ -41,7 +41,7 @@ export const createDevApp = (options: DevOptions) => {
41
41
  return c.notFound();
42
42
  }
43
43
 
44
- c.header("content-type", asset_content_types[extname(file)] ?? asset_content_type_default);
44
+ c.header("content-type", asset_content_types[extname(file).toLowerCase()] ?? asset_content_type_default);
45
45
 
46
46
  if (extname(file) === ".woff2") {
47
47
  c.header("Cache-Control", font_cache_control);
@@ -15,7 +15,7 @@ export const docDataSchema = z.object({
15
15
  */
16
16
  label: z.string(),
17
17
  /**
18
- * 문서 종류 · blueprint 전용 표현 미구현
18
+ * 기존 type 값 호환 · 생략 권장, 렌더링 차이 없음
19
19
  */
20
20
  type: z.enum(doc_type).default(doc_type.document),
21
21
  /**
@@ -0,0 +1,58 @@
1
+ import assert from "node:assert/strict";
2
+ import {test} from "node:test";
3
+ import {toChangeQueue} from "@/util/async/to-change-queue";
4
+
5
+ test("coalesces changes during an active render and never overlaps work", async () => {
6
+ const gate = Promise.withResolvers<void>();
7
+ const renders: number[] = [];
8
+ const errors: unknown[] = [];
9
+ let version = 1;
10
+ let running = 0;
11
+ const queue = toChangeQueue({
12
+ run: async () => {
13
+ running += 1;
14
+ assert.equal(running, 1);
15
+ const current = version;
16
+ if (current === 1) await gate.promise;
17
+ renders.push(current);
18
+ running -= 1;
19
+ },
20
+ onError: (error) => errors.push(error),
21
+ });
22
+ const first = queue();
23
+ for (const next of [2, 3, 4, 5]) {
24
+ version = next;
25
+ await queue();
26
+ }
27
+ gate.resolve();
28
+ await first;
29
+ assert.deepEqual(renders, [1, 5]);
30
+ assert.deepEqual(errors, []);
31
+ });
32
+
33
+ test("a failed render does not lose a queued recovery or block later edits", async () => {
34
+ const gate = Promise.withResolvers<void>();
35
+ const errors: unknown[] = [];
36
+ const renders: number[] = [];
37
+ let version = 1;
38
+ const queue = toChangeQueue({
39
+ run: async () => {
40
+ const current = version;
41
+ if (current === 1) {
42
+ await gate.promise;
43
+ throw new Error("Invalid source");
44
+ }
45
+ renders.push(current);
46
+ },
47
+ onError: (error) => errors.push(error),
48
+ });
49
+ const first = queue();
50
+ version = 2;
51
+ await queue();
52
+ gate.resolve();
53
+ await first;
54
+ version = 3;
55
+ await queue();
56
+ assert.deepEqual(renders, [2, 3]);
57
+ assert.equal(errors.length, 1);
58
+ });
@@ -0,0 +1,43 @@
1
+ /**
2
+ * 변경 재처리의 실행과 오류 보고 계약
3
+ */
4
+ export interface ChangeQueueOptions {
5
+ /**
6
+ * 한 번의 최신 입력 처리 · 이전 실행이 끝난 뒤 호출
7
+ */
8
+ run: () => Promise<void>;
9
+ /**
10
+ * 실패 보고 · 대기 중인 변경과 다음 실행은 유지
11
+ */
12
+ onError: (error: unknown) => void;
13
+ }
14
+
15
+ /**
16
+ * 동시에 하나만 실행하는 변경 요청 함수 · 실행 중 요청은 다음 한 번으로 합침
17
+ * 활성 실행의 반환 Promise는 대기 요청까지 끝나면 완료, 중첩 요청은 즉시 반환
18
+ */
19
+ export const toChangeQueue = (options: ChangeQueueOptions) => {
20
+ const state = {requested: 0, completed: 0, running: false};
21
+
22
+ return async () => {
23
+ state.requested += 1;
24
+ if (state.running) {
25
+ return;
26
+ }
27
+ state.running = true;
28
+ try {
29
+ while (state.completed !== state.requested) {
30
+ const requested = state.requested;
31
+ try {
32
+ await options.run();
33
+ } catch (error) {
34
+ options.onError(error);
35
+ } finally {
36
+ state.completed = requested;
37
+ }
38
+ }
39
+ } finally {
40
+ state.running = false;
41
+ }
42
+ };
43
+ };