@qnroa/qtype 0.0.5 → 0.0.7
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 +64 -0
- package/CHANGELOG.zh.md +50 -0
- package/dist/cli/commands/content/search/action.js +10 -10
- package/dist/cli/commands/content/validate/action.js +7 -9
- package/dist/cli/commands/publish/build/action.js +47 -8
- package/dist/core/parser/frontmatter.js +27 -4
- package/dist/core/parser/parseMaterial.js +47 -9
- package/dist/view/assets/{index-DgHvnMV9.js → index-BM7BgXjn.js} +1 -1
- package/dist/view/assets/{index-1XXyxiQO.js → index-C1KzsLUa.js} +40 -39
- package/dist/view/assets/{index-DIBSxFYl.js → index-DNcGZhd7.js} +2 -2
- package/dist/view/index.html +1 -1
- package/package.json +1 -1
- package/templates/en/QTYPE.md +71 -11
- package/templates/zh/QTYPE.md +68 -12
package/CHANGELOG.md
CHANGED
|
@@ -13,6 +13,70 @@ can land in any minor bump (`0.x.0`).
|
|
|
13
13
|
|
|
14
14
|
## [Unreleased]
|
|
15
15
|
|
|
16
|
+
## [0.0.7] — 2026-08-26
|
|
17
|
+
|
|
18
|
+
Fully backward compatible: existing repos parse unchanged. The
|
|
19
|
+
new capability is opt-in — write card-level frontmatter only where
|
|
20
|
+
a card differs from the material default.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- **`Card` runtime shape gains `type` and `tags` fields.** Populated
|
|
24
|
+
either by card-level frontmatter or by inheriting the material
|
|
25
|
+
default. Downstream code should read `card.type` / `card.tags`
|
|
26
|
+
instead of `material.meta.type` / `material.meta.tags` when it
|
|
27
|
+
wants per-card resolution.
|
|
28
|
+
- **Card-level frontmatter.** After an H1 you may open a second
|
|
29
|
+
`---\n...\n---` YAML block declaring `type` and `tags` for that one
|
|
30
|
+
card. This lets a single file mix qa / code / article — e.g. one qa
|
|
31
|
+
"principle" card followed by four code cards for cpp / py / go / java.
|
|
32
|
+
- **Material-level fields are defaults.** `material.type` is the
|
|
33
|
+
fallback type for cards that don't declare their own.
|
|
34
|
+
`material.tags` is unioned with each card's tags (deduped). A card
|
|
35
|
+
can always override with its own card-level fm.
|
|
36
|
+
- **`publish build` emits a flat card pool.** `dist/material/index.json`
|
|
37
|
+
carries `cards`, `tagIndex`, and `typeIndex` alongside the existing
|
|
38
|
+
`materials` list so the reader can filter/pick without fetching
|
|
39
|
+
every `.md`.
|
|
40
|
+
- **Migration scripts.** `scripts/migrate_card_fm.py` pushes
|
|
41
|
+
material-level defaults down into every card (opt-in). Bundled but
|
|
42
|
+
usually unnecessary — the parser now handles legacy files natively.
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
- QTYPE.md rules updated: "one material one type" is gone; the new
|
|
46
|
+
spec is "material-level fields set defaults; cards can override".
|
|
47
|
+
- Search / lint / all internal consumers operate on `card.type` and
|
|
48
|
+
`card.tags` directly (still transparently backed by material defaults
|
|
49
|
+
when the card doesn't override).
|
|
50
|
+
|
|
51
|
+
## [0.0.6] - 2026-08-24
|
|
52
|
+
|
|
53
|
+
### Changed
|
|
54
|
+
|
|
55
|
+
- **`QTYPE.md` template documents nested `material/` folders as
|
|
56
|
+
first-class.** The template already-existing `walkMaterial` recurses
|
|
57
|
+
into `material/<category>/<slug>.md`, and `publish build` even uses
|
|
58
|
+
the first path segment as a fallback `type` when frontmatter omits
|
|
59
|
+
it — but the previous wording ("flat `material/` + `assets/` only",
|
|
60
|
+
rule #9) read as a ban on subdirectories, misleading Agent-driven
|
|
61
|
+
authors into flattening large repos. The template now:
|
|
62
|
+
- shows both flat and nested placements in the Directory Layout
|
|
63
|
+
tree, with a one-line note that both are supported and discovery
|
|
64
|
+
is recursive;
|
|
65
|
+
- adds a **Grouping by folder (optional)** section covering the
|
|
66
|
+
recursive-discovery behavior and the first-segment-as-fallback-`type`
|
|
67
|
+
shortcut (`material/code/hello.md` → `type: code` when frontmatter
|
|
68
|
+
is silent);
|
|
69
|
+
- reworks the Naming section to say each path segment (`<slug>`,
|
|
70
|
+
`<category>`) uses the same kebab-case rules;
|
|
71
|
+
- rewrites rule #9 to enumerate the allowed root-level directories
|
|
72
|
+
(`material/`, `material/assets/`, `raw/`) and cross-reference the
|
|
73
|
+
new subfolder section, replacing the misleading "flat only"
|
|
74
|
+
phrasing.
|
|
75
|
+
Applies to both `templates/en/QTYPE.md` and `templates/zh/QTYPE.md`.
|
|
76
|
+
No runtime code changed; existing repos are unaffected until they
|
|
77
|
+
regenerate their `QTYPE.md` via `qtype repo new` or copy the new
|
|
78
|
+
template in.
|
|
79
|
+
|
|
16
80
|
## [0.0.5] - 2026-08-22
|
|
17
81
|
|
|
18
82
|
### Fixed
|
package/CHANGELOG.zh.md
CHANGED
|
@@ -12,6 +12,56 @@ English: [CHANGELOG.md](https://www.npmjs.com/package/@qnroa/qtype?activeTab=cod
|
|
|
12
12
|
|
|
13
13
|
## [Unreleased]
|
|
14
14
|
|
|
15
|
+
## [0.0.7] — 2026-08-26
|
|
16
|
+
|
|
17
|
+
完全向后兼容:老仓库解析不变。新能力是可选的 —— 只在卡片和材料
|
|
18
|
+
默认不同时才写卡级 frontmatter。
|
|
19
|
+
|
|
20
|
+
### 新增
|
|
21
|
+
- **`Card` 运行时结构新增 `type` 和 `tags` 字段**。值来自卡级 fm
|
|
22
|
+
或从材料默认继承。下游代码在需要 per-card 语义时应读 `card.type` /
|
|
23
|
+
`card.tags`,不要读 `material.meta.type` / `material.meta.tags`。
|
|
24
|
+
- **卡级 frontmatter**。H1 后可以开第二段 `---\n...\n---` YAML 声明
|
|
25
|
+
该卡的 `type` 和 `tags`。这样一个文件里可以自由混合 qa / code /
|
|
26
|
+
article —— 比如 1 张 qa 原理卡加 4 张不同语言的 code 实现卡。
|
|
27
|
+
- **素材级字段变成默认值**。`material.type` 是卡的 fallback,卡不写
|
|
28
|
+
就继承。`material.tags` 和卡级 tags 合并去重。任何一张卡都能靠自己
|
|
29
|
+
的卡级 fm 覆盖默认。
|
|
30
|
+
- **`publish build` 输出扁平卡池**。`dist/material/index.json` 新增
|
|
31
|
+
`cards`、`tagIndex`、`typeIndex` 三个字段,reader 无需拉每份 md
|
|
32
|
+
就能做筛选。
|
|
33
|
+
- **迁移脚本**:`scripts/migrate_card_fm.py` 一键把素材级 type/tags
|
|
34
|
+
下推到每张卡(可选,通常不需要 —— parser 原生支持向后继承)。
|
|
35
|
+
|
|
36
|
+
### 变更
|
|
37
|
+
- QTYPE.md 规则更新:"一个 material 一个 type" 改为"素材级字段是默认
|
|
38
|
+
值,卡可以覆盖"。
|
|
39
|
+
- 搜索、lint、所有内部消费方直接读 `card.type` 加 `card.tags`(值来
|
|
40
|
+
自卡自身或素材默认,parser 透明处理)。
|
|
41
|
+
|
|
42
|
+
## [0.0.6] - 2026-08-24
|
|
43
|
+
|
|
44
|
+
### 变更
|
|
45
|
+
|
|
46
|
+
- **`QTYPE.md` 模板把嵌套 `material/` 子目录写成一等公民**。
|
|
47
|
+
`walkMaterial` 一直就是递归的,`publish build` 甚至会用首段路径
|
|
48
|
+
作为 frontmatter 没写 `type` 时的 fallback —— 但之前模板里
|
|
49
|
+
"flat `material/` + `assets/` only"(规则 #9)的措辞读起来像禁止
|
|
50
|
+
子目录,把 Agent 驱动的作者误导成"再大的仓库也只能平铺"。这次:
|
|
51
|
+
- Directory Layout 目录树里同时画出平铺和嵌套两种放法,并加一行
|
|
52
|
+
"两种都支持,发现是递归的";
|
|
53
|
+
- 新增 **按分类分子目录(可选)** 小节,说明递归发现行为,以及
|
|
54
|
+
首段路径当 fallback `type` 的捷径(`material/code/hello.md`
|
|
55
|
+
在 frontmatter 没 `type` 时被视为 `type: code`);
|
|
56
|
+
- 命名节改为"每一段路径(`<slug>`、`<category>`)都用同一套
|
|
57
|
+
kebab-case 规则";
|
|
58
|
+
- 规则 #9 重写为枚举根目录允许的三个位置(`material/` /
|
|
59
|
+
`material/assets/` / `raw/`)并 xref 新的子目录小节,替换掉
|
|
60
|
+
误导性的"平铺"措辞。
|
|
61
|
+
同步改动 `templates/en/QTYPE.md` 和 `templates/zh/QTYPE.md`。
|
|
62
|
+
运行时代码没动;老仓库要拿到新文档需要重跑 `qtype repo new`
|
|
63
|
+
或手动把新模板拷进去。
|
|
64
|
+
|
|
15
65
|
## [0.0.5] - 2026-08-22
|
|
16
66
|
|
|
17
67
|
### 修复
|
|
@@ -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:
|
|
54
|
-
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
|
-
//
|
|
96
|
-
|
|
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) {
|
|
@@ -20,7 +20,7 @@ import { createRepoKv } from '../../../bootstrap.js';
|
|
|
20
20
|
import { deriveAesKey, encryptString, DEFAULT_ARGON2_PARAMS, } from '../../../../core/encrypt.js';
|
|
21
21
|
import { verifyPassword } from '../password/action.js';
|
|
22
22
|
import { prerenderMermaid, emptyMermaidPass } from './mermaidPass.js';
|
|
23
|
-
import {
|
|
23
|
+
import { parseMaterial } from '../../../../core/parser/parseMaterial.js';
|
|
24
24
|
import { walkMaterial, readMaterial, MATERIAL_DIR } from '../../../utils/material.js';
|
|
25
25
|
export async function publishBuildAction(opts = {}) {
|
|
26
26
|
const cwd = opts.path ? path.resolve(opts.path) : process.cwd();
|
|
@@ -51,11 +51,8 @@ export async function publishBuildAction(opts = {}) {
|
|
|
51
51
|
const body = readMaterial(file);
|
|
52
52
|
const filename = file.rel.split('/').pop() ?? file.rel;
|
|
53
53
|
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
54
|
const cardCount = countH1(body);
|
|
58
|
-
materials.push({ name: file.rel, body, title,
|
|
55
|
+
materials.push({ name: file.rel, body, title, cardCount });
|
|
59
56
|
}
|
|
60
57
|
// Mermaid pre-render — scan all bodies, produce svg + css maps.
|
|
61
58
|
const mermaid = mermaidMode === 'prerender'
|
|
@@ -96,8 +93,6 @@ export async function publishBuildAction(opts = {}) {
|
|
|
96
93
|
entries.push({
|
|
97
94
|
file: m.name,
|
|
98
95
|
title: m.title,
|
|
99
|
-
type: m.type,
|
|
100
|
-
tags: m.tags,
|
|
101
96
|
cardCount: m.cardCount,
|
|
102
97
|
});
|
|
103
98
|
}
|
|
@@ -124,8 +119,52 @@ export async function publishBuildAction(opts = {}) {
|
|
|
124
119
|
if (fs.existsSync(srcAssets)) {
|
|
125
120
|
copyDir(srcAssets, path.join(targetMaterial, 'assets'));
|
|
126
121
|
}
|
|
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).
|
|
127
|
+
const cardIndex = [];
|
|
128
|
+
const tagIndex = {};
|
|
129
|
+
const typeIndex = {};
|
|
130
|
+
for (const m of materials) {
|
|
131
|
+
let parsed;
|
|
132
|
+
try {
|
|
133
|
+
parsed = parseMaterial(m.body, m.name);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
for (const card of parsed.cards) {
|
|
139
|
+
cardIndex.push({
|
|
140
|
+
id: card.id,
|
|
141
|
+
file: m.name,
|
|
142
|
+
title: card.title,
|
|
143
|
+
type: card.type,
|
|
144
|
+
tags: card.tags,
|
|
145
|
+
});
|
|
146
|
+
for (const tag of card.tags) {
|
|
147
|
+
if (!tagIndex[tag])
|
|
148
|
+
tagIndex[tag] = [];
|
|
149
|
+
if (!tagIndex[tag].includes(m.name))
|
|
150
|
+
tagIndex[tag].push(m.name);
|
|
151
|
+
}
|
|
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);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
127
161
|
// Manifest: lists materials + optional encryption envelope + mermaid mode.
|
|
128
|
-
const manifest = {
|
|
162
|
+
const manifest = {
|
|
163
|
+
materials: entries,
|
|
164
|
+
cards: cardIndex,
|
|
165
|
+
tagIndex,
|
|
166
|
+
typeIndex,
|
|
167
|
+
};
|
|
129
168
|
if (encryptCtx) {
|
|
130
169
|
manifest.encrypted = {
|
|
131
170
|
algo: 'AES-GCM',
|
|
@@ -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 = {
|
|
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
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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
|
|
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],
|
|
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,
|
|
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
|
|
70
|
-
|
|
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
|
-
|
|
84
|
+
card = buildAnnotatedCard(title, body, filePath, index);
|
|
85
|
+
break;
|
|
74
86
|
case 'qa':
|
|
75
87
|
case 'code':
|
|
76
|
-
|
|
88
|
+
card = buildQACard(title, body, filePath, index);
|
|
89
|
+
break;
|
|
77
90
|
case 'article':
|
|
78
91
|
case 'snippet':
|
|
79
92
|
default:
|
|
80
|
-
|
|
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
|
/**
|