@godcreator/docfirst 0.0.0-stage → 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 godcreator02
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,72 @@
1
- # Temporary Holding Version
1
+ # god-docsite
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ ## 是什么
4
+
5
+ 文档站平台、按功能划分的功能包,以及两个 gwb 工作台插件。任何站点都是「fumadocs 官方 React Router 模板 + 挑选的功能包」:
6
+ 平台包 `@godcreator/god-docsite` 带唯一的站点模板与建站器,功能包各自经 fumadocs 与 React Router 原生的扩展点接入站点。
7
+ 仓正在重构,设计未定,以代码和 `AGENTS.md` 为准。
8
+
9
+ | 包(`packages/`) | 做什么 |
10
+ | --- | --- |
11
+ | `@godcreator/god-docsite` | 平台:站点模板与建站器、三种页面来源、布局与侧栏、搜索、llms、推送、术语标注、vite 插件 |
12
+ | `@godcreator/identity`、`@godcreator/comments` | 身份与评论,基础站默认装 |
13
+ | `@godcreator/explainer` | 讲解组件 |
14
+ | `@godcreator/code-pin`、`@godcreator/doc-export`、`@godcreator/trajectory` | 钉码、生成物、轨迹卡与星轨盘 |
15
+ | `@godcreator/docfirst` | 命令行 `docfirst`:核对、确认、生成 |
16
+ | `@godcreator/collab` | 项目协作:任务与模块、日志与日历、通知、合并检查 |
17
+ | `@godcreator/word-report` | 报告:JSON 报告文档、报告列表与报告页、命令行 `report-docx`(检查、导出 Word) |
18
+
19
+ | 插件(`plugins/`) | 做什么 |
20
+ | --- | --- |
21
+ | `@godcreator/gwb-docsite` | 身份码、建站与加功能、`docfirst.create` 建 docfirst 站、托管站点的开发服务、评论命令,带说明书 `docsite`、`docs-site`、`docs-writing`、`trajectory` |
22
+ | `@godcreator/gwb-collab` | 协作站的项目、提交审核、通知与任务命令 |
23
+
24
+ ## 怎么用
25
+
26
+ 建站与托管在 gwb 工作台里做:`docsite.site.init` 建基础站、`docsite.feature.add` 加功能包、`docsite.site.add` 托管开发服务;
27
+ 要钉码与轨迹卡的站用 `docfirst.create {"dir":"<仓>/docs-site"}` 一步建好。
28
+
29
+ docfirst 站的项目根放一份 `docfirst.config.mjs`(内容根、组件名册、生成物写到哪、轨迹卡目录)。内容根下 `future/` 是未来区,其余目录都是现状区;
30
+ 文档里单独写一行不带记录的记号,路径相对项目根:
31
+
32
+ ```mdx
33
+ {/* ref file="src/greet.ts" lines="2-3" */} 行段:这段代码、一条测试、配置里的一个值
34
+ {/* ref file="config.json" */} 整文件:只用在短文件上
35
+ {/* tree dir="src" */} 目录树;可带 depth="2"、glob="*.test.ts"
36
+ ```
37
+
38
+ 然后在站目录里:
39
+
40
+ ```bash
41
+ pnpm doc # 核对(docfirst check)。有红时退出码 1;本仓未提交改动引起的黄也算红
42
+ pnpm doc:confirm # 机械写回:给未确认的记号写上记录,挪了位置的写回新行号,重新生成生成物
43
+ pnpm doc:confirm content/docs/a.mdx --accept # 复核过后,接受点名页里的黄灯;必须点名页
44
+ pnpm doc:render # 只重新生成生成物
45
+ ```
46
+
47
+ ## 文档在哪
48
+
49
+ `docs-site/` 是全仓唯一的文档站:总览、fumadocs 原理、写页面、Gitea 与 NAS、维护。轨迹卡在 `docs-site/trajectory/`。
50
+ 随插件发布的说明书正本在 `docs-site/content/docs/outputs/skills/`,生成到 `plugins/docsite/skills/` 与 `plugins/collab/skills/`。
51
+
52
+ **这份 README 与仓根 `CLAUDE.md` 是生成物**(正本在 `docs-site/content/docs/outputs/`),`AGENTS.md` 手写;改 mdx 正本再在 `docs-site/` 里 `pnpm doc:render`。
53
+
54
+ ## 这个仓自己怎么运行
55
+
56
+ ```powershell
57
+ pnpm install
58
+ pnpm build # 按依赖序构建全部包、两个插件与各站
59
+ pnpm check # 逐个包跑 check(typecheck、快的一级测试,有 lint 的再 lint),一分钟左右
60
+ pnpm test:slow # 慢的一级测试(各包的 *.slow.test.ts:真建站、大量调 git 的),只在显式要求时跑,近三分钟
61
+ pnpm test:deps # 包之间的依赖方向、包内分层、模块之间不许有环(规则在 tests/dependency-rules.cjs,要先 pnpm build)
62
+ pnpm verify # 最终交付之前(推送、交给人验收、发版前):build → check → test:slow → test:deps → test:scaffold → test:e2e
63
+ pnpm test:pack # 发版前再跑:把包打成 tarball 装进测试站的副本,跑同样的端到端检查
64
+
65
+ cd docs-site
66
+ pnpm doc
67
+ pnpm typecheck
68
+ pnpm test
69
+ pnpm build
70
+ ```
71
+
72
+ 站点的开发服务都由 gwb-docsite 托管(`docsite.site.add`),不在会话里自己 `pnpm dev`;`pnpm build` 只作提交前的检查。
package/dist/bin.d.mts ADDED
@@ -0,0 +1 @@
1
+ export {}
package/dist/bin.mjs ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { n as main } from "./cli-D7N9_iMK.mjs";
3
+ //#region src/bin.ts
4
+ /** docfirst 可执行文件:项目根是当前目录,只转发参数与退出码 */
5
+ process.exit(await main(process.argv.slice(2)));
6
+ //#endregion
7
+ export {};
@@ -0,0 +1,482 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { isAbsolute, join, relative, sep } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { fumadocsRoster, placementsOf, unknownComponents } from "@godcreator/doc-export";
5
+ import { REASON_TEXT, codeFormat, diffText, parseMdx, targetKey } from "@godcreator/code-pin";
6
+ import { createPinWorkspace, createUncommittedProbe } from "@godcreator/code-pin/node";
7
+ import { assertOutputTurf, renderOutputs, verifyOutputs } from "@godcreator/doc-export/node";
8
+ import { scanCardDir } from "@godcreator/trajectory/node";
9
+ import { buildModel } from "@godcreator/trajectory";
10
+ //#region src/config.ts
11
+ /**
12
+ * 项目根下的 `docfirst.config.mjs`:读进来、按字段表校验、归一成命令行用的形状。
13
+ * 要改的地方一次列全报错;属于站点构建的字段在这里报错,说明它移到了站点配置。
14
+ */
15
+ const CONFIG_NAME = "docfirst.config.mjs";
16
+ const CONFIG_KEYS = [
17
+ "content",
18
+ "components",
19
+ "fumadocsRoster",
20
+ "outputs",
21
+ "trajectory"
22
+ ];
23
+ /** 旧配置里属于站点构建的键:命令行不读,遇到就报错并说清它去了哪 */
24
+ const KEYS_MOVED_TO_SITE = { glossary: "术语表的位置与不标注的页由站点声明(站点的 source.ts)" };
25
+ const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
26
+ const isNonEmptyString = (value) => typeof value === "string" && value.trim() !== "";
27
+ const isRule = (value) => value === "children" || value === "drop" || typeof value === "function";
28
+ function keyProblems(table) {
29
+ return Object.keys(table).flatMap((key) => {
30
+ const movedTo = KEYS_MOVED_TO_SITE[key];
31
+ if (movedTo !== void 0) return [`${key} 这一项移到站点配置了:${movedTo},从 ${CONFIG_NAME} 里删掉`];
32
+ if (!CONFIG_KEYS.includes(key)) return [`不认识的键 ${key}:配置只认 ${CONFIG_KEYS.join(" / ")}`];
33
+ return [];
34
+ });
35
+ }
36
+ function componentProblems(components) {
37
+ if (!isPlainObject(components)) return ["components 要是「组件名 → 规则」的对象"];
38
+ return Object.entries(components).filter(([, rule]) => !isRule(rule)).map(([name]) => `components.${name} 的规则要是 "children"、"drop" 或函数`);
39
+ }
40
+ /** 配置里要改的地方,一条一句;没有就是空表。只看形状,不读写文件 */
41
+ function configProblems(raw) {
42
+ if (!isPlainObject(raw)) return [`${CONFIG_NAME} 要 export default 一个对象`];
43
+ const problems = keyProblems(raw);
44
+ if (raw.content !== void 0 && !isNonEmptyString(raw.content)) problems.push("content 要是一个非空目录(相对项目根)");
45
+ if (raw.trajectory !== void 0 && !isNonEmptyString(raw.trajectory)) problems.push("trajectory 要是一个非空目录(相对项目根)");
46
+ if (raw.fumadocsRoster !== void 0 && typeof raw.fumadocsRoster !== "boolean") problems.push("fumadocsRoster 要是 true 或 false");
47
+ if (raw.components !== void 0) problems.push(...componentProblems(raw.components));
48
+ if (raw.outputs !== void 0 && !isPlainObject(raw.outputs)) problems.push("outputs 要是「正本 → 落点数组」的对象");
49
+ return problems;
50
+ }
51
+ /** 相对项目根的目录:正斜杠、不带尾斜杠 */
52
+ const normalizedDir = (dir) => dir.replace(/\\/g, "/").replace(/\/+$/, "");
53
+ /** 校验过的 default 导出归一成命令行用的形状;落点写法不对由 placementsOf 报错 */
54
+ function normalizedConfig(file) {
55
+ const contentDir = normalizedDir(file.content ?? "content");
56
+ return {
57
+ contentDir,
58
+ trajectoryDir: file.trajectory === void 0 ? `${contentDir}/trajectory` : normalizedDir(file.trajectory),
59
+ roster: {
60
+ ...file.fumadocsRoster === true ? fumadocsRoster : {},
61
+ ...file.components
62
+ },
63
+ outputs: placementsOf(file.outputs ?? {})
64
+ };
65
+ }
66
+ /** 校验并归一一份配置;source 是报错里说的配置出处 */
67
+ function parseConfig(raw, source) {
68
+ const problems = configProblems(raw);
69
+ if (problems.length > 0) throw new Error(`${source} 有 ${problems.length} 处要改:\n${problems.map((p) => ` - ${p}`).join("\n")}`);
70
+ return normalizedConfig(raw);
71
+ }
72
+ /** 由 Node 原生加载一个配置模块,交回它的 default 导出 */
73
+ async function importConfigModule(path) {
74
+ return (await import(pathToFileURL(path).href)).default;
75
+ }
76
+ /** 读项目根下的配置:文件只有 docfirst.config.mjs 这一个名字 */
77
+ async function loadConfig(root) {
78
+ const path = join(root, CONFIG_NAME);
79
+ if (!existsSync(path)) throw new Error(`${root} 下没有 ${CONFIG_NAME}——项目根就是放配置的那个目录`);
80
+ return parseConfig(await importConfigModule(path), path);
81
+ }
82
+ //#endregion
83
+ //#region src/project.ts
84
+ /**
85
+ * 一个项目 = 项目根 + 校验过的配置。装配时定下现状区有哪些文件,备好钉码与生成物两个功能包要的对象。
86
+ *
87
+ * 分区按约定:内容根下 `trajectory/` 与 `future/` 两个名字固定、不进核对;内容根下别的 .mdx 都是现状区,
88
+ * 再加上组件格式认领的代码文件(讲解组件的源码):现状区里的(页面同名文件夹、组里 `_` 开头的共用文件夹)与站点 `app/components/` 下的。
89
+ */
90
+ /** 文档身份:相对项目根、正斜杠。绝对路径按项目根算;相对路径原样收,`./` 前缀与反斜杠一并归一 */
91
+ function docIdOf(root, path) {
92
+ return (isAbsolute(path) ? relative(root, path) : path).split(sep).join("/").replace(/\\/g, "/").replace(/^\.\//, "");
93
+ }
94
+ const NOT_STATUS_AREAS = /* @__PURE__ */ new Set(["trajectory", "future"]);
95
+ const isPage = (name) => name.endsWith(".mdx");
96
+ /** 目录下的全部文件(递归),绝对路径;指向目录的软链接会跟进去 */
97
+ function filesUnderFollowingLinks(dir) {
98
+ return readdirSync(dir).flatMap((name) => {
99
+ const path = join(dir, name);
100
+ return statSync(path).isDirectory() ? filesUnderFollowingLinks(path) : [path];
101
+ });
102
+ }
103
+ /** 现状区里的全部文件(文档身份):内容根下除 trajectory/、future/ 之外的 */
104
+ function statusAreaFiles(root, contentAbs) {
105
+ return readdirSync(contentAbs).flatMap((name) => {
106
+ const path = join(contentAbs, name);
107
+ if (statSync(path).isDirectory()) return NOT_STATUS_AREAS.has(name) ? [] : filesUnderFollowingLinks(path).map((file) => docIdOf(root, file));
108
+ return [docIdOf(root, path)];
109
+ });
110
+ }
111
+ /** 目录下的全部文件(递归),绝对路径;按目录项自己的类型判断,指向目录的软链接当成文件 */
112
+ function filesUnder(dir) {
113
+ return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
114
+ const path = join(dir, entry.name);
115
+ return entry.isDirectory() ? filesUnder(path) : [path];
116
+ });
117
+ }
118
+ /** 站点里几组页面共用的组件 */
119
+ const SHARED_COMPONENTS_DIR = "app/components";
120
+ /** 讲解组件以前放的位置:还有文件就报错,不然里面的钉码没人核对 */
121
+ const RETIRED_COMPONENTS_DIR = "src/components";
122
+ function sharedComponentSources(root) {
123
+ const dir = join(root, SHARED_COMPONENTS_DIR);
124
+ if (!existsSync(dir)) return [];
125
+ return filesUnder(dir).map((path) => docIdOf(root, path)).filter(codeFormat.claims);
126
+ }
127
+ function assertNoRetiredComponents(root) {
128
+ const dir = join(root, RETIRED_COMPONENTS_DIR);
129
+ if (!existsSync(dir)) return;
130
+ const files = filesUnder(dir).map((path) => docIdOf(root, path));
131
+ if (files.length === 0) return;
132
+ throw new Error(`${RETIRED_COMPONENTS_DIR}/ 下还有文件:${files.join("、")}——讲解组件只给一页用的放进那一页的同名文件夹,几组页面共用的放进 ${SHARED_COMPONENTS_DIR}/,docfirst 只核对这两处的钉码`);
133
+ }
134
+ /** outputs 里的正本得是现状区里的 mdx 页面:写错路径的映射不会被核对,也不会被生成 */
135
+ function assertOutputSourcesAreStatusPages(outputs, docs) {
136
+ const pages = new Set(docs.filter(isPage));
137
+ const strays = Object.keys(outputs).filter((doc) => !pages.has(doc));
138
+ if (strays.length > 0) throw new Error(`outputs 里的正本不是现状区的 mdx 页面:${strays.join("、")}——正本要写相对项目根的路径,放在内容根下(trajectory/、future/ 之外)`);
139
+ }
140
+ function assembleProject(root, config) {
141
+ const contentAbs = join(root, config.contentDir);
142
+ if (!existsSync(contentAbs) || !statSync(contentAbs).isDirectory()) throw new Error(`内容根 ${config.contentDir} 不是项目根下的目录——配置的 content 写错了,或者还没建`);
143
+ assertOutputTurf(root, [config.contentDir], config.outputs);
144
+ assertNoRetiredComponents(root);
145
+ const statusFiles = statusAreaFiles(root, contentAbs);
146
+ const docs = [
147
+ ...statusFiles.filter(isPage),
148
+ ...statusFiles.filter(codeFormat.claims),
149
+ ...sharedComponentSources(root)
150
+ ].sort();
151
+ assertOutputSourcesAreStatusPages(config.outputs, docs);
152
+ const pins = createPinWorkspace(root);
153
+ return {
154
+ root,
155
+ config,
156
+ pins,
157
+ outputs: {
158
+ root,
159
+ outputs: config.outputs,
160
+ roster: config.roster,
161
+ pinner: pins.pinner
162
+ },
163
+ docs,
164
+ isOutputSource: (doc) => config.outputs[doc] !== void 0,
165
+ docIdOf: (path) => docIdOf(root, path),
166
+ read: (doc) => readFileSync(join(root, doc), "utf8")
167
+ };
168
+ }
169
+ //#endregion
170
+ //#region src/text.ts
171
+ /**
172
+ * 命令行打印的每一行:状态符号、状态的中文说法、每种结果写成哪一行。只拼字符串,不打印。
173
+ */
174
+ const MARK = {
175
+ fresh: "🟢",
176
+ changed: "🟡",
177
+ missing: "🔴",
178
+ unconfirmed: "⚪"
179
+ };
180
+ const LABEL = {
181
+ fresh: "新鲜",
182
+ changed: "有变动,等作者复核",
183
+ missing: "缺失",
184
+ unconfirmed: "未确认,等作者确认"
185
+ };
186
+ /** 一行说明后面的括号:状态的细分理由(`changed` 就是状态本身,不另说),或目录树的项数 */
187
+ function detailOf(check) {
188
+ if (check.reason !== void 0 && check.reason !== "changed") return `(${REASON_TEXT[check.reason]})`;
189
+ if (check.pin.target.kind !== "tree" || check.current === null) return "";
190
+ return `(${check.current.length} 项)`;
191
+ }
192
+ /** check 时一处钉的那一行;拦提交的黄单独一种说法,打印成红 */
193
+ function pinCheckLine(doc, check, blocking) {
194
+ const key = targetKey(check.pin.target);
195
+ if (blocking) return `🔴 ${doc} ${key} 有变动,是本仓还没提交的改动引起的${detailOf(check)}——在同一个提交里复核,再 docfirst confirm ${doc} --accept`;
196
+ return `${MARK[check.status]} ${doc} ${key} ${LABEL[check.status]}${detailOf(check)}`;
197
+ }
198
+ /** confirm 时一处钉的那一行;原地没动的绿不打印 */
199
+ function confirmStepLine(doc, { check, action, written, problem }) {
200
+ const key = targetKey(check.pin.target);
201
+ switch (action) {
202
+ case "recorded": return `✅ ${doc} ${key} 已确认,写上了记录`;
203
+ case "moved": {
204
+ const target = written.target;
205
+ return `✅ ${doc} ${key} 内容没变、挪到了 ${target.kind === "span" ? `${target.a}-${target.b}` : "新位置"},已写回新行号`;
206
+ }
207
+ case "accepted": return `✅ ${doc} ${key} 已按今天的内容重新记下`;
208
+ case "needs-accept": return `🟡 ${doc} ${key} ${LABEL.changed}${detailOf(check)}——复核后 docfirst confirm ${doc} --accept`;
209
+ case "refused": return `🔴 ${doc} 第 ${check.pin.line} 行 ${key} ${problem ?? `${LABEL.missing}${detailOf(check)}`},这处没动`;
210
+ case "unchanged": return null;
211
+ }
212
+ }
213
+ function unknownComponentLine(doc, name, line) {
214
+ return `🔴 ${doc} 第 ${line} 行 <${name}> 名册外组件,这篇是生成正本,docfirst.config.mjs 里要给它机读规则`;
215
+ }
216
+ /** 一个落点没生成成功的那一行:源头部缺点名的字段,或源里有渲染不了的东西;again 是改好之后再运行哪条命令 */
217
+ function unwrittenOutputLine(doc, { to, status, missing, reason }, again) {
218
+ const head = `🔴 ${doc} → ${to}`;
219
+ if (status === "no-frontmatter") return `${head} mdx 源的 frontmatter 里没有 ${(missing ?? []).join("、")}——补上再 ${again}`;
220
+ return `${head} 正本渲染不了:${reason}——改好再 ${again}`;
221
+ }
222
+ /** check 时一个落点的那一行:不新鲜的都红 */
223
+ function outputCheckLine(doc, output) {
224
+ const head = `${output.fresh ? "🟢" : "🔴"} ${doc} → ${output.to}`;
225
+ switch (output.status) {
226
+ case "no-frontmatter":
227
+ case "unrenderable": return unwrittenOutputLine(doc, output, "render");
228
+ case "missing": return `${head} 生成物还不存在——运行 render 生成`;
229
+ case "stale": return `${head} 生成物跟 mdx 源不一致(源改了没重新生成,或有人直接改了生成物)——重新运行 render`;
230
+ case "fresh": return `${head} 生成物跟 mdx 源一致`;
231
+ }
232
+ }
233
+ //#endregion
234
+ //#region src/commands/check.ts
235
+ /**
236
+ * check:逐篇核对钉、查生成正本的名册与生成物、校验轨迹卡,先汇总成一份报告再决定怎么输出。
237
+ * 人读模式一处一行,`--json` 打印整份报告;两种模式算的是同一份报告,红的口径对得上。
238
+ */
239
+ function refReport(check, blocking) {
240
+ return {
241
+ target: targetKey(check.pin.target),
242
+ kind: check.pin.target.kind,
243
+ status: check.status,
244
+ line: check.pin.line,
245
+ lines: check.lines,
246
+ ...check.reason !== void 0 ? { reason: check.reason } : {},
247
+ ...check.diff !== void 0 ? { diff: diffText(check.diff) } : {},
248
+ ...blocking ? { blocking: true } : {}
249
+ };
250
+ }
251
+ function checkDoc(project, doc, uncommitted) {
252
+ const text = project.read(doc);
253
+ const lines = [];
254
+ const items = [];
255
+ for (const check of project.pins.pinning.checkDoc(doc, text).checks) {
256
+ const blocking = check.status === "changed" && uncommitted(doc, check.pin.target);
257
+ items.push(refReport(check, blocking));
258
+ lines.push(pinCheckLine(doc, check, blocking));
259
+ }
260
+ const isOutputSource = project.isOutputSource(doc);
261
+ const components = isOutputSource ? unknownComponents(parseMdx(text), project.config.roster) : [];
262
+ for (const { name, line } of components) lines.push(unknownComponentLine(doc, name, line));
263
+ const outputs = isOutputSource ? verifyOutputs(project.outputs, [doc]).map(({ doc: _source, ...output }) => output) : [];
264
+ for (const output of outputs) lines.push(outputCheckLine(doc, output));
265
+ return {
266
+ report: {
267
+ doc,
268
+ red: items.filter((item) => item.status === "missing" || item.blocking === true).length + components.length + outputs.filter((output) => !output.fresh).length,
269
+ items,
270
+ components,
271
+ outputs
272
+ },
273
+ lines
274
+ };
275
+ }
276
+ /** 轨迹卡校验:卡目录还没建不算红;每条病一条,path 相对项目根 */
277
+ async function checkTrajectory(project) {
278
+ const dir = join(project.root, project.config.trajectoryDir);
279
+ const { sick } = await scanCardDir(dir);
280
+ return sick.flatMap((file) => {
281
+ const path = relative(project.root, join(dir, file.path)).split(sep).join("/");
282
+ return file.problems.map((p) => ({
283
+ path,
284
+ field: p.field,
285
+ value: p.value,
286
+ reason: p.reason
287
+ }));
288
+ });
289
+ }
290
+ async function checkCommand(project, json) {
291
+ const report = {
292
+ ok: true,
293
+ red: 0,
294
+ docs: [],
295
+ trajectory: []
296
+ };
297
+ const uncommitted = createUncommittedProbe(project.root);
298
+ for (const doc of project.docs) {
299
+ const { report: entry, lines } = checkDoc(project, doc, uncommitted);
300
+ report.docs.push(entry);
301
+ report.red += entry.red;
302
+ if (!json) for (const line of lines) console.log(line);
303
+ }
304
+ report.trajectory = await checkTrajectory(project);
305
+ report.red += report.trajectory.length;
306
+ report.ok = report.red === 0;
307
+ if (json) console.log(JSON.stringify(report));
308
+ else for (const p of report.trajectory) console.log(`🔴 ${p.path} ${p.field}:${p.reason}(${p.value})`);
309
+ return report.ok ? 0 : 1;
310
+ }
311
+ //#endregion
312
+ //#region src/commands/render.ts
313
+ /**
314
+ * render:生成正本按 outputs 生成到落点,内容变了才写;confirm 最后也走这里重新生成一遍。
315
+ */
316
+ /** 现状区里的生成正本,按文档身份排序 */
317
+ const outputSourcesOf = (project) => project.docs.filter(project.isOutputSource);
318
+ /** 写一遍全部生成物、逐个落点打印一行,返回没生成成功的落点数;command 是报错里让人改好之后再运行的那条命令 */
319
+ function writeOutputs(project, command) {
320
+ let red = 0;
321
+ for (const written of renderOutputs(project.outputs, outputSourcesOf(project))) {
322
+ const { doc, to, status } = written;
323
+ if (status === "no-frontmatter" || status === "unrenderable") {
324
+ console.log(unwrittenOutputLine(doc, written, command));
325
+ red += 1;
326
+ } else if (status === "placed") console.log(command === "render" ? `→ ${doc} 摆放到 ${to}` : `→ ${doc} 重新摆放到 ${to}`);
327
+ else if (command === "render") console.log(`· ${doc} → ${to} 没变`);
328
+ }
329
+ return red;
330
+ }
331
+ function renderCommand(project) {
332
+ return writeOutputs(project, "render") > 0 ? 1 : 0;
333
+ }
334
+ //#endregion
335
+ //#region src/commands/confirm.ts
336
+ /** 点名的路径换成文档身份;三种路径写法都收,对不上现状区的一并交回,不静默跳过 */
337
+ function pickDocs(project, named) {
338
+ const alive = new Set(project.docs);
339
+ const picked = [];
340
+ const unknown = [];
341
+ for (const raw of named) {
342
+ const doc = project.docIdOf(raw);
343
+ if (alive.has(doc)) picked.push(doc);
344
+ else unknown.push(raw);
345
+ }
346
+ return unknown.length > 0 ? { unknown } : { docs: [...new Set(picked)] };
347
+ }
348
+ /** 一篇的机械写回:逐处打印一行,返回这篇钉不住的处数(红) */
349
+ function confirmDoc(project, doc, accept) {
350
+ let red = 0;
351
+ for (const step of project.pins.confirm(doc, { accept }).steps) {
352
+ const line = confirmStepLine(doc, step);
353
+ if (line !== null) console.log(line);
354
+ if (step.action === "refused") red += 1;
355
+ }
356
+ return red;
357
+ }
358
+ function confirmCommand(project, named, accept) {
359
+ if (accept && named.length === 0) {
360
+ console.error("--accept 要点名页:docfirst confirm <文档路径…> --accept——确认黄灯得说清是哪几页,什么都没动");
361
+ return 2;
362
+ }
363
+ let pages = project.docs;
364
+ if (named.length > 0) {
365
+ const picked = pickDocs(project, named);
366
+ if ("unknown" in picked) {
367
+ console.error(`不是这个项目里的文档:${picked.unknown.join("、")}——什么都没动`);
368
+ return 2;
369
+ }
370
+ pages = picked.docs;
371
+ console.log(`· 按页限定:只动 ${pages.join("、")},别的页不碰`);
372
+ }
373
+ let red = 0;
374
+ for (const doc of pages) red += confirmDoc(project, doc, accept);
375
+ red += writeOutputs(project, "confirm");
376
+ return red > 0 ? 1 : 0;
377
+ }
378
+ //#endregion
379
+ //#region src/commands/trajectory.ts
380
+ /**
381
+ * trajectory:读轨迹卡目录、建星轨盘的模型,打印根卡、卡数与每棵树的主干;`--json` 输出盘的输入(盘心、卡、病卡)与树。
382
+ * 只要配置,不装配项目:它不遍历文档。
383
+ */
384
+ async function trajectoryCommand(root, config, json) {
385
+ const dir = join(root, config.trajectoryDir);
386
+ if (!existsSync(dir)) {
387
+ console.error(`这个项目还没有轨迹卡目录 ${config.trajectoryDir}(相对项目根;位置由配置的 trajectory 定,不写是内容根下的 trajectory/)`);
388
+ return 2;
389
+ }
390
+ const scan = await scanCardDir(dir);
391
+ const model = buildModel(scan);
392
+ if (json) {
393
+ console.log(JSON.stringify({
394
+ ...scan,
395
+ trees: model.trees
396
+ }));
397
+ return 0;
398
+ }
399
+ const rootText = model.center ? `${model.center.label}(${model.center.id})` : "没认出根卡(卡目录里根卡不是恰好一张,见 docfirst check)";
400
+ console.log(`盘 ${rootText} ${model.cards.length} 张卡`);
401
+ for (const tree of model.trees) console.log(` 主干:${tree.spine.join(" → ")}`);
402
+ const sublineCount = model.trees.reduce((n, t) => n + t.sublines.length, 0);
403
+ console.log(` 支线 ${sublineCount} 条,孤星 ${model.isolated.length} 张`);
404
+ return 0;
405
+ }
406
+ //#endregion
407
+ //#region src/cli.ts
408
+ /**
409
+ * 命令行的 main:认命令与开关、读配置、装配项目、交给 commands/ 里的命令,交回退出码。
410
+ * 项目根是传进来的目录(可执行文件传 cwd),那里有 docfirst.config.mjs。main 不调 process.exit,测试与别的程序直接调它。
411
+ */
412
+ /**
413
+ * 各命令认的开关。不在表里的一律拒绝:confirm 把 `-` 开头的参数从页名里筛掉,开关若被静默忽略,
414
+ * `confirm --dry-run` 就成了不带页名的全部页确认,而且没有任何提示。
415
+ */
416
+ const FLAGS = {
417
+ check: ["--json"],
418
+ confirm: ["--accept"],
419
+ render: [],
420
+ trajectory: ["--json"]
421
+ };
422
+ /** 求助的写法:命令位上三种都认,命令后面只认两种开关(`help` 在 confirm 后面可能是页名) */
423
+ const HELP_COMMANDS = /* @__PURE__ */ new Set([
424
+ "--help",
425
+ "-h",
426
+ "help"
427
+ ]);
428
+ const HELP_FLAGS = /* @__PURE__ */ new Set(["--help", "-h"]);
429
+ const USAGE = `docfirst——文档钉代码的核对与确认。在有 docfirst.config.mjs 的项目根运行。
430
+
431
+ docfirst check [--json] 核对,只读。红退 1;被钉文件在本仓有未提交改动的黄灯也算红,
432
+ 在同一个提交里复核并 --accept。--json 出整份机读报告,退出码不变
433
+ docfirst confirm [文档路径…] 机械写回:未确认的标记写上记录,挪了位置的写回新行号,
434
+ 再重新生成挂了 outputs 映射的生成物(内容变了才写)。
435
+ 黄灯不动、逐条列出;红的那几处不动、别的照写,有红退 1。
436
+ 不带路径 = 全部页;带路径 = 按页限定,只动点名的那几篇
437
+ (例:docfirst confirm content/docs/a.mdx)
438
+ docfirst confirm <文档路径…> --accept 另外把点名页里的黄灯按今天的内容重新记。复核过才用,必须点名页
439
+ docfirst render 把挂了 outputs 映射的生成正本生成到落点(内容变了才写)
440
+ docfirst trajectory [--json] 列轨迹卡那张盘的树与主干
441
+ docfirst --help 打这份用法,什么都不动
442
+
443
+ 不认识的开关一律报错退 2,不会被当作没写。`;
444
+ function parseArgs(argv) {
445
+ const [command = "check", ...rest] = argv;
446
+ if (HELP_COMMANDS.has(command) || rest.some((arg) => HELP_FLAGS.has(arg))) return { help: true };
447
+ const allowed = FLAGS[command];
448
+ if (allowed === void 0) return { error: `不认识的命令:${command}——有 ${Object.keys(FLAGS).join(" / ")},用法看 docfirst --help` };
449
+ const flags = rest.filter((arg) => arg.startsWith("-"));
450
+ const unknown = flags.filter((flag) => !allowed.includes(flag));
451
+ if (unknown.length > 0) {
452
+ const said = allowed.length === 0 ? "不收任何开关" : `只认 ${allowed.join(" / ")}`;
453
+ return { error: `${command} 不认识开关 ${unknown.join(" ")}(${command} ${said}),什么都没动。用法看 docfirst --help` };
454
+ }
455
+ const paths = rest.filter((arg) => !arg.startsWith("-"));
456
+ if (command !== "confirm" && paths.length > 0) return { error: `${command} 不收文档路径(${paths.join(" ")})——只有 confirm 能点名页,什么都没动。用法看 docfirst --help` };
457
+ return {
458
+ command,
459
+ flags,
460
+ paths
461
+ };
462
+ }
463
+ async function main(argv, root = process.cwd()) {
464
+ const parsed = parseArgs(argv);
465
+ if ("help" in parsed) {
466
+ console.log(USAGE);
467
+ return 0;
468
+ }
469
+ if ("error" in parsed) {
470
+ console.error(parsed.error);
471
+ return 2;
472
+ }
473
+ const { command, flags, paths } = parsed;
474
+ const config = await loadConfig(root);
475
+ if (command === "trajectory") return trajectoryCommand(root, config, flags.includes("--json"));
476
+ const project = assembleProject(root, config);
477
+ if (command === "check") return checkCommand(project, flags.includes("--json"));
478
+ if (command === "confirm") return confirmCommand(project, paths, flags.includes("--accept"));
479
+ return renderCommand(project);
480
+ }
481
+ //#endregion
482
+ export { loadConfig as a, configProblems as i, main as n, parseConfig as o, assembleProject as r, USAGE as t };
@@ -0,0 +1,94 @@
1
+ import { OutputPlacement, OutputsConfig, Roster } from "@godcreator/doc-export";
2
+ import { Reason, Status, Target } from "@godcreator/code-pin";
3
+ import { PinWorkspace } from "@godcreator/code-pin/node";
4
+ import { OutputCheck, OutputProject } from "@godcreator/doc-export/node";
5
+ //#region src/cli.d.ts
6
+ declare const USAGE = "docfirst——文档钉代码的核对与确认。在有 docfirst.config.mjs 的项目根运行。\n\n docfirst check [--json] 核对,只读。红退 1;被钉文件在本仓有未提交改动的黄灯也算红,\n 在同一个提交里复核并 --accept。--json 出整份机读报告,退出码不变\n docfirst confirm [文档路径…] 机械写回:未确认的标记写上记录,挪了位置的写回新行号,\n 再重新生成挂了 outputs 映射的生成物(内容变了才写)。\n 黄灯不动、逐条列出;红的那几处不动、别的照写,有红退 1。\n 不带路径 = 全部页;带路径 = 按页限定,只动点名的那几篇\n (例:docfirst confirm content/docs/a.mdx)\n docfirst confirm <文档路径…> --accept 另外把点名页里的黄灯按今天的内容重新记。复核过才用,必须点名页\n docfirst render 把挂了 outputs 映射的生成正本生成到落点(内容变了才写)\n docfirst trajectory [--json] 列轨迹卡那张盘的树与主干\n docfirst --help 打这份用法,什么都不动\n\n不认识的开关一律报错退 2,不会被当作没写。";
7
+ declare function main(argv: readonly string[], root?: string): Promise<number>;
8
+ //#endregion
9
+ //#region src/config.d.ts
10
+ /** `docfirst.config.mjs` 的 default 导出,字段表的正本 */
11
+ interface DocfirstConfigFile {
12
+ /** 内容根,相对项目根;不写是 content */
13
+ content?: string;
14
+ /** 组件名册:组件名 → 机读规则("children" / "drop" / 函数);生成正本里用到的组件都得在名册里 */
15
+ components?: Roster;
16
+ /** true:先用 doc-export 现成的 fumadocs 名册(Note、Files、Folder、File),components 里同名的规则盖过它 */
17
+ fumadocsRoster?: boolean;
18
+ /** 生成物:正本(相对项目根)→ 落点数组;落点写相对项目根的路径,或 { to, frontmatter } */
19
+ outputs?: OutputsConfig;
20
+ /** 轨迹卡目录,相对项目根;不写是内容根下的 trajectory/ */
21
+ trajectory?: string;
22
+ }
23
+ /** 校验并归一之后的配置:目录都相对项目根、正斜杠、不带尾斜杠 */
24
+ interface DocfirstConfig {
25
+ contentDir: string;
26
+ trajectoryDir: string;
27
+ /** 用来查名册与生成生成物的那张表:fumadocs 名册(选了才有)加上 components */
28
+ roster: Roster;
29
+ outputs: Record<string, OutputPlacement[]>;
30
+ }
31
+ /** 配置里要改的地方,一条一句;没有就是空表。只看形状,不读写文件 */
32
+ declare function configProblems(raw: unknown): string[];
33
+ /** 校验并归一一份配置;source 是报错里说的配置出处 */
34
+ declare function parseConfig(raw: unknown, source: string): DocfirstConfig;
35
+ /** 读项目根下的配置:文件只有 docfirst.config.mjs 这一个名字 */
36
+ declare function loadConfig(root: string): Promise<DocfirstConfig>;
37
+ //#endregion
38
+ //#region src/project.d.ts
39
+ interface Project {
40
+ root: string;
41
+ config: DocfirstConfig;
42
+ /** 钉码:读工作区、旧内容从 git 取 */
43
+ pins: PinWorkspace;
44
+ /** 生成物:落点、名册、和 pins 同一个钉码核心 */
45
+ outputs: OutputProject;
46
+ /** 现状区里的文件:相对项目根、正斜杠、排过序 */
47
+ docs: string[];
48
+ /** 这篇是不是生成正本(outputs 里有它) */
49
+ isOutputSource(doc: string): boolean;
50
+ docIdOf(path: string): string;
51
+ read(doc: string): string;
52
+ }
53
+ declare function assembleProject(root: string, config: DocfirstConfig): Project;
54
+ //#endregion
55
+ //#region src/report.d.ts
56
+ /** 一个生成物落点的状态:doc-export 的核对结果,去掉报告里已经由外层给出的正本身份 */
57
+ type OutputReport = Omit<OutputCheck, "doc">;
58
+ interface RefReport {
59
+ /** 目标的文本形态,同人读报告里的那一截(`file:a-b`、`tree dir depth=1`……) */
60
+ target: string;
61
+ kind: Target["kind"];
62
+ status: Status;
63
+ /** 标记在文件里的第几行,1 起 */
64
+ line: number;
65
+ lines: [number, number];
66
+ reason?: Reason;
67
+ diff?: string;
68
+ blocking?: true;
69
+ }
70
+ interface ComponentReport {
71
+ name: string;
72
+ line: number;
73
+ }
74
+ interface DocReport {
75
+ doc: string;
76
+ red: number;
77
+ items: RefReport[];
78
+ components: ComponentReport[];
79
+ outputs: OutputReport[];
80
+ }
81
+ interface CardProblemReport {
82
+ path: string;
83
+ field: string;
84
+ value: string;
85
+ reason: string;
86
+ }
87
+ interface CheckReport {
88
+ ok: boolean;
89
+ red: number;
90
+ docs: DocReport[];
91
+ trajectory: CardProblemReport[];
92
+ }
93
+ //#endregion
94
+ export { type CardProblemReport, type CheckReport, type ComponentReport, type DocReport, type DocfirstConfig, type DocfirstConfigFile, type OutputReport, type Project, type RefReport, USAGE, assembleProject, configProblems, loadConfig, main, parseConfig };
package/dist/index.mjs ADDED
@@ -0,0 +1 @@
1
+ export { t as USAGE, r as assembleProject, i as configProblems, a as loadConfig, n as main, o as parseConfig } from "./cli-D7N9_iMK.mjs";
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "@godcreator/docfirst",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "author": {
5
+ "name": "godcreator"
6
+ },
7
+ "type": "module",
8
+ "description": "docfirst 命令行:读项目根的 docfirst.config.mjs,把 check / confirm / render / trajectory 分派给钉码(code-pin)、生成物(doc-export)、轨迹卡(trajectory)三个功能包,汇总结果、给出退出码。template/ 是文档维护的起始物(配置、生成物正本、起始页、第一张轨迹卡),建站后叠在站点根上。",
9
+ "license": "MIT",
10
+ "bin": {
11
+ "docfirst": "./dist/bin.mjs"
12
+ },
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.mts",
16
+ "default": "./dist/index.mjs"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "template",
22
+ "LICENSE"
23
+ ],
24
+ "dependencies": {
25
+ "@godcreator/code-pin": "^0.2.0",
26
+ "@godcreator/doc-export": "^0.1.0",
27
+ "@godcreator/trajectory": "^0.1.0"
28
+ },
29
+ "publishConfig": {
30
+ "registry": "https://registry.npmjs.org/",
31
+ "access": "public"
32
+ },
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "https://github.com/godcreator02/god-docsite.git",
36
+ "directory": "packages/docfirst"
37
+ },
38
+ "keywords": [
39
+ "gwb"
40
+ ]
6
41
  }
@@ -0,0 +1,4 @@
1
+ {
2
+ "title": "现状",
3
+ "pages": ["outputs"]
4
+ }
@@ -0,0 +1,14 @@
1
+ ---
2
+ title: AGENTS
3
+ ---
4
+
5
+ # 仓的规矩
6
+
7
+ 这一页是仓根 AGENTS.md 的正本:`pnpm doc:render` 把它生成过去,改这里、不改那边,两份放进同一个提交。
8
+ 写给在这个仓干活的 agent 看,至少写清:
9
+
10
+ - 用什么语言回复、注释与提交信息用什么语言
11
+ - 怎么装依赖、构建、跑测试,提交前哪些检查要绿
12
+ - 这个仓里有哪些接缝与边界不许跨
13
+
14
+ 各客户端都该遵守的规矩写在这里;只给某一个客户端的指令写进 claude.mdx 那份正本。
@@ -0,0 +1,7 @@
1
+ ---
2
+ title: CLAUDE
3
+ ---
4
+
5
+ @AGENTS.md
6
+
7
+ 只给 Claude 的指令写在上面那一行的后面;各客户端都该遵守的规矩写进 agents.mdx 那份正本。
@@ -0,0 +1,10 @@
1
+ ---
2
+ title: README
3
+ ---
4
+
5
+ # 这个项目的名字
6
+
7
+ 这一页是仓根 README.md 的正本:`pnpm doc:render` 把它生成过去,改这里、不改那边,
8
+ 两份放进同一个提交,`pnpm doc` 会检查它们一致。删掉这页前先把 docfirst.config.mjs 里对应的 outputs 映射去掉。
9
+
10
+ 往下写:这个仓是什么、怎么跑起来(装依赖、构建、测试各一条命令)、从哪开始读。
@@ -0,0 +1,4 @@
1
+ {
2
+ "title": "未来",
3
+ "pages": ["overview"]
4
+ }
@@ -0,0 +1,8 @@
1
+ ---
2
+ title: 未来区
3
+ ---
4
+
5
+ 未来区记还没做的事:打算怎么做、做到哪一步了。每件事一页,写进 `content/future/` 下。
6
+
7
+ 与现状区的差别:现状区(`content/` 下其余的页面)钉的是代码今天的样子,`pnpm doc` 核对它们与代码一致;
8
+ 未来区不钉码、不进核对,写的是计划与进度。这一页是起始页,改掉它或删掉它都行。
@@ -0,0 +1,26 @@
1
+ /**
2
+ * 这个项目的 docfirst 配置:命令行 docfirst(check / confirm / render / trajectory)读它,项目根是站点目录。
3
+ *
4
+ * content:内容根。下面的 future/ 是未来区、不进核对,其余目录都是现状区、都进 check。
5
+ * outputs:仓根(站点目录的上一级)的 README.md / AGENTS.md / CLAUDE.md 是生成物,正本是映射左边的 mdx——
6
+ * 改了正本运行 `pnpm doc:render`,两份放进同一个提交,`pnpm doc` 检查生成物跟正本是否一致。
7
+ * CLAUDE.md 第一行固定是 `@AGENTS.md`,只给 Claude 的指令写在它后面。
8
+ * fumadocsRoster 与 components:组件名册。fumadocsRoster 放进 fumadocs 自带的 Note、Files、Folder、File 的机读规则,
9
+ * components 再加站里的组件;站里自己加组件,就在 components 里给它加一条机读规则(怎么写见 skill「docs-writing」)。
10
+ * trajectory:轨迹卡放在站点根下的 trajectory/,站点的卡集合(app/features/trajectory/mdx.ts)读同一个目录。
11
+ * 术语表不在这里:它是站点根的 glossary.json,由站点的 source.config.ts 交给术语标注插件。
12
+ */
13
+ export default {
14
+ content: "content",
15
+ outputs: {
16
+ "content/docs/outputs/readme.mdx": ["../README.md"],
17
+ "content/docs/outputs/agents.mdx": ["../AGENTS.md"],
18
+ "content/docs/outputs/claude.mdx": ["../CLAUDE.md"],
19
+ },
20
+ fumadocsRoster: true,
21
+ components: {
22
+ // 手写的术语标注:机读版里只留术语原词
23
+ Term: "children",
24
+ },
25
+ trajectory: "trajectory",
26
+ };
@@ -0,0 +1,12 @@
1
+ ---
2
+ born-at: 2026-01-01T00:00
3
+ type: root
4
+ label: 起始
5
+ ---
6
+
7
+ ## 背景
8
+
9
+ 这一张是建站时放的起始卡:星轨盘要有一张根卡当盘心,卡目录里没有根卡就画不出盘。
10
+
11
+ 把它改成这个站的第一张真卡(label、born-at 都换成真的),或者删掉它、写自己的根卡。
12
+ 卡怎么写看随工作台插件发的 skill「trajectory」。