@qnroa/qtype 0.0.6 → 0.0.8

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,76 @@ can land in any minor bump (`0.x.0`).
13
13
 
14
14
  ## [Unreleased]
15
15
 
16
+ ## [0.0.8] — 2026-08-27
17
+
18
+ Breaking dist format. Source `material/*.md` unchanged — the change
19
+ is entirely in the build output and the view runtime. Users must
20
+ re-run `qtype publish build` once; deployed sites need to re-upload
21
+ the new `dist/`.
22
+
23
+ ### Changed
24
+ - **Build ships one `.json` per material, not the raw `.md`.**
25
+ Each source `qa/security.md` now becomes `qa/security.json` under
26
+ `dist/material/`, containing the fully-parsed `Material` (structured
27
+ body, precomputed `charPlans`, romanization). Encrypted repos land
28
+ as `qa/security.json.enc`. The browser no longer parses markdown —
29
+ it fetches the JSON and renders directly. Roughly a 30–50% cold-load
30
+ latency win on medium repos; the parse ran on every navigation before.
31
+ - **`index.json.tagIndex` / `typeIndex` map to card ids, not file paths.**
32
+ Filter UIs can now enumerate matching cards without loading a single
33
+ material file. The `cards[]` flat card index remains as-is (id / file
34
+ / title / type / tags).
35
+
36
+ ### Added
37
+ - **Card-level filter view on Home.** Selecting a tag or type replaces
38
+ the material grid with a flat list of matching cards, grouped by
39
+ material for context but clickable per card.
40
+ - **Cross-material filter navigation in typing.** Clicking a card from
41
+ the filter hit list carries the hit list into the typing route —
42
+ prev/next walks the filter results across materials, not the source
43
+ material's own card order. The hit list is persisted in
44
+ `localStorage`, so a page refresh keeps the filter context (F5 no
45
+ longer drops it).
46
+
47
+ ### Migration
48
+ - Upgrade the qtype dependency, then run `qtype publish build` once.
49
+ - Old `dist/` layout is not read anymore; delete and redeploy.
50
+
51
+ ## [0.0.7] — 2026-08-26
52
+
53
+ Fully backward compatible: existing repos parse unchanged. The
54
+ new capability is opt-in — write card-level frontmatter only where
55
+ a card differs from the material default.
56
+
57
+ ### Added
58
+ - **`Card` runtime shape gains `type` and `tags` fields.** Populated
59
+ either by card-level frontmatter or by inheriting the material
60
+ default. Downstream code should read `card.type` / `card.tags`
61
+ instead of `material.meta.type` / `material.meta.tags` when it
62
+ wants per-card resolution.
63
+ - **Card-level frontmatter.** After an H1 you may open a second
64
+ `---\n...\n---` YAML block declaring `type` and `tags` for that one
65
+ card. This lets a single file mix qa / code / article — e.g. one qa
66
+ "principle" card followed by four code cards for cpp / py / go / java.
67
+ - **Material-level fields are defaults.** `material.type` is the
68
+ fallback type for cards that don't declare their own.
69
+ `material.tags` is unioned with each card's tags (deduped). A card
70
+ can always override with its own card-level fm.
71
+ - **`publish build` emits a flat card pool.** `dist/material/index.json`
72
+ carries `cards`, `tagIndex`, and `typeIndex` alongside the existing
73
+ `materials` list so the reader can filter/pick without fetching
74
+ every `.md`.
75
+ - **Migration scripts.** `scripts/migrate_card_fm.py` pushes
76
+ material-level defaults down into every card (opt-in). Bundled but
77
+ usually unnecessary — the parser now handles legacy files natively.
78
+
79
+ ### Changed
80
+ - QTYPE.md rules updated: "one material one type" is gone; the new
81
+ spec is "material-level fields set defaults; cards can override".
82
+ - Search / lint / all internal consumers operate on `card.type` and
83
+ `card.tags` directly (still transparently backed by material defaults
84
+ when the card doesn't override).
85
+
16
86
  ## [0.0.6] - 2026-08-24
17
87
 
18
88
  ### Changed
package/CHANGELOG.zh.md CHANGED
@@ -12,6 +12,61 @@ English: [CHANGELOG.md](https://www.npmjs.com/package/@qnroa/qtype?activeTab=cod
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.0.8] — 2026-08-27
16
+
17
+ 破坏性 dist 格式变更。源 `material/*.md` 不变 —— 变化都在 build
18
+ 产物和 view 运行时。用户需要重新跑一次 `qtype publish build`;已部署
19
+ 的站点需要重新上传新的 `dist/`。
20
+
21
+ ### 变更
22
+ - **build 产物为每份材料输出一个 `.json`,不再拷贝原始 `.md`。**
23
+ 源文件 `qa/security.md` 现在会构建为 `dist/material/qa/security.json`,
24
+ 内容是已完整解析的 `Material`(结构化 body、预算好的 `charPlans`、
25
+ 拼音等)。加密仓落地为 `qa/security.json.enc`。浏览器不再解析
26
+ markdown,直接读 JSON 渲染。中等规模仓库冷启动延迟降低 30–50%。
27
+ - **`index.json.tagIndex` / `typeIndex` 值改为卡 id,不再是材料 file。**
28
+ 筛选 UI 可以直接枚举命中卡而不需要拉任何材料文件。顶层的 `cards[]`
29
+ 扁平卡索引保持不变(id / file / title / type / tags)。
30
+
31
+ ### 新增
32
+ - **Home 页卡级筛选视图**。勾一个 tag 或 type 后,材料网格会被替换
33
+ 成命中卡列表,按材料聚合展示但每张卡独立可点。
34
+ - **筛选跨材料翻页**。从命中卡列表点进 typing 时,前端会把命中列表
35
+ 带到 typing 路由,prev/next 沿命中列表跨材料游走,而不是回退到
36
+ 源材料内翻页。命中列表持久化到 `localStorage`,刷新页面(F5)后
37
+ 筛选上下文仍然保留。
38
+
39
+ ### 迁移
40
+ - 升级 qtype 依赖后,跑一次 `qtype publish build`。
41
+ - 老的 `dist/` 结构已不再被读取,直接删除并重新部署。
42
+
43
+ ## [0.0.7] — 2026-08-26
44
+
45
+ 完全向后兼容:老仓库解析不变。新能力是可选的 —— 只在卡片和材料
46
+ 默认不同时才写卡级 frontmatter。
47
+
48
+ ### 新增
49
+ - **`Card` 运行时结构新增 `type` 和 `tags` 字段**。值来自卡级 fm
50
+ 或从材料默认继承。下游代码在需要 per-card 语义时应读 `card.type` /
51
+ `card.tags`,不要读 `material.meta.type` / `material.meta.tags`。
52
+ - **卡级 frontmatter**。H1 后可以开第二段 `---\n...\n---` YAML 声明
53
+ 该卡的 `type` 和 `tags`。这样一个文件里可以自由混合 qa / code /
54
+ article —— 比如 1 张 qa 原理卡加 4 张不同语言的 code 实现卡。
55
+ - **素材级字段变成默认值**。`material.type` 是卡的 fallback,卡不写
56
+ 就继承。`material.tags` 和卡级 tags 合并去重。任何一张卡都能靠自己
57
+ 的卡级 fm 覆盖默认。
58
+ - **`publish build` 输出扁平卡池**。`dist/material/index.json` 新增
59
+ `cards`、`tagIndex`、`typeIndex` 三个字段,reader 无需拉每份 md
60
+ 就能做筛选。
61
+ - **迁移脚本**:`scripts/migrate_card_fm.py` 一键把素材级 type/tags
62
+ 下推到每张卡(可选,通常不需要 —— parser 原生支持向后继承)。
63
+
64
+ ### 变更
65
+ - QTYPE.md 规则更新:"一个 material 一个 type" 改为"素材级字段是默认
66
+ 值,卡可以覆盖"。
67
+ - 搜索、lint、所有内部消费方直接读 `card.type` 加 `card.tags`(值来
68
+ 自卡自身或素材默认,parser 透明处理)。
69
+
15
70
  ## [0.0.6] - 2026-08-24
16
71
 
17
72
  ### 变更
@@ -21,16 +21,16 @@ export async function contentSearchAction(query, opts = {}) {
21
21
  const rel = `${MATERIAL_DIR}/${file.rel}`;
22
22
  const src = readMaterial(file);
23
23
  const material = parseMaterial(src, rel);
24
- const materialType = material.meta.type?.toLowerCase() ?? 'article';
25
- if (typeFilter.length > 0 && !typeFilter.includes(materialType))
26
- continue;
27
- const materialTags = (material.meta.tags ?? []).map((t) => t.toLowerCase());
28
- if (tagFilter.length > 0 &&
29
- !tagFilter.every((wanted) => materialTags.includes(wanted))) {
30
- continue;
31
- }
32
24
  for (let i = 0; i < material.cards.length; i++) {
33
25
  const card = material.cards[i];
26
+ const cardType = card.type.toLowerCase();
27
+ if (typeFilter.length > 0 && !typeFilter.includes(cardType))
28
+ continue;
29
+ const cardTags = card.tags.map((t) => t.toLowerCase());
30
+ if (tagFilter.length > 0 &&
31
+ !tagFilter.every((wanted) => cardTags.includes(wanted))) {
32
+ continue;
33
+ }
34
34
  const question = card.question ?? card.title;
35
35
  const qLc = question.toLowerCase();
36
36
  const aLc = card.answer.toLowerCase();
@@ -50,8 +50,8 @@ export async function contentSearchAction(query, opts = {}) {
50
50
  question,
51
51
  answer: card.answer,
52
52
  snippet,
53
- type: material.meta.type,
54
- tags: material.meta.tags ?? [],
53
+ type: card.type,
54
+ tags: card.tags,
55
55
  });
56
56
  if (hits.length >= limit)
57
57
  break outer;
@@ -92,16 +92,14 @@ export async function contentValidateAction(opts = {}) {
92
92
  questionIndex.set(key, here);
93
93
  }
94
94
  }
95
- // Card-level tag consistency was removed with the frontmatter
96
- // simplification only material-level tags exist now, handled below.
95
+ // Collect card tags for cross-corpus casing consistency check.
96
+ for (const tag of card.tags) {
97
+ const lower = tag.toLowerCase();
98
+ const set = tagVariants.get(lower) ?? new Set();
99
+ set.add(tag);
100
+ tagVariants.set(lower, set);
101
+ }
97
102
  });
98
- // Material-level tag casing consistency.
99
- for (const tag of material.meta.tags) {
100
- const lower = tag.toLowerCase();
101
- const set = tagVariants.get(lower) ?? new Set();
102
- set.add(tag);
103
- tagVariants.set(lower, set);
104
- }
105
103
  }
106
104
  for (const [lower, variants] of tagVariants) {
107
105
  if (variants.size > 1) {
@@ -6,9 +6,22 @@
6
6
  * 2. Resolve optional password-encryption context (see `publish/password/`).
7
7
  * 3. Wipe & recreate the output directory.
8
8
  * 4. Copy the view bundle wholesale, then strip its dev-seed material/.
9
- * 5. Copy the user's material/*.md (encrypting each file if the repo has
10
- * password protection enabled) and emit an index.json manifest that
11
- * records the file list + titles for the view to consume.
9
+ * 5. Parse each `material/*.md` into a fully-typed Material (structured
10
+ * body, charPlans, romanization all resolved), then emit one
11
+ * `<path>.json` per source `.md` the view no longer parses markdown
12
+ * at runtime. Card-level filter/browse indices live in `index.json`.
13
+ *
14
+ * ─── Why JSON per material, not raw md ───
15
+ * Prior versions shipped the raw `.md` and had the browser call
16
+ * `parseMaterial()` on every load. That made typing-target extraction,
17
+ * pinyin romanization, and keystroke planning all client-side hot paths
18
+ * — visible latency on cold navigation, and duplicated logic between
19
+ * `qtype check` and the view. Moving the parse to build:
20
+ * - View pulls one JSON per material and renders directly — no parse.
21
+ * - `charPlans` / `romanization` are computed once, reused everywhere.
22
+ * - The `tagIndex` / `typeIndex` in `index.json` are cardId-grained,
23
+ * so tag/type filters can enumerate matching cards without loading
24
+ * any material file.
12
25
  */
13
26
  import fs from 'node:fs';
14
27
  import path from 'node:path';
@@ -20,7 +33,7 @@ import { createRepoKv } from '../../../bootstrap.js';
20
33
  import { deriveAesKey, encryptString, DEFAULT_ARGON2_PARAMS, } from '../../../../core/encrypt.js';
21
34
  import { verifyPassword } from '../password/action.js';
22
35
  import { prerenderMermaid, emptyMermaidPass } from './mermaidPass.js';
23
- import { parseMaterialFrontmatter } from '../../../../core/parser/frontmatter.js';
36
+ import { parseMaterial } from '../../../../core/parser/parseMaterial.js';
24
37
  import { walkMaterial, readMaterial, MATERIAL_DIR } from '../../../utils/material.js';
25
38
  export async function publishBuildAction(opts = {}) {
26
39
  const cwd = opts.path ? path.resolve(opts.path) : process.cwd();
@@ -51,11 +64,8 @@ export async function publishBuildAction(opts = {}) {
51
64
  const body = readMaterial(file);
52
65
  const filename = file.rel.split('/').pop() ?? file.rel;
53
66
  const title = readTitle(body) ?? filename.replace(/\.md$/, '');
54
- const fm = parseMaterialFrontmatter(body).meta;
55
- const fallbackType = file.rel.includes('/') ? file.rel.split('/')[0] : '';
56
- const type = fm.type ?? fallbackType;
57
67
  const cardCount = countH1(body);
58
- materials.push({ name: file.rel, body, title, type, tags: fm.tags, cardCount });
68
+ materials.push({ name: file.rel, body, title, cardCount });
59
69
  }
60
70
  // Mermaid pre-render — scan all bodies, produce svg + css maps.
61
71
  const mermaid = mermaidMode === 'prerender'
@@ -80,25 +90,47 @@ export async function publishBuildAction(opts = {}) {
80
90
  const targetMaterial = path.join(outDir, MATERIAL_DIR);
81
91
  fs.rmSync(targetMaterial, { recursive: true, force: true });
82
92
  fs.mkdirSync(targetMaterial, { recursive: true });
93
+ // Parse each material once. We reuse the parsed Material both for
94
+ // the per-material JSON payload and for the flat card index below,
95
+ // so a bad parse skips this material from both outputs (rather than
96
+ // half-emitting it).
97
+ const parsedByFile = new Map();
98
+ for (const m of materials) {
99
+ try {
100
+ parsedByFile.set(m.name, parseMaterial(m.body, m.name));
101
+ }
102
+ catch (err) {
103
+ info(t('publish.build.msg.parseSkip', {
104
+ file: m.name,
105
+ reason: err instanceof Error ? err.message : String(err),
106
+ }));
107
+ }
108
+ }
83
109
  const entries = [];
84
110
  for (const m of materials) {
111
+ const parsed = parsedByFile.get(m.name);
112
+ if (!parsed)
113
+ continue;
114
+ // Source md becomes `<path>.json` (or `<path>.json.enc` when
115
+ // encrypted). We strip the `.md` extension so the URL path mirrors
116
+ // the source hierarchy while carrying the runtime payload shape.
117
+ const jsonRel = m.name.replace(/\.md$/, '.json');
85
118
  const outPath = encryptCtx
86
- ? path.join(targetMaterial, `${m.name}.enc.json`)
87
- : path.join(targetMaterial, m.name);
119
+ ? path.join(targetMaterial, `${jsonRel}.enc`)
120
+ : path.join(targetMaterial, jsonRel);
88
121
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
122
+ const payload = serializeMaterial(parsed);
89
123
  if (encryptCtx) {
90
- const blob = await encryptString(encryptCtx.key, m.body);
124
+ const blob = await encryptString(encryptCtx.key, payload);
91
125
  fs.writeFileSync(outPath, JSON.stringify(blob));
92
126
  }
93
127
  else {
94
- fs.writeFileSync(outPath, m.body);
128
+ fs.writeFileSync(outPath, payload);
95
129
  }
96
130
  entries.push({
97
131
  file: m.name,
98
132
  title: m.title,
99
- type: m.type,
100
- tags: m.tags,
101
- cardCount: m.cardCount,
133
+ cardCount: parsed.cards.length,
102
134
  });
103
135
  }
104
136
  // Mermaid SVG map + CSS: the view fetches these alongside index.json and
@@ -124,8 +156,48 @@ export async function publishBuildAction(opts = {}) {
124
156
  if (fs.existsSync(srcAssets)) {
125
157
  copyDir(srcAssets, path.join(targetMaterial, 'assets'));
126
158
  }
159
+ // Card-pool index — flat listing of every card with its effective
160
+ // (material + card fm merged) metadata. Body content is NOT
161
+ // duplicated here; it lives in each material's own JSON payload.
162
+ //
163
+ // `tagIndex` / `typeIndex` map to card ids (not file paths) so
164
+ // filter UIs can enumerate matching cards without touching any
165
+ // material file.
166
+ const cardIndex = [];
167
+ const tagIndex = {};
168
+ const typeIndex = {};
169
+ for (const m of materials) {
170
+ const parsed = parsedByFile.get(m.name);
171
+ if (!parsed)
172
+ continue;
173
+ for (const card of parsed.cards) {
174
+ cardIndex.push({
175
+ id: card.id,
176
+ file: m.name,
177
+ title: card.title,
178
+ type: card.type,
179
+ tags: card.tags,
180
+ });
181
+ for (const tag of card.tags) {
182
+ if (!tagIndex[tag])
183
+ tagIndex[tag] = [];
184
+ tagIndex[tag].push(card.id);
185
+ }
186
+ const ct = card.type || '';
187
+ if (ct) {
188
+ if (!typeIndex[ct])
189
+ typeIndex[ct] = [];
190
+ typeIndex[ct].push(card.id);
191
+ }
192
+ }
193
+ }
127
194
  // Manifest: lists materials + optional encryption envelope + mermaid mode.
128
- const manifest = { materials: entries };
195
+ const manifest = {
196
+ materials: entries,
197
+ cards: cardIndex,
198
+ tagIndex,
199
+ typeIndex,
200
+ };
129
201
  if (encryptCtx) {
130
202
  manifest.encrypted = {
131
203
  algo: 'AES-GCM',
@@ -143,10 +215,10 @@ export async function publishBuildAction(opts = {}) {
143
215
  // Repo language — the reader locks its UI to this so the interface
144
216
  // matches the material's own language.
145
217
  manifest.lang = repoLang;
146
- fs.writeFileSync(path.join(targetMaterial, 'index.json'), JSON.stringify(manifest, null, 2) + '\n');
218
+ fs.writeFileSync(path.join(targetMaterial, 'index.json'), JSON.stringify(manifest));
147
219
  success(t('publish.build.msg.done', { path: outDir }));
148
220
  info(t('publish.build.msg.stat', {
149
- count: countCards(materialDir).toString(),
221
+ count: cardIndex.length.toString(),
150
222
  materialCount: entries.length.toString(),
151
223
  }));
152
224
  info(t('publish.build.msg.hint'));
@@ -273,13 +345,36 @@ function countH1(source) {
273
345
  }
274
346
  return n;
275
347
  }
276
- function countCards(materialDir) {
277
- let n = 0;
278
- for (const name of fs.readdirSync(materialDir)) {
279
- if (!name.endsWith('.md'))
280
- continue;
281
- const body = fs.readFileSync(path.join(materialDir, name), 'utf8');
282
- n += body.split(/\n\n---\n\n/).filter((s) => /##\s*Q:/.test(s)).length;
283
- }
284
- return n;
348
+ /**
349
+ * Serialize a parsed Material into the JSON string we ship in dist.
350
+ *
351
+ * Two size-oriented decisions:
352
+ * - `charPlans` is stripped. It's the single biggest field per card
353
+ * (one entry per typing character × its full keystroke list),
354
+ * and the view already has a fallback: `TypingArena` calls
355
+ * `textToCharPlans(target)` when the field is absent. Recomputing
356
+ * on load is cheap (μs per card) and the size win is 10× on
357
+ * CJK-heavy repos.
358
+ * - `romanizationAccepted` is kept but folded from `Set<string>[]`
359
+ * to `string[]` — sets don't survive `JSON.stringify`, and the
360
+ * view rebuilds them via `new Set(str)` on load. Compact, and
361
+ * recomputing pinyin+heteronym data client-side isn't cheap
362
+ * (needs the whole pinyin dictionary), so shipping it is worth
363
+ * the bytes.
364
+ *
365
+ * Serialized without indentation — dist is a runtime artifact, not
366
+ * something authors read. Skipping the 2-space indent halves the file
367
+ * size before gzip and matches how CDNs expect JSON assets to look.
368
+ */
369
+ function serializeMaterial(material) {
370
+ const cards = material.cards.map((c) => {
371
+ const accepted = c.romanizationAccepted
372
+ ? c.romanizationAccepted.map((s) => [...s].sort().join(''))
373
+ : undefined;
374
+ // Explicitly omit charPlans — reconstructed by the view.
375
+ const { charPlans: _charPlans, ...rest } = c;
376
+ void _charPlans;
377
+ return { ...rest, romanizationAccepted: accepted };
378
+ });
379
+ return JSON.stringify({ ...material, cards });
285
380
  }
@@ -22,6 +22,7 @@ export const messages = {
22
22
  'publish.build.msg.mermaidDone': 'mermaid 渲染完成:{count} 个图,{errored} 个失败',
23
23
  'publish.build.msg.mermaidErrored': '{count} 个 mermaid 图渲染失败(已回落到错误占位 SVG)',
24
24
  'publish.build.msg.mermaidSkip': 'publish.mermaid={mode},跳过预渲染',
25
+ 'publish.build.msg.parseSkip': '跳过 {file}(解析失败:{reason})',
25
26
  },
26
27
  'en-US': {
27
28
  'publish.build.summary': 'Build the static material site',
@@ -46,5 +47,6 @@ export const messages = {
46
47
  'publish.build.msg.mermaidDone': 'Mermaid render complete: {count} diagrams, {errored} failed',
47
48
  'publish.build.msg.mermaidErrored': '{count} mermaid diagram(s) failed to render (falling back to inline error SVG)',
48
49
  'publish.build.msg.mermaidSkip': 'publish.mermaid={mode}, skipping prerender',
50
+ 'publish.build.msg.parseSkip': 'Skipped {file} (parse failed: {reason})',
49
51
  },
50
52
  };
@@ -15,7 +15,7 @@ const FM_RE = /^---\n([\s\S]*?)\n---\n?/;
15
15
  * per-type rules).
16
16
  */
17
17
  export function parseMaterialFrontmatter(source) {
18
- const meta = { tags: [] };
18
+ const meta = {};
19
19
  const match = source.match(FM_RE);
20
20
  if (!match)
21
21
  return { meta, rest: source };
@@ -54,9 +54,32 @@ function parseTagList(value) {
54
54
  .filter(Boolean);
55
55
  }
56
56
  /**
57
- * Kept for backwards compatibility of the barrel export. Card-level
58
- * frontmatter is no longer used; this always returns empty meta and
59
- * the original block unchanged.
57
+ * Card-level frontmatter sits on the very first non-empty lines of a
58
+ * card body (right after the H1). Same YAML-fence syntax as the
59
+ * material-level block so users don't have to learn a second grammar.
60
+ * Returns empty meta if the body doesn't start with `---`.
61
+ */
62
+ export function parseCardFrontmatter(cardBody) {
63
+ const meta = { tags: [] };
64
+ const match = cardBody.match(FM_RE);
65
+ if (!match)
66
+ return { meta, rest: cardBody };
67
+ const body = match[1];
68
+ const fields = {};
69
+ for (const line of body.split('\n')) {
70
+ const kv = line.match(/^(\w+)\s*:\s*(.+)$/);
71
+ if (!kv)
72
+ continue;
73
+ fields[kv[1]] = kv[2].trim();
74
+ }
75
+ if (fields.type)
76
+ meta.type = fields.type;
77
+ if (fields.tags)
78
+ meta.tags = parseTagList(fields.tags);
79
+ return { meta, rest: cardBody.slice(match[0].length) };
80
+ }
81
+ /**
82
+ * Kept for backwards compatibility of the barrel export.
60
83
  */
61
84
  export function parseFrontmatter(block) {
62
85
  return { meta: { tags: [] }, rest: block };
@@ -1,15 +1,16 @@
1
- import { parseMaterialFrontmatter } from './frontmatter.js';
1
+ import { parseMaterialFrontmatter, parseCardFrontmatter } from './frontmatter.js';
2
2
  import { textToCharPlans } from '../keystroke/index.js';
3
3
  import { validateTypingText } from '../keystroke/textNormalize.js';
4
4
  import { containsCJK, romanize } from '../romanization/index.js';
5
5
  export function parseMaterial(source, filePath = 'inline') {
6
6
  const normalized = source.replace(/\r\n/g, '\n').trim() + '\n';
7
7
  const { meta: materialMeta, rest } = parseMaterialFrontmatter(normalized);
8
- const materialType = materialMeta.type ?? 'article';
8
+ const defaultType = materialMeta.type;
9
+ const defaultTags = materialMeta.tags ?? [];
9
10
  const rawCards = splitByH1(rest);
10
11
  const cards = [];
11
12
  for (let i = 0; i < rawCards.length; i++) {
12
- const card = parseCard(rawCards[i], materialType, filePath, i);
13
+ const card = parseCard(rawCards[i], defaultType, defaultTags, filePath, i);
13
14
  if (card)
14
15
  cards.push(attachRomanization(card));
15
16
  }
@@ -57,7 +58,7 @@ function attachRomanization(card) {
57
58
  romanizationAccepted: rom.accepted,
58
59
  };
59
60
  }
60
- function parseCard(chunk, materialType, filePath, index) {
61
+ function parseCard(chunk, defaultType, defaultTags, filePath, index) {
61
62
  const lines = chunk.split('\n');
62
63
  const h1Line = lines[0];
63
64
  const titleMatch = h1Line.match(/^#\s+(.*)$/);
@@ -66,19 +67,50 @@ function parseCard(chunk, materialType, filePath, index) {
66
67
  const title = titleMatch[1].trim();
67
68
  if (!title)
68
69
  return null;
69
- const body = lines.slice(1).join('\n').trim();
70
- switch (materialType) {
70
+ const rawBody = lines.slice(1).join('\n').trim();
71
+ const { meta: cardMeta, rest: bodyAfterFm } = parseCardFrontmatter(rawBody);
72
+ const body = bodyAfterFm.trim();
73
+ // Type resolution: card's own type wins, otherwise fall back to the
74
+ // material's default. A card with neither is unparseable and gets
75
+ // skipped (validators can surface this to the author).
76
+ const cardType = (cardMeta.type ?? defaultType);
77
+ if (!cardType)
78
+ return null;
79
+ const tags = dedupe([...defaultTags, ...cardMeta.tags]);
80
+ let card;
81
+ switch (cardType) {
71
82
  case 'word':
72
83
  case 'sentence':
73
- return buildAnnotatedCard(title, body, filePath, index);
84
+ card = buildAnnotatedCard(title, body, filePath, index);
85
+ break;
74
86
  case 'qa':
75
87
  case 'code':
76
- return buildQACard(title, body, filePath, index);
88
+ card = buildQACard(title, body, filePath, index);
89
+ break;
77
90
  case 'article':
78
91
  case 'snippet':
79
92
  default:
80
- return buildArticleCard(title, body, filePath, index);
93
+ card = buildArticleCard(title, body, filePath, index);
94
+ break;
95
+ }
96
+ if (!card)
97
+ return null;
98
+ card.type = cardType;
99
+ card.tags = tags;
100
+ return card;
101
+ }
102
+ function dedupe(tags) {
103
+ const seen = new Set();
104
+ const out = [];
105
+ for (const t of tags) {
106
+ if (!t)
107
+ continue;
108
+ if (seen.has(t))
109
+ continue;
110
+ seen.add(t);
111
+ out.push(t);
81
112
  }
113
+ return out;
82
114
  }
83
115
  /**
84
116
  * word / sentence: H1 is the typing target. H2/H3/H4 are optional
@@ -102,6 +134,8 @@ function buildAnnotatedCard(title, body, filePath, index) {
102
134
  image: h4 || undefined,
103
135
  charPlans,
104
136
  unknownChars: validated.unknowns,
137
+ type: '',
138
+ tags: [],
105
139
  };
106
140
  }
107
141
  /**
@@ -129,6 +163,8 @@ function buildArticleCard(title, body, filePath, index) {
129
163
  question: body,
130
164
  charPlans,
131
165
  unknownChars: validated.unknowns,
166
+ type: '',
167
+ tags: [],
132
168
  };
133
169
  }
134
170
  /**
@@ -158,6 +194,8 @@ function buildQACard(title, body, filePath, index) {
158
194
  question,
159
195
  charPlans,
160
196
  unknownChars: validated.unknowns,
197
+ type: '',
198
+ tags: [],
161
199
  };
162
200
  }
163
201
  /**