for-humanity 0.10.0 → 0.12.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 +9 -0
- package/README.md +3 -3
- package/dist/cli.css +46 -2
- package/dist/cli.js +159 -23
- package/package.json +1 -1
- package/src/component/widget/prose/wg-prose.css +42 -0
- package/src/constant/copy.ts +5 -0
- package/src/entry/cli/_constant/snippet.ts +21 -0
- package/src/entry/cli/_function/create-processor/_remark-snippets.ts +117 -0
- package/src/entry/cli/_function/create-processor/_shiki-transformers.ts +58 -1
- package/src/entry/cli/_function/create-processor/create-processor.ts +4 -2
- package/src/entry/cli/cli.test.ts +76 -0
- package/src/entry/cli/cli.ts +15 -4
- package/src/style/token.css +16 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.12.0 — 2026-10-13
|
|
6
|
+
|
|
7
|
+
- Add a `[file name]` fence meta that draws a title bar with the file name and language above the code block, sharing the `bad`/`good` tone.
|
|
8
|
+
|
|
9
|
+
## 0.11.0 — 2026-10-13
|
|
10
|
+
|
|
11
|
+
- 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.
|
|
12
|
+
- Add `bad` and `good` fence meta that tint the whole code block, with line notation drawn one step darker on top.
|
|
13
|
+
|
|
5
14
|
## 0.10.0 — 2026-10-13
|
|
6
15
|
|
|
7
16
|
- 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.
|
|
36
|
+
pnpm add -D --save-exact for-humanity@0.12.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.
|
|
55
|
+
Version `0.12.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.
|
|
78
|
+
Changes pushed to `main` update the documentation site. A stable version tag such as `v0.12.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,11 +189,45 @@
|
|
|
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);
|
|
195
204
|
background: var(--app-color-accent-soft);
|
|
196
205
|
}
|
|
206
|
+
& .wg_prose__codeFile {
|
|
207
|
+
margin: var(--app-space-block) 0;
|
|
208
|
+
}
|
|
209
|
+
& .wg_prose__codeFile pre {
|
|
210
|
+
margin: 0;
|
|
211
|
+
border-top-left-radius: 0;
|
|
212
|
+
border-top-right-radius: 0;
|
|
213
|
+
}
|
|
214
|
+
& .wg_prose__codeFileTitle {
|
|
215
|
+
display: flex;
|
|
216
|
+
gap: var(--app-space-block);
|
|
217
|
+
justify-content: space-between;
|
|
218
|
+
padding: var(--app-space-inline) var(--app-space-block);
|
|
219
|
+
border: 1px solid var(--app-color-border);
|
|
220
|
+
border-bottom: 0;
|
|
221
|
+
border-radius: var(--app-radius-box) var(--app-radius-box) 0 0;
|
|
222
|
+
background: var(--app-code-background);
|
|
223
|
+
color: var(--app-color-text-muted);
|
|
224
|
+
font-family: var(--app-font-mono);
|
|
225
|
+
font-size: var(--app-font-size-dense);
|
|
226
|
+
letter-spacing: 0;
|
|
227
|
+
}
|
|
228
|
+
& .wg_prose__codeFileLang {
|
|
229
|
+
text-transform: uppercase;
|
|
230
|
+
}
|
|
197
231
|
& pre.wg_prose__lineNumbers code {
|
|
198
232
|
counter-reset: line;
|
|
199
233
|
}
|
|
@@ -779,9 +813,11 @@
|
|
|
779
813
|
--app-color-accent: light-dark(#555, #b5b5b5);
|
|
780
814
|
--app-color-accent-soft: light-dark(#f5f5f2, #222);
|
|
781
815
|
--app-color-bad: light-dark(#b2412e, #ff9b8a);
|
|
782
|
-
--app-color-bad-soft: light-dark(#
|
|
816
|
+
--app-color-bad-soft: light-dark(#f9ebe8, #3a2522);
|
|
817
|
+
--app-color-bad-mark: light-dark(#f1cdc6, #5b322c);
|
|
783
818
|
--app-color-good: light-dark(#2f7a47, #8bd3a3);
|
|
784
|
-
--app-color-good-soft: light-dark(#
|
|
819
|
+
--app-color-good-soft: light-dark(#e4f3ed, #1c342c);
|
|
820
|
+
--app-color-good-mark: light-dark(#c4e4d4, #27503f);
|
|
785
821
|
--app-color-warn: light-dark(#8a6400, #e6c46a);
|
|
786
822
|
--app-color-warn-soft: light-dark(#fbf3d9, #3d3418);
|
|
787
823
|
--app-color-link: light-dark(#11a, #9db8ff);
|
|
@@ -875,6 +911,14 @@
|
|
|
875
911
|
--app-space-gutter: 16px;
|
|
876
912
|
}
|
|
877
913
|
}
|
|
914
|
+
.wg_prose__code--bad,
|
|
915
|
+
.wg_prose__codeFile--bad {
|
|
916
|
+
--app-code-background: var(--app-color-bad-soft);
|
|
917
|
+
}
|
|
918
|
+
.wg_prose__code--good,
|
|
919
|
+
.wg_prose__codeFile--good {
|
|
920
|
+
--app-code-background: var(--app-color-good-soft);
|
|
921
|
+
}
|
|
878
922
|
|
|
879
923
|
/* src/component/widget/shell/wg-shell.css */
|
|
880
924
|
.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
|
|
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
|
|
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,66 @@ 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
|
+
};
|
|
1883
|
+
var fileTitle = {
|
|
1884
|
+
name: "for-humanity:code-title",
|
|
1885
|
+
root(root) {
|
|
1886
|
+
const meta = this.options.meta?.__raw;
|
|
1887
|
+
const title = typeof meta === "string" ? /\[([^\]]+)\]/.exec(meta)?.[1] : void 0;
|
|
1888
|
+
if (title === void 0) {
|
|
1889
|
+
return;
|
|
1890
|
+
}
|
|
1891
|
+
const tones = typeof meta === "string" ? ["bad", "good"].filter((word) => new RegExp(`\\b${word}\\b`).test(meta)) : [];
|
|
1892
|
+
const caption = {
|
|
1893
|
+
type: "element",
|
|
1894
|
+
tagName: "figcaption",
|
|
1895
|
+
properties: { className: ["wg_prose__codeFileTitle"] },
|
|
1896
|
+
children: [
|
|
1897
|
+
{ type: "text", value: title },
|
|
1898
|
+
{ type: "element", tagName: "span", properties: { className: ["wg_prose__codeFileLang"] }, children: [{ type: "text", value: this.options.lang }] }
|
|
1899
|
+
]
|
|
1900
|
+
};
|
|
1901
|
+
const figure = {
|
|
1902
|
+
type: "element",
|
|
1903
|
+
tagName: "figure",
|
|
1904
|
+
properties: { className: ["wg_prose__codeFile", ...tones.map((tone2) => `wg_prose__codeFile--${tone2}`)] },
|
|
1905
|
+
children: [caption, ...root.children.filter((child) => child.type !== "doctype")]
|
|
1906
|
+
};
|
|
1907
|
+
root.children = [figure];
|
|
1908
|
+
}
|
|
1909
|
+
};
|
|
1783
1910
|
var shikiTransformers = [
|
|
1784
1911
|
transformerNotationHighlight({ matchAlgorithm: "v3" }),
|
|
1785
1912
|
transformerNotationErrorLevel({ matchAlgorithm: "v3" }),
|
|
1786
1913
|
transformerNotationDiff({ matchAlgorithm: "v3" }),
|
|
1787
1914
|
transformerNotationWordHighlight({ matchAlgorithm: "v3" }),
|
|
1788
1915
|
transformerMetaHighlight(),
|
|
1789
|
-
lineNumbers
|
|
1916
|
+
lineNumbers,
|
|
1917
|
+
tone,
|
|
1918
|
+
fileTitle
|
|
1790
1919
|
];
|
|
1791
1920
|
|
|
1792
1921
|
// src/entry/cli/_function/create-processor/create-processor.ts
|
|
1793
1922
|
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 });
|
|
1923
|
+
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
1924
|
};
|
|
1796
1925
|
|
|
1797
1926
|
// src/entry/cli/_function/read-docs.ts
|
|
1798
1927
|
import { readdir, readFile as readFile2 } from "node:fs/promises";
|
|
1799
|
-
import { join, posix as posix3, sep as
|
|
1928
|
+
import { join, posix as posix3, sep as sep3 } from "node:path";
|
|
1800
1929
|
import { VFile } from "vfile";
|
|
1801
1930
|
import { parse as parseYaml } from "yaml";
|
|
1802
1931
|
|
|
@@ -1845,11 +1974,11 @@ var docDataSchema = z2.object({
|
|
|
1845
1974
|
// src/entry/cli/_function/read-docs.ts
|
|
1846
1975
|
var readDocs = async (options) => {
|
|
1847
1976
|
const paths = (await readdir(options.root, { recursive: true })).filter(
|
|
1848
|
-
(path) => /\.mdx?$/i.test(path) && !/^(readme|agents|claude)\.md$/i.test(path) && !path.split(
|
|
1977
|
+
(path) => /\.mdx?$/i.test(path) && !/^(readme|agents|claude)\.md$/i.test(path) && !path.split(sep3).includes("node_modules") && !path.startsWith(`dist${sep3}`)
|
|
1849
1978
|
);
|
|
1850
1979
|
const docs = await Promise.all(
|
|
1851
1980
|
paths.map(async (path) => {
|
|
1852
|
-
const id = path.replace(/\.mdx?$/i, "").split(
|
|
1981
|
+
const id = path.replace(/\.mdx?$/i, "").split(sep3).join("/").toLowerCase();
|
|
1853
1982
|
const segments = id.split("/");
|
|
1854
1983
|
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
1984
|
throw new Error(`${copy_error_doc_id}: ${path}`);
|
|
@@ -2043,15 +2172,15 @@ var toErrorMessage = (error) => {
|
|
|
2043
2172
|
|
|
2044
2173
|
// src/util/font/to-font-css.ts
|
|
2045
2174
|
import { createHash as createHash2 } from "node:crypto";
|
|
2046
|
-
import { readFileSync as
|
|
2047
|
-
import { basename, dirname as
|
|
2175
|
+
import { readFileSync as readFileSync3 } from "node:fs";
|
|
2176
|
+
import { basename, dirname as dirname4, extname as extname4, join as join3 } from "node:path";
|
|
2048
2177
|
var toFontCss = (options) => {
|
|
2049
2178
|
const files = /* @__PURE__ */ new Map();
|
|
2050
|
-
const css = [...
|
|
2179
|
+
const css = [...readFileSync3(options.css, "utf8").matchAll(/@font-face\s*\{[^}]*\}/g)].map(
|
|
2051
2180
|
(match) => match[0].replace(/font-display:\s*[^;]+;/g, `font-display: ${font_display_strategy};`).replace(/url\(([^)]+)\)/g, (_match, relativeUrl) => {
|
|
2052
|
-
const file = join3(
|
|
2053
|
-
const extension =
|
|
2054
|
-
const hash = createHash2("sha256").update(
|
|
2181
|
+
const file = join3(dirname4(options.css), relativeUrl);
|
|
2182
|
+
const extension = extname4(file);
|
|
2183
|
+
const hash = createHash2("sha256").update(readFileSync3(file)).digest("hex").slice(0, font_hash_length);
|
|
2055
2184
|
const url = `${options.fontDir}/${basename(file, extension)}.${hash}${extension}`;
|
|
2056
2185
|
files.set(url, file);
|
|
2057
2186
|
return `url(${url})`;
|
|
@@ -2068,10 +2197,10 @@ var main = async () => {
|
|
|
2068
2197
|
throw new Error(`${copy_error_unknown_command}: ${command} (${commands.join(", ")})`);
|
|
2069
2198
|
}
|
|
2070
2199
|
const kitRoot = fileURLToPath(new URL("../", import.meta.url));
|
|
2071
|
-
const docsRoot =
|
|
2200
|
+
const docsRoot = resolve4(docsDir);
|
|
2072
2201
|
const outDir = join4(docsRoot, "dist");
|
|
2073
2202
|
const configFile = join4(docsRoot, cli_config_file_name);
|
|
2074
|
-
const parsedConfig = siteConfigSchema.safeParse(
|
|
2203
|
+
const parsedConfig = siteConfigSchema.safeParse(existsSync4(configFile) ? (await import(pathToFileURL(configFile).href)).default : site_config_absent);
|
|
2075
2204
|
if (!parsedConfig.success) {
|
|
2076
2205
|
throw new Error(`${copy_error_config}: ${parsedConfig.error.issues.map((issue) => `${issue.path.join(".")} ${issue.message}`).join(", ")}`);
|
|
2077
2206
|
}
|
|
@@ -2081,7 +2210,7 @@ var main = async () => {
|
|
|
2081
2210
|
app2.use(
|
|
2082
2211
|
"/*",
|
|
2083
2212
|
serveStatic({
|
|
2084
|
-
root:
|
|
2213
|
+
root: relative5(process.cwd(), outDir),
|
|
2085
2214
|
onFound: (_path, c) => {
|
|
2086
2215
|
if (c.req.path.startsWith(`${asset_font_dir}/`)) {
|
|
2087
2216
|
c.header("Cache-Control", font_cache_control);
|
|
@@ -2097,7 +2226,7 @@ var main = async () => {
|
|
|
2097
2226
|
const brand = toFontCss({ css: join4(kitRoot, font_brand_css), fontDir: asset_font_dir });
|
|
2098
2227
|
const fontCss = [mono.css, body.css, brand.css].join("\n");
|
|
2099
2228
|
const stylePath = toAssetPath({ path: asset_style_path, file: join4(kitRoot, "dist/cli.css") });
|
|
2100
|
-
const browserScript =
|
|
2229
|
+
const browserScript = readFileSync4(join4(kitRoot, "dist/browser.js"), "utf8");
|
|
2101
2230
|
const kitFiles = new Map([
|
|
2102
2231
|
[stylePath, join4(kitRoot, "dist/cli.css")],
|
|
2103
2232
|
[asset_favicon_path, join4(kitRoot, "src/asset/favicon.svg")],
|
|
@@ -2106,7 +2235,8 @@ var main = async () => {
|
|
|
2106
2235
|
...brand.files
|
|
2107
2236
|
]);
|
|
2108
2237
|
const files = new Map(kitFiles);
|
|
2109
|
-
const
|
|
2238
|
+
const snippets = /* @__PURE__ */ new Set();
|
|
2239
|
+
const processor = createProcessor({ site: siteConfig, root: docsRoot, files, snippets });
|
|
2110
2240
|
const [docs, home] = await Promise.all([
|
|
2111
2241
|
readDocs({ root: docsRoot, processor, reserved: siteConfig.url === void 0 ? void 0 : crawling_reserved_roots }),
|
|
2112
2242
|
readHome({ root: docsRoot, processor })
|
|
@@ -2129,11 +2259,11 @@ var main = async () => {
|
|
|
2129
2259
|
throw crawlerResult.error;
|
|
2130
2260
|
}
|
|
2131
2261
|
for (const [url, file] of files) {
|
|
2132
|
-
await mkdir(
|
|
2262
|
+
await mkdir(dirname5(join4(outDir, url)), { recursive: true });
|
|
2133
2263
|
await copyFile(file, join4(outDir, url));
|
|
2134
2264
|
}
|
|
2135
2265
|
console.log(
|
|
2136
|
-
`${crawlerResult === void 0 ? result.files.length : result.files.length + crawlerResult.files.length} files, ${files.size} assets \u2192 ${
|
|
2266
|
+
`${crawlerResult === void 0 ? result.files.length : result.files.length + crawlerResult.files.length} files, ${files.size} assets \u2192 ${relative5(process.cwd(), outDir)}`
|
|
2137
2267
|
);
|
|
2138
2268
|
return;
|
|
2139
2269
|
}
|
|
@@ -2145,7 +2275,8 @@ var main = async () => {
|
|
|
2145
2275
|
run: async () => {
|
|
2146
2276
|
const currentGeneration = generation;
|
|
2147
2277
|
const nextFiles = new Map(kitFiles);
|
|
2148
|
-
const
|
|
2278
|
+
const nextSnippets = /* @__PURE__ */ new Set();
|
|
2279
|
+
const nextProcessor = createProcessor({ site: siteConfig, root: docsRoot, files: nextFiles, snippets: nextSnippets });
|
|
2149
2280
|
const [docs2, home2] = await Promise.all([
|
|
2150
2281
|
readDocs({ root: docsRoot, processor: nextProcessor, reserved: siteConfig.url === void 0 ? void 0 : crawling_reserved_roots }),
|
|
2151
2282
|
readHome({ root: docsRoot, processor: nextProcessor })
|
|
@@ -2157,6 +2288,10 @@ var main = async () => {
|
|
|
2157
2288
|
for (const entry of nextFiles) {
|
|
2158
2289
|
files.set(...entry);
|
|
2159
2290
|
}
|
|
2291
|
+
snippets.clear();
|
|
2292
|
+
for (const entry of nextSnippets) {
|
|
2293
|
+
snippets.add(entry);
|
|
2294
|
+
}
|
|
2160
2295
|
store.docs = docs2;
|
|
2161
2296
|
store.home = home2;
|
|
2162
2297
|
reload.emit("change");
|
|
@@ -2167,8 +2302,9 @@ var main = async () => {
|
|
|
2167
2302
|
);
|
|
2168
2303
|
const handleDocsChange = (_event, filename) => {
|
|
2169
2304
|
if (filename !== null) {
|
|
2170
|
-
const segments = filename.split(
|
|
2171
|
-
|
|
2305
|
+
const segments = filename.split(sep4);
|
|
2306
|
+
const isSnippet = snippets.has(join4(docsRoot, filename));
|
|
2307
|
+
if (segments.includes("dist") || segments.includes("node_modules") || !isSnippet && !/\.mdx?$/i.test(filename) && !asset_media_extensions.has(extname5(filename).toLowerCase())) {
|
|
2172
2308
|
return;
|
|
2173
2309
|
}
|
|
2174
2310
|
}
|
package/package.json
CHANGED
|
@@ -245,12 +245,54 @@
|
|
|
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);
|
|
251
263
|
background: var(--app-color-accent-soft);
|
|
252
264
|
}
|
|
253
265
|
|
|
266
|
+
/* 파일 이름 띠 · 펜스 메타 [파일 이름] · 코드 상자와 한 몸, 여백은 figure 가 맡음 */
|
|
267
|
+
& .wg_prose__codeFile {
|
|
268
|
+
margin: var(--app-space-block) 0;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
& .wg_prose__codeFile pre {
|
|
272
|
+
margin: 0;
|
|
273
|
+
border-top-left-radius: 0;
|
|
274
|
+
border-top-right-radius: 0;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
& .wg_prose__codeFileTitle {
|
|
278
|
+
display: flex;
|
|
279
|
+
gap: var(--app-space-block);
|
|
280
|
+
justify-content: space-between;
|
|
281
|
+
padding: var(--app-space-inline) var(--app-space-block);
|
|
282
|
+
border: 1px solid var(--app-color-border);
|
|
283
|
+
border-bottom: 0;
|
|
284
|
+
border-radius: var(--app-radius-box) var(--app-radius-box) 0 0;
|
|
285
|
+
background: var(--app-code-background);
|
|
286
|
+
color: var(--app-color-text-muted);
|
|
287
|
+
font-family: var(--app-font-mono);
|
|
288
|
+
font-size: var(--app-font-size-dense);
|
|
289
|
+
letter-spacing: 0;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
& .wg_prose__codeFileLang {
|
|
293
|
+
text-transform: uppercase;
|
|
294
|
+
}
|
|
295
|
+
|
|
254
296
|
/* 줄 번호 · 펜스 메타 line-numbers · 복사에 섞이지 않게 content 로 */
|
|
255
297
|
& pre.wg_prose__lineNumbers code {
|
|
256
298
|
counter-reset: line;
|
package/src/constant/copy.ts
CHANGED
|
@@ -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
|
+
};
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
transformerNotationHighlight,
|
|
6
6
|
transformerNotationWordHighlight,
|
|
7
7
|
} from "@shikijs/transformers";
|
|
8
|
+
import type {Element, ElementContent} from "hast";
|
|
8
9
|
import type {ShikiTransformer} from "shiki";
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -22,7 +23,61 @@ const lineNumbers: ShikiTransformer = {
|
|
|
22
23
|
};
|
|
23
24
|
|
|
24
25
|
/**
|
|
25
|
-
*
|
|
26
|
+
* 펜스 메타 `bad`·`good` → pre 에 블록 색 클래스 · 바탕색은 token.css 가 바꿈
|
|
27
|
+
*/
|
|
28
|
+
const tone: ShikiTransformer = {
|
|
29
|
+
name: "for-humanity:code-tone",
|
|
30
|
+
pre(node) {
|
|
31
|
+
const meta = this.options.meta?.__raw;
|
|
32
|
+
|
|
33
|
+
if (typeof meta !== "string") {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
for (const word of ["bad", "good"]) {
|
|
38
|
+
if (new RegExp(`\\b${word}\\b`).test(meta)) {
|
|
39
|
+
this.addClassToHast(node, `wg_prose__code--${word}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 펜스 메타 `[파일 이름]` → figure 로 감싸고 제목 띠에 파일 이름과 언어 · 블록 색 클래스는 figure 에도 둠
|
|
47
|
+
*/
|
|
48
|
+
const fileTitle: ShikiTransformer = {
|
|
49
|
+
name: "for-humanity:code-title",
|
|
50
|
+
root(root) {
|
|
51
|
+
const meta = this.options.meta?.__raw;
|
|
52
|
+
const title = typeof meta === "string" ? /\[([^\]]+)\]/.exec(meta)?.[1] : undefined;
|
|
53
|
+
|
|
54
|
+
if (title === undefined) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const tones = typeof meta === "string" ? ["bad", "good"].filter((word) => new RegExp(`\\b${word}\\b`).test(meta)) : [];
|
|
59
|
+
const caption: Element = {
|
|
60
|
+
type: "element",
|
|
61
|
+
tagName: "figcaption",
|
|
62
|
+
properties: {className: ["wg_prose__codeFileTitle"]},
|
|
63
|
+
children: [
|
|
64
|
+
{type: "text", value: title},
|
|
65
|
+
{type: "element", tagName: "span", properties: {className: ["wg_prose__codeFileLang"]}, children: [{type: "text", value: this.options.lang}]},
|
|
66
|
+
],
|
|
67
|
+
};
|
|
68
|
+
const figure: Element = {
|
|
69
|
+
type: "element",
|
|
70
|
+
tagName: "figure",
|
|
71
|
+
properties: {className: ["wg_prose__codeFile", ...tones.map((tone) => `wg_prose__codeFile--${tone}`)]},
|
|
72
|
+
children: [caption, ...root.children.filter((child): child is ElementContent => child.type !== "doctype")],
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
root.children = [figure];
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 코드 줄 표기 · `// [!code highlight|error|warning|++|--]`, `// [!code word:x]`, 메타 `{2,4-6}`, `line-numbers`, 블록 색 `bad`·`good`, 제목 `[파일 이름]`
|
|
26
81
|
*/
|
|
27
82
|
export const shikiTransformers: ShikiTransformer[] = [
|
|
28
83
|
transformerNotationHighlight({matchAlgorithm: "v3"}),
|
|
@@ -31,4 +86,6 @@ export const shikiTransformers: ShikiTransformer[] = [
|
|
|
31
86
|
transformerNotationWordHighlight({matchAlgorithm: "v3"}),
|
|
32
87
|
transformerMetaHighlight(),
|
|
33
88
|
lineNumbers,
|
|
89
|
+
tone,
|
|
90
|
+
fileTitle,
|
|
34
91
|
];
|
|
@@ -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(
|
|
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,82 @@ test("code notation marks lines, meta highlights lines, and line-numbers tags th
|
|
|
96
96
|
}
|
|
97
97
|
});
|
|
98
98
|
|
|
99
|
+
test("a bracketed fence meta renders a file title bar that carries the block tone", async () => {
|
|
100
|
+
const root = await mkdtemp(join(tmpdir(), "for-humanity-code-title-"));
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
await writeFile(
|
|
104
|
+
join(root, "guide.md"),
|
|
105
|
+
"---\nname: Guide\nlabel: Guide\ngroup: Guide\n---\n\n## Read\n\n```ts bad [page/products.ts]\nconst a = 1;\n```\n\n```ts {1}\nconst b = 2;\n```\n",
|
|
106
|
+
);
|
|
107
|
+
const site = siteConfigSchema.parse({});
|
|
108
|
+
const [doc] = await readDocs({root, processor: createProcessor({site, root})});
|
|
109
|
+
|
|
110
|
+
assert.ok(doc);
|
|
111
|
+
assert.match(
|
|
112
|
+
doc.html,
|
|
113
|
+
/<figure class="wg_prose__codeFile wg_prose__codeFile--bad"><figcaption class="wg_prose__codeFileTitle">page\/products\.ts<span class="wg_prose__codeFileLang">ts<\/span><\/figcaption><pre class="[^"]*wg_prose__code--bad/,
|
|
114
|
+
);
|
|
115
|
+
assert.equal(doc.html.split("<figure").length - 1, 1, "braces meta is not a title");
|
|
116
|
+
} finally {
|
|
117
|
+
await rm(root, {recursive: true, force: true});
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("code snippets import a file or a region from inside the document root and pass fence meta", async () => {
|
|
122
|
+
const root = await mkdtemp(join(tmpdir(), "for-humanity-snippets-"));
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
await mkdir(join(root, "snippet"));
|
|
126
|
+
await writeFile(
|
|
127
|
+
join(root, "snippet", "example.ts"),
|
|
128
|
+
"const outside = 0;\n// #region picked\n const a = 1; // [!code error]\n const b = 2;\n// #endregion picked\nconst after = 3;\n",
|
|
129
|
+
);
|
|
130
|
+
await writeFile(
|
|
131
|
+
join(root, "guide.md"),
|
|
132
|
+
"---\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",
|
|
133
|
+
);
|
|
134
|
+
const site = siteConfigSchema.parse({});
|
|
135
|
+
const snippets = new Set<string>();
|
|
136
|
+
const [doc] = await readDocs({root, processor: createProcessor({site, root, snippets})});
|
|
137
|
+
|
|
138
|
+
assert.ok(doc);
|
|
139
|
+
assert.match(doc.html, /<pre class="[^"]*wg_prose__lineNumbers[^"]*wg_prose__code--bad[^"]*"|<pre class="[^"]*wg_prose__code--bad[^"]*wg_prose__lineNumbers[^"]*"/);
|
|
140
|
+
assert.match(doc.html, /<span class="line highlighted error">[^\n]*const<\/span>[^\n]* a/);
|
|
141
|
+
assert.equal(doc.html.includes("#region"), false, "region marker lines are removed");
|
|
142
|
+
assert.equal(doc.html.split("const</span>").length - 1, 2 + 4, "the region block has two lines and the whole-file block four");
|
|
143
|
+
assert.deepEqual(
|
|
144
|
+
[...snippets].map((entry) => entry.endsWith(join("snippet", "example.ts"))),
|
|
145
|
+
[true],
|
|
146
|
+
);
|
|
147
|
+
} finally {
|
|
148
|
+
await rm(root, {recursive: true, force: true});
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("code snippets reject files outside the document root, missing files and missing regions", async () => {
|
|
153
|
+
const root = await mkdtemp(join(tmpdir(), "for-humanity-snippets-reject-"));
|
|
154
|
+
|
|
155
|
+
try {
|
|
156
|
+
await mkdir(join(root, "docs"));
|
|
157
|
+
await writeFile(join(root, "outside.ts"), "const x = 1;\n");
|
|
158
|
+
await writeFile(join(root, "docs", "present.ts"), "const y = 2;\n");
|
|
159
|
+
const site = siteConfigSchema.parse({});
|
|
160
|
+
const docsRoot = join(root, "docs");
|
|
161
|
+
|
|
162
|
+
for (const [reference, expected] of [
|
|
163
|
+
["../outside.ts", /문서 폴더 밖/],
|
|
164
|
+
["./missing.ts", /파일이 없다/],
|
|
165
|
+
["./present.ts#nope", /region 이 없다/],
|
|
166
|
+
] as const) {
|
|
167
|
+
await writeFile(join(docsRoot, "guide.md"), `---\nname: Guide\nlabel: Guide\ngroup: Guide\n---\n\n<<< ${reference}\n`);
|
|
168
|
+
await assert.rejects(readDocs({root: docsRoot, processor: createProcessor({site, root: docsRoot})}), expected, reference);
|
|
169
|
+
}
|
|
170
|
+
} finally {
|
|
171
|
+
await rm(root, {recursive: true, force: true});
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
99
175
|
test("cursor face is off unless the site turns it on", () => {
|
|
100
176
|
assert.equal(siteConfigSchema.parse({}).cursorFace, false);
|
|
101
177
|
assert.equal(siteConfigSchema.parse({cursorFace: true}).cursorFace, true);
|
package/src/entry/cli/cli.ts
CHANGED
|
@@ -92,7 +92,8 @@ const main = async () => {
|
|
|
92
92
|
...brand.files,
|
|
93
93
|
]);
|
|
94
94
|
const files = new Map(kitFiles);
|
|
95
|
-
const
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/style/token.css
CHANGED
|
@@ -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
|
-
/* 코드
|
|
38
|
+
/* 코드 판정 색 · soft 는 블록 바탕(메타 bad·good), mark 는 그 위의 줄 표기 · warn 은 줄 표기만 */
|
|
39
39
|
--app-color-bad: light-dark(#b2412e, #ff9b8a);
|
|
40
|
-
--app-color-bad-soft: light-dark(#
|
|
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(#
|
|
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,14 @@
|
|
|
173
175
|
--app-space-gutter: 16px;
|
|
174
176
|
}
|
|
175
177
|
}
|
|
178
|
+
|
|
179
|
+
/* 블록 색 · 펜스 메타 bad·good · Shiki 가 pre 에 인라인으로 쓰는 코드 바탕과 제목 띠의 바탕을 바꿈 */
|
|
180
|
+
.wg_prose__code--bad,
|
|
181
|
+
.wg_prose__codeFile--bad {
|
|
182
|
+
--app-code-background: var(--app-color-bad-soft);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.wg_prose__code--good,
|
|
186
|
+
.wg_prose__codeFile--good {
|
|
187
|
+
--app-code-background: var(--app-color-good-soft);
|
|
188
|
+
}
|