@mintfolio/core 0.1.5

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 (93) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +104 -0
  3. package/THIRD_PARTY_NOTICES.md +5 -0
  4. package/bin/lib/config-source.mjs +147 -0
  5. package/bin/lib/config.mjs +144 -0
  6. package/bin/lib/files.mjs +103 -0
  7. package/bin/lib/init.mjs +67 -0
  8. package/bin/lib/posts.mjs +114 -0
  9. package/bin/lib/process.mjs +67 -0
  10. package/bin/lib/site.mjs +65 -0
  11. package/bin/lib/themes.mjs +119 -0
  12. package/bin/mintfolio.mjs +244 -0
  13. package/bin/theme-config.mjs +111 -0
  14. package/dist/client/archive.d.ts +7 -0
  15. package/dist/client/archive.js +47 -0
  16. package/dist/client/code.d.ts +4 -0
  17. package/dist/client/code.js +126 -0
  18. package/dist/client/lifecycle.d.ts +18 -0
  19. package/dist/client/lifecycle.js +82 -0
  20. package/dist/client/lightbox.d.ts +24 -0
  21. package/dist/client/lightbox.js +142 -0
  22. package/dist/client/navigation.d.ts +22 -0
  23. package/dist/client/navigation.js +29 -0
  24. package/dist/client/postList.d.ts +41 -0
  25. package/dist/client/postList.js +71 -0
  26. package/dist/client/protectedArticle.d.ts +26 -0
  27. package/dist/client/protectedArticle.js +64 -0
  28. package/dist/client/toc.d.ts +22 -0
  29. package/dist/client/toc.js +90 -0
  30. package/dist/public/astro.d.ts +2 -0
  31. package/dist/public/astro.js +2 -0
  32. package/dist/public/client.d.ts +10 -0
  33. package/dist/public/client.js +10 -0
  34. package/dist/public/config.d.ts +32 -0
  35. package/dist/public/config.js +21 -0
  36. package/dist/public/search.d.ts +2 -0
  37. package/dist/public/search.js +2 -0
  38. package/dist/public/theme.d.ts +2 -0
  39. package/dist/public/theme.js +2 -0
  40. package/docs/cli.md +118 -0
  41. package/package.json +88 -0
  42. package/src/client/archive.ts +45 -0
  43. package/src/client/code.ts +141 -0
  44. package/src/client/lifecycle.ts +76 -0
  45. package/src/client/lightbox.ts +163 -0
  46. package/src/client/navigation.ts +46 -0
  47. package/src/client/postList.ts +92 -0
  48. package/src/client/protectedArticle.ts +80 -0
  49. package/src/client/toc.ts +90 -0
  50. package/src/components/Image.astro +28 -0
  51. package/src/components/PostArchive.astro +48 -0
  52. package/src/components/ProtectedArticle.astro +56 -0
  53. package/src/components/SeoHead.astro +7 -0
  54. package/src/content.d.ts +15 -0
  55. package/src/content.mjs +19 -0
  56. package/src/engine/context.ts +55 -0
  57. package/src/engine/import-boundary.mjs +154 -0
  58. package/src/engine/integration.mjs +166 -0
  59. package/src/engine/loader.mjs +114 -0
  60. package/src/engine/runtime/post-page.astro +35 -0
  61. package/src/engine/schema.mjs +125 -0
  62. package/src/engine/theme-config.mjs +67 -0
  63. package/src/engine/virtual.d.ts +12 -0
  64. package/src/fallback/layouts/MinimalLayout.astro +45 -0
  65. package/src/fallback/pages/archive.astro +13 -0
  66. package/src/fallback/pages/home.astro +44 -0
  67. package/src/fallback/pages/not-found.astro +18 -0
  68. package/src/fallback/pages/page.astro +39 -0
  69. package/src/fallback/pages/post.astro +41 -0
  70. package/src/fallback/settings.ts +8 -0
  71. package/src/fallback/styles/minimal.css +109 -0
  72. package/src/fallback/theme.mjs +48 -0
  73. package/src/integration.d.ts +10 -0
  74. package/src/integration.mjs +10 -0
  75. package/src/public/astro.ts +2 -0
  76. package/src/public/client.ts +10 -0
  77. package/src/public/config.ts +43 -0
  78. package/src/public/search.ts +2 -0
  79. package/src/public/theme.ts +2 -0
  80. package/src/routes/404.astro +9 -0
  81. package/src/routes/about.astro +9 -0
  82. package/src/routes/blog/[...slug].astro +17 -0
  83. package/src/routes/blog/index.astro +9 -0
  84. package/src/routes/index.astro +9 -0
  85. package/src/routes/rss.xml.ts +34 -0
  86. package/src/routes/sitemap.xml.ts +42 -0
  87. package/src/server/pages.ts +34 -0
  88. package/src/server/postModel.ts +98 -0
  89. package/src/server/posts.ts +15 -0
  90. package/src/server/routing.ts +32 -0
  91. package/src/server/seo.ts +14 -0
  92. package/src/server/site.ts +28 -0
  93. package/src/server/xml.ts +8 -0
@@ -0,0 +1,114 @@
1
+ import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { isMap, isScalar, parseDocument } from 'yaml';
4
+ import { checkedPath, exists, saveFile } from './files.mjs';
5
+
6
+ /** @param {Date} [date] Local calendar date for article frontmatter. @returns {string} YYYY-MM-DD. */
7
+ export function today(date = new Date()) {
8
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
9
+ }
10
+
11
+ /** @param {string} title Unicode title. @returns {string} Human-readable slug; no path is inferred from the title. */
12
+ export function slugFromTitle(title) { return title.normalize('NFKC').toLowerCase().replace(/[^\p{L}\p{N}_-]+/gu, '-').replace(/^-+|-+$/g, ''); }
13
+
14
+ /** @param {string} slug Relative article ID, optionally ending in .md. @returns {string} Checked relative Markdown path. */
15
+ export function postPath(slug) {
16
+ const id = slug.replace(/\.md$/i, '');
17
+ const pieces = id.split('/');
18
+ if (!id || pieces.some(piece => !/^[\p{L}\p{N}][\p{L}\p{N}_-]*$/u.test(piece) || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/i.test(piece))) throw new Error('文章 ID 仅支持文字、数字、下划线、连字符和目录分隔符 /;请用 --slug 指定有效路径。');
19
+ return pieces.join(path.sep) + '.md';
20
+ }
21
+
22
+ function checkedDate(value) {
23
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || !Number.isFinite(Date.parse(value)) || new Date(value).toISOString().slice(0, 10) !== value) throw new Error('日期必须是有效的 YYYY-MM-DD。');
24
+ return value;
25
+ }
26
+
27
+ /**
28
+ * Create a Markdown article exclusively; existing articles are never overwritten.
29
+ * @param {string} root Site root.
30
+ * @param {string} title Required article title.
31
+ * @param {{slug?:string,description?:string,category?:string,tags?:string,date?:string,publish?:boolean}} options Frontmatter inputs; new articles default to drafts.
32
+ * @returns {Promise<string>} Absolute created filename.
33
+ */
34
+ export async function createPost(root, title, options = {}) {
35
+ if (!title.trim()) throw new Error('文章标题不能为空。');
36
+ const filename = path.join(root, 'content/blog', postPath(options.slug || slugFromTitle(title)));
37
+ await checkedPath(root, filename);
38
+ const fields = {
39
+ title,
40
+ pubDate: checkedDate(options.date || today()),
41
+ description: options.description || '',
42
+ category: options.category || '',
43
+ tags: (options.tags || '').split(',').map(tag => tag.trim()).filter(Boolean),
44
+ draft: !options.publish,
45
+ };
46
+ const frontmatter = Object.entries(fields).map(([key, value]) => `${key}: ${JSON.stringify(value)}`).join('\n');
47
+ await mkdir(path.dirname(filename), { recursive: true });
48
+ await checkedPath(root, filename);
49
+ try { await writeFile(filename, `---\n${frontmatter}\n---\n\n## ${title.replace(/[\r\n]+/g, ' ')}\n\n开始写作吧。\n`, { flag: 'wx' }); }
50
+ catch (error) { if (error.code === 'EEXIST') throw new Error(`文章已存在,未覆盖:${filename}`); throw error; }
51
+ return filename;
52
+ }
53
+
54
+ /** @param {string} source Markdown source. @param {string} filename Diagnostic path. @returns {{document:object,start:number,end:number}} YAML document and exact source offsets. */
55
+ function frontmatter(source, filename) {
56
+ const opening = /^(?:\uFEFF)?---[^\S\r\n]*\r?\n/.exec(source);
57
+ if (!opening) throw new Error(`${filename} 缺少 YAML frontmatter。`);
58
+ const remaining = source.slice(opening[0].length);
59
+ const closing = /^---[^\S\r\n]*(?:\r?\n|$)/m.exec(remaining);
60
+ if (!closing) throw new Error(`${filename} 的 YAML frontmatter 未结束。`);
61
+ const start = opening[0].length;
62
+ const end = start + closing.index;
63
+ const document = parseDocument(source.slice(start, end), { uniqueKeys: true });
64
+ if (document.errors.length || !isMap(document.contents)) throw new Error(`${filename} 的 YAML frontmatter 无效:${document.errors[0]?.message || '需要键值对象'}`);
65
+ return { document, start, end };
66
+ }
67
+
68
+ /**
69
+ * Toggle draft status while retaining Markdown and all unrelated YAML bytes.
70
+ * @param {string} root Site root. @param {string} slug Article ID.
71
+ * @param {boolean} draft true saves as draft, false makes it eligible for the next build.
72
+ * @returns {Promise<{filename:string,backup:string|null}>} Updated article and exact backup.
73
+ */
74
+ export async function setDraft(root, slug, draft) {
75
+ const filename = await checkedPath(root, path.join(root, 'content/blog', postPath(slug)));
76
+ const original = await readFile(filename, 'utf8');
77
+ const { document, start, end } = frontmatter(original, filename);
78
+ const node = document.get('draft', true);
79
+ let source;
80
+ if (node !== undefined) {
81
+ if (!isScalar(node) || typeof node.value !== 'boolean') throw new Error('draft 必须是布尔值,请先修正文章 frontmatter。');
82
+ source = original.slice(0, start + node.range[0]) + String(draft) + original.slice(start + node.range[1]);
83
+ } else {
84
+ const eol = original.includes('\r\n') ? '\r\n' : '\n';
85
+ source = original.slice(0, end) + `draft: ${draft}${eol}` + original.slice(end);
86
+ }
87
+ return { filename, backup: await saveFile(root, filename, original, source) };
88
+ }
89
+
90
+ /**
91
+ * List public metadata only; passwords and article bodies are never printed.
92
+ * @param {string} root Site root.
93
+ * @returns {Promise<Array<{slug:string,title:string,date:string,draft:boolean}>>} Date-sorted article metadata.
94
+ */
95
+ export async function listPosts(root) {
96
+ const base = path.join(root, 'content/blog');
97
+ if (!await exists(base)) return [];
98
+ await checkedPath(root, base);
99
+ const posts = [];
100
+ async function visit(directory) {
101
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
102
+ const filename = path.join(directory, entry.name);
103
+ if (entry.isDirectory()) await visit(filename);
104
+ else if (entry.isFile() && entry.name.endsWith('.md')) {
105
+ const { document } = frontmatter(await readFile(filename, 'utf8'), filename);
106
+ const title = document.get('title');
107
+ if (typeof title !== 'string') throw new Error(`${filename} 的 title 必须是字符串。`);
108
+ posts.push({ slug: path.relative(base, filename).replace(/\\/g, '/').replace(/\.md$/, ''), title, date: String(document.get('pubDate') || '').slice(0, 10), draft: document.get('draft') === true });
109
+ }
110
+ }
111
+ }
112
+ await visit(base);
113
+ return posts.sort((a, b) => b.date.localeCompare(a.date) || a.slug.localeCompare(b.slug));
114
+ }
@@ -0,0 +1,67 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { realpath } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { createRequire } from 'node:module';
5
+ import { exists } from './files.mjs';
6
+
7
+ /**
8
+ * Execute arguments without a shell, including Windows paths and Unicode titles.
9
+ * @param {string} executable Executable path.
10
+ * @param {string[]} args Literal argument vector; never interpolated shell code.
11
+ * @param {string} cwd Working directory.
12
+ * @returns {Promise<void>} Reject on launch errors, signals or nonzero status.
13
+ */
14
+ export async function run(executable, args, cwd) {
15
+ await new Promise((resolve, reject) => {
16
+ const child = spawn(executable, args, { cwd, stdio: 'inherit', shell: false, windowsHide: true });
17
+ const forward = () => child.kill('SIGINT');
18
+ process.once('SIGINT', forward);
19
+ child.once('error', error => { process.removeListener('SIGINT', forward); reject(error); });
20
+ child.once('exit', (code, signal) => {
21
+ process.removeListener('SIGINT', forward);
22
+ if (code === 0) resolve();
23
+ else reject(Object.assign(new Error(`${path.basename(executable)} 退出,状态 ${code ?? signal}。`), { exitCode: code ?? 1 }));
24
+ });
25
+ });
26
+ }
27
+
28
+ /** @returns {Promise<string>} npm's JavaScript entry, for npx, global and direct CLI execution. */
29
+ export async function npmEntry() {
30
+ const candidates = [];
31
+ if (process.env.npm_execpath?.endsWith('npm-cli.js')) candidates.push(process.env.npm_execpath);
32
+ // PATH is the user's chosen npm (which may be newer than Node's bundled copy).
33
+ const directories = [...(process.env.PATH || '').split(path.delimiter).filter(Boolean), path.dirname(process.execPath)];
34
+ for (const directory of directories) {
35
+ candidates.push(path.join(directory, 'node_modules/npm/bin/npm-cli.js'), path.join(directory, '../lib/node_modules/npm/bin/npm-cli.js'));
36
+ try {
37
+ const binary = await realpath(path.join(directory, 'npm'));
38
+ if (binary.endsWith('npm-cli.js')) candidates.push(binary);
39
+ } catch {}
40
+ }
41
+ for (const candidate of candidates) if (await exists(candidate)) return candidate;
42
+ throw new Error('找不到 npm。请安装 Node.js/npm,或通过 npx mintfolio 运行。');
43
+ }
44
+
45
+ /** @param {string[]} args npm arguments. @param {string} root Host directory. */
46
+ export async function npm(args, root) { await run(process.execPath, [await npmEntry(), ...args], root); }
47
+
48
+ /** @param {string} root Host root. @returns {string} Astro installed with this site's Core, including nested layouts. */
49
+ export function astroEntry(root) {
50
+ const host = createRequire(path.join(root, 'package.json'));
51
+ const core = createRequire(host.resolve('@mintfolio/core/package.json'));
52
+ return path.join(path.dirname(core.resolve('astro/package.json')), 'bin/astro.mjs');
53
+ }
54
+
55
+ /**
56
+ * Open the exact file with an installed GUI editor. An explicit --editor is an
57
+ * executable name/path, not a shell expression. The CLI never invokes a shell.
58
+ * @param {string} filename Checked site file. @param {string|undefined} editor Explicit editor executable.
59
+ * @param {string} root Site root.
60
+ */
61
+ export async function editFile(filename, editor, root) {
62
+ const configured = editor || process.env.VISUAL || process.env.EDITOR;
63
+ if (configured) return run(configured, [filename], root);
64
+ if (process.platform === 'win32') return run('notepad.exe', [filename], root);
65
+ if (process.platform === 'darwin') return run('open', ['-t', filename], root);
66
+ return run('xdg-open', [filename], root);
67
+ }
@@ -0,0 +1,65 @@
1
+ import { mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { createRequire } from 'node:module';
4
+ import semver from 'semver';
5
+ import { initialize } from './init.mjs';
6
+ import { activeTheme, installTheme } from './themes.mjs';
7
+ import { npm } from './process.mjs';
8
+ import { exists } from './files.mjs';
9
+ import { getConfig } from './config.mjs';
10
+
11
+ /** @returns {Promise<string>} Version of the executing CLI package. */
12
+ export async function cliVersion() { return JSON.parse(await readFile(new URL('../../package.json', import.meta.url), 'utf8')).version; }
13
+
14
+ /** @param {string} root Site root. Reject theme-file editing against engines that cannot read those files. */
15
+ export async function requireThemeConfigRuntime(root) {
16
+ const host = createRequire(path.join(root, 'package.json'));
17
+ const core = JSON.parse(await readFile(host.resolve('@mintfolio/core/package.json'), 'utf8'));
18
+ if (!semver.gte(core.version, '0.1.1')) throw new Error(`当前站点 Core ${core.version} 不支持独立主题配置,请先运行 mintfolio upgrade。`);
19
+ }
20
+
21
+ /**
22
+ * Create a new site in an empty target and install Core through npm. A supplied
23
+ * registry config applies only to @mintfolio; no global npm settings are changed.
24
+ * @param {string} directory Explicit target directory.
25
+ * @param {{theme?:string,registry?:string}} options Optional initial theme and scoped registry.
26
+ * @returns {Promise<string>} Absolute created site directory.
27
+ */
28
+ export async function createSite(directory, options = {}) {
29
+ const target = path.resolve(directory);
30
+ if (await exists(target) && (await readdir(target)).length) throw new Error('新站点目录必须为空;已有站点请使用 mintfolio init。');
31
+ let registry;
32
+ if (options.registry) {
33
+ const url = new URL(options.registry);
34
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) throw new Error('--registry 需要不带账号或查询参数的 HTTP(S) 仓库地址。');
35
+ registry = url.href;
36
+ }
37
+ await mkdir(target, { recursive: true });
38
+ const root = await realpath(target);
39
+ const name = path.basename(root).toLowerCase().replace(/[^a-z0-9-]/g, '-') || 'mintfolio-site';
40
+ await writeFile(path.join(root, 'package.json'), JSON.stringify({ name, private: true, version: '1.0.0', type: 'module' }, null, 2) + '\n', { flag: 'wx' });
41
+ if (registry) await writeFile(path.join(root, '.npmrc'), `registry=https://registry.npmjs.org/\n@mintfolio:registry=${registry}\n`, { flag: 'wx' });
42
+ await npm(['install', `@mintfolio/core@^${await cliVersion()}`, '--no-audit'], root);
43
+ await initialize(root);
44
+ if (options.theme && options.theme !== 'minimal') await installTheme(root, options.theme, true);
45
+ console.log(`站点已创建:${root}\n进入目录后执行 mintfolio dev。`);
46
+ return root;
47
+ }
48
+
49
+ /**
50
+ * Diagnose the actual installed host and selected theme without changing files.
51
+ * @param {string} root Site root.
52
+ * @returns {Promise<object>} Runtime paths, versions and configuration status.
53
+ */
54
+ export async function doctor(root) {
55
+ if (!semver.gte(process.versions.node, '22.12.0')) throw new Error('需要 Node.js >= 22.12.0。');
56
+ const host = createRequire(path.join(root, 'package.json'));
57
+ const corePath = host.resolve('@mintfolio/core/package.json');
58
+ const core = JSON.parse(await readFile(corePath, 'utf8'));
59
+ const coreRequire = createRequire(corePath);
60
+ const astro = JSON.parse(await readFile(coreRequire.resolve('astro/package.json'), 'utf8'));
61
+ const active = await activeTheme(root, process.env.MINTFOLIO_THEME || undefined);
62
+ const site = await getConfig(root, 'site');
63
+ if (!site.site) throw new Error('site.config.ts 缺少 site 信息。');
64
+ return { root, node: process.versions.node, cli: await cliVersion(), core: core.version, astro: astro.version, theme: active.selector, themeVersion: active.definition.manifest.version, config: active.themeConfigFile, themeConfigExists: await exists(active.themeConfigFile), status: 'ok' };
65
+ }
@@ -0,0 +1,119 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
4
+ import semver from 'semver';
5
+ import { createThemeConfig, normalizeThemeName } from '../theme-config.mjs';
6
+ import { loadTheme } from '../../src/engine/loader.mjs';
7
+ import { mergeThemeSettings } from '../../src/engine/theme-config.mjs';
8
+ import { resolveSettings } from '../../src/engine/schema.mjs';
9
+ import { configSource, setSourceValue, sourceNode, sourceValue } from './config-source.mjs';
10
+ import { checkedPath, exists, saveFile } from './files.mjs';
11
+ import { npm } from './process.mjs';
12
+
13
+ let revision = 0;
14
+
15
+ /** @param {string} root Site root. @returns {Promise<object>} Persistent theme selection, independent of a temporary environment override. */
16
+ export async function readSelection(root) {
17
+ const filename = await checkedPath(root, path.join(root, 'theme.config.mjs'));
18
+ const url = pathToFileURL(filename);
19
+ url.searchParams.set('cli', `${Date.now()}-${++revision}`);
20
+ const selection = (await import(url.href)).default;
21
+ if (!selection || typeof selection !== 'object' || Array.isArray(selection)) throw new Error('theme.config.mjs 必须导出配置对象。');
22
+ return selection;
23
+ }
24
+
25
+ /** @param {string} root @param {string} [selector] Explicit package, local theme or Minimal. @returns {Promise<object>} Validated selected theme and persistent selection. */
26
+ export async function activeTheme(root, selector) {
27
+ const selection = await readSelection(root);
28
+ const theme = normalizeThemeName(selector || selection.theme || 'minimal');
29
+ const same = theme === normalizeThemeName(selection.theme || 'minimal');
30
+ const active = await loadTheme({ root, theme, settings: same ? selection.settings : {}, overrides: same ? selection.overrides : {} });
31
+ return { ...active, selection, selector: theme, same };
32
+ }
33
+
34
+ /** @param {string} spec npm package with optional version/range/tag; default and happyhues are aliases. @returns {{name:string,spec:string}} Validated package identity and install spec. */
35
+ export function packageSpec(spec) {
36
+ const separator = spec.lastIndexOf('@');
37
+ const split = separator > spec.indexOf('/') && separator > 0;
38
+ const name = normalizeThemeName(split ? spec.slice(0, separator) : spec);
39
+ const version = split ? spec.slice(separator + 1) : '';
40
+ if (typeof name !== 'string' || !/^(?:@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/.test(name) || name === 'minimal' || (split && !version) || (version && !semver.validRange(version) && !/^[a-z][a-z0-9._-]*$/i.test(version))) throw new Error('请提供 npm 主题包名,可附加 @版本;例如 default 或 @mintfolio/theme-default@0.1.1。');
41
+ return { name, spec: name + (version ? `@${version}` : '') };
42
+ }
43
+
44
+ /** @param {string} root @returns {Promise<Array<{name:string,version:string,selected:boolean}>>} Built-in Minimal and directly installed themes. */
45
+ export async function listThemes(root) {
46
+ const selection = await readSelection(root);
47
+ const selected = normalizeThemeName(selection.theme || 'minimal');
48
+ const pkg = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
49
+ const themes = [{ name: 'minimal', version: 'built-in', selected: selected === 'minimal' }];
50
+ const dependencies = { ...pkg.dependencies, ...pkg.devDependencies, ...pkg.optionalDependencies };
51
+ for (const name of Object.keys(dependencies)) {
52
+ const filename = path.join(root, 'node_modules', name, 'package.json');
53
+ if (!await exists(filename)) continue;
54
+ const installed = JSON.parse(await readFile(filename, 'utf8'));
55
+ if (installed.exports?.['./theme'] && name !== '@mintfolio/core') themes.push({ name, version: installed.version, selected: selected === name });
56
+ }
57
+ if (!themes.some(theme => theme.name === selected)) themes.push({ name: selected, version: 'local', selected: true });
58
+ return themes;
59
+ }
60
+
61
+ /** @param {string} root @param {string} spec npm theme spec. @param {boolean} use Select after a successful install. */
62
+ export async function installTheme(root, spec, use = false) {
63
+ const pkg = packageSpec(spec);
64
+ await npm(['install', pkg.spec, '--no-audit'], root);
65
+ const result = await createThemeConfig(root, pkg.name);
66
+ console.log(`${result.created ? '已生成' : '已保留'} ${result.filename}`);
67
+ if (use) await useTheme(root, pkg.name);
68
+ else console.log(`启用主题:mintfolio theme use ${pkg.name}`);
69
+ }
70
+
71
+ /** Apply an object as individual source edits so unrelated comments remain intact. */
72
+ function mergeSource(source, value, prefix = []) {
73
+ for (const [key, child] of Object.entries(value)) {
74
+ const keys = [...prefix, key];
75
+ if (child && typeof child === 'object' && !Array.isArray(child) && Object.keys(child).length) source = mergeSource(source, child, keys);
76
+ else source = setSourceValue(source, keys.join('.'), child);
77
+ }
78
+ return source;
79
+ }
80
+
81
+ /**
82
+ * Select an installed theme. Legacy inline settings are retained in the previous
83
+ * theme's own file before clearing the inline override; both changes get backups.
84
+ * @param {string} root Site root. @param {string} selector Installed theme/package or local directory.
85
+ * @returns {Promise<void>} New selection is validated before persistent changes.
86
+ */
87
+ export async function useTheme(root, selector) {
88
+ const theme = normalizeThemeName(selector);
89
+ const selection = await readSelection(root);
90
+ const previous = normalizeThemeName(selection.theme || 'minimal');
91
+ if (theme === previous) { await activeTheme(root, theme); console.log(`当前已使用 ${theme}`); return; }
92
+ // Page overrides are intentional host choices; keep them and validate them with the new theme.
93
+ await loadTheme({ root, theme, overrides: selection.overrides });
94
+ const filename = path.join(root, 'theme.config.mjs');
95
+ const original = await readFile(filename, 'utf8');
96
+ let source = setSourceValue(original, 'theme', theme, filename);
97
+ const inline = selection.settings;
98
+ let migration;
99
+ if (inline && Object.keys(inline).length) {
100
+ const current = await activeTheme(root);
101
+ const doc = configSource(original, filename);
102
+ const literal = sourceValue(doc, sourceNode(doc, ['settings']));
103
+ if (JSON.stringify(literal) !== JSON.stringify(inline)) throw new Error('旧的内联 settings 包含动态表达式,请先移到对应主题配置文件再切换。');
104
+ const result = await createThemeConfig(root, previous);
105
+ const before = await readFile(result.filename, 'utf8');
106
+ const after = mergeSource(before, inline);
107
+ resolveSettings(current.definition, mergeThemeSettings(sourceValue(configSource(after)), {}));
108
+ migration = { filename: result.filename, before, after };
109
+ source = setSourceValue(source, 'settings', {}, filename);
110
+ }
111
+ await createThemeConfig(root, theme);
112
+ // Check the selection again before the first mutation to avoid stale writes.
113
+ if (await readFile(filename, 'utf8') !== original) throw new Error('主题选择已被其他程序修改,请重试。');
114
+ if (migration) await saveFile(root, migration.filename, migration.before, migration.after);
115
+ const backup = await saveFile(root, filename, original, source);
116
+ console.log(`已启用 ${theme}`);
117
+ if (migration) console.log(`旧主题设置已保留在 ${migration.filename}`);
118
+ if (backup) console.log(`备份:${backup}`);
119
+ }
@@ -0,0 +1,244 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
3
+ import { mkdir, realpath } from 'node:fs/promises';
4
+ import path from 'node:path';
5
+ import { createRequire } from 'node:module';
6
+ import { initialize } from './lib/init.mjs';
7
+ import { cliVersion, createSite, doctor, requireThemeConfigRuntime } from './lib/site.mjs';
8
+ import { createPost, listPosts, setDraft } from './lib/posts.mjs';
9
+ import { activeTheme, installTheme, listThemes, packageSpec, readSelection, useTheme } from './lib/themes.mjs';
10
+ import { configFilename, configSchema, getConfig, setConfig } from './lib/config.mjs';
11
+ import { display, exists, findSite } from './lib/files.mjs';
12
+ import { astroEntry, editFile, npm, run } from './lib/process.mjs';
13
+ import { createThemeConfig, reportThemeConfigs, syncThemeConfigs } from './theme-config.mjs';
14
+
15
+ const help = {
16
+ main: `Mintfolio — 站点、文章、主题和配置管理
17
+
18
+ 用法:mintfolio [--cwd <目录>] <命令>
19
+
20
+ create <目录> [--theme default] 创建站点并安装依赖
21
+ init 补齐当前站点的初始化文件
22
+ upgrade [--version <版本>] 更新当前站点的 Core
23
+ dev / build / preview / sync 开发、构建、预览或同步内容
24
+ post new|list|publish|draft 创建和管理文章
25
+ theme list|current|install|use 安装和切换主题
26
+ theme init|sync|check 生成和校验主题配置
27
+ config get|set|path|edit|schema 查看、修改或打开配置
28
+ doctor 检查站点环境与已安装版本
29
+ check 校验主题并同步 Astro 内容
30
+ --version 显示 CLI 版本
31
+
32
+ 使用 mintfolio <命令> --help 查看用法。
33
+ 在站点子目录也可运行;--cwd / -C 指定其他站点。
34
+ 旧的 theme:add/theme:init/theme:sync/theme:check 命令继续有效。`,
35
+ create: `用法:mintfolio create <空目录> [--theme <主题>] [--registry <URL>]
36
+
37
+ 安装 Core,生成站点文件;--theme 可同时安装并启用主题。
38
+ --registry 只配置新站点的 @mintfolio 仓库,例如本地仓库地址。`,
39
+ init: '用法:mintfolio init\n在已安装 Core 的当前目录补齐初始化文件,保留已有内容和 npm scripts。',
40
+ upgrade: '用法:mintfolio upgrade [--version <版本或标签>]\n从当前站点配置的 npm 仓库更新 Core,默认 latest。更新后请重新启动开发服务。',
41
+ post: `用法:mintfolio post <操作>
42
+
43
+ new <标题> [--slug <ID>] [--description <摘要>] [--category <分类>]
44
+ [--tags <逗号分隔标签>] [--date YYYY-MM-DD] [--publish]
45
+ list [--draft | --published] [--json]
46
+ publish <ID> 将 draft 设为 false,下一次构建可见
47
+ draft <ID> 将 draft 设为 true
48
+
49
+ 新文章默认是草稿,存放在 content/blog;ID 支持 notes/hello 等目录。
50
+ 标题含空格时请加引号。不会覆盖同名文件。publish 不执行部署。
51
+ 示例:mintfolio post new "我的第一篇文章" --slug first-post --tags 随笔,生活`,
52
+ theme: `用法:mintfolio theme <操作>
53
+
54
+ list [--json] 列出内置和已安装主题
55
+ current 显示当前配置中选定的主题
56
+ install <包名[@版本]> [--use] 安装主题并生成配置,可同时启用
57
+ use <主题> 选择 minimal、已安装包或本地主题目录
58
+ init [主题] 生成完整配置,保留已有文件
59
+ sync 补齐已安装主题的配置
60
+ check [主题] 校验清单、配置和页面路径
61
+
62
+ verdant 是 @mintfolio/theme-default 的别名;default / happyhues 继续兼容。
63
+ 示例:mintfolio theme install default --use`,
64
+ config: `用法:mintfolio config <操作> <site|theme>
65
+
66
+ get <范围> [字段] 查看配置;theme 返回合并默认值后的设置
67
+ set <范围> <字段> <值> [--json] 修改单项,保存前校验并备份
68
+ path <范围> 显示配置路径
69
+ edit <范围> [--editor <程序>] 用编辑器打开配置
70
+ schema <范围> 查看可用字段和限制
71
+
72
+ theme 范围可加 --theme <主题> 编辑未启用主题。
73
+ 字段用点号访问,例如 sidebar.quote.enabled;数组下标也用点号。
74
+ 字符串直接填写;布尔值用 true/false,数组和对象用 JSON。
75
+ site 值按源文件读取;动态表达式显示为 $expression,不执行导入。
76
+ 修改会保留周围注释,并在 .mintfolio/backups 中备份原文件。
77
+
78
+ 示例:mintfolio config set site site.title "我的博客"
79
+ mintfolio config set theme initialPalette 3
80
+ mintfolio config set theme sidebar.quote.enabled true`,
81
+ doctor: '用法:mintfolio doctor [--json]\n检查 Node、当前站点 Core/Astro、主题与配置,不修改文件。',
82
+ check: '用法:mintfolio check\n检查站点和主题,然后运行 Astro sync 校验内容并生成类型;不代替生产构建。',
83
+ };
84
+
85
+ /** @param {string[]} args @param {object} [options] @returns {{values:object,positionals:string[]}} Strict argument parsing shared by commands. */
86
+ function options(args, options = {}) { return parseArgs({ args, options: { ...options, help: { type: 'boolean', short: 'h' } }, allowPositionals: true, strict: true }); }
87
+
88
+ /** @param {string[]} positionals @param {number} min @param {number} max @param {string} group */
89
+ function count(positionals, min, max, group) {
90
+ if (positionals.length < min || positionals.length > max) throw new Error(`参数数量不正确。\n${help[group]}`);
91
+ }
92
+
93
+ function printHelp(group = 'main') {
94
+ if (!help[group]) throw new Error(`未知命令:${group}`);
95
+ console.log(help[group]);
96
+ }
97
+
98
+ /** @param {string[]} argv Raw process arguments. @returns {Promise<void>} Dispatch one command and propagate failures. */
99
+ async function main(argv) {
100
+ let cwd = process.cwd();
101
+ if (['--cwd', '-C'].includes(argv[0])) {
102
+ if (!argv[1]) throw new Error('--cwd 需要目录。');
103
+ cwd = path.resolve(argv[1]);
104
+ argv = argv.slice(2);
105
+ }
106
+ let [command = 'help', ...args] = argv;
107
+ if (['--version', '-v', 'version'].includes(command)) { console.log(await cliVersion()); return; }
108
+ if (['help', '--help', '-h'].includes(command)) { printHelp(args[0] || 'main'); return; }
109
+ const aliases = { 'theme:add': 'install', 'theme:init': 'init', 'theme:sync': 'sync', 'theme:check': 'check' };
110
+ if (Object.hasOwn(aliases, command)) { args.unshift(aliases[command]); command = 'theme'; }
111
+ if (command === 'new') { command = 'post'; args.unshift('new'); }
112
+
113
+ if (command === 'create') {
114
+ const parsed = options(args, { theme: { type: 'string' }, registry: { type: 'string' } });
115
+ if (parsed.values.help) return printHelp(command);
116
+ count(parsed.positionals, 1, 1, command);
117
+ await createSite(path.resolve(cwd, parsed.positionals[0]), parsed.values);
118
+ return;
119
+ }
120
+ if (command === 'init') {
121
+ const parsed = options(args);
122
+ if (parsed.values.help) return printHelp(command);
123
+ count(parsed.positionals, 0, 0, command);
124
+ await mkdir(cwd, { recursive: true });
125
+ const root = await realpath(cwd);
126
+ try { createRequire(path.join(root, 'package.json')).resolve('@mintfolio/core/package.json'); }
127
+ catch { throw new Error('当前目录尚未安装 Core。请先 npm install @mintfolio/core,或使用 mintfolio create <目录>。'); }
128
+ await initialize(root);
129
+ return;
130
+ }
131
+ if (command === 'upgrade') {
132
+ const parsed = options(args, { version: { type: 'string' } });
133
+ if (parsed.values.help) return printHelp(command);
134
+ count(parsed.positionals, 0, 0, command);
135
+ const root = await findSite(cwd);
136
+ const target = packageSpec(`@mintfolio/core@${parsed.values.version || 'latest'}`);
137
+ await npm(['install', target.spec, '--no-audit'], root);
138
+ console.log('Core 已更新,请重新启动开发服务。');
139
+ return;
140
+ }
141
+ if (['dev', 'build', 'preview', 'sync'].includes(command)) {
142
+ const root = await findSite(cwd);
143
+ if (!args.includes('--help') && !args.includes('-h') && command !== 'preview') reportThemeConfigs(await syncThemeConfigs(root));
144
+ await run(process.execPath, [astroEntry(root), command, ...args], root);
145
+ return;
146
+ }
147
+ if (command === 'post') {
148
+ const [action, ...rest] = args;
149
+ if (!action || action === '--help' || action === '-h') return printHelp(command);
150
+ const definitions = action === 'new' ? { slug: { type: 'string' }, description: { type: 'string' }, category: { type: 'string' }, tags: { type: 'string' }, date: { type: 'string' }, publish: { type: 'boolean' } }
151
+ : action === 'list' ? { draft: { type: 'boolean' }, published: { type: 'boolean' }, json: { type: 'boolean' } } : {};
152
+ const parsed = options(rest, definitions);
153
+ if (parsed.values.help) return printHelp(command);
154
+ if (!['new', 'list', 'publish', 'draft'].includes(action)) throw new Error(`未知文章操作:${action}`);
155
+ count(parsed.positionals, action === 'list' ? 0 : 1, action === 'list' ? 0 : 1, command);
156
+ const root = await findSite(cwd);
157
+ if (action === 'new') console.log(`已创建${parsed.values.publish ? '文章' : '草稿'}:${await createPost(root, parsed.positionals[0], parsed.values)}`);
158
+ else if (action === 'list') {
159
+ if (parsed.values.draft && parsed.values.published) throw new Error('--draft 和 --published 不能同时使用。');
160
+ const posts = (await listPosts(root)).filter(post => parsed.values.draft ? post.draft : parsed.values.published ? !post.draft : true);
161
+ if (parsed.values.json) console.log(JSON.stringify(posts, null, 2));
162
+ else if (!posts.length) console.log('没有匹配的文章。');
163
+ else for (const post of posts) console.log(`${post.draft ? '草稿' : '已发布'} ${post.date} ${post.slug} ${post.title}`);
164
+ } else {
165
+ const result = await setDraft(root, parsed.positionals[0], action === 'draft');
166
+ console.log(`${action === 'draft' ? '已设为草稿' : '已标记发布,下次构建生效'}:${result.filename}`);
167
+ if (result.backup) console.log(`备份:${result.backup}`);
168
+ }
169
+ return;
170
+ }
171
+ if (command === 'theme') {
172
+ const [action, ...rest] = args;
173
+ if (!action || action === '--help' || action === '-h') return printHelp(command);
174
+ const parsed = options(rest, action === 'install' ? { use: { type: 'boolean' } } : action === 'list' ? { json: { type: 'boolean' } } : {});
175
+ if (parsed.values.help) return printHelp(command);
176
+ if (!['list', 'current', 'install', 'use', 'init', 'sync', 'check'].includes(action)) throw new Error(`未知主题操作:${action}`);
177
+ const required = ['install', 'use'].includes(action);
178
+ count(parsed.positionals, required ? 1 : 0, ['install', 'use', 'init', 'check'].includes(action) ? 1 : 0, command);
179
+ const root = await findSite(cwd);
180
+ if (action === 'list') {
181
+ const themes = await listThemes(root);
182
+ if (parsed.values.json) console.log(JSON.stringify(themes, null, 2));
183
+ else for (const theme of themes) console.log(`${theme.selected ? '*' : ' '} ${theme.name} ${theme.version}`);
184
+ } else if (action === 'current') console.log((await activeTheme(root)).selector);
185
+ else if (action === 'install') await installTheme(root, parsed.positionals[0], parsed.values.use);
186
+ else if (action === 'use') { await requireThemeConfigRuntime(root); await useTheme(root, parsed.positionals[0]); }
187
+ else if (action === 'sync') reportThemeConfigs(await syncThemeConfigs(root));
188
+ else if (action === 'init') {
189
+ const selection = await readSelection(root);
190
+ const result = await createThemeConfig(root, parsed.positionals[0] || selection.theme || 'minimal');
191
+ console.log(`${result.created ? '已生成' : '已保留'} ${result.filename}`);
192
+ } else {
193
+ const active = await activeTheme(root, parsed.positionals[0] || process.env.MINTFOLIO_THEME);
194
+ console.log(`主题 ${active.definition.manifest.id} 的清单、配置与页面路径校验通过。`);
195
+ }
196
+ return;
197
+ }
198
+ if (command === 'config') {
199
+ const [action, ...rest] = args;
200
+ if (!action || action === '--help' || action === '-h') return printHelp(command);
201
+ const definitions = { theme: { type: 'string' }, ...(action === 'set' ? { json: { type: 'boolean' } } : {}), ...(action === 'edit' ? { editor: { type: 'string' } } : {}) };
202
+ const parsed = options(rest, definitions);
203
+ if (parsed.values.help) return printHelp(command);
204
+ if (!['get', 'set', 'path', 'edit', 'schema'].includes(action)) throw new Error(`未知配置操作:${action}`);
205
+ count(parsed.positionals, action === 'set' ? 3 : 1, action === 'set' ? 3 : action === 'get' ? 2 : 1, command);
206
+ const [scope, key, raw] = parsed.positionals;
207
+ if (!['site', 'theme'].includes(scope)) throw new Error('配置范围为 site 或 theme。');
208
+ if (scope === 'site' && parsed.values.theme) throw new Error('--theme 只用于 theme 配置。');
209
+ const root = await findSite(cwd);
210
+ if (scope === 'theme') await requireThemeConfigRuntime(root);
211
+ if (action === 'get') console.log(display(await getConfig(root, scope, key, parsed.values.theme)));
212
+ else if (action === 'schema') console.log(display(await configSchema(root, scope, parsed.values.theme)));
213
+ else if (action === 'set') {
214
+ const result = await setConfig(root, scope, key, raw, parsed.values);
215
+ console.log(`${key} = ${display(result.value)}\n文件:${result.filename}`);
216
+ if (result.backup) console.log(`备份:${result.backup}`);
217
+ } else {
218
+ const filename = await configFilename(root, scope, parsed.values.theme);
219
+ if (action === 'path') console.log(filename);
220
+ else {
221
+ if (!await exists(filename) && scope === 'theme') await createThemeConfig(root, parsed.values.theme || (await readSelection(root)).theme || 'minimal');
222
+ await editFile(filename, parsed.values.editor, root);
223
+ }
224
+ }
225
+ return;
226
+ }
227
+ if (command === 'doctor' || command === 'check') {
228
+ const parsed = options(args, command === 'doctor' ? { json: { type: 'boolean' } } : {});
229
+ if (parsed.values.help) return printHelp(command);
230
+ count(parsed.positionals, 0, 0, command);
231
+ const root = await findSite(cwd);
232
+ const result = await doctor(root);
233
+ console.log(parsed.values.json ? JSON.stringify(result, null, 2) : `环境检查通过\n站点:${result.root}\nNode ${result.node} · Core ${result.core} · Astro ${result.astro}\n主题:${result.theme} ${result.themeVersion}\n配置:${result.config}`);
234
+ if (command === 'check') await run(process.execPath, [astroEntry(root), 'sync'], root);
235
+ return;
236
+ }
237
+ throw new Error(`未知命令:${command}\n运行 mintfolio --help 查看可用命令。`);
238
+ }
239
+
240
+ try { await main(process.argv.slice(2)); }
241
+ catch (error) {
242
+ process.stderr.write(`[mintfolio] ${error instanceof Error ? error.message : String(error)}\n`);
243
+ process.exitCode = error.exitCode || 1;
244
+ }