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 +9 -0
- package/README.md +5 -5
- package/dist/cli.js +105 -38
- package/dist/client.js +2 -2
- package/package.json +1 -1
- package/src/cli.ts +18 -7
- package/src/client.ts +12 -1
- package/src/constant/asset.ts +20 -0
- package/src/constant/copy.ts +5 -0
- package/src/constant/doc.ts +2 -2
- package/src/content/create-processor.ts +3 -1
- package/src/content/read-docs.test.ts +53 -3
- package/src/content/rehype-assets.ts +70 -0
- package/src/dev.ts +1 -1
- package/src/type/doc-data.ts +1 -1
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.
|
|
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.
|
|
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.
|
|
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 [
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
1060
|
+
import { relative as relative3 } from "node:path";
|
|
995
1061
|
var remarkReport = (options) => (_tree, file) => {
|
|
996
|
-
const 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
|
|
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
|
-
*
|
|
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(
|
|
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(
|
|
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) ?
|
|
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[
|
|
1149
|
-
if (
|
|
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
|
|
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(
|
|
1251
|
-
const extension =
|
|
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 =
|
|
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:
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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 ${
|
|
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
|
|
1337
|
-
|
|
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
|
|
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
|
|
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")}))},
|
|
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
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
|
|
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
|
|
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
|
|
136
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/constant/asset.ts
CHANGED
|
@@ -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"]);
|
package/src/constant/copy.ts
CHANGED
package/src/constant/doc.ts
CHANGED
|
@@ -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\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\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\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\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\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\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);
|