for-humanity 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +5 -5
- package/dist/cli.js +149 -48
- package/dist/client.js +2 -2
- package/package.json +2 -2
- package/src/cli.ts +43 -24
- package/src/client.ts +12 -1
- package/src/constant/asset.ts +20 -0
- package/src/constant/cli.ts +5 -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/src/util/async/to-change-queue.test.ts +58 -0
- package/src/util/async/to-change-queue.ts +43 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.4.1 — 2026-10-04
|
|
6
|
+
|
|
7
|
+
- Coalesce consecutive document and asset saves and serialize dev refreshes so bulk AI edits cannot start overlapping full-site renders and exhaust memory.
|
|
8
|
+
- Continue queued and later edits after failed processing, and apply only the latest valid generation.
|
|
9
|
+
|
|
10
|
+
## 0.4.0 — 2026-10-04
|
|
11
|
+
|
|
12
|
+
- Copy explicitly referenced local images and supported attachments to fingerprinted asset URLs, resolving paths relative to their Markdown files in both build and dev.
|
|
13
|
+
- Support reference-style images and raw HTML image sources, preserve query/hash suffixes, and reject missing files, unsupported local image formats and paths or symlinks outside the document root.
|
|
14
|
+
- Reload referenced image and attachment changes in dev while retaining the previous valid document and resource map after failed processing.
|
|
15
|
+
- Mark only the current table-of-contents anchor as active, with `aria-current="location"`, while retaining the tree path through its ancestors.
|
|
16
|
+
- Add image documentation, purpose-based authoring guidance and fictional design and research examples.
|
|
17
|
+
- Keep legacy `type: document` and `type: blueprint` inputs compatible, recommend omitting `type`, and focus the roadmap on reusable visualization and theme modules rather than a dedicated Blueprint renderer.
|
|
18
|
+
|
|
5
19
|
## 0.3.0 — 2026-10-04
|
|
6
20
|
|
|
7
21
|
- Support nested document pages with optional `parent` IDs, preserving existing groups and document URLs.
|
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ Write Markdown. Keep the context. Share a static site.
|
|
|
33
33
|
Node.js 22 or later and pnpm. Install the CLI in your document project:
|
|
34
34
|
|
|
35
35
|
```sh
|
|
36
|
-
pnpm add -D --save-exact for-humanity@0.
|
|
36
|
+
pnpm add -D --save-exact for-humanity@0.4.1
|
|
37
37
|
mkdir docs
|
|
38
38
|
```
|
|
39
39
|
|
|
@@ -52,7 +52,7 @@ pnpm exec for-humanity preview docs
|
|
|
52
52
|
|
|
53
53
|
If your Markdown files are in the project root, use `.` instead of `docs`. The root `README.md` becomes the home page; root `AGENTS.md` and `CLAUDE.md` stay out of the site.
|
|
54
54
|
|
|
55
|
-
Version `0.
|
|
55
|
+
Version `0.4.1` supports `dev`, `build` and `preview`. `init` is planned; see the [Roadmap](https://github.com/minu-ha/for-humanity/blob/main/templates/document/roadmap.md) and [Changelog](https://github.com/minu-ha/for-humanity/blob/main/CHANGELOG.md).
|
|
56
56
|
Cloudflare Pages can host the generated site. See [Deployment](https://github.com/minu-ha/for-humanity/blob/main/templates/document/deployment.md) for the build settings.
|
|
57
57
|
|
|
58
58
|
### Develop the kit
|
|
@@ -74,7 +74,7 @@ pnpm preview # Preview the static site
|
|
|
74
74
|
pnpm build:pages # Add Cloudflare Pages headers and a 404 page
|
|
75
75
|
```
|
|
76
76
|
|
|
77
|
-
Changes pushed to `main` update the documentation site. A stable version tag such as `v0.
|
|
77
|
+
Changes pushed to `main` update the documentation site. A stable version tag such as `v0.4.1` runs checks, publishes the package to npm, then creates a GitHub Release with the same package file.
|
|
78
78
|
The npm README updates with each package release. See [Maintenance](https://github.com/minu-ha/for-humanity/blob/main/templates/document/maintenance.md#packaging-and-release) for the release steps.
|
|
79
79
|
|
|
80
80
|
## Make it yours
|
|
@@ -109,7 +109,7 @@ The contract, code or reasoning behind the visible result.
|
|
|
109
109
|
:::
|
|
110
110
|
```
|
|
111
111
|
|
|
112
|
-
[Parts](https://github.com/minu-ha/for-humanity/blob/main/templates/document/parts.md) pairs source examples with their rendered results. The [
|
|
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,12 +2,13 @@
|
|
|
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";
|
|
11
|
+
import { debounce } from "es-toolkit";
|
|
11
12
|
import { Hono as Hono3 } from "hono";
|
|
12
13
|
import { toSSG } from "hono/ssg";
|
|
13
14
|
|
|
@@ -149,6 +150,7 @@ var copy_error_unknown_directive = "\uBAA8\uB974\uB294 \uBD80\uD488\uC774\uB2E4"
|
|
|
149
150
|
var copy_error_directive_syntax = "\uBD80\uD488 \uBB38\uBC95\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uB2E4";
|
|
150
151
|
var copy_warn_broken_link = "\uC5C6\uB294 \uBB38\uC11C\uB85C \uAC74 \uB9C1\uD06C\uB2E4";
|
|
151
152
|
var copy_warn_unknown_status = "\uC124\uC815\uC5D0 \uC5C6\uB294 \uC0C1\uD0DC \uBB38\uAD6C\uB2E4";
|
|
153
|
+
var copy_error_asset = "\uC790\uC6D0\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uB2E4";
|
|
152
154
|
|
|
153
155
|
// src/constant/locale.ts
|
|
154
156
|
var locale_doc_name = "en";
|
|
@@ -242,9 +244,21 @@ var asset_content_types = {
|
|
|
242
244
|
".css": "text/css; charset=utf-8",
|
|
243
245
|
".js": "text/javascript; charset=utf-8",
|
|
244
246
|
".woff2": "font/woff2",
|
|
245
|
-
".svg": "image/svg+xml"
|
|
247
|
+
".svg": "image/svg+xml",
|
|
248
|
+
".png": "image/png",
|
|
249
|
+
".jpg": "image/jpeg",
|
|
250
|
+
".jpeg": "image/jpeg",
|
|
251
|
+
".gif": "image/gif",
|
|
252
|
+
".webp": "image/webp",
|
|
253
|
+
".avif": "image/avif",
|
|
254
|
+
".pdf": "application/pdf",
|
|
255
|
+
".csv": "text/csv; charset=utf-8",
|
|
256
|
+
".txt": "text/plain; charset=utf-8",
|
|
257
|
+
".zip": "application/zip"
|
|
246
258
|
};
|
|
247
259
|
var asset_content_type_default = "application/octet-stream";
|
|
260
|
+
var asset_media_dir = `/${asset_dir}/media`;
|
|
261
|
+
var asset_media_extensions = /* @__PURE__ */ new Set([".svg", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".pdf", ".csv", ".txt", ".zip"]);
|
|
248
262
|
|
|
249
263
|
// src/component/widget/shell/wg-shell.tsx
|
|
250
264
|
import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
|
|
@@ -336,6 +350,7 @@ var cli_default_command = "dev";
|
|
|
336
350
|
var cli_default_docs_dir = ".";
|
|
337
351
|
var cli_config_file_name = "for-humanity.config.mjs";
|
|
338
352
|
var cli_dev_port = 4321;
|
|
353
|
+
var cli_reload_delay_ms = 150;
|
|
339
354
|
|
|
340
355
|
// src/constant/font.ts
|
|
341
356
|
var font_sans_css = "src/asset/font/pretendard/pretendardvariable-dynamic-subset.css";
|
|
@@ -968,14 +983,67 @@ var remarkUnknownDirectives = () => (tree, file) => {
|
|
|
968
983
|
});
|
|
969
984
|
};
|
|
970
985
|
|
|
986
|
+
// src/content/rehype-assets.ts
|
|
987
|
+
import { existsSync as existsSync2, realpathSync, statSync } from "node:fs";
|
|
988
|
+
import { dirname as dirname2, extname, isAbsolute, relative as relative2, resolve as resolve2, sep } from "node:path";
|
|
989
|
+
import { visit as visit7 } from "unist-util-visit";
|
|
990
|
+
|
|
991
|
+
// src/util/file/to-asset-path.ts
|
|
992
|
+
import { createHash } from "node:crypto";
|
|
993
|
+
import { readFileSync } from "node:fs";
|
|
994
|
+
import { posix as posix2 } from "node:path";
|
|
995
|
+
var toAssetPath = (options) => {
|
|
996
|
+
const extension = posix2.extname(options.path);
|
|
997
|
+
return `${posix2.dirname(options.path)}/${posix2.basename(options.path, extension)}.${createHash("sha256").update(readFileSync(options.file)).digest("hex")}${extension}`;
|
|
998
|
+
};
|
|
999
|
+
|
|
1000
|
+
// src/content/rehype-assets.ts
|
|
1001
|
+
var rehypeAssets = (options) => (tree, file) => {
|
|
1002
|
+
visit7(tree, "element", (node) => {
|
|
1003
|
+
const property = node.tagName === "img" ? "src" : "href";
|
|
1004
|
+
const url = node.properties[property];
|
|
1005
|
+
if (node.tagName !== "img" && node.tagName !== "a" || typeof url !== "string" || /^(?:[a-z][a-z\d+.-]*:|\/|#)/i.test(url)) {
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
const match = /^([^?#]+)([?#].*)?$/.exec(url);
|
|
1009
|
+
if (match === null) {
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
const path = decodeURIComponent(match[1]);
|
|
1013
|
+
const extension = extname(path).toLowerCase();
|
|
1014
|
+
if (!asset_media_extensions.has(extension)) {
|
|
1015
|
+
if (node.tagName === "img") {
|
|
1016
|
+
file.fail(`${copy_error_asset}: \uC9C0\uC6D0\uD558\uC9C0 \uC54A\uB294 \uC774\uBBF8\uC9C0 \uD615\uC2DD \xB7 ${url}`, node);
|
|
1017
|
+
}
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
const target = resolve2(dirname2(file.path), path);
|
|
1021
|
+
const location = relative2(options.root, target);
|
|
1022
|
+
if (location === ".." || location.startsWith(`..${sep}`) || isAbsolute(location)) {
|
|
1023
|
+
file.fail(`${copy_error_asset}: \uBB38\uC11C \uD3F4\uB354 \uBC16 \xB7 ${url}`, node);
|
|
1024
|
+
}
|
|
1025
|
+
if (!existsSync2(target)) {
|
|
1026
|
+
file.fail(`${copy_error_asset}: \uD30C\uC77C\uC774 \uC5C6\uB2E4 \xB7 ${url}`, node);
|
|
1027
|
+
}
|
|
1028
|
+
const actual = realpathSync(target);
|
|
1029
|
+
const actualLocation = relative2(realpathSync(options.root), actual);
|
|
1030
|
+
if (actualLocation === ".." || actualLocation.startsWith(`..${sep}`) || isAbsolute(actualLocation) || !statSync(actual).isFile()) {
|
|
1031
|
+
file.fail(`${copy_error_asset}: \uBB38\uC11C \uD3F4\uB354 \uBC16\uC774\uAC70\uB098 \uC77C\uBC18 \uD30C\uC77C\uC774 \uC544\uB2C8\uB2E4 \xB7 ${url}`, node);
|
|
1032
|
+
}
|
|
1033
|
+
const assetUrl = toAssetPath({ path: `${asset_media_dir}/asset${extension}`, file: actual });
|
|
1034
|
+
options.files.set(assetUrl, actual);
|
|
1035
|
+
node.properties[property] = match[2] === void 0 ? assetUrl : `${assetUrl}${match[2]}`;
|
|
1036
|
+
});
|
|
1037
|
+
};
|
|
1038
|
+
|
|
971
1039
|
// src/content/rehype-heading-ids.ts
|
|
972
1040
|
import Slugger from "github-slugger";
|
|
973
1041
|
import { toString as toPlainText3 } from "hast-util-to-string";
|
|
974
|
-
import { visit as
|
|
1042
|
+
import { visit as visit8 } from "unist-util-visit";
|
|
975
1043
|
var rehypeHeadingIds = () => (tree, file) => {
|
|
976
1044
|
const slugger = new Slugger();
|
|
977
1045
|
const headings = [];
|
|
978
|
-
|
|
1046
|
+
visit8(tree, "element", (node) => {
|
|
979
1047
|
const depth = /^h([1-6])$/.exec(node.tagName)?.[1];
|
|
980
1048
|
if (depth === void 0) {
|
|
981
1049
|
return;
|
|
@@ -991,9 +1059,9 @@ var rehypeHeadingIds = () => (tree, file) => {
|
|
|
991
1059
|
};
|
|
992
1060
|
|
|
993
1061
|
// src/content/remark-report.ts
|
|
994
|
-
import { relative as
|
|
1062
|
+
import { relative as relative3 } from "node:path";
|
|
995
1063
|
var remarkReport = (options) => (_tree, file) => {
|
|
996
|
-
const path =
|
|
1064
|
+
const path = relative3(options.root, file.path);
|
|
997
1065
|
for (const message of file.messages) {
|
|
998
1066
|
console.warn(`${message.line === void 0 ? path : `${path}:${message.line}`} ${message.reason}`);
|
|
999
1067
|
}
|
|
@@ -1001,12 +1069,12 @@ var remarkReport = (options) => (_tree, file) => {
|
|
|
1001
1069
|
|
|
1002
1070
|
// src/content/create-processor.ts
|
|
1003
1071
|
var createProcessor = (options) => {
|
|
1004
|
-
return unified().use(remarkParse).use(remarkFrontmatter, ["yaml"]).use(remarkGfm).use(remarkSmartypants).use(remarkDirective).use(remarkBlocks).use(remarkParts).use(remarkFlow).use(remarkStatus, { status: options.site.status }).use(remarkSwatch).use(remarkLinks, { root: options.root }).use(remarkUnknownDirectives).use(remarkReport, { root: options.root }).use(remarkRehype, { allowDangerousHtml: true }).use(rehypeShiki, { theme: createCssVariablesTheme({ name: "for-humanity", variablePrefix: "--app-code-" }) }).use(rehypeHead).use(rehypeHeadingIds).use(rehypeSections).use(rehypeTables).use(rehypeRaw).use(rehypeStringify, { allowDangerousHtml: true });
|
|
1072
|
+
return unified().use(remarkParse).use(remarkFrontmatter, ["yaml"]).use(remarkGfm).use(remarkSmartypants).use(remarkDirective).use(remarkBlocks).use(remarkParts).use(remarkFlow).use(remarkStatus, { status: options.site.status }).use(remarkSwatch).use(remarkLinks, { root: options.root }).use(remarkUnknownDirectives).use(remarkReport, { root: options.root }).use(remarkRehype, { allowDangerousHtml: true }).use(rehypeShiki, { theme: createCssVariablesTheme({ name: "for-humanity", variablePrefix: "--app-code-" }) }).use(rehypeHead).use(rehypeHeadingIds).use(rehypeSections).use(rehypeTables).use(rehypeRaw).use(rehypeAssets, { root: options.root, files: options.files ?? /* @__PURE__ */ new Map() }).use(rehypeStringify, { allowDangerousHtml: true });
|
|
1005
1073
|
};
|
|
1006
1074
|
|
|
1007
1075
|
// src/content/read-docs.ts
|
|
1008
1076
|
import { readdir, readFile } from "node:fs/promises";
|
|
1009
|
-
import { join, posix as
|
|
1077
|
+
import { join, posix as posix3, sep as sep2 } from "node:path";
|
|
1010
1078
|
import { VFile } from "vfile";
|
|
1011
1079
|
import { parse as parseYaml } from "yaml";
|
|
1012
1080
|
|
|
@@ -1035,7 +1103,7 @@ var docDataSchema = z2.object({
|
|
|
1035
1103
|
*/
|
|
1036
1104
|
label: z2.string(),
|
|
1037
1105
|
/**
|
|
1038
|
-
*
|
|
1106
|
+
* 기존 type 값 호환 · 생략 권장, 렌더링 차이 없음
|
|
1039
1107
|
*/
|
|
1040
1108
|
type: z2.enum(doc_type).default(doc_type.document),
|
|
1041
1109
|
/**
|
|
@@ -1055,11 +1123,11 @@ var docDataSchema = z2.object({
|
|
|
1055
1123
|
// src/content/read-docs.ts
|
|
1056
1124
|
var readDocs = async (options) => {
|
|
1057
1125
|
const paths = (await readdir(options.root, { recursive: true })).filter(
|
|
1058
|
-
(path) => /\.mdx?$/i.test(path) && !/^(readme|agents|claude)\.md$/i.test(path) && !path.split(
|
|
1126
|
+
(path) => /\.mdx?$/i.test(path) && !/^(readme|agents|claude)\.md$/i.test(path) && !path.split(sep2).includes("node_modules") && !path.startsWith(`dist${sep2}`)
|
|
1059
1127
|
);
|
|
1060
1128
|
const docs = await Promise.all(
|
|
1061
1129
|
paths.map(async (path) => {
|
|
1062
|
-
const id = path.replace(/\.mdx?$/i, "").split(
|
|
1130
|
+
const id = path.replace(/\.mdx?$/i, "").split(sep2).join("/").toLowerCase();
|
|
1063
1131
|
const segments = id.split("/");
|
|
1064
1132
|
if (segments.some((segment) => segment === "" || segment === "." || segment === ".." || segment === "index.html") || /[#?%*:\\]/.test(id) || /[\p{Cc}]/u.test(id) || segments[0] === asset_dir || segments[0] === asset_favicon_path.slice(1)) {
|
|
1065
1133
|
throw new Error(`${copy_error_doc_id}: ${path}`);
|
|
@@ -1074,7 +1142,7 @@ var readDocs = async (options) => {
|
|
|
1074
1142
|
}
|
|
1075
1143
|
const data = {
|
|
1076
1144
|
...parsed.data,
|
|
1077
|
-
parent: parsed.data.parent !== void 0 && /^\.\.?\//.test(parsed.data.parent) ?
|
|
1145
|
+
parent: parsed.data.parent !== void 0 && /^\.\.?\//.test(parsed.data.parent) ? posix3.join(posix3.dirname(id), parsed.data.parent) : parsed.data.parent
|
|
1078
1146
|
};
|
|
1079
1147
|
const fh = { frontmatter: data, headings: [], sections: [] };
|
|
1080
1148
|
file.data.fh = fh;
|
|
@@ -1135,7 +1203,7 @@ var readHome = async (options) => {
|
|
|
1135
1203
|
|
|
1136
1204
|
// src/dev.ts
|
|
1137
1205
|
import { readFile as readFile3 } from "node:fs/promises";
|
|
1138
|
-
import { extname } from "node:path";
|
|
1206
|
+
import { extname as extname2 } from "node:path";
|
|
1139
1207
|
import { Hono as Hono2 } from "hono";
|
|
1140
1208
|
import { streamSSE } from "hono/streaming";
|
|
1141
1209
|
var createDevApp = (options) => {
|
|
@@ -1145,8 +1213,8 @@ var createDevApp = (options) => {
|
|
|
1145
1213
|
if (file === void 0) {
|
|
1146
1214
|
return c.notFound();
|
|
1147
1215
|
}
|
|
1148
|
-
c.header("content-type", asset_content_types[
|
|
1149
|
-
if (
|
|
1216
|
+
c.header("content-type", asset_content_types[extname2(file).toLowerCase()] ?? asset_content_type_default);
|
|
1217
|
+
if (extname2(file) === ".woff2") {
|
|
1150
1218
|
c.header("Cache-Control", font_cache_control);
|
|
1151
1219
|
}
|
|
1152
1220
|
return c.body(await readFile3(file));
|
|
@@ -1222,6 +1290,32 @@ var siteConfigSchema = z3.object({
|
|
|
1222
1290
|
).default(status_default_phrases)
|
|
1223
1291
|
});
|
|
1224
1292
|
|
|
1293
|
+
// src/util/async/to-change-queue.ts
|
|
1294
|
+
var toChangeQueue = (options) => {
|
|
1295
|
+
const state = { requested: 0, completed: 0, running: false };
|
|
1296
|
+
return async () => {
|
|
1297
|
+
state.requested += 1;
|
|
1298
|
+
if (state.running) {
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
state.running = true;
|
|
1302
|
+
try {
|
|
1303
|
+
while (state.completed !== state.requested) {
|
|
1304
|
+
const requested = state.requested;
|
|
1305
|
+
try {
|
|
1306
|
+
await options.run();
|
|
1307
|
+
} catch (error) {
|
|
1308
|
+
options.onError(error);
|
|
1309
|
+
} finally {
|
|
1310
|
+
state.completed = requested;
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
} finally {
|
|
1314
|
+
state.running = false;
|
|
1315
|
+
}
|
|
1316
|
+
};
|
|
1317
|
+
};
|
|
1318
|
+
|
|
1225
1319
|
// src/util/error/to-error-message.ts
|
|
1226
1320
|
var toErrorMessage = (error) => {
|
|
1227
1321
|
if (error instanceof Error && "file" in error && typeof error.file === "string" && "line" in error && typeof error.line === "number") {
|
|
@@ -1230,25 +1324,16 @@ var toErrorMessage = (error) => {
|
|
|
1230
1324
|
return error instanceof Error ? error.message : String(error);
|
|
1231
1325
|
};
|
|
1232
1326
|
|
|
1233
|
-
// src/util/file/to-asset-path.ts
|
|
1234
|
-
import { createHash } from "node:crypto";
|
|
1235
|
-
import { readFileSync } from "node:fs";
|
|
1236
|
-
import { posix as posix3 } from "node:path";
|
|
1237
|
-
var toAssetPath = (options) => {
|
|
1238
|
-
const extension = posix3.extname(options.path);
|
|
1239
|
-
return `${posix3.dirname(options.path)}/${posix3.basename(options.path, extension)}.${createHash("sha256").update(readFileSync(options.file)).digest("hex")}${extension}`;
|
|
1240
|
-
};
|
|
1241
|
-
|
|
1242
1327
|
// src/util/font/to-font-css.ts
|
|
1243
1328
|
import { createHash as createHash2 } from "node:crypto";
|
|
1244
1329
|
import { readFileSync as readFileSync2 } from "node:fs";
|
|
1245
|
-
import { basename, dirname as
|
|
1330
|
+
import { basename, dirname as dirname3, extname as extname3, join as join3 } from "node:path";
|
|
1246
1331
|
var toFontCss = (options) => {
|
|
1247
1332
|
const files = /* @__PURE__ */ new Map();
|
|
1248
1333
|
const css = [...readFileSync2(options.css, "utf8").matchAll(/@font-face\s*\{[^}]*\}/g)].map(
|
|
1249
1334
|
(match) => match[0].replace(/font-display:\s*[^;]+;/g, `font-display: ${font_display_strategy};`).replace(/url\(([^)]+)\)/g, (_match, relativeUrl) => {
|
|
1250
|
-
const file = join3(
|
|
1251
|
-
const extension =
|
|
1335
|
+
const file = join3(dirname3(options.css), relativeUrl);
|
|
1336
|
+
const extension = extname3(file);
|
|
1252
1337
|
const hash = createHash2("sha256").update(readFileSync2(file)).digest("hex").slice(0, font_hash_length);
|
|
1253
1338
|
const url = `${options.fontDir}/${basename(file, extension)}.${hash}${extension}`;
|
|
1254
1339
|
files.set(url, file);
|
|
@@ -1266,14 +1351,14 @@ var main = async () => {
|
|
|
1266
1351
|
throw new Error(`${copy_error_unknown_command}: ${command} (${commands.join(", ")})`);
|
|
1267
1352
|
}
|
|
1268
1353
|
const kitRoot = fileURLToPath(new URL("../", import.meta.url));
|
|
1269
|
-
const docsRoot =
|
|
1354
|
+
const docsRoot = resolve3(docsDir);
|
|
1270
1355
|
const outDir = join4(docsRoot, "dist");
|
|
1271
1356
|
if (command === "preview") {
|
|
1272
1357
|
const app2 = new Hono3();
|
|
1273
1358
|
app2.use(
|
|
1274
1359
|
"/*",
|
|
1275
1360
|
serveStatic({
|
|
1276
|
-
root:
|
|
1361
|
+
root: relative4(process.cwd(), outDir),
|
|
1277
1362
|
onFound: (_path, c) => {
|
|
1278
1363
|
if (c.req.path.startsWith(`${asset_font_dir}/`)) {
|
|
1279
1364
|
c.header("Cache-Control", font_cache_control);
|
|
@@ -1285,7 +1370,7 @@ var main = async () => {
|
|
|
1285
1370
|
return;
|
|
1286
1371
|
}
|
|
1287
1372
|
const configFile = join4(docsRoot, cli_config_file_name);
|
|
1288
|
-
const parsedConfig = siteConfigSchema.safeParse(
|
|
1373
|
+
const parsedConfig = siteConfigSchema.safeParse(existsSync3(configFile) ? (await import(pathToFileURL(configFile).href)).default : site_config_absent);
|
|
1289
1374
|
if (!parsedConfig.success) {
|
|
1290
1375
|
throw new Error(`${copy_error_config}: ${parsedConfig.error.issues.map((issue) => `${issue.path.join(".")} ${issue.message}`).join(", ")}`);
|
|
1291
1376
|
}
|
|
@@ -1300,7 +1385,7 @@ var main = async () => {
|
|
|
1300
1385
|
const fontCss = [sans.css, mono.css, brand.css].join("\n");
|
|
1301
1386
|
const stylePath = toAssetPath({ path: asset_style_path, file: join4(kitRoot, "dist/cli.css") });
|
|
1302
1387
|
const clientPath = toAssetPath({ path: asset_client_path, file: join4(kitRoot, "dist/client.js") });
|
|
1303
|
-
const
|
|
1388
|
+
const kitFiles = new Map([
|
|
1304
1389
|
[stylePath, join4(kitRoot, "dist/cli.css")],
|
|
1305
1390
|
[clientPath, join4(kitRoot, "dist/client.js")],
|
|
1306
1391
|
[asset_favicon_path, join4(kitRoot, "src/asset/favicon.svg")],
|
|
@@ -1308,7 +1393,8 @@ var main = async () => {
|
|
|
1308
1393
|
...mono.files,
|
|
1309
1394
|
...brand.files
|
|
1310
1395
|
]);
|
|
1311
|
-
const
|
|
1396
|
+
const files = new Map(kitFiles);
|
|
1397
|
+
const processor = createProcessor({ site: siteConfig, root: docsRoot, files });
|
|
1312
1398
|
const [docs, home] = await Promise.all([readDocs({ root: docsRoot, processor }), readHome({ root: docsRoot, processor })]);
|
|
1313
1399
|
const store = { docs, home };
|
|
1314
1400
|
const app = createApp({
|
|
@@ -1323,31 +1409,46 @@ var main = async () => {
|
|
|
1323
1409
|
throw result.error;
|
|
1324
1410
|
}
|
|
1325
1411
|
for (const [url, file] of files) {
|
|
1326
|
-
await mkdir(
|
|
1412
|
+
await mkdir(dirname4(join4(outDir, url)), { recursive: true });
|
|
1327
1413
|
await copyFile(file, join4(outDir, url));
|
|
1328
1414
|
}
|
|
1329
|
-
console.log(`${result.files.length} pages, ${files.size} assets \u2192 ${
|
|
1415
|
+
console.log(`${result.files.length} pages, ${files.size} assets \u2192 ${relative4(process.cwd(), outDir)}`);
|
|
1330
1416
|
return;
|
|
1331
1417
|
}
|
|
1332
1418
|
const reload = new EventEmitter();
|
|
1333
1419
|
let generation = 0;
|
|
1334
1420
|
serve({ fetch: createDevApp({ pages: app, files, reload }).fetch, port: cli_dev_port }, (info) => console.log(`http://localhost:${info.port}/`));
|
|
1335
|
-
const
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1421
|
+
const reloadDocs = debounce(
|
|
1422
|
+
toChangeQueue({
|
|
1423
|
+
run: async () => {
|
|
1424
|
+
const currentGeneration = generation;
|
|
1425
|
+
const nextFiles = new Map(kitFiles);
|
|
1426
|
+
const nextProcessor = createProcessor({ site: siteConfig, root: docsRoot, files: nextFiles });
|
|
1427
|
+
const [docs2, home2] = await Promise.all([readDocs({ root: docsRoot, processor: nextProcessor }), readHome({ root: docsRoot, processor: nextProcessor })]);
|
|
1428
|
+
if (currentGeneration !== generation) {
|
|
1429
|
+
return;
|
|
1430
|
+
}
|
|
1431
|
+
files.clear();
|
|
1432
|
+
for (const entry of nextFiles) {
|
|
1433
|
+
files.set(...entry);
|
|
1434
|
+
}
|
|
1435
|
+
store.docs = docs2;
|
|
1436
|
+
store.home = home2;
|
|
1437
|
+
reload.emit("change");
|
|
1438
|
+
},
|
|
1439
|
+
onError: (error) => console.error(`${copy_error_prefix}: ${toErrorMessage(error)}`)
|
|
1440
|
+
}),
|
|
1441
|
+
cli_reload_delay_ms
|
|
1442
|
+
);
|
|
1443
|
+
const handleDocsChange = (_event, filename) => {
|
|
1444
|
+
if (filename !== null) {
|
|
1445
|
+
const segments = filename.split(sep3);
|
|
1446
|
+
if (segments.includes("dist") || segments.includes("node_modules") || !/\.mdx?$/i.test(filename) && !asset_media_extensions.has(extname4(filename).toLowerCase())) {
|
|
1343
1447
|
return;
|
|
1344
1448
|
}
|
|
1345
|
-
store.docs = docs2;
|
|
1346
|
-
store.home = home2;
|
|
1347
|
-
reload.emit("change");
|
|
1348
|
-
} catch (error) {
|
|
1349
|
-
console.error(`${copy_error_prefix}: ${toErrorMessage(error)}`);
|
|
1350
1449
|
}
|
|
1450
|
+
generation += 1;
|
|
1451
|
+
reloadDocs();
|
|
1351
1452
|
};
|
|
1352
1453
|
watch(docsRoot, { recursive: true }, handleDocsChange);
|
|
1353
1454
|
};
|
package/dist/client.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
var L="fh-cursor-face";var
|
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "for-humanity",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"description": "Documents written for humans to read: a docs kit with a sidebar, parts, sections and flowcharts, built on Hono and React",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"docs",
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"build": "pnpm sync:docs && pnpm build:kit && node dist/cli.js build templates/document",
|
|
42
42
|
"build:pages": "pnpm build && node util/prepare-pages.mjs",
|
|
43
43
|
"preview": "node dist/cli.js preview templates/document",
|
|
44
|
-
"test": "esbuild src/util/font/to-font-css.test.ts src/content/read-docs.test.ts --bundle --platform=node --format=esm --packages=external --outbase=src --outdir=node_modules/.cache/for-humanity-tests --out-extension:.js=.mjs && node --test node_modules/.cache/for-humanity-tests/content/*.test.mjs node_modules/.cache/for-humanity-tests/util/font/*.test.mjs util/*.test.mjs",
|
|
44
|
+
"test": "esbuild src/util/async/to-change-queue.test.ts src/util/font/to-font-css.test.ts src/content/read-docs.test.ts --bundle --platform=node --format=esm --packages=external --outbase=src --outdir=node_modules/.cache/for-humanity-tests --out-extension:.js=.mjs && node --test node_modules/.cache/for-humanity-tests/util/async/*.test.mjs node_modules/.cache/for-humanity-tests/content/*.test.mjs node_modules/.cache/for-humanity-tests/util/font/*.test.mjs util/*.test.mjs",
|
|
45
45
|
"lint": "biome check && stylelint \"src/**/*.css\"",
|
|
46
46
|
"lint:fix": "biome check --write && stylelint \"src/**/*.css\" --fix",
|
|
47
47
|
"typecheck": "tsc --noEmit",
|
package/src/cli.ts
CHANGED
|
@@ -7,15 +7,16 @@
|
|
|
7
7
|
import {EventEmitter} from "node:events";
|
|
8
8
|
import {existsSync, watch} from "node:fs";
|
|
9
9
|
import {copyFile, mkdir, rm, writeFile} from "node:fs/promises";
|
|
10
|
-
import {basename, dirname, join, relative, resolve} from "node:path";
|
|
10
|
+
import {basename, dirname, extname, join, relative, resolve, sep} from "node:path";
|
|
11
11
|
import {fileURLToPath, pathToFileURL} from "node:url";
|
|
12
12
|
import {serve} from "@hono/node-server";
|
|
13
13
|
import {serveStatic} from "@hono/node-server/serve-static";
|
|
14
|
+
import {debounce} from "es-toolkit";
|
|
14
15
|
import {Hono} from "hono";
|
|
15
16
|
import {toSSG} from "hono/ssg";
|
|
16
17
|
import {createApp} from "@/app";
|
|
17
|
-
import {asset_client_path, asset_favicon_path, asset_font_dir, asset_style_path} from "@/constant/asset";
|
|
18
|
-
import {cli_config_file_name, cli_default_command, cli_default_docs_dir, cli_dev_port} from "@/constant/cli";
|
|
18
|
+
import {asset_client_path, asset_favicon_path, asset_font_dir, asset_media_extensions, asset_style_path} from "@/constant/asset";
|
|
19
|
+
import {cli_config_file_name, cli_default_command, cli_default_docs_dir, cli_dev_port, cli_reload_delay_ms} from "@/constant/cli";
|
|
19
20
|
import {copy_error_config, copy_error_font_preload, copy_error_prefix, copy_error_unknown_command} from "@/constant/copy";
|
|
20
21
|
import {font_brand_css, font_cache_control, font_mono_css, font_sans_css, font_sans_preload_file} from "@/constant/font";
|
|
21
22
|
import {site_config_absent} from "@/constant/site";
|
|
@@ -24,6 +25,7 @@ import {readDocs} from "@/content/read-docs";
|
|
|
24
25
|
import {readHome} from "@/content/read-home";
|
|
25
26
|
import {createDevApp} from "@/dev";
|
|
26
27
|
import {siteConfigSchema} from "@/type/site-config";
|
|
28
|
+
import {toChangeQueue} from "@/util/async/to-change-queue";
|
|
27
29
|
import {toErrorMessage} from "@/util/error/to-error-message";
|
|
28
30
|
import {toAssetPath} from "@/util/file/to-asset-path";
|
|
29
31
|
import {toFontCss} from "@/util/font/to-font-css";
|
|
@@ -85,7 +87,7 @@ const main = async () => {
|
|
|
85
87
|
const fontCss = [sans.css, mono.css, brand.css].join("\n");
|
|
86
88
|
const stylePath = toAssetPath({path: asset_style_path, file: join(kitRoot, "dist/cli.css")});
|
|
87
89
|
const clientPath = toAssetPath({path: asset_client_path, file: join(kitRoot, "dist/client.js")});
|
|
88
|
-
const
|
|
90
|
+
const kitFiles = new Map([
|
|
89
91
|
[stylePath, join(kitRoot, "dist/cli.css")],
|
|
90
92
|
[clientPath, join(kitRoot, "dist/client.js")],
|
|
91
93
|
[asset_favicon_path, join(kitRoot, "src/asset/favicon.svg")],
|
|
@@ -93,7 +95,8 @@ const main = async () => {
|
|
|
93
95
|
...mono.files,
|
|
94
96
|
...brand.files,
|
|
95
97
|
]);
|
|
96
|
-
const
|
|
98
|
+
const files = new Map(kitFiles);
|
|
99
|
+
const processor = createProcessor({site: siteConfig, root: docsRoot, files});
|
|
97
100
|
const [docs, home] = await Promise.all([readDocs({root: docsRoot, processor}), readHome({root: docsRoot, processor})]);
|
|
98
101
|
const store = {docs, home};
|
|
99
102
|
const app = createApp({
|
|
@@ -128,30 +131,46 @@ const main = async () => {
|
|
|
128
131
|
serve({fetch: createDevApp({pages: app, files, reload}).fetch, port: cli_dev_port}, (info) => console.log(`http://localhost:${info.port}/`));
|
|
129
132
|
|
|
130
133
|
/**
|
|
131
|
-
*
|
|
132
|
-
*
|
|
134
|
+
* 갱신은 하나씩 실행 · 연속 저장 합치기 · 최신 변경만 적용
|
|
135
|
+
* 문서 수가 많아도 파일 이벤트마다 전체 렌더링이 동시에 쌓이지 않음
|
|
133
136
|
*/
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
137
|
+
const reloadDocs = debounce(
|
|
138
|
+
toChangeQueue({
|
|
139
|
+
run: async () => {
|
|
140
|
+
const currentGeneration = generation;
|
|
141
|
+
const nextFiles = new Map(kitFiles);
|
|
142
|
+
const nextProcessor = createProcessor({site: siteConfig, root: docsRoot, files: nextFiles});
|
|
143
|
+
const [docs, home] = await Promise.all([readDocs({root: docsRoot, processor: nextProcessor}), readHome({root: docsRoot, processor: nextProcessor})]);
|
|
144
|
+
|
|
145
|
+
if (currentGeneration !== generation) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
files.clear();
|
|
150
|
+
for (const entry of nextFiles) {
|
|
151
|
+
files.set(...entry);
|
|
152
|
+
}
|
|
153
|
+
store.docs = docs;
|
|
154
|
+
store.home = home;
|
|
155
|
+
reload.emit("change");
|
|
156
|
+
},
|
|
157
|
+
onError: (error) => console.error(`${copy_error_prefix}: ${toErrorMessage(error)}`),
|
|
158
|
+
}),
|
|
159
|
+
cli_reload_delay_ms,
|
|
160
|
+
);
|
|
143
161
|
|
|
144
|
-
|
|
145
|
-
|
|
162
|
+
/**
|
|
163
|
+
* 입력 변경 감지 · 출력·설치 폴더 제외 · 누락 자원 추가도 다시 확인
|
|
164
|
+
*/
|
|
165
|
+
const handleDocsChange = (_event: string, filename: string | null) => {
|
|
166
|
+
if (filename !== null) {
|
|
167
|
+
const segments = filename.split(sep);
|
|
168
|
+
if (segments.includes("dist") || segments.includes("node_modules") || (!/\.mdx?$/i.test(filename) && !asset_media_extensions.has(extname(filename).toLowerCase()))) {
|
|
146
169
|
return;
|
|
147
170
|
}
|
|
148
|
-
|
|
149
|
-
store.docs = docs;
|
|
150
|
-
store.home = home;
|
|
151
|
-
reload.emit("change");
|
|
152
|
-
} catch (error) {
|
|
153
|
-
console.error(`${copy_error_prefix}: ${toErrorMessage(error)}`);
|
|
154
171
|
}
|
|
172
|
+
generation += 1;
|
|
173
|
+
reloadDocs();
|
|
155
174
|
};
|
|
156
175
|
|
|
157
176
|
watch(docsRoot, {recursive: true}, handleDocsChange);
|
package/src/client.ts
CHANGED
|
@@ -275,12 +275,23 @@ if (firstSection) {
|
|
|
275
275
|
const currentSubIndex = sub === undefined ? -1 : current.subs.indexOf(sub);
|
|
276
276
|
|
|
277
277
|
for (const [index, section] of tocSections.entries()) {
|
|
278
|
-
|
|
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/cli.ts
CHANGED
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);
|
package/src/type/doc-data.ts
CHANGED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import {test} from "node:test";
|
|
3
|
+
import {toChangeQueue} from "@/util/async/to-change-queue";
|
|
4
|
+
|
|
5
|
+
test("coalesces changes during an active render and never overlaps work", async () => {
|
|
6
|
+
const gate = Promise.withResolvers<void>();
|
|
7
|
+
const renders: number[] = [];
|
|
8
|
+
const errors: unknown[] = [];
|
|
9
|
+
let version = 1;
|
|
10
|
+
let running = 0;
|
|
11
|
+
const queue = toChangeQueue({
|
|
12
|
+
run: async () => {
|
|
13
|
+
running += 1;
|
|
14
|
+
assert.equal(running, 1);
|
|
15
|
+
const current = version;
|
|
16
|
+
if (current === 1) await gate.promise;
|
|
17
|
+
renders.push(current);
|
|
18
|
+
running -= 1;
|
|
19
|
+
},
|
|
20
|
+
onError: (error) => errors.push(error),
|
|
21
|
+
});
|
|
22
|
+
const first = queue();
|
|
23
|
+
for (const next of [2, 3, 4, 5]) {
|
|
24
|
+
version = next;
|
|
25
|
+
await queue();
|
|
26
|
+
}
|
|
27
|
+
gate.resolve();
|
|
28
|
+
await first;
|
|
29
|
+
assert.deepEqual(renders, [1, 5]);
|
|
30
|
+
assert.deepEqual(errors, []);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("a failed render does not lose a queued recovery or block later edits", async () => {
|
|
34
|
+
const gate = Promise.withResolvers<void>();
|
|
35
|
+
const errors: unknown[] = [];
|
|
36
|
+
const renders: number[] = [];
|
|
37
|
+
let version = 1;
|
|
38
|
+
const queue = toChangeQueue({
|
|
39
|
+
run: async () => {
|
|
40
|
+
const current = version;
|
|
41
|
+
if (current === 1) {
|
|
42
|
+
await gate.promise;
|
|
43
|
+
throw new Error("Invalid source");
|
|
44
|
+
}
|
|
45
|
+
renders.push(current);
|
|
46
|
+
},
|
|
47
|
+
onError: (error) => errors.push(error),
|
|
48
|
+
});
|
|
49
|
+
const first = queue();
|
|
50
|
+
version = 2;
|
|
51
|
+
await queue();
|
|
52
|
+
gate.resolve();
|
|
53
|
+
await first;
|
|
54
|
+
version = 3;
|
|
55
|
+
await queue();
|
|
56
|
+
assert.deepEqual(renders, [2, 3]);
|
|
57
|
+
assert.equal(errors.length, 1);
|
|
58
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 변경 재처리의 실행과 오류 보고 계약
|
|
3
|
+
*/
|
|
4
|
+
export interface ChangeQueueOptions {
|
|
5
|
+
/**
|
|
6
|
+
* 한 번의 최신 입력 처리 · 이전 실행이 끝난 뒤 호출
|
|
7
|
+
*/
|
|
8
|
+
run: () => Promise<void>;
|
|
9
|
+
/**
|
|
10
|
+
* 실패 보고 · 대기 중인 변경과 다음 실행은 유지
|
|
11
|
+
*/
|
|
12
|
+
onError: (error: unknown) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 동시에 하나만 실행하는 변경 요청 함수 · 실행 중 요청은 다음 한 번으로 합침
|
|
17
|
+
* 활성 실행의 반환 Promise는 대기 요청까지 끝나면 완료, 중첩 요청은 즉시 반환
|
|
18
|
+
*/
|
|
19
|
+
export const toChangeQueue = (options: ChangeQueueOptions) => {
|
|
20
|
+
const state = {requested: 0, completed: 0, running: false};
|
|
21
|
+
|
|
22
|
+
return async () => {
|
|
23
|
+
state.requested += 1;
|
|
24
|
+
if (state.running) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
state.running = true;
|
|
28
|
+
try {
|
|
29
|
+
while (state.completed !== state.requested) {
|
|
30
|
+
const requested = state.requested;
|
|
31
|
+
try {
|
|
32
|
+
await options.run();
|
|
33
|
+
} catch (error) {
|
|
34
|
+
options.onError(error);
|
|
35
|
+
} finally {
|
|
36
|
+
state.completed = requested;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
} finally {
|
|
40
|
+
state.running = false;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
};
|