for-humanity 0.5.0 → 0.6.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 +5 -0
- package/README.md +4 -3
- package/dist/cli.js +57 -7
- package/package.json +1 -1
- package/src/constant/copy.ts +5 -0
- package/src/entry/cli/_constant/crawling.ts +14 -0
- package/src/entry/cli/_function/create-dev-app.ts +9 -1
- package/src/entry/cli/_function/read-docs.ts +20 -1
- package/src/entry/cli/_function/to-crawling-app.ts +42 -0
- package/src/entry/cli/cli.test.ts +106 -1
- package/src/entry/cli/cli.ts +21 -4
- package/src/type/site-config.ts +10 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.6.0 — 2026-10-04
|
|
6
|
+
|
|
7
|
+
- Generate `robots.txt` and `sitemap.xml` in dev and static builds when an optional public root `url` is configured; include home and encoded document URLs, and leave projects without a URL unchanged.
|
|
8
|
+
- Validate public HTTP(S) origins and reject crawler-file document path collisions before processing; document Cloudflare crawler policy and Markdown negotiation separately.
|
|
9
|
+
|
|
5
10
|
## 0.5.0 — 2026-10-04
|
|
6
11
|
|
|
7
12
|
- Place document loading, Markdown processing and server setup under the CLI owner; keep document tree construction under navigation and share a standalone shell-controls widget through a lifecycle-managed Hono browser root.
|
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.6.0
|
|
37
37
|
mkdir docs
|
|
38
38
|
```
|
|
39
39
|
|
|
@@ -52,8 +52,9 @@ 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.6.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
|
+
Set an optional public root `url` in the site configuration to generate `robots.txt` and `sitemap.xml`; projects without a URL emit neither file. See [Crawling](https://github.com/minu-ha/for-humanity/blob/main/templates/document/crawling.md).
|
|
57
58
|
|
|
58
59
|
### Develop the kit
|
|
59
60
|
|
|
@@ -74,7 +75,7 @@ pnpm preview # Preview the static site
|
|
|
74
75
|
pnpm build:pages # Add Cloudflare Pages headers and a 404 page
|
|
75
76
|
```
|
|
76
77
|
|
|
77
|
-
Changes pushed to `main` update the documentation site. A stable version tag such as `v0.
|
|
78
|
+
Changes pushed to `main` update the documentation site. A stable version tag such as `v0.6.0` runs checks, publishes the package to npm, then creates a GitHub Release with the same package file.
|
|
78
79
|
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
80
|
|
|
80
81
|
## Make it yours
|
package/dist/cli.js
CHANGED
|
@@ -9,7 +9,7 @@ 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
11
|
import { debounce } from "es-toolkit";
|
|
12
|
-
import { Hono as
|
|
12
|
+
import { Hono as Hono4 } from "hono";
|
|
13
13
|
import { toSSG } from "hono/ssg";
|
|
14
14
|
|
|
15
15
|
// src/constant/asset.ts
|
|
@@ -51,6 +51,7 @@ var copy_error_prefix = "for-humanity";
|
|
|
51
51
|
var copy_error_font_preload = "Missing bundled font for preload";
|
|
52
52
|
var copy_error_unknown_command = "\uBAA8\uB974\uB294 \uBA85\uB839\uC774\uB2E4";
|
|
53
53
|
var copy_error_config = "\uC124\uC815\uC774 \uD2C0\uB838\uB2E4";
|
|
54
|
+
var copy_error_site_url = "url\uC740 \uC0AC\uC6A9\uC790 \uC815\uBCF4\xB7\uD558\uC704 \uACBD\uB85C\xB7query\xB7hash\uAC00 \uC5C6\uB294 HTTP(S) \uC0AC\uC774\uD2B8 \uB8E8\uD2B8\uC5EC\uC57C \uD55C\uB2E4";
|
|
54
55
|
var copy_error_frontmatter = "frontmatter\uAC00 \uC62C\uBC14\uB974\uC9C0 \uC54A\uB2E4";
|
|
55
56
|
var copy_error_navigation_clash = "navigation\uC758 \uBB36\uC74C\uC774 \uACB9\uCE5C\uB2E4";
|
|
56
57
|
var copy_error_doc_id = "\uBB38\uC11C id\uC5D0 \uC0AC\uC6A9\uD560 \uC218 \uC5C6\uB294 \uACBD\uB85C";
|
|
@@ -85,6 +86,11 @@ var cli_config_file_name = "for-humanity.config.mjs";
|
|
|
85
86
|
var cli_dev_port = 4321;
|
|
86
87
|
var cli_reload_delay_ms = 150;
|
|
87
88
|
|
|
89
|
+
// src/entry/cli/_constant/crawling.ts
|
|
90
|
+
var crawling_robots_path = "/robots.txt";
|
|
91
|
+
var crawling_sitemap_path = "/sitemap.xml";
|
|
92
|
+
var crawling_reserved_roots = [crawling_robots_path.slice(1), crawling_sitemap_path.slice(1)];
|
|
93
|
+
|
|
88
94
|
// src/entry/cli/_function/create-app.tsx
|
|
89
95
|
import { Hono } from "hono";
|
|
90
96
|
import { html } from "hono/html";
|
|
@@ -1227,6 +1233,9 @@ var createDevApp = (options) => {
|
|
|
1227
1233
|
);
|
|
1228
1234
|
app.get(`/${asset_dir}/*`, handleAsset);
|
|
1229
1235
|
app.get(asset_favicon_path, handleAsset);
|
|
1236
|
+
if (options.crawling !== void 0) {
|
|
1237
|
+
app.on("GET", [crawling_robots_path, crawling_sitemap_path], (c) => options.crawling?.fetch(c.req.raw, c.env) ?? c.notFound());
|
|
1238
|
+
}
|
|
1230
1239
|
app.all("/*", (c) => options.pages.fetch(c.req.raw, c.env));
|
|
1231
1240
|
return app;
|
|
1232
1241
|
};
|
|
@@ -1995,7 +2004,7 @@ var readDocs = async (options) => {
|
|
|
1995
2004
|
paths.map(async (path) => {
|
|
1996
2005
|
const id = path.replace(/\.mdx?$/i, "").split(sep2).join("/").toLowerCase();
|
|
1997
2006
|
const segments = id.split("/");
|
|
1998
|
-
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)) {
|
|
2007
|
+
if (segments.some((segment) => segment === "" || segment === "." || segment === ".." || segment === "index.html") || /[#?%*:\\]/.test(id) || /[\p{Cc}]/u.test(id) || options.reserved?.includes(segments[0]) === true || segments[0] === asset_dir || segments[0] === asset_favicon_path.slice(1)) {
|
|
1999
2008
|
throw new Error(`${copy_error_doc_id}: ${path}`);
|
|
2000
2009
|
}
|
|
2001
2010
|
const source = await readFile2(join(options.root, path), "utf8");
|
|
@@ -2067,6 +2076,30 @@ var readHome = async (options) => {
|
|
|
2067
2076
|
return { html: String(file), outline: { headings: fh.headings, sections: fh.sections } };
|
|
2068
2077
|
};
|
|
2069
2078
|
|
|
2079
|
+
// src/entry/cli/_function/to-crawling-app.ts
|
|
2080
|
+
import { Hono as Hono3 } from "hono";
|
|
2081
|
+
import { html as html2 } from "hono/html";
|
|
2082
|
+
var toCrawlingApp = (params) => {
|
|
2083
|
+
const app = new Hono3();
|
|
2084
|
+
app.get(crawling_robots_path, (c) => c.text(`User-agent: *
|
|
2085
|
+
Allow: /
|
|
2086
|
+
|
|
2087
|
+
Sitemap: ${params.url}${crawling_sitemap_path}
|
|
2088
|
+
`));
|
|
2089
|
+
app.get(
|
|
2090
|
+
crawling_sitemap_path,
|
|
2091
|
+
(_c) => new Response(
|
|
2092
|
+
html2`<?xml version="1.0" encoding="UTF-8"?>
|
|
2093
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
2094
|
+
<url><loc>${params.url}/</loc></url>
|
|
2095
|
+
${params.store.docs.map((doc) => html2`<url><loc>${params.url}/${doc.id.split("/").map(encodeURIComponent).join("/")}/</loc></url>`)}
|
|
2096
|
+
</urlset>`.toString(),
|
|
2097
|
+
{ headers: { "Content-Type": "application/xml; charset=utf-8" } }
|
|
2098
|
+
)
|
|
2099
|
+
);
|
|
2100
|
+
return app;
|
|
2101
|
+
};
|
|
2102
|
+
|
|
2070
2103
|
// src/type/site-config.ts
|
|
2071
2104
|
import { z as z3 } from "zod";
|
|
2072
2105
|
|
|
@@ -2090,6 +2123,10 @@ var siteConfigSchema = z3.object({
|
|
|
2090
2123
|
* HTML 설명 메타데이터 · 선택
|
|
2091
2124
|
*/
|
|
2092
2125
|
description: z3.string().optional(),
|
|
2126
|
+
/**
|
|
2127
|
+
* 공개 사이트 루트 URL · 지정할 때만 robots.txt와 sitemap.xml 생성
|
|
2128
|
+
*/
|
|
2129
|
+
url: z3.url({ protocol: /^https?$/ }).transform((value) => new URL(value)).refine((value) => value.username === "" && value.password === "" && value.pathname === "/" && value.search === "" && value.hash === "", copy_error_site_url).transform((value) => value.origin).optional(),
|
|
2093
2130
|
/**
|
|
2094
2131
|
* 탐색 묶음 경로의 읽는 순서 · 미지정 형제 묶음은 뒤에서 이름순
|
|
2095
2132
|
*/
|
|
@@ -2179,7 +2216,7 @@ var main = async () => {
|
|
|
2179
2216
|
const docsRoot = resolve3(docsDir);
|
|
2180
2217
|
const outDir = join4(docsRoot, "dist");
|
|
2181
2218
|
if (command === "preview") {
|
|
2182
|
-
const app2 = new
|
|
2219
|
+
const app2 = new Hono4();
|
|
2183
2220
|
app2.use(
|
|
2184
2221
|
"/*",
|
|
2185
2222
|
serveStatic({
|
|
@@ -2219,8 +2256,12 @@ var main = async () => {
|
|
|
2219
2256
|
]);
|
|
2220
2257
|
const files = new Map(kitFiles);
|
|
2221
2258
|
const processor = createProcessor({ site: siteConfig, root: docsRoot, files });
|
|
2222
|
-
const [docs, home] = await Promise.all([
|
|
2259
|
+
const [docs, home] = await Promise.all([
|
|
2260
|
+
readDocs({ root: docsRoot, processor, reserved: siteConfig.url === void 0 ? void 0 : crawling_reserved_roots }),
|
|
2261
|
+
readHome({ root: docsRoot, processor })
|
|
2262
|
+
]);
|
|
2223
2263
|
const store = { docs, home };
|
|
2264
|
+
const crawling = siteConfig.url === void 0 ? void 0 : toCrawlingApp({ url: siteConfig.url, store });
|
|
2224
2265
|
const app = createApp({
|
|
2225
2266
|
site: siteConfig,
|
|
2226
2267
|
store,
|
|
@@ -2232,23 +2273,32 @@ var main = async () => {
|
|
|
2232
2273
|
if (!result.success) {
|
|
2233
2274
|
throw result.error;
|
|
2234
2275
|
}
|
|
2276
|
+
const crawlerResult = crawling === void 0 ? void 0 : await toSSG(crawling, { writeFile, mkdir }, { dir: outDir });
|
|
2277
|
+
if (crawlerResult?.success === false) {
|
|
2278
|
+
throw crawlerResult.error;
|
|
2279
|
+
}
|
|
2235
2280
|
for (const [url, file] of files) {
|
|
2236
2281
|
await mkdir(dirname4(join4(outDir, url)), { recursive: true });
|
|
2237
2282
|
await copyFile(file, join4(outDir, url));
|
|
2238
2283
|
}
|
|
2239
|
-
console.log(
|
|
2284
|
+
console.log(
|
|
2285
|
+
`${crawlerResult === void 0 ? result.files.length : result.files.length + crawlerResult.files.length} files, ${files.size} assets \u2192 ${relative4(process.cwd(), outDir)}`
|
|
2286
|
+
);
|
|
2240
2287
|
return;
|
|
2241
2288
|
}
|
|
2242
2289
|
const reload = new EventEmitter();
|
|
2243
2290
|
let generation = 0;
|
|
2244
|
-
serve({ fetch: createDevApp({ pages: app, files, reload }).fetch, port: cli_dev_port }, (info) => console.log(`http://localhost:${info.port}/`));
|
|
2291
|
+
serve({ fetch: createDevApp({ pages: app, crawling, files, reload }).fetch, port: cli_dev_port }, (info) => console.log(`http://localhost:${info.port}/`));
|
|
2245
2292
|
const reloadDocs = debounce(
|
|
2246
2293
|
toChangeQueue({
|
|
2247
2294
|
run: async () => {
|
|
2248
2295
|
const currentGeneration = generation;
|
|
2249
2296
|
const nextFiles = new Map(kitFiles);
|
|
2250
2297
|
const nextProcessor = createProcessor({ site: siteConfig, root: docsRoot, files: nextFiles });
|
|
2251
|
-
const [docs2, home2] = await Promise.all([
|
|
2298
|
+
const [docs2, home2] = await Promise.all([
|
|
2299
|
+
readDocs({ root: docsRoot, processor: nextProcessor, reserved: siteConfig.url === void 0 ? void 0 : crawling_reserved_roots }),
|
|
2300
|
+
readHome({ root: docsRoot, processor: nextProcessor })
|
|
2301
|
+
]);
|
|
2252
2302
|
if (currentGeneration !== generation) {
|
|
2253
2303
|
return;
|
|
2254
2304
|
}
|
package/package.json
CHANGED
package/src/constant/copy.ts
CHANGED
|
@@ -62,6 +62,11 @@ export const copy_error_unknown_command = "모르는 명령이다";
|
|
|
62
62
|
*/
|
|
63
63
|
export const copy_error_config = "설정이 틀렸다";
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* 공개 사이트 URL의 미지원 경로·자격 증명·검색·앵커 오류
|
|
67
|
+
*/
|
|
68
|
+
export const copy_error_site_url = "url은 사용자 정보·하위 경로·query·hash가 없는 HTTP(S) 사이트 루트여야 한다";
|
|
69
|
+
|
|
65
70
|
/**
|
|
66
71
|
* 문서 frontmatter 오류 · 파일과 항목 추가
|
|
67
72
|
*/
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 크롤러 안내 파일의 사이트 루트 경로
|
|
3
|
+
*/
|
|
4
|
+
export const crawling_robots_path = "/robots.txt";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 공개 페이지 목록의 사이트 루트 경로
|
|
8
|
+
*/
|
|
9
|
+
export const crawling_sitemap_path = "/sitemap.xml";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* 공개 크롤러 파일과 정적 출력이 충돌하는 문서의 첫 경로 조각
|
|
13
|
+
*/
|
|
14
|
+
export const crawling_reserved_roots = [crawling_robots_path.slice(1), crawling_sitemap_path.slice(1)];
|
|
@@ -5,6 +5,7 @@ import {type Context, Hono} from "hono";
|
|
|
5
5
|
import {streamSSE} from "hono/streaming";
|
|
6
6
|
import {asset_content_type_default, asset_content_types, asset_dir, asset_favicon_path, asset_reload_path} from "@/constant/asset";
|
|
7
7
|
import {font_cache_control} from "@/constant/font";
|
|
8
|
+
import {crawling_robots_path, crawling_sitemap_path} from "@/entry/cli/_constant/crawling";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* 개발 서버 입력 계약
|
|
@@ -14,6 +15,10 @@ export interface DevOptions {
|
|
|
14
15
|
* 정적 자원 외 요청을 처리할 페이지 앱
|
|
15
16
|
*/
|
|
16
17
|
pages: Hono;
|
|
18
|
+
/**
|
|
19
|
+
* 공개 URL을 설정한 프로젝트의 크롤러 파일 앱
|
|
20
|
+
*/
|
|
21
|
+
crawling?: Hono;
|
|
17
22
|
/**
|
|
18
23
|
* 자원 URL → 파일 경로 · 정적 빌드와 동일 목록
|
|
19
24
|
*/
|
|
@@ -25,7 +30,7 @@ export interface DevOptions {
|
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
/**
|
|
28
|
-
* 개발 전용 자원·SSE
|
|
33
|
+
* 개발 전용 자원·SSE와 선택적 크롤러 응답 · 나머지 요청은 페이지 앱에 위임
|
|
29
34
|
* 정적 빌드의 페이지 라우트와 분리
|
|
30
35
|
*/
|
|
31
36
|
export const createDevApp = (options: DevOptions) => {
|
|
@@ -73,6 +78,9 @@ export const createDevApp = (options: DevOptions) => {
|
|
|
73
78
|
);
|
|
74
79
|
app.get(`/${asset_dir}/*`, handleAsset);
|
|
75
80
|
app.get(asset_favicon_path, handleAsset);
|
|
81
|
+
if (options.crawling !== undefined) {
|
|
82
|
+
app.on("GET", [crawling_robots_path, crawling_sitemap_path], (c) => options.crawling?.fetch(c.req.raw, c.env) ?? c.notFound());
|
|
83
|
+
}
|
|
76
84
|
app.all("/*", (c) => options.pages.fetch(c.req.raw, c.env));
|
|
77
85
|
|
|
78
86
|
return app;
|
|
@@ -16,12 +16,30 @@ import type {Doc} from "@/type/doc";
|
|
|
16
16
|
import {docDataSchema} from "@/type/doc-data";
|
|
17
17
|
import type {DocFileData} from "@/type/doc-file-data";
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* 문서 폴더를 읽고 명령별 출력 경로와 함께 검증하는 입력
|
|
21
|
+
*/
|
|
22
|
+
export interface ReadDocsParams {
|
|
23
|
+
/**
|
|
24
|
+
* CLI에 넘긴 문서 폴더의 절대 경로
|
|
25
|
+
*/
|
|
26
|
+
root: string;
|
|
27
|
+
/**
|
|
28
|
+
* 설정·자원 목록을 연결한 Markdown 처리기
|
|
29
|
+
*/
|
|
30
|
+
processor: Processor;
|
|
31
|
+
/**
|
|
32
|
+
* 명령이 생성할 파일과 충돌하는 첫 경로 조각 · 생략 시 추가 예약 없음
|
|
33
|
+
*/
|
|
34
|
+
reserved?: readonly string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
19
37
|
/**
|
|
20
38
|
* Markdown 수집과 렌더링 · 루트 README는 홈, AGENTS·CLAUDE는 작성 지침으로 분리
|
|
21
39
|
* 문서 목록에서 루트 안내 파일·node_modules·dist 제외
|
|
22
40
|
* 머리말·문서 식별자 중복·플러그인 오류 시 실패
|
|
23
41
|
*/
|
|
24
|
-
export const readDocs = async (options:
|
|
42
|
+
export const readDocs = async (options: ReadDocsParams): Promise<Doc[]> => {
|
|
25
43
|
const paths = (await readdir(options.root, {recursive: true})).filter(
|
|
26
44
|
(path) => /\.mdx?$/i.test(path) && !/^(readme|agents|claude)\.md$/i.test(path) && !path.split(sep).includes("node_modules") && !path.startsWith(`dist${sep}`),
|
|
27
45
|
);
|
|
@@ -39,6 +57,7 @@ export const readDocs = async (options: {root: string; processor: Processor}): P
|
|
|
39
57
|
segments.some((segment) => segment === "" || segment === "." || segment === ".." || segment === "index.html") ||
|
|
40
58
|
/[#?%*:\\]/.test(id) ||
|
|
41
59
|
/[\p{Cc}]/u.test(id) ||
|
|
60
|
+
options.reserved?.includes(segments[0]) === true ||
|
|
42
61
|
segments[0] === asset_dir ||
|
|
43
62
|
segments[0] === asset_favicon_path.slice(1)
|
|
44
63
|
) {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import {Hono} from "hono";
|
|
2
|
+
import {html} from "hono/html";
|
|
3
|
+
import {crawling_robots_path, crawling_sitemap_path} from "@/entry/cli/_constant/crawling";
|
|
4
|
+
import type {AppOptions} from "@/entry/cli/_function/create-app";
|
|
5
|
+
import type {SiteConfig} from "@/type/site-config";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* URL을 명시한 공개 프로젝트의 크롤러 응답 입력
|
|
9
|
+
*/
|
|
10
|
+
export interface CrawlingParams {
|
|
11
|
+
/**
|
|
12
|
+
* 설정 검증에서 루트 origin으로 정규화한 공개 HTTP(S) URL
|
|
13
|
+
*/
|
|
14
|
+
url: NonNullable<SiteConfig["url"]>;
|
|
15
|
+
/**
|
|
16
|
+
* dev 파일 변경 시 교체되는 현재 문서 목록 · 본문은 사이트맵에 포함하지 않음
|
|
17
|
+
*/
|
|
18
|
+
store: AppOptions["store"];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* dev와 정적 빌드에서 같은 크롤러 파일 생성 · 정책·수정 시각은 추정하지 않음
|
|
23
|
+
*/
|
|
24
|
+
export const toCrawlingApp = (params: CrawlingParams) => {
|
|
25
|
+
const app = new Hono();
|
|
26
|
+
|
|
27
|
+
app.get(crawling_robots_path, (c) => c.text(`User-agent: *\nAllow: /\n\nSitemap: ${params.url}${crawling_sitemap_path}\n`));
|
|
28
|
+
app.get(
|
|
29
|
+
crawling_sitemap_path,
|
|
30
|
+
(_c) =>
|
|
31
|
+
new Response(
|
|
32
|
+
html`<?xml version="1.0" encoding="UTF-8"?>
|
|
33
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
34
|
+
<url><loc>${params.url}/</loc></url>
|
|
35
|
+
${params.store.docs.map((doc) => html`<url><loc>${params.url}/${doc.id.split("/").map(encodeURIComponent).join("/")}/</loc></url>`)}
|
|
36
|
+
</urlset>`.toString(),
|
|
37
|
+
{headers: {"Content-Type": "application/xml; charset=utf-8"}},
|
|
38
|
+
),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
return app;
|
|
42
|
+
};
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import {
|
|
2
|
+
import {EventEmitter} from "node:events";
|
|
3
|
+
import {mkdir, mkdtemp, readFile, rm, symlink, writeFile} from "node:fs/promises";
|
|
3
4
|
import {tmpdir} from "node:os";
|
|
4
5
|
import {join} from "node:path";
|
|
5
6
|
import {test} from "node:test";
|
|
7
|
+
import {toSSG} from "hono/ssg";
|
|
6
8
|
import {navigationDataSchema} from "@/component/widget/navigation/_constant/navigation-data-schema";
|
|
7
9
|
import {toDocGroups} from "@/component/widget/navigation/_function/to-doc-groups/to-doc-groups";
|
|
8
10
|
import {toNavigationData} from "@/component/widget/navigation/_function/to-navigation-data/to-navigation-data";
|
|
@@ -10,9 +12,11 @@ import {toNavigationJson} from "@/component/widget/navigation/_function/to-navig
|
|
|
10
12
|
import {WgNavigation} from "@/component/widget/navigation/wg-navigation";
|
|
11
13
|
import {asset_style_path} from "@/constant/asset";
|
|
12
14
|
import {createApp} from "@/entry/cli/_function/create-app";
|
|
15
|
+
import {createDevApp} from "@/entry/cli/_function/create-dev-app";
|
|
13
16
|
import {createProcessor} from "@/entry/cli/_function/create-processor/create-processor";
|
|
14
17
|
import {readDocs} from "@/entry/cli/_function/read-docs";
|
|
15
18
|
import {readHome} from "@/entry/cli/_function/read-home";
|
|
19
|
+
import {toCrawlingApp} from "@/entry/cli/_function/to-crawling-app";
|
|
16
20
|
import {docDataSchema} from "@/type/doc-data";
|
|
17
21
|
import {siteConfigSchema} from "@/type/site-config";
|
|
18
22
|
|
|
@@ -54,6 +58,107 @@ test("page resource URLs use the supplied content fingerprints instead of fixed
|
|
|
54
58
|
assert.doesNotMatch(html, /(?:href|src)="\/_fh\/(?:style\.css|client\.js)"/);
|
|
55
59
|
});
|
|
56
60
|
|
|
61
|
+
test("public site URL is opt-in and normalizes an HTTP(S) root URL", () => {
|
|
62
|
+
assert.deepEqual(siteConfigSchema.parse({url: "https://docs.example.org/"}), {...siteConfigSchema.parse({}), url: "https://docs.example.org"});
|
|
63
|
+
assert.deepEqual(siteConfigSchema.parse({url: "http://localhost:4321"}), {...siteConfigSchema.parse({}), url: "http://localhost:4321"});
|
|
64
|
+
assert.equal("url" in siteConfigSchema.parse({}), false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("public site URL rejects unsupported protocols, credentials and non-root URLs", () => {
|
|
68
|
+
for (const url of [
|
|
69
|
+
"",
|
|
70
|
+
"/",
|
|
71
|
+
"file:///docs",
|
|
72
|
+
"ftp://docs.example.org",
|
|
73
|
+
"https://user:password@docs.example.org",
|
|
74
|
+
"https://docs.example.org/docs/",
|
|
75
|
+
"https://docs.example.org/?q=guide",
|
|
76
|
+
"https://docs.example.org/#guide",
|
|
77
|
+
]) {
|
|
78
|
+
assert.equal(siteConfigSchema.safeParse({url}).success, false, url);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("crawler files are absent when the public site URL is omitted", async () => {
|
|
83
|
+
const site = siteConfigSchema.parse({});
|
|
84
|
+
const assets = {fontCss: "", preload: [], reload: false, browserScript: "", stylePath: asset_style_path};
|
|
85
|
+
const app = createDevApp({pages: createApp({site, store: {docs: []}, assets}), files: new Map(), reload: new EventEmitter()});
|
|
86
|
+
|
|
87
|
+
assert.equal((await app.request("/robots.txt")).status, 404);
|
|
88
|
+
assert.equal((await app.request("/sitemap.xml")).status, 404);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("crawler files list home and encoded nested document URLs with the current document store", async (t) => {
|
|
92
|
+
const root = await mkdtemp(join(tmpdir(), "for-humanity-crawling-"));
|
|
93
|
+
t.after(() => rm(root, {recursive: true, force: true}));
|
|
94
|
+
await mkdir(join(root, "guide"));
|
|
95
|
+
await writeFile(join(root, "guide.md"), "---\nname: Start\nlabel: Start\ngroup: Guide\n---\n\nStart here.\n");
|
|
96
|
+
await writeFile(join(root, "guide/한국어 & tools.md"), "---\nname: Guide\nlabel: Guide\ngroup: Guide\n---\n\n## Read\n\nText.\n");
|
|
97
|
+
const site = siteConfigSchema.parse({url: "https://docs.example.org/"});
|
|
98
|
+
const docs = await readDocs({root, processor: createProcessor({site, root})});
|
|
99
|
+
const store = {docs};
|
|
100
|
+
const assets = {fontCss: "", preload: [], reload: false, browserScript: "", stylePath: asset_style_path};
|
|
101
|
+
assert.ok(site.url);
|
|
102
|
+
const app = createDevApp({pages: createApp({site, store, assets}), crawling: toCrawlingApp({url: site.url, store}), files: new Map(), reload: new EventEmitter()});
|
|
103
|
+
const robots = await app.request("/robots.txt");
|
|
104
|
+
const sitemap = await app.request("/sitemap.xml");
|
|
105
|
+
|
|
106
|
+
assert.equal((await app.request("/guide/")).status, 200, "crawler routes must preserve ordinary document routes");
|
|
107
|
+
assert.equal((await app.request(`/guide/${encodeURIComponent("한국어 & tools")}/`)).status, 200, "crawler routes must preserve nested document routes");
|
|
108
|
+
assert.equal(robots.status, 200);
|
|
109
|
+
assert.match(String(robots.headers.get("content-type")), /^text\/plain/);
|
|
110
|
+
assert.equal(await robots.text(), "User-agent: *\nAllow: /\n\nSitemap: https://docs.example.org/sitemap.xml\n");
|
|
111
|
+
assert.equal(sitemap.status, 200);
|
|
112
|
+
assert.match(String(sitemap.headers.get("content-type")), /^application\/xml/);
|
|
113
|
+
const xml = await sitemap.text();
|
|
114
|
+
assert.match(xml, /^<\?xml version="1\.0" encoding="UTF-8"\?>/);
|
|
115
|
+
assert.match(xml, /<urlset xmlns="http:\/\/www\.sitemaps\.org\/schemas\/sitemap\/0\.9">/);
|
|
116
|
+
assert.match(xml, /<loc>https:\/\/docs\.example\.org\/<\/loc>/);
|
|
117
|
+
assert.ok(xml.includes(`<loc>https://docs.example.org/guide/${encodeURIComponent("한국어 & tools")}/</loc>`));
|
|
118
|
+
assert.equal([...xml.matchAll(/<loc>/g)].length, 3);
|
|
119
|
+
assert.doesNotMatch(xml, /<lastmod>|<changefreq>|<priority>|robots\.txt|sitemap\.xml|_fh|#read/);
|
|
120
|
+
|
|
121
|
+
store.docs = [];
|
|
122
|
+
assert.equal([...(await (await app.request("/sitemap.xml")).text()).matchAll(/<loc>/g)].length, 1);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("SSG writes crawler files and preserves ordinary and nested document pages", async (t) => {
|
|
126
|
+
const root = await mkdtemp(join(tmpdir(), "for-humanity-crawling-build-"));
|
|
127
|
+
t.after(() => rm(root, {recursive: true, force: true}));
|
|
128
|
+
await mkdir(join(root, "guide"));
|
|
129
|
+
await writeFile(join(root, "guide.md"), "---\nname: Start\nlabel: Start\ngroup: Guide\n---\n\nStart here.\n");
|
|
130
|
+
await writeFile(join(root, "guide/details.md"), "---\nname: Details\nlabel: Details\ngroup: Guide\n---\n\nNested details.\n");
|
|
131
|
+
const site = siteConfigSchema.parse({url: "https://docs.example.org"});
|
|
132
|
+
const docs = await readDocs({root, processor: createProcessor({site, root})});
|
|
133
|
+
const assets = {fontCss: "", preload: [], reload: false, browserScript: "", stylePath: asset_style_path};
|
|
134
|
+
const app = createApp({site, store: {docs}, assets});
|
|
135
|
+
const result = await toSSG(app, {writeFile, mkdir}, {dir: root});
|
|
136
|
+
assert.ok(site.url);
|
|
137
|
+
const crawlerResult = await toSSG(toCrawlingApp({url: site.url, store: {docs}}), {writeFile, mkdir}, {dir: root});
|
|
138
|
+
|
|
139
|
+
assert.equal(result.success, true);
|
|
140
|
+
assert.equal(crawlerResult.success, true);
|
|
141
|
+
assert.equal(result.files.length + crawlerResult.files.length, 5, "home, both documents and both crawler files must be emitted");
|
|
142
|
+
assert.match(await readFile(join(root, "guide/index.html"), "utf8"), /Start here\./);
|
|
143
|
+
assert.match(await readFile(join(root, "guide/details/index.html"), "utf8"), /Nested details\./);
|
|
144
|
+
assert.match(await readFile(join(root, "robots.txt"), "utf8"), /Sitemap: https:\/\/docs\.example\.org\/sitemap\.xml/);
|
|
145
|
+
assert.match(await readFile(join(root, "sitemap.xml"), "utf8"), /<loc>https:\/\/docs\.example\.org\/<\/loc>/);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("crawler filenames reject conflicting document roots only when reserved", async (t) => {
|
|
149
|
+
const root = await mkdtemp(join(tmpdir(), "for-humanity-crawling-paths-"));
|
|
150
|
+
t.after(() => rm(root, {recursive: true, force: true}));
|
|
151
|
+
await mkdir(join(root, "sitemap.xml"));
|
|
152
|
+
await writeFile(join(root, "Robots.txt.md"), "---\nname: Robots\nlabel: Robots\ngroup: Guide\n---\n\nText.\n");
|
|
153
|
+
await writeFile(join(root, "sitemap.xml/guide.md"), "---\nname: Sitemap\nlabel: Sitemap\ngroup: Guide\n---\n\nText.\n");
|
|
154
|
+
const site = siteConfigSchema.parse({});
|
|
155
|
+
const processor = createProcessor({site, root});
|
|
156
|
+
const params = {root, processor, reserved: ["robots.txt", "sitemap.xml"]};
|
|
157
|
+
|
|
158
|
+
await assert.rejects(readDocs(params), /문서 id에 사용할 수 없는 경로/);
|
|
159
|
+
assert.equal((await readDocs({root, processor})).length, 2);
|
|
160
|
+
});
|
|
161
|
+
|
|
57
162
|
test("navigation initialization runs after its DOM and before the document body", async () => {
|
|
58
163
|
const site = siteConfigSchema.parse({});
|
|
59
164
|
const browserScript = "window.navigationFixture = true;";
|
package/src/entry/cli/cli.ts
CHANGED
|
@@ -19,11 +19,13 @@ import {copy_error_config, copy_error_font_preload, copy_error_prefix, copy_erro
|
|
|
19
19
|
import {font_brand_css, font_cache_control, font_mono_css, font_sans_css, font_sans_preload_file} from "@/constant/font";
|
|
20
20
|
import {site_config_absent} from "@/constant/site";
|
|
21
21
|
import {cli_config_file_name, cli_default_command, cli_default_docs_dir, cli_dev_port, cli_reload_delay_ms} from "@/entry/cli/_constant/cli";
|
|
22
|
+
import {crawling_reserved_roots} from "@/entry/cli/_constant/crawling";
|
|
22
23
|
import {createApp} from "@/entry/cli/_function/create-app";
|
|
23
24
|
import {createDevApp} from "@/entry/cli/_function/create-dev-app";
|
|
24
25
|
import {createProcessor} from "@/entry/cli/_function/create-processor/create-processor";
|
|
25
26
|
import {readDocs} from "@/entry/cli/_function/read-docs";
|
|
26
27
|
import {readHome} from "@/entry/cli/_function/read-home";
|
|
28
|
+
import {toCrawlingApp} from "@/entry/cli/_function/to-crawling-app";
|
|
27
29
|
import {siteConfigSchema} from "@/type/site-config";
|
|
28
30
|
import {toChangeQueue} from "@/util/async/to-change-queue";
|
|
29
31
|
import {toErrorMessage} from "@/util/error/to-error-message";
|
|
@@ -96,8 +98,12 @@ const main = async () => {
|
|
|
96
98
|
]);
|
|
97
99
|
const files = new Map(kitFiles);
|
|
98
100
|
const processor = createProcessor({site: siteConfig, root: docsRoot, files});
|
|
99
|
-
const [docs, home] = await Promise.all([
|
|
101
|
+
const [docs, home] = await Promise.all([
|
|
102
|
+
readDocs({root: docsRoot, processor, reserved: siteConfig.url === undefined ? undefined : crawling_reserved_roots}),
|
|
103
|
+
readHome({root: docsRoot, processor}),
|
|
104
|
+
]);
|
|
100
105
|
const store = {docs, home};
|
|
106
|
+
const crawling = siteConfig.url === undefined ? undefined : toCrawlingApp({url: siteConfig.url, store});
|
|
101
107
|
const app = createApp({
|
|
102
108
|
site: siteConfig,
|
|
103
109
|
store,
|
|
@@ -114,12 +120,20 @@ const main = async () => {
|
|
|
114
120
|
throw result.error;
|
|
115
121
|
}
|
|
116
122
|
|
|
123
|
+
const crawlerResult = crawling === undefined ? undefined : await toSSG(crawling, {writeFile, mkdir}, {dir: outDir});
|
|
124
|
+
|
|
125
|
+
if (crawlerResult?.success === false) {
|
|
126
|
+
throw crawlerResult.error;
|
|
127
|
+
}
|
|
128
|
+
|
|
117
129
|
for (const [url, file] of files) {
|
|
118
130
|
await mkdir(dirname(join(outDir, url)), {recursive: true});
|
|
119
131
|
await copyFile(file, join(outDir, url));
|
|
120
132
|
}
|
|
121
133
|
|
|
122
|
-
console.log(
|
|
134
|
+
console.log(
|
|
135
|
+
`${crawlerResult === undefined ? result.files.length : result.files.length + crawlerResult.files.length} files, ${files.size} assets → ${relative(process.cwd(), outDir)}`,
|
|
136
|
+
);
|
|
123
137
|
|
|
124
138
|
return;
|
|
125
139
|
}
|
|
@@ -127,7 +141,7 @@ const main = async () => {
|
|
|
127
141
|
const reload = new EventEmitter();
|
|
128
142
|
let generation = 0;
|
|
129
143
|
|
|
130
|
-
serve({fetch: createDevApp({pages: app, files, reload}).fetch, port: cli_dev_port}, (info) => console.log(`http://localhost:${info.port}/`));
|
|
144
|
+
serve({fetch: createDevApp({pages: app, crawling, files, reload}).fetch, port: cli_dev_port}, (info) => console.log(`http://localhost:${info.port}/`));
|
|
131
145
|
|
|
132
146
|
/**
|
|
133
147
|
* 갱신은 하나씩 실행 · 연속 저장 합치기 · 최신 변경만 적용
|
|
@@ -139,7 +153,10 @@ const main = async () => {
|
|
|
139
153
|
const currentGeneration = generation;
|
|
140
154
|
const nextFiles = new Map(kitFiles);
|
|
141
155
|
const nextProcessor = createProcessor({site: siteConfig, root: docsRoot, files: nextFiles});
|
|
142
|
-
const [docs, home] = await Promise.all([
|
|
156
|
+
const [docs, home] = await Promise.all([
|
|
157
|
+
readDocs({root: docsRoot, processor: nextProcessor, reserved: siteConfig.url === undefined ? undefined : crawling_reserved_roots}),
|
|
158
|
+
readHome({root: docsRoot, processor: nextProcessor}),
|
|
159
|
+
]);
|
|
143
160
|
|
|
144
161
|
if (currentGeneration !== generation) {
|
|
145
162
|
return;
|
package/src/type/site-config.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {z} from "zod";
|
|
2
|
-
import {copy_error_navigation_clash, copy_site_title_default} from "@/constant/copy";
|
|
2
|
+
import {copy_error_navigation_clash, copy_error_site_url, copy_site_title_default} from "@/constant/copy";
|
|
3
3
|
import {site_navigation_default} from "@/constant/site";
|
|
4
4
|
import {status_default_phrases, status_kind} from "@/constant/status";
|
|
5
5
|
import {groupPathSchema} from "@/type/group-path";
|
|
@@ -17,6 +17,15 @@ export const siteConfigSchema = z.object({
|
|
|
17
17
|
* HTML 설명 메타데이터 · 선택
|
|
18
18
|
*/
|
|
19
19
|
description: z.string().optional(),
|
|
20
|
+
/**
|
|
21
|
+
* 공개 사이트 루트 URL · 지정할 때만 robots.txt와 sitemap.xml 생성
|
|
22
|
+
*/
|
|
23
|
+
url: z
|
|
24
|
+
.url({protocol: /^https?$/})
|
|
25
|
+
.transform((value) => new URL(value))
|
|
26
|
+
.refine((value) => value.username === "" && value.password === "" && value.pathname === "/" && value.search === "" && value.hash === "", copy_error_site_url)
|
|
27
|
+
.transform((value) => value.origin)
|
|
28
|
+
.optional(),
|
|
20
29
|
/**
|
|
21
30
|
* 탐색 묶음 경로의 읽는 순서 · 미지정 형제 묶음은 뒤에서 이름순
|
|
22
31
|
*/
|