@qnroa/qtype 0.2.0 → 0.2.2

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,79 @@ can land in any minor bump (`0.x.0`).
13
13
 
14
14
  ## [Unreleased]
15
15
 
16
+ ## [0.2.2] — 2026-09-01
17
+
18
+ A responsive footer, a convention-based way to swap the site logo
19
+ and favicon without touching code, and a housecleaning rename of the
20
+ public-facing brand assets to drop the redundant `qtype-` prefix.
21
+
22
+ ### Added
23
+ - **Custom site logo via `.qtype/assets/logo.{svg,png}`.** `qtype repo
24
+ new` seeds `<repo>/.qtype/assets/logo.svg` from the language template,
25
+ and `qtype publish build` copies that file over the default
26
+ `logo.<ext>` in `dist/` when it's present. Users edit the file in
27
+ place (keeping the filename `logo.<ext>`) — no config key, no CLI
28
+ flag. Missing file → the built-in default keeps working. Used by the
29
+ Header brand mark; the browser tab now reads a separate `favicon`
30
+ (see below).
31
+ - **Custom favicon via `.qtype/assets/favicon.{svg,png,ico}`.** Same
32
+ convention as the logo — drop a file at this path, `publish build`
33
+ ships it as `dist/favicon.<ext>` and rewrites `index.html`'s
34
+ `<link rel="icon">` to point at the resolved format. Separating
35
+ favicon from logo lets users keep a wide Header brand mark while
36
+ still having a square browser-tab icon.
37
+ - **`data-qtype-logo-ext` on `<html>`** — new boot attribute so the
38
+ Header's runtime `<img>` can point at `/logo.<ext>` with the right
39
+ extension after a build.
40
+
41
+ ### Changed
42
+ - **Public brand assets renamed** to drop the `qtype-` prefix: files
43
+ in `src/view/public/` are now `logo.svg`, `favicon.svg`,
44
+ `wordmark.svg`; the built site emits `dist/logo.<ext>`,
45
+ `dist/favicon.<ext>`, and `dist/wordmark.svg` (unused for now, kept
46
+ around for future brand surfaces). Any external consumer referencing
47
+ `qtype-mark.svg` / `qtype-logo.svg` (the old file names) in
48
+ hard-coded URLs will need to update — but the runtime `<link rel="icon">`
49
+ and Header `<img>` are both regenerated on build so a fresh
50
+ `publish build` in an existing repo is enough.
51
+ - **Footer material name is now responsive.** The left-side title
52
+ truncates with an ellipsis on all viewports (max width 240px on
53
+ desktop, 80px on narrow screens, hidden below 400px) so an unusually
54
+ long material title can't crowd the absolute-centered CardNav or
55
+ push the theme/settings buttons around.
56
+ - **Footer layout wraps the title in an inner span**, which lets the
57
+ outer flex slot keep claiming the remaining space (so the right-side
58
+ buttons stay pinned to the edge) while the inner span alone handles
59
+ the ellipsis. Fixes a mobile-only regression where the settings /
60
+ theme buttons drifted next to the title instead of hugging the right
61
+ edge.
62
+
63
+ ## [0.2.1] — 2026-09-01
64
+
65
+ Discoverability pass for `qtype config`. Users no longer have to grep
66
+ the source (or the wiki) to find out what keys they can set.
67
+
68
+ ### Added
69
+ - **`qtype config --help` now lists every configurable key** at the
70
+ bottom of the help output — one line per key with its default, scope,
71
+ description, and enum options where applicable. Same source of truth
72
+ as the actual schema, so it stays honest.
73
+ - **`qtype config list --all`** — verbose per-key view: current value
74
+ (or default if unset), source (which file / default), scope, and
75
+ enum options. Complements `list` (only shows keys the user has
76
+ actually set).
77
+ - **`SchemaEntry.description` + `SchemaEntry.options`** fields, both
78
+ optional. Populated for every currently-registered key so the two
79
+ new help surfaces have something to show.
80
+ - **`CommandModule.afterHelp`** hook — commands can append reference
81
+ material after the standard help sections. `config` uses it; other
82
+ commands can add their own without touching the registry.
83
+
84
+ ### Changed
85
+ - Cleaned up in-line doc comments in the schema files (they've been
86
+ moved into the `description` field where possible, so they're
87
+ reachable at runtime).
88
+
16
89
  ## [0.2.0] — 2026-08-31
17
90
 
18
91
  Substantial rework of the typing page. The screen-flow is now a real
package/CHANGELOG.zh.md CHANGED
@@ -12,6 +12,63 @@ English: [CHANGELOG.md](https://www.npmjs.com/package/@qnroa/qtype?activeTab=cod
12
12
 
13
13
  ## [Unreleased]
14
14
 
15
+ ## [0.2.2] — 2026-09-01
16
+
17
+ 响应式页脚,约定式自定义 logo 和 favicon(不用改代码),以及把 view
18
+ 的公用品牌资源改名去掉 `qtype-` 前缀。
19
+
20
+ ### 新增
21
+ - **通过 `.qtype/assets/logo.{svg,png}` 自定义站点 logo。** `qtype repo
22
+ new` 会从语言模板复制 `logo.svg` 到 `<repo>/.qtype/assets/`;
23
+ `publish build` 时若该文件存在,就用它覆盖 dist 里的 `logo.<ext>`。
24
+ 用户直接编辑该文件(保留文件名 `logo.<ext>`) —— 不用 config key,
25
+ 不用 CLI 参数,文件不存在就用内置默认。用于 Header 品牌图标;浏
26
+ 览器 tab 用独立的 favicon(见下)。
27
+ - **通过 `.qtype/assets/favicon.{svg,png,ico}` 自定义浏览器 tab 图标。**
28
+ 和 logo 同样的约定式覆盖机制,`publish build` 会把用户的 favicon
29
+ 复制成 `dist/favicon.<ext>` 并重写 `index.html` 里的
30
+ `<link rel="icon">`。favicon 和 logo 分开是为了让用户可以放宽横
31
+ 的 logo,同时保留方形的 tab 图标。
32
+ - **`<html data-qtype-logo-ext>` 新 boot 属性** —— 让 Header 的
33
+ `<img src="/logo.<ext>">` 在运行时挑对扩展名。
34
+
35
+ ### 变更
36
+ - **view 的公用品牌资源改名去掉 `qtype-` 前缀。** `src/view/public/`
37
+ 下现在叫 `logo.svg`、`favicon.svg`、`wordmark.svg`;build 出来的
38
+ 站点里也是 `dist/logo.<ext>`、`dist/favicon.<ext>` 和
39
+ `dist/wordmark.svg`(暂时没被引用,保留给以后用)。外部如果硬编码
40
+ 引用了旧文件名(`qtype-mark.svg` / `qtype-logo.svg`)需要更新
41
+ —— 但运行时 `<link rel="icon">` 和 Header 的 `<img>` 都是构建时
42
+ 重新生成的,已有 repo 只要跑一次 `publish build` 就同步了。
43
+ - **页脚材料名做了响应式截断。** 左边的材料标题在所有断点上都会
44
+ ellipsis(桌面 240px 上限,窄屏 80px,超窄 400px 以下隐藏),避免过
45
+ 长的材料标题挤占 CardNav 绝对居中区域或推走右侧的主题 / 设置按钮。
46
+ - **标题外面包了一层 inner span**,让外层 flex 槽继续占满剩余空间
47
+ (右侧按钮才能贴到最右边),ellipsis 只作用在 inner span 上。修复
48
+ 了窄屏上出现的一个视觉回归 —— 设置 / 主题按钮会飘到标题旁边,而
49
+ 不是紧贴右侧。
50
+
51
+ ## [0.2.1] — 2026-09-01
52
+
53
+ `qtype config` 的可发现性升级。用户不再需要翻源码或 wiki 才知道能设
54
+ 置哪些 key。
55
+
56
+ ### 新增
57
+ - **`qtype config --help` 现在会在底部列出所有可配置的 key**,一行一个,
58
+ 含默认值、作用域、描述、枚举选项(如果有)。数据源就是 schema 本身,
59
+ 不会脱节。
60
+ - **`qtype config list --all`** —— 详版:每个 key 显示当前值(或未设置
61
+ 时的默认)、来源(哪个文件 / 默认)、作用域、枚举选项。和 `list`(只
62
+ 显示用户设过的)互补。
63
+ - **`SchemaEntry.description` + `SchemaEntry.options`** 两个可选字段。
64
+ 现在每个已注册 key 都填了,给上面两个新的 help 出口提供内容。
65
+ - **`CommandModule.afterHelp` 钩子** —— 命令可以在标准 help 段落后追加
66
+ 参考内容。`config` 用它,其它命令想加也行,不用改 registry。
67
+
68
+ ### 变更
69
+ - 清理了 schema 文件里的行内文档注释(能放进 `description` 的都挪进
70
+ 去了,这样运行时就能读到)。
71
+
15
72
  ## [0.2.0] — 2026-08-31
16
73
 
17
74
  打字页面的整体架构重写。屏幕流程改成了真正的有限状态机(xstate),
@@ -178,6 +178,12 @@ export async function configUnsetAction(key, opts) {
178
178
  /** `qtype config list [--global|--local] [--show-origin]` */
179
179
  export async function configListAction(opts) {
180
180
  const scope = pickScope(opts);
181
+ // --all: enumerate every schema-known key, filling in defaults for
182
+ // ones the user hasn't set. Useful for discovering what can be
183
+ // configured.
184
+ if (opts.all) {
185
+ return listAllKnown(scope);
186
+ }
181
187
  const userEntries = scope === 'repo' ? [] : await userKv.list();
182
188
  const repoEntries = scope === 'user' ? [] : (await repoKv.available()) ? await repoKv.list() : [];
183
189
  const rows = [];
@@ -220,6 +226,68 @@ export async function configListAction(opts) {
220
226
  // ═══════════════════════════════════════════════════════════════
221
227
  // helpers
222
228
  // ═══════════════════════════════════════════════════════════════
229
+ /**
230
+ * List every key known to the schema in the given scope (all if scope
231
+ * is undefined). For each key: current value + source, or default +
232
+ * `(default)` marker if unset, plus type / options / description.
233
+ */
234
+ async function listAllKnown(scope) {
235
+ const rows = [];
236
+ const seen = new Set();
237
+ if (scope !== 'repo') {
238
+ for (const k of userKv.knownKeys()) {
239
+ if (seen.has(k))
240
+ continue;
241
+ seen.add(k);
242
+ rows.push({ key: k, kv: userKv, scope: 'user' });
243
+ }
244
+ }
245
+ if (scope !== 'user') {
246
+ for (const k of repoKv.knownKeys()) {
247
+ if (seen.has(k))
248
+ continue;
249
+ seen.add(k);
250
+ rows.push({ key: k, kv: repoKv, scope: 'repo' });
251
+ }
252
+ }
253
+ rows.sort((a, b) => a.key.localeCompare(b.key));
254
+ const repoAvailable = scope === 'user' ? false : await repoKv.available();
255
+ for (const r of rows) {
256
+ const entry = r.kv.getSchema(r.key);
257
+ // Prefer the stored value (from user or repo scope); fall back to
258
+ // the schema default. `get` returns default when unset, so guard
259
+ // with `has` first to know whether the value is user-set.
260
+ let currentDisplay = '';
261
+ let sourceLabel = '';
262
+ const canRead = r.scope === 'user' || repoAvailable;
263
+ if (canRead && (await r.kv.has(r.key))) {
264
+ const raw = await r.kv.get(r.key);
265
+ currentDisplay = formatValue(raw);
266
+ sourceLabel = `${r.scope}:${r.kv.origin}`;
267
+ }
268
+ else {
269
+ currentDisplay = formatValue(entry.default);
270
+ sourceLabel = 'default';
271
+ }
272
+ const parts = [`${r.key}=${currentDisplay}`];
273
+ if (entry.description)
274
+ parts.push(dim(entry.description));
275
+ console.log(parts.join(' '));
276
+ const meta = [];
277
+ if (entry.options && entry.options.length > 0) {
278
+ meta.push(`options: ${entry.options.join(' | ')}`);
279
+ }
280
+ if (entry.default !== undefined && sourceLabel === 'default') {
281
+ // already shown as current; keep meta uncluttered
282
+ }
283
+ else if (entry.default !== undefined) {
284
+ meta.push(`default: ${formatValue(entry.default)}`);
285
+ }
286
+ meta.push(`scope: ${r.scope}`);
287
+ meta.push(`source: ${sourceLabel}`);
288
+ console.log(' ' + dim(meta.join(' · ')));
289
+ }
290
+ }
223
291
  /** Format a value as a single-line string. Objects / arrays use JSON. */
224
292
  function formatValue(v) {
225
293
  if (v == null)
@@ -7,8 +7,10 @@ export const messages = {
7
7
  'config.opt.global': '操作 user 层(~/.config/qtype/config.json)',
8
8
  'config.opt.local': '操作 repo 层(<repo>/.qtype/config.json)',
9
9
  'config.opt.showOrigin': '显示配置项来源(文件路径 / 哪一层)',
10
+ 'config.opt.all': '列出所有可配置项(含 schema 里未设置的默认值)',
10
11
  'config.example.setLang': '设置 CLI 语言',
11
12
  'config.example.list': '列出所有生效配置',
13
+ 'config.list.example.all': '列出所有可配置项及默认值',
12
14
  'config.get.summary': '读配置',
13
15
  'config.get.description': '读取指定 key 的值(默认合并两层;key 不存在 exit 1)',
14
16
  'config.get.example.basic': '读 CLI 语言偏好',
@@ -29,6 +31,8 @@ export const messages = {
29
31
  'config.list.example.global': '只看 user 层',
30
32
  'config.list.msg.empty': '配置为空',
31
33
  'config.list.msg.emptyScoped': '配置为空 ({scope})',
34
+ 'config.help.keysHeader': '可配置项:',
35
+ 'config.help.keysFooter': '用 `qtype config set <key> <value>` 修改;`qtype config list --all` 查看完整状态。',
32
36
  },
33
37
  'en-US': {
34
38
  'config.summary': 'Manage config',
@@ -38,8 +42,10 @@ export const messages = {
38
42
  'config.opt.global': 'Operate on user scope (~/.config/qtype/config.json)',
39
43
  'config.opt.local': 'Operate on repo scope (<repo>/.qtype/config.json)',
40
44
  'config.opt.showOrigin': 'Show where each entry comes from (file path and scope)',
45
+ 'config.opt.all': 'List every configurable key, including schema defaults for unset keys',
41
46
  'config.example.setLang': 'Set CLI language',
42
47
  'config.example.list': 'List all effective config',
48
+ 'config.list.example.all': 'List every configurable key with defaults',
43
49
  'config.get.summary': 'Get value',
44
50
  'config.get.description': 'Get value for a key (merged by default; exit 1 if key not set)',
45
51
  'config.get.example.basic': 'Read CLI language preference',
@@ -60,5 +66,7 @@ export const messages = {
60
66
  'config.list.example.global': 'Only user scope',
61
67
  'config.list.msg.empty': 'Config is empty',
62
68
  'config.list.msg.emptyScoped': 'Config is empty ({scope})',
69
+ 'config.help.keysHeader': 'Available keys:',
70
+ 'config.help.keysFooter': 'Use `qtype config set <key> <value>` to change one; `qtype config list --all` to see the full state.',
63
71
  },
64
72
  };
@@ -1,5 +1,7 @@
1
1
  import { t } from '../../i18n/index.js';
2
2
  import { messages } from './i18n.js';
3
+ import { userKv, repoKv } from '../../bootstrap.js';
4
+ import { bold, dim } from '../../utils/output.js';
3
5
  import { configGetAction, configSetAction, configUnsetAction, configListAction, } from './action.js';
4
6
  /** Shared --global / --local / --show-origin options. */
5
7
  const scopeOptions = [
@@ -52,13 +54,59 @@ const listCmd = {
52
54
  options: [
53
55
  ...scopeOptions,
54
56
  { flags: '--show-origin', describe: () => t('config.opt.showOrigin') },
57
+ { flags: '--all', describe: () => t('config.opt.all') },
55
58
  ],
56
59
  examples: () => [
57
60
  { command: 'qtype config list', describe: () => t('config.list.example.merged') },
58
61
  { command: 'qtype config list --global', describe: () => t('config.list.example.global') },
62
+ { command: 'qtype config list --all', describe: () => t('config.list.example.all') },
59
63
  ],
60
64
  action: async (opts) => configListAction(opts),
61
65
  };
66
+ /**
67
+ * Render every configurable key (from both user and repo schemas)
68
+ * as a `key = <default> — description` list. Used by `qtype
69
+ * config --help` so users can discover what can be tuned without
70
+ * hunting through docs.
71
+ */
72
+ function renderAvailableKeys() {
73
+ const rows = [];
74
+ const seen = new Set();
75
+ for (const k of userKv.knownKeys()) {
76
+ if (seen.has(k))
77
+ continue;
78
+ seen.add(k);
79
+ const e = userKv.getSchema(k);
80
+ rows.push({ key: k, scope: 'user', default: e.default, description: e.description, options: e.options });
81
+ }
82
+ for (const k of repoKv.knownKeys()) {
83
+ if (seen.has(k))
84
+ continue;
85
+ seen.add(k);
86
+ const e = repoKv.getSchema(k);
87
+ rows.push({ key: k, scope: 'repo', default: e.default, description: e.description, options: e.options });
88
+ }
89
+ rows.sort((a, b) => a.key.localeCompare(b.key));
90
+ const fmtDefault = (v) => {
91
+ if (v === undefined)
92
+ return '(no default)';
93
+ if (typeof v === 'string')
94
+ return v || '""';
95
+ return JSON.stringify(v);
96
+ };
97
+ const lines = [bold(t('config.help.keysHeader'))];
98
+ for (const r of rows) {
99
+ lines.push(` ${r.key} ${dim('=')} ${fmtDefault(r.default)} ${dim(`(${r.scope})`)}`);
100
+ if (r.description)
101
+ lines.push(` ${dim(r.description)}`);
102
+ if (r.options && r.options.length > 0) {
103
+ lines.push(` ${dim(`options: ${r.options.join(' | ')}`)}`);
104
+ }
105
+ }
106
+ lines.push('');
107
+ lines.push(dim(t('config.help.keysFooter')));
108
+ return lines.join('\n');
109
+ }
62
110
  export const configCmd = {
63
111
  name: 'config',
64
112
  messages,
@@ -67,6 +115,8 @@ export const configCmd = {
67
115
  examples: () => [
68
116
  { command: 'qtype config set cli.lang zh', describe: () => t('config.example.setLang') },
69
117
  { command: 'qtype config list', describe: () => t('config.example.list') },
118
+ { command: 'qtype config list --all', describe: () => t('config.list.example.all') },
70
119
  ],
120
+ afterHelp: () => renderAvailableKeys(),
71
121
  subcommands: [getCmd, setCmd, unsetCmd, listCmd],
72
122
  };
@@ -80,11 +80,20 @@ export async function publishBuildAction(opts = {}) {
80
80
  fs.rmSync(outDir, { recursive: true, force: true });
81
81
  fs.mkdirSync(outDir, { recursive: true });
82
82
  copyDir(viewBundle, outDir);
83
+ // Optional brand overrides: user may drop custom assets under
84
+ // `<repo>/.qtype/assets/`:
85
+ // - logo.{svg,png} → Header brand mark (dist/logo.<ext>)
86
+ // - favicon.{svg,png,ico} → browser tab (dist/favicon.<ext>)
87
+ // Whichever the user provides overrides the view bundle default;
88
+ // the losing sibling extensions are removed so we don't ship stale
89
+ // duplicates.
90
+ const logoExt = resolveCustomAsset(cwd, outDir, 'logo', ['svg', 'png']);
91
+ const faviconExt = resolveCustomAsset(cwd, outDir, 'favicon', ['svg', 'png', 'ico']);
83
92
  // Rewrite dist/index.html to carry the site's name — both in the
84
93
  // <title> (browser tab) and as a `data-qtype-repo-name` attribute on
85
94
  // <html> (read by the view's `readBootConfig()` for the header
86
95
  // brand label and anywhere else the repo name is shown).
87
- rewriteIndexHtml(path.join(outDir, 'index.html'), name);
96
+ rewriteIndexHtml(path.join(outDir, 'index.html'), name, logoExt, faviconExt);
88
97
  // Defensive: clear any stale material/ that might have snuck into the
89
98
  // view bundle. Since dev-seed content lives at repo root's examples/
90
99
  // (served only by the vite dev middleware, not the bundle), this is a
@@ -304,7 +313,43 @@ async function resolveRepoName(cwd) {
304
313
  }
305
314
  return path.basename(cwd);
306
315
  }
307
- function rewriteIndexHtml(indexPath, name) {
316
+ /**
317
+ * Resolve a user-provided brand asset (`logo` or `favicon`) by walking
318
+ * the accepted extensions in preference order and copying the first
319
+ * hit to `dist/<name>.<ext>`. Losing sibling extensions in the
320
+ * output are cleaned up so the site never carries stale defaults
321
+ * alongside the user's override. Returns the winning extension so
322
+ * downstream code (index.html rewrite, Header runtime) can build the
323
+ * right URL. Missing files → keep the view bundle's default (svg).
324
+ */
325
+ function resolveCustomAsset(cwd, outDir, name, order) {
326
+ const candidates = order.map((ext) => ({
327
+ ext,
328
+ src: path.join(cwd, '.qtype', 'assets', `${name}.${ext}`),
329
+ }));
330
+ for (const c of candidates) {
331
+ if (!fs.existsSync(c.src))
332
+ continue;
333
+ fs.copyFileSync(c.src, path.join(outDir, `${name}.${c.ext}`));
334
+ for (const other of candidates) {
335
+ if (other.ext === c.ext)
336
+ continue;
337
+ const stale = path.join(outDir, `${name}.${other.ext}`);
338
+ if (fs.existsSync(stale))
339
+ fs.rmSync(stale, { force: true });
340
+ }
341
+ return c.ext;
342
+ }
343
+ return 'svg';
344
+ }
345
+ function extToMime(ext) {
346
+ if (ext === 'png')
347
+ return 'image/png';
348
+ if (ext === 'ico')
349
+ return 'image/x-icon';
350
+ return 'image/svg+xml';
351
+ }
352
+ function rewriteIndexHtml(indexPath, name, logoExt, faviconExt) {
308
353
  if (!fs.existsSync(indexPath))
309
354
  return;
310
355
  const html = fs.readFileSync(indexPath, 'utf8');
@@ -314,13 +359,22 @@ function rewriteIndexHtml(indexPath, name) {
314
359
  .replace(/>/g, '&gt;')
315
360
  .replace(/"/g, '&quot;');
316
361
  const withTitle = html.replace(/<title>[^<]*<\/title>/, `<title>${escaped}</title>`);
317
- // Add or replace data-qtype-repo-name on the root <html> tag.
318
- // Two branches so a re-run cleanly overwrites an existing attribute.
319
- const rewritten = /<html\b[^>]*\bdata-qtype-repo-name=/.test(withTitle)
320
- ? withTitle.replace(/(<html\b[^>]*\bdata-qtype-repo-name=)"[^"]*"/, `$1"${escaped}"`)
321
- : withTitle.replace(/<html\b/, `<html data-qtype-repo-name="${escaped}"`);
322
- if (rewritten !== html)
323
- fs.writeFileSync(indexPath, rewritten);
362
+ // Add or replace data-qtype-repo-name / data-qtype-logo-ext on the
363
+ // root <html> tag. Two branches per attribute so a re-run cleanly
364
+ // overwrites the existing value.
365
+ let out = withTitle;
366
+ out = /<html\b[^>]*\bdata-qtype-repo-name=/.test(out)
367
+ ? out.replace(/(<html\b[^>]*\bdata-qtype-repo-name=)"[^"]*"/, `$1"${escaped}"`)
368
+ : out.replace(/<html\b/, `<html data-qtype-repo-name="${escaped}"`);
369
+ out = /<html\b[^>]*\bdata-qtype-logo-ext=/.test(out)
370
+ ? out.replace(/(<html\b[^>]*\bdata-qtype-logo-ext=)"[^"]*"/, `$1"${logoExt}"`)
371
+ : out.replace(/<html\b/, `<html data-qtype-logo-ext="${logoExt}"`);
372
+ // Rewrite the favicon <link> to point at the resolved favicon file
373
+ // (SVG / PNG / ICO). Kept independent of the logo so the Header
374
+ // and the browser tab can carry different aspect ratios.
375
+ out = out.replace(/<link\s+rel="icon"[^>]*>/, `<link rel="icon" type="${extToMime(faviconExt)}" href="/favicon.${faviconExt}" />`);
376
+ if (out !== html)
377
+ fs.writeFileSync(indexPath, out);
324
378
  }
325
379
  async function obtainPassword() {
326
380
  const envPwd = process.env.QTYPE_PUBLISH_PASSWORD;
@@ -93,6 +93,20 @@ export async function repoNewAction(opts) {
93
93
  fs.mkdirSync(qtypeDir, { recursive: true });
94
94
  fs.writeFileSync(path.join(qtypeDir, 'config.json'), JSON.stringify({ repo: { lang: canonicalLang } }, null, 2) + '\n');
95
95
  console.log(` + .qtype/config.json`);
96
+ // 5b. Seed brand assets from the language template so users can
97
+ // rebrand by editing files in place. `publish build` picks them
98
+ // up over the defaults that ship in the view bundle.
99
+ // - logo.svg → Header brand mark
100
+ // - favicon.svg → browser tab icon
101
+ for (const rel of ['logo.svg', 'favicon.svg']) {
102
+ const src = path.join(templatesDir, '.qtype', 'assets', rel);
103
+ if (!fs.existsSync(src))
104
+ continue;
105
+ const dest = path.join(repoDir, '.qtype', 'assets', rel);
106
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
107
+ fs.copyFileSync(src, dest);
108
+ console.log(` + .qtype/assets/${rel}`);
109
+ }
96
110
  // 6. git init (optional; requires explicit --git)
97
111
  if (opts.git === true) {
98
112
  try {
@@ -66,6 +66,12 @@ export function applyHelpMetadata(cmd, mod) {
66
66
  if (mod.seeAlso && mod.seeAlso.length > 0) {
67
67
  cmd.addHelpText('after', renderSeeAlso(mod.seeAlso));
68
68
  }
69
+ // Free-form afterHelp — printed last, useful for reference lists.
70
+ if (mod.afterHelp) {
71
+ const extra = mod.afterHelp();
72
+ if (extra && extra.trim())
73
+ cmd.addHelpText('after', '\n' + extra);
74
+ }
69
75
  // Aliases
70
76
  if (mod.aliases && mod.aliases.length > 0) {
71
77
  for (const alias of mod.aliases)
@@ -5,48 +5,50 @@
5
5
  */
6
6
  import { enumValidator, boolValidator, positiveIntValidator, } from '../../store/index.js';
7
7
  export const bothSchema = {
8
- // Repo language. Drives:
9
- // • which template locale (examples/zh vs examples/en) new repos are
10
- // scaffolded from
11
- // • the web UI language — the reader is locked to repo.lang so the UI
12
- // matches the material's own language
13
- //
14
- // Values are BCP 47 codes (`zh-CN` / `en-US`); the short forms `zh` /
15
- // `en` are accepted for backwards compatibility with older `.qtype/
16
- // config.json` files and normalised at read time.
17
8
  'repo.lang': {
18
9
  validator: enumValidator(['zh-CN', 'en-US', 'zh', 'en']),
19
10
  default: 'en-US',
11
+ description: 'Repo (and its published web UI) language.',
12
+ options: ['zh-CN', 'en-US'],
20
13
  },
21
14
  'typing.theme': {
22
15
  validator: enumValidator(['mocha', 'latte']),
23
16
  default: 'mocha',
17
+ description: 'Web reader theme.',
18
+ options: ['mocha', 'latte'],
24
19
  },
25
20
  'typing.targetWpm': {
26
21
  validator: positiveIntValidator(),
22
+ description: 'Ghost-pacer target speed in WPM. 0 = disabled.',
27
23
  },
28
24
  'typing.tabBehavior': {
29
25
  validator: enumValidator(['reset', 'input', 'disabled']),
30
26
  default: 'reset',
27
+ description: 'What pressing Tab in the arena does.',
28
+ options: ['reset', 'input', 'disabled'],
31
29
  },
32
30
  'typing.hint': {
33
31
  validator: enumValidator(['none', 'ghost']),
34
32
  default: 'none',
33
+ description: 'Whether to render a faint ghost hint under the caret.',
34
+ options: ['none', 'ghost'],
35
35
  },
36
36
  'typing.showKeyboard': {
37
37
  validator: boolValidator(),
38
38
  default: true,
39
+ description: 'Show the on-screen keyboard beneath the arena.',
40
+ options: ['true', 'false'],
39
41
  },
40
42
  'typing.showHands': {
41
43
  validator: boolValidator(),
42
44
  default: true,
45
+ description: 'Show the hand overlay on the on-screen keyboard.',
46
+ options: ['true', 'false'],
43
47
  },
44
- // Mermaid pre-render mode. `prerender` (default) uses puppeteer at build
45
- // time to bake SVGs into dist/material/mermaid.json; `client` ships raw
46
- // ```mermaid fences and lets the browser render them; `off` treats mermaid
47
- // as plain code blocks.
48
48
  'publish.mermaid': {
49
49
  validator: enumValidator(['prerender', 'client', 'off']),
50
50
  default: 'prerender',
51
+ description: 'Mermaid diagram handling at `publish build` time.',
52
+ options: ['prerender', 'client', 'off'],
51
53
  },
52
54
  };
@@ -6,43 +6,35 @@ import { enumValidator, nonEmptyStringValidator, boolValidator, } from '../../st
6
6
  export const repoOnlySchema = {
7
7
  'repo.name': {
8
8
  validator: nonEmptyStringValidator(),
9
+ description: 'Repo display name (folder title, package.json name).',
9
10
  },
10
11
  // ── Password protection for `publish build` ─────────────
11
- // On/off toggle. When true, `publish build` reads the other three fields
12
- // and encrypts every material/*.md before writing to dist.
13
12
  'publish.password.enabled': {
14
13
  validator: boolValidator(),
15
14
  default: false,
15
+ description: 'Encrypt materials at `publish build` time.',
16
+ options: ['true', 'false'],
16
17
  },
17
- // Argon2id PHC-style hash of the plaintext password, used to verify the
18
- // password entered at build time. NOT the AES key — that's derived
19
- // separately via `publish.password.aesSalt`. Empty string when protection
20
- // is off; shape enforced by `publish password set` writing a well-formed
21
- // PHC string.
22
18
  'publish.password.hash': {
23
19
  validator: (v) => typeof v === 'string'
24
20
  ? null
25
21
  : { code: 'err.schema.nonEmptyString', fallback: 'must be a string' },
26
22
  default: '',
23
+ description: 'Argon2id PHC hash of the publish password (managed by `qtype publish password`).',
27
24
  },
28
- // Base64 salt used with argon2id (password → AES-256 key). Rotated only
29
- // when the user calls `publish password set` — stable across rebuilds so
30
- // the same password unlocks previously published sites.
31
25
  'publish.password.aesSalt': {
32
26
  validator: (v) => typeof v === 'string'
33
27
  ? null
34
28
  : { code: 'err.schema.nonEmptyString', fallback: 'must be a string' },
35
29
  default: '',
30
+ description: 'Base64 salt used to derive the AES key (managed by `qtype publish password`).',
36
31
  },
37
- // What to encrypt. `all` covers material/ + any future encrypted assets;
38
- // `material-only` limits encryption to material/*.md (same behavior in
39
- // v0 since qtype has no other content streams).
40
32
  'publish.password.scope': {
41
33
  validator: enumValidator(['all', 'material-only', 'selected']),
42
34
  default: 'all',
35
+ description: 'What to encrypt when password protection is on.',
36
+ options: ['all', 'material-only', 'selected'],
43
37
  },
44
- // When scope='selected', the list of file names (relative to material/)
45
- // to encrypt. Ignored otherwise.
46
38
  'publish.password.selected': {
47
39
  validator: (v) => Array.isArray(v) && v.every((x) => typeof x === 'string')
48
40
  ? null
@@ -51,5 +43,6 @@ export const repoOnlySchema = {
51
43
  fallback: 'must be an array of strings',
52
44
  },
53
45
  default: [],
46
+ description: 'File names to encrypt when scope is `selected` (JSON array).',
54
47
  },
55
48
  };
@@ -11,9 +11,13 @@ export const userOnlySchema = {
11
11
  'cli.lang': {
12
12
  validator: enumValidator(['zh-CN', 'en-US', 'zh', 'en']),
13
13
  default: 'en-US',
14
+ description: 'CLI display language.',
15
+ options: ['zh-CN', 'en-US'],
14
16
  },
15
17
  'cli.color': {
16
18
  validator: enumValidator(['always', 'never', 'auto']),
17
19
  default: 'auto',
20
+ description: 'Color output policy for CLI messages.',
21
+ options: ['always', 'never', 'auto'],
18
22
  },
19
23
  };
@@ -1,3 +1,3 @@
1
- const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["assets/index-DpS4p4Fg.js","assets/index-zP1tnFzO.js","assets/index-DB9GS9vg.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-zP1tnFzO.js";let o=null,c=null;async function L(){return o||c||(c=h(()=>import("./index-DpS4p4Fg.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-Dd26zdqJ.js","assets/index-Dzj2d3KO.js","assets/index-C59VhMlk.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-Dzj2d3KO.js";let o=null,c=null;async function L(){return o||c||(c=h(()=>import("./index-Dd26zdqJ.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};