@qnroa/qtype 0.0.7 → 0.0.9

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,72 @@ can land in any minor bump (`0.x.0`).
13
13
 
14
14
  ## [Unreleased]
15
15
 
16
+ ## [0.0.9] — 2026-08-27
17
+
18
+ View-only. No dist change, no need to rebuild — refreshing the page
19
+ on an existing deployment is enough.
20
+
21
+ ### Changed
22
+ - **Grid ↔ list view toggle is now the outermost switch, orthogonal
23
+ to filtering.** Previously any active tag/type filter forced the
24
+ home page into a list layout, ignoring the user's chosen view
25
+ mode. Now grid stays grid, list stays list — the filter changes
26
+ *what* is listed (materials vs. matching cards), not *how*.
27
+
28
+ ### Added
29
+ - **`CardHitCard`** — grid tile for a card hit, sibling of
30
+ `MaterialCard`. Kept alongside the existing `CardHitRow` so the
31
+ new 2×2 (grid/list × material/card) matrix has one component per
32
+ cell.
33
+ - **`EntryCard` / `EntryRow` base components.** The reusable shell
34
+ (click / keyboard routing, selection checkbox, done stamp,
35
+ hover / focus states, header + actions slots) is now factored
36
+ out so specializations only own their body content. Same
37
+ `.qt-deckcard*` / `.qt-deckrow*` selectors, no visual change.
38
+ - **`RowTags` helper.** The responsive tag-chip truncation logic
39
+ from `MaterialRow` moved into a shared component and is now used
40
+ by `CardHitRow` too.
41
+
42
+ ### Internal
43
+ - No public API changes to `MaterialRepository` or build outputs.
44
+ - Card-level filter view now shows a `done` stamp per card
45
+ (backed by `useDoneState.isCardDone`).
46
+
47
+ ## [0.0.8] — 2026-08-27
48
+
49
+ Breaking dist format. Source `material/*.md` unchanged — the change
50
+ is entirely in the build output and the view runtime. Users must
51
+ re-run `qtype publish build` once; deployed sites need to re-upload
52
+ the new `dist/`.
53
+
54
+ ### Changed
55
+ - **Build ships one `.json` per material, not the raw `.md`.**
56
+ Each source `qa/security.md` now becomes `qa/security.json` under
57
+ `dist/material/`, containing the fully-parsed `Material` (structured
58
+ body, precomputed `charPlans`, romanization). Encrypted repos land
59
+ as `qa/security.json.enc`. The browser no longer parses markdown —
60
+ it fetches the JSON and renders directly. Roughly a 30–50% cold-load
61
+ latency win on medium repos; the parse ran on every navigation before.
62
+ - **`index.json.tagIndex` / `typeIndex` map to card ids, not file paths.**
63
+ Filter UIs can now enumerate matching cards without loading a single
64
+ material file. The `cards[]` flat card index remains as-is (id / file
65
+ / title / type / tags).
66
+
67
+ ### Added
68
+ - **Card-level filter view on Home.** Selecting a tag or type replaces
69
+ the material grid with a flat list of matching cards, grouped by
70
+ material for context but clickable per card.
71
+ - **Cross-material filter navigation in typing.** Clicking a card from
72
+ the filter hit list carries the hit list into the typing route —
73
+ prev/next walks the filter results across materials, not the source
74
+ material's own card order. The hit list is persisted in
75
+ `localStorage`, so a page refresh keeps the filter context (F5 no
76
+ longer drops it).
77
+
78
+ ### Migration
79
+ - Upgrade the qtype dependency, then run `qtype publish build` once.
80
+ - Old `dist/` layout is not read anymore; delete and redeploy.
81
+
16
82
  ## [0.0.7] — 2026-08-26
17
83
 
18
84
  Fully backward compatible: existing repos parse unchanged. The
package/CHANGELOG.zh.md CHANGED
@@ -12,6 +12,60 @@ English: [CHANGELOG.md](https://www.npmjs.com/package/@qnroa/qtype?activeTab=cod
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.0.9] — 2026-08-27
16
+
17
+ 只改 view,dist 未变,老部署刷新页面即可,无需重跑 build。
18
+
19
+ ### 变更
20
+ - **grid ↔ list 视图切换现在是最外层开关,和筛选正交**。之前只要
21
+ 勾了 tag/type,不管用户当前选的是 grid 还是 list,都强制变成 list;
22
+ 现在 grid 保持 grid,list 保持 list,筛选只决定"列的是什么"
23
+ (材料还是命中卡),不决定"怎么呈现"。
24
+
25
+ ### 新增
26
+ - **`CardHitCard`** — 命中卡的 grid 版本,和 `MaterialCard` 是兄弟。
27
+ 与已有的 `CardHitRow` 一起,凑齐新的 2×2 (grid/list × 材料/卡)
28
+ 组合,每格一个组件。
29
+ - **`EntryCard` / `EntryRow` 基类**。可复用的容器逻辑(点击/键盘
30
+ 路由、选中框、done stamp、hover/focus 态、header + actions 槽)
31
+ 抽成基类,特化组件只负责自己的 body。`.qt-deckcard*` / `.qt-deckrow*`
32
+ 样式选择器不变,视觉零变化。
33
+ - **`RowTags` helper**。之前写死在 `MaterialRow` 里的响应式 tag
34
+ chip 截断逻辑抽成独立组件,现在 `CardHitRow` 也能复用。
35
+
36
+ ### 内部
37
+ - `MaterialRepository` 公共接口和 build 产物未变。
38
+ - 命中卡列表 grid/list 都开始显示卡级 done stamp
39
+ (由 `useDoneState.isCardDone` 提供)。
40
+
41
+ ## [0.0.8] — 2026-08-27
42
+
43
+ 破坏性 dist 格式变更。源 `material/*.md` 不变 —— 变化都在 build
44
+ 产物和 view 运行时。用户需要重新跑一次 `qtype publish build`;已部署
45
+ 的站点需要重新上传新的 `dist/`。
46
+
47
+ ### 变更
48
+ - **build 产物为每份材料输出一个 `.json`,不再拷贝原始 `.md`。**
49
+ 源文件 `qa/security.md` 现在会构建为 `dist/material/qa/security.json`,
50
+ 内容是已完整解析的 `Material`(结构化 body、预算好的 `charPlans`、
51
+ 拼音等)。加密仓落地为 `qa/security.json.enc`。浏览器不再解析
52
+ markdown,直接读 JSON 渲染。中等规模仓库冷启动延迟降低 30–50%。
53
+ - **`index.json.tagIndex` / `typeIndex` 值改为卡 id,不再是材料 file。**
54
+ 筛选 UI 可以直接枚举命中卡而不需要拉任何材料文件。顶层的 `cards[]`
55
+ 扁平卡索引保持不变(id / file / title / type / tags)。
56
+
57
+ ### 新增
58
+ - **Home 页卡级筛选视图**。勾一个 tag 或 type 后,材料网格会被替换
59
+ 成命中卡列表,按材料聚合展示但每张卡独立可点。
60
+ - **筛选跨材料翻页**。从命中卡列表点进 typing 时,前端会把命中列表
61
+ 带到 typing 路由,prev/next 沿命中列表跨材料游走,而不是回退到
62
+ 源材料内翻页。命中列表持久化到 `localStorage`,刷新页面(F5)后
63
+ 筛选上下文仍然保留。
64
+
65
+ ### 迁移
66
+ - 升级 qtype 依赖后,跑一次 `qtype publish build`。
67
+ - 老的 `dist/` 结构已不再被读取,直接删除并重新部署。
68
+
15
69
  ## [0.0.7] — 2026-08-26
16
70
 
17
71
  完全向后兼容:老仓库解析不变。新能力是可选的 —— 只在卡片和材料
@@ -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';
@@ -77,23 +90,47 @@ export async function publishBuildAction(opts = {}) {
77
90
  const targetMaterial = path.join(outDir, MATERIAL_DIR);
78
91
  fs.rmSync(targetMaterial, { recursive: true, force: true });
79
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
+ }
80
109
  const entries = [];
81
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');
82
118
  const outPath = encryptCtx
83
- ? path.join(targetMaterial, `${m.name}.enc.json`)
84
- : path.join(targetMaterial, m.name);
119
+ ? path.join(targetMaterial, `${jsonRel}.enc`)
120
+ : path.join(targetMaterial, jsonRel);
85
121
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
122
+ const payload = serializeMaterial(parsed);
86
123
  if (encryptCtx) {
87
- const blob = await encryptString(encryptCtx.key, m.body);
124
+ const blob = await encryptString(encryptCtx.key, payload);
88
125
  fs.writeFileSync(outPath, JSON.stringify(blob));
89
126
  }
90
127
  else {
91
- fs.writeFileSync(outPath, m.body);
128
+ fs.writeFileSync(outPath, payload);
92
129
  }
93
130
  entries.push({
94
131
  file: m.name,
95
132
  title: m.title,
96
- cardCount: m.cardCount,
133
+ cardCount: parsed.cards.length,
97
134
  });
98
135
  }
99
136
  // Mermaid SVG map + CSS: the view fetches these alongside index.json and
@@ -119,22 +156,20 @@ export async function publishBuildAction(opts = {}) {
119
156
  if (fs.existsSync(srcAssets)) {
120
157
  copyDir(srcAssets, path.join(targetMaterial, 'assets'));
121
158
  }
122
- // Card-pool index — parse every material once so the view can
123
- // filter across files by card-level type/tags without fetching each
124
- // md. Cards keep their full effective (material + card fm merged)
125
- // metadata; body content is NOT duplicated here (still fetched
126
- // on-demand from the .md file).
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.
127
166
  const cardIndex = [];
128
167
  const tagIndex = {};
129
168
  const typeIndex = {};
130
169
  for (const m of materials) {
131
- let parsed;
132
- try {
133
- parsed = parseMaterial(m.body, m.name);
134
- }
135
- catch {
170
+ const parsed = parsedByFile.get(m.name);
171
+ if (!parsed)
136
172
  continue;
137
- }
138
173
  for (const card of parsed.cards) {
139
174
  cardIndex.push({
140
175
  id: card.id,
@@ -146,15 +181,13 @@ export async function publishBuildAction(opts = {}) {
146
181
  for (const tag of card.tags) {
147
182
  if (!tagIndex[tag])
148
183
  tagIndex[tag] = [];
149
- if (!tagIndex[tag].includes(m.name))
150
- tagIndex[tag].push(m.name);
184
+ tagIndex[tag].push(card.id);
151
185
  }
152
- const t = card.type || '';
153
- if (t) {
154
- if (!typeIndex[t])
155
- typeIndex[t] = [];
156
- if (!typeIndex[t].includes(m.name))
157
- typeIndex[t].push(m.name);
186
+ const ct = card.type || '';
187
+ if (ct) {
188
+ if (!typeIndex[ct])
189
+ typeIndex[ct] = [];
190
+ typeIndex[ct].push(card.id);
158
191
  }
159
192
  }
160
193
  }
@@ -182,10 +215,10 @@ export async function publishBuildAction(opts = {}) {
182
215
  // Repo language — the reader locks its UI to this so the interface
183
216
  // matches the material's own language.
184
217
  manifest.lang = repoLang;
185
- fs.writeFileSync(path.join(targetMaterial, 'index.json'), JSON.stringify(manifest, null, 2) + '\n');
218
+ fs.writeFileSync(path.join(targetMaterial, 'index.json'), JSON.stringify(manifest));
186
219
  success(t('publish.build.msg.done', { path: outDir }));
187
220
  info(t('publish.build.msg.stat', {
188
- count: countCards(materialDir).toString(),
221
+ count: cardIndex.length.toString(),
189
222
  materialCount: entries.length.toString(),
190
223
  }));
191
224
  info(t('publish.build.msg.hint'));
@@ -312,13 +345,36 @@ function countH1(source) {
312
345
  }
313
346
  return n;
314
347
  }
315
- function countCards(materialDir) {
316
- let n = 0;
317
- for (const name of fs.readdirSync(materialDir)) {
318
- if (!name.endsWith('.md'))
319
- continue;
320
- const body = fs.readFileSync(path.join(materialDir, name), 'utf8');
321
- n += body.split(/\n\n---\n\n/).filter((s) => /##\s*Q:/.test(s)).length;
322
- }
323
- 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 });
324
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
  };
@@ -1,3 +1,3 @@
1
- const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/index-BM7BgXjn.js","assets/index-C1KzsLUa.js","assets/index-BLryk3W4.css"])))=>i.map(i=>d[i]);
2
- import{r as l,_ as h,u as g,j as f,C as p,M as m,T as y,a as w}from"./index-C1KzsLUa.js";let o=null,c=null;async function L(){return o||c||(c=h(()=>import("./index-BM7BgXjn.js"),__vite__mapDeps([0,1,2])).then(async e=>(o=await e.createHighlighter({themes:["github-dark","github-light"],langs:[]}),o)),c)}async function _(e,t){if(!e.getLoadedLanguages().includes(t))try{await e.loadLanguage(t)}catch{}}function x(e){const[t,n]=l.useState(o);return l.useEffect(()=>{if(!e)return;let r=!1;return(async()=>{const s=await L();await _(s,e),r||n(s)})(),()=>{r=!0}},[e]),t}const A=/```(\w+)/;function E(e){const t=e.toLowerCase();return t.includes("typescript")||t.endsWith(".ts")?"typescript":t.includes("python")||t.endsWith(".py")?"python":t.includes("cpp")||t.includes("c++")?"cpp":t.includes("java")&&!t.includes("script")?"java":t.includes("go")?"go":t.includes("rust")||t.endsWith(".rs")?"rust":t.includes("code-c")||t.endsWith(".c")?"c":"javascript"}function j(e,t){if(e){const n=e.match(A);if(n)return n[1]}return E(t)}function k(e,t,n,r){if(!r||!r.getLoadedLanguages().includes(t))return new Array(Array.from(e).length).fill(null);try{const s=r.codeToTokens(e,{lang:t,theme:n}),i=[];for(const u of s.tokens){for(const a of u)for(const d of a.content)i.push(a.color??null);i.push(null)}return!e.endsWith(`
1
+ const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/index-Due6dVBA.js","assets/index-D2236cNR.js","assets/index-DI1_zxwA.css"])))=>i.map(i=>d[i]);
2
+ import{r as l,_ as h,u as g,j as f,C as p,M as m,T as y,a as w}from"./index-D2236cNR.js";let o=null,c=null;async function L(){return o||c||(c=h(()=>import("./index-Due6dVBA.js"),__vite__mapDeps([0,1,2])).then(async e=>(o=await e.createHighlighter({themes:["github-dark","github-light"],langs:[]}),o)),c)}async function _(e,t){if(!e.getLoadedLanguages().includes(t))try{await e.loadLanguage(t)}catch{}}function x(e){const[t,n]=l.useState(o);return l.useEffect(()=>{if(!e)return;let r=!1;return(async()=>{const s=await L();await _(s,e),r||n(s)})(),()=>{r=!0}},[e]),t}const A=/```(\w+)/;function E(e){const t=e.toLowerCase();return t.includes("typescript")||t.endsWith(".ts")?"typescript":t.includes("python")||t.endsWith(".py")?"python":t.includes("cpp")||t.includes("c++")?"cpp":t.includes("java")&&!t.includes("script")?"java":t.includes("go")?"go":t.includes("rust")||t.endsWith(".rs")?"rust":t.includes("code-c")||t.endsWith(".c")?"c":"javascript"}function j(e,t){if(e){const n=e.match(A);if(n)return n[1]}return E(t)}function k(e,t,n,r){if(!r||!r.getLoadedLanguages().includes(t))return new Array(Array.from(e).length).fill(null);try{const s=r.codeToTokens(e,{lang:t,theme:n}),i=[];for(const u of s.tokens){for(const a of u)for(const d of a.content)i.push(a.color??null);i.push(null)}return!e.endsWith(`
3
3
  `)&&i.length>Array.from(e).length&&i.pop(),i}catch{return new Array(Array.from(e).length).fill(null)}}function v(e){var d;const{material:t,card:n}=e,[r]=g(),s=l.useMemo(()=>r?j(n.question,t.filePath):"",[n.question,t.filePath,r]),i=typeof document<"u"&&((d=document.documentElement.getAttribute("data-theme"))!=null&&d.includes("latte"))?"github-light":"github-dark",u=x(r?s:""),a=l.useMemo(()=>r?k(n.answer,s,i,u):void 0,[n.answer,s,i,u,r]);return f.jsx(p,{type:"code",variant:"flow",slots:[{role:"title",content:n.title},n.question?{role:"question",content:f.jsx(m,{source:n.question})}:{role:"question",content:null},{role:"arena",content:f.jsx(y,{...w(e),charColors:a},n.id)}]})}export{v as CodeCard};