@qnroa/qtype 0.0.9 → 0.1.1

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.
Files changed (31) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/CHANGELOG.zh.md +108 -0
  3. package/dist/cli/commands/content/lint/action.js +32 -3
  4. package/dist/cli/commands/content/lint/i18n.js +2 -0
  5. package/dist/cli/commands/publish/build/action.js +26 -11
  6. package/dist/cli/commands/publish/build/mermaidPass.js +29 -9
  7. package/dist/cli/commands/repo/sync/action.js +62 -1
  8. package/dist/cli/utils/prompt.js +7 -3
  9. package/dist/core/cardId.js +101 -0
  10. package/dist/core/index.js +1 -0
  11. package/dist/core/keystroke/textNormalize.js +13 -10
  12. package/dist/core/parser/frontmatter.js +2 -3
  13. package/dist/core/parser/parseMaterial.js +35 -4
  14. package/dist/core/typing/engine/TypingInput.js +8 -2
  15. package/dist/core/typing/engine/normalize.js +87 -0
  16. package/dist/core/typing/engine/settings.js +0 -2
  17. package/dist/core/typing/engine/stats.js +1 -4
  18. package/dist/core/typing/metrics.js +1 -4
  19. package/dist/core/typing/round.js +12 -0
  20. package/dist/store/index.js +1 -0
  21. package/dist/store/storage/browser.js +49 -0
  22. package/dist/store/storage/session.js +92 -0
  23. package/dist/view/assets/index--xiys9ma.js +182 -0
  24. package/dist/view/assets/{index-Due6dVBA.js → index-CpOQ9x3V.js} +1 -1
  25. package/dist/view/assets/{index-DI1_zxwA.css → index-_1m9NuFe.css} +1 -1
  26. package/dist/view/assets/{index-B29a4MHP.js → index-y4VkSsUN.js} +2 -2
  27. package/dist/view/index.html +2 -2
  28. package/docs/en/cli-reference.md +10 -6
  29. package/docs/zh/cli-reference.md +10 -6
  30. package/package.json +2 -1
  31. package/dist/view/assets/index-D2236cNR.js +0 -182
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.1] — 2026-08-28
17
+
18
+ Fixes typing comparison for equivalent-but-different characters that
19
+ the OS/IME hands out (fullwidth space in Chinese input mode, smart
20
+ quotes on macOS/iOS, CJK punctuation vs ASCII, etc.). Display side is
21
+ unchanged; equivalence only runs when comparing a keystroke against
22
+ the expected character.
23
+
24
+ ### Fixed
25
+ - **Fullwidth / half-width space now match on input.** Typing ` `
26
+ (Chinese IME space) against a card whose target is ` ` no
27
+ longer misses. Also covers non-breaking space ` `. Mobile users
28
+ on Chinese keyboards used to have to switch to English just to hit
29
+ space; that friction is gone.
30
+ - **Smart quotes match straight quotes on input.** iOS / macOS auto-
31
+ substituted `“`/`”`/`‘`/`’` now compare equal to
32
+ ASCII `"` and `'`. Cards typed on a phone stop failing on quotes.
33
+ - **CJK punctuation matches ASCII punctuation on input.** `,。?!:
34
+ ;()【】《》/+=~` and friends are compared equivalent to their
35
+ ASCII forms. Users don't need to swap IME state mid-card.
36
+ - **En-dash / em-dash / fullwidth hyphen match ASCII `-` on input.**
37
+
38
+ ### Changed
39
+ - **Display is honest about the source.** `normalizeText` no longer
40
+ rewrites user-visible characters at import time (previously
41
+ `   ` were folded to ASCII space, `—` was expanded
42
+ to `--`, etc.). Cards render exactly what the author wrote; the
43
+ equivalence table lives in `typing/engine/normalize.ts` and only
44
+ runs at keystroke comparison time. Untypable characters (control,
45
+ zero-width, emoji) are still stripped.
46
+
47
+ ## [0.1.0] — 2026-08-27
48
+
49
+ Breaking dist format. Source `material/*.md` unchanged — the source
50
+ grammar and semantics are preserved end-to-end. Users must re-run
51
+ `qtype publish build` once; deployed sites need to re-upload the new
52
+ `dist/`.
53
+
54
+ ### Fixed
55
+ - **`splitByH1` now respects fenced code blocks.** A `# ...` line
56
+ inside a ``` ``` fence is no longer mistaken for a new card. This
57
+ was silently slicing materials with hash-comment code samples (e.g.
58
+ a regex primer's Python snippets) into dozens of bogus cards whose
59
+ indices leaked downstream through the `<file>#<idx>` id scheme.
60
+ - **`promptPassword` recognises Ctrl-C (`\x03`) and DEL (`\x7f`).**
61
+ Both control characters had been silently dropped from the source
62
+ during an earlier copy-paste, leaving password entry unable to
63
+ abort on Ctrl-C or backspace correctly on modern terminals.
64
+ - **HUD "back to library" preserves the selected tag filter.** The
65
+ purge effect in `useLibraryFilters` used to run against the initial
66
+ `availableTags: []` from a still-loading manifest and nuke every
67
+ persisted tag before the corpus arrived. Now waits until the corpus
68
+ is known.
69
+ - **Filter-list navigation walks the filter, not the source material.**
70
+ `useFilterFlow` no longer depends on the async manifest — the
71
+ neighbour `(file, idx)` is carried inside each hit entry, so
72
+ `next()` / `prev()` work from the moment localStorage settles.
73
+ - **Grid ↔ list view toggle is orthogonal to filtering.** Prior
74
+ versions forced list layout whenever a tag/type filter was active,
75
+ ignoring the user's chosen mode. Now grid stays grid, list stays
76
+ list — the filter changes *what* is listed (materials vs. matching
77
+ cards), not *how*.
78
+ - **F5 keeps the filter hit list.** The hit list moved from a
79
+ module-level variable to `localStorage`, so refreshing the typing
80
+ page no longer degrades to intra-material navigation.
81
+
82
+ ### Changed (breaking)
83
+ - **`Card.id` is a 12-hex-char content hash instead of
84
+ `<file>#<idx>`.** Reordering cards inside a material no longer
85
+ invalidates every downstream reference (bookmarks, filter picks,
86
+ CDN cache keys). Position information is preserved as first-class
87
+ fields on the card: `Card.materialFile` + `Card.indexInMaterial`.
88
+ - **`Card` gains required fields `materialFile` and `indexInMaterial`.**
89
+ Downstream consumers that need location info should read these
90
+ instead of parsing `id`.
91
+ - **`dist/material/mermaid.json` is gone.** Mermaid SVGs are now
92
+ sharded per material: `qa/security.md` gets
93
+ `dist/material/qa/security.mermaid.json`. Encrypted repos land as
94
+ `<file>.mermaid.json.enc`. The browser only fetches the shard for
95
+ the material it's rendering, not the union of every diagram in the
96
+ repo.
97
+ - **Build ships one JSON per material, not the raw `.md`.** Source
98
+ `qa/security.md` now becomes `qa/security.json` under
99
+ `dist/material/`, containing the fully-parsed `Material`
100
+ (structured body, precomputed romanization). Encrypted repos land
101
+ as `qa/security.json.enc`. The browser no longer parses markdown —
102
+ it fetches the JSON and renders directly. (Introduced in 0.0.8; the
103
+ full breaking history is kept here for the 0.1.0 upgrade guide.)
104
+ - **`index.json.tagIndex` / `typeIndex` map to card ids
105
+ (content-hash), not file paths.** Filter UIs can enumerate matching
106
+ cards without loading any material file. (Introduced in 0.0.8.)
107
+
108
+ ### Added
109
+ - **`computeCardId(input)`** exported from `@qnroa/qtype/core` for
110
+ external tooling that wants to compute the same stable id qtype
111
+ uses.
112
+ - **Card-level filter view on Home.** Selecting a tag or type
113
+ replaces the material grid with matching cards, laid out per the
114
+ current grid/list mode. (Introduced in 0.0.8, generalised to
115
+ grid + list in 0.0.9.)
116
+ - **Cross-material filter navigation in typing.** Clicking a card
117
+ from the filter hit list carries the hit list into the typing
118
+ route — prev/next walks the filter results across materials, not
119
+ the source material's own card order. (Introduced in 0.0.8;
120
+ refactored to hit-entry rows carrying `(id, file, idx)` in 0.1.0.)
121
+ - **`EntryCard` / `EntryRow` base components + `CardHitCard` /
122
+ `CardHitRow` specialisations.** Materials and card hits share one
123
+ shell (click / keyboard / selection / done stamp) so visual
124
+ behaviour is consistent regardless of what's being listed.
125
+ (Introduced in 0.0.9.)
126
+ - **Lint warning `card.unknownType`.** Cards whose `type` isn't one
127
+ of the six built-ins (word / sentence / article / snippet / qa /
128
+ code) now surface a warning — 90% of the time it's a typo that
129
+ would otherwise render silently wrong.
130
+ - **`qtype init --force` skips sample material seeds on repos with
131
+ real content.** Established repos no longer accumulate 6 unwanted
132
+ starter `.md` files (plus their asset SVGs) on every re-sync.
133
+
134
+ ### Migration
135
+ - Upgrade the qtype dependency, then run `qtype publish build` once.
136
+ - The old `dist/` layout is not read anymore; delete the deployed
137
+ copy and upload the fresh build.
138
+ - User progress (`qtype.done`, `qtype.doneCards`) is stored under
139
+ `(file, indexInMaterial)` and still works — the new `id` is a
140
+ parallel identity for filter / bookmark purposes.
141
+
142
+ ## [0.0.10] — 2026-08-27
143
+
144
+ Bug-fix release. Two regressions from the 0.0.8 filter feature —
145
+ both view-only, no dist change.
146
+
147
+ ### Fixed
148
+ - **Filter-list navigation now actually walks the filter, not the
149
+ source material.** `useFilterFlow` depended on the manifest's flat
150
+ card index to resolve neighbour (file, idx), but that index arrives
151
+ async — until it landed, `next()` / `prev()` returned `null` and
152
+ the typing route silently fell back to intra-material navigation.
153
+ The hook now parses `(file, idx)` directly out of the card id
154
+ (`<file>#<idx>`), so it works from the moment localStorage settles.
155
+ - **Selected tags survive HUD "back to library".** The purge effect
156
+ in `useLibraryFilters` ran on the initial `availableTags: []` from
157
+ a still-loading manifest and nuked every persisted tag before the
158
+ corpus arrived. The purge now waits until the corpus is known
159
+ (new `corpusReady` param, defaults to `true` for backward
160
+ compatibility).
161
+
16
162
  ## [0.0.9] — 2026-08-27
17
163
 
18
164
  View-only. No dist change, no need to rebuild — refreshing the page
package/CHANGELOG.zh.md CHANGED
@@ -12,6 +12,114 @@ English: [CHANGELOG.md](https://www.npmjs.com/package/@qnroa/qtype?activeTab=cod
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.1.1] — 2026-08-28
16
+
17
+ 修复输入法/终端产出的等价字符(中文输入法下的全角空格、macOS/iOS
18
+ 自动替换的智能引号、中文标点 vs 英文标点等)造成的误判。显示层
19
+ 不变,等价比较只在"按键 vs 期望字符"的那一步生效。
20
+
21
+ ### 修复
22
+ - **全角/半角空格视为等价**。中文输入法下敲 ` `(U+3000) 匹配卡片
23
+ 里的半角 ` ` 不再算错;` ` 不间断空格同样等价。手机上中文
24
+ 输入法用户不再需要切英文只为了打个空格。
25
+ - **智能引号 = 直引号**。iOS/macOS 自动替换的 `""''` 现在跟 ASCII
26
+ `"` `'` 比较相等。手机上打卡不会因为引号变形而卡住。
27
+ - **中文标点 = 英文标点**。`,。?!:;()【】《》/+=~` 等在
28
+ 比较时等价于对应的 ASCII 标点,无需切换输入法状态。
29
+ - **En-dash / em-dash / 全角减号 = ASCII `-`**。
30
+
31
+ ### 变更
32
+ - **显示忠实于源码**。`normalizeText` 不再在 import 时改写用户可见
33
+ 字符(之前 `  ` 会被折叠成 ASCII 空格,`—` 被扩展成 `--`)。
34
+ 卡片显示什么就是作者写的什么;等价表放在
35
+ `typing/engine/normalize.ts`,只在打字比较时生效。控制字符 / 零
36
+ 宽字符 / emoji 依然会剔除。
37
+
38
+ ## [0.1.0] — 2026-08-27
39
+
40
+ 破坏性 dist 格式变更。源 `material/*.md` 未变 —— 语法和语义端到端
41
+ 保持一致。用户需要重新跑一次 `qtype publish build`;已部署的站点
42
+ 需要重新上传新的 `dist/`。
43
+
44
+ ### 修复
45
+ - **`splitByH1` 识别代码块**。fenced code block 里以 `# ...` 开头的
46
+ 行不再被误认为新卡。之前会把带 hash 注释的代码示例(比如 regex
47
+ 教程里的 Python 片段)切成几十个假卡片,让 `<file>#<idx>` 的 id
48
+ 索引跳跃。
49
+ - **`promptPassword` 识别 Ctrl-C (`\x03`) 和 DEL (`\x7f`)**。两个
50
+ 控制字符早期从 qrism 复制过来时被吃掉了,导致输密码时 Ctrl-C 无
51
+ 法中断,现代终端的 Backspace 也无效。
52
+ - **HUD"返回上级"后 tag 筛选保留**。`useLibraryFilters` 的 purge
53
+ effect 之前会在 manifest 还没加载完(`availableTags = []`)的初
54
+ 始态跑,把持久化的所有 tag 都清掉。现在等 corpus 就绪再跑。
55
+ - **筛选后翻页真的走筛选,不是走材料内**。`useFilterFlow` 不再依
56
+ 赖异步 manifest —— 每条命中记录本身携带 `(file, idx)`,
57
+ `next()` / `prev()` 从 localStorage 就绪起就能工作。
58
+ - **grid ↔ list 视图和筛选正交**。之前只要勾了 tag/type,不管用户
59
+ 当前选的是 grid 还是 list,都强制变 list。现在 grid 保持 grid,
60
+ list 保持 list,筛选只决定"列的是什么"(材料还是命中卡)。
61
+ - **F5 保留筛选命中列表**。hitList 从 module 级内存变量迁到
62
+ localStorage,刷新 typing 页不再回落到材料内翻页。
63
+
64
+ ### 变更(破坏性)
65
+ - **`Card.id` 是 12 位内容 hash,不再是 `<file>#<idx>`**。材料内重排
66
+ 卡片不再让下游引用(书签、筛选选中、CDN 缓存 key)全部失效。位置
67
+ 信息作为 Card 的一等字段保留:`Card.materialFile` 和
68
+ `Card.indexInMaterial`。
69
+ - **`Card` 新增必填字段 `materialFile` 和 `indexInMaterial`**。需要
70
+ 位置信息的下游消费者应该读这两个字段而不是解析 `id`。
71
+ - **`dist/material/mermaid.json` 消失**。Mermaid SVG 按材料分片:
72
+ `qa/security.md` 对应 `dist/material/qa/security.mermaid.json`。
73
+ 加密仓库落到 `<file>.mermaid.json.enc`。浏览器只 fetch 当前材料
74
+ 的 shard,不再下载全站图的并集。
75
+ - **build 产物为每份材料一个 JSON,不再拷贝 `.md`**。源文件
76
+ `qa/security.md` 现在会构建为 `dist/material/qa/security.json`,
77
+ 内容是已完整解析的 `Material`。加密仓库落地为
78
+ `qa/security.json.enc`。浏览器不再解析 markdown。(0.0.8 引入,
79
+ 0.1.0 升级指南里保留完整破坏性历史。)
80
+ - **`index.json.tagIndex` / `typeIndex` 值改为卡 id(内容 hash),
81
+ 不再是材料 file**。(0.0.8 引入。)
82
+
83
+ ### 新增
84
+ - **`computeCardId(input)`** 从 `@qnroa/qtype/core` 导出,给外部
85
+ 工具用来算和 qtype 一致的稳定 id。
86
+ - **Home 卡级筛选视图**。勾一个 tag 或 type 后,材料网格被替换成
87
+ 命中卡列表,按当前 grid/list 模式展示。(0.0.8 引入,0.0.9 泛化
88
+ 为 grid + list。)
89
+ - **筛选跨材料翻页**。从命中卡列表点进 typing 时,前端把命中列表
90
+ 带到 typing 路由,prev/next 沿命中列表跨材料游走。(0.0.8 引入,
91
+ 0.1.0 重构成每条记录带 `(id, file, idx)`。)
92
+ - **`EntryCard` / `EntryRow` 基类 + `CardHitCard` / `CardHitRow`
93
+ 特化**。材料和命中卡共享一套 shell(点击 / 键盘 / 选中 /
94
+ done stamp),视觉行为一致。(0.0.9 引入。)
95
+ - **lint 加 `card.unknownType` 警告**。`type` 不是内置 6 种(word /
96
+ sentence / article / snippet / qa / code)时报警 —— 90% 都是手
97
+ 滑,不然会静默走 article 兜底渲染。
98
+ - **`qtype init --force` 已有真实内容的 repo 跳过 sample seed**。
99
+ 成熟 repo 每次同步不再累积 6 个 starter md(含 asset SVG)噪音。
100
+
101
+ ### 迁移
102
+ - 升级 qtype 依赖后,跑一次 `qtype publish build`。
103
+ - 老 `dist/` 结构已不再被读取,直接删除并重新部署。
104
+ - 用户进度(`qtype.done` / `qtype.doneCards`)按 `(file,
105
+ indexInMaterial)` 存,继续可用 —— 新的 `id` 是给筛选 / 书签用的
106
+ 并列身份,不替代它。
107
+
108
+ ## [0.0.10] — 2026-08-27
109
+
110
+ Bug 修复。两个从 0.0.8 筛选功能引入的回归 —— 都是 view 层,dist 未变。
111
+
112
+ ### 修复
113
+ - **筛选后翻页现在真的走筛选,不是走材料内**。`useFilterFlow` 之前
114
+ 依赖 manifest 的扁平卡索引来解析下一张的 `(file, idx)`,但该索引
115
+ 是异步加载的 —— 在它到达之前,`next()` / `prev()` 返回 `null`,
116
+ typing 页静默回退到材料内翻页。现在改成直接从 cardId (`<file>#<idx>`)
117
+ 自解析 `(file, idx)`,localStorage 一就位就能用。
118
+ - **HUD"返回上级"后 tag 筛选保留**。`useLibraryFilters` 的 purge
119
+ effect 会在 manifest 还没加载完(此时 `availableTags` 是 `[]`)
120
+ 的初始态就跑,把持久化的所有 tag 都清掉。现在 purge 会等到 corpus
121
+ 就绪才跑(新的 `corpusReady` 参数,默认 `true` 保持向后兼容)。
122
+
15
123
  ## [0.0.9] — 2026-08-27
16
124
 
17
125
  只改 view,dist 未变,老部署刷新页面即可,无需重跑 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
- // `emptyTitle` is treated as a warning (informational). Anything else is an error.
66
- const errorIssues = issues.filter((i) => i.code !== 'material.emptyTitle');
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 === 'material.emptyTitle');
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 + css maps.
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 map + CSS: the view fetches these alongside index.json and
137
- // looks up each fence by its trimmed source when rendering material bodies.
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 mermaidJson = mermaidMode === 'prerender' ? mermaid.svgs : {};
140
- if (encryptCtx) {
141
- const blob = await encryptString(encryptCtx.key, JSON.stringify(mermaidJson));
142
- fs.writeFileSync(path.join(targetMaterial, 'mermaid.json.enc'), JSON.stringify(blob));
143
- }
144
- else {
145
- fs.writeFileSync(path.join(targetMaterial, 'mermaid.json'), JSON.stringify(mermaidJson));
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 { svgs: {}, css: '', count: 0, errored: 0 };
46
+ return { perFile: new Map(), css: '', count: 0, errored: 0 };
47
47
  }
48
48
  /**
49
- * Scan all provided material bodies for ```mermaid fences and pre-render
50
- * each unique diagram twice (dark + light). Returns a lookup map + the
51
- * associated CSS. Returns an empty result when no diagrams are found.
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(materialBodies) {
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
- for (const body of materialBodies) {
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 svgs = {};
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
- svgs[key] = { dark: r.svgDark, light: r.svgLight };
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
- svgs,
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
+ }
@@ -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
- if (ch === '') {
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
- if (ch === '' || ch === '\b') {
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
  }