@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,74 @@
1
+ /**
2
+ * `qtype publish init` action — scaffold .gitignore + optional GitHub Pages workflow.
3
+ *
4
+ * Files:
5
+ * - .gitignore (always)
6
+ * - .github/workflows/pages.yml (when --ci github)
7
+ *
8
+ * Both refuse to overwrite unless --force. Users don't want their
9
+ * hand-edited workflow silently clobbered when they re-run this after
10
+ * upgrading qtype.
11
+ */
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { error, success } from '../../../utils/output.js';
15
+ import { t } from '../../../i18n/index.js';
16
+ const GITIGNORE = `# qtype
17
+ dist/
18
+ node_modules/
19
+ *.tar.gz
20
+ .DS_Store
21
+ `;
22
+ const GITHUB_YML = `name: Deploy qtype site to Pages
23
+ on:
24
+ push:
25
+ branches: [main]
26
+ workflow_dispatch:
27
+ permissions:
28
+ contents: read
29
+ pages: write
30
+ id-token: write
31
+ concurrency:
32
+ group: pages
33
+ cancel-in-progress: true
34
+ jobs:
35
+ build:
36
+ runs-on: ubuntu-latest
37
+ steps:
38
+ - uses: actions/checkout@v4
39
+ - uses: actions/setup-node@v4
40
+ with:
41
+ node-version: 20
42
+ - run: npx -y @qnroa/qtype publish build --out dist
43
+ - uses: actions/upload-pages-artifact@v3
44
+ with:
45
+ path: dist
46
+ deploy:
47
+ needs: build
48
+ environment:
49
+ name: github-pages
50
+ url: \${{ steps.deployment.outputs.page_url }}
51
+ runs-on: ubuntu-latest
52
+ steps:
53
+ - id: deployment
54
+ uses: actions/deploy-pages@v4
55
+ `;
56
+ function writeIfMissing(target, content, force, cwd) {
57
+ if (fs.existsSync(target) && !force) {
58
+ error(t('err.file.exists', { path: path.relative(cwd, target) }));
59
+ return false;
60
+ }
61
+ fs.mkdirSync(path.dirname(target), { recursive: true });
62
+ fs.writeFileSync(target, content, 'utf8');
63
+ const rel = path.relative(cwd, target);
64
+ success(t('publish.init.msg.wrote', { path: rel }));
65
+ return true;
66
+ }
67
+ export async function publishInitAction(opts = {}) {
68
+ const cwd = opts.path ? path.resolve(opts.path) : process.cwd();
69
+ writeIfMissing(path.join(cwd, '.gitignore'), GITIGNORE, opts.force ?? false, cwd);
70
+ if (opts.ci === 'github') {
71
+ writeIfMissing(path.join(cwd, '.github', 'workflows', 'pages.yml'), GITHUB_YML, opts.force ?? false, cwd);
72
+ }
73
+ success(t('publish.init.msg.done'));
74
+ }
@@ -0,0 +1,22 @@
1
+ export const messages = {
2
+ 'zh-CN': {
3
+ 'publish.init.summary': '写入 .gitignore 与 GitHub Pages 工作流',
4
+ 'publish.init.description': '给当前资料仓库添加 .gitignore 与 .github/workflows/pages.yml,方便一键部署到 GitHub Pages',
5
+ 'publish.init.arg.path': '仓库路径(默认当前目录)',
6
+ 'publish.init.opt.ci': 'CI 平台(目前仅 github)',
7
+ 'publish.init.example.basic': '只写 .gitignore',
8
+ 'publish.init.example.github': '带 GitHub Pages 部署',
9
+ 'publish.init.msg.wrote': '写入 {path}',
10
+ 'publish.init.msg.done': '发布配置已就绪。git commit + push 后由 Actions 部署。',
11
+ },
12
+ 'en-US': {
13
+ 'publish.init.summary': 'Write .gitignore and a GitHub Pages workflow',
14
+ 'publish.init.description': 'Add .gitignore and .github/workflows/pages.yml so this repo deploys to GitHub Pages',
15
+ 'publish.init.arg.path': 'Repo path (default: current directory)',
16
+ 'publish.init.opt.ci': 'CI platform (currently github only)',
17
+ 'publish.init.example.basic': 'gitignore only',
18
+ 'publish.init.example.github': 'With GitHub Pages deploy',
19
+ 'publish.init.msg.wrote': 'Wrote {path}',
20
+ 'publish.init.msg.done': 'Publish config ready. Commit + push and Actions will deploy.',
21
+ },
22
+ };
@@ -0,0 +1,26 @@
1
+ import { t } from '../../../i18n/index.js';
2
+ import { messages } from './i18n.js';
3
+ import { publishInitAction } from './action.js';
4
+ export const publishInitCmd = {
5
+ name: 'init',
6
+ messages,
7
+ summary: () => t('publish.init.summary'),
8
+ describe: () => t('publish.init.description'),
9
+ args: [{ flag: '[path]', describe: () => t('publish.init.arg.path') }],
10
+ options: [
11
+ {
12
+ flags: '--ci <platform>',
13
+ describe: () => t('publish.init.opt.ci'),
14
+ choices: ['github'],
15
+ },
16
+ { flags: '--force', describe: () => t('opt.force') },
17
+ ],
18
+ examples: () => [
19
+ { command: 'qtype publish init', describe: () => t('publish.init.example.basic') },
20
+ { command: 'qtype publish init --ci github', describe: () => t('publish.init.example.github') },
21
+ ],
22
+ seeAlso: ['publish build', 'publish serve'],
23
+ action: async (pathArg, opts) => {
24
+ await publishInitAction({ path: pathArg, ci: opts.ci, force: opts.force });
25
+ },
26
+ };
@@ -0,0 +1,158 @@
1
+ /**
2
+ * Implementation of `qtype publish password set|clear|status`.
3
+ *
4
+ * Goes through `repoKv` (same store as `qtype config`), so it's identical
5
+ * to a direct `config set`:
6
+ * - Same `.qtype/config.json`
7
+ * - Shares JsonStorage's atomic writes + key sorting
8
+ * - Avoids ordering-jitter git diffs when the two commands alternate
9
+ *
10
+ * Hashing: argon2id via @noble/hashes (pure JS, no native build).
11
+ * Stored format:
12
+ * `argon2id$v=19$m=<memory>,t=<iter>,p=<para>$<saltB64>$<hashB64>`
13
+ * Modeled on the PHC string format so future tooling can read it.
14
+ */
15
+ import { randomBytes } from 'node:crypto';
16
+ import { argon2id } from '@noble/hashes/argon2.js';
17
+ import { generateAesSalt } from '../../../../core/encrypt.js';
18
+ import { repoKv as defaultRepoKv, createRepoKv } from '../../../bootstrap.js';
19
+ import { error, success, info } from '../../../utils/output.js';
20
+ import { promptPassword } from '../../../utils/prompt.js';
21
+ import { t } from '../../../i18n/index.js';
22
+ // Argon2id parameters — interactive-login profile per OWASP guidance:
23
+ // 64 MiB memory, 3 iterations, 1 lane, 32-byte output.
24
+ const ARGON2_MEMORY = 64 * 1024; // 64 MiB
25
+ const ARGON2_TIME = 3;
26
+ const ARGON2_PARALLEL = 1;
27
+ const ARGON2_KEY_LEN = 32;
28
+ const ARGON2_SALT_LEN = 16;
29
+ function toB64(u8) {
30
+ return Buffer.from(u8).toString('base64');
31
+ }
32
+ function fromB64(s) {
33
+ return new Uint8Array(Buffer.from(s, 'base64'));
34
+ }
35
+ /** Hash a plaintext password with argon2id, return PHC-style storage string. */
36
+ function hashPassword(plaintext) {
37
+ const salt = randomBytes(ARGON2_SALT_LEN);
38
+ const hash = argon2id(plaintext, salt, {
39
+ t: ARGON2_TIME,
40
+ m: ARGON2_MEMORY,
41
+ p: ARGON2_PARALLEL,
42
+ dkLen: ARGON2_KEY_LEN,
43
+ });
44
+ return [
45
+ 'argon2id',
46
+ 'v=19',
47
+ `m=${ARGON2_MEMORY},t=${ARGON2_TIME},p=${ARGON2_PARALLEL}`,
48
+ toB64(salt),
49
+ toB64(hash),
50
+ ].join('$');
51
+ }
52
+ /**
53
+ * Verify a plaintext against a stored PHC string. Exported (rather than
54
+ * just exercised in `set`) because `publish build` needs it to check the
55
+ * password before deriving the AES key.
56
+ */
57
+ export function verifyPassword(plaintext, stored) {
58
+ try {
59
+ const parts = stored.split('$');
60
+ if (parts.length !== 5 || parts[0] !== 'argon2id')
61
+ return false;
62
+ // parts[1]=v=19, parts[2]=m=…,t=…,p=…
63
+ const params = Object.fromEntries(parts[2].split(',').map((kv) => {
64
+ const [k, v] = kv.split('=');
65
+ return [k, Number(v)];
66
+ }));
67
+ const salt = fromB64(parts[3]);
68
+ const expected = fromB64(parts[4]);
69
+ const got = argon2id(plaintext, salt, {
70
+ t: params.t,
71
+ m: params.m,
72
+ p: params.p,
73
+ dkLen: expected.length,
74
+ });
75
+ if (got.length !== expected.length)
76
+ return false;
77
+ let diff = 0;
78
+ for (let i = 0; i < got.length; i++)
79
+ diff |= got[i] ^ expected[i];
80
+ return diff === 0;
81
+ }
82
+ catch {
83
+ return false;
84
+ }
85
+ }
86
+ /**
87
+ * Resolve the KvStore to operate on:
88
+ * - With a `path` argument → build a fresh one bound to that root
89
+ * - Without → use the default cwd-derived singleton
90
+ */
91
+ function resolveRepoKv(repoPath) {
92
+ return repoPath ? createRepoKv(repoPath) : defaultRepoKv;
93
+ }
94
+ async function ensureRepo(kv) {
95
+ if (await kv.available())
96
+ return true;
97
+ error(t('err.file.notFound', { path: '.qtype/config.json' }));
98
+ console.error(` ${t('msg.err.hint')} ${t('hint.publish.initFirst')}`);
99
+ process.exitCode = 1;
100
+ return false;
101
+ }
102
+ export async function passwordSetAction(repoPath) {
103
+ const kv = resolveRepoKv(repoPath);
104
+ if (!(await ensureRepo(kv)))
105
+ return;
106
+ const pwd = await promptPassword(t('publish.password.msg.prompt'));
107
+ if (!pwd) {
108
+ error(t('publish.password.msg.empty'));
109
+ return;
110
+ }
111
+ // Argon2id with a fresh salt. ~300ms on a modern laptop at these params —
112
+ // right ballpark for an interactive login: slow enough to throttle
113
+ // brute-force, fast enough that the user doesn't notice.
114
+ const token = hashPassword(pwd);
115
+ await kv.set('publish.password.enabled', true);
116
+ await kv.set('publish.password.hash', token);
117
+ // A fresh AES salt is generated once here and reused by every subsequent
118
+ // `publish build`. Rotating it would invalidate previously-built sites
119
+ // that were unlocked with the old salt-derived key — since the same
120
+ // password + same salt always derives the same AES key, this stays
121
+ // stable across rebuilds without needing a stored key file.
122
+ await kv.set('publish.password.aesSalt', generateAesSalt());
123
+ // When scope / selected are unset, fall back to schema defaults.
124
+ if (!(await kv.has('publish.password.scope'))) {
125
+ await kv.set('publish.password.scope', 'all');
126
+ }
127
+ if (!(await kv.has('publish.password.selected'))) {
128
+ await kv.set('publish.password.selected', []);
129
+ }
130
+ success(t('publish.password.msg.setDone'));
131
+ console.log(` ${t('publish.password.msg.setEffective')}`);
132
+ }
133
+ export async function passwordClearAction(repoPath) {
134
+ const kv = resolveRepoKv(repoPath);
135
+ if (!(await ensureRepo(kv)))
136
+ return;
137
+ await kv.set('publish.password.enabled', false);
138
+ await kv.set('publish.password.hash', '');
139
+ await kv.set('publish.password.aesSalt', '');
140
+ success(t('publish.password.msg.clearDone'));
141
+ }
142
+ export async function passwordStatusAction(repoPath) {
143
+ const kv = resolveRepoKv(repoPath);
144
+ if (!(await kv.available())) {
145
+ info(t('publish.password.msg.uninitialized'));
146
+ return;
147
+ }
148
+ const hasEnabled = await kv.has('publish.password.enabled');
149
+ if (!hasEnabled) {
150
+ info(t('publish.password.msg.unset'));
151
+ return;
152
+ }
153
+ const enabled = await kv.get('publish.password.enabled');
154
+ if (enabled === true)
155
+ success(t('publish.password.msg.enabled'));
156
+ else
157
+ info(t('publish.password.msg.disabled'));
158
+ }
@@ -0,0 +1,44 @@
1
+ export const messages = {
2
+ 'zh-CN': {
3
+ 'publish.password.summary': '密码保护',
4
+ 'publish.password.description': '密码保护:set / clear / status',
5
+ 'publish.password.set.summary': '设置或修改密码',
6
+ 'publish.password.set.description': '设置访问密码',
7
+ 'publish.password.clear.summary': '移除密码',
8
+ 'publish.password.clear.description': '移除密码保护',
9
+ 'publish.password.status.summary': '查看状态',
10
+ 'publish.password.status.description': '显示当前密码保护状态',
11
+ 'publish.password.arg.path': '仓库路径(默认当前目录)',
12
+ // runtime messages
13
+ 'publish.password.msg.prompt': '输入新密码: ',
14
+ 'publish.password.msg.empty': '密码为空,已取消',
15
+ 'publish.password.msg.setDone': '密码已设置',
16
+ 'publish.password.msg.setEffective': '下次 `qtype publish build` 后生效',
17
+ 'publish.password.msg.clearDone': '密码已清除',
18
+ 'publish.password.msg.uninitialized': '未初始化(.qtype/config.json 不存在)',
19
+ 'publish.password.msg.unset': '未设置密码',
20
+ 'publish.password.msg.enabled': '已启用密码保护',
21
+ 'publish.password.msg.disabled': '未启用',
22
+ },
23
+ 'en-US': {
24
+ 'publish.password.summary': 'Password protection',
25
+ 'publish.password.description': 'Password protection: set / clear / status',
26
+ 'publish.password.set.summary': 'Set or change password',
27
+ 'publish.password.set.description': 'Set access password',
28
+ 'publish.password.clear.summary': 'Clear password',
29
+ 'publish.password.clear.description': 'Remove password protection',
30
+ 'publish.password.status.summary': 'Show status',
31
+ 'publish.password.status.description': 'Show current password protection status',
32
+ 'publish.password.arg.path': 'Repo path (default: current directory)',
33
+ // runtime messages
34
+ 'publish.password.msg.prompt': 'New password: ',
35
+ 'publish.password.msg.empty': 'Empty password; cancelled',
36
+ 'publish.password.msg.setDone': 'Password set',
37
+ 'publish.password.msg.setEffective': 'Takes effect on next `qtype publish build`',
38
+ 'publish.password.msg.clearDone': 'Password cleared',
39
+ 'publish.password.msg.uninitialized': 'Not initialized (.qtype/config.json not found)',
40
+ 'publish.password.msg.unset': 'No password set',
41
+ 'publish.password.msg.enabled': 'Password protection enabled',
42
+ 'publish.password.msg.disabled': 'Disabled',
43
+ },
44
+ };
@@ -0,0 +1,35 @@
1
+ import { t } from '../../../i18n/index.js';
2
+ import { messages } from './i18n.js';
3
+ import { passwordSetAction, passwordClearAction, passwordStatusAction, } from './action.js';
4
+ const pathArg = {
5
+ flag: '[path]',
6
+ describe: () => t('publish.password.arg.path'),
7
+ };
8
+ const setCmd = {
9
+ name: 'set',
10
+ summary: () => t('publish.password.set.summary'),
11
+ describe: () => t('publish.password.set.description'),
12
+ args: [pathArg],
13
+ action: passwordSetAction,
14
+ };
15
+ const clearCmd = {
16
+ name: 'clear',
17
+ summary: () => t('publish.password.clear.summary'),
18
+ describe: () => t('publish.password.clear.description'),
19
+ args: [pathArg],
20
+ action: passwordClearAction,
21
+ };
22
+ const statusCmd = {
23
+ name: 'status',
24
+ summary: () => t('publish.password.status.summary'),
25
+ describe: () => t('publish.password.status.description'),
26
+ args: [pathArg],
27
+ action: passwordStatusAction,
28
+ };
29
+ export const publishPasswordCmd = {
30
+ name: 'password',
31
+ messages,
32
+ summary: () => t('publish.password.summary'),
33
+ describe: () => t('publish.password.description'),
34
+ subcommands: [setCmd, clearCmd, statusCmd],
35
+ };
@@ -0,0 +1,117 @@
1
+ /**
2
+ * `qtype publish serve` action — HTTP-serve a built dist/ directory.
3
+ *
4
+ * Static, read-only file server with:
5
+ * - MIME lookup for the file types the built site emits
6
+ * - SPA fallback: unknown extensionless routes fall back to index.html so
7
+ * client-side routing works when a user opens a deep link directly
8
+ * - Structured error handling for EADDRINUSE / EACCES so the operator
9
+ * sees a one-line message instead of a stack trace
10
+ */
11
+ import fs from 'node:fs';
12
+ import http from 'node:http';
13
+ import path from 'node:path';
14
+ import { error, success } from '../../../utils/output.js';
15
+ import { t } from '../../../i18n/index.js';
16
+ const MIME = {
17
+ '.html': 'text/html; charset=utf-8',
18
+ '.css': 'text/css; charset=utf-8',
19
+ '.js': 'application/javascript; charset=utf-8',
20
+ '.json': 'application/json; charset=utf-8',
21
+ '.md': 'text/markdown; charset=utf-8',
22
+ '.svg': 'image/svg+xml',
23
+ '.png': 'image/png',
24
+ '.jpg': 'image/jpeg',
25
+ '.jpeg': 'image/jpeg',
26
+ '.gif': 'image/gif',
27
+ '.webp': 'image/webp',
28
+ '.ico': 'image/x-icon',
29
+ '.txt': 'text/plain; charset=utf-8',
30
+ '.woff': 'font/woff',
31
+ '.woff2': 'font/woff2',
32
+ '.map': 'application/json; charset=utf-8',
33
+ };
34
+ export async function publishServeAction(opts = {}) {
35
+ const port = opts.port ?? 4000;
36
+ const dir = opts.dir
37
+ ? path.resolve(opts.dir)
38
+ : path.resolve(process.cwd(), 'dist');
39
+ if (!fs.existsSync(dir)) {
40
+ error(t('err.dir.notExists', { path: dir }));
41
+ console.error(` ${t('msg.err.hint')} ${t('hint.serve.buildFirst')}`);
42
+ process.exitCode = 1;
43
+ return;
44
+ }
45
+ const server = http.createServer((req, res) => {
46
+ const parsed = new URL(req.url || '/', 'http://localhost');
47
+ const decoded = decodeURIComponent(parsed.pathname);
48
+ const safe = path.posix.normalize(decoded).replace(/^(\.\.[/\\])+/, '');
49
+ let file = path.join(dir, safe);
50
+ try {
51
+ const stat = fs.statSync(file);
52
+ if (stat.isDirectory())
53
+ file = path.join(file, 'index.html');
54
+ }
55
+ catch {
56
+ // fall through to 404 / SPA fallback
57
+ }
58
+ fs.readFile(file, (err, data) => {
59
+ if (err) {
60
+ // SPA fallback for GET requests. Real static-resource paths
61
+ // (`/material/*`, `/assets/*`) must 404 honestly — the client
62
+ // relies on those failures to detect missing content. Everything
63
+ // else (typing routes like `/typing/foo.md`, unknown deep links)
64
+ // falls back to index.html so client-side routing can take over.
65
+ const rp = safe; // decoded, normalised URL pathname
66
+ const isReservedAsset = rp === '/index.html' ||
67
+ rp.startsWith('/material/') ||
68
+ rp.startsWith('/assets/');
69
+ if (!isReservedAsset && req.method === 'GET') {
70
+ fs.readFile(path.join(dir, 'index.html'), (e2, d2) => {
71
+ if (e2) {
72
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
73
+ res.end('404 Not Found');
74
+ }
75
+ else {
76
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
77
+ res.end(d2);
78
+ }
79
+ });
80
+ return;
81
+ }
82
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
83
+ res.end('404 Not Found');
84
+ return;
85
+ }
86
+ const ext = path.extname(file).toLowerCase();
87
+ res.writeHead(200, {
88
+ 'Content-Type': MIME[ext] || 'application/octet-stream',
89
+ 'Cache-Control': 'no-cache',
90
+ });
91
+ res.end(data);
92
+ });
93
+ });
94
+ server.on('error', (err) => {
95
+ if (err.code === 'EADDRINUSE') {
96
+ error(t('publish.serve.err.portInUse', { port }));
97
+ console.error(` ${t('msg.err.hint')} ${t('publish.serve.hint.portInUse')}`);
98
+ }
99
+ else if (err.code === 'EACCES') {
100
+ error(t('publish.serve.err.portDenied', { port }));
101
+ console.error(` ${t('msg.err.hint')} ${t('publish.serve.hint.portDenied')}`);
102
+ }
103
+ else {
104
+ error(t('publish.serve.err.listenFailed', {
105
+ port,
106
+ code: err.code ?? '',
107
+ message: err.message,
108
+ }));
109
+ }
110
+ process.exitCode = 1;
111
+ });
112
+ server.listen(port, () => {
113
+ success(t('publish.serve.msg.listening', { port }));
114
+ console.log(` ${t('publish.serve.msg.dir', { dir })}`);
115
+ console.log(` ${t('publish.serve.msg.stopHint')}`);
116
+ });
117
+ }
@@ -0,0 +1,32 @@
1
+ export const messages = {
2
+ 'zh-CN': {
3
+ 'publish.serve.summary': '预览已构建的资料站',
4
+ 'publish.serve.description': '本地 HTTP 服务器预览 publish build 的产物(静态只读),部署前 debug 用',
5
+ 'publish.serve.arg.dir': '要服务的目录(默认 ./dist)',
6
+ 'publish.serve.example.basic': '默认端口预览',
7
+ 'publish.serve.example.port': '自定义端口',
8
+ 'publish.serve.msg.listening': 'QTYPE 站点服务中 http://localhost:{port}/',
9
+ 'publish.serve.msg.dir': '目录: {dir}',
10
+ 'publish.serve.msg.stopHint': '按 Ctrl+C 停止',
11
+ 'publish.serve.err.portInUse': '端口 {port} 已被占用',
12
+ 'publish.serve.hint.portInUse': '换个端口试试:qtype publish serve -p <port>',
13
+ 'publish.serve.err.portDenied': '端口 {port} 无权限(<1024 需 sudo)',
14
+ 'publish.serve.hint.portDenied': '换 1024 以上的端口,如 -p 4000',
15
+ 'publish.serve.err.listenFailed': '监听 {port} 失败 [{code}]: {message}',
16
+ },
17
+ 'en-US': {
18
+ 'publish.serve.summary': 'Preview the built site',
19
+ 'publish.serve.description': 'Local HTTP server for publish-build output (read-only), for pre-deploy debug',
20
+ 'publish.serve.arg.dir': 'Directory to serve (default: ./dist)',
21
+ 'publish.serve.example.basic': 'Default port',
22
+ 'publish.serve.example.port': 'Custom port',
23
+ 'publish.serve.msg.listening': 'QTYPE site serving at http://localhost:{port}/',
24
+ 'publish.serve.msg.dir': 'Directory: {dir}',
25
+ 'publish.serve.msg.stopHint': 'Press Ctrl+C to stop',
26
+ 'publish.serve.err.portInUse': 'Port {port} is already in use',
27
+ 'publish.serve.hint.portInUse': 'Try another port: qtype publish serve -p <port>',
28
+ 'publish.serve.err.portDenied': 'Permission denied for port {port} (<1024 requires sudo)',
29
+ 'publish.serve.hint.portDenied': 'Use a port ≥ 1024, e.g. -p 4000',
30
+ 'publish.serve.err.listenFailed': 'Failed to listen on {port} [{code}]: {message}',
31
+ },
32
+ };
@@ -0,0 +1,37 @@
1
+ /**
2
+ * `qtype publish serve` command module.
3
+ */
4
+ import { InvalidArgumentError } from 'commander';
5
+ import { t } from '../../../i18n/index.js';
6
+ import { messages } from './i18n.js';
7
+ import { publishServeAction } from './action.js';
8
+ function parsePort(value) {
9
+ const n = parseInt(value, 10);
10
+ if (Number.isNaN(n) || n <= 0 || n > 65535) {
11
+ throw new InvalidArgumentError(t('msg.port.invalid'));
12
+ }
13
+ return n;
14
+ }
15
+ export const publishServeCmd = {
16
+ name: 'serve',
17
+ messages,
18
+ summary: () => t('publish.serve.summary'),
19
+ describe: () => t('publish.serve.description'),
20
+ args: [{ flag: '[dir]', describe: () => t('publish.serve.arg.dir') }],
21
+ options: [
22
+ {
23
+ flags: '-p, --port <port>',
24
+ describe: () => t('opt.port'),
25
+ defaultValue: 4000,
26
+ parser: parsePort,
27
+ },
28
+ ],
29
+ examples: () => [
30
+ { command: 'qtype publish serve', describe: () => t('publish.serve.example.basic') },
31
+ { command: 'qtype publish serve -p 8080', describe: () => t('publish.serve.example.port') },
32
+ ],
33
+ seeAlso: ['publish build'],
34
+ action: async (dir, opts) => {
35
+ await publishServeAction({ port: opts.port, dir });
36
+ },
37
+ };
@@ -0,0 +1,19 @@
1
+ import { t } from '../../i18n/index.js';
2
+ import { repoNewCmd } from './new/index.js';
3
+ import { repoSyncCmd } from './sync/index.js';
4
+ export const repoCmd = {
5
+ name: 'repo',
6
+ summary: () => t('repo.summary'),
7
+ describe: () => t('repo.description'),
8
+ messages: {
9
+ 'zh-CN': {
10
+ 'repo.summary': '仓库管理',
11
+ 'repo.description': '管理 qtype 资料仓库:新建 / 补齐缺失文件',
12
+ },
13
+ 'en-US': {
14
+ 'repo.summary': 'Repository management',
15
+ 'repo.description': 'Manage qtype material repositories: create new / sync missing files',
16
+ },
17
+ },
18
+ subcommands: [repoNewCmd, repoSyncCmd],
19
+ };
@@ -0,0 +1,98 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { execSync } from 'node:child_process';
4
+ import { getTemplatesDir, readTemplate } from '../templates.js';
5
+ import { info, success, warn, fatal } from '../../../utils/output.js';
6
+ import { t } from '../../../i18n/index.js';
7
+ export async function repoNewAction(opts) {
8
+ // Validate the repo name. Path separators, '.' / '..' are all rejected
9
+ // — they would either silently create nested dirs the user didn't mean
10
+ // or refer to an existing directory rather than creating a new one.
11
+ if (opts.name.length === 0 ||
12
+ opts.name === '.' ||
13
+ opts.name === '..' ||
14
+ /[/\\]/.test(opts.name)) {
15
+ fatal(t('err.repo.invalidName', { name: opts.name }));
16
+ }
17
+ // Reduce BCP 47 / short-code inputs to the two-letter short form used
18
+ // by template and content directories.
19
+ const shortLang = opts.lang === 'zh' || opts.lang === 'zh-CN' ? 'zh' : 'en';
20
+ // Canonical BCP 47 form used in .qtype/config.json (repo.lang) so the
21
+ // web reader can pick it up directly without further normalisation.
22
+ const canonicalLang = shortLang === 'zh' ? 'zh-CN' : 'en-US';
23
+ // Retained under the old name so the rest of this file (which uses
24
+ // `lang` for directory lookups) keeps working unchanged.
25
+ const lang = shortLang;
26
+ const qtypeFileName = 'QTYPE.md';
27
+ const parentDir = opts.dir ?? process.cwd();
28
+ const repoDir = path.join(parentDir, opts.name);
29
+ const today = new Date().toISOString().slice(0, 10);
30
+ if (fs.existsSync(repoDir)) {
31
+ const entries = fs.readdirSync(repoDir);
32
+ if (entries.length > 0) {
33
+ fatal(t('err.dir.notEmpty', { path: repoDir }), t('hint.sync.dirExists'));
34
+ }
35
+ }
36
+ else {
37
+ fs.mkdirSync(repoDir, { recursive: true });
38
+ }
39
+ info(t('repo.new.msg.creating', { path: repoDir }));
40
+ const templatesDir = getTemplatesDir(lang);
41
+ const vars = {
42
+ name: opts.name,
43
+ description: opts.description || t('repo.new.tpl.defaultDescription'),
44
+ date: today,
45
+ };
46
+ // 1. Copy QTYPE.md from the language-specific template directory
47
+ fs.writeFileSync(path.join(repoDir, qtypeFileName), readTemplate(templatesDir, 'QTYPE.md', vars));
48
+ console.log(` + ${qtypeFileName}`);
49
+ // 2. Create AGENTS/CLAUDE/GEMINI symlinks → QTYPE.md
50
+ for (const alias of ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md']) {
51
+ try {
52
+ fs.symlinkSync(qtypeFileName, path.join(repoDir, alias));
53
+ console.log(` + ${alias} → ${qtypeFileName}`);
54
+ }
55
+ catch {
56
+ fs.copyFileSync(path.join(repoDir, qtypeFileName), path.join(repoDir, alias));
57
+ console.log(` + ${t('repo.new.msg.symlinkFallback', { alias })}`);
58
+ }
59
+ }
60
+ // 3. Copy the other localized template files
61
+ for (const fileName of ['README.md', 'TODO.md', 'index.md', 'log.md']) {
62
+ fs.writeFileSync(path.join(repoDir, fileName), readTemplate(templatesDir, fileName, vars));
63
+ console.log(` + ${fileName}`);
64
+ }
65
+ // 4. Seed material/example.md and empty .gitkeep so the directory exists
66
+ // but stays trivially prunable if the user wipes the seed.
67
+ fs.mkdirSync(path.join(repoDir, 'material'), { recursive: true });
68
+ fs.writeFileSync(path.join(repoDir, 'material', 'example.md'), readTemplate(templatesDir, path.join('material', 'example.md'), vars));
69
+ console.log(` + material/example.md`);
70
+ fs.writeFileSync(path.join(repoDir, 'material', '.gitkeep'), '');
71
+ console.log(` + material/.gitkeep`);
72
+ // 5. Write .qtype/config.json with the repo language.
73
+ const qtypeDir = path.join(repoDir, '.qtype');
74
+ fs.mkdirSync(qtypeDir, { recursive: true });
75
+ fs.writeFileSync(path.join(qtypeDir, 'config.json'), JSON.stringify({ repo: { lang: canonicalLang } }, null, 2) + '\n');
76
+ console.log(` + .qtype/config.json`);
77
+ // 6. git init (optional; requires explicit --git)
78
+ if (opts.git === true) {
79
+ try {
80
+ execSync('git init -b main', { cwd: repoDir, stdio: 'ignore' });
81
+ console.log(` + ${t('repo.new.msg.gitInited')}`);
82
+ }
83
+ catch (err) {
84
+ warn(t('repo.new.msg.gitInitFailed', { reason: err.message }));
85
+ }
86
+ }
87
+ console.log('');
88
+ success(t('repo.new.msg.done', { path: repoDir }));
89
+ console.log(`\n${t('repo.new.msg.nextSteps')}`);
90
+ console.log(` ${t('repo.new.msg.next.cd', { name: opts.name })}`);
91
+ console.log(` ${t('repo.new.msg.next.edit')}`);
92
+ if (opts.git === true) {
93
+ console.log(` ${t('repo.new.msg.next.commit')}`);
94
+ }
95
+ else {
96
+ console.log(` ${t('repo.new.msg.next.gitHint')}`);
97
+ }
98
+ }