@qnroa/qtype 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/CHANGELOG.zh.md +82 -0
  3. package/LICENSE +201 -0
  4. package/README.md +113 -0
  5. package/README.zh.md +114 -0
  6. package/dist/cli/bootstrap.js +190 -0
  7. package/dist/cli/commands/check/action.js +56 -0
  8. package/dist/cli/commands/check/i18n.js +26 -0
  9. package/dist/cli/commands/check/index.js +20 -0
  10. package/dist/cli/commands/config/action.js +236 -0
  11. package/dist/cli/commands/config/i18n.js +64 -0
  12. package/dist/cli/commands/config/index.js +72 -0
  13. package/dist/cli/commands/content/index.js +22 -0
  14. package/dist/cli/commands/content/lint/action.js +102 -0
  15. package/dist/cli/commands/content/lint/i18n.js +32 -0
  16. package/dist/cli/commands/content/lint/index.js +19 -0
  17. package/dist/cli/commands/content/search/action.js +64 -0
  18. package/dist/cli/commands/content/search/i18n.js +20 -0
  19. package/dist/cli/commands/content/search/index.js +24 -0
  20. package/dist/cli/commands/content/validate/action.js +145 -0
  21. package/dist/cli/commands/content/validate/i18n.js +32 -0
  22. package/dist/cli/commands/content/validate/index.js +19 -0
  23. package/dist/cli/commands/index.js +19 -0
  24. package/dist/cli/commands/init/index.js +53 -0
  25. package/dist/cli/commands/pack/i18n.js +20 -0
  26. package/dist/cli/commands/pack/index.js +48 -0
  27. package/dist/cli/commands/plugin/i18n.js +28 -0
  28. package/dist/cli/commands/plugin/index.js +41 -0
  29. package/dist/cli/commands/publish/build/action.js +208 -0
  30. package/dist/cli/commands/publish/build/i18n.js +50 -0
  31. package/dist/cli/commands/publish/build/index.js +24 -0
  32. package/dist/cli/commands/publish/build/mermaidPass.js +93 -0
  33. package/dist/cli/commands/publish/build/mermaidPrerender.js +199 -0
  34. package/dist/cli/commands/publish/index.js +21 -0
  35. package/dist/cli/commands/publish/init/action.js +74 -0
  36. package/dist/cli/commands/publish/init/i18n.js +22 -0
  37. package/dist/cli/commands/publish/init/index.js +26 -0
  38. package/dist/cli/commands/publish/password/action.js +158 -0
  39. package/dist/cli/commands/publish/password/i18n.js +44 -0
  40. package/dist/cli/commands/publish/password/index.js +35 -0
  41. package/dist/cli/commands/publish/serve/action.js +117 -0
  42. package/dist/cli/commands/publish/serve/i18n.js +32 -0
  43. package/dist/cli/commands/publish/serve/index.js +37 -0
  44. package/dist/cli/commands/repo/index.js +19 -0
  45. package/dist/cli/commands/repo/new/action.js +98 -0
  46. package/dist/cli/commands/repo/new/i18n.js +46 -0
  47. package/dist/cli/commands/repo/new/index.js +57 -0
  48. package/dist/cli/commands/repo/sync/action.js +202 -0
  49. package/dist/cli/commands/repo/sync/i18n.js +44 -0
  50. package/dist/cli/commands/repo/sync/index.js +47 -0
  51. package/dist/cli/commands/repo/templates.js +87 -0
  52. package/dist/cli/i18n/index.js +141 -0
  53. package/dist/cli/i18n/shared.js +142 -0
  54. package/dist/cli/index.js +79 -0
  55. package/dist/cli/registry/command.js +1 -0
  56. package/dist/cli/registry/commander-i18n.js +76 -0
  57. package/dist/cli/registry/help.js +142 -0
  58. package/dist/cli/registry/mount.js +71 -0
  59. package/dist/cli/utils/output.js +92 -0
  60. package/dist/cli/utils/paths.js +13 -0
  61. package/dist/cli/utils/prompt.js +72 -0
  62. package/dist/cli/utils/repoGuard.js +41 -0
  63. package/dist/core/encrypt.js +113 -0
  64. package/dist/core/index.js +8 -0
  65. package/dist/core/keystroke/asciiMap.js +39 -0
  66. package/dist/core/keystroke/chineseMap.js +32 -0
  67. package/dist/core/keystroke/fullwidthMap.js +41 -0
  68. package/dist/core/keystroke/index.js +22 -0
  69. package/dist/core/keystroke/textNormalize.js +68 -0
  70. package/dist/core/keystroke/types.js +1 -0
  71. package/dist/core/parser/frontmatter.js +58 -0
  72. package/dist/core/parser/index.js +2 -0
  73. package/dist/core/parser/parseMaterial.js +42 -0
  74. package/dist/core/types.js +1 -0
  75. package/dist/core/typing/engine.js +112 -0
  76. package/dist/core/typing/index.js +2 -0
  77. package/dist/core/typing/metrics.js +20 -0
  78. package/dist/store/index.js +44 -0
  79. package/dist/store/kv.js +276 -0
  80. package/dist/store/storage/browser.js +61 -0
  81. package/dist/store/storage/json.js +113 -0
  82. package/dist/store/storage/memory.js +24 -0
  83. package/dist/store/types.js +47 -0
  84. package/dist/store/validators.js +71 -0
  85. package/dist/view/assets/index-DBABH3dG.js +111 -0
  86. package/dist/view/assets/index-RTLZ-zOC.css +1 -0
  87. package/dist/view/index.html +14 -0
  88. package/dist/view/qtype-logo.svg +49 -0
  89. package/dist/view/qtype-mark.svg +21 -0
  90. package/docs/README.md +23 -0
  91. package/docs/README.zh.md +20 -0
  92. package/docs/en/authoring.md +138 -0
  93. package/docs/en/cli-reference.md +171 -0
  94. package/docs/zh/authoring.md +131 -0
  95. package/docs/zh/cli-reference.md +162 -0
  96. package/package.json +85 -0
  97. package/templates/en/QTYPE.md +618 -0
  98. package/templates/en/README.md +64 -0
  99. package/templates/en/TODO.md +7 -0
  100. package/templates/en/index.md +7 -0
  101. package/templates/en/log.md +3 -0
  102. package/templates/en/material/.gitkeep +0 -0
  103. package/templates/en/material/example.md +47 -0
  104. package/templates/zh/QTYPE.md +618 -0
  105. package/templates/zh/README.md +64 -0
  106. package/templates/zh/TODO.md +7 -0
  107. package/templates/zh/index.md +7 -0
  108. package/templates/zh/log.md +3 -0
  109. package/templates/zh/material/.gitkeep +0 -0
  110. package/templates/zh/material/example.md +47 -0
@@ -0,0 +1,22 @@
1
+ import { t } from '../../i18n/index.js';
2
+ import { contentLintCmd } from './lint/index.js';
3
+ import { contentValidateCmd } from './validate/index.js';
4
+ import { contentSearchCmd } from './search/index.js';
5
+ export const contentCmd = {
6
+ name: 'content',
7
+ summary: () => t('content.summary'),
8
+ describe: () => t('content.description'),
9
+ messages: {
10
+ 'zh-CN': {
11
+ 'content.summary': '内容检查',
12
+ 'content.description': '仓库内容检查与搜索(material/*.md)',
13
+ },
14
+ 'en-US': {
15
+ 'content.summary': 'Content tools',
16
+ 'content.description': 'Repo content checks and search over material/*.md',
17
+ },
18
+ },
19
+ subcommands: [contentLintCmd, contentValidateCmd, contentSearchCmd],
20
+ };
21
+ export { contentLintAction } from './lint/action.js';
22
+ export { contentValidateAction } from './validate/action.js';
@@ -0,0 +1,102 @@
1
+ /**
2
+ * `qtype content lint` action — format-lint material/*.md.
3
+ *
4
+ * Checks (per-file / per-card):
5
+ * - File-level title: `title: <str>` frontmatter or a top `# Heading` (warning)
6
+ * - Each file parses to at least one Q/A card (error)
7
+ * - No card contains characters unmapped in the keystroke plan (error)
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { info, success, error, warn } from '../../../utils/output.js';
12
+ import { ensureQtypeRepo } from '../../../utils/repoGuard.js';
13
+ import { t } from '../../../i18n/index.js';
14
+ import { parseMaterial } from '../../../../core/parser/parseMaterial.js';
15
+ const TITLE_RE = /^title:\s*(.+)$/m;
16
+ const HEADING_RE = /^#\s+.+$/m;
17
+ export async function contentLintAction(opts = {}) {
18
+ const root = ensureQtypeRepo(opts.path ?? process.cwd());
19
+ const materialDir = path.join(root, 'material');
20
+ if (!opts.json)
21
+ info(t('content.lint.msg.scanning', { path: root }));
22
+ const issues = [];
23
+ let cardTotal = 0;
24
+ let materialCount = 0;
25
+ if (fs.existsSync(materialDir)) {
26
+ for (const name of fs.readdirSync(materialDir).sort()) {
27
+ if (!name.endsWith('.md'))
28
+ continue;
29
+ materialCount++;
30
+ const rel = path.posix.join('material', name);
31
+ const src = fs.readFileSync(path.join(materialDir, name), 'utf8');
32
+ // File-level title (soft): warn if neither frontmatter title nor
33
+ // a top-level heading is present — the browser view has nothing to
34
+ // show as the material's name.
35
+ const head = src.slice(0, 2048);
36
+ if (!TITLE_RE.test(head) && !HEADING_RE.test(head)) {
37
+ issues.push({
38
+ code: 'material.emptyTitle',
39
+ path: rel,
40
+ message: t('content.lint.issue.emptyTitle', { path: rel }),
41
+ });
42
+ }
43
+ const material = parseMaterial(src, rel);
44
+ cardTotal += material.cards.length;
45
+ if (material.cards.length === 0) {
46
+ issues.push({
47
+ code: 'material.noCards',
48
+ path: rel,
49
+ message: t('content.lint.issue.noCards', { path: rel }),
50
+ });
51
+ continue;
52
+ }
53
+ material.cards.forEach((c, i) => {
54
+ if (c.unknownChars.length > 0) {
55
+ const chars = [...new Set(c.unknownChars)];
56
+ issues.push({
57
+ code: 'material.unknownChars',
58
+ path: rel,
59
+ card: i + 1,
60
+ chars,
61
+ message: t('content.lint.issue.unknownChars', {
62
+ path: rel,
63
+ card: i + 1,
64
+ chars: chars.join(' '),
65
+ }),
66
+ });
67
+ }
68
+ });
69
+ }
70
+ }
71
+ // `emptyTitle` is treated as a warning (informational). Anything else is an error.
72
+ const errorIssues = issues.filter((i) => i.code !== 'material.emptyTitle');
73
+ const result = {
74
+ ok: errorIssues.length === 0,
75
+ stats: { materials: materialCount, cards: cardTotal },
76
+ issues,
77
+ };
78
+ if (opts.json) {
79
+ console.log(JSON.stringify(result, null, 2));
80
+ }
81
+ else if (issues.length === 0) {
82
+ success(t('content.lint.msg.ok'));
83
+ }
84
+ else {
85
+ if (errorIssues.length > 0) {
86
+ error(t('content.lint.msg.issues', { count: errorIssues.length }));
87
+ for (const i of errorIssues.slice(0, 30)) {
88
+ console.error(` - ${i.message}`);
89
+ }
90
+ if (errorIssues.length > 30) {
91
+ console.error(` ${t('content.lint.msg.andMore', { count: errorIssues.length - 30 })}`);
92
+ }
93
+ }
94
+ const warnIssues = issues.filter((i) => i.code === 'material.emptyTitle');
95
+ for (const w of warnIssues)
96
+ warn(w.message);
97
+ if (result.ok)
98
+ success(t('content.lint.msg.ok'));
99
+ }
100
+ if (!result.ok)
101
+ process.exitCode = 1;
102
+ }
@@ -0,0 +1,32 @@
1
+ export const messages = {
2
+ 'zh-CN': {
3
+ 'content.lint.summary': '资料格式检查',
4
+ 'content.lint.description': '检查 material/*.md 的 frontmatter 与 Q/A 卡片结构是否合规',
5
+ 'content.lint.arg.path': '仓库路径(默认当前目录)',
6
+ 'content.lint.opt.json': '以 JSON 格式输出(机器可解析)',
7
+ 'content.lint.example.basic': '扫描当前仓库',
8
+ 'content.lint.example.json': 'CI 模式,JSON 输出',
9
+ 'content.lint.msg.scanning': '扫描 {path}',
10
+ 'content.lint.msg.ok': '所有 material/*.md 通过格式检查',
11
+ 'content.lint.msg.issues': '发现 {count} 个问题:',
12
+ 'content.lint.msg.andMore': '... 另有 {count} 条',
13
+ 'content.lint.issue.noCards': '{path}: 未识别到任何 Q/A 卡片',
14
+ 'content.lint.issue.unknownChars': '{path}#{card}: 含无法练习的字符 → {chars}',
15
+ 'content.lint.issue.emptyTitle': '{path}: frontmatter 缺少 title',
16
+ },
17
+ 'en-US': {
18
+ 'content.lint.summary': 'Material format lint',
19
+ 'content.lint.description': 'Check frontmatter and Q/A card structure of material/*.md',
20
+ 'content.lint.arg.path': 'Repo path (default: current directory)',
21
+ 'content.lint.opt.json': 'Output as JSON (machine-readable)',
22
+ 'content.lint.example.basic': 'Scan current repo',
23
+ 'content.lint.example.json': 'CI mode, JSON output',
24
+ 'content.lint.msg.scanning': 'Scanning {path}',
25
+ 'content.lint.msg.ok': 'All material/*.md passed format lint',
26
+ 'content.lint.msg.issues': 'Found {count} issue(s):',
27
+ 'content.lint.msg.andMore': '... and {count} more',
28
+ 'content.lint.issue.noCards': '{path}: no Q/A cards parsed',
29
+ 'content.lint.issue.unknownChars': '{path}#{card}: non-typable chars → {chars}',
30
+ 'content.lint.issue.emptyTitle': '{path}: frontmatter missing title',
31
+ },
32
+ };
@@ -0,0 +1,19 @@
1
+ import { t } from '../../../i18n/index.js';
2
+ import { messages } from './i18n.js';
3
+ import { contentLintAction } from './action.js';
4
+ export const contentLintCmd = {
5
+ name: 'lint',
6
+ messages,
7
+ summary: () => t('content.lint.summary'),
8
+ describe: () => t('content.lint.description'),
9
+ args: [{ flag: '[path]', describe: () => t('content.lint.arg.path') }],
10
+ options: [{ flags: '--json', describe: () => t('content.lint.opt.json') }],
11
+ examples: () => [
12
+ { command: 'qtype content lint', describe: () => t('content.lint.example.basic') },
13
+ { command: 'qtype content lint --json', describe: () => t('content.lint.example.json') },
14
+ ],
15
+ seeAlso: ['content validate'],
16
+ action: async (pathArg, opts) => {
17
+ await contentLintAction({ path: pathArg, json: opts.json });
18
+ },
19
+ };
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `qtype content search <query>` action — grep-style search over material/*.md.
3
+ *
4
+ * Simple case-insensitive substring match on card questions + answers. Not
5
+ * ranked — just returns first-N hits so a user can pipe grep-style output
6
+ * into vim/less. Deliberately dumb: fits the "types of cards, not a knowledge
7
+ * graph" scope of qtype.
8
+ */
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import { info, dim } from '../../../utils/output.js';
12
+ import { ensureQtypeRepo } from '../../../utils/repoGuard.js';
13
+ import { t } from '../../../i18n/index.js';
14
+ import { parseMaterial } from '../../../../core/parser/parseMaterial.js';
15
+ export async function contentSearchAction(query, opts = {}) {
16
+ const root = ensureQtypeRepo(opts.path ?? process.cwd());
17
+ const materialDir = path.join(root, 'material');
18
+ const limit = opts.limit ?? 20;
19
+ const q = query.toLowerCase();
20
+ const hits = [];
21
+ if (fs.existsSync(materialDir)) {
22
+ outer: for (const name of fs.readdirSync(materialDir).sort()) {
23
+ if (!name.endsWith('.md'))
24
+ continue;
25
+ const rel = path.posix.join('material', name);
26
+ const src = fs.readFileSync(path.join(materialDir, name), 'utf8');
27
+ const material = parseMaterial(src, rel);
28
+ material.cards.forEach((card, i) => {
29
+ const qLc = card.question.toLowerCase();
30
+ const aLc = card.answer.toLowerCase();
31
+ if (!qLc.includes(q) && !aLc.includes(q))
32
+ return;
33
+ const snippetSrc = qLc.includes(q) ? card.question : card.answer;
34
+ const idx = snippetSrc.toLowerCase().indexOf(q);
35
+ const start = Math.max(0, idx - 20);
36
+ const end = Math.min(snippetSrc.length, idx + q.length + 40);
37
+ const snippet = (start > 0 ? '…' : '') +
38
+ snippetSrc.slice(start, end).replace(/\n+/g, ' ') +
39
+ (end < snippetSrc.length ? '…' : '');
40
+ hits.push({
41
+ file: rel,
42
+ card: i + 1,
43
+ question: card.question,
44
+ answer: card.answer,
45
+ snippet,
46
+ });
47
+ if (hits.length >= limit)
48
+ return;
49
+ });
50
+ if (hits.length >= limit)
51
+ break outer;
52
+ }
53
+ }
54
+ if (hits.length === 0) {
55
+ info(t('content.search.msg.noResults'));
56
+ return;
57
+ }
58
+ console.log(t('content.search.msg.results', { count: hits.length }));
59
+ console.log('');
60
+ for (const h of hits) {
61
+ console.log(` ${h.file}#${h.card}`);
62
+ console.log(` ${h.question} ${dim(h.snippet)}`);
63
+ }
64
+ }
@@ -0,0 +1,20 @@
1
+ export const messages = {
2
+ 'zh-CN': {
3
+ 'content.search.summary': '全文搜索',
4
+ 'content.search.description': '在 material/*.md 中搜索关键词(匹配问题与答案)',
5
+ 'content.search.arg.query': '搜索关键词',
6
+ 'content.search.arg.path': '仓库路径(默认当前目录)',
7
+ 'content.search.opt.limit': '最多返回结果数(默认 20)',
8
+ 'content.search.msg.noResults': '没有找到匹配的结果',
9
+ 'content.search.msg.results': '找到 {count} 个结果',
10
+ },
11
+ 'en-US': {
12
+ 'content.search.summary': 'Full-text search',
13
+ 'content.search.description': 'Search material/*.md for a query (matches questions and answers)',
14
+ 'content.search.arg.query': 'Search query',
15
+ 'content.search.arg.path': 'Repo path (default: current directory)',
16
+ 'content.search.opt.limit': 'Max results (default: 20)',
17
+ 'content.search.msg.noResults': 'No results found',
18
+ 'content.search.msg.results': 'Found {count} results',
19
+ },
20
+ };
@@ -0,0 +1,24 @@
1
+ import { t } from '../../../i18n/index.js';
2
+ import { messages } from './i18n.js';
3
+ import { contentSearchAction } from './action.js';
4
+ export const contentSearchCmd = {
5
+ name: 'search',
6
+ messages,
7
+ summary: () => t('content.search.summary'),
8
+ describe: () => t('content.search.description'),
9
+ args: [
10
+ { flag: '<query>', describe: () => t('content.search.arg.query') },
11
+ { flag: '[path]', describe: () => t('content.search.arg.path') },
12
+ ],
13
+ options: [
14
+ {
15
+ flags: '--limit <n>',
16
+ describe: () => t('content.search.opt.limit'),
17
+ parser: (v) => parseInt(v, 10),
18
+ defaultValue: 20,
19
+ },
20
+ ],
21
+ action: async (query, pathArg, opts) => {
22
+ await contentSearchAction(query, { path: pathArg, limit: opts.limit });
23
+ },
24
+ };
@@ -0,0 +1,145 @@
1
+ /**
2
+ * `qtype content validate` action — cross-file validation of material/*.md.
3
+ *
4
+ * Cross-file checks (things `content lint` misses because it looks at each
5
+ * file in isolation):
6
+ * - Duplicate material filenames (across case-insensitive collisions)
7
+ * - Duplicate card questions (identical Q text in two places → likely
8
+ * copy-paste bug)
9
+ * - Any unknown chars (also caught by lint, re-reported so validate is
10
+ * self-sufficient in CI)
11
+ * - Tag consistency: warns when the same tag is used with different
12
+ * casing (`react` vs `React`)
13
+ */
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { info, success, error, warn } from '../../../utils/output.js';
17
+ import { ensureQtypeRepo } from '../../../utils/repoGuard.js';
18
+ import { t } from '../../../i18n/index.js';
19
+ import { parseMaterial } from '../../../../core/parser/parseMaterial.js';
20
+ export async function contentValidateAction(opts = {}) {
21
+ const root = ensureQtypeRepo(opts.path ?? process.cwd());
22
+ const materialDir = path.join(root, 'material');
23
+ if (!opts.json)
24
+ info(t('content.validate.msg.validating', { path: root }));
25
+ const errors = [];
26
+ const warnings = [];
27
+ let cardTotal = 0;
28
+ const files = [];
29
+ // ── Filename uniqueness (case-insensitive) ──────────────
30
+ if (fs.existsSync(materialDir)) {
31
+ const seen = new Map();
32
+ for (const name of fs.readdirSync(materialDir).sort()) {
33
+ if (!name.endsWith('.md'))
34
+ continue;
35
+ files.push(name);
36
+ const key = name.toLowerCase();
37
+ const prior = seen.get(key);
38
+ if (prior && prior !== name) {
39
+ errors.push({
40
+ code: 'duplicate.file',
41
+ path: name,
42
+ message: t('content.validate.err.duplicateFile', { file: name }),
43
+ extra: { collidesWith: prior },
44
+ });
45
+ }
46
+ else {
47
+ seen.set(key, name);
48
+ }
49
+ }
50
+ }
51
+ // ── Parse every material, then run cross-file checks ────
52
+ const questionIndex = new Map(); // normalized Q → "path#card"
53
+ const tagVariants = new Map(); // lower → { original casings }
54
+ for (const name of files) {
55
+ const rel = path.posix.join('material', name);
56
+ const src = fs.readFileSync(path.join(materialDir, name), 'utf8');
57
+ const material = parseMaterial(src, rel);
58
+ cardTotal += material.cards.length;
59
+ material.cards.forEach((card, i) => {
60
+ const cardIdx = i + 1;
61
+ // Unknown chars — also caught by lint; re-report so validate stands alone.
62
+ if (card.unknownChars.length > 0) {
63
+ const chars = [...new Set(card.unknownChars)];
64
+ errors.push({
65
+ code: 'material.unknownChars',
66
+ path: rel,
67
+ message: t('content.validate.err.unknownChars', {
68
+ path: rel,
69
+ card: cardIdx,
70
+ chars: chars.join(' '),
71
+ }),
72
+ extra: { chars },
73
+ });
74
+ }
75
+ // Duplicate card questions across the corpus.
76
+ const key = card.question.trim().toLowerCase();
77
+ if (key.length > 0) {
78
+ const prior = questionIndex.get(key);
79
+ const here = `${rel}#${cardIdx}`;
80
+ if (prior && prior !== here) {
81
+ errors.push({
82
+ code: 'duplicate.card',
83
+ path: rel,
84
+ message: t('content.validate.err.duplicateCard', {
85
+ path: rel,
86
+ card: cardIdx,
87
+ other: prior,
88
+ }),
89
+ extra: { collidesWith: prior },
90
+ });
91
+ }
92
+ else {
93
+ questionIndex.set(key, here);
94
+ }
95
+ }
96
+ // Tag casing consistency.
97
+ for (const tag of card.meta.tags) {
98
+ const lower = tag.toLowerCase();
99
+ const set = tagVariants.get(lower) ?? new Set();
100
+ set.add(tag);
101
+ tagVariants.set(lower, set);
102
+ }
103
+ });
104
+ }
105
+ for (const [lower, variants] of tagVariants) {
106
+ if (variants.size > 1) {
107
+ warnings.push({
108
+ code: 'tag.caseInconsistent',
109
+ message: t('content.validate.warn.tagCase', {
110
+ tag: lower,
111
+ variants: [...variants].join(', '),
112
+ }),
113
+ extra: { variants: [...variants] },
114
+ });
115
+ }
116
+ }
117
+ const result = {
118
+ ok: errors.length === 0,
119
+ stats: { materials: files.length, cards: cardTotal, warnings: warnings.length },
120
+ errors,
121
+ warnings,
122
+ };
123
+ if (opts.json) {
124
+ console.log(JSON.stringify(result, null, 2));
125
+ }
126
+ else {
127
+ for (const e of errors)
128
+ error(e.message);
129
+ for (const w of warnings)
130
+ warn(w.message);
131
+ console.log('');
132
+ if (result.ok) {
133
+ success(t('content.validate.msg.ok', {
134
+ materials: result.stats.materials,
135
+ cards: result.stats.cards,
136
+ warnings: result.stats.warnings,
137
+ }));
138
+ }
139
+ else {
140
+ error(t('content.validate.msg.errorCount', { count: errors.length }));
141
+ }
142
+ }
143
+ if (!result.ok)
144
+ process.exitCode = 1;
145
+ }
@@ -0,0 +1,32 @@
1
+ export const messages = {
2
+ 'zh-CN': {
3
+ 'content.validate.summary': '全量校验',
4
+ 'content.validate.description': '跨文件校验:文件名唯一 / 标签一致 / 无重复卡片 / 无未知字符',
5
+ 'content.validate.arg.path': '仓库路径(默认当前目录)',
6
+ 'content.validate.opt.json': '以 JSON 格式输出(机器可解析)',
7
+ 'content.validate.example.basic': '校验当前仓库',
8
+ 'content.validate.example.json': 'CI 模式,JSON 输出',
9
+ 'content.validate.msg.validating': '校验 {path}',
10
+ 'content.validate.msg.ok': '校验通过 (materials={materials} cards={cards} warnings={warnings})',
11
+ 'content.validate.msg.errorCount': '共 {count} 个错误',
12
+ 'content.validate.err.duplicateFile': '重名 material 文件:{file}',
13
+ 'content.validate.err.duplicateCard': '{path}#{card} 与 {other} 的问题完全相同',
14
+ 'content.validate.err.unknownChars': '{path}#{card}: 含无法练习的字符 → {chars}',
15
+ 'content.validate.warn.tagCase': "标签 '{tag}' 大小写不一致:{variants}",
16
+ },
17
+ 'en-US': {
18
+ 'content.validate.summary': 'Full validation',
19
+ 'content.validate.description': 'Cross-file checks: unique filenames, tag consistency, no duplicate cards, no unknown chars',
20
+ 'content.validate.arg.path': 'Repo path (default: current directory)',
21
+ 'content.validate.opt.json': 'Output as JSON (machine-readable)',
22
+ 'content.validate.example.basic': 'Validate current repo',
23
+ 'content.validate.example.json': 'CI mode, JSON output',
24
+ 'content.validate.msg.validating': 'Validating {path}',
25
+ 'content.validate.msg.ok': 'Validation passed (materials={materials} cards={cards} warnings={warnings})',
26
+ 'content.validate.msg.errorCount': '{count} error(s) total',
27
+ 'content.validate.err.duplicateFile': 'Duplicate material filename: {file}',
28
+ 'content.validate.err.duplicateCard': "{path}#{card} shares the question of {other}",
29
+ 'content.validate.err.unknownChars': '{path}#{card}: non-typable chars → {chars}',
30
+ 'content.validate.warn.tagCase': "Tag '{tag}' has inconsistent casing: {variants}",
31
+ },
32
+ };
@@ -0,0 +1,19 @@
1
+ import { t } from '../../../i18n/index.js';
2
+ import { messages } from './i18n.js';
3
+ import { contentValidateAction } from './action.js';
4
+ export const contentValidateCmd = {
5
+ name: 'validate',
6
+ messages,
7
+ summary: () => t('content.validate.summary'),
8
+ describe: () => t('content.validate.description'),
9
+ args: [{ flag: '[path]', describe: () => t('content.validate.arg.path') }],
10
+ options: [{ flags: '--json', describe: () => t('content.validate.opt.json') }],
11
+ examples: () => [
12
+ { command: 'qtype content validate', describe: () => t('content.validate.example.basic') },
13
+ { command: 'qtype content validate --json', describe: () => t('content.validate.example.json') },
14
+ ],
15
+ seeAlso: ['content lint', 'publish build'],
16
+ action: async (pathArg, opts) => {
17
+ await contentValidateAction({ path: pathArg, json: opts.json });
18
+ },
19
+ };
@@ -0,0 +1,19 @@
1
+ import { initCmd } from './init/index.js';
2
+ import { repoCmd } from './repo/index.js';
3
+ import { publishCmd } from './publish/index.js';
4
+ import { checkCmd } from './check/index.js';
5
+ import { contentCmd } from './content/index.js';
6
+ import { configCmd } from './config/index.js';
7
+ import { pluginCmd } from './plugin/index.js';
8
+ import { packCmd } from './pack/index.js';
9
+ export const allCommands = [
10
+ initCmd,
11
+ repoCmd,
12
+ publishCmd,
13
+ checkCmd,
14
+ contentCmd,
15
+ packCmd,
16
+ pluginCmd,
17
+ configCmd,
18
+ ];
19
+ export { initCmd, repoCmd, publishCmd, checkCmd, contentCmd, packCmd, pluginCmd, configCmd, };
@@ -0,0 +1,53 @@
1
+ /**
2
+ * `qtype init` — top-level alias for `qtype repo sync`.
3
+ *
4
+ * Lives at the top level (not under `repo/`) because it's the standard
5
+ * "drop QTYPE into this directory" entry point new users look for —
6
+ * mirroring `git init`, `npm init`, etc. The actual scaffolding logic
7
+ * lives in repoSyncAction; this is just a shorter, more discoverable
8
+ * front door.
9
+ */
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import { t, getLocale } from '../../i18n/index.js';
13
+ import { userKv } from '../../bootstrap.js';
14
+ import { warn } from '../../utils/output.js';
15
+ import { repoSyncAction } from '../repo/sync/action.js';
16
+ import { messages as syncMessages } from '../repo/sync/i18n.js';
17
+ function warnIfHome(dir) {
18
+ if (path.resolve(dir) === os.homedir())
19
+ warn(t('msg.home.warn'));
20
+ }
21
+ export const initCmd = {
22
+ name: 'init',
23
+ // Re-use the sync command's i18n bundle so messages stay in lockstep.
24
+ messages: syncMessages,
25
+ summary: () => t('repo.sync.summary'),
26
+ describe: () => t('repo.sync.description'),
27
+ args: [{ flag: '[path]', describe: () => t('repo.sync.arg.path') }],
28
+ options: [
29
+ {
30
+ flags: '--lang <lang>',
31
+ describe: () => t('opt.lang'),
32
+ choices: ['zh-CN', 'en-US', 'zh', 'en'],
33
+ },
34
+ { flags: '--force', describe: () => t('repo.sync.opt.force') },
35
+ { flags: '--dry-run', describe: () => t('opt.dryRun') },
36
+ ],
37
+ examples: () => [
38
+ { command: 'qtype init', describe: () => t('repo.sync.example.dryRun') },
39
+ { command: 'qtype init ./my-material --force', describe: () => t('repo.sync.example.force') },
40
+ ],
41
+ seeAlso: ['repo new', 'repo sync', 'publish init'],
42
+ action: async (pathArg, opts) => {
43
+ warnIfHome(pathArg ?? process.cwd());
44
+ const userDefault = (await userKv.get('repo.lang'));
45
+ const lang = opts.lang ?? userDefault ?? getLocale();
46
+ await repoSyncAction({
47
+ path: pathArg,
48
+ lang,
49
+ force: opts.force === true,
50
+ dryRun: opts.dryRun === true,
51
+ });
52
+ },
53
+ };
@@ -0,0 +1,20 @@
1
+ export const messages = {
2
+ 'zh-CN': {
3
+ 'pack.summary': '打包资料仓库',
4
+ 'pack.description': '把当前目录的资料打成 tar.gz 便于分享(忽略 dist/、node_modules/、.git/)',
5
+ 'pack.opt.out': '输出文件(默认 <目录名>.tar.gz)',
6
+ 'pack.err.noMaterial': '当前目录不像 qtype 资料(未找到 material/)。先跑 `qtype init`。',
7
+ 'pack.err.tar': '打包失败:{msg}',
8
+ 'pack.msg.packing': '打包中 → {path}',
9
+ 'pack.msg.done': '已打包 → {path}',
10
+ },
11
+ 'en-US': {
12
+ 'pack.summary': 'Bundle a material repo',
13
+ 'pack.description': 'Pack the current material repo into a tar.gz for sharing (excludes dist/, node_modules/, .git/)',
14
+ 'pack.opt.out': 'Output file (default: <dirname>.tar.gz)',
15
+ 'pack.err.noMaterial': 'Current directory is not a qtype repo (no material/). Run `qtype init` first.',
16
+ 'pack.err.tar': 'Pack failed: {msg}',
17
+ 'pack.msg.packing': 'Packing → {path}',
18
+ 'pack.msg.done': 'Packed → {path}',
19
+ },
20
+ };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `qtype pack` — bundle the current material repo into a tar.gz for sharing.
3
+ *
4
+ * Simple wrapper over `tar -czf`. Ignores dist/, node_modules/, .git/ and any
5
+ * pre-existing tarballs so re-running is idempotent.
6
+ */
7
+ import { spawnSync } from 'node:child_process';
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+ import { t } from '../../i18n/index.js';
11
+ import { error, info, success } from '../../utils/output.js';
12
+ import { messages } from './i18n.js';
13
+ const IGNORE = new Set(['dist', 'node_modules', '.git']);
14
+ export const packCmd = {
15
+ name: 'pack',
16
+ messages,
17
+ summary: () => t('pack.summary'),
18
+ describe: () => t('pack.description'),
19
+ options: [
20
+ { flags: '-o, --out <file>', describe: () => t('pack.opt.out') },
21
+ ],
22
+ action: async (opts) => {
23
+ const cwd = process.cwd();
24
+ if (!fs.existsSync(path.join(cwd, 'material'))) {
25
+ error(t('pack.err.noMaterial'));
26
+ process.exitCode = 1;
27
+ return;
28
+ }
29
+ const name = path.basename(cwd);
30
+ const outFile = path.resolve(cwd, opts.out ?? `${name}.tar.gz`);
31
+ const entries = fs
32
+ .readdirSync(cwd)
33
+ .filter((n) => !IGNORE.has(n) && !n.endsWith('.tar.gz'));
34
+ info(t('pack.msg.packing', { path: outFile }));
35
+ const res = spawnSync('tar', ['-czf', outFile, ...entries], {
36
+ cwd,
37
+ stdio: 'inherit',
38
+ });
39
+ if (res.status !== 0) {
40
+ error(t('pack.err.tar', {
41
+ msg: res.error?.message ?? `exit ${res.status}`,
42
+ }));
43
+ process.exitCode = 1;
44
+ return;
45
+ }
46
+ success(t('pack.msg.done', { path: outFile }));
47
+ },
48
+ };
@@ -0,0 +1,28 @@
1
+ export const messages = {
2
+ 'zh-CN': {
3
+ 'plugin.summary': '插件管理',
4
+ 'plugin.description': '管理 qtype 插件:列表 / 启用 / 禁用(骨架,暂未接入插件系统)',
5
+ 'plugin.list.summary': '列出插件',
6
+ 'plugin.list.description': '列出所有已注册的插件及其状态',
7
+ 'plugin.enable.summary': '启用插件',
8
+ 'plugin.enable.description': '启用一个已注册的插件',
9
+ 'plugin.disable.summary': '禁用插件',
10
+ 'plugin.disable.description': '禁用一个已启用的插件',
11
+ 'plugin.msg.noPlugins': '没有已注册的插件',
12
+ 'plugin.msg.notImplemented': '插件系统尚未实现(qtype {phase})',
13
+ 'plugin.msg.notFound': '插件不存在:{id}',
14
+ },
15
+ 'en-US': {
16
+ 'plugin.summary': 'Plugin management',
17
+ 'plugin.description': 'Manage qtype plugins: list / enable / disable (skeleton, plugin system not yet wired)',
18
+ 'plugin.list.summary': 'List plugins',
19
+ 'plugin.list.description': 'List all registered plugins and their status',
20
+ 'plugin.enable.summary': 'Enable plugin',
21
+ 'plugin.enable.description': 'Enable a registered plugin',
22
+ 'plugin.disable.summary': 'Disable plugin',
23
+ 'plugin.disable.description': 'Disable an enabled plugin',
24
+ 'plugin.msg.noPlugins': 'No plugins registered',
25
+ 'plugin.msg.notImplemented': 'Plugin system not yet implemented (qtype {phase})',
26
+ 'plugin.msg.notFound': 'Plugin not found: {id}',
27
+ },
28
+ };