@qnroa/qtype 0.0.8 → 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/CHANGELOG.md +146 -0
- package/CHANGELOG.zh.md +111 -0
- package/dist/cli/commands/content/lint/action.js +32 -3
- package/dist/cli/commands/content/lint/i18n.js +2 -0
- package/dist/cli/commands/publish/build/action.js +26 -11
- package/dist/cli/commands/publish/build/mermaidPass.js +29 -9
- package/dist/cli/commands/repo/sync/action.js +62 -1
- package/dist/cli/utils/prompt.js +7 -3
- package/dist/core/cardId.js +101 -0
- package/dist/core/index.js +1 -0
- package/dist/core/parser/frontmatter.js +2 -3
- package/dist/core/parser/parseMaterial.js +35 -4
- package/dist/core/typing/engine/settings.js +0 -2
- package/dist/core/typing/engine/stats.js +1 -4
- package/dist/core/typing/metrics.js +1 -4
- package/dist/core/typing/round.js +12 -0
- package/dist/store/index.js +1 -0
- package/dist/store/storage/browser.js +49 -0
- package/dist/store/storage/session.js +92 -0
- package/dist/view/assets/index-BY0yuQoY.js +182 -0
- package/dist/view/assets/{index-DFOCJiFs.js → index-Bp7wq7S1.js} +1 -1
- package/dist/view/assets/{index-BkcIwMkg.js → index-D6FqmGLJ.js} +2 -2
- package/dist/view/assets/{index-TxZJkfgF.css → index-_1m9NuFe.css} +1 -1
- package/dist/view/index.html +2 -2
- package/docs/en/cli-reference.md +10 -6
- package/docs/en/deployment.md +2 -1
- package/docs/zh/cli-reference.md +10 -6
- package/docs/zh/deployment.md +2 -1
- package/package.json +2 -1
- package/dist/view/assets/index-BLq7QuZg.js +0 -181
package/CHANGELOG.md
CHANGED
|
@@ -13,6 +13,152 @@ can land in any minor bump (`0.x.0`).
|
|
|
13
13
|
|
|
14
14
|
## [Unreleased]
|
|
15
15
|
|
|
16
|
+
## [0.1.0] — 2026-08-27
|
|
17
|
+
|
|
18
|
+
Breaking dist format. Source `material/*.md` unchanged — the source
|
|
19
|
+
grammar and semantics are preserved end-to-end. Users must re-run
|
|
20
|
+
`qtype publish build` once; deployed sites need to re-upload the new
|
|
21
|
+
`dist/`.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- **`splitByH1` now respects fenced code blocks.** A `# ...` line
|
|
25
|
+
inside a ``` ``` fence is no longer mistaken for a new card. This
|
|
26
|
+
was silently slicing materials with hash-comment code samples (e.g.
|
|
27
|
+
a regex primer's Python snippets) into dozens of bogus cards whose
|
|
28
|
+
indices leaked downstream through the `<file>#<idx>` id scheme.
|
|
29
|
+
- **`promptPassword` recognises Ctrl-C (`\x03`) and DEL (`\x7f`).**
|
|
30
|
+
Both control characters had been silently dropped from the source
|
|
31
|
+
during an earlier copy-paste, leaving password entry unable to
|
|
32
|
+
abort on Ctrl-C or backspace correctly on modern terminals.
|
|
33
|
+
- **HUD "back to library" preserves the selected tag filter.** The
|
|
34
|
+
purge effect in `useLibraryFilters` used to run against the initial
|
|
35
|
+
`availableTags: []` from a still-loading manifest and nuke every
|
|
36
|
+
persisted tag before the corpus arrived. Now waits until the corpus
|
|
37
|
+
is known.
|
|
38
|
+
- **Filter-list navigation walks the filter, not the source material.**
|
|
39
|
+
`useFilterFlow` no longer depends on the async manifest — the
|
|
40
|
+
neighbour `(file, idx)` is carried inside each hit entry, so
|
|
41
|
+
`next()` / `prev()` work from the moment localStorage settles.
|
|
42
|
+
- **Grid ↔ list view toggle is orthogonal to filtering.** Prior
|
|
43
|
+
versions forced list layout whenever a tag/type filter was active,
|
|
44
|
+
ignoring the user's chosen mode. Now grid stays grid, list stays
|
|
45
|
+
list — the filter changes *what* is listed (materials vs. matching
|
|
46
|
+
cards), not *how*.
|
|
47
|
+
- **F5 keeps the filter hit list.** The hit list moved from a
|
|
48
|
+
module-level variable to `localStorage`, so refreshing the typing
|
|
49
|
+
page no longer degrades to intra-material navigation.
|
|
50
|
+
|
|
51
|
+
### Changed (breaking)
|
|
52
|
+
- **`Card.id` is a 12-hex-char content hash instead of
|
|
53
|
+
`<file>#<idx>`.** Reordering cards inside a material no longer
|
|
54
|
+
invalidates every downstream reference (bookmarks, filter picks,
|
|
55
|
+
CDN cache keys). Position information is preserved as first-class
|
|
56
|
+
fields on the card: `Card.materialFile` + `Card.indexInMaterial`.
|
|
57
|
+
- **`Card` gains required fields `materialFile` and `indexInMaterial`.**
|
|
58
|
+
Downstream consumers that need location info should read these
|
|
59
|
+
instead of parsing `id`.
|
|
60
|
+
- **`dist/material/mermaid.json` is gone.** Mermaid SVGs are now
|
|
61
|
+
sharded per material: `qa/security.md` gets
|
|
62
|
+
`dist/material/qa/security.mermaid.json`. Encrypted repos land as
|
|
63
|
+
`<file>.mermaid.json.enc`. The browser only fetches the shard for
|
|
64
|
+
the material it's rendering, not the union of every diagram in the
|
|
65
|
+
repo.
|
|
66
|
+
- **Build ships one JSON per material, not the raw `.md`.** Source
|
|
67
|
+
`qa/security.md` now becomes `qa/security.json` under
|
|
68
|
+
`dist/material/`, containing the fully-parsed `Material`
|
|
69
|
+
(structured body, precomputed romanization). Encrypted repos land
|
|
70
|
+
as `qa/security.json.enc`. The browser no longer parses markdown —
|
|
71
|
+
it fetches the JSON and renders directly. (Introduced in 0.0.8; the
|
|
72
|
+
full breaking history is kept here for the 0.1.0 upgrade guide.)
|
|
73
|
+
- **`index.json.tagIndex` / `typeIndex` map to card ids
|
|
74
|
+
(content-hash), not file paths.** Filter UIs can enumerate matching
|
|
75
|
+
cards without loading any material file. (Introduced in 0.0.8.)
|
|
76
|
+
|
|
77
|
+
### Added
|
|
78
|
+
- **`computeCardId(input)`** exported from `@qnroa/qtype/core` for
|
|
79
|
+
external tooling that wants to compute the same stable id qtype
|
|
80
|
+
uses.
|
|
81
|
+
- **Card-level filter view on Home.** Selecting a tag or type
|
|
82
|
+
replaces the material grid with matching cards, laid out per the
|
|
83
|
+
current grid/list mode. (Introduced in 0.0.8, generalised to
|
|
84
|
+
grid + list in 0.0.9.)
|
|
85
|
+
- **Cross-material filter navigation in typing.** Clicking a card
|
|
86
|
+
from the filter hit list carries the hit list into the typing
|
|
87
|
+
route — prev/next walks the filter results across materials, not
|
|
88
|
+
the source material's own card order. (Introduced in 0.0.8;
|
|
89
|
+
refactored to hit-entry rows carrying `(id, file, idx)` in 0.1.0.)
|
|
90
|
+
- **`EntryCard` / `EntryRow` base components + `CardHitCard` /
|
|
91
|
+
`CardHitRow` specialisations.** Materials and card hits share one
|
|
92
|
+
shell (click / keyboard / selection / done stamp) so visual
|
|
93
|
+
behaviour is consistent regardless of what's being listed.
|
|
94
|
+
(Introduced in 0.0.9.)
|
|
95
|
+
- **Lint warning `card.unknownType`.** Cards whose `type` isn't one
|
|
96
|
+
of the six built-ins (word / sentence / article / snippet / qa /
|
|
97
|
+
code) now surface a warning — 90% of the time it's a typo that
|
|
98
|
+
would otherwise render silently wrong.
|
|
99
|
+
- **`qtype init --force` skips sample material seeds on repos with
|
|
100
|
+
real content.** Established repos no longer accumulate 6 unwanted
|
|
101
|
+
starter `.md` files (plus their asset SVGs) on every re-sync.
|
|
102
|
+
|
|
103
|
+
### Migration
|
|
104
|
+
- Upgrade the qtype dependency, then run `qtype publish build` once.
|
|
105
|
+
- The old `dist/` layout is not read anymore; delete the deployed
|
|
106
|
+
copy and upload the fresh build.
|
|
107
|
+
- User progress (`qtype.done`, `qtype.doneCards`) is stored under
|
|
108
|
+
`(file, indexInMaterial)` and still works — the new `id` is a
|
|
109
|
+
parallel identity for filter / bookmark purposes.
|
|
110
|
+
|
|
111
|
+
## [0.0.10] — 2026-08-27
|
|
112
|
+
|
|
113
|
+
Bug-fix release. Two regressions from the 0.0.8 filter feature —
|
|
114
|
+
both view-only, no dist change.
|
|
115
|
+
|
|
116
|
+
### Fixed
|
|
117
|
+
- **Filter-list navigation now actually walks the filter, not the
|
|
118
|
+
source material.** `useFilterFlow` depended on the manifest's flat
|
|
119
|
+
card index to resolve neighbour (file, idx), but that index arrives
|
|
120
|
+
async — until it landed, `next()` / `prev()` returned `null` and
|
|
121
|
+
the typing route silently fell back to intra-material navigation.
|
|
122
|
+
The hook now parses `(file, idx)` directly out of the card id
|
|
123
|
+
(`<file>#<idx>`), so it works from the moment localStorage settles.
|
|
124
|
+
- **Selected tags survive HUD "back to library".** The purge effect
|
|
125
|
+
in `useLibraryFilters` ran on the initial `availableTags: []` from
|
|
126
|
+
a still-loading manifest and nuked every persisted tag before the
|
|
127
|
+
corpus arrived. The purge now waits until the corpus is known
|
|
128
|
+
(new `corpusReady` param, defaults to `true` for backward
|
|
129
|
+
compatibility).
|
|
130
|
+
|
|
131
|
+
## [0.0.9] — 2026-08-27
|
|
132
|
+
|
|
133
|
+
View-only. No dist change, no need to rebuild — refreshing the page
|
|
134
|
+
on an existing deployment is enough.
|
|
135
|
+
|
|
136
|
+
### Changed
|
|
137
|
+
- **Grid ↔ list view toggle is now the outermost switch, orthogonal
|
|
138
|
+
to filtering.** Previously any active tag/type filter forced the
|
|
139
|
+
home page into a list layout, ignoring the user's chosen view
|
|
140
|
+
mode. Now grid stays grid, list stays list — the filter changes
|
|
141
|
+
*what* is listed (materials vs. matching cards), not *how*.
|
|
142
|
+
|
|
143
|
+
### Added
|
|
144
|
+
- **`CardHitCard`** — grid tile for a card hit, sibling of
|
|
145
|
+
`MaterialCard`. Kept alongside the existing `CardHitRow` so the
|
|
146
|
+
new 2×2 (grid/list × material/card) matrix has one component per
|
|
147
|
+
cell.
|
|
148
|
+
- **`EntryCard` / `EntryRow` base components.** The reusable shell
|
|
149
|
+
(click / keyboard routing, selection checkbox, done stamp,
|
|
150
|
+
hover / focus states, header + actions slots) is now factored
|
|
151
|
+
out so specializations only own their body content. Same
|
|
152
|
+
`.qt-deckcard*` / `.qt-deckrow*` selectors, no visual change.
|
|
153
|
+
- **`RowTags` helper.** The responsive tag-chip truncation logic
|
|
154
|
+
from `MaterialRow` moved into a shared component and is now used
|
|
155
|
+
by `CardHitRow` too.
|
|
156
|
+
|
|
157
|
+
### Internal
|
|
158
|
+
- No public API changes to `MaterialRepository` or build outputs.
|
|
159
|
+
- Card-level filter view now shows a `done` stamp per card
|
|
160
|
+
(backed by `useDoneState.isCardDone`).
|
|
161
|
+
|
|
16
162
|
## [0.0.8] — 2026-08-27
|
|
17
163
|
|
|
18
164
|
Breaking dist format. Source `material/*.md` unchanged — the change
|
package/CHANGELOG.zh.md
CHANGED
|
@@ -12,6 +12,117 @@ English: [CHANGELOG.md](https://www.npmjs.com/package/@qnroa/qtype?activeTab=cod
|
|
|
12
12
|
|
|
13
13
|
## [Unreleased]
|
|
14
14
|
|
|
15
|
+
## [0.1.0] — 2026-08-27
|
|
16
|
+
|
|
17
|
+
破坏性 dist 格式变更。源 `material/*.md` 未变 —— 语法和语义端到端
|
|
18
|
+
保持一致。用户需要重新跑一次 `qtype publish build`;已部署的站点
|
|
19
|
+
需要重新上传新的 `dist/`。
|
|
20
|
+
|
|
21
|
+
### 修复
|
|
22
|
+
- **`splitByH1` 识别代码块**。fenced code block 里以 `# ...` 开头的
|
|
23
|
+
行不再被误认为新卡。之前会把带 hash 注释的代码示例(比如 regex
|
|
24
|
+
教程里的 Python 片段)切成几十个假卡片,让 `<file>#<idx>` 的 id
|
|
25
|
+
索引跳跃。
|
|
26
|
+
- **`promptPassword` 识别 Ctrl-C (`\x03`) 和 DEL (`\x7f`)**。两个
|
|
27
|
+
控制字符早期从 qrism 复制过来时被吃掉了,导致输密码时 Ctrl-C 无
|
|
28
|
+
法中断,现代终端的 Backspace 也无效。
|
|
29
|
+
- **HUD"返回上级"后 tag 筛选保留**。`useLibraryFilters` 的 purge
|
|
30
|
+
effect 之前会在 manifest 还没加载完(`availableTags = []`)的初
|
|
31
|
+
始态跑,把持久化的所有 tag 都清掉。现在等 corpus 就绪再跑。
|
|
32
|
+
- **筛选后翻页真的走筛选,不是走材料内**。`useFilterFlow` 不再依
|
|
33
|
+
赖异步 manifest —— 每条命中记录本身携带 `(file, idx)`,
|
|
34
|
+
`next()` / `prev()` 从 localStorage 就绪起就能工作。
|
|
35
|
+
- **grid ↔ list 视图和筛选正交**。之前只要勾了 tag/type,不管用户
|
|
36
|
+
当前选的是 grid 还是 list,都强制变 list。现在 grid 保持 grid,
|
|
37
|
+
list 保持 list,筛选只决定"列的是什么"(材料还是命中卡)。
|
|
38
|
+
- **F5 保留筛选命中列表**。hitList 从 module 级内存变量迁到
|
|
39
|
+
localStorage,刷新 typing 页不再回落到材料内翻页。
|
|
40
|
+
|
|
41
|
+
### 变更(破坏性)
|
|
42
|
+
- **`Card.id` 是 12 位内容 hash,不再是 `<file>#<idx>`**。材料内重排
|
|
43
|
+
卡片不再让下游引用(书签、筛选选中、CDN 缓存 key)全部失效。位置
|
|
44
|
+
信息作为 Card 的一等字段保留:`Card.materialFile` 和
|
|
45
|
+
`Card.indexInMaterial`。
|
|
46
|
+
- **`Card` 新增必填字段 `materialFile` 和 `indexInMaterial`**。需要
|
|
47
|
+
位置信息的下游消费者应该读这两个字段而不是解析 `id`。
|
|
48
|
+
- **`dist/material/mermaid.json` 消失**。Mermaid SVG 按材料分片:
|
|
49
|
+
`qa/security.md` 对应 `dist/material/qa/security.mermaid.json`。
|
|
50
|
+
加密仓库落到 `<file>.mermaid.json.enc`。浏览器只 fetch 当前材料
|
|
51
|
+
的 shard,不再下载全站图的并集。
|
|
52
|
+
- **build 产物为每份材料一个 JSON,不再拷贝 `.md`**。源文件
|
|
53
|
+
`qa/security.md` 现在会构建为 `dist/material/qa/security.json`,
|
|
54
|
+
内容是已完整解析的 `Material`。加密仓库落地为
|
|
55
|
+
`qa/security.json.enc`。浏览器不再解析 markdown。(0.0.8 引入,
|
|
56
|
+
0.1.0 升级指南里保留完整破坏性历史。)
|
|
57
|
+
- **`index.json.tagIndex` / `typeIndex` 值改为卡 id(内容 hash),
|
|
58
|
+
不再是材料 file**。(0.0.8 引入。)
|
|
59
|
+
|
|
60
|
+
### 新增
|
|
61
|
+
- **`computeCardId(input)`** 从 `@qnroa/qtype/core` 导出,给外部
|
|
62
|
+
工具用来算和 qtype 一致的稳定 id。
|
|
63
|
+
- **Home 卡级筛选视图**。勾一个 tag 或 type 后,材料网格被替换成
|
|
64
|
+
命中卡列表,按当前 grid/list 模式展示。(0.0.8 引入,0.0.9 泛化
|
|
65
|
+
为 grid + list。)
|
|
66
|
+
- **筛选跨材料翻页**。从命中卡列表点进 typing 时,前端把命中列表
|
|
67
|
+
带到 typing 路由,prev/next 沿命中列表跨材料游走。(0.0.8 引入,
|
|
68
|
+
0.1.0 重构成每条记录带 `(id, file, idx)`。)
|
|
69
|
+
- **`EntryCard` / `EntryRow` 基类 + `CardHitCard` / `CardHitRow`
|
|
70
|
+
特化**。材料和命中卡共享一套 shell(点击 / 键盘 / 选中 /
|
|
71
|
+
done stamp),视觉行为一致。(0.0.9 引入。)
|
|
72
|
+
- **lint 加 `card.unknownType` 警告**。`type` 不是内置 6 种(word /
|
|
73
|
+
sentence / article / snippet / qa / code)时报警 —— 90% 都是手
|
|
74
|
+
滑,不然会静默走 article 兜底渲染。
|
|
75
|
+
- **`qtype init --force` 已有真实内容的 repo 跳过 sample seed**。
|
|
76
|
+
成熟 repo 每次同步不再累积 6 个 starter md(含 asset SVG)噪音。
|
|
77
|
+
|
|
78
|
+
### 迁移
|
|
79
|
+
- 升级 qtype 依赖后,跑一次 `qtype publish build`。
|
|
80
|
+
- 老 `dist/` 结构已不再被读取,直接删除并重新部署。
|
|
81
|
+
- 用户进度(`qtype.done` / `qtype.doneCards`)按 `(file,
|
|
82
|
+
indexInMaterial)` 存,继续可用 —— 新的 `id` 是给筛选 / 书签用的
|
|
83
|
+
并列身份,不替代它。
|
|
84
|
+
|
|
85
|
+
## [0.0.10] — 2026-08-27
|
|
86
|
+
|
|
87
|
+
Bug 修复。两个从 0.0.8 筛选功能引入的回归 —— 都是 view 层,dist 未变。
|
|
88
|
+
|
|
89
|
+
### 修复
|
|
90
|
+
- **筛选后翻页现在真的走筛选,不是走材料内**。`useFilterFlow` 之前
|
|
91
|
+
依赖 manifest 的扁平卡索引来解析下一张的 `(file, idx)`,但该索引
|
|
92
|
+
是异步加载的 —— 在它到达之前,`next()` / `prev()` 返回 `null`,
|
|
93
|
+
typing 页静默回退到材料内翻页。现在改成直接从 cardId (`<file>#<idx>`)
|
|
94
|
+
自解析 `(file, idx)`,localStorage 一就位就能用。
|
|
95
|
+
- **HUD"返回上级"后 tag 筛选保留**。`useLibraryFilters` 的 purge
|
|
96
|
+
effect 会在 manifest 还没加载完(此时 `availableTags` 是 `[]`)
|
|
97
|
+
的初始态就跑,把持久化的所有 tag 都清掉。现在 purge 会等到 corpus
|
|
98
|
+
就绪才跑(新的 `corpusReady` 参数,默认 `true` 保持向后兼容)。
|
|
99
|
+
|
|
100
|
+
## [0.0.9] — 2026-08-27
|
|
101
|
+
|
|
102
|
+
只改 view,dist 未变,老部署刷新页面即可,无需重跑 build。
|
|
103
|
+
|
|
104
|
+
### 变更
|
|
105
|
+
- **grid ↔ list 视图切换现在是最外层开关,和筛选正交**。之前只要
|
|
106
|
+
勾了 tag/type,不管用户当前选的是 grid 还是 list,都强制变成 list;
|
|
107
|
+
现在 grid 保持 grid,list 保持 list,筛选只决定"列的是什么"
|
|
108
|
+
(材料还是命中卡),不决定"怎么呈现"。
|
|
109
|
+
|
|
110
|
+
### 新增
|
|
111
|
+
- **`CardHitCard`** — 命中卡的 grid 版本,和 `MaterialCard` 是兄弟。
|
|
112
|
+
与已有的 `CardHitRow` 一起,凑齐新的 2×2 (grid/list × 材料/卡)
|
|
113
|
+
组合,每格一个组件。
|
|
114
|
+
- **`EntryCard` / `EntryRow` 基类**。可复用的容器逻辑(点击/键盘
|
|
115
|
+
路由、选中框、done stamp、hover/focus 态、header + actions 槽)
|
|
116
|
+
抽成基类,特化组件只负责自己的 body。`.qt-deckcard*` / `.qt-deckrow*`
|
|
117
|
+
样式选择器不变,视觉零变化。
|
|
118
|
+
- **`RowTags` helper**。之前写死在 `MaterialRow` 里的响应式 tag
|
|
119
|
+
chip 截断逻辑抽成独立组件,现在 `CardHitRow` 也能复用。
|
|
120
|
+
|
|
121
|
+
### 内部
|
|
122
|
+
- `MaterialRepository` 公共接口和 build 产物未变。
|
|
123
|
+
- 命中卡列表 grid/list 都开始显示卡级 done stamp
|
|
124
|
+
(由 `useDoneState.isCardDone` 提供)。
|
|
125
|
+
|
|
15
126
|
## [0.0.8] — 2026-08-27
|
|
16
127
|
|
|
17
128
|
破坏性 dist 格式变更。源 `material/*.md` 不变 —— 变化都在 build
|
|
@@ -11,6 +11,17 @@ import { ensureQtypeRepo } from '../../../utils/repoGuard.js';
|
|
|
11
11
|
import { t } from '../../../i18n/index.js';
|
|
12
12
|
import { parseMaterial } from '../../../../core/parser/parseMaterial.js';
|
|
13
13
|
import { walkMaterial, readMaterial, MATERIAL_DIR } from '../../../utils/material.js';
|
|
14
|
+
/** The six built-in card types the parser and renderers understand. A
|
|
15
|
+
* card with any other `type` falls through to article-style rendering —
|
|
16
|
+
* legal (custom user types are allowed) but often a typo. */
|
|
17
|
+
const BUILT_IN_TYPES = new Set([
|
|
18
|
+
'word',
|
|
19
|
+
'sentence',
|
|
20
|
+
'article',
|
|
21
|
+
'snippet',
|
|
22
|
+
'qa',
|
|
23
|
+
'code',
|
|
24
|
+
]);
|
|
14
25
|
const TITLE_RE = /^title:\s*(.+)$/m;
|
|
15
26
|
const HEADING_RE = /^#\s+.+$/m;
|
|
16
27
|
export async function contentLintAction(opts = {}) {
|
|
@@ -60,10 +71,28 @@ export async function contentLintAction(opts = {}) {
|
|
|
60
71
|
}),
|
|
61
72
|
});
|
|
62
73
|
}
|
|
74
|
+
// Warn (not error) when card.type isn't one of the six built-ins.
|
|
75
|
+
// Custom types are legal per QTYPE spec — the parser accepts them
|
|
76
|
+
// and the renderer falls back to article shape — but nine times
|
|
77
|
+
// out of ten it's a typo (`qaa` for `qa`, `articel` for `article`)
|
|
78
|
+
// that would otherwise render silently wrong.
|
|
79
|
+
if (c.type && !BUILT_IN_TYPES.has(c.type)) {
|
|
80
|
+
issues.push({
|
|
81
|
+
code: 'card.unknownType',
|
|
82
|
+
path: rel,
|
|
83
|
+
card: i + 1,
|
|
84
|
+
message: t('content.lint.issue.unknownType', {
|
|
85
|
+
path: rel,
|
|
86
|
+
card: i + 1,
|
|
87
|
+
type: c.type,
|
|
88
|
+
}),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
63
91
|
});
|
|
64
92
|
}
|
|
65
|
-
//
|
|
66
|
-
const
|
|
93
|
+
// Informational warnings — reported but don't fail the check.
|
|
94
|
+
const WARN_CODES = new Set(['material.emptyTitle', 'card.unknownType']);
|
|
95
|
+
const errorIssues = issues.filter((i) => !WARN_CODES.has(i.code));
|
|
67
96
|
const result = {
|
|
68
97
|
ok: errorIssues.length === 0,
|
|
69
98
|
stats: { materials: materialCount, cards: cardTotal },
|
|
@@ -85,7 +114,7 @@ export async function contentLintAction(opts = {}) {
|
|
|
85
114
|
console.error(` ${t('content.lint.msg.andMore', { count: errorIssues.length - 30 })}`);
|
|
86
115
|
}
|
|
87
116
|
}
|
|
88
|
-
const warnIssues = issues.filter((i) => i.code
|
|
117
|
+
const warnIssues = issues.filter((i) => WARN_CODES.has(i.code));
|
|
89
118
|
for (const w of warnIssues)
|
|
90
119
|
warn(w.message);
|
|
91
120
|
if (result.ok)
|
|
@@ -13,6 +13,7 @@ export const messages = {
|
|
|
13
13
|
'content.lint.issue.noCards': '{path}: 未识别到任何 Q/A 卡片',
|
|
14
14
|
'content.lint.issue.unknownChars': '{path}#{card}: 含无法练习的字符 → {chars}',
|
|
15
15
|
'content.lint.issue.emptyTitle': '{path}: frontmatter 缺少 title',
|
|
16
|
+
'content.lint.issue.unknownType': '{path}#{card}: type={type} 不是内置 6 种(word/sentence/article/snippet/qa/code),将走 article 兜底渲染',
|
|
16
17
|
},
|
|
17
18
|
'en-US': {
|
|
18
19
|
'content.lint.summary': 'Material format lint',
|
|
@@ -28,5 +29,6 @@ export const messages = {
|
|
|
28
29
|
'content.lint.issue.noCards': '{path}: no Q/A cards parsed',
|
|
29
30
|
'content.lint.issue.unknownChars': '{path}#{card}: non-typable chars → {chars}',
|
|
30
31
|
'content.lint.issue.emptyTitle': '{path}: frontmatter missing title',
|
|
32
|
+
'content.lint.issue.unknownType': '{path}#{card}: type={type} is not one of the six built-ins (word/sentence/article/snippet/qa/code) — will fall back to article rendering',
|
|
31
33
|
},
|
|
32
34
|
};
|
|
@@ -67,9 +67,11 @@ export async function publishBuildAction(opts = {}) {
|
|
|
67
67
|
const cardCount = countH1(body);
|
|
68
68
|
materials.push({ name: file.rel, body, title, cardCount });
|
|
69
69
|
}
|
|
70
|
-
// Mermaid pre-render — scan all bodies, produce svg
|
|
70
|
+
// Mermaid pre-render — scan all bodies, produce per-file svg maps
|
|
71
|
+
// + a shared css blob. Sharded per material so the browser only
|
|
72
|
+
// downloads the diagrams referenced by the material it's opening.
|
|
71
73
|
const mermaid = mermaidMode === 'prerender'
|
|
72
|
-
? await prerenderMermaid(materials.map((m) => m.body))
|
|
74
|
+
? await prerenderMermaid(materials.map((m) => ({ file: m.name, body: m.body })))
|
|
73
75
|
: emptyMermaidPass();
|
|
74
76
|
if (mermaidMode !== 'prerender') {
|
|
75
77
|
info(t('publish.build.msg.mermaidSkip', { mode: mermaidMode }));
|
|
@@ -133,16 +135,28 @@ export async function publishBuildAction(opts = {}) {
|
|
|
133
135
|
cardCount: parsed.cards.length,
|
|
134
136
|
});
|
|
135
137
|
}
|
|
136
|
-
// Mermaid SVG
|
|
137
|
-
//
|
|
138
|
+
// Mermaid SVG shards — one per material that has diagrams. The view
|
|
139
|
+
// fetches `<file>.mermaid.json` next to `<file>.json` on demand.
|
|
140
|
+
// Prior versions shipped a single top-level `mermaid.json` covering
|
|
141
|
+
// every diagram in the repo; that forced every reader to download
|
|
142
|
+
// the union even when they opened one material.
|
|
138
143
|
if (mermaid.count > 0) {
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
144
|
+
for (const [file, svgs] of mermaid.perFile) {
|
|
145
|
+
if (Object.keys(svgs).length === 0)
|
|
146
|
+
continue;
|
|
147
|
+
const jsonRel = file.replace(/\.md$/, '.mermaid.json');
|
|
148
|
+
const outPath = encryptCtx
|
|
149
|
+
? path.join(targetMaterial, `${jsonRel}.enc`)
|
|
150
|
+
: path.join(targetMaterial, jsonRel);
|
|
151
|
+
fs.mkdirSync(path.dirname(outPath), { recursive: true });
|
|
152
|
+
const payload = JSON.stringify(svgs);
|
|
153
|
+
if (encryptCtx) {
|
|
154
|
+
const blob = await encryptString(encryptCtx.key, payload);
|
|
155
|
+
fs.writeFileSync(outPath, JSON.stringify(blob));
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
fs.writeFileSync(outPath, payload);
|
|
159
|
+
}
|
|
146
160
|
}
|
|
147
161
|
if (mermaid.css) {
|
|
148
162
|
fs.writeFileSync(path.join(targetMaterial, 'mermaid.css'), mermaid.css);
|
|
@@ -177,6 +191,7 @@ export async function publishBuildAction(opts = {}) {
|
|
|
177
191
|
title: card.title,
|
|
178
192
|
type: card.type,
|
|
179
193
|
tags: card.tags,
|
|
194
|
+
indexInMaterial: card.indexInMaterial,
|
|
180
195
|
});
|
|
181
196
|
for (const tag of card.tags) {
|
|
182
197
|
if (!tagIndex[tag])
|
|
@@ -43,20 +43,30 @@ function extractSources(md) {
|
|
|
43
43
|
}
|
|
44
44
|
/** Empty result — used when no diagrams found or mermaid pass disabled. */
|
|
45
45
|
export function emptyMermaidPass() {
|
|
46
|
-
return {
|
|
46
|
+
return { perFile: new Map(), css: '', count: 0, errored: 0 };
|
|
47
47
|
}
|
|
48
48
|
/**
|
|
49
|
-
* Scan
|
|
50
|
-
* each unique diagram twice (dark + light).
|
|
51
|
-
*
|
|
49
|
+
* Scan every provided material body for ```mermaid fences and pre-render
|
|
50
|
+
* each unique diagram twice (dark + light). Rendering is deduped
|
|
51
|
+
* globally (a diagram appearing in three materials is drawn once);
|
|
52
|
+
* emission is per-material (the same SVG bytes may be copied into
|
|
53
|
+
* three per-file maps). Returns an empty result when no diagrams are
|
|
54
|
+
* found.
|
|
52
55
|
*/
|
|
53
|
-
export async function prerenderMermaid(
|
|
56
|
+
export async function prerenderMermaid(inputs) {
|
|
57
|
+
// Collect (file, key) → source. `uniqueSources` dedupes for the
|
|
58
|
+
// renderer; `perFileSources` remembers which files each diagram
|
|
59
|
+
// belongs to so we can copy the SVG into every shard that needs it.
|
|
54
60
|
const uniqueSources = new Map();
|
|
55
|
-
|
|
61
|
+
const perFileSources = new Map();
|
|
62
|
+
for (const { file, body } of inputs) {
|
|
56
63
|
for (const src of extractSources(body)) {
|
|
57
64
|
const key = mermaidSvgKey(src);
|
|
58
65
|
if (!uniqueSources.has(key))
|
|
59
66
|
uniqueSources.set(key, src);
|
|
67
|
+
const set = perFileSources.get(file) ?? new Set();
|
|
68
|
+
set.add(key);
|
|
69
|
+
perFileSources.set(file, set);
|
|
60
70
|
}
|
|
61
71
|
}
|
|
62
72
|
if (uniqueSources.size === 0)
|
|
@@ -64,7 +74,7 @@ export async function prerenderMermaid(materialBodies) {
|
|
|
64
74
|
info(t('publish.build.msg.mermaidStart', { count: uniqueSources.size }));
|
|
65
75
|
const renderer = new MermaidRenderer();
|
|
66
76
|
await renderer.init();
|
|
67
|
-
const
|
|
77
|
+
const renderedByKey = {};
|
|
68
78
|
const cssChunks = [];
|
|
69
79
|
let errored = 0;
|
|
70
80
|
try {
|
|
@@ -72,7 +82,7 @@ export async function prerenderMermaid(materialBodies) {
|
|
|
72
82
|
const r = await renderer.render(source);
|
|
73
83
|
if (r.errored)
|
|
74
84
|
errored++;
|
|
75
|
-
|
|
85
|
+
renderedByKey[key] = { dark: r.svgDark, light: r.svgLight };
|
|
76
86
|
if (r.cssDark)
|
|
77
87
|
cssChunks.push(r.cssDark);
|
|
78
88
|
if (r.cssLight)
|
|
@@ -82,10 +92,20 @@ export async function prerenderMermaid(materialBodies) {
|
|
|
82
92
|
finally {
|
|
83
93
|
await renderer.close();
|
|
84
94
|
}
|
|
95
|
+
const perFile = new Map();
|
|
96
|
+
for (const [file, keys] of perFileSources) {
|
|
97
|
+
const map = {};
|
|
98
|
+
for (const k of keys) {
|
|
99
|
+
const svg = renderedByKey[k];
|
|
100
|
+
if (svg)
|
|
101
|
+
map[k] = svg;
|
|
102
|
+
}
|
|
103
|
+
perFile.set(file, map);
|
|
104
|
+
}
|
|
85
105
|
if (errored > 0)
|
|
86
106
|
warn(t('publish.build.msg.mermaidErrored', { count: errored }));
|
|
87
107
|
return {
|
|
88
|
-
|
|
108
|
+
perFile,
|
|
89
109
|
css: cssChunks.join('\n'),
|
|
90
110
|
count: uniqueSources.size,
|
|
91
111
|
errored,
|
|
@@ -74,7 +74,7 @@ export async function repoSyncAction(opts) {
|
|
|
74
74
|
description: t('repo.sync.tpl.defaultDescription'),
|
|
75
75
|
date: today,
|
|
76
76
|
};
|
|
77
|
-
const plan = buildSyncPlan();
|
|
77
|
+
const plan = filterOutSampleSeedsIfUserContent(root, buildSyncPlan());
|
|
78
78
|
const toCreate = [];
|
|
79
79
|
const toOverwrite = [];
|
|
80
80
|
const skipped = [];
|
|
@@ -220,3 +220,64 @@ function writeItem(root, item, templatesDir, vars) {
|
|
|
220
220
|
fs.writeFileSync(fullPath, '');
|
|
221
221
|
}
|
|
222
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* Six sample material files (`material/<type>.md` + their `assets/<type>/*.svg`
|
|
225
|
+
* companions) exist to bootstrap a brand-new repo — every card type gets a
|
|
226
|
+
* copy-and-edit starter. On an established repo (`qtype init --force` after
|
|
227
|
+
* writing real content), re-seeding them is pure noise: the user has to
|
|
228
|
+
* manually delete six files every time they run sync.
|
|
229
|
+
*
|
|
230
|
+
* Detection heuristic: if `material/` already holds any `.md` whose base
|
|
231
|
+
* name is not one of the six sample types, the user has real content.
|
|
232
|
+
* Skip both the starter `.md` seeds and their asset seeds; keep everything
|
|
233
|
+
* else in the plan.
|
|
234
|
+
*
|
|
235
|
+
* The first-time init flow is unaffected — an empty (or missing) `material/`
|
|
236
|
+
* still gets the full sample seed.
|
|
237
|
+
*/
|
|
238
|
+
const SAMPLE_TYPES = new Set(['word', 'sentence', 'article', 'snippet', 'qa', 'code']);
|
|
239
|
+
function isSampleSeed(target) {
|
|
240
|
+
// `material/<type>.md` or `material/assets/<type>/*.svg`
|
|
241
|
+
const md = /^material\/([^/]+)\.md$/.exec(target);
|
|
242
|
+
if (md && SAMPLE_TYPES.has(md[1]))
|
|
243
|
+
return true;
|
|
244
|
+
const asset = /^material\/assets\/([^/]+)\//.exec(target);
|
|
245
|
+
if (asset && SAMPLE_TYPES.has(asset[1]))
|
|
246
|
+
return true;
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
function hasUserContent(materialDir) {
|
|
250
|
+
if (!fs.existsSync(materialDir))
|
|
251
|
+
return false;
|
|
252
|
+
const walk = (dir) => {
|
|
253
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
254
|
+
if (entry.name === 'assets' && entry.isDirectory())
|
|
255
|
+
continue;
|
|
256
|
+
const full = path.join(dir, entry.name);
|
|
257
|
+
if (entry.isDirectory()) {
|
|
258
|
+
if (walk(full))
|
|
259
|
+
return true;
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (!entry.name.endsWith('.md'))
|
|
263
|
+
continue;
|
|
264
|
+
// A sample seed at the top level (`material/qa.md`) doesn't count.
|
|
265
|
+
// Anything else — deeper paths or top-level files with non-sample
|
|
266
|
+
// names — is user-authored content.
|
|
267
|
+
const rel = path.relative(materialDir, full);
|
|
268
|
+
const topLevel = rel.split(path.sep).length === 1;
|
|
269
|
+
const base = entry.name.replace(/\.md$/, '');
|
|
270
|
+
if (topLevel && SAMPLE_TYPES.has(base))
|
|
271
|
+
continue;
|
|
272
|
+
return true;
|
|
273
|
+
}
|
|
274
|
+
return false;
|
|
275
|
+
};
|
|
276
|
+
return walk(materialDir);
|
|
277
|
+
}
|
|
278
|
+
function filterOutSampleSeedsIfUserContent(root, plan) {
|
|
279
|
+
const materialDir = path.join(root, 'material');
|
|
280
|
+
if (!hasUserContent(materialDir))
|
|
281
|
+
return plan;
|
|
282
|
+
return plan.filter((item) => !isSampleSeed(item.target));
|
|
283
|
+
}
|
package/dist/cli/utils/prompt.js
CHANGED
|
@@ -46,8 +46,10 @@ export function promptPassword(prompt) {
|
|
|
46
46
|
const onData = (chunk) => {
|
|
47
47
|
for (const ch of chunk) {
|
|
48
48
|
// Ctrl-C in raw mode: abort with a clear error rather than letting
|
|
49
|
-
// the byte fall into the password buffer.
|
|
50
|
-
|
|
49
|
+
// the byte fall into the password buffer. Escape sequence used
|
|
50
|
+
// instead of the literal 0x03 byte so a stray copy-paste through a
|
|
51
|
+
// terminal / chat app can't silently drop the control character.
|
|
52
|
+
if (ch === '\x03') {
|
|
51
53
|
if (isTty && typeof stdin.setRawMode === 'function')
|
|
52
54
|
stdin.setRawMode(false);
|
|
53
55
|
stdin.pause();
|
|
@@ -60,7 +62,9 @@ export function promptPassword(prompt) {
|
|
|
60
62
|
finish(data);
|
|
61
63
|
return;
|
|
62
64
|
}
|
|
63
|
-
|
|
65
|
+
// Backspace: modern macOS/Linux terminals send DEL (0x7f); older
|
|
66
|
+
// ones and some Windows shells send BS (0x08). Handle both.
|
|
67
|
+
if (ch === '\x7f' || ch === '\b') {
|
|
64
68
|
data = data.slice(0, -1);
|
|
65
69
|
continue;
|
|
66
70
|
}
|