for-humanity 0.10.0 → 0.11.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.11.0 — 2026-10-13
6
+
7
+ - Add `<<< ./file.ext#region meta` code snippet imports: a file inside the document root becomes a code block, with optional `#region` extraction, dedent, fence meta passthrough and dev reloads when the file changes.
8
+ - Add `bad` and `good` fence meta that tint the whole code block, with line notation drawn one step darker on top.
9
+
5
10
  ## 0.10.0 — 2026-10-13
6
11
 
7
12
  - Add VitePress-style code notation through Shiki transformers: `// [!code highlight|error|warning|++|--]`, `// [!code word:name]`, meta `{2,4-6}` and a `line-numbers` meta that numbers lines with CSS counters.
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.10.0
36
+ pnpm add -D --save-exact for-humanity@0.11.0
37
37
  mkdir docs
38
38
  ```
39
39
 
@@ -52,7 +52,7 @@ pnpm exec for-humanity preview docs
52
52
 
53
53
  If your Markdown files are in the project root, use `.` instead of `docs`. The root `README.md` becomes the home page; root `AGENTS.md` and `CLAUDE.md` stay out of the site.
54
54
 
55
- Version `0.10.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.11.0` supports `dev`, `build` and `preview`. `init` is planned; see the [Roadmap](https://github.com/minu-ha/for-humanity/blob/main/templates/document/roadmap.md) and [Changelog](https://github.com/minu-ha/for-humanity/blob/main/CHANGELOG.md).
56
56
  Cloudflare Pages can host the generated site. See [Deployment](https://github.com/minu-ha/for-humanity/blob/main/templates/document/deployment.md) for the build settings.
57
57
  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).
58
58
 
@@ -75,7 +75,7 @@ pnpm preview # Preview the static site
75
75
  pnpm build:pages # Add Cloudflare Pages headers and a 404 page
76
76
  ```
77
77
 
78
- Changes pushed to `main` update the documentation site. A stable version tag such as `v0.10.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.11.0` runs checks, publishes the package to npm, then creates a GitHub Release with the same package file.
79
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.
80
80
 
81
81
  ## Make it yours
package/dist/cli.css CHANGED
@@ -189,6 +189,15 @@
189
189
  background: var(--app-color-bad-soft);
190
190
  opacity: 0.8;
191
191
  }
192
+ & pre.wg_prose__code--bad code .line.highlighted,
193
+ & pre.wg_prose__code--bad code .line.highlighted.error,
194
+ & pre.wg_prose__code--bad code .line.diff.remove {
195
+ background: var(--app-color-bad-mark);
196
+ }
197
+ & pre.wg_prose__code--good code .line.highlighted,
198
+ & pre.wg_prose__code--good code .line.diff.add {
199
+ background: var(--app-color-good-mark);
200
+ }
192
201
  & pre code .highlighted-word {
193
202
  padding: 0 2px;
194
203
  border-radius: var(--app-radius-inline);
@@ -779,9 +788,11 @@
779
788
  --app-color-accent: light-dark(#555, #b5b5b5);
780
789
  --app-color-accent-soft: light-dark(#f5f5f2, #222);
781
790
  --app-color-bad: light-dark(#b2412e, #ff9b8a);
782
- --app-color-bad-soft: light-dark(#fbe9e6, #42241f);
791
+ --app-color-bad-soft: light-dark(#f9ebe8, #3a2522);
792
+ --app-color-bad-mark: light-dark(#f1cdc6, #5b322c);
783
793
  --app-color-good: light-dark(#2f7a47, #8bd3a3);
784
- --app-color-good-soft: light-dark(#e3f3e8, #1f3a27);
794
+ --app-color-good-soft: light-dark(#e4f3ed, #1c342c);
795
+ --app-color-good-mark: light-dark(#c4e4d4, #27503f);
785
796
  --app-color-warn: light-dark(#8a6400, #e6c46a);
786
797
  --app-color-warn-soft: light-dark(#fbf3d9, #3d3418);
787
798
  --app-color-link: light-dark(#11a, #9db8ff);
@@ -875,6 +886,12 @@
875
886
  --app-space-gutter: 16px;
876
887
  }
877
888
  }
889
+ .wg_prose__code--bad {
890
+ --app-code-background: var(--app-color-bad-soft);
891
+ }
892
+ .wg_prose__code--good {
893
+ --app-code-background: var(--app-color-good-soft);
894
+ }
878
895
 
879
896
  /* src/component/widget/shell/wg-shell.css */
880
897
  .wg_shell__root {
package/dist/cli.js CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  // src/entry/cli/cli.ts
4
4
  import { EventEmitter } from "node:events";
5
- import { existsSync as existsSync3, readFileSync as readFileSync3, watch } from "node:fs";
5
+ import { existsSync as existsSync4, readFileSync as readFileSync4, watch } from "node:fs";
6
6
  import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
7
- import { dirname as dirname4, extname as extname4, join as join4, relative as relative4, resolve as resolve3, sep as sep3 } from "node:path";
7
+ import { dirname as dirname5, extname as extname5, join as join4, relative as relative5, resolve as resolve4, sep as sep4 } 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";
@@ -64,6 +64,7 @@ var copy_error_directive_syntax = "\uBD80\uD488 \uBB38\uBC95\uC774 \uC62C\uBC14\
64
64
  var copy_warn_broken_link = "\uC5C6\uB294 \uBB38\uC11C\uB85C \uAC74 \uB9C1\uD06C\uB2E4";
65
65
  var copy_warn_unknown_status = "\uC124\uC815\uC5D0 \uC5C6\uB294 \uC0C1\uD0DC \uBB38\uAD6C\uB2E4";
66
66
  var copy_error_asset = "\uC790\uC6D0\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uB2E4";
67
+ var copy_error_snippet = "\uCF54\uB4DC \uC2A4\uB2C8\uD3AB\uC774 \uC62C\uBC14\uB974\uC9C0 \uC54A\uB2E4";
67
68
 
68
69
  // src/constant/font.ts
69
70
  var font_mono_css = "src/asset/font/monoplex-kr/monoplex-kr.css";
@@ -1763,6 +1764,91 @@ var remarkReport = (options) => (_tree, file) => {
1763
1764
  }
1764
1765
  };
1765
1766
 
1767
+ // src/entry/cli/_function/create-processor/_remark-snippets.ts
1768
+ import { existsSync as existsSync3, readFileSync as readFileSync2, realpathSync as realpathSync2, statSync as statSync2 } from "node:fs";
1769
+ import { dirname as dirname3, extname as extname3, isAbsolute as isAbsolute2, relative as relative4, resolve as resolve3, sep as sep2 } from "node:path";
1770
+ import { visit as visit9 } from "unist-util-visit";
1771
+
1772
+ // src/entry/cli/_constant/snippet.ts
1773
+ var snippet_pattern = /^<<<\s+(\S+?)(?:#([\w-]+))?(?:\s+(.+?))?\s*$/;
1774
+ var snippet_region_pattern = /^\s*\S*\s*#(end)?region\s+([\w-]+)/;
1775
+ var snippet_language_by_extension = {
1776
+ ".cjs": "js",
1777
+ ".md": "markdown",
1778
+ ".mjs": "js",
1779
+ ".mts": "ts",
1780
+ ".txt": "text",
1781
+ ".yml": "yaml"
1782
+ };
1783
+
1784
+ // src/entry/cli/_function/create-processor/_remark-snippets.ts
1785
+ var toSnippetBody = (source, region) => {
1786
+ const lines = source.replace(/\r\n/g, "\n").split("\n");
1787
+ let inside = region === void 0;
1788
+ let found = region === void 0;
1789
+ const kept = [];
1790
+ for (const line of lines) {
1791
+ const marker = snippet_region_pattern.exec(line);
1792
+ if (marker !== null) {
1793
+ if (region !== void 0 && marker[2] === region) {
1794
+ inside = marker[1] === void 0;
1795
+ found = true;
1796
+ }
1797
+ continue;
1798
+ }
1799
+ if (inside) {
1800
+ kept.push(line);
1801
+ }
1802
+ }
1803
+ if (!found) {
1804
+ return void 0;
1805
+ }
1806
+ while (kept.length > 0 && kept[0]?.trim() === "") {
1807
+ kept.shift();
1808
+ }
1809
+ while (kept.length > 0 && kept.at(-1)?.trim() === "") {
1810
+ kept.pop();
1811
+ }
1812
+ const indents = kept.filter((line) => line.trim() !== "").map((line) => line.length - line.trimStart().length);
1813
+ const indent = indents.length === 0 ? 0 : Math.min(...indents);
1814
+ return kept.map((line) => line.slice(Math.min(indent, line.length - line.trimStart().length))).join("\n");
1815
+ };
1816
+ var remarkSnippets = (options) => (tree, file) => {
1817
+ visit9(tree, "paragraph", (node, index, parent) => {
1818
+ const [child] = node.children;
1819
+ if (node.children.length !== 1 || child === void 0 || child.type !== "text" || parent === void 0 || index === void 0) {
1820
+ return;
1821
+ }
1822
+ const match = snippet_pattern.exec(child.value.trim());
1823
+ if (match === null) {
1824
+ return;
1825
+ }
1826
+ const [, path = "", region, meta] = match;
1827
+ const target = resolve3(dirname3(file.path), path);
1828
+ const location2 = relative4(options.root, target);
1829
+ if (location2 === ".." || location2.startsWith(`..${sep2}`) || isAbsolute2(location2)) {
1830
+ file.fail(`${copy_error_snippet}: \uBB38\uC11C \uD3F4\uB354 \uBC16 \xB7 ${path}`, node);
1831
+ }
1832
+ if (!existsSync3(target)) {
1833
+ file.fail(`${copy_error_snippet}: \uD30C\uC77C\uC774 \uC5C6\uB2E4 \xB7 ${path}`, node);
1834
+ }
1835
+ const actual = realpathSync2(target);
1836
+ const actualLocation = relative4(realpathSync2(options.root), actual);
1837
+ if (actualLocation === ".." || actualLocation.startsWith(`..${sep2}`) || isAbsolute2(actualLocation) || !statSync2(actual).isFile()) {
1838
+ file.fail(`${copy_error_snippet}: \uBB38\uC11C \uD3F4\uB354 \uBC16\uC774\uAC70\uB098 \uC77C\uBC18 \uD30C\uC77C\uC774 \uC544\uB2C8\uB2E4 \xB7 ${path}`, node);
1839
+ }
1840
+ const body = toSnippetBody(readFileSync2(actual, "utf8"), region);
1841
+ if (body === void 0) {
1842
+ file.fail(`${copy_error_snippet}: region \uC774 \uC5C6\uB2E4 \xB7 ${path}#${region}`, node);
1843
+ return;
1844
+ }
1845
+ const extension = extname3(actual).toLowerCase();
1846
+ const lang = snippet_language_by_extension[extension] === void 0 ? extension.slice(1) : snippet_language_by_extension[extension];
1847
+ options.snippets.add(actual);
1848
+ parent.children.splice(index, 1, { type: "code", lang, meta: meta === void 0 ? null : meta, value: body });
1849
+ });
1850
+ };
1851
+
1766
1852
  // src/entry/cli/_function/create-processor/_shiki-transformers.ts
1767
1853
  import {
1768
1854
  transformerMetaHighlight,
@@ -1780,23 +1866,38 @@ var lineNumbers = {
1780
1866
  }
1781
1867
  }
1782
1868
  };
1869
+ var tone = {
1870
+ name: "for-humanity:code-tone",
1871
+ pre(node) {
1872
+ const meta = this.options.meta?.__raw;
1873
+ if (typeof meta !== "string") {
1874
+ return;
1875
+ }
1876
+ for (const word of ["bad", "good"]) {
1877
+ if (new RegExp(`\\b${word}\\b`).test(meta)) {
1878
+ this.addClassToHast(node, `wg_prose__code--${word}`);
1879
+ }
1880
+ }
1881
+ }
1882
+ };
1783
1883
  var shikiTransformers = [
1784
1884
  transformerNotationHighlight({ matchAlgorithm: "v3" }),
1785
1885
  transformerNotationErrorLevel({ matchAlgorithm: "v3" }),
1786
1886
  transformerNotationDiff({ matchAlgorithm: "v3" }),
1787
1887
  transformerNotationWordHighlight({ matchAlgorithm: "v3" }),
1788
1888
  transformerMetaHighlight(),
1789
- lineNumbers
1889
+ lineNumbers,
1890
+ tone
1790
1891
  ];
1791
1892
 
1792
1893
  // src/entry/cli/_function/create-processor/create-processor.ts
1793
1894
  var createProcessor = (options) => {
1794
- 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-" }), transformers: shikiTransformers }).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 });
1895
+ return unified().use(remarkParse).use(remarkFrontmatter, ["yaml"]).use(remarkGfm).use(remarkSnippets, { root: options.root, snippets: options.snippets ?? /* @__PURE__ */ new Set() }).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-" }), transformers: shikiTransformers }).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 });
1795
1896
  };
1796
1897
 
1797
1898
  // src/entry/cli/_function/read-docs.ts
1798
1899
  import { readdir, readFile as readFile2 } from "node:fs/promises";
1799
- import { join, posix as posix3, sep as sep2 } from "node:path";
1900
+ import { join, posix as posix3, sep as sep3 } from "node:path";
1800
1901
  import { VFile } from "vfile";
1801
1902
  import { parse as parseYaml } from "yaml";
1802
1903
 
@@ -1845,11 +1946,11 @@ var docDataSchema = z2.object({
1845
1946
  // src/entry/cli/_function/read-docs.ts
1846
1947
  var readDocs = async (options) => {
1847
1948
  const paths = (await readdir(options.root, { recursive: true })).filter(
1848
- (path) => /\.mdx?$/i.test(path) && !/^(readme|agents|claude)\.md$/i.test(path) && !path.split(sep2).includes("node_modules") && !path.startsWith(`dist${sep2}`)
1949
+ (path) => /\.mdx?$/i.test(path) && !/^(readme|agents|claude)\.md$/i.test(path) && !path.split(sep3).includes("node_modules") && !path.startsWith(`dist${sep3}`)
1849
1950
  );
1850
1951
  const docs = await Promise.all(
1851
1952
  paths.map(async (path) => {
1852
- const id = path.replace(/\.mdx?$/i, "").split(sep2).join("/").toLowerCase();
1953
+ const id = path.replace(/\.mdx?$/i, "").split(sep3).join("/").toLowerCase();
1853
1954
  const segments = id.split("/");
1854
1955
  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)) {
1855
1956
  throw new Error(`${copy_error_doc_id}: ${path}`);
@@ -2043,15 +2144,15 @@ var toErrorMessage = (error) => {
2043
2144
 
2044
2145
  // src/util/font/to-font-css.ts
2045
2146
  import { createHash as createHash2 } from "node:crypto";
2046
- import { readFileSync as readFileSync2 } from "node:fs";
2047
- import { basename, dirname as dirname3, extname as extname3, join as join3 } from "node:path";
2147
+ import { readFileSync as readFileSync3 } from "node:fs";
2148
+ import { basename, dirname as dirname4, extname as extname4, join as join3 } from "node:path";
2048
2149
  var toFontCss = (options) => {
2049
2150
  const files = /* @__PURE__ */ new Map();
2050
- const css = [...readFileSync2(options.css, "utf8").matchAll(/@font-face\s*\{[^}]*\}/g)].map(
2151
+ const css = [...readFileSync3(options.css, "utf8").matchAll(/@font-face\s*\{[^}]*\}/g)].map(
2051
2152
  (match) => match[0].replace(/font-display:\s*[^;]+;/g, `font-display: ${font_display_strategy};`).replace(/url\(([^)]+)\)/g, (_match, relativeUrl) => {
2052
- const file = join3(dirname3(options.css), relativeUrl);
2053
- const extension = extname3(file);
2054
- const hash = createHash2("sha256").update(readFileSync2(file)).digest("hex").slice(0, font_hash_length);
2153
+ const file = join3(dirname4(options.css), relativeUrl);
2154
+ const extension = extname4(file);
2155
+ const hash = createHash2("sha256").update(readFileSync3(file)).digest("hex").slice(0, font_hash_length);
2055
2156
  const url = `${options.fontDir}/${basename(file, extension)}.${hash}${extension}`;
2056
2157
  files.set(url, file);
2057
2158
  return `url(${url})`;
@@ -2068,10 +2169,10 @@ var main = async () => {
2068
2169
  throw new Error(`${copy_error_unknown_command}: ${command} (${commands.join(", ")})`);
2069
2170
  }
2070
2171
  const kitRoot = fileURLToPath(new URL("../", import.meta.url));
2071
- const docsRoot = resolve3(docsDir);
2172
+ const docsRoot = resolve4(docsDir);
2072
2173
  const outDir = join4(docsRoot, "dist");
2073
2174
  const configFile = join4(docsRoot, cli_config_file_name);
2074
- const parsedConfig = siteConfigSchema.safeParse(existsSync3(configFile) ? (await import(pathToFileURL(configFile).href)).default : site_config_absent);
2175
+ const parsedConfig = siteConfigSchema.safeParse(existsSync4(configFile) ? (await import(pathToFileURL(configFile).href)).default : site_config_absent);
2075
2176
  if (!parsedConfig.success) {
2076
2177
  throw new Error(`${copy_error_config}: ${parsedConfig.error.issues.map((issue) => `${issue.path.join(".")} ${issue.message}`).join(", ")}`);
2077
2178
  }
@@ -2081,7 +2182,7 @@ var main = async () => {
2081
2182
  app2.use(
2082
2183
  "/*",
2083
2184
  serveStatic({
2084
- root: relative4(process.cwd(), outDir),
2185
+ root: relative5(process.cwd(), outDir),
2085
2186
  onFound: (_path, c) => {
2086
2187
  if (c.req.path.startsWith(`${asset_font_dir}/`)) {
2087
2188
  c.header("Cache-Control", font_cache_control);
@@ -2097,7 +2198,7 @@ var main = async () => {
2097
2198
  const brand = toFontCss({ css: join4(kitRoot, font_brand_css), fontDir: asset_font_dir });
2098
2199
  const fontCss = [mono.css, body.css, brand.css].join("\n");
2099
2200
  const stylePath = toAssetPath({ path: asset_style_path, file: join4(kitRoot, "dist/cli.css") });
2100
- const browserScript = readFileSync3(join4(kitRoot, "dist/browser.js"), "utf8");
2201
+ const browserScript = readFileSync4(join4(kitRoot, "dist/browser.js"), "utf8");
2101
2202
  const kitFiles = new Map([
2102
2203
  [stylePath, join4(kitRoot, "dist/cli.css")],
2103
2204
  [asset_favicon_path, join4(kitRoot, "src/asset/favicon.svg")],
@@ -2106,7 +2207,8 @@ var main = async () => {
2106
2207
  ...brand.files
2107
2208
  ]);
2108
2209
  const files = new Map(kitFiles);
2109
- const processor = createProcessor({ site: siteConfig, root: docsRoot, files });
2210
+ const snippets = /* @__PURE__ */ new Set();
2211
+ const processor = createProcessor({ site: siteConfig, root: docsRoot, files, snippets });
2110
2212
  const [docs, home] = await Promise.all([
2111
2213
  readDocs({ root: docsRoot, processor, reserved: siteConfig.url === void 0 ? void 0 : crawling_reserved_roots }),
2112
2214
  readHome({ root: docsRoot, processor })
@@ -2129,11 +2231,11 @@ var main = async () => {
2129
2231
  throw crawlerResult.error;
2130
2232
  }
2131
2233
  for (const [url, file] of files) {
2132
- await mkdir(dirname4(join4(outDir, url)), { recursive: true });
2234
+ await mkdir(dirname5(join4(outDir, url)), { recursive: true });
2133
2235
  await copyFile(file, join4(outDir, url));
2134
2236
  }
2135
2237
  console.log(
2136
- `${crawlerResult === void 0 ? result.files.length : result.files.length + crawlerResult.files.length} files, ${files.size} assets \u2192 ${relative4(process.cwd(), outDir)}`
2238
+ `${crawlerResult === void 0 ? result.files.length : result.files.length + crawlerResult.files.length} files, ${files.size} assets \u2192 ${relative5(process.cwd(), outDir)}`
2137
2239
  );
2138
2240
  return;
2139
2241
  }
@@ -2145,7 +2247,8 @@ var main = async () => {
2145
2247
  run: async () => {
2146
2248
  const currentGeneration = generation;
2147
2249
  const nextFiles = new Map(kitFiles);
2148
- const nextProcessor = createProcessor({ site: siteConfig, root: docsRoot, files: nextFiles });
2250
+ const nextSnippets = /* @__PURE__ */ new Set();
2251
+ const nextProcessor = createProcessor({ site: siteConfig, root: docsRoot, files: nextFiles, snippets: nextSnippets });
2149
2252
  const [docs2, home2] = await Promise.all([
2150
2253
  readDocs({ root: docsRoot, processor: nextProcessor, reserved: siteConfig.url === void 0 ? void 0 : crawling_reserved_roots }),
2151
2254
  readHome({ root: docsRoot, processor: nextProcessor })
@@ -2157,6 +2260,10 @@ var main = async () => {
2157
2260
  for (const entry of nextFiles) {
2158
2261
  files.set(...entry);
2159
2262
  }
2263
+ snippets.clear();
2264
+ for (const entry of nextSnippets) {
2265
+ snippets.add(entry);
2266
+ }
2160
2267
  store.docs = docs2;
2161
2268
  store.home = home2;
2162
2269
  reload.emit("change");
@@ -2167,8 +2274,9 @@ var main = async () => {
2167
2274
  );
2168
2275
  const handleDocsChange = (_event, filename) => {
2169
2276
  if (filename !== null) {
2170
- const segments = filename.split(sep3);
2171
- if (segments.includes("dist") || segments.includes("node_modules") || !/\.mdx?$/i.test(filename) && !asset_media_extensions.has(extname4(filename).toLowerCase())) {
2277
+ const segments = filename.split(sep4);
2278
+ const isSnippet = snippets.has(join4(docsRoot, filename));
2279
+ if (segments.includes("dist") || segments.includes("node_modules") || !isSnippet && !/\.mdx?$/i.test(filename) && !asset_media_extensions.has(extname5(filename).toLowerCase())) {
2172
2280
  return;
2173
2281
  }
2174
2282
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "for-humanity",
3
- "version": "0.10.0",
3
+ "version": "0.11.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",
@@ -245,6 +245,18 @@
245
245
  opacity: 0.8;
246
246
  }
247
247
 
248
+ /* 색 블록 위의 줄 표기 · 바탕보다 한 단계 진하게 */
249
+ & pre.wg_prose__code--bad code .line.highlighted,
250
+ & pre.wg_prose__code--bad code .line.highlighted.error,
251
+ & pre.wg_prose__code--bad code .line.diff.remove {
252
+ background: var(--app-color-bad-mark);
253
+ }
254
+
255
+ & pre.wg_prose__code--good code .line.highlighted,
256
+ & pre.wg_prose__code--good code .line.diff.add {
257
+ background: var(--app-color-good-mark);
258
+ }
259
+
248
260
  & pre code .highlighted-word {
249
261
  padding: 0 2px;
250
262
  border-radius: var(--app-radius-inline);
@@ -126,3 +126,8 @@ export const copy_warn_unknown_status = "설정에 없는 상태 문구다";
126
126
  * 문서에서 참조한 로컬 자원의 경로·형식 오류
127
127
  */
128
128
  export const copy_error_asset = "자원이 올바르지 않다";
129
+
130
+ /**
131
+ * `<<<` 로 불러온 코드 스니펫의 경로·파일·region 오류
132
+ */
133
+ export const copy_error_snippet = "코드 스니펫이 올바르지 않다";
@@ -0,0 +1,21 @@
1
+ /**
2
+ * 스니펫 줄 · `<<< ./path.ext`, 선택 `#region`, 선택 메타(펜스 메타와 같음)
3
+ */
4
+ export const snippet_pattern = /^<<<\s+(\S+?)(?:#([\w-]+))?(?:\s+(.+?))?\s*$/;
5
+
6
+ /**
7
+ * region 표시 줄 · 주석 모양은 자유 · `#region 이름`, `#endregion 이름`
8
+ */
9
+ export const snippet_region_pattern = /^\s*\S*\s*#(end)?region\s+([\w-]+)/;
10
+
11
+ /**
12
+ * 확장자 → 펜스 언어 · 없으면 확장자 그대로
13
+ */
14
+ export const snippet_language_by_extension: Record<string, string> = {
15
+ ".cjs": "js",
16
+ ".md": "markdown",
17
+ ".mjs": "js",
18
+ ".mts": "ts",
19
+ ".txt": "text",
20
+ ".yml": "yaml",
21
+ };
@@ -0,0 +1,117 @@
1
+ import {existsSync, readFileSync, realpathSync, statSync} from "node:fs";
2
+ import {dirname, extname, isAbsolute, relative, resolve, sep} from "node:path";
3
+ import type {Root} from "mdast";
4
+ import {visit} from "unist-util-visit";
5
+ import type {VFile} from "vfile";
6
+ import {copy_error_snippet} from "@/constant/copy";
7
+ import {snippet_language_by_extension, snippet_pattern, snippet_region_pattern} from "@/entry/cli/_constant/snippet";
8
+
9
+ /**
10
+ * 스니펫 수집 계약
11
+ */
12
+ export interface SnippetOptions {
13
+ /**
14
+ * 스니펫을 읽을 수 있는 문서 루트
15
+ */
16
+ root: string;
17
+ /**
18
+ * dev 새로고침이 지켜볼 불러온 파일 경로
19
+ */
20
+ snippets: Set<string>;
21
+ }
22
+
23
+ /**
24
+ * region 이름이 있으면 그 안만, 없으면 전체 · region 표시 줄은 항상 제거 · 공통 들여쓰기 제거
25
+ */
26
+ const toSnippetBody = (source: string, region: string | undefined): string | undefined => {
27
+ const lines = source.replace(/\r\n/g, "\n").split("\n");
28
+ let inside = region === undefined;
29
+ let found = region === undefined;
30
+ const kept: string[] = [];
31
+
32
+ for (const line of lines) {
33
+ const marker = snippet_region_pattern.exec(line);
34
+
35
+ if (marker !== null) {
36
+ if (region !== undefined && marker[2] === region) {
37
+ inside = marker[1] === undefined;
38
+ found = true;
39
+ }
40
+
41
+ continue;
42
+ }
43
+
44
+ if (inside) {
45
+ kept.push(line);
46
+ }
47
+ }
48
+
49
+ if (!found) {
50
+ return undefined;
51
+ }
52
+
53
+ while (kept.length > 0 && kept[0]?.trim() === "") {
54
+ kept.shift();
55
+ }
56
+
57
+ while (kept.length > 0 && kept.at(-1)?.trim() === "") {
58
+ kept.pop();
59
+ }
60
+
61
+ const indents = kept.filter((line) => line.trim() !== "").map((line) => line.length - line.trimStart().length);
62
+ const indent = indents.length === 0 ? 0 : Math.min(...indents);
63
+
64
+ return kept.map((line) => line.slice(Math.min(indent, line.length - line.trimStart().length))).join("\n");
65
+ };
66
+
67
+ /**
68
+ * `<<< ./path.ext#region 메타` 문단 → 코드 펜스 · 파일 위치 기준 경로 · 문서 폴더 밖과 없는 파일은 빌드 실패
69
+ * 불러온 파일은 dev 감시 목록에 올림
70
+ */
71
+ export const remarkSnippets = (options: SnippetOptions) => (tree: Root, file: VFile) => {
72
+ visit(tree, "paragraph", (node, index, parent) => {
73
+ const [child] = node.children;
74
+
75
+ if (node.children.length !== 1 || child === undefined || child.type !== "text" || parent === undefined || index === undefined) {
76
+ return;
77
+ }
78
+
79
+ const match = snippet_pattern.exec(child.value.trim());
80
+
81
+ if (match === null) {
82
+ return;
83
+ }
84
+
85
+ const [, path = "", region, meta] = match;
86
+ const target = resolve(dirname(file.path), path);
87
+ const location = relative(options.root, target);
88
+
89
+ if (location === ".." || location.startsWith(`..${sep}`) || isAbsolute(location)) {
90
+ file.fail(`${copy_error_snippet}: 문서 폴더 밖 · ${path}`, node);
91
+ }
92
+
93
+ if (!existsSync(target)) {
94
+ file.fail(`${copy_error_snippet}: 파일이 없다 · ${path}`, node);
95
+ }
96
+
97
+ const actual = realpathSync(target);
98
+ const actualLocation = relative(realpathSync(options.root), actual);
99
+
100
+ if (actualLocation === ".." || actualLocation.startsWith(`..${sep}`) || isAbsolute(actualLocation) || !statSync(actual).isFile()) {
101
+ file.fail(`${copy_error_snippet}: 문서 폴더 밖이거나 일반 파일이 아니다 · ${path}`, node);
102
+ }
103
+
104
+ const body = toSnippetBody(readFileSync(actual, "utf8"), region);
105
+
106
+ if (body === undefined) {
107
+ file.fail(`${copy_error_snippet}: region 이 없다 · ${path}#${region}`, node);
108
+ return;
109
+ }
110
+
111
+ const extension = extname(actual).toLowerCase();
112
+ const lang = snippet_language_by_extension[extension] === undefined ? extension.slice(1) : snippet_language_by_extension[extension];
113
+
114
+ options.snippets.add(actual);
115
+ parent.children.splice(index, 1, {type: "code", lang, meta: meta === undefined ? null : meta, value: body});
116
+ });
117
+ };
@@ -22,7 +22,27 @@ const lineNumbers: ShikiTransformer = {
22
22
  };
23
23
 
24
24
  /**
25
- * 코드 줄 표기 · `// [!code highlight|error|warning|++|--]`, `// [!code word:x]`, 메타 `{2,4-6}`, `line-numbers`
25
+ * 펜스 메타 `bad`·`good` → pre 에 블록 색 클래스 · 바탕색은 token.css 가 바꿈
26
+ */
27
+ const tone: ShikiTransformer = {
28
+ name: "for-humanity:code-tone",
29
+ pre(node) {
30
+ const meta = this.options.meta?.__raw;
31
+
32
+ if (typeof meta !== "string") {
33
+ return;
34
+ }
35
+
36
+ for (const word of ["bad", "good"]) {
37
+ if (new RegExp(`\\b${word}\\b`).test(meta)) {
38
+ this.addClassToHast(node, `wg_prose__code--${word}`);
39
+ }
40
+ }
41
+ },
42
+ };
43
+
44
+ /**
45
+ * 코드 줄 표기 · `// [!code highlight|error|warning|++|--]`, `// [!code word:x]`, 메타 `{2,4-6}`, `line-numbers`, 블록 색 `bad`·`good`
26
46
  */
27
47
  export const shikiTransformers: ShikiTransformer[] = [
28
48
  transformerNotationHighlight({matchAlgorithm: "v3"}),
@@ -31,4 +51,5 @@ export const shikiTransformers: ShikiTransformer[] = [
31
51
  transformerNotationWordHighlight({matchAlgorithm: "v3"}),
32
52
  transformerMetaHighlight(),
33
53
  lineNumbers,
54
+ tone,
34
55
  ];
@@ -22,19 +22,21 @@ import {remarkUnknownDirectives} from "@/component/widget/prose/_function/remark
22
22
  import {rehypeAssets} from "@/entry/cli/_function/create-processor/_rehype-assets";
23
23
  import {rehypeHeadingIds} from "@/entry/cli/_function/create-processor/_rehype-heading-ids";
24
24
  import {remarkReport} from "@/entry/cli/_function/create-processor/_remark-report";
25
+ import {remarkSnippets} from "@/entry/cli/_function/create-processor/_remark-snippets";
25
26
  import {shikiTransformers} from "@/entry/cli/_function/create-processor/_shiki-transformers";
26
27
  import type {SiteConfig} from "@/type/site-config";
27
28
 
28
29
  /**
29
- * Markdown 처리 순서: remark(부품·흐름도) → 코드 강조(줄 표기 transformer) → 머리 → 제목 id → 가름 수집 → 표
30
+ * Markdown 처리 순서: remark(스니펫·부품·흐름도) → 코드 강조(줄 표기·블록 색 transformer) → 머리 → 제목 id → 가름 수집 → 표
30
31
  * 플러그인 HTML 조각은 마지막 rehype-raw에서 해석
31
32
  * Shiki inline style: token.css의 --app-code-* 참조
32
33
  */
33
- export const createProcessor = (options: {site: SiteConfig; root: string; files?: Map<string, string>}) => {
34
+ export const createProcessor = (options: {site: SiteConfig; root: string; files?: Map<string, string>; snippets?: Set<string>}) => {
34
35
  return unified()
35
36
  .use(remarkParse)
36
37
  .use(remarkFrontmatter, ["yaml"])
37
38
  .use(remarkGfm)
39
+ .use(remarkSnippets, {root: options.root, snippets: options.snippets ?? new Set<string>()})
38
40
  .use(remarkSmartypants)
39
41
  .use(remarkDirective)
40
42
  .use(remarkBlocks)
@@ -96,6 +96,60 @@ test("code notation marks lines, meta highlights lines, and line-numbers tags th
96
96
  }
97
97
  });
98
98
 
99
+ test("code snippets import a file or a region from inside the document root and pass fence meta", async () => {
100
+ const root = await mkdtemp(join(tmpdir(), "for-humanity-snippets-"));
101
+
102
+ try {
103
+ await mkdir(join(root, "snippet"));
104
+ await writeFile(
105
+ join(root, "snippet", "example.ts"),
106
+ "const outside = 0;\n// #region picked\n const a = 1; // [!code error]\n const b = 2;\n// #endregion picked\nconst after = 3;\n",
107
+ );
108
+ await writeFile(
109
+ join(root, "guide.md"),
110
+ "---\nname: Guide\nlabel: Guide\ngroup: Guide\n---\n\n## Read\n\n<<< ./snippet/example.ts#picked bad line-numbers\n\n<<< ./snippet/example.ts\n",
111
+ );
112
+ const site = siteConfigSchema.parse({});
113
+ const snippets = new Set<string>();
114
+ const [doc] = await readDocs({root, processor: createProcessor({site, root, snippets})});
115
+
116
+ assert.ok(doc);
117
+ assert.match(doc.html, /<pre class="[^"]*wg_prose__lineNumbers[^"]*wg_prose__code--bad[^"]*"|<pre class="[^"]*wg_prose__code--bad[^"]*wg_prose__lineNumbers[^"]*"/);
118
+ assert.match(doc.html, /<span class="line highlighted error">[^\n]*const<\/span>[^\n]* a/);
119
+ assert.equal(doc.html.includes("#region"), false, "region marker lines are removed");
120
+ assert.equal(doc.html.split("const</span>").length - 1, 2 + 4, "the region block has two lines and the whole-file block four");
121
+ assert.deepEqual(
122
+ [...snippets].map((entry) => entry.endsWith(join("snippet", "example.ts"))),
123
+ [true],
124
+ );
125
+ } finally {
126
+ await rm(root, {recursive: true, force: true});
127
+ }
128
+ });
129
+
130
+ test("code snippets reject files outside the document root, missing files and missing regions", async () => {
131
+ const root = await mkdtemp(join(tmpdir(), "for-humanity-snippets-reject-"));
132
+
133
+ try {
134
+ await mkdir(join(root, "docs"));
135
+ await writeFile(join(root, "outside.ts"), "const x = 1;\n");
136
+ await writeFile(join(root, "docs", "present.ts"), "const y = 2;\n");
137
+ const site = siteConfigSchema.parse({});
138
+ const docsRoot = join(root, "docs");
139
+
140
+ for (const [reference, expected] of [
141
+ ["../outside.ts", /문서 폴더 밖/],
142
+ ["./missing.ts", /파일이 없다/],
143
+ ["./present.ts#nope", /region 이 없다/],
144
+ ] as const) {
145
+ await writeFile(join(docsRoot, "guide.md"), `---\nname: Guide\nlabel: Guide\ngroup: Guide\n---\n\n<<< ${reference}\n`);
146
+ await assert.rejects(readDocs({root: docsRoot, processor: createProcessor({site, root: docsRoot})}), expected, reference);
147
+ }
148
+ } finally {
149
+ await rm(root, {recursive: true, force: true});
150
+ }
151
+ });
152
+
99
153
  test("cursor face is off unless the site turns it on", () => {
100
154
  assert.equal(siteConfigSchema.parse({}).cursorFace, false);
101
155
  assert.equal(siteConfigSchema.parse({cursorFace: true}).cursorFace, true);
@@ -92,7 +92,8 @@ const main = async () => {
92
92
  ...brand.files,
93
93
  ]);
94
94
  const files = new Map(kitFiles);
95
- const processor = createProcessor({site: siteConfig, root: docsRoot, files});
95
+ const snippets = new Set<string>();
96
+ const processor = createProcessor({site: siteConfig, root: docsRoot, files, snippets});
96
97
  const [docs, home] = await Promise.all([
97
98
  readDocs({root: docsRoot, processor, reserved: siteConfig.url === undefined ? undefined : crawling_reserved_roots}),
98
99
  readHome({root: docsRoot, processor}),
@@ -147,7 +148,8 @@ const main = async () => {
147
148
  run: async () => {
148
149
  const currentGeneration = generation;
149
150
  const nextFiles = new Map(kitFiles);
150
- const nextProcessor = createProcessor({site: siteConfig, root: docsRoot, files: nextFiles});
151
+ const nextSnippets = new Set<string>();
152
+ const nextProcessor = createProcessor({site: siteConfig, root: docsRoot, files: nextFiles, snippets: nextSnippets});
151
153
  const [docs, home] = await Promise.all([
152
154
  readDocs({root: docsRoot, processor: nextProcessor, reserved: siteConfig.url === undefined ? undefined : crawling_reserved_roots}),
153
155
  readHome({root: docsRoot, processor: nextProcessor}),
@@ -161,6 +163,10 @@ const main = async () => {
161
163
  for (const entry of nextFiles) {
162
164
  files.set(...entry);
163
165
  }
166
+ snippets.clear();
167
+ for (const entry of nextSnippets) {
168
+ snippets.add(entry);
169
+ }
164
170
  store.docs = docs;
165
171
  store.home = home;
166
172
  reload.emit("change");
@@ -171,12 +177,17 @@ const main = async () => {
171
177
  );
172
178
 
173
179
  /**
174
- * 입력 변경 감지 · 출력·설치 폴더 제외 · 누락 자원 추가도 다시 확인
180
+ * 입력 변경 감지 · 출력·설치 폴더 제외 · 누락 자원 추가도 다시 확인 · 불러온 스니펫 파일 변경 포함
175
181
  */
176
182
  const handleDocsChange = (_event: string, filename: string | null) => {
177
183
  if (filename !== null) {
178
184
  const segments = filename.split(sep);
179
- if (segments.includes("dist") || segments.includes("node_modules") || (!/\.mdx?$/i.test(filename) && !asset_media_extensions.has(extname(filename).toLowerCase()))) {
185
+ const isSnippet = snippets.has(join(docsRoot, filename));
186
+ if (
187
+ segments.includes("dist") ||
188
+ segments.includes("node_modules") ||
189
+ (!isSnippet && !/\.mdx?$/i.test(filename) && !asset_media_extensions.has(extname(filename).toLowerCase()))
190
+ ) {
180
191
  return;
181
192
  }
182
193
  }
@@ -35,11 +35,13 @@
35
35
  --app-color-accent: light-dark(#555, #b5b5b5);
36
36
  --app-color-accent-soft: light-dark(#f5f5f2, #222);
37
37
 
38
- /* 코드 줄 표기 · error·remove 는 bad, add 는 good, warning 은 warn · soft 가 줄 바탕 */
38
+ /* 코드 판정 색 · soft 는 블록 바탕(메타 bad·good), mark 는 그 위의 줄 표기 · warn 은 줄 표기만 */
39
39
  --app-color-bad: light-dark(#b2412e, #ff9b8a);
40
- --app-color-bad-soft: light-dark(#fbe9e6, #42241f);
40
+ --app-color-bad-soft: light-dark(#f9ebe8, #3a2522);
41
+ --app-color-bad-mark: light-dark(#f1cdc6, #5b322c);
41
42
  --app-color-good: light-dark(#2f7a47, #8bd3a3);
42
- --app-color-good-soft: light-dark(#e3f3e8, #1f3a27);
43
+ --app-color-good-soft: light-dark(#e4f3ed, #1c342c);
44
+ --app-color-good-mark: light-dark(#c4e4d4, #27503f);
43
45
  --app-color-warn: light-dark(#8a6400, #e6c46a);
44
46
  --app-color-warn-soft: light-dark(#fbf3d9, #3d3418);
45
47
 
@@ -173,3 +175,12 @@
173
175
  --app-space-gutter: 16px;
174
176
  }
175
177
  }
178
+
179
+ /* 블록 색 · 펜스 메타 bad·good · Shiki 가 pre 에 인라인으로 쓰는 코드 바탕을 바꿈 */
180
+ .wg_prose__code--bad {
181
+ --app-code-background: var(--app-color-bad-soft);
182
+ }
183
+
184
+ .wg_prose__code--good {
185
+ --app-code-background: var(--app-color-good-soft);
186
+ }