for-humanity 0.9.1 → 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,17 @@
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
+
10
+ ## 0.10.0 — 2026-10-13
11
+
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.
13
+ - Remove the `bad`/`good` code labels added in 0.9.0; mark the lines instead.
14
+ - Draw tables and flowchart boxes with the same border color as code blocks.
15
+
5
16
  ## 0.9.1 — 2026-10-13
6
17
 
7
18
  - Draw tables with an outer border and cell borders, box flowcharts with a border, and separate sections with a solid rule and 64px of space.
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.9.1
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.9.1` supports `dev`, `build` and `preview`. `init` is planned; see the [Roadmap](https://github.com/minu-ha/for-humanity/blob/main/templates/document/roadmap.md) and [Changelog](https://github.com/minu-ha/for-humanity/blob/main/CHANGELOG.md).
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.9.1` 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
@@ -138,15 +138,14 @@
138
138
  }
139
139
  & table {
140
140
  width: 100%;
141
- border: 1px solid var(--app-color-border-soft);
141
+ border: 1px solid var(--app-color-border);
142
142
  border-collapse: collapse;
143
143
  font-size: var(--app-font-size-dense);
144
144
  font-variant-numeric: tabular-nums;
145
145
  }
146
146
  & th {
147
147
  padding: var(--app-space-inline) var(--app-space-stack);
148
- border: 1px solid var(--app-color-border-soft);
149
- border-bottom-color: var(--app-color-border);
148
+ border: 1px solid var(--app-color-border);
150
149
  background: var(--app-color-code);
151
150
  color: var(--app-color-text-strong);
152
151
  font-weight: var(--app-font-weight-strong);
@@ -156,7 +155,7 @@
156
155
  }
157
156
  & td {
158
157
  padding: var(--app-space-inline) var(--app-space-stack);
159
- border: 1px solid var(--app-color-border-soft);
158
+ border: 1px solid var(--app-color-border);
160
159
  text-align: left;
161
160
  vertical-align: top;
162
161
  }
@@ -168,28 +167,54 @@
168
167
  border: 0;
169
168
  border-top: 1px dotted var(--app-color-border);
170
169
  }
171
- & .wg_prose__codeLabel {
172
- margin: var(--app-space-block) 0;
173
- border-left: 3px solid var(--app-color-bad);
170
+ & pre code .line {
171
+ display: inline-block;
172
+ width: calc(100% + 2 * var(--app-space-block));
173
+ margin: 0 calc(-1 * var(--app-space-block));
174
+ padding: 0 var(--app-space-block);
174
175
  }
175
- & .wg_prose__codeLabel--good {
176
- border-left-color: var(--app-color-good);
176
+ & pre code .line.highlighted {
177
+ background: var(--app-color-accent-soft);
177
178
  }
178
- & .wg_prose__codeLabelTitle {
179
- padding: var(--app-space-inline) var(--app-space-block);
179
+ & pre code .line.highlighted.error {
180
180
  background: var(--app-color-bad-soft);
181
- color: var(--app-color-bad);
182
- font-size: var(--app-font-size-dense);
183
- font-weight: var(--app-font-weight-strong);
184
181
  }
185
- & .wg_prose__codeLabel--good .wg_prose__codeLabelTitle {
182
+ & pre code .line.highlighted.warning {
183
+ background: var(--app-color-warn-soft);
184
+ }
185
+ & pre code .line.diff.add {
186
186
  background: var(--app-color-good-soft);
187
- color: var(--app-color-good);
188
187
  }
189
- & .wg_prose__codeLabel pre {
190
- margin: 0;
191
- border-left: 0;
192
- border-radius: 0 var(--app-radius-box) var(--app-radius-box) 0;
188
+ & pre code .line.diff.remove {
189
+ background: var(--app-color-bad-soft);
190
+ opacity: 0.8;
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
+ }
201
+ & pre code .highlighted-word {
202
+ padding: 0 2px;
203
+ border-radius: var(--app-radius-inline);
204
+ background: var(--app-color-accent-soft);
205
+ }
206
+ & pre.wg_prose__lineNumbers code {
207
+ counter-reset: line;
208
+ }
209
+ & pre.wg_prose__lineNumbers code .line::before {
210
+ display: inline-block;
211
+ width: 2em;
212
+ margin-right: var(--app-space-stack);
213
+ color: var(--app-color-text-muted);
214
+ text-align: right;
215
+ content: counter(line);
216
+ counter-increment: line;
217
+ user-select: none;
193
218
  }
194
219
  }
195
220
  .wg_prose__head {
@@ -272,7 +297,7 @@
272
297
  overflow-x: auto;
273
298
  margin: var(--app-space-block) 0;
274
299
  padding: var(--app-space-block);
275
- border: 1px solid var(--app-color-border-soft);
300
+ border: 1px solid var(--app-color-border);
276
301
  border-radius: var(--app-radius-box);
277
302
  letter-spacing: 0;
278
303
  & svg {
@@ -763,9 +788,13 @@
763
788
  --app-color-accent: light-dark(#555, #b5b5b5);
764
789
  --app-color-accent-soft: light-dark(#f5f5f2, #222);
765
790
  --app-color-bad: light-dark(#b2412e, #ff9b8a);
766
- --app-color-bad-soft: light-dark(#fbeeec, #3a2522);
791
+ --app-color-bad-soft: light-dark(#f9ebe8, #3a2522);
792
+ --app-color-bad-mark: light-dark(#f1cdc6, #5b322c);
767
793
  --app-color-good: light-dark(#2f7a47, #8bd3a3);
768
- --app-color-good-soft: light-dark(#eaf5ee, #1f3327);
794
+ --app-color-good-soft: light-dark(#e4f3ed, #1c342c);
795
+ --app-color-good-mark: light-dark(#c4e4d4, #27503f);
796
+ --app-color-warn: light-dark(#8a6400, #e6c46a);
797
+ --app-color-warn-soft: light-dark(#fbf3d9, #3d3418);
769
798
  --app-color-link: light-dark(#11a, #9db8ff);
770
799
  --app-color-link-visited: light-dark(#51a, #c5a5ed);
771
800
  --app-color-link-hover: light-dark(#44d, #c1d1ff);
@@ -857,10 +886,10 @@
857
886
  --app-space-gutter: 16px;
858
887
  }
859
888
  }
860
- .wg_prose__codeLabel--bad {
889
+ .wg_prose__code--bad {
861
890
  --app-code-background: var(--app-color-bad-soft);
862
891
  }
863
- .wg_prose__codeLabel--good {
892
+ .wg_prose__code--good {
864
893
  --app-code-background: var(--app-color-good-soft);
865
894
  }
866
895
 
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";
@@ -1231,33 +1232,8 @@ var remarkBlocks = () => (tree, file) => {
1231
1232
  });
1232
1233
  };
1233
1234
 
1234
- // src/component/widget/prose/_function/remark-code-labels.ts
1235
- import { visit as visit3 } from "unist-util-visit";
1236
- var code_label_pattern = /^(bad|good)(?:\s+(.+?))?\s*$/;
1237
- var inserted_node_count = 3;
1238
- var escapeHtml = (text) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
1239
- var remarkCodeLabels = () => (tree) => {
1240
- visit3(tree, "code", (node, index, parent) => {
1241
- if (typeof node.meta !== "string" || parent === void 0 || index === void 0) {
1242
- return;
1243
- }
1244
- const match = code_label_pattern.exec(node.meta);
1245
- if (match === null) {
1246
- return;
1247
- }
1248
- const [, tone, title] = match;
1249
- const caption = title === void 0 ? "" : `<figcaption class="wg_prose__codeLabelTitle">${escapeHtml(title)}</figcaption>`;
1250
- node.meta = null;
1251
- parent.children.splice(index, 1, { type: "html", value: `<figure class="wg_prose__codeLabel wg_prose__codeLabel--${tone}">${caption}` }, node, {
1252
- type: "html",
1253
- value: "</figure>"
1254
- });
1255
- return index + inserted_node_count;
1256
- });
1257
- };
1258
-
1259
1235
  // src/component/widget/prose/_function/remark-flow.ts
1260
- import { visit as visit4 } from "unist-util-visit";
1236
+ import { visit as visit3 } from "unist-util-visit";
1261
1237
 
1262
1238
  // src/util/mermaid/render-flow.js
1263
1239
  import { renderMermaidASCII } from "beautiful-mermaid";
@@ -1571,7 +1547,7 @@ var renderFlow = (source) => gridToSvg(renderMermaidASCII(widenCjk(source), ASCI
1571
1547
 
1572
1548
  // src/component/widget/prose/_function/remark-flow.ts
1573
1549
  var remarkFlow = () => (tree, file) => {
1574
- visit4(tree, "code", (node, index, parent) => {
1550
+ visit3(tree, "code", (node, index, parent) => {
1575
1551
  if (node.lang !== "mermaid" || parent === void 0 || index === void 0) {
1576
1552
  return;
1577
1553
  }
@@ -1589,10 +1565,10 @@ var remarkFlow = () => (tree, file) => {
1589
1565
  // src/component/widget/prose/_function/remark-links.ts
1590
1566
  import { existsSync } from "node:fs";
1591
1567
  import { dirname, posix, relative, resolve } from "node:path";
1592
- import { visit as visit5 } from "unist-util-visit";
1568
+ import { visit as visit4 } from "unist-util-visit";
1593
1569
  var remarkLinks = (options) => (tree, file) => {
1594
1570
  const here = dirname(relative(options.root, file.path));
1595
- visit5(tree, (node) => {
1571
+ visit4(tree, (node) => {
1596
1572
  if (node.type !== "link" && node.type !== "definition") {
1597
1573
  return;
1598
1574
  }
@@ -1626,7 +1602,7 @@ var remarkParts = () => (tree) => {
1626
1602
  };
1627
1603
 
1628
1604
  // src/component/widget/prose/_function/remark-status.ts
1629
- import { escape as escapeHtml2, escapeRegExp } from "es-toolkit/string";
1605
+ import { escape as escapeHtml, escapeRegExp } from "es-toolkit/string";
1630
1606
  import { findAndReplace } from "mdast-util-find-and-replace";
1631
1607
 
1632
1608
  // src/component/widget/prose/_constant/status.ts
@@ -1652,7 +1628,7 @@ var remarkStatus = (options) => {
1652
1628
  }
1653
1629
  return {
1654
1630
  type: "html",
1655
- value: `<span class="wg_prose__pill wg_prose__pill--${item.kind}">${escapeHtml2(text)}</span>`
1631
+ value: `<span class="wg_prose__pill wg_prose__pill--${item.kind}">${escapeHtml(text)}</span>`
1656
1632
  };
1657
1633
  }
1658
1634
  ],
@@ -1676,9 +1652,9 @@ var remarkStatus = (options) => {
1676
1652
  };
1677
1653
 
1678
1654
  // src/component/widget/prose/_function/remark-swatch.ts
1679
- import { visit as visit6 } from "unist-util-visit";
1655
+ import { visit as visit5 } from "unist-util-visit";
1680
1656
  var remarkSwatch = () => (tree) => {
1681
- visit6(tree, "inlineCode", (node) => {
1657
+ visit5(tree, "inlineCode", (node) => {
1682
1658
  if (/^#[0-9a-f]{3,8}$/i.test(node.value.trim())) {
1683
1659
  node.data = {
1684
1660
  hProperties: { className: ["wg_prose__color"], style: `--wg-prose-chip-color: ${node.value.trim()}` }
@@ -1688,9 +1664,9 @@ var remarkSwatch = () => (tree) => {
1688
1664
  };
1689
1665
 
1690
1666
  // src/component/widget/prose/_function/remark-unknown-directives.ts
1691
- import { SKIP as SKIP2, visit as visit7 } from "unist-util-visit";
1667
+ import { SKIP as SKIP2, visit as visit6 } from "unist-util-visit";
1692
1668
  var remarkUnknownDirectives = () => (tree, file) => {
1693
- visit7(tree, (node, index, parent) => {
1669
+ visit6(tree, (node, index, parent) => {
1694
1670
  if (node.type === "textDirective" && parent !== void 0 && index !== void 0) {
1695
1671
  parent.children[index] = {
1696
1672
  type: "text",
@@ -1707,7 +1683,7 @@ var remarkUnknownDirectives = () => (tree, file) => {
1707
1683
  // src/entry/cli/_function/create-processor/_rehype-assets.ts
1708
1684
  import { existsSync as existsSync2, realpathSync, statSync } from "node:fs";
1709
1685
  import { dirname as dirname2, extname as extname2, isAbsolute, relative as relative2, resolve as resolve2, sep } from "node:path";
1710
- import { visit as visit8 } from "unist-util-visit";
1686
+ import { visit as visit7 } from "unist-util-visit";
1711
1687
 
1712
1688
  // src/util/file/to-asset-path.ts
1713
1689
  import { createHash } from "node:crypto";
@@ -1720,7 +1696,7 @@ var toAssetPath = (options) => {
1720
1696
 
1721
1697
  // src/entry/cli/_function/create-processor/_rehype-assets.ts
1722
1698
  var rehypeAssets = (options) => (tree, file) => {
1723
- visit8(tree, "element", (node) => {
1699
+ visit7(tree, "element", (node) => {
1724
1700
  const property = node.tagName === "img" ? "src" : "href";
1725
1701
  const url = node.properties[property];
1726
1702
  if (node.tagName !== "img" && node.tagName !== "a" || typeof url !== "string" || /^(?:[a-z][a-z\d+.-]*:|\/|#)/i.test(url)) {
@@ -1760,11 +1736,11 @@ var rehypeAssets = (options) => (tree, file) => {
1760
1736
  // src/entry/cli/_function/create-processor/_rehype-heading-ids.ts
1761
1737
  import Slugger from "github-slugger";
1762
1738
  import { toString as toPlainText3 } from "hast-util-to-string";
1763
- import { visit as visit9 } from "unist-util-visit";
1739
+ import { visit as visit8 } from "unist-util-visit";
1764
1740
  var rehypeHeadingIds = () => (tree, file) => {
1765
1741
  const slugger = new Slugger();
1766
1742
  const headings = [];
1767
- visit9(tree, "element", (node) => {
1743
+ visit8(tree, "element", (node) => {
1768
1744
  const depth = /^h([1-6])$/.exec(node.tagName)?.[1];
1769
1745
  if (depth === void 0) {
1770
1746
  return;
@@ -1788,14 +1764,140 @@ var remarkReport = (options) => (_tree, file) => {
1788
1764
  }
1789
1765
  };
1790
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
+
1852
+ // src/entry/cli/_function/create-processor/_shiki-transformers.ts
1853
+ import {
1854
+ transformerMetaHighlight,
1855
+ transformerNotationDiff,
1856
+ transformerNotationErrorLevel,
1857
+ transformerNotationHighlight,
1858
+ transformerNotationWordHighlight
1859
+ } from "@shikijs/transformers";
1860
+ var lineNumbers = {
1861
+ name: "for-humanity:line-numbers",
1862
+ pre(node) {
1863
+ const meta = this.options.meta?.__raw;
1864
+ if (typeof meta === "string" && /\bline-numbers\b/.test(meta)) {
1865
+ this.addClassToHast(node, "wg_prose__lineNumbers");
1866
+ }
1867
+ }
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
+ };
1883
+ var shikiTransformers = [
1884
+ transformerNotationHighlight({ matchAlgorithm: "v3" }),
1885
+ transformerNotationErrorLevel({ matchAlgorithm: "v3" }),
1886
+ transformerNotationDiff({ matchAlgorithm: "v3" }),
1887
+ transformerNotationWordHighlight({ matchAlgorithm: "v3" }),
1888
+ transformerMetaHighlight(),
1889
+ lineNumbers,
1890
+ tone
1891
+ ];
1892
+
1791
1893
  // src/entry/cli/_function/create-processor/create-processor.ts
1792
1894
  var createProcessor = (options) => {
1793
- return unified().use(remarkParse).use(remarkFrontmatter, ["yaml"]).use(remarkGfm).use(remarkSmartypants).use(remarkDirective).use(remarkBlocks).use(remarkParts).use(remarkFlow).use(remarkCodeLabels).use(remarkStatus, { status: options.site.status }).use(remarkSwatch).use(remarkLinks, { root: options.root }).use(remarkUnknownDirectives).use(remarkReport, { root: options.root }).use(remarkRehype, { allowDangerousHtml: true }).use(rehypeShiki, { theme: createCssVariablesTheme({ name: "for-humanity", variablePrefix: "--app-code-" }) }).use(rehypeHead).use(rehypeHeadingIds).use(rehypeSections).use(rehypeTables).use(rehypeRaw).use(rehypeAssets, { root: options.root, files: options.files ?? /* @__PURE__ */ new Map() }).use(rehypeStringify, { allowDangerousHtml: true });
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 });
1794
1896
  };
1795
1897
 
1796
1898
  // src/entry/cli/_function/read-docs.ts
1797
1899
  import { readdir, readFile as readFile2 } from "node:fs/promises";
1798
- import { join, posix as posix3, sep as sep2 } from "node:path";
1900
+ import { join, posix as posix3, sep as sep3 } from "node:path";
1799
1901
  import { VFile } from "vfile";
1800
1902
  import { parse as parseYaml } from "yaml";
1801
1903
 
@@ -1844,11 +1946,11 @@ var docDataSchema = z2.object({
1844
1946
  // src/entry/cli/_function/read-docs.ts
1845
1947
  var readDocs = async (options) => {
1846
1948
  const paths = (await readdir(options.root, { recursive: true })).filter(
1847
- (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}`)
1848
1950
  );
1849
1951
  const docs = await Promise.all(
1850
1952
  paths.map(async (path) => {
1851
- const id = path.replace(/\.mdx?$/i, "").split(sep2).join("/").toLowerCase();
1953
+ const id = path.replace(/\.mdx?$/i, "").split(sep3).join("/").toLowerCase();
1852
1954
  const segments = id.split("/");
1853
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)) {
1854
1956
  throw new Error(`${copy_error_doc_id}: ${path}`);
@@ -2042,15 +2144,15 @@ var toErrorMessage = (error) => {
2042
2144
 
2043
2145
  // src/util/font/to-font-css.ts
2044
2146
  import { createHash as createHash2 } from "node:crypto";
2045
- import { readFileSync as readFileSync2 } from "node:fs";
2046
- 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";
2047
2149
  var toFontCss = (options) => {
2048
2150
  const files = /* @__PURE__ */ new Map();
2049
- 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(
2050
2152
  (match) => match[0].replace(/font-display:\s*[^;]+;/g, `font-display: ${font_display_strategy};`).replace(/url\(([^)]+)\)/g, (_match, relativeUrl) => {
2051
- const file = join3(dirname3(options.css), relativeUrl);
2052
- const extension = extname3(file);
2053
- 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);
2054
2156
  const url = `${options.fontDir}/${basename(file, extension)}.${hash}${extension}`;
2055
2157
  files.set(url, file);
2056
2158
  return `url(${url})`;
@@ -2067,10 +2169,10 @@ var main = async () => {
2067
2169
  throw new Error(`${copy_error_unknown_command}: ${command} (${commands.join(", ")})`);
2068
2170
  }
2069
2171
  const kitRoot = fileURLToPath(new URL("../", import.meta.url));
2070
- const docsRoot = resolve3(docsDir);
2172
+ const docsRoot = resolve4(docsDir);
2071
2173
  const outDir = join4(docsRoot, "dist");
2072
2174
  const configFile = join4(docsRoot, cli_config_file_name);
2073
- 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);
2074
2176
  if (!parsedConfig.success) {
2075
2177
  throw new Error(`${copy_error_config}: ${parsedConfig.error.issues.map((issue) => `${issue.path.join(".")} ${issue.message}`).join(", ")}`);
2076
2178
  }
@@ -2080,7 +2182,7 @@ var main = async () => {
2080
2182
  app2.use(
2081
2183
  "/*",
2082
2184
  serveStatic({
2083
- root: relative4(process.cwd(), outDir),
2185
+ root: relative5(process.cwd(), outDir),
2084
2186
  onFound: (_path, c) => {
2085
2187
  if (c.req.path.startsWith(`${asset_font_dir}/`)) {
2086
2188
  c.header("Cache-Control", font_cache_control);
@@ -2096,7 +2198,7 @@ var main = async () => {
2096
2198
  const brand = toFontCss({ css: join4(kitRoot, font_brand_css), fontDir: asset_font_dir });
2097
2199
  const fontCss = [mono.css, body.css, brand.css].join("\n");
2098
2200
  const stylePath = toAssetPath({ path: asset_style_path, file: join4(kitRoot, "dist/cli.css") });
2099
- const browserScript = readFileSync3(join4(kitRoot, "dist/browser.js"), "utf8");
2201
+ const browserScript = readFileSync4(join4(kitRoot, "dist/browser.js"), "utf8");
2100
2202
  const kitFiles = new Map([
2101
2203
  [stylePath, join4(kitRoot, "dist/cli.css")],
2102
2204
  [asset_favicon_path, join4(kitRoot, "src/asset/favicon.svg")],
@@ -2105,7 +2207,8 @@ var main = async () => {
2105
2207
  ...brand.files
2106
2208
  ]);
2107
2209
  const files = new Map(kitFiles);
2108
- const processor = createProcessor({ site: siteConfig, root: docsRoot, files });
2210
+ const snippets = /* @__PURE__ */ new Set();
2211
+ const processor = createProcessor({ site: siteConfig, root: docsRoot, files, snippets });
2109
2212
  const [docs, home] = await Promise.all([
2110
2213
  readDocs({ root: docsRoot, processor, reserved: siteConfig.url === void 0 ? void 0 : crawling_reserved_roots }),
2111
2214
  readHome({ root: docsRoot, processor })
@@ -2128,11 +2231,11 @@ var main = async () => {
2128
2231
  throw crawlerResult.error;
2129
2232
  }
2130
2233
  for (const [url, file] of files) {
2131
- await mkdir(dirname4(join4(outDir, url)), { recursive: true });
2234
+ await mkdir(dirname5(join4(outDir, url)), { recursive: true });
2132
2235
  await copyFile(file, join4(outDir, url));
2133
2236
  }
2134
2237
  console.log(
2135
- `${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)}`
2136
2239
  );
2137
2240
  return;
2138
2241
  }
@@ -2144,7 +2247,8 @@ var main = async () => {
2144
2247
  run: async () => {
2145
2248
  const currentGeneration = generation;
2146
2249
  const nextFiles = new Map(kitFiles);
2147
- 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 });
2148
2252
  const [docs2, home2] = await Promise.all([
2149
2253
  readDocs({ root: docsRoot, processor: nextProcessor, reserved: siteConfig.url === void 0 ? void 0 : crawling_reserved_roots }),
2150
2254
  readHome({ root: docsRoot, processor: nextProcessor })
@@ -2156,6 +2260,10 @@ var main = async () => {
2156
2260
  for (const entry of nextFiles) {
2157
2261
  files.set(...entry);
2158
2262
  }
2263
+ snippets.clear();
2264
+ for (const entry of nextSnippets) {
2265
+ snippets.add(entry);
2266
+ }
2159
2267
  store.docs = docs2;
2160
2268
  store.home = home2;
2161
2269
  reload.emit("change");
@@ -2166,8 +2274,9 @@ var main = async () => {
2166
2274
  );
2167
2275
  const handleDocsChange = (_event, filename) => {
2168
2276
  if (filename !== null) {
2169
- const segments = filename.split(sep3);
2170
- 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())) {
2171
2280
  return;
2172
2281
  }
2173
2282
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "for-humanity",
3
- "version": "0.9.1",
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",
@@ -51,6 +51,7 @@
51
51
  "dependencies": {
52
52
  "@hono/node-server": "^2.1.3",
53
53
  "@shikijs/rehype": "^4.4.3",
54
+ "@shikijs/transformers": "4.4.3",
54
55
  "beautiful-mermaid": "^1.1.3",
55
56
  "clsx": "^2.1.1",
56
57
  "es-toolkit": "^1.52.0",
@@ -176,10 +176,10 @@
176
176
  margin-bottom: 0;
177
177
  }
178
178
 
179
- /* 표 · 코드 상자처럼 바깥 선과 칸 선 · 머리 행은 바탕과 진한 밑선 */
179
+ /* 표 · 코드 상자와 같은 선 색으로 바깥 선과 칸 선 · 머리 행은 바탕 */
180
180
  & table {
181
181
  width: 100%;
182
- border: 1px solid var(--app-color-border-soft);
182
+ border: 1px solid var(--app-color-border);
183
183
  border-collapse: collapse;
184
184
  font-size: var(--app-font-size-dense);
185
185
  font-variant-numeric: tabular-nums;
@@ -187,8 +187,7 @@
187
187
 
188
188
  & th {
189
189
  padding: var(--app-space-inline) var(--app-space-stack);
190
- border: 1px solid var(--app-color-border-soft);
191
- border-bottom-color: var(--app-color-border);
190
+ border: 1px solid var(--app-color-border);
192
191
  background: var(--app-color-code);
193
192
  color: var(--app-color-text-strong);
194
193
  font-weight: var(--app-font-weight-strong);
@@ -199,7 +198,7 @@
199
198
 
200
199
  & td {
201
200
  padding: var(--app-space-inline) var(--app-space-stack);
202
- border: 1px solid var(--app-color-border-soft);
201
+ border: 1px solid var(--app-color-border);
203
202
  text-align: left;
204
203
  vertical-align: top;
205
204
  }
@@ -215,34 +214,71 @@
215
214
  border-top: 1px dotted var(--app-color-border);
216
215
  }
217
216
 
218
- /* 라벨 코드 · 왼쪽 띠와 제목 · 코드 상자의 여백은 figure 가 맡음 · 토큰은 token.css 의 bad·good */
219
- & .wg_prose__codeLabel {
220
- margin: var(--app-space-block) 0;
221
- border-left: 3px solid var(--app-color-bad);
217
+ /* stylelint-disable selector-class-pattern -- Shiki 가 만드는 line·highlighted·diff 클래스 */
218
+
219
+ /* 코드 줄 · 바탕을 상자 가로 전체로 · 표기 없는 줄은 보이는 차이 없음 */
220
+ & pre code .line {
221
+ display: inline-block;
222
+ width: calc(100% + 2 * var(--app-space-block));
223
+ margin: 0 calc(-1 * var(--app-space-block));
224
+ padding: 0 var(--app-space-block);
222
225
  }
223
226
 
224
- & .wg_prose__codeLabel--good {
225
- border-left-color: var(--app-color-good);
227
+ & pre code .line.highlighted {
228
+ background: var(--app-color-accent-soft);
226
229
  }
227
230
 
228
- & .wg_prose__codeLabelTitle {
229
- padding: var(--app-space-inline) var(--app-space-block);
231
+ & pre code .line.highlighted.error {
230
232
  background: var(--app-color-bad-soft);
231
- color: var(--app-color-bad);
232
- font-size: var(--app-font-size-dense);
233
- font-weight: var(--app-font-weight-strong);
234
233
  }
235
234
 
236
- & .wg_prose__codeLabel--good .wg_prose__codeLabelTitle {
235
+ & pre code .line.highlighted.warning {
236
+ background: var(--app-color-warn-soft);
237
+ }
238
+
239
+ & pre code .line.diff.add {
237
240
  background: var(--app-color-good-soft);
238
- color: var(--app-color-good);
239
241
  }
240
242
 
241
- & .wg_prose__codeLabel pre {
242
- margin: 0;
243
- border-left: 0;
244
- border-radius: 0 var(--app-radius-box) var(--app-radius-box) 0;
243
+ & pre code .line.diff.remove {
244
+ background: var(--app-color-bad-soft);
245
+ opacity: 0.8;
246
+ }
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
+
260
+ & pre code .highlighted-word {
261
+ padding: 0 2px;
262
+ border-radius: var(--app-radius-inline);
263
+ background: var(--app-color-accent-soft);
264
+ }
265
+
266
+ /* 줄 번호 · 펜스 메타 line-numbers · 복사에 섞이지 않게 content 로 */
267
+ & pre.wg_prose__lineNumbers code {
268
+ counter-reset: line;
269
+ }
270
+
271
+ & pre.wg_prose__lineNumbers code .line::before {
272
+ display: inline-block;
273
+ width: 2em;
274
+ margin-right: var(--app-space-stack);
275
+ color: var(--app-color-text-muted);
276
+ text-align: right;
277
+ content: counter(line);
278
+ counter-increment: line;
279
+ user-select: none;
245
280
  }
281
+ /* stylelint-enable selector-class-pattern */
246
282
  /* stylelint-enable selector-disallowed-list */
247
283
  }
248
284
 
@@ -354,7 +390,7 @@
354
390
  overflow-x: auto;
355
391
  margin: var(--app-space-block) 0;
356
392
  padding: var(--app-space-block);
357
- border: 1px solid var(--app-color-border-soft);
393
+ border: 1px solid var(--app-color-border);
358
394
  border-radius: var(--app-radius-box);
359
395
 
360
396
  /* 라벨 위치는 자간 0의 격자로 계산 */
@@ -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
+ };
@@ -0,0 +1,55 @@
1
+ import {
2
+ transformerMetaHighlight,
3
+ transformerNotationDiff,
4
+ transformerNotationErrorLevel,
5
+ transformerNotationHighlight,
6
+ transformerNotationWordHighlight,
7
+ } from "@shikijs/transformers";
8
+ import type {ShikiTransformer} from "shiki";
9
+
10
+ /**
11
+ * 펜스 메타에 `line-numbers` 가 있으면 pre 에 줄 번호 클래스 · 번호는 CSS counter
12
+ */
13
+ const lineNumbers: ShikiTransformer = {
14
+ name: "for-humanity:line-numbers",
15
+ pre(node) {
16
+ const meta = this.options.meta?.__raw;
17
+
18
+ if (typeof meta === "string" && /\bline-numbers\b/.test(meta)) {
19
+ this.addClassToHast(node, "wg_prose__lineNumbers");
20
+ }
21
+ },
22
+ };
23
+
24
+ /**
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`
46
+ */
47
+ export const shikiTransformers: ShikiTransformer[] = [
48
+ transformerNotationHighlight({matchAlgorithm: "v3"}),
49
+ transformerNotationErrorLevel({matchAlgorithm: "v3"}),
50
+ transformerNotationDiff({matchAlgorithm: "v3"}),
51
+ transformerNotationWordHighlight({matchAlgorithm: "v3"}),
52
+ transformerMetaHighlight(),
53
+ lineNumbers,
54
+ tone,
55
+ ];
@@ -13,7 +13,6 @@ import {rehypeHead} from "@/component/widget/prose/_function/rehype-head";
13
13
  import {rehypeSections} from "@/component/widget/prose/_function/rehype-sections/rehype-sections";
14
14
  import {rehypeTables} from "@/component/widget/prose/_function/rehype-tables";
15
15
  import {remarkBlocks} from "@/component/widget/prose/_function/remark-blocks";
16
- import {remarkCodeLabels} from "@/component/widget/prose/_function/remark-code-labels";
17
16
  import {remarkFlow} from "@/component/widget/prose/_function/remark-flow";
18
17
  import {remarkLinks} from "@/component/widget/prose/_function/remark-links";
19
18
  import {remarkParts} from "@/component/widget/prose/_function/remark-parts";
@@ -23,31 +22,33 @@ import {remarkUnknownDirectives} from "@/component/widget/prose/_function/remark
23
22
  import {rehypeAssets} from "@/entry/cli/_function/create-processor/_rehype-assets";
24
23
  import {rehypeHeadingIds} from "@/entry/cli/_function/create-processor/_rehype-heading-ids";
25
24
  import {remarkReport} from "@/entry/cli/_function/create-processor/_remark-report";
25
+ import {remarkSnippets} from "@/entry/cli/_function/create-processor/_remark-snippets";
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(부품·흐름도·코드 라벨) → 코드 강조 → 머리 → 제목 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)
41
43
  .use(remarkParts)
42
44
  .use(remarkFlow)
43
- .use(remarkCodeLabels)
44
45
  .use(remarkStatus, {status: options.site.status})
45
46
  .use(remarkSwatch)
46
47
  .use(remarkLinks, {root: options.root})
47
48
  .use(remarkUnknownDirectives)
48
49
  .use(remarkReport, {root: options.root})
49
50
  .use(remarkRehype, {allowDangerousHtml: true})
50
- .use(rehypeShiki, {theme: createCssVariablesTheme({name: "for-humanity", variablePrefix: "--app-code-"})})
51
+ .use(rehypeShiki, {theme: createCssVariablesTheme({name: "for-humanity", variablePrefix: "--app-code-"}), transformers: shikiTransformers})
51
52
  .use(rehypeHead)
52
53
  .use(rehypeHeadingIds)
53
54
  .use(rehypeSections)
@@ -73,25 +73,78 @@ test("site port defaults to 4321 and accepts only integers in the TCP range", ()
73
73
  }
74
74
  });
75
75
 
76
- test("code fences labelled bad or good render a captioned figure and keep highlighting", async () => {
77
- const root = await mkdtemp(join(tmpdir(), "for-humanity-code-labels-"));
76
+ test("code notation marks lines, meta highlights lines, and line-numbers tags the block", async () => {
77
+ const root = await mkdtemp(join(tmpdir(), "for-humanity-code-notation-"));
78
78
 
79
79
  try {
80
80
  await writeFile(
81
81
  join(root, "guide.md"),
82
- "---\nname: Guide\nlabel: Guide\ngroup: Guide\n---\n\n## Read\n\n```ts bad 리터럴 <폴백>\nconst a = 1;\n```\n\n```ts good\nconst b = 2;\n```\n\n```ts other meta\nconst c = 3;\n```\n",
82
+ "---\nname: Guide\nlabel: Guide\ngroup: Guide\n---\n\n## Read\n\n```ts line-numbers\nconst a = 1; // [!code error]\nconst b = 2; // [!code ++]\nconst c = 3;\n```\n\n```txt\n# [!code highlight]\nfirst\nsecond\n```\n\n```css {2}\n.a {\n color: red;\n}\n```\n",
83
83
  );
84
84
  const site = siteConfigSchema.parse({});
85
85
  const [doc] = await readDocs({root, processor: createProcessor({site, root})});
86
86
 
87
87
  assert.ok(doc);
88
- assert.match(
89
- doc.html,
90
- /<figure class="wg_prose__codeLabel wg_prose__codeLabel--bad"><figcaption class="wg_prose__codeLabelTitle">리터럴 &#x3C;폴백><\/figcaption>\s*<pre class="shiki/,
88
+ assert.match(doc.html, /<pre class="[^"]*wg_prose__lineNumbers[^"]*"/);
89
+ assert.match(doc.html, /<span class="line highlighted error">/);
90
+ assert.match(doc.html, /<span class="line diff add">/);
91
+ assert.match(doc.html, /<span class="line highlighted">[^\n]*first/);
92
+ assert.equal(doc.html.includes("[!code"), false, "notation comments are removed from the output");
93
+ assert.equal(doc.html.split('<span class="line highlighted">').length - 1, 2, "a standalone txt notation line and the css {2} meta each highlight one line");
94
+ } finally {
95
+ await rm(root, {recursive: true, force: true});
96
+ }
97
+ });
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],
91
124
  );
92
- assert.match(doc.html, /<figure class="wg_prose__codeLabel wg_prose__codeLabel--good">\s*<pre class="shiki/);
93
- assert.equal(doc.html.split("<figure").length - 1, 2, "other meta stays a plain code block");
94
- assert.equal(doc.html.split("</figure>").length - 1, 2);
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
+ }
95
148
  } finally {
96
149
  await rm(root, {recursive: true, force: true});
97
150
  }
@@ -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,15 @@
35
35
  --app-color-accent: light-dark(#555, #b5b5b5);
36
36
  --app-color-accent-soft: light-dark(#f5f5f2, #222);
37
37
 
38
- /* 라벨 코드 · bad·good 띠와 제목 · soft 제목 바탕 */
38
+ /* 코드 판정 색 · soft 는 블록 바탕(메타 bad·good), mark 는 그 위의 줄 표기 · warn 은 줄 표기만 */
39
39
  --app-color-bad: light-dark(#b2412e, #ff9b8a);
40
- --app-color-bad-soft: light-dark(#fbeeec, #3a2522);
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(#eaf5ee, #1f3327);
43
+ --app-color-good-soft: light-dark(#e4f3ed, #1c342c);
44
+ --app-color-good-mark: light-dark(#c4e4d4, #27503f);
45
+ --app-color-warn: light-dark(#8a6400, #e6c46a);
46
+ --app-color-warn-soft: light-dark(#fbf3d9, #3d3418);
43
47
 
44
48
  /* 링크 · 기본·방문·hover */
45
49
  --app-color-link: light-dark(#11a, #9db8ff);
@@ -172,11 +176,11 @@
172
176
  }
173
177
  }
174
178
 
175
- /* 라벨 코드 · Shiki 가 pre 에 인라인으로 쓰는 코드 바탕을 figure 색으로 바꿈 */
176
- .wg_prose__codeLabel--bad {
179
+ /* 블록 색 · 펜스 메타 bad·good · Shiki 가 pre 에 인라인으로 쓰는 코드 바탕을 바꿈 */
180
+ .wg_prose__code--bad {
177
181
  --app-code-background: var(--app-color-bad-soft);
178
182
  }
179
183
 
180
- .wg_prose__codeLabel--good {
184
+ .wg_prose__code--good {
181
185
  --app-code-background: var(--app-color-good-soft);
182
186
  }
@@ -1,47 +0,0 @@
1
- import type {Root} from "mdast";
2
- import {visit} from "unist-util-visit";
3
-
4
- /**
5
- * 펜스 메타의 라벨 · `bad 제목`, `good 제목` · 제목은 선택
6
- */
7
- const code_label_pattern = /^(bad|good)(?:\s+(.+?))?\s*$/;
8
-
9
- /**
10
- * figure 여는 조각, 코드, 닫는 조각
11
- */
12
- const inserted_node_count = 3;
13
-
14
- /**
15
- * 제목을 HTML 문자열에 넣기 위한 escape
16
- */
17
- const escapeHtml = (text: string) => text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
18
-
19
- /**
20
- * 코드 펜스 메타 `bad`·`good` → 색 띠와 제목이 붙은 figure · 다른 메타는 그대로 둠
21
- * 코드 노드는 유지해 Shiki 강조를 받고, figure 조각은 마지막 rehype-raw에서 합쳐짐
22
- */
23
- export const remarkCodeLabels = () => (tree: Root) => {
24
- visit(tree, "code", (node, index, parent) => {
25
- if (typeof node.meta !== "string" || parent === undefined || index === undefined) {
26
- return;
27
- }
28
-
29
- const match = code_label_pattern.exec(node.meta);
30
-
31
- if (match === null) {
32
- return;
33
- }
34
-
35
- const [, tone, title] = match;
36
- const caption = title === undefined ? "" : `<figcaption class="wg_prose__codeLabelTitle">${escapeHtml(title)}</figcaption>`;
37
-
38
- node.meta = null;
39
- parent.children.splice(index, 1, {type: "html", value: `<figure class="wg_prose__codeLabel wg_prose__codeLabel--${tone}">${caption}`}, node, {
40
- type: "html",
41
- value: "</figure>",
42
- });
43
-
44
- // 끼워 넣은 세 노드 다음부터 계속
45
- return index + inserted_node_count;
46
- });
47
- };