dsh-mini-utility-dock 0.5.1 → 0.7.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/README.en.md +22 -6
- package/README.md +22 -6
- package/bin/dsh-mini-utility-dock.js +71 -26
- package/bin/dsh-plugin-docs.js +101 -37
- package/bin/dsh-plugin-parity.js +105 -60
- package/dist/guard.js +18 -21
- package/dist/host-http.js +207 -0
- package/dist/launcher.js +19 -16
- package/dist/loopback.js +11 -14
- package/docs/CHANGELOG.en.md +91 -0
- package/docs/CHANGELOG.md +91 -0
- package/package.json +10 -5
- package/CHANGELOG.en.md +0 -73
- package/CHANGELOG.md +0 -73
package/README.en.md
CHANGED
|
@@ -17,6 +17,7 @@ The DSH plugin family's shared assets: source fragments, an embedding CLI, and t
|
|
|
17
17
|
| --- | --- | --- | --- |
|
|
18
18
|
| loopback predicates | `dsh-loopback-helpers` | `lib/shared.js` | `LOOPBACK_HOSTNAMES`, `normalizeHostValue`, `hostHostname`, `isLoopbackName`, `isLoopbackAddress` |
|
|
19
19
|
| host request guard | `dsh-host-guard` | `lib/shared.js` | `portOf`, `GUARD_REASONS`, `DEFAULT_GUARD_POLICY` |
|
|
20
|
+
| host HTTP glue | `dsh-host-http` | `lib/shared.js` | `sendJson`, `CONNECTION_UNAVAILABLE`, `connectionUnavailable`, `REQUIRE_POST_REASONS`, `DEFAULT_REQUIRE_POST_POLICY`, `createRequirePost`, `createBrowserAuthorizer`, `optionalSessionId` |
|
|
20
21
|
| utility launcher | `dsh-utility-launcher` | `lib/client.js` | `registerUtilityLauncher`, `UTILITY_ITEM_SLOT` |
|
|
21
22
|
|
|
22
23
|
## Usage
|
|
@@ -37,19 +38,34 @@ npm run dock:embed -- check path/to/shared.js
|
|
|
37
38
|
npm run dock:embed -- sync path/to/shared.js
|
|
38
39
|
```
|
|
39
40
|
|
|
40
|
-
Consumers invoke the same CLI as `loopback:sync` / `guard:sync` with the target fixed to their own `lib/shared.js`, and as `launcher:sync` for the `dsh-utility-launcher` block in their own `lib/client.js`.
|
|
41
|
+
Consumers invoke the same CLI as `loopback:sync` / `guard:sync` / `http:sync` with the target fixed to their own `lib/shared.js`, and as `launcher:sync` for the `dsh-utility-launcher` block in their own `lib/client.js`. One command handles the three host-half blocks together: a block is selected by the marker that exists in the file, so `http:sync` and `loopback:sync` write different blocks of the same file.
|
|
41
42
|
|
|
42
43
|
## Constraints
|
|
43
44
|
|
|
44
|
-
- `dist/` is the single source.
|
|
45
|
-
- The
|
|
45
|
+
- `dist/` is the single source. After changing a fragment, run the whole set below under "Development and verification", and have consumers re-`sync`.
|
|
46
|
+
- The three fragments in `lib/shared.js` have fixed positions: `dsh-loopback-helpers` → `dsh-host-guard` → `dsh-host-http`. The guard uses the module-scope names the preceding fragment exports, and neither redeclares nor imports them; `dsh-host-http` reads nothing from the two blocks above it and declares none of their names, so it can be embedded alone. Those positions are the `FRAGMENTS` order, and `sync` replaces blocks from the bottom of the file up; a file whose marker ranges overlap is rejected outright and left unwritten.
|
|
47
|
+
- `dsh-utility-launcher` lands in the client half: exactly one copy of the assembly runs per page (the first to load wins the `window` mutex and declares the menu seat), and every other copy contributes one row to that seat. That is a runtime fact, not a styling choice.
|
|
46
48
|
- A fragment must not contain `import` or `require`; a consumer must publish standalone.
|
|
47
49
|
- `bindGuard` is not exported — a consumer declares its own guard export in the same file. Each plugin passes its own error codes and wording through `policy`; the enforcement is shared.
|
|
50
|
+
- `createRequirePost({ respond, policy })` works the same way: the default code is `method`, and a repository's published vocabulary — `need_post`, Chinese wording, the `action` echoed back — is an override of `policy.method_not_allowed`, not a behavior difference. An unknown key throws.
|
|
51
|
+
- `createBrowserAuthorizer({ getConnection, getConnectionSeen, guard, respond })` takes accessors, not values: a host half's Connection is reassigned to `null` by a service reload, and the value-shaped version would keep admitting requests through a destroyed Connection. The semantics are fixed: a Connection that throws answers 503 `connection_unavailable`; a rejection is answered with its own code (401 → `unauthorized`, anything else → `forbidden`); a Connection that was seen but is absent now answers 503 and never falls back to the weaker guard; a host that never had one is decided by `guard`.
|
|
48
52
|
|
|
49
53
|
## Cross-repo consistency
|
|
50
54
|
|
|
51
|
-
A consumer's `npm test` runs `loopback:check` / `guard:check`, comparing its
|
|
55
|
+
A consumer's `npm test` runs `loopback:check` / `guard:check` / `http:check`, comparing the three blocks in its own `lib/shared.js` byte for byte against the dock version it pins. A published version is immutable and consumers pin an exact version, so pin agreement implies fragment agreement. The converse does not hold: `check` selects blocks by the markers it recognizes, so a consumer that has not raised its pin never looks at a block the older CLI does not know and that one check passes vacuously. After a fragment is added, a consumer re-runs all four `sync` scripts and raises its pin to the released version in the same commit.
|
|
52
56
|
|
|
53
|
-
`dsh-plugin-parity` (a bin provided by this package) checks that cross-repo property directly,
|
|
57
|
+
`dsh-plugin-parity` (a bin provided by this package) checks that cross-repo property directly: every host block compared in isolation after stripping the block's common indentation (the three, `dsh-host-http` included), pin agreement, block position, and a behavioral comparison. It is a manual diagnostic and **does not run in CI**: the property cannot hold while peers sit on a different branch. The member list is caller-supplied via `--member <repo>:<export>` — the guard factory's export name is per-repository by fragment design, so the tool presupposes no members; `--fleet <repo>` names the single member that opts into fleet mode, and only its guard is checked for "the relaxation stays bounded", while naming a repository that is not a member fails with a non-zero exit; without `--member` it runs the static checks only and discovers every checkout that embeds the guard block. `--self-test` needs no checkout at all: it exercises the block extractor and the block-dropping step against inline samples, so run it first whenever either changes.
|
|
54
58
|
|
|
55
|
-
`dsh-plugin-docs` (another bin of this package) checks the documentation pairs for structural alignment: the
|
|
59
|
+
`dsh-plugin-docs` (another bin of this package) checks the bilingual documentation pairs for structural alignment. It names no document of its own: which pairs to check, which directory holds them, and how each is compared come from an array of `{ name, zh, en, shape? }` that the calling repository supplies through `--config <module>`; this package declares its own pairs in `docs.config.mjs` the same way. Two shapes exist. The default, `markdown`, compares the heading-level sequence and the code-fence languages on both sides; content inside a fence is exempt because that is where language-specific examples live. `changelog` compares the releases on both sides, the `Unreleased` section included, together with their sections and item counts, with section titles normalized through a bilingual category map. `--base <revision>` additionally requires both files of each declared pair to have changed since that revision: a one-sided edit is a missing translation. When that revision is the all-zero null OID, which is what `github.event.before` carries for a newly created or force-pushed branch, there is no previous state to compare, so that one requirement is skipped with a note while the structural checks still run. Repositories call it from their own `docs:check` script.
|
|
60
|
+
|
|
61
|
+
## Development and verification
|
|
62
|
+
|
|
63
|
+
`dist/` is the source, so one change has to pass three layers: fragment behavior, the CLI, and the documentation pair. This is the full check of this repository (identical to the Verify block in `AGENTS.md`; the release procedure is in `docs/RELEASING.md`):
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
npm test
|
|
67
|
+
node bin/dsh-plugin-docs.js
|
|
68
|
+
for f in bin/*.js dist/*.js; do node --check "$f"; done
|
|
69
|
+
node bin/dsh-plugin-parity.js --self-test
|
|
70
|
+
npm pack --dry-run
|
|
71
|
+
```
|
package/README.md
CHANGED
|
@@ -17,6 +17,7 @@ DSH 插件族的共享资产:源码片段、嵌入 CLI,以及围绕它们的
|
|
|
17
17
|
| --- | --- | --- | --- |
|
|
18
18
|
| loopback 判定 | `dsh-loopback-helpers` | `lib/shared.js` | `LOOPBACK_HOSTNAMES`、`normalizeHostValue`、`hostHostname`、`isLoopbackName`、`isLoopbackAddress` |
|
|
19
19
|
| host 请求守卫 | `dsh-host-guard` | `lib/shared.js` | `portOf`、`GUARD_REASONS`、`DEFAULT_GUARD_POLICY` |
|
|
20
|
+
| host HTTP 胶水 | `dsh-host-http` | `lib/shared.js` | `sendJson`、`CONNECTION_UNAVAILABLE`、`connectionUnavailable`、`REQUIRE_POST_REASONS`、`DEFAULT_REQUIRE_POST_POLICY`、`createRequirePost`、`createBrowserAuthorizer`、`optionalSessionId` |
|
|
20
21
|
| utility 入口 | `dsh-utility-launcher` | `lib/client.js` | `registerUtilityLauncher`、`UTILITY_ITEM_SLOT` |
|
|
21
22
|
|
|
22
23
|
## 使用
|
|
@@ -37,19 +38,34 @@ npm run dock:embed -- check path/to/shared.js
|
|
|
37
38
|
npm run dock:embed -- sync path/to/shared.js
|
|
38
39
|
```
|
|
39
40
|
|
|
40
|
-
消费插件以 `loopback:sync` / `guard:sync` 调用同一 CLI,目标固定为自身 `lib/shared.js`;client 半以 `launcher:sync` 维护 `dsh-utility-launcher` 块,目标固定为自身 `lib/client.js
|
|
41
|
+
消费插件以 `loopback:sync` / `guard:sync` / `http:sync` 调用同一 CLI,目标固定为自身 `lib/shared.js`;client 半以 `launcher:sync` 维护 `dsh-utility-launcher` 块,目标固定为自身 `lib/client.js`。宿主半的三个块由一条命令一并处理:标记存在即被选中,因此 `http:sync` 与 `loopback:sync` 写的是同一个文件里的不同块。
|
|
41
42
|
|
|
42
43
|
## 约束
|
|
43
44
|
|
|
44
|
-
- `dist/`
|
|
45
|
-
- `lib/shared.js`
|
|
45
|
+
- `dist/` 是唯一来源。修改片段后执行下面「开发与验证」列出的命令,并让消费插件重新 `sync`。
|
|
46
|
+
- `lib/shared.js` 中三个片段的落点固定:`dsh-loopback-helpers` → `dsh-host-guard` → `dsh-host-http`。守卫直接使用前一块声明的模块级名字,既不重新声明也不 import;`dsh-host-http` 不读上面两块、也不声明它们的名字,故可单独嵌入。落点顺序即 `FRAGMENTS` 顺序;`sync` 自下而上替换块,标记区间彼此交叠的文件会被直接拒绝、不写出任何内容。
|
|
47
|
+
- `dsh-utility-launcher` 落在 client 半:每页只运行一份该装配(先加载的一方赢得 `window` 互斥并声明菜单座位),其余副本只往座位里投一行。这是运行时事实,不是样式选择。
|
|
46
48
|
- 片段不得包含 `import` 或 `require`;消费插件必须能独立发布。
|
|
47
49
|
- `bindGuard` 不从片段导出——消费插件在同一文件中声明自己的 guard 导出。各插件通过 `policy` 传入自身错误码与文案,判定逻辑共用。
|
|
50
|
+
- `createRequirePost({ respond, policy })` 同上:默认 code 是 `method`,一个仓已发布的词汇(例如 `need_post`、中文文案、回带 `action`)由 `policy.method_not_allowed` 覆盖,行为不变;未知 key 抛错。
|
|
51
|
+
- `createBrowserAuthorizer({ getConnection, getConnectionSeen, guard, respond })` 收的是访问器而非值:宿主半的 Connection 会被服务重载置空,捕获值的那一版会一直放行给一个已销毁的 Connection。语义固定:Connection 抛异常 → 503 `connection_unavailable`;给出 rejection → 以该码回绝(401 → `unauthorized`,其余 → `forbidden`);见过 Connection 而当下没有 → 503,且绝不退回更弱的 guard;从未见过 → 交给 `guard`。
|
|
48
52
|
|
|
49
53
|
## 跨仓一致性
|
|
50
54
|
|
|
51
|
-
|
|
55
|
+
消费插件的 `npm test` 用 `loopback:check` / `guard:check` / `http:check` 把自己 `lib/shared.js` 里的三个块与所 pin 的 dock 版本逐字节比对。dock 版本不可变且消费方 pin 精确版本,故「pin 一致」即「片段一致」。反向不成立:`check` 只按它能识别的标记选块,一个还没抬 pin 的消费方根本不比对新增的那个块,对它这一项是空过的。所以新增片段后,消费方要重跑四个 `sync`,并把 pin 一起抬到发布版本。
|
|
52
56
|
|
|
53
|
-
`dsh-plugin-parity`(本包提供的 bin
|
|
57
|
+
`dsh-plugin-parity`(本包提供的 bin)直接检这条跨仓性质:逐块(剥离块的公共缩进后)比对三个 host 块(含 `dsh-host-http`)、pin 一致性断言、块落点顺序断言与行为级对比。它是人工诊断工具,**不在 CI 中运行**:peer 处于不同分支时该性质本就不成立。成员表由调用方经 `--member <repo>:<export>` 提供——守卫工厂的导出名按片段设计是各仓自定的,故本工具不预设任何成员;`--fleet <repo>` 点名唯一启用 fleet 模式的成员,只对它检查「放宽有边界」,点名不在成员表中的仓即判 FAIL 并非零退出;不带 `--member` 时只跑静态检查,自动发现所有嵌入了宿主守卫块的仓。`--self-test` 不需要任何 checkout:它用内联样例检验块提取与「丢块」两步,改动这两个函数后先跑它。
|
|
54
58
|
|
|
55
|
-
`dsh-plugin-docs`(同样是本包的 bin
|
|
59
|
+
`dsh-plugin-docs`(同样是本包的 bin)校验双语文档的结构对齐。它不预设文档名:检查哪些文档对、文件位于哪个目录、按什么形状比较,都由调用仓通过 `--config <module>` 提供一个 `{ name, zh, en, shape? }` 数组决定;本包用同一种方式声明自己的 `docs.config.mjs`。形状有两种。默认的 `markdown` 比较中英两侧的标题层级序列与代码围栏语言,围栏内的内容豁免,因为语言相关的示例写在那里。`changelog` 比较中英两侧的版本段(含 `Unreleased` 段)、分类段与小节条目数,分类标题经双语映射归一。`--base <revision>` 额外要求声明的每一对文件在该 revision 之后一起改动,单边改动即缺翻译;revision 为全 0 的 null OID(新建分支或强推时 `github.event.before` 即为此值)时没有可比的前态,这一项跳过并给出提示,其余结构校验照常。各仓的 `docs:check` script 调用它。
|
|
60
|
+
|
|
61
|
+
## 开发与验证
|
|
62
|
+
|
|
63
|
+
片段是唯一来源,所以一次改动要同时过片段行为、CLI 与文档这三层;下面是本仓的完整校验(与 `AGENTS.md` 的 Verify 块一致,发布步骤见 `docs/RELEASING.md`):
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
npm test
|
|
67
|
+
node bin/dsh-plugin-docs.js
|
|
68
|
+
for f in bin/*.js dist/*.js; do node --check "$f"; done
|
|
69
|
+
node bin/dsh-plugin-parity.js --self-test
|
|
70
|
+
npm pack --dry-run
|
|
71
|
+
```
|
|
@@ -1,42 +1,59 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Embed a canonical fragment from this package into a consumer file.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
4
|
+
// Every fragment is maintained the same way, because all of them have the same
|
|
5
5
|
// shape of problem: the consumer must ship the code standalone (a browser classic
|
|
6
|
-
// script cannot `import`, and a host half must not depend on
|
|
6
|
+
// script cannot `import`, and a host half must not depend on this package being
|
|
7
7
|
// installed), so the code is embedded at build time and drift is a build error.
|
|
8
8
|
//
|
|
9
|
-
// dsh-utility-launcher -> <dsh-utility-launcher> ... </dsh-utility-launcher>
|
|
10
|
-
// the family launcher (one icon plus the menu of family panels it opens),
|
|
11
|
-
// embedded into a plugin's lib/client.js
|
|
12
|
-
//
|
|
13
9
|
// dsh-loopback-helpers -> <dsh-loopback-helpers> ... </dsh-loopback-helpers>
|
|
14
10
|
// the loopback predicates, embedded into a plugin's lib/shared.js
|
|
15
11
|
//
|
|
16
12
|
// dsh-host-guard -> <dsh-host-guard> ... </dsh-host-guard>
|
|
17
13
|
// the same-origin request guard, embedded into the same file
|
|
18
14
|
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
15
|
+
// dsh-host-http -> <dsh-host-http> ... </dsh-host-http>
|
|
16
|
+
// the host response glue: JSON replies, the POST gate, the browser
|
|
17
|
+
// authorizer, embedded into the same file below the guard block
|
|
18
|
+
//
|
|
19
|
+
// dsh-utility-launcher -> <dsh-utility-launcher> ... </dsh-utility-launcher>
|
|
20
|
+
// the shared launcher assembly: one icon plus the menu of utility panels it
|
|
21
|
+
// opens, embedded into a plugin's lib/client.js
|
|
22
|
+
//
|
|
23
|
+
// The host half of a plugin embeds ALL THREE shared.js fragments, in the order
|
|
24
|
+
// above, because its guard reads the predicates the block above it declares. Each
|
|
25
|
+
// is an independent block with its own markers and its own check, so a plugin that
|
|
26
|
+
// only needs the predicates can embed just those.
|
|
23
27
|
//
|
|
24
28
|
// The fragment is selected by the marker already present in the target file, so
|
|
25
29
|
// one command serves every target and only a named block is ever touched. A
|
|
26
30
|
// consumer calls this through its own `launcher:sync` / `guard:sync` /
|
|
27
|
-
// `loopback:sync` script; the paired `check` mode is what CI runs.
|
|
31
|
+
// `loopback:sync` / `http:sync` script; the paired `check` mode is what CI runs.
|
|
28
32
|
|
|
29
33
|
import { readFile, writeFile, stat } from 'node:fs/promises'
|
|
30
34
|
import { resolve } from 'node:path'
|
|
31
35
|
import { fileURLToPath } from 'node:url'
|
|
32
36
|
|
|
33
|
-
// Listed in
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
+
// Listed in the order the blocks sit in a consumer's file. That order is
|
|
38
|
+
// load-bearing for the consumer, not for this tool:
|
|
39
|
+
//
|
|
40
|
+
// 1. the guard fragment reads the predicates the loopback fragment declares, so
|
|
41
|
+
// a consumer that wants the guard syncs both, loopback first;
|
|
42
|
+
// 2. the host-http fragment lands below the guard block, where a consumer's own
|
|
43
|
+
// `lib/shared.js` glue has always lived. It depends on neither block above
|
|
44
|
+
// it and declares nothing they declare, so the three coexist in one file;
|
|
45
|
+
// 3. the launcher is independent of all of them and lands in the client half
|
|
46
|
+
// instead, so it sits last.
|
|
47
|
+
//
|
|
48
|
+
// `main()` replaces each block working from the bottom of the file upward, and it
|
|
49
|
+
// orders that pass by the line numbers it found rather than by this list, so
|
|
50
|
+
// adding a row here states where a block sits and cannot shift another block's
|
|
51
|
+
// splice. Registering a fragment whose markers sit in a different order is still
|
|
52
|
+
// worth catching — `dsh-plugin-parity` checks it across repositories.
|
|
37
53
|
const FRAGMENTS = [
|
|
38
54
|
{ name: 'dsh-loopback-helpers', source: 'loopback.js' },
|
|
39
55
|
{ name: 'dsh-host-guard', source: 'guard.js' },
|
|
56
|
+
{ name: 'dsh-host-http', source: 'host-http.js' },
|
|
40
57
|
{ name: 'dsh-utility-launcher', source: 'launcher.js' }
|
|
41
58
|
]
|
|
42
59
|
|
|
@@ -44,12 +61,19 @@ const packageRoot = resolve(fileURLToPath(new URL('..', import.meta.url)))
|
|
|
44
61
|
const marker = (name, edge) => `// <${edge}${name}>`
|
|
45
62
|
|
|
46
63
|
function usage() {
|
|
47
|
-
const fragments = FRAGMENTS.map((f) => ` ${marker(f.name, '')} ->
|
|
64
|
+
const fragments = FRAGMENTS.map((f) => ` ${marker(f.name, '')} ... ${marker(f.name, '/')} -> dist/${f.source}`).join('\n')
|
|
48
65
|
return [
|
|
49
66
|
'Usage: dsh-mini-utility-dock <sync|check> <consumer-file>',
|
|
50
67
|
'',
|
|
51
|
-
'The
|
|
52
|
-
|
|
68
|
+
'The blocks to maintain are the ones whose markers are present in <consumer-file>;',
|
|
69
|
+
'one command handles every block in that file:',
|
|
70
|
+
fragments,
|
|
71
|
+
'',
|
|
72
|
+
'A plugin host half embeds the three lib/shared.js blocks: the loopback',
|
|
73
|
+
'predicates, the same-origin guard that reads them, and the host HTTP glue',
|
|
74
|
+
'(JSON replies, the POST gate, the browser authorizer) below the guard. The',
|
|
75
|
+
'launcher block belongs to the client half in lib/client.js.',
|
|
76
|
+
'`sync` writes them, `check` exits non-zero on drift.'
|
|
53
77
|
].join('\n')
|
|
54
78
|
}
|
|
55
79
|
|
|
@@ -72,9 +96,10 @@ function indentation(line) {
|
|
|
72
96
|
}
|
|
73
97
|
|
|
74
98
|
// Locate every marked block in the file, in fragment order. A host half embeds
|
|
75
|
-
//
|
|
76
|
-
// FRAGMENTS order also keeps the dependency order (the guard
|
|
77
|
-
// declared by the loopback block above it
|
|
99
|
+
// all three shared.js fragments, so one command must maintain all of them;
|
|
100
|
+
// processing them in FRAGMENTS order also keeps the dependency order (the guard
|
|
101
|
+
// reads the predicates declared by the loopback block above it, and the HTTP glue
|
|
102
|
+
// sits below both without reading either).
|
|
78
103
|
function locate(source) {
|
|
79
104
|
const lines = source.split(/\r?\n/)
|
|
80
105
|
const candidates = FRAGMENTS.map((fragment) => {
|
|
@@ -91,7 +116,20 @@ function locate(source) {
|
|
|
91
116
|
throw new Error(`expected exactly one marked block for ${fragment.name} (${marker(fragment.name, '')} ... ${marker(fragment.name, '/')})`)
|
|
92
117
|
}
|
|
93
118
|
}
|
|
94
|
-
|
|
119
|
+
const blocks = candidates.map(({ fragment, start, end }) => ({ fragment, start: start[0], end: end[0] }))
|
|
120
|
+
// Each block is replaced by splicing its own line range, so two different
|
|
121
|
+
// fragments' ranges must not overlap. Interleaved markers (`<A> <B> </A> </B>`)
|
|
122
|
+
// would make one pass write into the range another pass had just rewritten, so
|
|
123
|
+
// the result would be corrupted rather than merely stale; the uniqueness of each
|
|
124
|
+
// marker pair is enforced above, and this closes the case a single pair cannot.
|
|
125
|
+
for (let i = 0; i < blocks.length; i++) {
|
|
126
|
+
for (let j = i + 1; j < blocks.length; j++) {
|
|
127
|
+
if (blocks[i].start <= blocks[j].end && blocks[j].start <= blocks[i].end) {
|
|
128
|
+
throw new Error(`marked blocks for ${blocks[i].fragment.name} and ${blocks[j].fragment.name} overlap; each marker pair must enclose its own lines`)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return { lines, blocks }
|
|
95
133
|
}
|
|
96
134
|
|
|
97
135
|
function rendered(lines, fragmentSource, block) {
|
|
@@ -101,9 +139,9 @@ function rendered(lines, fragmentSource, block) {
|
|
|
101
139
|
return [...lines.slice(0, block.start + 1), ...body, ...lines.slice(block.end)]
|
|
102
140
|
}
|
|
103
141
|
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
142
|
+
// One pass over the target file maintains EVERY marked block it carries, applied
|
|
143
|
+
// from the bottom of the file upward so each block's line indices are still valid
|
|
144
|
+
// when its turn comes. See the loop below for why that order is the whole trick.
|
|
107
145
|
async function main() {
|
|
108
146
|
const [command, fileName, ...rest] = process.argv.slice(2)
|
|
109
147
|
if (!['sync', 'check'].includes(command) || !fileName || rest.length) throw new Error(usage())
|
|
@@ -112,7 +150,14 @@ async function main() {
|
|
|
112
150
|
|
|
113
151
|
let lines = located.lines
|
|
114
152
|
const applied = []
|
|
115
|
-
|
|
153
|
+
// Walk by line position, the block nearest the end of the file first. Each pass
|
|
154
|
+
// replaces a line range, so every block above it must keep valid indices; going
|
|
155
|
+
// top-down would shift them and a later pass would splice the wrong lines.
|
|
156
|
+
// `locate()` has already rejected any file whose marker ranges overlap, so these
|
|
157
|
+
// blocks are disjoint: the pass can be ordered by the lines themselves rather
|
|
158
|
+
// than by the table, and a table/file order disagreement then only rewrites
|
|
159
|
+
// blocks in a different sequence, corrupting nothing.
|
|
160
|
+
for (const block of [...located.blocks].sort((a, b) => b.start - a.start)) {
|
|
116
161
|
const fragmentSource = await readFile(resolve(packageRoot, 'dist', block.fragment.source), 'utf8')
|
|
117
162
|
lines = rendered(lines, fragmentSource, block)
|
|
118
163
|
applied.unshift(block.fragment.name)
|
package/bin/dsh-plugin-docs.js
CHANGED
|
@@ -1,23 +1,31 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// Bilingual structure check for the documentation pairs
|
|
3
|
-
// README.md against README.en.md, CHANGELOG.md against CHANGELOG.en.md.
|
|
2
|
+
// Bilingual structure check for the documentation pairs a repository ships.
|
|
4
3
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
4
|
+
// The pairs are the repository's own declaration — `--config <module>` points at
|
|
5
|
+
// a module exporting `{ name, zh, en, shape? }` entries, so which documents
|
|
6
|
+
// exist, where they live, and how each is compared is decided there, never here.
|
|
7
|
+
// Two shapes are provided:
|
|
8
|
+
// * `markdown` (the default): both files share the same heading-level sequence
|
|
9
|
+
// and code-fence languages — content inside fences is exempt, since that is
|
|
10
|
+
// where language-specific examples live;
|
|
11
|
+
// * `changelog`: both files expose the same releases — the `Unreleased` section
|
|
12
|
+
// included, since that is where the newest edits land — with version, date,
|
|
13
|
+
// per-section item counts, section titles normalized through a bilingual
|
|
12
14
|
// category map (新增/Added, 修复/Fixed, ...);
|
|
13
|
-
// * with `--base <revision>`, both files of
|
|
14
|
-
// since that revision — a one-sided edit is a missing translation.
|
|
15
|
+
// * with `--base <revision>`, both files of every declared pair changed
|
|
16
|
+
// together since that revision — a one-sided edit is a missing translation.
|
|
17
|
+
// An all-zero revision is the null OID, which is what `github.event.before`
|
|
18
|
+
// carries for a branch that was just created or force-pushed: there is no
|
|
19
|
+
// previous revision to diff against, so that one requirement is skipped with
|
|
20
|
+
// a note instead of dying inside `git diff`.
|
|
15
21
|
//
|
|
16
22
|
// Repositories opt in through their own `docs:check` script; this package
|
|
17
|
-
//
|
|
23
|
+
// declares its pairs the same way.
|
|
18
24
|
|
|
19
25
|
import { execFileSync } from 'node:child_process'
|
|
20
26
|
import fs from 'node:fs'
|
|
27
|
+
import path from 'node:path'
|
|
28
|
+
import { pathToFileURL } from 'node:url'
|
|
21
29
|
|
|
22
30
|
const read = (file) => fs.readFileSync(file, 'utf8').replace(/\r\n/g, '\n')
|
|
23
31
|
|
|
@@ -47,12 +55,26 @@ function changelogShape(file) {
|
|
|
47
55
|
let release
|
|
48
56
|
let section
|
|
49
57
|
|
|
58
|
+
// A `## ...` heading opens a release and clears the current section, so bullets
|
|
59
|
+
// that follow it before any `###` still count — into an anonymous leading
|
|
60
|
+
// section — instead of being silently dropped.
|
|
61
|
+
const openRelease = (version, date) => {
|
|
62
|
+
release = { version, date, sections: [] }
|
|
63
|
+
releases.push(release)
|
|
64
|
+
section = undefined
|
|
65
|
+
}
|
|
66
|
+
|
|
50
67
|
for (const line of read(file).split('\n')) {
|
|
51
68
|
const version = line.match(/^##\s+(\d+\.\d+\.\d+)(?:\s+-\s+(\d{4}-\d{2}-\d{2}))?\s*$/)
|
|
52
69
|
if (version) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
70
|
+
openRelease(version[1], version[2] ?? '')
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
// `Unreleased` is the version-less top release and usually the most-edited
|
|
74
|
+
// section, so it is compared with the same shape as a numbered one. Both
|
|
75
|
+
// languages keep the literal heading, so the labels match directly.
|
|
76
|
+
if (/^##\s+Unreleased\s*$/i.test(line)) {
|
|
77
|
+
openRelease('Unreleased', '')
|
|
56
78
|
continue
|
|
57
79
|
}
|
|
58
80
|
if (!release) continue
|
|
@@ -63,7 +85,13 @@ function changelogShape(file) {
|
|
|
63
85
|
release.sections.push(section)
|
|
64
86
|
continue
|
|
65
87
|
}
|
|
66
|
-
if (
|
|
88
|
+
if (/^\s*-\s+/.test(line)) {
|
|
89
|
+
if (!section) {
|
|
90
|
+
section = { title: '', items: 0 }
|
|
91
|
+
release.sections.push(section)
|
|
92
|
+
}
|
|
93
|
+
section.items += 1
|
|
94
|
+
}
|
|
67
95
|
}
|
|
68
96
|
|
|
69
97
|
return releases
|
|
@@ -80,16 +108,6 @@ const category = new Map([
|
|
|
80
108
|
['维护', 'maintenance'], ['Maintenance', 'maintenance'],
|
|
81
109
|
])
|
|
82
110
|
|
|
83
|
-
function assertEqual(left, right, message) {
|
|
84
|
-
if (JSON.stringify(left) !== JSON.stringify(right)) {
|
|
85
|
-
throw new Error(`${message}\nleft: ${JSON.stringify(left)}\nright: ${JSON.stringify(right)}`)
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
const zhReadme = markdownShape('README.md')
|
|
90
|
-
const enReadme = markdownShape('README.en.md')
|
|
91
|
-
assertEqual(zhReadme, enReadme, 'README structure differs between languages')
|
|
92
|
-
|
|
93
111
|
const normalizeLog = (file) => changelogShape(file).map((release) => ({
|
|
94
112
|
version: release.version,
|
|
95
113
|
date: release.date,
|
|
@@ -98,23 +116,69 @@ const normalizeLog = (file) => changelogShape(file).map((release) => ({
|
|
|
98
116
|
items,
|
|
99
117
|
})),
|
|
100
118
|
}))
|
|
101
|
-
|
|
119
|
+
|
|
120
|
+
function assertEqual(left, right, message) {
|
|
121
|
+
if (JSON.stringify(left) !== JSON.stringify(right)) {
|
|
122
|
+
throw new Error(`${message}\nleft: ${JSON.stringify(left)}\nright: ${JSON.stringify(right)}`)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// The repository declares its own pairs; this tool names none of them. Paths are
|
|
127
|
+
// read from the current directory, so run it from the repository root — `--base`
|
|
128
|
+
// compares against paths as Git reports them.
|
|
129
|
+
async function loadPairs(argv) {
|
|
130
|
+
const index = argv.indexOf('--config')
|
|
131
|
+
const file = argv[index + 1]
|
|
132
|
+
if (index === -1 || !file) {
|
|
133
|
+
throw new Error('--config <module> is required: the module exports the bilingual pairs this repository ships')
|
|
134
|
+
}
|
|
135
|
+
if (!fs.existsSync(file)) throw new Error(`${file} not found — declare the repository's pairs there`)
|
|
136
|
+
|
|
137
|
+
const loaded = await import(pathToFileURL(path.resolve(file)).href)
|
|
138
|
+
const pairs = loaded.pairs ?? loaded.default
|
|
139
|
+
|
|
140
|
+
if (!Array.isArray(pairs) || pairs.length === 0) {
|
|
141
|
+
throw new Error(`${file}: export a non-empty array of pairs`)
|
|
142
|
+
}
|
|
143
|
+
for (const pair of pairs) {
|
|
144
|
+
for (const field of ['name', 'zh', 'en']) {
|
|
145
|
+
if (typeof pair?.[field] !== 'string' || !pair[field]) throw new Error(`${file}: every pair needs name, zh and en`)
|
|
146
|
+
}
|
|
147
|
+
if (pair.shape !== undefined && !shapes[pair.shape]) throw new Error(`${file}: ${pair.name}: unknown shape '${pair.shape}'`)
|
|
148
|
+
}
|
|
149
|
+
return pairs
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const shapes = { markdown: markdownShape, changelog: normalizeLog }
|
|
153
|
+
|
|
154
|
+
const pairs = await loadPairs(process.argv)
|
|
155
|
+
|
|
156
|
+
for (const pair of pairs) {
|
|
157
|
+
const shape = shapes[pair.shape ?? 'markdown']
|
|
158
|
+
assertEqual(shape(pair.zh), shape(pair.en), `${pair.name} structure differs between languages`)
|
|
159
|
+
}
|
|
102
160
|
|
|
103
161
|
const baseIndex = process.argv.indexOf('--base')
|
|
104
162
|
if (baseIndex !== -1) {
|
|
105
163
|
const base = process.argv[baseIndex + 1]
|
|
106
164
|
if (!base) throw new Error('--base requires a Git revision')
|
|
107
165
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
166
|
+
// The null OID, in either hash length. A workflow that passes
|
|
167
|
+
// `github.event.before` here gets it whenever a branch is created or
|
|
168
|
+
// force-pushed, and `git diff 000... HEAD` is not a comparison with no base —
|
|
169
|
+
// it is an error. So the honest reading is the one the push event states: there
|
|
170
|
+
// is nothing before this push, and the pair test cannot run.
|
|
171
|
+
if (/^0+$/.test(base)) {
|
|
172
|
+
console.log(`note: --base ${base} is the null OID (new branch or force-push) — pair-change check skipped`)
|
|
173
|
+
} else {
|
|
174
|
+
const changed = new Set(execFileSync('git', ['diff', '--name-only', base, 'HEAD'], { encoding: 'utf8' })
|
|
175
|
+
.split(/\r?\n/)
|
|
176
|
+
.filter(Boolean))
|
|
177
|
+
|
|
178
|
+
for (const pair of pairs) {
|
|
179
|
+
if (changed.has(pair.zh) !== changed.has(pair.en)) {
|
|
180
|
+
throw new Error(`${pair.zh} and ${pair.en} must change together`)
|
|
181
|
+
}
|
|
118
182
|
}
|
|
119
183
|
}
|
|
120
184
|
}
|