for-humanity 0.3.0 → 0.4.0

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,15 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.4.0 — 2026-10-04
6
+
7
+ - Copy explicitly referenced local images and supported attachments to fingerprinted asset URLs, resolving paths relative to their Markdown files in both build and dev.
8
+ - 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.
9
+ - Reload referenced image and attachment changes in dev while retaining the previous valid document and resource map after failed processing.
10
+ - Mark only the current table-of-contents anchor as active, with `aria-current="location"`, while retaining the tree path through its ancestors.
11
+ - Add image documentation, purpose-based authoring guidance and fictional design and research examples.
12
+ - 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.
13
+
5
14
  ## 0.3.0 — 2026-10-04
6
15
 
7
16
  - 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.0
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.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).
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.0` 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,9 +2,9 @@
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";
@@ -149,6 +149,7 @@ var copy_error_unknown_directive = "\uBAA8\uB974\uB294 \uBD80\uD488\uC774\uB2E4"
149
149
  var copy_error_directive_syntax = "\uBD80\uD488 \uBB38\uBC95\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uB2E4";
150
150
  var copy_warn_broken_link = "\uC5C6\uB294 \uBB38\uC11C\uB85C \uAC74 \uB9C1\uD06C\uB2E4";
151
151
  var copy_warn_unknown_status = "\uC124\uC815\uC5D0 \uC5C6\uB294 \uC0C1\uD0DC \uBB38\uAD6C\uB2E4";
152
+ var copy_error_asset = "\uC790\uC6D0\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uB2E4";
152
153
 
153
154
  // src/constant/locale.ts
154
155
  var locale_doc_name = "en";
@@ -242,9 +243,21 @@ var asset_content_types = {
242
243
  ".css": "text/css; charset=utf-8",
243
244
  ".js": "text/javascript; charset=utf-8",
244
245
  ".woff2": "font/woff2",
245
- ".svg": "image/svg+xml"
246
+ ".svg": "image/svg+xml",
247
+ ".png": "image/png",
248
+ ".jpg": "image/jpeg",
249
+ ".jpeg": "image/jpeg",
250
+ ".gif": "image/gif",
251
+ ".webp": "image/webp",
252
+ ".avif": "image/avif",
253
+ ".pdf": "application/pdf",
254
+ ".csv": "text/csv; charset=utf-8",
255
+ ".txt": "text/plain; charset=utf-8",
256
+ ".zip": "application/zip"
246
257
  };
247
258
  var asset_content_type_default = "application/octet-stream";
259
+ var asset_media_dir = `/${asset_dir}/media`;
260
+ var asset_media_extensions = /* @__PURE__ */ new Set([".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".pdf", ".csv", ".txt", ".zip"]);
248
261
 
249
262
  // src/component/widget/shell/wg-shell.tsx
250
263
  import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
@@ -968,14 +981,67 @@ var remarkUnknownDirectives = () => (tree, file) => {
968
981
  });
969
982
  };
970
983
 
984
+ // src/content/rehype-assets.ts
985
+ import { existsSync as existsSync2, realpathSync, statSync } from "node:fs";
986
+ import { dirname as dirname2, extname, isAbsolute, relative as relative2, resolve as resolve2, sep } from "node:path";
987
+ import { visit as visit7 } from "unist-util-visit";
988
+
989
+ // src/util/file/to-asset-path.ts
990
+ import { createHash } from "node:crypto";
991
+ import { readFileSync } from "node:fs";
992
+ import { posix as posix2 } from "node:path";
993
+ var toAssetPath = (options) => {
994
+ const extension = posix2.extname(options.path);
995
+ return `${posix2.dirname(options.path)}/${posix2.basename(options.path, extension)}.${createHash("sha256").update(readFileSync(options.file)).digest("hex")}${extension}`;
996
+ };
997
+
998
+ // src/content/rehype-assets.ts
999
+ var rehypeAssets = (options) => (tree, file) => {
1000
+ visit7(tree, "element", (node) => {
1001
+ const property = node.tagName === "img" ? "src" : "href";
1002
+ const url = node.properties[property];
1003
+ if (node.tagName !== "img" && node.tagName !== "a" || typeof url !== "string" || /^(?:[a-z][a-z\d+.-]*:|\/|#)/i.test(url)) {
1004
+ return;
1005
+ }
1006
+ const match = /^([^?#]+)([?#].*)?$/.exec(url);
1007
+ if (match === null) {
1008
+ return;
1009
+ }
1010
+ const path = decodeURIComponent(match[1]);
1011
+ const extension = extname(path).toLowerCase();
1012
+ if (!asset_media_extensions.has(extension)) {
1013
+ if (node.tagName === "img") {
1014
+ file.fail(`${copy_error_asset}: \uC9C0\uC6D0\uD558\uC9C0 \uC54A\uB294 \uC774\uBBF8\uC9C0 \uD615\uC2DD \xB7 ${url}`, node);
1015
+ }
1016
+ return;
1017
+ }
1018
+ const target = resolve2(dirname2(file.path), path);
1019
+ const location = relative2(options.root, target);
1020
+ if (location === ".." || location.startsWith(`..${sep}`) || isAbsolute(location)) {
1021
+ file.fail(`${copy_error_asset}: \uBB38\uC11C \uD3F4\uB354 \uBC16 \xB7 ${url}`, node);
1022
+ }
1023
+ if (!existsSync2(target)) {
1024
+ file.fail(`${copy_error_asset}: \uD30C\uC77C\uC774 \uC5C6\uB2E4 \xB7 ${url}`, node);
1025
+ }
1026
+ const actual = realpathSync(target);
1027
+ const actualLocation = relative2(realpathSync(options.root), actual);
1028
+ if (actualLocation === ".." || actualLocation.startsWith(`..${sep}`) || isAbsolute(actualLocation) || !statSync(actual).isFile()) {
1029
+ file.fail(`${copy_error_asset}: \uBB38\uC11C \uD3F4\uB354 \uBC16\uC774\uAC70\uB098 \uC77C\uBC18 \uD30C\uC77C\uC774 \uC544\uB2C8\uB2E4 \xB7 ${url}`, node);
1030
+ }
1031
+ const assetUrl = toAssetPath({ path: `${asset_media_dir}/asset${extension}`, file: actual });
1032
+ options.files.set(assetUrl, actual);
1033
+ node.properties[property] = match[2] === void 0 ? assetUrl : `${assetUrl}${match[2]}`;
1034
+ });
1035
+ };
1036
+
971
1037
  // src/content/rehype-heading-ids.ts
972
1038
  import Slugger from "github-slugger";
973
1039
  import { toString as toPlainText3 } from "hast-util-to-string";
974
- import { visit as visit7 } from "unist-util-visit";
1040
+ import { visit as visit8 } from "unist-util-visit";
975
1041
  var rehypeHeadingIds = () => (tree, file) => {
976
1042
  const slugger = new Slugger();
977
1043
  const headings = [];
978
- visit7(tree, "element", (node) => {
1044
+ visit8(tree, "element", (node) => {
979
1045
  const depth = /^h([1-6])$/.exec(node.tagName)?.[1];
980
1046
  if (depth === void 0) {
981
1047
  return;
@@ -991,9 +1057,9 @@ var rehypeHeadingIds = () => (tree, file) => {
991
1057
  };
992
1058
 
993
1059
  // src/content/remark-report.ts
994
- import { relative as relative2 } from "node:path";
1060
+ import { relative as relative3 } from "node:path";
995
1061
  var remarkReport = (options) => (_tree, file) => {
996
- const path = relative2(options.root, file.path);
1062
+ const path = relative3(options.root, file.path);
997
1063
  for (const message of file.messages) {
998
1064
  console.warn(`${message.line === void 0 ? path : `${path}:${message.line}`} ${message.reason}`);
999
1065
  }
@@ -1001,12 +1067,12 @@ var remarkReport = (options) => (_tree, file) => {
1001
1067
 
1002
1068
  // src/content/create-processor.ts
1003
1069
  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 });
1070
+ 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
1071
  };
1006
1072
 
1007
1073
  // src/content/read-docs.ts
1008
1074
  import { readdir, readFile } from "node:fs/promises";
1009
- import { join, posix as posix2, sep } from "node:path";
1075
+ import { join, posix as posix3, sep as sep2 } from "node:path";
1010
1076
  import { VFile } from "vfile";
1011
1077
  import { parse as parseYaml } from "yaml";
1012
1078
 
@@ -1035,7 +1101,7 @@ var docDataSchema = z2.object({
1035
1101
  */
1036
1102
  label: z2.string(),
1037
1103
  /**
1038
- * 문서 종류 · blueprint 전용 표현 미구현
1104
+ * 기존 type 값 호환 · 생략 권장, 렌더링 차이 없음
1039
1105
  */
1040
1106
  type: z2.enum(doc_type).default(doc_type.document),
1041
1107
  /**
@@ -1055,11 +1121,11 @@ var docDataSchema = z2.object({
1055
1121
  // src/content/read-docs.ts
1056
1122
  var readDocs = async (options) => {
1057
1123
  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}`)
1124
+ (path) => /\.mdx?$/i.test(path) && !/^(readme|agents|claude)\.md$/i.test(path) && !path.split(sep2).includes("node_modules") && !path.startsWith(`dist${sep2}`)
1059
1125
  );
1060
1126
  const docs = await Promise.all(
1061
1127
  paths.map(async (path) => {
1062
- const id = path.replace(/\.mdx?$/i, "").split(sep).join("/").toLowerCase();
1128
+ const id = path.replace(/\.mdx?$/i, "").split(sep2).join("/").toLowerCase();
1063
1129
  const segments = id.split("/");
1064
1130
  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
1131
  throw new Error(`${copy_error_doc_id}: ${path}`);
@@ -1074,7 +1140,7 @@ var readDocs = async (options) => {
1074
1140
  }
1075
1141
  const data = {
1076
1142
  ...parsed.data,
1077
- parent: parsed.data.parent !== void 0 && /^\.\.?\//.test(parsed.data.parent) ? posix2.join(posix2.dirname(id), parsed.data.parent) : parsed.data.parent
1143
+ parent: parsed.data.parent !== void 0 && /^\.\.?\//.test(parsed.data.parent) ? posix3.join(posix3.dirname(id), parsed.data.parent) : parsed.data.parent
1078
1144
  };
1079
1145
  const fh = { frontmatter: data, headings: [], sections: [] };
1080
1146
  file.data.fh = fh;
@@ -1135,7 +1201,7 @@ var readHome = async (options) => {
1135
1201
 
1136
1202
  // src/dev.ts
1137
1203
  import { readFile as readFile3 } from "node:fs/promises";
1138
- import { extname } from "node:path";
1204
+ import { extname as extname2 } from "node:path";
1139
1205
  import { Hono as Hono2 } from "hono";
1140
1206
  import { streamSSE } from "hono/streaming";
1141
1207
  var createDevApp = (options) => {
@@ -1145,8 +1211,8 @@ var createDevApp = (options) => {
1145
1211
  if (file === void 0) {
1146
1212
  return c.notFound();
1147
1213
  }
1148
- c.header("content-type", asset_content_types[extname(file)] ?? asset_content_type_default);
1149
- if (extname(file) === ".woff2") {
1214
+ c.header("content-type", asset_content_types[extname2(file).toLowerCase()] ?? asset_content_type_default);
1215
+ if (extname2(file) === ".woff2") {
1150
1216
  c.header("Cache-Control", font_cache_control);
1151
1217
  }
1152
1218
  return c.body(await readFile3(file));
@@ -1230,25 +1296,16 @@ var toErrorMessage = (error) => {
1230
1296
  return error instanceof Error ? error.message : String(error);
1231
1297
  };
1232
1298
 
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
1299
  // src/util/font/to-font-css.ts
1243
1300
  import { createHash as createHash2 } from "node:crypto";
1244
1301
  import { readFileSync as readFileSync2 } from "node:fs";
1245
- import { basename, dirname as dirname2, extname as extname2, join as join3 } from "node:path";
1302
+ import { basename, dirname as dirname3, extname as extname3, join as join3 } from "node:path";
1246
1303
  var toFontCss = (options) => {
1247
1304
  const files = /* @__PURE__ */ new Map();
1248
1305
  const css = [...readFileSync2(options.css, "utf8").matchAll(/@font-face\s*\{[^}]*\}/g)].map(
1249
1306
  (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);
1307
+ const file = join3(dirname3(options.css), relativeUrl);
1308
+ const extension = extname3(file);
1252
1309
  const hash = createHash2("sha256").update(readFileSync2(file)).digest("hex").slice(0, font_hash_length);
1253
1310
  const url = `${options.fontDir}/${basename(file, extension)}.${hash}${extension}`;
1254
1311
  files.set(url, file);
@@ -1266,14 +1323,14 @@ var main = async () => {
1266
1323
  throw new Error(`${copy_error_unknown_command}: ${command} (${commands.join(", ")})`);
1267
1324
  }
1268
1325
  const kitRoot = fileURLToPath(new URL("../", import.meta.url));
1269
- const docsRoot = resolve2(docsDir);
1326
+ const docsRoot = resolve3(docsDir);
1270
1327
  const outDir = join4(docsRoot, "dist");
1271
1328
  if (command === "preview") {
1272
1329
  const app2 = new Hono3();
1273
1330
  app2.use(
1274
1331
  "/*",
1275
1332
  serveStatic({
1276
- root: relative3(process.cwd(), outDir),
1333
+ root: relative4(process.cwd(), outDir),
1277
1334
  onFound: (_path, c) => {
1278
1335
  if (c.req.path.startsWith(`${asset_font_dir}/`)) {
1279
1336
  c.header("Cache-Control", font_cache_control);
@@ -1285,7 +1342,7 @@ var main = async () => {
1285
1342
  return;
1286
1343
  }
1287
1344
  const configFile = join4(docsRoot, cli_config_file_name);
1288
- const parsedConfig = siteConfigSchema.safeParse(existsSync2(configFile) ? (await import(pathToFileURL(configFile).href)).default : site_config_absent);
1345
+ const parsedConfig = siteConfigSchema.safeParse(existsSync3(configFile) ? (await import(pathToFileURL(configFile).href)).default : site_config_absent);
1289
1346
  if (!parsedConfig.success) {
1290
1347
  throw new Error(`${copy_error_config}: ${parsedConfig.error.issues.map((issue) => `${issue.path.join(".")} ${issue.message}`).join(", ")}`);
1291
1348
  }
@@ -1300,7 +1357,7 @@ var main = async () => {
1300
1357
  const fontCss = [sans.css, mono.css, brand.css].join("\n");
1301
1358
  const stylePath = toAssetPath({ path: asset_style_path, file: join4(kitRoot, "dist/cli.css") });
1302
1359
  const clientPath = toAssetPath({ path: asset_client_path, file: join4(kitRoot, "dist/client.js") });
1303
- const files = new Map([
1360
+ const kitFiles = new Map([
1304
1361
  [stylePath, join4(kitRoot, "dist/cli.css")],
1305
1362
  [clientPath, join4(kitRoot, "dist/client.js")],
1306
1363
  [asset_favicon_path, join4(kitRoot, "src/asset/favicon.svg")],
@@ -1308,7 +1365,8 @@ var main = async () => {
1308
1365
  ...mono.files,
1309
1366
  ...brand.files
1310
1367
  ]);
1311
- const processor = createProcessor({ site: siteConfig, root: docsRoot });
1368
+ const files = new Map(kitFiles);
1369
+ const processor = createProcessor({ site: siteConfig, root: docsRoot, files });
1312
1370
  const [docs, home] = await Promise.all([readDocs({ root: docsRoot, processor }), readHome({ root: docsRoot, processor })]);
1313
1371
  const store = { docs, home };
1314
1372
  const app = createApp({
@@ -1323,25 +1381,34 @@ var main = async () => {
1323
1381
  throw result.error;
1324
1382
  }
1325
1383
  for (const [url, file] of files) {
1326
- await mkdir(dirname3(join4(outDir, url)), { recursive: true });
1384
+ await mkdir(dirname4(join4(outDir, url)), { recursive: true });
1327
1385
  await copyFile(file, join4(outDir, url));
1328
1386
  }
1329
- console.log(`${result.files.length} pages, ${files.size} assets \u2192 ${relative3(process.cwd(), outDir)}`);
1387
+ console.log(`${result.files.length} pages, ${files.size} assets \u2192 ${relative4(process.cwd(), outDir)}`);
1330
1388
  return;
1331
1389
  }
1332
1390
  const reload = new EventEmitter();
1333
1391
  let generation = 0;
1334
1392
  serve({ fetch: createDevApp({ pages: app, files, reload }).fetch, port: cli_dev_port }, (info) => console.log(`http://localhost:${info.port}/`));
1335
1393
  const handleDocsChange = async (_event, filename) => {
1336
- if (filename !== null && !/\.mdx?$/i.test(filename)) {
1337
- return;
1394
+ if (filename !== null) {
1395
+ const segments = filename.split(sep3);
1396
+ if (segments.includes("dist") || segments.includes("node_modules") || !/\.mdx?$/i.test(filename) && !asset_media_extensions.has(extname4(filename).toLowerCase())) {
1397
+ return;
1398
+ }
1338
1399
  }
1339
1400
  const currentGeneration = ++generation;
1340
1401
  try {
1341
- const [docs2, home2] = await Promise.all([readDocs({ root: docsRoot, processor }), readHome({ root: docsRoot, processor })]);
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 })]);
1342
1405
  if (currentGeneration !== generation) {
1343
1406
  return;
1344
1407
  }
1408
+ files.clear();
1409
+ for (const entry of nextFiles) {
1410
+ files.set(...entry);
1411
+ }
1345
1412
  store.docs = docs2;
1346
1413
  store.home = home2;
1347
1414
  reload.emit("change");
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.0",
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
@@ -7,14 +7,14 @@
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
14
  import {Hono} from "hono";
15
15
  import {toSSG} from "hono/ssg";
16
16
  import {createApp} from "@/app";
17
- import {asset_client_path, asset_favicon_path, asset_font_dir, asset_style_path} from "@/constant/asset";
17
+ import {asset_client_path, asset_favicon_path, asset_font_dir, asset_media_extensions, asset_style_path} from "@/constant/asset";
18
18
  import {cli_config_file_name, cli_default_command, cli_default_docs_dir, cli_dev_port} from "@/constant/cli";
19
19
  import {copy_error_config, copy_error_font_preload, copy_error_prefix, copy_error_unknown_command} from "@/constant/copy";
20
20
  import {font_brand_css, font_cache_control, font_mono_css, font_sans_css, font_sans_preload_file} from "@/constant/font";
@@ -85,7 +85,7 @@ const main = async () => {
85
85
  const fontCss = [sans.css, mono.css, brand.css].join("\n");
86
86
  const stylePath = toAssetPath({path: asset_style_path, file: join(kitRoot, "dist/cli.css")});
87
87
  const clientPath = toAssetPath({path: asset_client_path, file: join(kitRoot, "dist/client.js")});
88
- const files = new Map([
88
+ const kitFiles = new Map([
89
89
  [stylePath, join(kitRoot, "dist/cli.css")],
90
90
  [clientPath, join(kitRoot, "dist/client.js")],
91
91
  [asset_favicon_path, join(kitRoot, "src/asset/favicon.svg")],
@@ -93,7 +93,8 @@ const main = async () => {
93
93
  ...mono.files,
94
94
  ...brand.files,
95
95
  ]);
96
- const processor = createProcessor({site: siteConfig, root: docsRoot});
96
+ const files = new Map(kitFiles);
97
+ const processor = createProcessor({site: siteConfig, root: docsRoot, files});
97
98
  const [docs, home] = await Promise.all([readDocs({root: docsRoot, processor}), readHome({root: docsRoot, processor})]);
98
99
  const store = {docs, home};
99
100
  const app = createApp({
@@ -132,20 +133,30 @@ const main = async () => {
132
133
  * 재처리 실패 시 직전 정상 문서 유지
133
134
  */
134
135
  const handleDocsChange = async (_event: string, filename: string | null) => {
135
- if (filename !== null && !/\.mdx?$/i.test(filename)) {
136
- return;
136
+ if (filename !== null) {
137
+ const segments = filename.split(sep);
138
+ // 출력·설치 폴더는 제외 · 실패한 참조의 파일이 뒤늦게 생겨도 다시 처리
139
+ if (segments.includes("dist") || segments.includes("node_modules") || (!/\.mdx?$/i.test(filename) && !asset_media_extensions.has(extname(filename).toLowerCase()))) {
140
+ return;
141
+ }
137
142
  }
138
143
 
139
144
  const currentGeneration = ++generation;
140
145
 
141
146
  try {
142
- const [docs, home] = await Promise.all([readDocs({root: docsRoot, processor}), readHome({root: docsRoot, processor})]);
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})]);
143
150
 
144
151
  // 최신 변경의 처리 결과만 반영
145
152
  if (currentGeneration !== generation) {
146
153
  return;
147
154
  }
148
155
 
156
+ files.clear();
157
+ for (const entry of nextFiles) {
158
+ files.set(...entry);
159
+ }
149
160
  store.docs = docs;
150
161
  store.home = home;
151
162
  reload.emit("change");
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"]);
@@ -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
  /**