@qnroa/qtype 0.0.9 → 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 CHANGED
@@ -13,6 +13,121 @@ 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
+
16
131
  ## [0.0.9] — 2026-08-27
17
132
 
18
133
  View-only. No dist change, no need to rebuild — refreshing the page
package/CHANGELOG.zh.md CHANGED
@@ -12,6 +12,91 @@ 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
+
15
100
  ## [0.0.9] — 2026-08-27
16
101
 
17
102
  只改 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
  }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Content-derived card id.
3
+ *
4
+ * Historically qtype used `<file>#<indexInMaterial>` as the card id. That
5
+ * value doubled as a stable "identity" (for done tracking, filter hit
6
+ * lists, deep links) while also being the physical position pointer —
7
+ * so any time the author inserted / removed / reordered a card, every
8
+ * downstream id in that material shifted, silently invalidating every
9
+ * saved reference.
10
+ *
11
+ * The new id is a SHA-256 prefix over the card's content-defining
12
+ * fields:
13
+ *
14
+ * materialFile + '\n' + title + '\n' + answer + '\n' + question
15
+ *
16
+ * — trimmed to 12 hex chars (48 bits, ~2^24 birthday collision boundary,
17
+ * plenty for a single repo). This makes the id:
18
+ * • Reorder-safe (position doesn't feed the hash).
19
+ * • Human-copy-friendly (short enough to paste in URLs).
20
+ * • Content-change-sensitive (a real edit produces a new id, which is
21
+ * the honest behaviour — the reader treats the pre-edit and post-edit
22
+ * forms as distinct cards).
23
+ *
24
+ * We ship a small sync SHA-256 rather than pulling in `crypto.subtle`
25
+ * (async, needs to be awaited at parse time) — the payload is tiny and
26
+ * the parser stays synchronous.
27
+ */
28
+ import { sha256 } from '@noble/hashes/sha2.js';
29
+ const HEX = '0123456789abcdef';
30
+ function toHex(bytes) {
31
+ let out = '';
32
+ for (let i = 0; i < bytes.length; i++) {
33
+ const b = bytes[i];
34
+ out += HEX[b >>> 4] + HEX[b & 0x0f];
35
+ }
36
+ return out;
37
+ }
38
+ /** Content hash prefix length in hex chars. 12 hex = 48 bits. */
39
+ export const CARD_ID_LEN = 12;
40
+ export function computeCardId(input) {
41
+ const src = [
42
+ input.materialFile,
43
+ input.title,
44
+ input.answer,
45
+ input.question ?? '',
46
+ ].join('\n');
47
+ const bytes = sha256(new TextEncoder().encode(src));
48
+ return toHex(bytes).slice(0, CARD_ID_LEN);
49
+ }
50
+ // ─── URL slug encoding ────────────────────────────────────────────
51
+ //
52
+ // URL shape:
53
+ // /typing/<material-path>/<idx>-<cardId>
54
+ //
55
+ // /typing/qa/algorithms/25-f849bcc10faf
56
+ // /typing/code/algo/array/cpp/3-abc123def456
57
+ //
58
+ // The material path echoes the source `material/*.md` tree with the
59
+ // `.md` extension stripped. `idx` is the card's 0-based position in
60
+ // its material; `cardId` is a 12-hex content hash used as a
61
+ // verification code — if the URL's `idx` no longer points at the
62
+ // card whose content produced `cardId`, the typing route quietly
63
+ // redirects to the current position.
64
+ //
65
+ // Splitting on the LAST dash is deliberate: material names may
66
+ // legitimately contain dashes (`en/idioms-day1`), so a "split on
67
+ // any dash" scheme would misinterpret them. Idx + cardId sit at the
68
+ // end; the material path is whatever comes before the last `/`.
69
+ /**
70
+ * Build the `/typing/...` splat portion (i.e. everything after
71
+ * `/typing/`). Does NOT `.md`-strip or url-encode — callers pass
72
+ * `materialFile` without the extension.
73
+ */
74
+ export function formatCardSlug(materialFile, indexInMaterial, cardId) {
75
+ const stem = materialFile.replace(/\.md$/, '');
76
+ return `${stem}/${indexInMaterial}-${cardId}`;
77
+ }
78
+ export function parseCardSlug(slug) {
79
+ const lastSlash = slug.lastIndexOf('/');
80
+ if (lastSlash < 0)
81
+ return null;
82
+ const stem = slug.slice(0, lastSlash);
83
+ const tail = slug.slice(lastSlash + 1);
84
+ const lastDash = tail.lastIndexOf('-');
85
+ if (lastDash < 0)
86
+ return null;
87
+ const idxStr = tail.slice(0, lastDash);
88
+ const cardId = tail.slice(lastDash + 1);
89
+ const idx = parseInt(idxStr, 10);
90
+ if (!Number.isFinite(idx) || idx < 0)
91
+ return null;
92
+ if (!/^[0-9a-f]{12}$/.test(cardId))
93
+ return null;
94
+ if (!stem)
95
+ return null;
96
+ return {
97
+ materialFile: `${stem}.md`,
98
+ indexInMaterial: idx,
99
+ cardId,
100
+ };
101
+ }
@@ -7,3 +7,4 @@ export * from './typing/index.js';
7
7
  export { charToKeystrokes, textToCharPlans } from './keystroke/index.js';
8
8
  export { normalizeText, validateTypingText } from './keystroke/textNormalize.js';
9
9
  export * from './encrypt.js';
10
+ export { computeCardId, CARD_ID_LEN, formatCardSlug, parseCardSlug, } from './cardId.js';
@@ -10,9 +10,8 @@ const FM_RE = /^---\n([\s\S]*?)\n---\n?/;
10
10
  * updated: YYYY-MM-DD
11
11
  * ---
12
12
  *
13
- * Card-level frontmatter no longer existseverything a card carries
14
- * is expressed by its markdown structure (see parseMaterial for the
15
- * per-type rules).
13
+ * `type` and `tags` here are *defaults* cards can override via
14
+ * their own `parseCardFrontmatter` block (see above).
16
15
  */
17
16
  export function parseMaterialFrontmatter(source) {
18
17
  const meta = {};