@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,41 @@
1
+ import { t } from '../../i18n/index.js';
2
+ import { info } from '../../utils/output.js';
3
+ import { messages } from './i18n.js';
4
+ const listCmd = {
5
+ name: 'list',
6
+ aliases: ['ls'],
7
+ summary: () => t('plugin.list.summary'),
8
+ describe: () => t('plugin.list.description'),
9
+ action: async () => {
10
+ info(t('plugin.msg.noPlugins'));
11
+ },
12
+ };
13
+ const enableCmd = {
14
+ name: 'enable',
15
+ summary: () => t('plugin.enable.summary'),
16
+ describe: () => t('plugin.enable.description'),
17
+ args: [{ flag: '<id>', describe: () => 'plugin id' }],
18
+ action: async (id) => {
19
+ info(t('plugin.msg.notImplemented', { phase: 'v0' }));
20
+ info(t('plugin.msg.notFound', { id }));
21
+ process.exitCode = 1;
22
+ },
23
+ };
24
+ const disableCmd = {
25
+ name: 'disable',
26
+ summary: () => t('plugin.disable.summary'),
27
+ describe: () => t('plugin.disable.description'),
28
+ args: [{ flag: '<id>', describe: () => 'plugin id' }],
29
+ action: async (id) => {
30
+ info(t('plugin.msg.notImplemented', { phase: 'v0' }));
31
+ info(t('plugin.msg.notFound', { id }));
32
+ process.exitCode = 1;
33
+ },
34
+ };
35
+ export const pluginCmd = {
36
+ name: 'plugin',
37
+ messages,
38
+ summary: () => t('plugin.summary'),
39
+ describe: () => t('plugin.description'),
40
+ subcommands: [listCmd, enableCmd, disableCmd],
41
+ };
@@ -0,0 +1,208 @@
1
+ /**
2
+ * `qtype publish build` action — assemble the deployable static site.
3
+ *
4
+ * Flow:
5
+ * 1. Verify repo has material/ and qtype has a prebuilt view bundle.
6
+ * 2. Resolve optional password-encryption context (see `publish/password/`).
7
+ * 3. Wipe & recreate the output directory.
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.
12
+ */
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { error, info, success } from '../../../utils/output.js';
16
+ import { resolveBundle } from '../../../utils/paths.js';
17
+ import { promptPassword } from '../../../utils/prompt.js';
18
+ import { t } from '../../../i18n/index.js';
19
+ import { createRepoKv } from '../../../bootstrap.js';
20
+ import { deriveAesKey, encryptString, DEFAULT_ARGON2_PARAMS, } from '../../../../core/encrypt.js';
21
+ import { verifyPassword } from '../password/action.js';
22
+ import { prerenderMermaid, emptyMermaidPass } from './mermaidPass.js';
23
+ export async function publishBuildAction(opts = {}) {
24
+ const cwd = opts.path ? path.resolve(opts.path) : process.cwd();
25
+ const outDir = path.resolve(cwd, opts.out ?? 'dist');
26
+ const viewBundle = resolveBundle('dist/view');
27
+ const materialDir = path.join(cwd, 'material');
28
+ info(t('publish.build.msg.parsing', { path: cwd }));
29
+ if (!fs.existsSync(viewBundle) ||
30
+ !fs.existsSync(path.join(viewBundle, 'index.html'))) {
31
+ error(t('publish.build.err.noView', { path: viewBundle }));
32
+ process.exitCode = 1;
33
+ return;
34
+ }
35
+ if (!fs.existsSync(materialDir)) {
36
+ error(t('publish.build.err.noMaterial'));
37
+ process.exitCode = 1;
38
+ return;
39
+ }
40
+ const encryptCtx = await resolveEncryptCtx(cwd);
41
+ const mermaidMode = await resolveMermaidMode(cwd);
42
+ const repoLang = await resolveRepoLang(cwd);
43
+ const materials = [];
44
+ for (const name of fs.readdirSync(materialDir)) {
45
+ if (!name.endsWith('.md'))
46
+ continue;
47
+ const body = fs.readFileSync(path.join(materialDir, name), 'utf8');
48
+ const title = readTitle(body) ?? name.replace(/\.md$/, '');
49
+ materials.push({ name, body, title });
50
+ }
51
+ // Mermaid pre-render — scan all bodies, produce svg + css maps.
52
+ const mermaid = mermaidMode === 'prerender'
53
+ ? await prerenderMermaid(materials.map((m) => m.body))
54
+ : emptyMermaidPass();
55
+ if (mermaidMode !== 'prerender') {
56
+ info(t('publish.build.msg.mermaidSkip', { mode: mermaidMode }));
57
+ }
58
+ info(t('publish.build.msg.building', { path: outDir }));
59
+ fs.rmSync(outDir, { recursive: true, force: true });
60
+ fs.mkdirSync(outDir, { recursive: true });
61
+ copyDir(viewBundle, outDir);
62
+ // Defensive: clear any stale material/ that might have snuck into the
63
+ // view bundle. Since dev-seed content lives at repo root's examples/
64
+ // (served only by the vite dev middleware, not the bundle), this is a
65
+ // no-op today — kept in case a future build tool ships anything here.
66
+ const targetMaterial = path.join(outDir, 'material');
67
+ fs.rmSync(targetMaterial, { recursive: true, force: true });
68
+ fs.mkdirSync(targetMaterial, { recursive: true });
69
+ const entries = [];
70
+ for (const m of materials) {
71
+ if (encryptCtx) {
72
+ const blob = await encryptString(encryptCtx.key, m.body);
73
+ fs.writeFileSync(path.join(targetMaterial, `${m.name}.enc.json`), JSON.stringify(blob));
74
+ }
75
+ else {
76
+ fs.writeFileSync(path.join(targetMaterial, m.name), m.body);
77
+ }
78
+ entries.push({ file: m.name, title: m.title });
79
+ }
80
+ // Mermaid SVG map + CSS: the view fetches these alongside index.json and
81
+ // looks up each fence by its trimmed source when rendering material bodies.
82
+ if (mermaid.count > 0) {
83
+ const mermaidJson = mermaidMode === 'prerender' ? mermaid.svgs : {};
84
+ if (encryptCtx) {
85
+ const blob = await encryptString(encryptCtx.key, JSON.stringify(mermaidJson));
86
+ fs.writeFileSync(path.join(targetMaterial, 'mermaid.json.enc'), JSON.stringify(blob));
87
+ }
88
+ else {
89
+ fs.writeFileSync(path.join(targetMaterial, 'mermaid.json'), JSON.stringify(mermaidJson));
90
+ }
91
+ if (mermaid.css) {
92
+ fs.writeFileSync(path.join(targetMaterial, 'mermaid.css'), mermaid.css);
93
+ }
94
+ }
95
+ // Manifest: lists materials + optional encryption envelope + mermaid mode.
96
+ const manifest = { materials: entries };
97
+ if (encryptCtx) {
98
+ manifest.encrypted = {
99
+ algo: 'AES-GCM',
100
+ argon2: DEFAULT_ARGON2_PARAMS,
101
+ aesSalt: encryptCtx.aesSalt,
102
+ scope: encryptCtx.scope,
103
+ };
104
+ }
105
+ manifest.mermaid = {
106
+ mode: mermaidMode,
107
+ count: mermaid.count,
108
+ errored: mermaid.errored,
109
+ hasCss: mermaid.css.length > 0,
110
+ };
111
+ // Repo language — the reader locks its UI to this so the interface
112
+ // matches the material's own language.
113
+ manifest.lang = repoLang;
114
+ fs.writeFileSync(path.join(targetMaterial, 'index.json'), JSON.stringify(manifest, null, 2) + '\n');
115
+ success(t('publish.build.msg.done', { path: outDir }));
116
+ info(t('publish.build.msg.stat', {
117
+ count: countCards(materialDir).toString(),
118
+ materialCount: entries.length.toString(),
119
+ }));
120
+ info(t('publish.build.msg.hint'));
121
+ }
122
+ /**
123
+ * Read publish.password.* from the repo's KV and, if enabled, obtain the
124
+ * plaintext (env var or TTY prompt), verify it against the stored hash, and
125
+ * derive the AES key. Returns null when protection is off — build stays
126
+ * plaintext.
127
+ */
128
+ async function resolveEncryptCtx(cwd) {
129
+ const kv = createRepoKv(cwd);
130
+ if (!(await kv.available()))
131
+ return null;
132
+ const enabled = (await kv.get('publish.password.enabled')) === true;
133
+ if (!enabled)
134
+ return null;
135
+ const scope = (await kv.get('publish.password.scope')) ?? 'all';
136
+ if (scope !== 'all' && scope !== 'material-only') {
137
+ throw new Error(t('publish.build.err.scopeUnsupported', { scope }));
138
+ }
139
+ const hash = (await kv.get('publish.password.hash'));
140
+ const aesSalt = (await kv.get('publish.password.aesSalt'));
141
+ if (!hash || !aesSalt) {
142
+ throw new Error(t('publish.build.err.pwdNoHash'));
143
+ }
144
+ const plaintext = await obtainPassword();
145
+ if (!verifyPassword(plaintext, hash)) {
146
+ throw new Error(t('publish.build.err.pwdWrong'));
147
+ }
148
+ info(t('publish.build.msg.encrypting', { scope }));
149
+ const key = await deriveAesKey(plaintext, aesSalt);
150
+ return { key, aesSalt, scope: scope };
151
+ }
152
+ /** Read `publish.mermaid` from the repo KV, falling back to 'prerender'. */
153
+ async function resolveMermaidMode(cwd) {
154
+ const kv = createRepoKv(cwd);
155
+ if (!(await kv.available()))
156
+ return 'prerender';
157
+ const v = (await kv.get('publish.mermaid'));
158
+ return v === 'client' || v === 'off' ? v : 'prerender';
159
+ }
160
+ /**
161
+ * Read `repo.lang` from the repo KV, normalise to canonical BCP 47
162
+ * (`zh-CN` / `en-US`). Falls back to `en-US` when the field is missing
163
+ * or unrecognised. The reader consumes this to lock its UI language.
164
+ */
165
+ async function resolveRepoLang(cwd) {
166
+ const kv = createRepoKv(cwd);
167
+ if (!(await kv.available()))
168
+ return 'en-US';
169
+ const v = await kv.get('repo.lang');
170
+ if (v === 'zh' || v === 'zh-CN')
171
+ return 'zh-CN';
172
+ return 'en-US';
173
+ }
174
+ async function obtainPassword() {
175
+ const envPwd = process.env.QTYPE_PUBLISH_PASSWORD;
176
+ if (envPwd)
177
+ return envPwd;
178
+ if (process.stdin.isTTY) {
179
+ return promptPassword(t('publish.build.msg.pwdPrompt'));
180
+ }
181
+ throw new Error(t('publish.build.err.pwdMissing'));
182
+ }
183
+ function copyDir(src, dst) {
184
+ fs.mkdirSync(dst, { recursive: true });
185
+ for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
186
+ const s = path.join(src, entry.name);
187
+ const d = path.join(dst, entry.name);
188
+ if (entry.isDirectory())
189
+ copyDir(s, d);
190
+ else if (entry.isFile())
191
+ fs.copyFileSync(s, d);
192
+ }
193
+ }
194
+ function readTitle(source) {
195
+ const head = source.slice(0, 1024);
196
+ const m = head.match(/^title:\s*(.+)$/m);
197
+ return m ? m[1].trim().replace(/^["']|["']$/g, '') : null;
198
+ }
199
+ function countCards(materialDir) {
200
+ let n = 0;
201
+ for (const name of fs.readdirSync(materialDir)) {
202
+ if (!name.endsWith('.md'))
203
+ continue;
204
+ const body = fs.readFileSync(path.join(materialDir, name), 'utf8');
205
+ n += body.split(/\n\n---\n\n/).filter((s) => /##\s*Q:/.test(s)).length;
206
+ }
207
+ return n;
208
+ }
@@ -0,0 +1,50 @@
1
+ export const messages = {
2
+ 'zh-CN': {
3
+ 'publish.build.summary': '构建静态资料站',
4
+ 'publish.build.description': '拼装 qtype 预编译 view 与当前目录 material/ 为可部署静态站(CI 主用,本地可验证产物)',
5
+ 'publish.build.arg.path': '仓库路径(默认当前目录)',
6
+ 'publish.build.opt.out': '输出目录(默认 dist)',
7
+ 'publish.build.example.basic': 'CI 里构建 / 本地验证产物',
8
+ 'publish.build.msg.parsing': '解析仓库: {path}',
9
+ 'publish.build.msg.building': '构建 → {path}',
10
+ 'publish.build.msg.stat': '{count} 张卡片,{materialCount} 个资料',
11
+ 'publish.build.msg.done': '构建完成 → {path}',
12
+ 'publish.build.msg.hint': '运行 `qtype publish serve` 预览。',
13
+ 'publish.build.err.noView': '未找到 qtype 预编译 view({path})。若你正在开发 qtype,请先运行 npm run build:view。',
14
+ 'publish.build.err.noMaterial': '未找到 material/。请先运行 `qtype init`。',
15
+ 'publish.build.msg.encrypting': '启用密码保护,正在加密 material/ (scope={scope})',
16
+ 'publish.build.msg.pwdPrompt': '输入发布密码: ',
17
+ 'publish.build.err.pwdMissing': '密码保护已启用但 build 时未提供密码。请通过 TTY 交互输入,或设置环境变量 QTYPE_PUBLISH_PASSWORD',
18
+ 'publish.build.err.pwdWrong': '密码错误,与 `qtype publish password set` 记录的哈希不匹配',
19
+ 'publish.build.err.pwdNoHash': '`publish.password.enabled=true` 但缺少 hash / aesSalt。请重新运行 `qtype publish password set`',
20
+ 'publish.build.err.scopeUnsupported': '当前仅支持 `scope=all` 与 `scope=material-only`;`scope={scope}` 暂未实现',
21
+ 'publish.build.msg.mermaidStart': '预渲染 {count} 个 mermaid 图...',
22
+ 'publish.build.msg.mermaidDone': 'mermaid 渲染完成:{count} 个图,{errored} 个失败',
23
+ 'publish.build.msg.mermaidErrored': '{count} 个 mermaid 图渲染失败(已回落到错误占位 SVG)',
24
+ 'publish.build.msg.mermaidSkip': 'publish.mermaid={mode},跳过预渲染',
25
+ },
26
+ 'en-US': {
27
+ 'publish.build.summary': 'Build the static material site',
28
+ 'publish.build.description': "Assemble qtype's prebuilt view with the local material/ into a deployable static site (CI-first)",
29
+ 'publish.build.arg.path': 'Repo path (default: current directory)',
30
+ 'publish.build.opt.out': 'Output directory (default: dist)',
31
+ 'publish.build.example.basic': 'CI build / local dry run',
32
+ 'publish.build.msg.parsing': 'Parsing repo: {path}',
33
+ 'publish.build.msg.building': 'Building → {path}',
34
+ 'publish.build.msg.stat': '{count} cards across {materialCount} materials',
35
+ 'publish.build.msg.done': 'Build complete → {path}',
36
+ 'publish.build.msg.hint': 'Run `qtype publish serve` to preview.',
37
+ 'publish.build.err.noView': 'Prebuilt qtype view not found ({path}). If you are hacking on qtype, run `npm run build:view` first.',
38
+ 'publish.build.err.noMaterial': 'No material/ found. Run `qtype init` first.',
39
+ 'publish.build.msg.encrypting': 'Password protection enabled, encrypting material/ (scope={scope})',
40
+ 'publish.build.msg.pwdPrompt': 'Publish password: ',
41
+ 'publish.build.err.pwdMissing': 'Password protection enabled but no password provided to build. Enter interactively via TTY, or set QTYPE_PUBLISH_PASSWORD env var',
42
+ 'publish.build.err.pwdWrong': 'Wrong password (does not match the hash stored by `qtype publish password set`)',
43
+ 'publish.build.err.pwdNoHash': '`publish.password.enabled=true` but hash / aesSalt is missing. Re-run `qtype publish password set`',
44
+ 'publish.build.err.scopeUnsupported': "Only scope='all' and scope='material-only' are currently supported; scope='{scope}' is not implemented",
45
+ 'publish.build.msg.mermaidStart': 'Prerendering {count} mermaid diagram(s)...',
46
+ 'publish.build.msg.mermaidDone': 'Mermaid render complete: {count} diagrams, {errored} failed',
47
+ 'publish.build.msg.mermaidErrored': '{count} mermaid diagram(s) failed to render (falling back to inline error SVG)',
48
+ 'publish.build.msg.mermaidSkip': 'publish.mermaid={mode}, skipping prerender',
49
+ },
50
+ };
@@ -0,0 +1,24 @@
1
+ import { t } from '../../../i18n/index.js';
2
+ import { messages } from './i18n.js';
3
+ import { publishBuildAction } from './action.js';
4
+ export const publishBuildCmd = {
5
+ name: 'build',
6
+ messages,
7
+ summary: () => t('publish.build.summary'),
8
+ describe: () => t('publish.build.description'),
9
+ args: [{ flag: '[path]', describe: () => t('publish.build.arg.path') }],
10
+ options: [
11
+ {
12
+ flags: '-o, --out <dir>',
13
+ describe: () => t('publish.build.opt.out'),
14
+ defaultValue: 'dist',
15
+ },
16
+ ],
17
+ examples: () => [
18
+ { command: 'qtype publish build', describe: () => t('publish.build.example.basic') },
19
+ ],
20
+ seeAlso: ['publish serve', 'publish init'],
21
+ action: async (pathArg, opts) => {
22
+ await publishBuildAction({ path: pathArg, out: opts.out });
23
+ },
24
+ };
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Pre-render every ```mermaid code block found across all `material/*.md`
3
+ * files into inline SVG.
4
+ *
5
+ * The view side runs markdown-it (or similar) on each material file when
6
+ * the user opens it. Rather than let the browser drag in mermaid.js on
7
+ * demand (~800 KB gz, delayed first paint), we scan every material at
8
+ * build time and produce a map keyed by the mermaid source. The view
9
+ * looks up the SVG synchronously and swaps it in — no JS mermaid runtime
10
+ * needed at runtime.
11
+ *
12
+ * Two SVGs per unique diagram (dark + light theme) so theme switching is
13
+ * a pure CSS swap.
14
+ *
15
+ * Order in publishBuildAction:
16
+ * 1. Read all material/*.md
17
+ * 2. Prerender mermaid → collect svgs + css
18
+ * 3. Encrypt if enabled (svg map goes through the same key)
19
+ * 4. Emit `dist/material/mermaid.json` (+ mermaid.css) alongside the materials
20
+ */
21
+ import { MermaidRenderer } from './mermaidPrerender.js';
22
+ import { info, warn } from '../../../utils/output.js';
23
+ import { t } from '../../../i18n/index.js';
24
+ const MERMAID_FENCE = /^([ \t]{0,3})(```+)\s*mermaid[ \t]*\n([\s\S]*?)\n\1\2[ \t]*(?=\n|$)/gm;
25
+ /**
26
+ * Map key: the mermaid source itself (trimmed). markdown-it's `highlight`
27
+ * hook is sync while the browser's SubtleCrypto is async — using the raw
28
+ * source as the key sidesteps hashing altogether. The map JSON is slightly
29
+ * bigger but typical qtype materials have few enough diagrams that this
30
+ * is negligible next to each diagram's ~2 KB SVG payload.
31
+ */
32
+ export function mermaidSvgKey(source) {
33
+ return source.trim();
34
+ }
35
+ function extractSources(md) {
36
+ const out = [];
37
+ MERMAID_FENCE.lastIndex = 0;
38
+ let m;
39
+ while ((m = MERMAID_FENCE.exec(md)) !== null) {
40
+ out.push(m[3]);
41
+ }
42
+ return out;
43
+ }
44
+ /** Empty result — used when no diagrams found or mermaid pass disabled. */
45
+ export function emptyMermaidPass() {
46
+ return { svgs: {}, css: '', count: 0, errored: 0 };
47
+ }
48
+ /**
49
+ * Scan all provided material bodies for ```mermaid fences and pre-render
50
+ * each unique diagram twice (dark + light). Returns a lookup map + the
51
+ * associated CSS. Returns an empty result when no diagrams are found.
52
+ */
53
+ export async function prerenderMermaid(materialBodies) {
54
+ const uniqueSources = new Map();
55
+ for (const body of materialBodies) {
56
+ for (const src of extractSources(body)) {
57
+ const key = mermaidSvgKey(src);
58
+ if (!uniqueSources.has(key))
59
+ uniqueSources.set(key, src);
60
+ }
61
+ }
62
+ if (uniqueSources.size === 0)
63
+ return emptyMermaidPass();
64
+ info(t('publish.build.msg.mermaidStart', { count: uniqueSources.size }));
65
+ const renderer = new MermaidRenderer();
66
+ await renderer.init();
67
+ const svgs = {};
68
+ const cssChunks = [];
69
+ let errored = 0;
70
+ try {
71
+ for (const [key, source] of uniqueSources) {
72
+ const r = await renderer.render(source);
73
+ if (r.errored)
74
+ errored++;
75
+ svgs[key] = { dark: r.svgDark, light: r.svgLight };
76
+ if (r.cssDark)
77
+ cssChunks.push(r.cssDark);
78
+ if (r.cssLight)
79
+ cssChunks.push(r.cssLight);
80
+ }
81
+ }
82
+ finally {
83
+ await renderer.close();
84
+ }
85
+ if (errored > 0)
86
+ warn(t('publish.build.msg.mermaidErrored', { count: errored }));
87
+ return {
88
+ svgs,
89
+ css: cssChunks.join('\n'),
90
+ count: uniqueSources.size,
91
+ errored,
92
+ };
93
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Build-time Mermaid pre-renderer.
3
+ *
4
+ * Boots ONE headless chromium via puppeteer, loads mermaid ESM from the
5
+ * local node_modules (no CDN, works offline / in air-gapped CI), then
6
+ * batch-renders every mermaid code block TWICE — once against mermaid's
7
+ * built-in `dark` theme and once against `default` (light). Both SVGs
8
+ * are shipped; the client shows/hides them via CSS
9
+ * `[data-theme="dark"|"light"]` selectors, so switching themes swaps
10
+ * the whole rendered figure with no re-render, no JS, no CSP violation.
11
+ *
12
+ * Why two full renders instead of one SVG with CSS variables:
13
+ * `themeVariables` only controls ~20 mermaid palette slots. Mermaid
14
+ * internally derives dozens more (subgraph tints, alternating rows,
15
+ * error accents, cluster shadows) and hard-codes those derived values
16
+ * into the emitted SVG. Rewriting them post-hoc is fragile — the
17
+ * mapping "which derived HSL corresponds to which theme role" isn't
18
+ * documented and changes across mermaid versions. Letting mermaid do
19
+ * both passes with its own coherent palette yields two clean SVGs.
20
+ *
21
+ * SVG post-processing (per theme):
22
+ * 1. Inline `style="..."` attrs → `.qt-mm-s{N}` classes (moved into
23
+ * the SVG's internal <style>). Strict CSPs treat inline style attrs
24
+ * as violations even inside freshly-inserted SVGs.
25
+ * 2. The SVG's internal `<style>` element is *also* stripped and its
26
+ * CSS returned separately so the caller can concatenate it into an
27
+ * external stylesheet — again, `style-src-elem` blocks any newly
28
+ * inserted `<style>` element.
29
+ */
30
+ import puppeteer from 'puppeteer';
31
+ import path from 'node:path';
32
+ import os from 'node:os';
33
+ import fs from 'node:fs';
34
+ import { fileURLToPath, pathToFileURL } from 'node:url';
35
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
36
+ export class MermaidRenderer {
37
+ browser = null;
38
+ pageDark = null;
39
+ pageLight = null;
40
+ async init() {
41
+ const candidates = [
42
+ path.resolve(__dirname, '..', '..', '..', '..', '..', 'node_modules', 'mermaid', 'dist', 'mermaid.esm.min.mjs'),
43
+ path.resolve(process.cwd(), 'node_modules', 'mermaid', 'dist', 'mermaid.esm.min.mjs'),
44
+ ];
45
+ let mermaidEsm = null;
46
+ for (const c of candidates) {
47
+ if (fs.existsSync(c)) {
48
+ mermaidEsm = c;
49
+ break;
50
+ }
51
+ }
52
+ if (!mermaidEsm) {
53
+ throw new Error('mermaid ESM not found in node_modules — cannot pre-render mermaid diagrams. ' +
54
+ 'Set publish.mermaid to "client" or "off" in .qtype/config.json to disable prerender.');
55
+ }
56
+ this.browser = await puppeteer.launch({
57
+ headless: true,
58
+ args: ['--allow-file-access-from-files'],
59
+ });
60
+ this.pageDark = await this.makeThemePage(mermaidEsm, 'dark');
61
+ this.pageLight = await this.makeThemePage(mermaidEsm, 'default');
62
+ }
63
+ async makeThemePage(mermaidEsm, theme) {
64
+ if (!this.browser)
65
+ throw new Error('browser not launched');
66
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qtype-mermaid-'));
67
+ const hostHtml = path.join(tmpDir, 'host.html');
68
+ const initJson = JSON.stringify({
69
+ startOnLoad: false,
70
+ securityLevel: 'strict',
71
+ theme,
72
+ themeVariables: {
73
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif',
74
+ },
75
+ flowchart: { htmlLabels: true, useMaxWidth: true },
76
+ });
77
+ fs.writeFileSync(hostHtml, `<!doctype html>
78
+ <html><head><meta charset="utf-8" /></head><body>
79
+ <script type="module">
80
+ import mermaid from ${JSON.stringify(pathToFileURL(mermaidEsm).href)};
81
+ mermaid.initialize(${initJson});
82
+ window.__qtRender = async (code) => {
83
+ try {
84
+ const { svg } = await mermaid.render('qm-' + Math.random().toString(36).slice(2), code);
85
+ return { ok: true, svg };
86
+ } catch (e) {
87
+ return { ok: false, error: e && e.message ? e.message : String(e) };
88
+ }
89
+ };
90
+ window.__qtReady = true;
91
+ </script>
92
+ </body></html>`);
93
+ const page = await this.browser.newPage();
94
+ await page.goto(pathToFileURL(hostHtml).href);
95
+ await page.waitForFunction('window.__qtReady === true', { timeout: 30_000 });
96
+ return page;
97
+ }
98
+ async renderOne(page, code) {
99
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
100
+ return await page.evaluate(
101
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
102
+ (c) => globalThis.__qtRender(c), code);
103
+ }
104
+ async render(code) {
105
+ if (!this.pageDark || !this.pageLight) {
106
+ throw new Error('MermaidRenderer not initialised — call init() first');
107
+ }
108
+ const [rDark, rLight] = await Promise.all([
109
+ this.renderOne(this.pageDark, code),
110
+ this.renderOne(this.pageLight, code),
111
+ ]);
112
+ if (!rDark.ok || !rLight.ok) {
113
+ const msg = (!rDark.ok && rDark.error) || (!rLight.ok && rLight.error) || 'render failed';
114
+ return {
115
+ svgDark: errorSvg(msg, code, 'qt-mm-dark'),
116
+ svgLight: errorSvg(msg, code, 'qt-mm-light'),
117
+ cssDark: '',
118
+ cssLight: '',
119
+ errored: true,
120
+ };
121
+ }
122
+ const dark = processSvg(rDark.svg, 'qt-mm-dark');
123
+ const light = processSvg(rLight.svg, 'qt-mm-light');
124
+ return {
125
+ svgDark: dark.svg,
126
+ svgLight: light.svg,
127
+ cssDark: dark.css,
128
+ cssLight: light.css,
129
+ errored: false,
130
+ };
131
+ }
132
+ async close() {
133
+ if (this.browser) {
134
+ await this.browser.close();
135
+ this.browser = null;
136
+ this.pageDark = null;
137
+ this.pageLight = null;
138
+ }
139
+ }
140
+ }
141
+ /**
142
+ * All post-processing for a raw mermaid SVG in one pass:
143
+ * 1. Tag the <svg> root with a theme class (`qt-mm-dark` / `qt-mm-light`).
144
+ * 2. Move every inline `style="..."` attribute to a generated class.
145
+ * 3. Extract the SVG's internal <style> body, scoped by SVG id.
146
+ * Returns the sanitised SVG plus the extracted CSS.
147
+ */
148
+ function processSvg(rawSvg, themeClass) {
149
+ const idMatch = rawSvg.match(/<svg\b[^>]*\bid="([^"]+)"/);
150
+ const scope = idMatch ? idMatch[1] : `qm-${Math.random().toString(36).slice(2, 8)}`;
151
+ let svg = rawSvg;
152
+ if (/<svg\b[^>]*\sclass="([^"]*)"/.test(svg)) {
153
+ svg = svg.replace(/(<svg\b[^>]*\sclass=")([^"]*)("[^>]*>)/, (_full, a, cls, b) => `${a}${cls} ${themeClass}${b}`);
154
+ }
155
+ else {
156
+ svg = svg.replace(/<svg\b/, `<svg class="${themeClass}"`);
157
+ }
158
+ const styleMap = new Map();
159
+ let counter = 0;
160
+ const withClasses = svg.replace(/<([a-zA-Z][a-zA-Z0-9-]*)\b([^>]*?)\sstyle="([^"]*)"([^>]*)>/g, (_full, tag, before, style, after) => {
161
+ const norm = style.trim().replace(/;\s*$/, '');
162
+ if (!norm)
163
+ return `<${tag}${before}${after}>`;
164
+ let cls = styleMap.get(norm);
165
+ if (!cls) {
166
+ counter += 1;
167
+ cls = `qt-mm-s${counter}`;
168
+ styleMap.set(norm, cls);
169
+ }
170
+ const combined = before + after;
171
+ if (/\sclass="([^"]*)"/.test(combined)) {
172
+ const replaced = combined.replace(/\sclass="([^"]*)"/, ` class="$1 ${cls}"`);
173
+ return `<${tag}${replaced}>`;
174
+ }
175
+ return `<${tag}${before} class="${cls}"${after}>`;
176
+ });
177
+ let extractedBody = '';
178
+ const stripped = withClasses.replace(/<style[^>]*>([\s\S]*?)<\/style>/g, (_full, body) => {
179
+ extractedBody += body;
180
+ return '';
181
+ });
182
+ const classRules = Array.from(styleMap.entries())
183
+ .map(([style, cls]) => `#${scope} .${cls}{${style}}`)
184
+ .join('');
185
+ return {
186
+ svg: stripped,
187
+ css: extractedBody + classRules,
188
+ };
189
+ }
190
+ function errorSvg(message, source, themeClass) {
191
+ const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
192
+ const short = message.length > 200 ? message.slice(0, 200) + '…' : message;
193
+ return `<svg xmlns="http://www.w3.org/2000/svg" class="${themeClass}" role="img" aria-label="Mermaid render error" width="600" height="80">
194
+ <rect x="0" y="0" width="600" height="80" fill="rgba(200,50,50,0.15)" rx="6"/>
195
+ <text x="12" y="24" fill="#c00">Mermaid render error:</text>
196
+ <text x="12" y="44" fill="#c00">${esc(short)}</text>
197
+ <text x="12" y="64" fill="#c00">${esc(source.split('\n')[0].slice(0, 80))}…</text>
198
+ </svg>`;
199
+ }
@@ -0,0 +1,21 @@
1
+ import { t } from '../../i18n/index.js';
2
+ import { publishBuildCmd } from './build/index.js';
3
+ import { publishInitCmd } from './init/index.js';
4
+ import { publishServeCmd } from './serve/index.js';
5
+ import { publishPasswordCmd } from './password/index.js';
6
+ export const publishCmd = {
7
+ name: 'publish',
8
+ summary: () => t('publish.summary'),
9
+ describe: () => t('publish.description'),
10
+ messages: {
11
+ 'zh-CN': {
12
+ 'publish.summary': '发布管理',
13
+ 'publish.description': '构建、预览与保护静态资料站',
14
+ },
15
+ 'en-US': {
16
+ 'publish.summary': 'Publishing',
17
+ 'publish.description': 'Build, preview, and protect the static material site',
18
+ },
19
+ },
20
+ subcommands: [publishInitCmd, publishBuildCmd, publishServeCmd, publishPasswordCmd],
21
+ };