for-humanity 0.2.0 → 0.2.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
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.1 — 2026-10-04
4
+
5
+ - Fingerprint stylesheet and browser-script URLs so new pages cannot reuse assets cached from an older deployment.
6
+ - Cache fingerprinted CSS, scripts and fonts with immutable headers on Cloudflare Pages.
7
+
3
8
  ## 0.2.0 — 2026-10-04
4
9
 
5
10
  - Support nested navigation with `group` name arrays while keeping single-name groups compatible.
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.2.0
36
+ pnpm add -D --save-exact for-humanity@0.2.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.2.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.2.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.2.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.2.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
@@ -227,7 +227,7 @@ var WgShell = (props) => {
227
227
  /* @__PURE__ */ jsx5("style", { dangerouslySetInnerHTML: { __html: props.assets.fontCss } }),
228
228
  props.assets.preload.map((href) => /* @__PURE__ */ jsx5("link", { rel: "preload", href, as: "font", type: "font/woff2", crossOrigin: "" }, href)),
229
229
  /* @__PURE__ */ jsx5("script", { dangerouslySetInnerHTML: { __html: 'document.documentElement.style.scrollBehavior="auto"' } }),
230
- /* @__PURE__ */ jsx5("link", { rel: "stylesheet", href: asset_style_path }),
230
+ /* @__PURE__ */ jsx5("link", { rel: "stylesheet", href: props.assets.stylePath }),
231
231
  props.assets.reload && /* @__PURE__ */ jsx5("script", { dangerouslySetInnerHTML: { __html: reloadScript } })
232
232
  ] }),
233
233
  /* @__PURE__ */ jsxs5("body", { className: clsx5("wg_shell__root"), children: [
@@ -261,7 +261,7 @@ var WgShell = (props) => {
261
261
  /* @__PURE__ */ jsx5("div", { className: clsx5("wg_shell__drawerContent"), "data-navigation-content": "" })
262
262
  ] }),
263
263
  /* @__PURE__ */ jsx5("img", { className: clsx5("wg_shell__cursorFace"), src: asset_favicon_path, width: "24", height: "24", alt: "", "aria-hidden": "true", draggable: false, "data-cursor-face": "" }),
264
- /* @__PURE__ */ jsx5("script", { type: "module", src: asset_client_path })
264
+ /* @__PURE__ */ jsx5("script", { type: "module", src: props.assets.clientPath })
265
265
  ] })
266
266
  ] });
267
267
  };
@@ -1169,17 +1169,26 @@ var toErrorMessage = (error) => {
1169
1169
  return error instanceof Error ? error.message : String(error);
1170
1170
  };
1171
1171
 
1172
- // src/util/font/to-font-css.ts
1172
+ // src/util/file/to-asset-path.ts
1173
1173
  import { createHash } from "node:crypto";
1174
1174
  import { readFileSync } from "node:fs";
1175
+ import { posix as posix2 } from "node:path";
1176
+ var toAssetPath = (options) => {
1177
+ const extension = posix2.extname(options.path);
1178
+ return `${posix2.dirname(options.path)}/${posix2.basename(options.path, extension)}.${createHash("sha256").update(readFileSync(options.file)).digest("hex")}${extension}`;
1179
+ };
1180
+
1181
+ // src/util/font/to-font-css.ts
1182
+ import { createHash as createHash2 } from "node:crypto";
1183
+ import { readFileSync as readFileSync2 } from "node:fs";
1175
1184
  import { basename, dirname as dirname2, extname as extname2, join as join3 } from "node:path";
1176
1185
  var toFontCss = (options) => {
1177
1186
  const files = /* @__PURE__ */ new Map();
1178
- const css = [...readFileSync(options.css, "utf8").matchAll(/@font-face\s*\{[^}]*\}/g)].map(
1187
+ const css = [...readFileSync2(options.css, "utf8").matchAll(/@font-face\s*\{[^}]*\}/g)].map(
1179
1188
  (match) => match[0].replace(/font-display:\s*[^;]+;/g, `font-display: ${font_display_strategy};`).replace(/url\(([^)]+)\)/g, (_match, relativeUrl) => {
1180
1189
  const file = join3(dirname2(options.css), relativeUrl);
1181
1190
  const extension = extname2(file);
1182
- const hash = createHash("sha256").update(readFileSync(file)).digest("hex").slice(0, font_hash_length);
1191
+ const hash = createHash2("sha256").update(readFileSync2(file)).digest("hex").slice(0, font_hash_length);
1183
1192
  const url = `${options.fontDir}/${basename(file, extension)}.${hash}${extension}`;
1184
1193
  files.set(url, file);
1185
1194
  return `url(${url})`;
@@ -1228,9 +1237,11 @@ var main = async () => {
1228
1237
  throw new Error(`${copy_error_font_preload}: ${font_sans_preload_file}`);
1229
1238
  }
1230
1239
  const fontCss = [sans.css, mono.css, brand.css].join("\n");
1240
+ const stylePath = toAssetPath({ path: asset_style_path, file: join4(kitRoot, "dist/cli.css") });
1241
+ const clientPath = toAssetPath({ path: asset_client_path, file: join4(kitRoot, "dist/client.js") });
1231
1242
  const files = new Map([
1232
- [asset_style_path, join4(kitRoot, "dist/cli.css")],
1233
- [asset_client_path, join4(kitRoot, "dist/client.js")],
1243
+ [stylePath, join4(kitRoot, "dist/cli.css")],
1244
+ [clientPath, join4(kitRoot, "dist/client.js")],
1234
1245
  [asset_favicon_path, join4(kitRoot, "src/asset/favicon.svg")],
1235
1246
  ...sans.files,
1236
1247
  ...mono.files,
@@ -1242,7 +1253,7 @@ var main = async () => {
1242
1253
  const app = createApp({
1243
1254
  site: siteConfig,
1244
1255
  store,
1245
- assets: { fontCss, preload: [...brand.files.keys(), sansPreload[0], ...mono.files.keys()], reload: command === "dev" }
1256
+ assets: { stylePath, clientPath, fontCss, preload: [...brand.files.keys(), sansPreload[0], ...mono.files.keys()], reload: command === "dev" }
1246
1257
  });
1247
1258
  if (command === "build") {
1248
1259
  await rm(outDir, { recursive: true, force: true });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "for-humanity",
3
- "version": "0.2.0",
3
+ "version": "0.2.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",
package/src/cli.ts CHANGED
@@ -25,6 +25,7 @@ import {readHome} from "@/content/read-home";
25
25
  import {createDevApp} from "@/dev";
26
26
  import {siteConfigSchema} from "@/type/site-config";
27
27
  import {toErrorMessage} from "@/util/error/to-error-message";
28
+ import {toAssetPath} from "@/util/file/to-asset-path";
28
29
  import {toFontCss} from "@/util/font/to-font-css";
29
30
 
30
31
  const commands = ["dev", "build", "preview"];
@@ -82,9 +83,11 @@ const main = async () => {
82
83
  }
83
84
 
84
85
  const fontCss = [sans.css, mono.css, brand.css].join("\n");
86
+ const stylePath = toAssetPath({path: asset_style_path, file: join(kitRoot, "dist/cli.css")});
87
+ const clientPath = toAssetPath({path: asset_client_path, file: join(kitRoot, "dist/client.js")});
85
88
  const files = new Map([
86
- [asset_style_path, join(kitRoot, "dist/cli.css")],
87
- [asset_client_path, join(kitRoot, "dist/client.js")],
89
+ [stylePath, join(kitRoot, "dist/cli.css")],
90
+ [clientPath, join(kitRoot, "dist/client.js")],
88
91
  [asset_favicon_path, join(kitRoot, "src/asset/favicon.svg")],
89
92
  ...sans.files,
90
93
  ...mono.files,
@@ -96,7 +99,7 @@ const main = async () => {
96
99
  const app = createApp({
97
100
  site: siteConfig,
98
101
  store,
99
- assets: {fontCss, preload: [...brand.files.keys(), sansPreload[0], ...mono.files.keys()], reload: command === "dev"},
102
+ assets: {stylePath, clientPath, fontCss, preload: [...brand.files.keys(), sansPreload[0], ...mono.files.keys()], reload: command === "dev"},
100
103
  });
101
104
 
102
105
  if (command === "build") {
@@ -4,7 +4,7 @@ import {toTocGroups} from "@/component/widget/shell/_function/to-toc-groups";
4
4
  import type {DocOutline} from "@/component/widget/shell/_type/doc-outline";
5
5
  import {WgShellNav} from "@/component/widget/shell/_wg-shell-nav";
6
6
  import {WgShellToc} from "@/component/widget/shell/_wg-shell-toc";
7
- import {asset_client_path, asset_favicon_path, asset_reload_path, asset_style_path} from "@/constant/asset";
7
+ import {asset_favicon_path, asset_reload_path} from "@/constant/asset";
8
8
  import {copy_nav_close, copy_nav_open, copy_nav_title} from "@/constant/copy";
9
9
  import type {Doc} from "@/type/doc";
10
10
  import type {SiteAssets} from "@/type/site-assets";
@@ -71,7 +71,7 @@ export const WgShell = (props: WgShellProps) => {
71
71
  <link key={href} rel="preload" href={href} as="font" type="font/woff2" crossOrigin="" />
72
72
  ))}
73
73
  <script dangerouslySetInnerHTML={{__html: 'document.documentElement.style.scrollBehavior="auto"'}} />
74
- <link rel="stylesheet" href={asset_style_path} />
74
+ <link rel="stylesheet" href={props.assets.stylePath} />
75
75
  {props.assets.reload && <script dangerouslySetInnerHTML={{__html: reloadScript}} />}
76
76
  </head>
77
77
  {/**
@@ -140,7 +140,7 @@ export const WgShell = (props: WgShellProps) => {
140
140
  <div className={clsx("wg_shell__drawerContent")} data-navigation-content="" />
141
141
  </dialog>
142
142
  <img className={clsx("wg_shell__cursorFace")} src={asset_favicon_path} width="24" height="24" alt="" aria-hidden="true" draggable={false} data-cursor-face="" />
143
- <script type="module" src={asset_client_path} />
143
+ <script type="module" src={props.assets.clientPath} />
144
144
  </body>
145
145
  </html>
146
146
  );
@@ -8,12 +8,12 @@
8
8
  export const asset_dir = "_fh";
9
9
 
10
10
  /**
11
- * esbuild의 컴포넌트 CSS 묶음
11
+ * 내용 지문을 붙일 esbuild CSS의 기본 URL
12
12
  */
13
13
  export const asset_style_path = `/${asset_dir}/style.css`;
14
14
 
15
15
  /**
16
- * 테마·목차·hash 보정 브라우저 스크립트
16
+ * 내용 지문을 붙일 테마·목차·hash 보정 스크립트의 기본 URL
17
17
  */
18
18
  export const asset_client_path = `/${asset_dir}/client.js`;
19
19
 
@@ -6,6 +6,7 @@ import {test} from "node:test";
6
6
  import {renderToStaticMarkup} from "react-dom/server";
7
7
  import {createApp} from "@/app";
8
8
  import {WgShellNav} from "@/component/widget/shell/_wg-shell-nav";
9
+ import {asset_client_path, asset_style_path} from "@/constant/asset";
9
10
  import {createProcessor} from "@/content/create-processor";
10
11
  import {readDocs} from "@/content/read-docs";
11
12
  import {readHome} from "@/content/read-home";
@@ -13,6 +14,17 @@ import {toDocGroups} from "@/content/to-doc-groups/to-doc-groups";
13
14
  import {docDataSchema} from "@/type/doc-data";
14
15
  import {siteConfigSchema} from "@/type/site-config";
15
16
 
17
+ test("page resource URLs use the supplied content fingerprints instead of fixed cache keys", async () => {
18
+ const site = siteConfigSchema.parse({});
19
+ const assets = {fontCss: "", preload: [], reload: false, stylePath: "/_fh/style.abc123.css", clientPath: "/_fh/client.def456.js"};
20
+ const app = createApp({site, store: {docs: []}, assets});
21
+ const html = await (await app.request("/")).text();
22
+
23
+ assert.match(html, /rel="stylesheet" href="\/_fh\/style\.abc123\.css"/);
24
+ assert.match(html, /type="module" src="\/_fh\/client\.def456\.js"/);
25
+ assert.doesNotMatch(html, /(?:href|src)="\/_fh\/(?:style\.css|client\.js)"/);
26
+ });
27
+
16
28
  test("documents with the same initial retain distinct URLs", async (t) => {
17
29
  const root = await mkdtemp(join(tmpdir(), "for-humanity-docs-"));
18
30
  const site = siteConfigSchema.parse({});
@@ -201,7 +213,7 @@ test("a plain root README renders at home and relative home links resolve from n
201
213
  ]);
202
214
 
203
215
  const [docs, home] = await Promise.all([readDocs({root, processor}), readHome({root, processor})]);
204
- const app = createApp({site, store: {docs, home}, assets: {fontCss: "", preload: [], reload: false}});
216
+ const app = createApp({site, store: {docs, home}, assets: {stylePath: asset_style_path, clientPath: asset_client_path, fontCss: "", preload: [], reload: false}});
205
217
  const response = await app.request("/");
206
218
  const html = await response.text();
207
219
 
@@ -230,7 +242,7 @@ test("a missing README provides a creation hint without generated overview cards
230
242
 
231
243
  t.after(() => rm(root, {recursive: true, force: true}));
232
244
  const home = await readHome({root, processor: createProcessor({site, root})});
233
- const app = createApp({site, store: {docs: [], home}, assets: {fontCss: "", preload: [], reload: false}});
245
+ const app = createApp({site, store: {docs: [], home}, assets: {stylePath: asset_style_path, clientPath: asset_client_path, fontCss: "", preload: [], reload: false}});
234
246
  const html = await (await app.request("/")).text();
235
247
 
236
248
  assert.equal(home, undefined);
@@ -309,7 +321,7 @@ test("headings and contents preserve authored text without automatic numbering",
309
321
  );
310
322
 
311
323
  const docs = await readDocs({root, processor: createProcessor({site, root})});
312
- const app = createApp({site, store: {docs}, assets: {fontCss: "", preload: [], reload: false}});
324
+ const app = createApp({site, store: {docs}, assets: {stylePath: asset_style_path, clientPath: asset_client_path, fontCss: "", preload: [], reload: false}});
313
325
  const html = await (await app.request("/guide/")).text();
314
326
 
315
327
  assert.match(html, /<h2[^>]*id="overview"[^>]*><span class="wg_prose__headingText">Overview<\/span><\/h2>/);
@@ -2,6 +2,14 @@
2
2
  * 모든 페이지가 공유하는 HTML 머리 자원
3
3
  */
4
4
  export interface SiteAssets {
5
+ /**
6
+ * 내용 지문으로 구분한 스타일시트 URL
7
+ */
8
+ stylePath: string;
9
+ /**
10
+ * 내용 지문으로 구분한 브라우저 스크립트 URL
11
+ */
12
+ clientPath: string;
5
13
  /**
6
14
  * 내장 @font-face · head의 style 내용
7
15
  */
@@ -0,0 +1,26 @@
1
+ import {createHash} from "node:crypto";
2
+ import {readFileSync} from "node:fs";
3
+ import {posix} from "node:path";
4
+
5
+ /**
6
+ * 파일 내용으로 자원 URL을 구분하는 입력
7
+ */
8
+ export interface AssetPathOptions {
9
+ /**
10
+ * 사이트 루트 기준 기본 자원 경로
11
+ */
12
+ path: string;
13
+ /**
14
+ * 배포할 실제 파일 경로
15
+ */
16
+ file: string;
17
+ }
18
+
19
+ /**
20
+ * 파일명에 내용 지문 추가 · 같은 내용은 같은 URL, 변경한 내용은 새 캐시 키 사용
21
+ */
22
+ export const toAssetPath = (options: AssetPathOptions) => {
23
+ const extension = posix.extname(options.path);
24
+
25
+ return `${posix.dirname(options.path)}/${posix.basename(options.path, extension)}.${createHash("sha256").update(readFileSync(options.file)).digest("hex")}${extension}`;
26
+ };