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 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.5.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.5.0` supports `dev`, `build` and `preview`. `init` is planned; see the [Roadmap](https://github.com/minu-ha/for-humanity/blob/main/templates/document/roadmap.md) and [Changelog](https://github.com/minu-ha/for-humanity/blob/main/CHANGELOG.md).
55
+ Version `0.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.5.0` runs checks, publishes the package to npm, then creates a GitHub Release with the same package file.
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 Hono3 } from "hono";
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 Hono3();
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([readDocs({ root: docsRoot, processor }), readHome({ root: docsRoot, processor })]);
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(`${result.files.length} pages, ${files.size} assets \u2192 ${relative4(process.cwd(), outDir)}`);
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([readDocs({ root: docsRoot, processor: nextProcessor }), readHome({ root: docsRoot, processor: nextProcessor })]);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "for-humanity",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Documents written for humans to read: a docs kit with a sidebar, parts, sections and flowcharts, built on Hono JSX",
5
5
  "keywords": [
6
6
  "docs",
@@ -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: {root: string; processor: Processor}): Promise<Doc[]> => {
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 {mkdir, mkdtemp, rm, symlink, writeFile} from "node:fs/promises";
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;";
@@ -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([readDocs({root: docsRoot, processor}), readHome({root: docsRoot, processor})]);
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(`${result.files.length} pages, ${files.size} assets → ${relative(process.cwd(), outDir)}`);
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([readDocs({root: docsRoot, processor: nextProcessor}), readHome({root: docsRoot, processor: nextProcessor})]);
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;
@@ -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
  */