for-humanity 0.4.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,11 @@
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
+
5
10
  ## 0.4.0 — 2026-10-04
6
11
 
7
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.
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.4.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.4.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.4.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
package/dist/cli.js CHANGED
@@ -8,6 +8,7 @@ import { basename as basename2, dirname as dirname4, extname as extname4, join a
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
 
@@ -349,6 +350,7 @@ var cli_default_command = "dev";
349
350
  var cli_default_docs_dir = ".";
350
351
  var cli_config_file_name = "for-humanity.config.mjs";
351
352
  var cli_dev_port = 4321;
353
+ var cli_reload_delay_ms = 150;
352
354
 
353
355
  // src/constant/font.ts
354
356
  var font_sans_css = "src/asset/font/pretendard/pretendardvariable-dynamic-subset.css";
@@ -1288,6 +1290,32 @@ var siteConfigSchema = z3.object({
1288
1290
  ).default(status_default_phrases)
1289
1291
  });
1290
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
+
1291
1319
  // src/util/error/to-error-message.ts
1292
1320
  var toErrorMessage = (error) => {
1293
1321
  if (error instanceof Error && "file" in error && typeof error.file === "string" && "line" in error && typeof error.line === "number") {
@@ -1390,31 +1418,37 @@ var main = async () => {
1390
1418
  const reload = new EventEmitter();
1391
1419
  let generation = 0;
1392
1420
  serve({ fetch: createDevApp({ pages: app, files, reload }).fetch, port: cli_dev_port }, (info) => console.log(`http://localhost:${info.port}/`));
1393
- const handleDocsChange = async (_event, filename) => {
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) => {
1394
1444
  if (filename !== null) {
1395
1445
  const segments = filename.split(sep3);
1396
1446
  if (segments.includes("dist") || segments.includes("node_modules") || !/\.mdx?$/i.test(filename) && !asset_media_extensions.has(extname4(filename).toLowerCase())) {
1397
1447
  return;
1398
1448
  }
1399
1449
  }
1400
- const currentGeneration = ++generation;
1401
- try {
1402
- const nextFiles = new Map(kitFiles);
1403
- const nextProcessor = createProcessor({ site: siteConfig, root: docsRoot, files: nextFiles });
1404
- const [docs2, home2] = await Promise.all([readDocs({ root: docsRoot, processor: nextProcessor }), readHome({ root: docsRoot, processor: nextProcessor })]);
1405
- if (currentGeneration !== generation) {
1406
- return;
1407
- }
1408
- files.clear();
1409
- for (const entry of nextFiles) {
1410
- files.set(...entry);
1411
- }
1412
- store.docs = docs2;
1413
- store.home = home2;
1414
- reload.emit("change");
1415
- } catch (error) {
1416
- console.error(`${copy_error_prefix}: ${toErrorMessage(error)}`);
1417
- }
1450
+ generation += 1;
1451
+ reloadDocs();
1418
1452
  };
1419
1453
  watch(docsRoot, { recursive: true }, handleDocsChange);
1420
1454
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "for-humanity",
3
- "version": "0.4.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
@@ -11,11 +11,12 @@ import {basename, dirname, extname, join, relative, resolve, sep} from "node:pat
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
18
  import {asset_client_path, asset_favicon_path, asset_font_dir, asset_media_extensions, 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";
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";
@@ -129,40 +131,46 @@ const main = async () => {
129
131
  serve({fetch: createDevApp({pages: app, files, reload}).fetch, port: cli_dev_port}, (info) => console.log(`http://localhost:${info.port}/`));
130
132
 
131
133
  /**
132
- * Markdown 변경 시 전체 재처리와 새로고침
133
- * 재처리 실패 시 직전 정상 문서 유지
134
+ * 갱신은 하나씩 실행 · 연속 저장 합치기 · 최신 변경만 적용
135
+ * 문서 수가 많아도 파일 이벤트마다 전체 렌더링이 동시에 쌓이지 않음
134
136
  */
135
- const handleDocsChange = async (_event: string, filename: string | null) => {
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
+ );
161
+
162
+ /**
163
+ * 입력 변경 감지 · 출력·설치 폴더 제외 · 누락 자원 추가도 다시 확인
164
+ */
165
+ const handleDocsChange = (_event: string, filename: string | null) => {
136
166
  if (filename !== null) {
137
167
  const segments = filename.split(sep);
138
- // 출력·설치 폴더는 제외 · 실패한 참조의 파일이 뒤늦게 생겨도 다시 처리
139
168
  if (segments.includes("dist") || segments.includes("node_modules") || (!/\.mdx?$/i.test(filename) && !asset_media_extensions.has(extname(filename).toLowerCase()))) {
140
169
  return;
141
170
  }
142
171
  }
143
-
144
- const currentGeneration = ++generation;
145
-
146
- try {
147
- const nextFiles = new Map(kitFiles);
148
- const nextProcessor = createProcessor({site: siteConfig, root: docsRoot, files: nextFiles});
149
- const [docs, home] = await Promise.all([readDocs({root: docsRoot, processor: nextProcessor}), readHome({root: docsRoot, processor: nextProcessor})]);
150
-
151
- // 최신 변경의 처리 결과만 반영
152
- if (currentGeneration !== generation) {
153
- return;
154
- }
155
-
156
- files.clear();
157
- for (const entry of nextFiles) {
158
- files.set(...entry);
159
- }
160
- store.docs = docs;
161
- store.home = home;
162
- reload.emit("change");
163
- } catch (error) {
164
- console.error(`${copy_error_prefix}: ${toErrorMessage(error)}`);
165
- }
172
+ generation += 1;
173
+ reloadDocs();
166
174
  };
167
175
 
168
176
  watch(docsRoot, {recursive: true}, handleDocsChange);
@@ -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;
@@ -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
+ };