@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.
- package/CHANGELOG.md +97 -0
- package/CHANGELOG.zh.md +82 -0
- package/LICENSE +201 -0
- package/README.md +113 -0
- package/README.zh.md +114 -0
- package/dist/cli/bootstrap.js +190 -0
- package/dist/cli/commands/check/action.js +56 -0
- package/dist/cli/commands/check/i18n.js +26 -0
- package/dist/cli/commands/check/index.js +20 -0
- package/dist/cli/commands/config/action.js +236 -0
- package/dist/cli/commands/config/i18n.js +64 -0
- package/dist/cli/commands/config/index.js +72 -0
- package/dist/cli/commands/content/index.js +22 -0
- package/dist/cli/commands/content/lint/action.js +102 -0
- package/dist/cli/commands/content/lint/i18n.js +32 -0
- package/dist/cli/commands/content/lint/index.js +19 -0
- package/dist/cli/commands/content/search/action.js +64 -0
- package/dist/cli/commands/content/search/i18n.js +20 -0
- package/dist/cli/commands/content/search/index.js +24 -0
- package/dist/cli/commands/content/validate/action.js +145 -0
- package/dist/cli/commands/content/validate/i18n.js +32 -0
- package/dist/cli/commands/content/validate/index.js +19 -0
- package/dist/cli/commands/index.js +19 -0
- package/dist/cli/commands/init/index.js +53 -0
- package/dist/cli/commands/pack/i18n.js +20 -0
- package/dist/cli/commands/pack/index.js +48 -0
- package/dist/cli/commands/plugin/i18n.js +28 -0
- package/dist/cli/commands/plugin/index.js +41 -0
- package/dist/cli/commands/publish/build/action.js +208 -0
- package/dist/cli/commands/publish/build/i18n.js +50 -0
- package/dist/cli/commands/publish/build/index.js +24 -0
- package/dist/cli/commands/publish/build/mermaidPass.js +93 -0
- package/dist/cli/commands/publish/build/mermaidPrerender.js +199 -0
- package/dist/cli/commands/publish/index.js +21 -0
- package/dist/cli/commands/publish/init/action.js +74 -0
- package/dist/cli/commands/publish/init/i18n.js +22 -0
- package/dist/cli/commands/publish/init/index.js +26 -0
- package/dist/cli/commands/publish/password/action.js +158 -0
- package/dist/cli/commands/publish/password/i18n.js +44 -0
- package/dist/cli/commands/publish/password/index.js +35 -0
- package/dist/cli/commands/publish/serve/action.js +117 -0
- package/dist/cli/commands/publish/serve/i18n.js +32 -0
- package/dist/cli/commands/publish/serve/index.js +37 -0
- package/dist/cli/commands/repo/index.js +19 -0
- package/dist/cli/commands/repo/new/action.js +98 -0
- package/dist/cli/commands/repo/new/i18n.js +46 -0
- package/dist/cli/commands/repo/new/index.js +57 -0
- package/dist/cli/commands/repo/sync/action.js +202 -0
- package/dist/cli/commands/repo/sync/i18n.js +44 -0
- package/dist/cli/commands/repo/sync/index.js +47 -0
- package/dist/cli/commands/repo/templates.js +87 -0
- package/dist/cli/i18n/index.js +141 -0
- package/dist/cli/i18n/shared.js +142 -0
- package/dist/cli/index.js +79 -0
- package/dist/cli/registry/command.js +1 -0
- package/dist/cli/registry/commander-i18n.js +76 -0
- package/dist/cli/registry/help.js +142 -0
- package/dist/cli/registry/mount.js +71 -0
- package/dist/cli/utils/output.js +92 -0
- package/dist/cli/utils/paths.js +13 -0
- package/dist/cli/utils/prompt.js +72 -0
- package/dist/cli/utils/repoGuard.js +41 -0
- package/dist/core/encrypt.js +113 -0
- package/dist/core/index.js +8 -0
- package/dist/core/keystroke/asciiMap.js +39 -0
- package/dist/core/keystroke/chineseMap.js +32 -0
- package/dist/core/keystroke/fullwidthMap.js +41 -0
- package/dist/core/keystroke/index.js +22 -0
- package/dist/core/keystroke/textNormalize.js +68 -0
- package/dist/core/keystroke/types.js +1 -0
- package/dist/core/parser/frontmatter.js +58 -0
- package/dist/core/parser/index.js +2 -0
- package/dist/core/parser/parseMaterial.js +42 -0
- package/dist/core/types.js +1 -0
- package/dist/core/typing/engine.js +112 -0
- package/dist/core/typing/index.js +2 -0
- package/dist/core/typing/metrics.js +20 -0
- package/dist/store/index.js +44 -0
- package/dist/store/kv.js +276 -0
- package/dist/store/storage/browser.js +61 -0
- package/dist/store/storage/json.js +113 -0
- package/dist/store/storage/memory.js +24 -0
- package/dist/store/types.js +47 -0
- package/dist/store/validators.js +71 -0
- package/dist/view/assets/index-DBABH3dG.js +111 -0
- package/dist/view/assets/index-RTLZ-zOC.css +1 -0
- package/dist/view/index.html +14 -0
- package/dist/view/qtype-logo.svg +49 -0
- package/dist/view/qtype-mark.svg +21 -0
- package/docs/README.md +23 -0
- package/docs/README.zh.md +20 -0
- package/docs/en/authoring.md +138 -0
- package/docs/en/cli-reference.md +171 -0
- package/docs/zh/authoring.md +131 -0
- package/docs/zh/cli-reference.md +162 -0
- package/package.json +85 -0
- package/templates/en/QTYPE.md +618 -0
- package/templates/en/README.md +64 -0
- package/templates/en/TODO.md +7 -0
- package/templates/en/index.md +7 -0
- package/templates/en/log.md +3 -0
- package/templates/en/material/.gitkeep +0 -0
- package/templates/en/material/example.md +47 -0
- package/templates/zh/QTYPE.md +618 -0
- package/templates/zh/README.md +64 -0
- package/templates/zh/TODO.md +7 -0
- package/templates/zh/index.md +7 -0
- package/templates/zh/log.md +3 -0
- package/templates/zh/material/.gitkeep +0 -0
- package/templates/zh/material/example.md +47 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export const messages = {
|
|
2
|
+
'zh-CN': {
|
|
3
|
+
'repo.new.summary': '新建仓库',
|
|
4
|
+
'repo.new.description': '在空目录创建一个 qtype 资料仓库(含 QTYPE.md、AGENTS/CLAUDE/GEMINI 软链、README、material/ 骨架)',
|
|
5
|
+
'repo.new.arg.name': '仓库名(作为目录名)',
|
|
6
|
+
'repo.new.opt.description': 'README 一行简介',
|
|
7
|
+
'repo.new.opt.dir': '父目录(默认当前目录)',
|
|
8
|
+
'repo.new.opt.git': '同时 git init(默认不执行)',
|
|
9
|
+
'repo.new.example.basic': '中文模板新建',
|
|
10
|
+
'repo.new.example.git': '新建并初始化 git 仓库',
|
|
11
|
+
// runtime messages
|
|
12
|
+
'repo.new.msg.creating': '在 {path} 新建 qtype 仓库',
|
|
13
|
+
'repo.new.msg.symlinkFallback': '{alias} (copy, symlink failed)',
|
|
14
|
+
'repo.new.msg.gitInited': 'git 已初始化(分支:main)',
|
|
15
|
+
'repo.new.msg.gitInitFailed': 'git init 失败:{reason}',
|
|
16
|
+
'repo.new.msg.done': '仓库已建好:{path}',
|
|
17
|
+
'repo.new.msg.nextSteps': '下一步:',
|
|
18
|
+
'repo.new.msg.next.cd': 'cd {name}',
|
|
19
|
+
'repo.new.msg.next.edit': 'vim material/example.md # 或添加新的 material/*.md',
|
|
20
|
+
'repo.new.msg.next.commit': 'git add -A && git commit # 首次提交',
|
|
21
|
+
'repo.new.msg.next.gitHint': 'git init # 如需版本控制,也可以 qtype repo new --git',
|
|
22
|
+
'repo.new.tpl.defaultDescription': '资料库',
|
|
23
|
+
},
|
|
24
|
+
'en-US': {
|
|
25
|
+
'repo.new.summary': 'Create a new repo',
|
|
26
|
+
'repo.new.description': 'Create a qtype material repo in an empty directory (QTYPE.md + symlinks + material/ skeleton)',
|
|
27
|
+
'repo.new.arg.name': 'Repo name (used as directory name)',
|
|
28
|
+
'repo.new.opt.description': 'One-line description for README',
|
|
29
|
+
'repo.new.opt.dir': 'Parent directory (default: current directory)',
|
|
30
|
+
'repo.new.opt.git': 'Also run git init (off by default)',
|
|
31
|
+
'repo.new.example.basic': 'English template',
|
|
32
|
+
'repo.new.example.git': 'Create and initialize git',
|
|
33
|
+
// runtime messages
|
|
34
|
+
'repo.new.msg.creating': 'Creating qtype repo in {path}',
|
|
35
|
+
'repo.new.msg.symlinkFallback': '{alias} (copied, symlink not supported)',
|
|
36
|
+
'repo.new.msg.gitInited': 'git initialized (branch: main)',
|
|
37
|
+
'repo.new.msg.gitInitFailed': 'git init failed: {reason}',
|
|
38
|
+
'repo.new.msg.done': 'Repo created: {path}',
|
|
39
|
+
'repo.new.msg.nextSteps': 'Next steps:',
|
|
40
|
+
'repo.new.msg.next.cd': 'cd {name}',
|
|
41
|
+
'repo.new.msg.next.edit': 'vim material/example.md # or add new material/*.md',
|
|
42
|
+
'repo.new.msg.next.commit': 'git add -A && git commit # initial commit',
|
|
43
|
+
'repo.new.msg.next.gitHint': 'git init # or re-run with qtype repo new --git',
|
|
44
|
+
'repo.new.tpl.defaultDescription': 'material library',
|
|
45
|
+
},
|
|
46
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `qtype repo new <name>` command module.
|
|
3
|
+
*/
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { t, getLocale } from '../../../i18n/index.js';
|
|
7
|
+
import { userKv } from '../../../bootstrap.js';
|
|
8
|
+
import { warn } from '../../../utils/output.js';
|
|
9
|
+
import { messages } from './i18n.js';
|
|
10
|
+
import { repoNewAction } from './action.js';
|
|
11
|
+
function warnIfHome(dir) {
|
|
12
|
+
if (path.resolve(dir) === os.homedir())
|
|
13
|
+
warn(t('msg.home.warn'));
|
|
14
|
+
}
|
|
15
|
+
export const repoNewCmd = {
|
|
16
|
+
name: 'new',
|
|
17
|
+
messages,
|
|
18
|
+
summary: () => t('repo.new.summary'),
|
|
19
|
+
describe: () => t('repo.new.description'),
|
|
20
|
+
args: [{ flag: '<name>', describe: () => t('repo.new.arg.name') }],
|
|
21
|
+
options: [
|
|
22
|
+
{
|
|
23
|
+
flags: '--lang <lang>',
|
|
24
|
+
describe: () => t('opt.lang'),
|
|
25
|
+
choices: ['zh-CN', 'en-US', 'zh', 'en'],
|
|
26
|
+
// No defaultValue: when omitted, the action inherits from user's
|
|
27
|
+
// global repo.lang → CLI locale → 'zh' fallback.
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
flags: '--description <text>',
|
|
31
|
+
describe: () => t('repo.new.opt.description'),
|
|
32
|
+
defaultValue: '',
|
|
33
|
+
},
|
|
34
|
+
{ flags: '--dir <path>', describe: () => t('repo.new.opt.dir') },
|
|
35
|
+
{ flags: '--git', describe: () => t('repo.new.opt.git') },
|
|
36
|
+
],
|
|
37
|
+
examples: () => [
|
|
38
|
+
{ command: 'qtype repo new my-material', describe: () => t('repo.new.example.basic') },
|
|
39
|
+
{ command: 'qtype repo new my-material --git', describe: () => t('repo.new.example.git') },
|
|
40
|
+
],
|
|
41
|
+
seeAlso: ['repo sync', 'publish init'],
|
|
42
|
+
action: async (name, opts) => {
|
|
43
|
+
warnIfHome(opts.dir ?? process.cwd());
|
|
44
|
+
// Precedence: --lang flag → user's global `repo.lang` → CLI locale → 'zh'.
|
|
45
|
+
// `qtype config set repo.lang en --global` becomes a durable "new repos
|
|
46
|
+
// should be English" preference.
|
|
47
|
+
const userDefault = (await userKv.get('repo.lang'));
|
|
48
|
+
const lang = opts.lang ?? userDefault ?? getLocale();
|
|
49
|
+
await repoNewAction({
|
|
50
|
+
name,
|
|
51
|
+
lang,
|
|
52
|
+
description: opts.description,
|
|
53
|
+
dir: opts.dir,
|
|
54
|
+
git: opts.git === true,
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
};
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { getTemplatesDir, readTemplate } from '../templates.js';
|
|
4
|
+
import { info, success, warn, fatal } from '../../../utils/output.js';
|
|
5
|
+
import { t } from '../../../i18n/index.js';
|
|
6
|
+
function buildSyncPlan() {
|
|
7
|
+
const qtypeFile = 'QTYPE.md';
|
|
8
|
+
return [
|
|
9
|
+
// ── qtype-managed (overwriteable with --force) ──
|
|
10
|
+
{ kind: 'file', target: qtypeFile, source: 'QTYPE.md', managed: true },
|
|
11
|
+
{ kind: 'symlink', target: 'AGENTS.md', source: qtypeFile, managed: true },
|
|
12
|
+
{ kind: 'symlink', target: 'CLAUDE.md', source: qtypeFile, managed: true },
|
|
13
|
+
{ kind: 'symlink', target: 'GEMINI.md', source: qtypeFile, managed: true },
|
|
14
|
+
// ── User content (never overwritten) ────────────
|
|
15
|
+
{ kind: 'file', target: 'README.md', source: 'README.md', managed: false },
|
|
16
|
+
{ kind: 'file', target: 'TODO.md', source: 'TODO.md', managed: false },
|
|
17
|
+
{ kind: 'file', target: 'index.md', source: 'index.md', managed: false },
|
|
18
|
+
{ kind: 'file', target: 'log.md', source: 'log.md', managed: false },
|
|
19
|
+
{
|
|
20
|
+
kind: 'file',
|
|
21
|
+
target: 'material/example.md',
|
|
22
|
+
source: path.join('material', 'example.md'),
|
|
23
|
+
managed: false,
|
|
24
|
+
},
|
|
25
|
+
// ── Skeleton (create-only, never touched) ───────
|
|
26
|
+
{ kind: 'dir', target: '.qtype', managed: false },
|
|
27
|
+
{ kind: 'dir', target: 'material', managed: false },
|
|
28
|
+
{ kind: 'gitkeep', target: 'material/.gitkeep', managed: false },
|
|
29
|
+
];
|
|
30
|
+
}
|
|
31
|
+
export async function repoSyncAction(opts) {
|
|
32
|
+
const root = opts.path ?? process.cwd();
|
|
33
|
+
// Accept BCP 47 (`zh-CN` / `en-US`) or the legacy short forms; reduce
|
|
34
|
+
// to the short code used by template / content directories, and derive
|
|
35
|
+
// the canonical BCP 47 form that gets written to .qtype/config.json.
|
|
36
|
+
const lang = opts.lang === 'zh' || opts.lang === 'zh-CN' ? 'zh' : 'en';
|
|
37
|
+
const canonicalLang = lang === 'zh' ? 'zh-CN' : 'en-US';
|
|
38
|
+
const dryRun = opts.dryRun === true;
|
|
39
|
+
const force = opts.force === true;
|
|
40
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
41
|
+
if (!fs.existsSync(root)) {
|
|
42
|
+
fatal(t('err.path.notExists', { path: root }));
|
|
43
|
+
}
|
|
44
|
+
const name = path.basename(path.resolve(root));
|
|
45
|
+
const scanningKey = dryRun
|
|
46
|
+
? 'repo.sync.msg.scanning.dryRun'
|
|
47
|
+
: force
|
|
48
|
+
? 'repo.sync.msg.scanning.force'
|
|
49
|
+
: 'repo.sync.msg.scanning';
|
|
50
|
+
info(t(scanningKey, { path: root }));
|
|
51
|
+
const templatesDir = getTemplatesDir(lang);
|
|
52
|
+
const vars = {
|
|
53
|
+
name,
|
|
54
|
+
description: t('repo.sync.tpl.defaultDescription'),
|
|
55
|
+
date: today,
|
|
56
|
+
};
|
|
57
|
+
const plan = buildSyncPlan();
|
|
58
|
+
const toCreate = [];
|
|
59
|
+
const toOverwrite = [];
|
|
60
|
+
const skipped = [];
|
|
61
|
+
for (const item of plan) {
|
|
62
|
+
const fullPath = path.join(root, item.target);
|
|
63
|
+
if (!fs.existsSync(fullPath)) {
|
|
64
|
+
toCreate.push(item);
|
|
65
|
+
}
|
|
66
|
+
else if (force && item.managed) {
|
|
67
|
+
toOverwrite.push(item);
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
skipped.push(item.target);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (skipped.length > 0) {
|
|
74
|
+
console.log('\n' + t('repo.sync.msg.header.skipped', { count: skipped.length }));
|
|
75
|
+
if (!dryRun) {
|
|
76
|
+
for (const e of skipped)
|
|
77
|
+
console.log(` · ${e}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (toOverwrite.length > 0) {
|
|
81
|
+
const headerKey = dryRun
|
|
82
|
+
? 'repo.sync.msg.header.willOverwrite'
|
|
83
|
+
: 'repo.sync.msg.header.overwriting';
|
|
84
|
+
console.log('\n' + t(headerKey, { count: toOverwrite.length }));
|
|
85
|
+
for (const item of toOverwrite) {
|
|
86
|
+
console.log(` ~ ${item.target}`);
|
|
87
|
+
if (dryRun)
|
|
88
|
+
continue;
|
|
89
|
+
writeItem(root, item, templatesDir, vars);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const createHeaderKey = dryRun
|
|
93
|
+
? 'repo.sync.msg.header.willCreate'
|
|
94
|
+
: 'repo.sync.msg.header.creating';
|
|
95
|
+
console.log('\n' + t(createHeaderKey, { count: toCreate.length }));
|
|
96
|
+
for (const item of toCreate) {
|
|
97
|
+
console.log(` + ${item.target}`);
|
|
98
|
+
if (dryRun)
|
|
99
|
+
continue;
|
|
100
|
+
writeItem(root, item, templatesDir, vars);
|
|
101
|
+
}
|
|
102
|
+
// Ensure .qtype/config.json exists and records the repo language.
|
|
103
|
+
if (!dryRun) {
|
|
104
|
+
ensureConfigLang(root, canonicalLang);
|
|
105
|
+
}
|
|
106
|
+
if (dryRun) {
|
|
107
|
+
console.log('');
|
|
108
|
+
warn(t('repo.sync.msg.dryRun'));
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
console.log('');
|
|
112
|
+
success(t('repo.sync.msg.done', {
|
|
113
|
+
created: toCreate.length,
|
|
114
|
+
overwritten: toOverwrite.length,
|
|
115
|
+
skipped: skipped.length,
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Ensure `.qtype/config.json` exists and has `repo.lang` set. Merges with
|
|
121
|
+
* an existing config non-destructively (won't clobber other settings) —
|
|
122
|
+
* only writes `repo.lang` when it's missing or empty.
|
|
123
|
+
*/
|
|
124
|
+
function ensureConfigLang(root, canonicalLang) {
|
|
125
|
+
const dir = path.join(root, '.qtype');
|
|
126
|
+
const file = path.join(dir, 'config.json');
|
|
127
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
128
|
+
let obj = {};
|
|
129
|
+
if (fs.existsSync(file)) {
|
|
130
|
+
try {
|
|
131
|
+
const text = fs.readFileSync(file, 'utf8').trim();
|
|
132
|
+
if (text.length > 0) {
|
|
133
|
+
const parsed = JSON.parse(text);
|
|
134
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
135
|
+
obj = parsed;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
// Malformed config — leave alone rather than clobbering the user's edits.
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const repoBlock = obj.repo && typeof obj.repo === 'object' && !Array.isArray(obj.repo)
|
|
145
|
+
? obj.repo
|
|
146
|
+
: {};
|
|
147
|
+
// Existing values are accepted whether they're BCP 47 or short codes —
|
|
148
|
+
// the reader normalises on load. Only skip the write when the value is
|
|
149
|
+
// already one we recognise.
|
|
150
|
+
if (typeof repoBlock.lang === 'string' &&
|
|
151
|
+
(repoBlock.lang === 'zh' ||
|
|
152
|
+
repoBlock.lang === 'en' ||
|
|
153
|
+
repoBlock.lang === 'zh-CN' ||
|
|
154
|
+
repoBlock.lang === 'en-US')) {
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
repoBlock.lang = canonicalLang;
|
|
158
|
+
obj.repo = repoBlock;
|
|
159
|
+
fs.writeFileSync(file, JSON.stringify(obj, null, 2) + '\n');
|
|
160
|
+
}
|
|
161
|
+
function writeItem(root, item, templatesDir, vars) {
|
|
162
|
+
const fullPath = path.join(root, item.target);
|
|
163
|
+
if (item.kind === 'file' && item.source) {
|
|
164
|
+
const content = readTemplate(templatesDir, item.source, vars);
|
|
165
|
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
166
|
+
fs.writeFileSync(fullPath, content);
|
|
167
|
+
}
|
|
168
|
+
else if (item.kind === 'symlink' && item.source) {
|
|
169
|
+
let exists = false;
|
|
170
|
+
try {
|
|
171
|
+
fs.lstatSync(fullPath);
|
|
172
|
+
exists = true;
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
// ENOENT — nothing to unlink
|
|
176
|
+
}
|
|
177
|
+
if (exists) {
|
|
178
|
+
try {
|
|
179
|
+
fs.unlinkSync(fullPath);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
// If we can't delete, let symlinkSync below report the reason
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
fs.symlinkSync(item.source, fullPath);
|
|
187
|
+
}
|
|
188
|
+
catch {
|
|
189
|
+
// Windows / permission issue → fall back to a copy
|
|
190
|
+
const sourceFull = path.join(root, item.source);
|
|
191
|
+
if (fs.existsSync(sourceFull))
|
|
192
|
+
fs.copyFileSync(sourceFull, fullPath);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
else if (item.kind === 'dir') {
|
|
196
|
+
fs.mkdirSync(fullPath, { recursive: true });
|
|
197
|
+
}
|
|
198
|
+
else if (item.kind === 'gitkeep') {
|
|
199
|
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
200
|
+
fs.writeFileSync(fullPath, '');
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export const messages = {
|
|
2
|
+
'zh-CN': {
|
|
3
|
+
'repo.sync.summary': '补齐缺失文件',
|
|
4
|
+
'repo.sync.description': '已有仓库补齐缺失的 QTYPE 规范文件(默认不覆盖;--force 覆盖 qtype 管理的文件)',
|
|
5
|
+
'repo.sync.arg.path': '仓库路径(默认当前目录)',
|
|
6
|
+
'repo.sync.opt.force': '覆盖 qtype 管理的文件(规范 + 软链);用户内容不动',
|
|
7
|
+
'repo.sync.example.dryRun': '只报告缺啥,不写任何文件',
|
|
8
|
+
'repo.sync.example.explicit': '指定仓库路径',
|
|
9
|
+
'repo.sync.example.force': '升级 qtype 后,更新 QTYPE.md 等规范文件',
|
|
10
|
+
// runtime messages
|
|
11
|
+
'repo.sync.msg.scanning': '检查 {path}',
|
|
12
|
+
'repo.sync.msg.scanning.force': '检查 {path}(--force: 将覆盖 qtype 管理的文件)',
|
|
13
|
+
'repo.sync.msg.scanning.dryRun': '[dry-run] 检查 {path}',
|
|
14
|
+
'repo.sync.msg.dryRun': '[dry-run] 没有写入任何文件。去掉 --dry-run 实际执行。',
|
|
15
|
+
'repo.sync.msg.header.skipped': '已存在(跳过):{count} 项',
|
|
16
|
+
'repo.sync.msg.header.overwriting': '正在覆盖:{count} 项 (--force)',
|
|
17
|
+
'repo.sync.msg.header.willOverwrite': '将要覆盖:{count} 项 (--force)',
|
|
18
|
+
'repo.sync.msg.header.creating': '正在建:{count} 项',
|
|
19
|
+
'repo.sync.msg.header.willCreate': '将要建:{count} 项',
|
|
20
|
+
'repo.sync.msg.done': '完成:建 {created},覆盖 {overwritten},跳过 {skipped}',
|
|
21
|
+
'repo.sync.tpl.defaultDescription': '资料库',
|
|
22
|
+
},
|
|
23
|
+
'en-US': {
|
|
24
|
+
'repo.sync.summary': 'Sync missing files',
|
|
25
|
+
'repo.sync.description': 'Add missing QTYPE spec files to an existing repo (safe by default; --force overwrites qtype-managed files only)',
|
|
26
|
+
'repo.sync.arg.path': 'Repo path (default: current directory)',
|
|
27
|
+
'repo.sync.opt.force': 'Overwrite qtype-managed files only (spec + symlinks); user content untouched',
|
|
28
|
+
'repo.sync.example.dryRun': 'Report only, no writes',
|
|
29
|
+
'repo.sync.example.explicit': 'Explicit repo path',
|
|
30
|
+
'repo.sync.example.force': 'After upgrading qtype, refresh QTYPE.md etc.',
|
|
31
|
+
// runtime messages
|
|
32
|
+
'repo.sync.msg.scanning': 'Scanning {path}',
|
|
33
|
+
'repo.sync.msg.scanning.force': 'Scanning {path} (--force: will overwrite qtype-managed files)',
|
|
34
|
+
'repo.sync.msg.scanning.dryRun': '[dry-run] Scanning {path}',
|
|
35
|
+
'repo.sync.msg.dryRun': '[dry-run] No files written. Remove --dry-run to actually apply.',
|
|
36
|
+
'repo.sync.msg.header.skipped': 'Already present (skipped): {count}',
|
|
37
|
+
'repo.sync.msg.header.overwriting': 'Overwriting: {count} (--force)',
|
|
38
|
+
'repo.sync.msg.header.willOverwrite': 'Will overwrite: {count} (--force)',
|
|
39
|
+
'repo.sync.msg.header.creating': 'Creating: {count}',
|
|
40
|
+
'repo.sync.msg.header.willCreate': 'Will create: {count}',
|
|
41
|
+
'repo.sync.msg.done': 'Done: {created} created, {overwritten} overwritten, {skipped} skipped',
|
|
42
|
+
'repo.sync.tpl.defaultDescription': 'material library',
|
|
43
|
+
},
|
|
44
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `qtype repo sync` command module.
|
|
3
|
+
*/
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { t, getLocale } from '../../../i18n/index.js';
|
|
7
|
+
import { userKv } from '../../../bootstrap.js';
|
|
8
|
+
import { warn } from '../../../utils/output.js';
|
|
9
|
+
import { messages } from './i18n.js';
|
|
10
|
+
import { repoSyncAction } from './action.js';
|
|
11
|
+
function warnIfHome(dir) {
|
|
12
|
+
if (path.resolve(dir) === os.homedir())
|
|
13
|
+
warn(t('msg.home.warn'));
|
|
14
|
+
}
|
|
15
|
+
export const repoSyncCmd = {
|
|
16
|
+
name: 'sync',
|
|
17
|
+
messages,
|
|
18
|
+
summary: () => t('repo.sync.summary'),
|
|
19
|
+
describe: () => t('repo.sync.description'),
|
|
20
|
+
args: [{ flag: '[path]', describe: () => t('repo.sync.arg.path') }],
|
|
21
|
+
options: [
|
|
22
|
+
{
|
|
23
|
+
flags: '--lang <lang>',
|
|
24
|
+
describe: () => t('opt.lang'),
|
|
25
|
+
choices: ['zh-CN', 'en-US', 'zh', 'en'],
|
|
26
|
+
},
|
|
27
|
+
{ flags: '--force', describe: () => t('repo.sync.opt.force') },
|
|
28
|
+
{ flags: '--dry-run', describe: () => t('opt.dryRun') },
|
|
29
|
+
],
|
|
30
|
+
examples: () => [
|
|
31
|
+
{ command: 'qtype repo sync --dry-run', describe: () => t('repo.sync.example.dryRun') },
|
|
32
|
+
{ command: 'qtype repo sync ./my-material', describe: () => t('repo.sync.example.explicit') },
|
|
33
|
+
{ command: 'qtype repo sync --force', describe: () => t('repo.sync.example.force') },
|
|
34
|
+
],
|
|
35
|
+
seeAlso: ['repo new'],
|
|
36
|
+
action: async (pathArg, opts) => {
|
|
37
|
+
warnIfHome(pathArg ?? process.cwd());
|
|
38
|
+
const userDefault = (await userKv.get('repo.lang'));
|
|
39
|
+
const lang = opts.lang ?? userDefault ?? getLocale();
|
|
40
|
+
await repoSyncAction({
|
|
41
|
+
path: pathArg,
|
|
42
|
+
lang,
|
|
43
|
+
force: opts.force,
|
|
44
|
+
dryRun: opts.dryRun,
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
/**
|
|
5
|
+
* Locate the templates root inside the qtype package.
|
|
6
|
+
*
|
|
7
|
+
* Layout:
|
|
8
|
+
* templates/
|
|
9
|
+
* ├── en/
|
|
10
|
+
* │ ├── QTYPE.md README.md TODO.md index.md log.md
|
|
11
|
+
* │ └── material/
|
|
12
|
+
* │ └── example.md
|
|
13
|
+
* └── zh/
|
|
14
|
+
* └── (same files)
|
|
15
|
+
*
|
|
16
|
+
* In development: source at qtype/src/cli/commands/repo/templates.ts
|
|
17
|
+
* templates at qtype/templates/
|
|
18
|
+
* relative path: ../../../../templates
|
|
19
|
+
*
|
|
20
|
+
* After publish: compiled output at dist/cli/commands/repo/templates.js
|
|
21
|
+
* templates should live at <pkg>/templates/ (declared in files)
|
|
22
|
+
* relative path: ../../../../templates (same, since dist is at pkg root)
|
|
23
|
+
*/
|
|
24
|
+
export function getTemplatesRoot() {
|
|
25
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
26
|
+
const __dirname = path.dirname(__filename);
|
|
27
|
+
const candidates = [
|
|
28
|
+
path.resolve(__dirname, '../../../../templates'), // development / after publish (dist at pkg root)
|
|
29
|
+
path.resolve(__dirname, '../../../templates'), // fallback when nesting differs
|
|
30
|
+
];
|
|
31
|
+
for (const c of candidates) {
|
|
32
|
+
if (fs.existsSync(c) && fs.statSync(c).isDirectory())
|
|
33
|
+
return c;
|
|
34
|
+
}
|
|
35
|
+
throw new Error(`Templates directory not found. Tried:\n ${candidates.join('\n ')}\nMake sure the qtype package includes templates/.`);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Reduce a full BCP 47 language code (or legacy short code) to the short
|
|
39
|
+
* form used as a templates/examples directory name. `zh-CN` / `zh` → `zh`;
|
|
40
|
+
* `en-US` / `en` → `en`; anything else → `en`.
|
|
41
|
+
*/
|
|
42
|
+
function toShortLang(lang) {
|
|
43
|
+
if (lang === 'zh' || lang === 'zh-CN')
|
|
44
|
+
return 'zh';
|
|
45
|
+
return 'en';
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Resolve the language-specific template directory (templates/<lang>/).
|
|
49
|
+
*
|
|
50
|
+
* Accepts BCP 47 codes (`zh-CN` / `en-US`) or short codes (`zh` / `en`);
|
|
51
|
+
* both map to the same directory since template folders are named by the
|
|
52
|
+
* short code. Falls back to `en` when the requested language directory
|
|
53
|
+
* doesn't exist.
|
|
54
|
+
*/
|
|
55
|
+
export function getTemplatesDir(lang = 'en') {
|
|
56
|
+
const root = getTemplatesRoot();
|
|
57
|
+
const short = toShortLang(lang);
|
|
58
|
+
const preferred = path.join(root, short);
|
|
59
|
+
if (fs.existsSync(preferred) && fs.statSync(preferred).isDirectory()) {
|
|
60
|
+
return preferred;
|
|
61
|
+
}
|
|
62
|
+
const fallback = path.join(root, 'en');
|
|
63
|
+
if (fs.existsSync(fallback) && fs.statSync(fallback).isDirectory()) {
|
|
64
|
+
return fallback;
|
|
65
|
+
}
|
|
66
|
+
throw new Error(`Templates language directory not found: ${preferred} (no en/ fallback either).`);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Read a template file with {{placeholder}} substitution.
|
|
70
|
+
*/
|
|
71
|
+
export function readTemplate(templatesDir, relName, vars = {}) {
|
|
72
|
+
const filePath = path.join(templatesDir, relName);
|
|
73
|
+
if (!fs.existsSync(filePath)) {
|
|
74
|
+
throw new Error(`Template file not found: ${filePath}`);
|
|
75
|
+
}
|
|
76
|
+
let content = fs.readFileSync(filePath, 'utf8');
|
|
77
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
78
|
+
content = content.replaceAll(`{{${key}}}`, value);
|
|
79
|
+
}
|
|
80
|
+
return content;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* List all filenames in the templates directory (non-recursive).
|
|
84
|
+
*/
|
|
85
|
+
export function listTemplateFiles(templatesDir) {
|
|
86
|
+
return fs.readdirSync(templatesDir);
|
|
87
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI i18n: dictionary lookup + runtime locale detection + per-command injection.
|
|
3
|
+
*
|
|
4
|
+
* ## Locale detection priority (high → low)
|
|
5
|
+
* 1. `setLocale(value)` set at runtime (--lang flag)
|
|
6
|
+
* 2. `$QTYPE_LANG` env var (app-specific temporary override)
|
|
7
|
+
* 3. `~/.config/qtype/config.json` `cli.lang` field (explicit user preference)
|
|
8
|
+
* 4. `$LC_ALL` / `$LC_MESSAGES` / `$LANG` (system default locale)
|
|
9
|
+
* 5. Default `'en-US'`
|
|
10
|
+
*
|
|
11
|
+
* Locale identifiers are BCP 47 (`zh-CN` / `en-US`) throughout. Legacy
|
|
12
|
+
* short codes (`zh` / `en`) coming from any user-facing input — CLI
|
|
13
|
+
* flags, env vars, config files, POSIX $LANG — are normalised at the
|
|
14
|
+
* boundary via `normalizeLocale()`. Never pass a short code past that
|
|
15
|
+
* gate.
|
|
16
|
+
*
|
|
17
|
+
* ## Why i18n doesn't go through the shared config system
|
|
18
|
+
*
|
|
19
|
+
* i18n is "the lowest layer" — every command module imports it. But the
|
|
20
|
+
* store system's bootstrap (creating `userKv` / `repoKv` instances) happens
|
|
21
|
+
* at CLI startup, after module import. To break the cycle, locale detection
|
|
22
|
+
* here reads the JSON file directly, bypassing `#store`. This doesn't
|
|
23
|
+
* conflict with other modules reading config via `#store` — i18n is
|
|
24
|
+
* self-contained at its own layer.
|
|
25
|
+
*
|
|
26
|
+
* ## Dictionary sources
|
|
27
|
+
* - `shared.ts` — keys shared across commands (`opt.help` / `msg.port.invalid`...)
|
|
28
|
+
* - Each command module's `messages: { 'zh-CN', 'en-US' }` field
|
|
29
|
+
* → merged in via `extend()` during registry mount
|
|
30
|
+
*
|
|
31
|
+
* Merge strategy: later writes overwrite earlier. Command-internal keys
|
|
32
|
+
* should use the `<group>.<cmd>.<item>` namespace to avoid collisions.
|
|
33
|
+
*/
|
|
34
|
+
import fs from 'node:fs';
|
|
35
|
+
import os from 'node:os';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
import { zhCN, enUS } from './shared.js';
|
|
38
|
+
// Mutable dictionaries: initialized from shared, then merged with each
|
|
39
|
+
// CommandModule's own `messages` at mount time (via extend()).
|
|
40
|
+
const dicts = {
|
|
41
|
+
'zh-CN': { ...zhCN },
|
|
42
|
+
'en-US': { ...enUS },
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Normalise arbitrary user-supplied language input to a canonical BCP 47
|
|
46
|
+
* locale. Accepts both short codes (`zh` / `en`) — carried over from old
|
|
47
|
+
* config files, CLI habit, and POSIX $LANG output — and full tags
|
|
48
|
+
* (`zh-CN` / `en-US`). Returns `null` for anything else.
|
|
49
|
+
*/
|
|
50
|
+
export function normalizeLocale(v) {
|
|
51
|
+
if (v === 'zh' || v === 'zh-CN')
|
|
52
|
+
return 'zh-CN';
|
|
53
|
+
if (v === 'en' || v === 'en-US')
|
|
54
|
+
return 'en-US';
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Extend the dictionaries (merging command-module messages).
|
|
59
|
+
*
|
|
60
|
+
* Later writes overwrite earlier. Command modules should namespace their
|
|
61
|
+
* keys with dot-separators to avoid collisions.
|
|
62
|
+
*/
|
|
63
|
+
export function extend(messages) {
|
|
64
|
+
for (const loc of Object.keys(messages)) {
|
|
65
|
+
const src = messages[loc];
|
|
66
|
+
if (!src)
|
|
67
|
+
continue;
|
|
68
|
+
for (const [k, v] of Object.entries(src)) {
|
|
69
|
+
if (typeof v === 'string')
|
|
70
|
+
dicts[loc][k] = v;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
// ─── locale detection ───────────────────────────────────
|
|
75
|
+
function detectLocaleFromAppEnv() {
|
|
76
|
+
return normalizeLocale(process.env.QTYPE_LANG);
|
|
77
|
+
}
|
|
78
|
+
function detectLocaleFromConfig() {
|
|
79
|
+
// Read ~/.config/qtype/config.json directly, bypassing the config system
|
|
80
|
+
// (to avoid circular dependency — see top-of-file comment). Follows the
|
|
81
|
+
// XDG Base Directory spec.
|
|
82
|
+
try {
|
|
83
|
+
const xdg = process.env.XDG_CONFIG_HOME;
|
|
84
|
+
const base = xdg && xdg.length > 0 ? xdg : path.join(os.homedir(), '.config');
|
|
85
|
+
const filePath = path.join(base, 'qtype', 'config.json');
|
|
86
|
+
if (!fs.existsSync(filePath))
|
|
87
|
+
return null;
|
|
88
|
+
const text = fs.readFileSync(filePath, 'utf8').trim();
|
|
89
|
+
if (!text)
|
|
90
|
+
return null;
|
|
91
|
+
const data = JSON.parse(text);
|
|
92
|
+
if (data == null || typeof data !== 'object' || Array.isArray(data))
|
|
93
|
+
return null;
|
|
94
|
+
const cli = data.cli;
|
|
95
|
+
if (cli != null && typeof cli === 'object' && !Array.isArray(cli)) {
|
|
96
|
+
return normalizeLocale(cli.lang);
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function detectLocaleFromSystemLocale() {
|
|
105
|
+
const lc = process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || '';
|
|
106
|
+
if (/^zh/i.test(lc))
|
|
107
|
+
return 'zh-CN';
|
|
108
|
+
if (/^en/i.test(lc))
|
|
109
|
+
return 'en-US';
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
function detectInitialLocale() {
|
|
113
|
+
return (detectLocaleFromAppEnv() ??
|
|
114
|
+
detectLocaleFromConfig() ??
|
|
115
|
+
detectLocaleFromSystemLocale() ??
|
|
116
|
+
'en-US');
|
|
117
|
+
}
|
|
118
|
+
let currentLocale = detectInitialLocale();
|
|
119
|
+
export function getLocale() {
|
|
120
|
+
return currentLocale;
|
|
121
|
+
}
|
|
122
|
+
/** Set locale at runtime (used by the --lang option). */
|
|
123
|
+
export function setLocale(locale) {
|
|
124
|
+
currentLocale = locale;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Translation function.
|
|
128
|
+
* - When the key is missing: fall back to zh-CN, then to the key itself
|
|
129
|
+
* (debug-friendly)
|
|
130
|
+
* - Supports `{name}` placeholders
|
|
131
|
+
*/
|
|
132
|
+
export function t(key, vars) {
|
|
133
|
+
const dict = dicts[currentLocale];
|
|
134
|
+
let msg = dict[key] ?? dicts['zh-CN'][key] ?? key;
|
|
135
|
+
if (vars) {
|
|
136
|
+
for (const [k, v] of Object.entries(vars)) {
|
|
137
|
+
msg = msg.replaceAll(`{${k}}`, String(v));
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return msg;
|
|
141
|
+
}
|