@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.
- package/LICENSE +674 -0
- package/README.md +104 -0
- package/THIRD_PARTY_NOTICES.md +5 -0
- package/bin/lib/config-source.mjs +147 -0
- package/bin/lib/config.mjs +144 -0
- package/bin/lib/files.mjs +103 -0
- package/bin/lib/init.mjs +67 -0
- package/bin/lib/posts.mjs +114 -0
- package/bin/lib/process.mjs +67 -0
- package/bin/lib/site.mjs +65 -0
- package/bin/lib/themes.mjs +119 -0
- package/bin/mintfolio.mjs +244 -0
- package/bin/theme-config.mjs +111 -0
- package/dist/client/archive.d.ts +7 -0
- package/dist/client/archive.js +47 -0
- package/dist/client/code.d.ts +4 -0
- package/dist/client/code.js +126 -0
- package/dist/client/lifecycle.d.ts +18 -0
- package/dist/client/lifecycle.js +82 -0
- package/dist/client/lightbox.d.ts +24 -0
- package/dist/client/lightbox.js +142 -0
- package/dist/client/navigation.d.ts +22 -0
- package/dist/client/navigation.js +29 -0
- package/dist/client/postList.d.ts +41 -0
- package/dist/client/postList.js +71 -0
- package/dist/client/protectedArticle.d.ts +26 -0
- package/dist/client/protectedArticle.js +64 -0
- package/dist/client/toc.d.ts +22 -0
- package/dist/client/toc.js +90 -0
- package/dist/public/astro.d.ts +2 -0
- package/dist/public/astro.js +2 -0
- package/dist/public/client.d.ts +10 -0
- package/dist/public/client.js +10 -0
- package/dist/public/config.d.ts +32 -0
- package/dist/public/config.js +21 -0
- package/dist/public/search.d.ts +2 -0
- package/dist/public/search.js +2 -0
- package/dist/public/theme.d.ts +2 -0
- package/dist/public/theme.js +2 -0
- package/docs/cli.md +118 -0
- package/package.json +88 -0
- package/src/client/archive.ts +45 -0
- package/src/client/code.ts +141 -0
- package/src/client/lifecycle.ts +76 -0
- package/src/client/lightbox.ts +163 -0
- package/src/client/navigation.ts +46 -0
- package/src/client/postList.ts +92 -0
- package/src/client/protectedArticle.ts +80 -0
- package/src/client/toc.ts +90 -0
- package/src/components/Image.astro +28 -0
- package/src/components/PostArchive.astro +48 -0
- package/src/components/ProtectedArticle.astro +56 -0
- package/src/components/SeoHead.astro +7 -0
- package/src/content.d.ts +15 -0
- package/src/content.mjs +19 -0
- package/src/engine/context.ts +55 -0
- package/src/engine/import-boundary.mjs +154 -0
- package/src/engine/integration.mjs +166 -0
- package/src/engine/loader.mjs +114 -0
- package/src/engine/runtime/post-page.astro +35 -0
- package/src/engine/schema.mjs +125 -0
- package/src/engine/theme-config.mjs +67 -0
- package/src/engine/virtual.d.ts +12 -0
- package/src/fallback/layouts/MinimalLayout.astro +45 -0
- package/src/fallback/pages/archive.astro +13 -0
- package/src/fallback/pages/home.astro +44 -0
- package/src/fallback/pages/not-found.astro +18 -0
- package/src/fallback/pages/page.astro +39 -0
- package/src/fallback/pages/post.astro +41 -0
- package/src/fallback/settings.ts +8 -0
- package/src/fallback/styles/minimal.css +109 -0
- package/src/fallback/theme.mjs +48 -0
- package/src/integration.d.ts +10 -0
- package/src/integration.mjs +10 -0
- package/src/public/astro.ts +2 -0
- package/src/public/client.ts +10 -0
- package/src/public/config.ts +43 -0
- package/src/public/search.ts +2 -0
- package/src/public/theme.ts +2 -0
- package/src/routes/404.astro +9 -0
- package/src/routes/about.astro +9 -0
- package/src/routes/blog/[...slug].astro +17 -0
- package/src/routes/blog/index.astro +9 -0
- package/src/routes/index.astro +9 -0
- package/src/routes/rss.xml.ts +34 -0
- package/src/routes/sitemap.xml.ts +42 -0
- package/src/server/pages.ts +34 -0
- package/src/server/postModel.ts +98 -0
- package/src/server/posts.ts +15 -0
- package/src/server/routing.ts +32 -0
- package/src/server/seo.ts +14 -0
- package/src/server/site.ts +28 -0
- package/src/server/xml.ts +8 -0
package/README.md
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
<p align="center"><img src="docs/assets/mintfolio-icon.png" width="112" height="112" alt="Mintfolio" /></p>
|
|
2
|
+
|
|
3
|
+
# Mintfolio
|
|
4
|
+
|
|
5
|
+
把个人主页和博客放在一起,用 Markdown 写文章,用主题决定它们的样子。
|
|
6
|
+
|
|
7
|
+
Mintfolio 基于 Astro。站点目录保存你的资料、文章和图片;引擎负责文章路由、搜索数据、RSS、Sitemap 和密码文章。换一个主题,内容仍然留在原处。
|
|
8
|
+
|
|
9
|
+
[使用教程](https://github.com/cnflwzh/mintfolio/wiki/Getting-Started) · [命令参考](docs/cli.md) · [开发主题](https://github.com/cnflwzh/mintfolio/wiki/Theme-Development) · [更新记录](CHANGELOG.md) · [反馈问题](https://github.com/cnflwzh/mintfolio/issues)
|
|
10
|
+
|
|
11
|
+

|
|
12
|
+
|
|
13
|
+
*上图使用 Verdant 主题和示例资料。只安装引擎时,默认使用文字为主的 Minimal。*
|
|
14
|
+
|
|
15
|
+
## 能做什么
|
|
16
|
+
|
|
17
|
+
- 用 Markdown 管理文章,支持分类、标签、标题与摘要搜索。
|
|
18
|
+
- 在主页展示个人资料、技能、项目和社交链接。
|
|
19
|
+
- 为单篇文章设置密码,在浏览器中解锁正文。
|
|
20
|
+
- 通过命令行新建草稿、安装主题、修改配置并构建网站。
|
|
21
|
+
- 输出静态文件,部署到普通 Web 服务器或静态托管平台。
|
|
22
|
+
|
|
23
|
+
Core 自带 Minimal;[Verdant](https://github.com/cnflwzh/mintfolio-theme-verdant) 提供文章卡片、八套配色、明暗模式、目录和阅读工具。第三方主题可以从两个 Astro 页面开始,再逐步补齐自己的布局。
|
|
24
|
+
|
|
25
|
+
## 开始使用
|
|
26
|
+
|
|
27
|
+
需要 **Node.js >= 22.12.0**。项目仍在持续开发中。
|
|
28
|
+
|
|
29
|
+
~~~sh
|
|
30
|
+
npm install -g @mintfolio/core
|
|
31
|
+
mintfolio create my-blog
|
|
32
|
+
cd my-blog
|
|
33
|
+
mintfolio dev
|
|
34
|
+
~~~
|
|
35
|
+
|
|
36
|
+
打开终端显示的地址。编辑 site.config.ts 填写资料,文章放在 content/blog。
|
|
37
|
+
|
|
38
|
+
想使用上图的 Verdant:
|
|
39
|
+
|
|
40
|
+
~~~sh
|
|
41
|
+
mintfolio theme install verdant --use
|
|
42
|
+
~~~
|
|
43
|
+
|
|
44
|
+
## 写第一篇文章
|
|
45
|
+
|
|
46
|
+
~~~sh
|
|
47
|
+
mintfolio post new "我的第一篇文章" --slug first-post
|
|
48
|
+
~~~
|
|
49
|
+
|
|
50
|
+
打开 content/blog/first-post.md 写正文。新文章默认是草稿,准备好后再发布并构建:
|
|
51
|
+
|
|
52
|
+
~~~sh
|
|
53
|
+
mintfolio post publish first-post
|
|
54
|
+
mintfolio build
|
|
55
|
+
mintfolio preview
|
|
56
|
+
~~~
|
|
57
|
+
|
|
58
|
+
post publish 修改本地草稿状态;build 生成 dist;部署时上传 dist 中的内容。[写作教程](https://github.com/cnflwzh/mintfolio/wiki/Writing) 介绍日期、封面、嵌套目录和密码文章。
|
|
59
|
+
|
|
60
|
+
## 常用操作
|
|
61
|
+
|
|
62
|
+
| 想做什么 | 命令或文件 |
|
|
63
|
+
| --- | --- |
|
|
64
|
+
| 修改网站标题 | mintfolio config set site site.title "我的博客" |
|
|
65
|
+
| 编辑个人资料 | site.config.ts |
|
|
66
|
+
| 调整主题设置 | mintfolio config edit theme |
|
|
67
|
+
| 查看主题配置项 | mintfolio config schema theme |
|
|
68
|
+
| 检查环境 | mintfolio doctor |
|
|
69
|
+
| 查看命令帮助 | mintfolio --help |
|
|
70
|
+
|
|
71
|
+
[配置教程](https://github.com/cnflwzh/mintfolio/wiki/Configuration) 说明内容与显示设置的区别、备案号、图片和配置优先级。
|
|
72
|
+
|
|
73
|
+
## 界面
|
|
74
|
+
|
|
75
|
+

|
|
76
|
+
|
|
77
|
+
<p><img src="docs/assets/home-mobile.webp" width="280" alt="手机上的 Verdant 首页" /> <img src="docs/assets/encrypted-mobile.webp" width="280" alt="手机上的密码文章" /></p>
|
|
78
|
+
|
|
79
|
+
## 开发状态
|
|
80
|
+
|
|
81
|
+
当前重点是完善写作流程、主题接口和跨平台安装。0.x 阶段仍可能调整接口;升级前请保留源文件与锁文件,并阅读版本说明。
|
|
82
|
+
|
|
83
|
+
目前需要知道的几件事:
|
|
84
|
+
|
|
85
|
+
- 草稿在开发和生产构建中都不会公开显示。
|
|
86
|
+
- 搜索匹配标题和公开摘要,不搜索全文;标签、分类与搜索共用博客归档页。
|
|
87
|
+
- Minimal 目前不显示备案号;Verdant 的首页和普通页面支持 icp 字段。
|
|
88
|
+
- 密码文章的正文和目录会被加密,标题、图片等信息仍公开。
|
|
89
|
+
- CLI 负责本地操作与构建,部署由你选择的托管平台完成。
|
|
90
|
+
|
|
91
|
+
## 仓库
|
|
92
|
+
|
|
93
|
+
| 仓库 | 内容 |
|
|
94
|
+
| --- | --- |
|
|
95
|
+
| [mintfolio](https://github.com/cnflwzh/mintfolio) | 项目入口、CLI、博客引擎与 Minimal |
|
|
96
|
+
| [mintfolio-theme-verdant](https://github.com/cnflwzh/mintfolio-theme-verdant) | Verdant 主题 |
|
|
97
|
+
| [mintfolio-theme-api](https://github.com/cnflwzh/mintfolio-theme-api) | 主题的公共类型与契约 |
|
|
98
|
+
| [mintfolio-theme-starter](https://github.com/cnflwzh/mintfolio-theme-starter) | 可直接修改的主题起点 |
|
|
99
|
+
|
|
100
|
+
想贡献代码,从 [CONTRIBUTING.md](CONTRIBUTING.md) 开始。发现安全问题,请按 [SECURITY.md](SECURITY.md) 私下报告。
|
|
101
|
+
|
|
102
|
+
## 许可证
|
|
103
|
+
|
|
104
|
+
[GPL-3.0-only](LICENSE)。第三方依赖和资源保留各自许可证,见 [第三方声明](THIRD_PARTY_NOTICES.md)。
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { parse } from '@babel/parser';
|
|
2
|
+
import { keyPath } from './files.mjs';
|
|
3
|
+
|
|
4
|
+
const wrappers = new Set(['TSAsExpression', 'TSSatisfiesExpression', 'TypeCastExpression', 'ParenthesizedExpression']);
|
|
5
|
+
const unsupported = () => new Error('该字段使用动态表达式、展开或不明确的定义,请用 mintfolio config edit 打开配置文件修改。');
|
|
6
|
+
|
|
7
|
+
function unwrap(node) {
|
|
8
|
+
while (node && wrappers.has(node.type)) node = node.expression;
|
|
9
|
+
return node;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Locate an exported config object without evaluating host code or its imports.
|
|
14
|
+
* Supports literals, top-level const aliases, TS satisfies/as, and defineSiteConfig.
|
|
15
|
+
* @param {string} source Original ESM/TypeScript source.
|
|
16
|
+
* @param {string} filename Used in syntax diagnostics.
|
|
17
|
+
* @returns {{source:string,root:object}} Parsed source with an editable object root.
|
|
18
|
+
*/
|
|
19
|
+
export function configSource(source, filename = 'config.mjs') {
|
|
20
|
+
const ast = parse(source, { sourceType: 'module', sourceFilename: filename, plugins: ['typescript'] });
|
|
21
|
+
const constants = new Map();
|
|
22
|
+
const siteHelpers = new Set();
|
|
23
|
+
for (const statement of ast.program.body) {
|
|
24
|
+
if (statement.type === 'VariableDeclaration' && statement.kind === 'const') {
|
|
25
|
+
for (const declaration of statement.declarations) if (declaration.id.type === 'Identifier') constants.set(declaration.id.name, declaration.init);
|
|
26
|
+
}
|
|
27
|
+
if (statement.type === 'ImportDeclaration' && statement.source.value === '@mintfolio/core/config') {
|
|
28
|
+
for (const specifier of statement.specifiers) if (specifier.type === 'ImportSpecifier' && specifier.imported.name === 'defineSiteConfig') siteHelpers.add(specifier.local.name);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const declaration = ast.program.body.find(statement => statement.type === 'ExportDefaultDeclaration');
|
|
32
|
+
if (!declaration) throw new Error(`${filename} 必须有 export default。`);
|
|
33
|
+
let root = unwrap(declaration.declaration);
|
|
34
|
+
const visited = new Set();
|
|
35
|
+
while (root?.type === 'Identifier') {
|
|
36
|
+
if (visited.has(root.name)) throw unsupported();
|
|
37
|
+
visited.add(root.name);
|
|
38
|
+
root = unwrap(constants.get(root.name));
|
|
39
|
+
}
|
|
40
|
+
if (root?.type === 'CallExpression' && root.callee.type === 'Identifier' && siteHelpers.has(root.callee.name) && root.arguments.length === 1) root = unwrap(root.arguments[0]);
|
|
41
|
+
if (root?.type !== 'ObjectExpression') throw unsupported();
|
|
42
|
+
return { source, root };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function propertyName(property) {
|
|
46
|
+
if (property.type !== 'ObjectProperty' || property.computed || property.method) throw unsupported();
|
|
47
|
+
if (property.key.type === 'Identifier') return property.key.name;
|
|
48
|
+
if (property.key.type === 'StringLiteral' || property.key.type === 'NumericLiteral') return String(property.key.value);
|
|
49
|
+
throw unsupported();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function properties(node) {
|
|
53
|
+
const entries = new Map();
|
|
54
|
+
for (const property of node.properties) {
|
|
55
|
+
const name = propertyName(property);
|
|
56
|
+
if (entries.has(name)) throw unsupported();
|
|
57
|
+
entries.set(name, property);
|
|
58
|
+
}
|
|
59
|
+
return entries;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @param {{source:string,root:object}} document @param {string[]} keys @returns {object|undefined} Source node for a direct field. */
|
|
63
|
+
export function sourceNode(document, keys) {
|
|
64
|
+
let node = document.root;
|
|
65
|
+
for (const key of keys) {
|
|
66
|
+
node = unwrap(node);
|
|
67
|
+
if (node?.type === 'ObjectExpression') node = properties(node).get(key)?.value;
|
|
68
|
+
else if (node?.type === 'ArrayExpression' && /^(0|[1-9]\d*)$/.test(key)) node = node.elements[Number(key)];
|
|
69
|
+
else if (node === undefined) return undefined;
|
|
70
|
+
else throw unsupported();
|
|
71
|
+
}
|
|
72
|
+
return unwrap(node);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Read literal data. Expressions are represented explicitly, never executed.
|
|
77
|
+
* @param {{source:string,root:object}} document Parsed config.
|
|
78
|
+
* @param {object|undefined} [node] Node to inspect, or the default export.
|
|
79
|
+
* @returns {unknown} JSON-compatible value; dynamic leaves have a $expression key.
|
|
80
|
+
*/
|
|
81
|
+
export function sourceValue(document, node = document.root) {
|
|
82
|
+
node = unwrap(node);
|
|
83
|
+
if (!node) return undefined;
|
|
84
|
+
if (['StringLiteral', 'NumericLiteral', 'BooleanLiteral'].includes(node.type)) return node.value;
|
|
85
|
+
if (node.type === 'NullLiteral') return null;
|
|
86
|
+
if (node.type === 'UnaryExpression' && node.operator === '-' && node.argument.type === 'NumericLiteral') return -node.argument.value;
|
|
87
|
+
if (node.type === 'TemplateLiteral' && node.expressions.length === 0) return node.quasis[0].value.cooked;
|
|
88
|
+
if (node.type === 'ArrayExpression') return node.elements.map(element => element ? sourceValue(document, element) : null);
|
|
89
|
+
if (node.type === 'ObjectExpression') return Object.fromEntries([...properties(node)].map(([name, property]) => [name, sourceValue(document, property.value)]));
|
|
90
|
+
return { $expression: document.source.slice(node.start, node.end) };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function indentation(source, index) {
|
|
94
|
+
return source.slice(source.lastIndexOf('\n', index - 1) + 1, index).match(/^[\t ]*/)[0];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Change just one field's source range, preserving imports and surrounding comments.
|
|
99
|
+
* Unknown intermediate objects are created; ambiguous/dynamic containers fail.
|
|
100
|
+
* @param {string} source Original ESM/TS content.
|
|
101
|
+
* @param {string} key Dotted field path; numeric segments address existing array items.
|
|
102
|
+
* @param {unknown} value JSON-serializable replacement.
|
|
103
|
+
* @param {string} [filename] Diagnostic filename.
|
|
104
|
+
* @returns {string} Updated source, still with the original surrounding formatting.
|
|
105
|
+
*/
|
|
106
|
+
export function setSourceValue(source, key, value, filename) {
|
|
107
|
+
const document = configSource(source, filename);
|
|
108
|
+
const keys = keyPath(key);
|
|
109
|
+
const eol = source.includes('\r\n') ? '\r\n' : '\n';
|
|
110
|
+
const format = (input, indent) => JSON.stringify(input, null, 2).replace(/\n/g, eol + indent);
|
|
111
|
+
let container = document.root;
|
|
112
|
+
for (let index = 0; index < keys.length; index++) {
|
|
113
|
+
container = unwrap(container);
|
|
114
|
+
const name = keys[index];
|
|
115
|
+
let node;
|
|
116
|
+
let property;
|
|
117
|
+
if (container?.type === 'ObjectExpression') {
|
|
118
|
+
property = properties(container).get(name);
|
|
119
|
+
node = property?.value;
|
|
120
|
+
} else if (container?.type === 'ArrayExpression' && /^(0|[1-9]\d*)$/.test(name) && Number(name) < container.elements.length) node = container.elements[Number(name)];
|
|
121
|
+
else throw unsupported();
|
|
122
|
+
if (node && index < keys.length - 1) { container = node; continue; }
|
|
123
|
+
let replacement = value;
|
|
124
|
+
for (const tail of keys.slice(index + 1).reverse()) {
|
|
125
|
+
if (/^\d+$/.test(tail)) throw new Error('不能通过下标创建数组,请用 JSON 设置完整数组。');
|
|
126
|
+
replacement = { [tail]: replacement };
|
|
127
|
+
}
|
|
128
|
+
if (node) {
|
|
129
|
+
if (property?.shorthand) return source.slice(0, property.start) + `${name}: ${format(replacement, indentation(source, property.start))}` + source.slice(property.end);
|
|
130
|
+
return source.slice(0, node.start) + format(replacement, indentation(source, node.start)) + source.slice(node.end);
|
|
131
|
+
}
|
|
132
|
+
const last = container.properties.at(-1);
|
|
133
|
+
const baseIndent = indentation(source, container.start);
|
|
134
|
+
const indent = last ? indentation(source, last.start) || baseIndent + ' ' : baseIndent + ' ';
|
|
135
|
+
const entry = `${JSON.stringify(name)}: ${format(replacement, indent)},`;
|
|
136
|
+
const insertion = `${eol}${indent}${entry}${eol}${baseIndent}`;
|
|
137
|
+
const edits = [{ start: container.end - 1, end: container.end - 1, text: insertion }];
|
|
138
|
+
// Babel excludes a property's trailing comma from its range. Ignore comments
|
|
139
|
+
// while checking that comma, then place any missing comma before the comment.
|
|
140
|
+
if (last && !/^\s*,/.test(source.slice(last.end, container.end - 1).replace(/\/\*[\s\S]*?\*\/|\/\/[^\r\n]*/g, ''))) edits.push({ start: last.end, end: last.end, text: ',' });
|
|
141
|
+
let result = source;
|
|
142
|
+
for (const edit of edits.sort((a, b) => b.start - a.start)) result = result.slice(0, edit.start) + edit.text + result.slice(edit.end);
|
|
143
|
+
configSource(result, filename);
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
throw unsupported();
|
|
147
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { z } from 'astro/zod';
|
|
4
|
+
import { activeTheme, readSelection } from './themes.mjs';
|
|
5
|
+
import { createThemeConfig } from '../theme-config.mjs';
|
|
6
|
+
import { loadTheme } from '../../src/engine/loader.mjs';
|
|
7
|
+
import { resolveSettings } from '../../src/engine/schema.mjs';
|
|
8
|
+
import { configSource, setSourceValue, sourceNode, sourceValue } from './config-source.mjs';
|
|
9
|
+
import { checkedPath, getIn, keyPath, saveFile, setIn } from './files.mjs';
|
|
10
|
+
|
|
11
|
+
const string = z.string();
|
|
12
|
+
const image = z.union([string, z.object({ src: string, width: z.number(), height: z.number(), format: string.optional() })]);
|
|
13
|
+
/** CLI input validation mirrors the public SiteConfigInput, without evaluating site imports. */
|
|
14
|
+
const siteSchema = z.object({
|
|
15
|
+
site: z.object({
|
|
16
|
+
title: string.refine(value => value.trim().length > 0, '标题不能为空'),
|
|
17
|
+
url: string.url().refine(value => ['http:', 'https:'].includes(new URL(value).protocol), '域名须使用 HTTP 或 HTTPS'),
|
|
18
|
+
description: string.optional(), language: string.optional(),
|
|
19
|
+
}).strict(),
|
|
20
|
+
profile: z.object({ name: string.optional(), avatar: image.optional(), bio: string.optional(), location: string.optional(), signature: string.optional() }).strict(),
|
|
21
|
+
social: z.array(z.object({ platform: string, url: string, icon: string }).strict()),
|
|
22
|
+
skills: z.array(z.object({ category: string, items: z.array(z.object({ name: string, level: z.number().min(0).max(100) }).strict()) }).strict()),
|
|
23
|
+
projects: z.array(z.object({ title: string, description: string, link: string.optional(), repo: string.optional(), tags: z.array(string), image: string.optional() }).strict()),
|
|
24
|
+
contact: z.object({ email: string.optional(), social: z.array(string).optional() }).strict(),
|
|
25
|
+
icp: string,
|
|
26
|
+
}).strict();
|
|
27
|
+
|
|
28
|
+
function siteField(keys) {
|
|
29
|
+
let field = siteSchema;
|
|
30
|
+
for (const key of keys) {
|
|
31
|
+
while (field instanceof z.ZodOptional) field = field.unwrap();
|
|
32
|
+
if (field instanceof z.ZodObject) field = field.shape[key];
|
|
33
|
+
else if (field instanceof z.ZodArray && /^(0|[1-9]\d*)$/.test(key)) field = field.element;
|
|
34
|
+
else field = undefined;
|
|
35
|
+
if (!field) throw new Error(`未知站点配置项:${keys.join('.')}`);
|
|
36
|
+
}
|
|
37
|
+
return field;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parse values against the target field. String fields keep numeric-looking text;
|
|
42
|
+
* booleans/numbers/arrays/objects use JSON. --json forces explicit JSON parsing.
|
|
43
|
+
* @param {string} raw Literal argument. @param {(value:unknown)=>unknown} validate Target validator.
|
|
44
|
+
* @param {boolean} json Force JSON, including JSON strings.
|
|
45
|
+
* @returns {unknown} Validated replacement value.
|
|
46
|
+
*/
|
|
47
|
+
export function inputValue(raw, validate, json = false) {
|
|
48
|
+
if (!json) {
|
|
49
|
+
try { return validate(raw); } catch {}
|
|
50
|
+
}
|
|
51
|
+
let parsed;
|
|
52
|
+
try { parsed = JSON.parse(raw); }
|
|
53
|
+
catch { throw new Error('值的类型不正确:布尔值使用 true/false,数字直接填写,数组和对象使用 JSON。'); }
|
|
54
|
+
return validate(parsed);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** @param {string} root @param {'site'|'theme'} scope @param {string} [selector] Theme selector for inactive settings. @returns {Promise<string>} Checked editable config path. */
|
|
58
|
+
export async function configFilename(root, scope, selector) {
|
|
59
|
+
if (scope === 'site') {
|
|
60
|
+
const selection = await readSelection(root);
|
|
61
|
+
return checkedPath(root, path.resolve(root, selection.siteConfig || 'site.config.ts'));
|
|
62
|
+
}
|
|
63
|
+
if (scope === 'theme') {
|
|
64
|
+
// Opening an editor must still work when the editable settings are invalid.
|
|
65
|
+
const theme = selector || (await readSelection(root)).theme || 'minimal';
|
|
66
|
+
return (await loadTheme({ root, theme, readUserConfig: false })).themeConfigFile;
|
|
67
|
+
}
|
|
68
|
+
throw new Error('配置范围为 site 或 theme。');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** @param {string} root @param {'site'|'theme'} scope @param {string} [key] Optional dotted field. @param {string} [selector] @returns {Promise<unknown>} Literal site values or effective theme settings. */
|
|
72
|
+
export async function getConfig(root, scope, key, selector) {
|
|
73
|
+
const keys = key ? keyPath(key) : [];
|
|
74
|
+
let value;
|
|
75
|
+
if (scope === 'theme') value = getIn((await activeTheme(root, selector)).settings, keys);
|
|
76
|
+
else {
|
|
77
|
+
const filename = await configFilename(root, scope, selector);
|
|
78
|
+
const document = configSource(await readFile(filename, 'utf8'), filename);
|
|
79
|
+
const node = sourceNode(document, keys);
|
|
80
|
+
value = node ? sourceValue(document, node) : undefined;
|
|
81
|
+
}
|
|
82
|
+
if (value === undefined) throw new Error(`配置项未填写或不存在:${key}`);
|
|
83
|
+
return value;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Validate and edit one site/theme field, with source-preserving backup writes.
|
|
88
|
+
* Existing legacy inline overrides remain authoritative, so edits target that
|
|
89
|
+
* override when it owns the field; otherwise they target the theme-specific file.
|
|
90
|
+
* @param {string} root Site root.
|
|
91
|
+
* @param {'site'|'theme'} scope Which config family to change.
|
|
92
|
+
* @param {string} key Dotted field path.
|
|
93
|
+
* @param {string} raw Literal argument or JSON text.
|
|
94
|
+
* @param {{theme?:string,json?:boolean}} [options] Optional inactive theme and forced JSON.
|
|
95
|
+
* @returns {Promise<{filename:string,value:unknown,backup:string|null}>} Actual file, validated value and backup.
|
|
96
|
+
*/
|
|
97
|
+
export async function setConfig(root, scope, key, raw, options = {}) {
|
|
98
|
+
const keys = keyPath(key);
|
|
99
|
+
let filename;
|
|
100
|
+
let value;
|
|
101
|
+
let sourceKey = key;
|
|
102
|
+
if (scope === 'site') {
|
|
103
|
+
const field = siteField(keys);
|
|
104
|
+
value = inputValue(raw, candidate => field.parse(candidate), options.json);
|
|
105
|
+
filename = await configFilename(root, scope);
|
|
106
|
+
} else if (scope === 'theme') {
|
|
107
|
+
const active = await activeTheme(root, options.theme);
|
|
108
|
+
const validate = candidate => {
|
|
109
|
+
const settings = structuredClone(active.settings);
|
|
110
|
+
setIn(settings, keys, candidate);
|
|
111
|
+
resolveSettings(active.definition, settings);
|
|
112
|
+
return candidate;
|
|
113
|
+
};
|
|
114
|
+
value = inputValue(raw, validate, options.json);
|
|
115
|
+
if (active.same && getIn(active.selection.settings, keys) !== undefined) {
|
|
116
|
+
filename = path.join(root, 'theme.config.mjs');
|
|
117
|
+
sourceKey = `settings.${key}`;
|
|
118
|
+
} else {
|
|
119
|
+
filename = (await createThemeConfig(root, active.selector)).filename;
|
|
120
|
+
}
|
|
121
|
+
} else throw new Error('配置范围为 site 或 theme。');
|
|
122
|
+
await checkedPath(root, filename);
|
|
123
|
+
const original = await readFile(filename, 'utf8');
|
|
124
|
+
const source = setSourceValue(original, sourceKey, value, filename);
|
|
125
|
+
return { filename, value, backup: await saveFile(root, filename, original, source) };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** @param {string} root @param {'site'|'theme'} scope @param {string} [selector] @returns {Promise<unknown>} Available setting names and constraints. */
|
|
129
|
+
export async function configSchema(root, scope, selector) {
|
|
130
|
+
if (scope === 'theme') {
|
|
131
|
+
const theme = selector || (await readSelection(root)).theme || 'minimal';
|
|
132
|
+
return (await loadTheme({ root, theme, readUserConfig: false })).definition.settings;
|
|
133
|
+
}
|
|
134
|
+
if (scope !== 'site') throw new Error('配置范围为 site 或 theme。');
|
|
135
|
+
return {
|
|
136
|
+
site: ['title', 'url', 'description', 'language'],
|
|
137
|
+
profile: ['name', 'avatar', 'bio', 'location', 'signature'],
|
|
138
|
+
social: 'JSON 数组:{ platform, url, icon }',
|
|
139
|
+
skills: 'JSON 数组:{ category, items: [{ name, level: 0–100 }] }',
|
|
140
|
+
projects: 'JSON 数组:{ title, description, tags, link?, repo?, image? }',
|
|
141
|
+
contact: { email: '字符串', social: '平台名字符串数组' },
|
|
142
|
+
icp: '字符串',
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { mkdir, readFile, realpath, rename, stat, writeFile, unlink } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
/** @param {string} filename @returns {Promise<boolean>} Whether a filesystem entry exists. */
|
|
6
|
+
export async function exists(filename) {
|
|
7
|
+
try { await stat(filename); return true; }
|
|
8
|
+
catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** @param {string} start Working directory, including a site subdirectory. @returns {Promise<string>} Real site root. */
|
|
12
|
+
export async function findSite(start) {
|
|
13
|
+
let directory = await realpath(start);
|
|
14
|
+
while (true) {
|
|
15
|
+
if (await exists(path.join(directory, 'package.json')) && await exists(path.join(directory, 'theme.config.mjs'))) return directory;
|
|
16
|
+
const parent = path.dirname(directory);
|
|
17
|
+
if (parent === directory) throw new Error('当前目录不在 Mintfolio 站点中。请进入站点,或使用 mintfolio create <目录>。');
|
|
18
|
+
directory = parent;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** @param {string} filename @param {string} root @returns {boolean} True when the path is contained by root. */
|
|
23
|
+
export function within(filename, root) {
|
|
24
|
+
const relative = path.relative(root, filename);
|
|
25
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check both lexical and real ancestors before reading or writing host files.
|
|
30
|
+
* @param {string} root Allowed site/content root.
|
|
31
|
+
* @param {string} filename Absolute target, which may not exist yet.
|
|
32
|
+
* @returns {Promise<string>} Checked absolute filename; escaping symlinks fail.
|
|
33
|
+
*/
|
|
34
|
+
export async function checkedPath(root, filename) {
|
|
35
|
+
const base = await realpath(root);
|
|
36
|
+
const target = path.resolve(filename);
|
|
37
|
+
if (!within(target, base)) throw new Error('文件路径必须位于站点目录内。');
|
|
38
|
+
let ancestor = target;
|
|
39
|
+
while (!await exists(ancestor)) ancestor = path.dirname(ancestor);
|
|
40
|
+
if (!within(await realpath(ancestor), base)) throw new Error('文件路径通过符号链接离开了站点目录。');
|
|
41
|
+
return target;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Replace one file only if it still matches the inspected version. Keep an exact
|
|
46
|
+
* backup, then rename a complete sibling file so readers never see partial text.
|
|
47
|
+
* @param {string} root Site root.
|
|
48
|
+
* @param {string} filename Existing editable file within root.
|
|
49
|
+
* @param {string} original Expected UTF-8 content.
|
|
50
|
+
* @param {string} source Replacement UTF-8 content.
|
|
51
|
+
* @returns {Promise<string|null>} Backup path, or null when unchanged.
|
|
52
|
+
*/
|
|
53
|
+
export async function saveFile(root, filename, original, source) {
|
|
54
|
+
if (source === original) return null;
|
|
55
|
+
await checkedPath(root, filename);
|
|
56
|
+
if (await readFile(filename, 'utf8') !== original) throw new Error(`文件已被其他程序修改,请重试:${filename}`);
|
|
57
|
+
const backup = path.join(root, '.mintfolio', 'backups', `${Date.now()}-${randomUUID()}`, path.relative(root, filename));
|
|
58
|
+
await checkedPath(root, backup);
|
|
59
|
+
await mkdir(path.dirname(backup), { recursive: true });
|
|
60
|
+
await writeFile(backup, original, { flag: 'wx' });
|
|
61
|
+
const temporary = path.join(path.dirname(filename), `.${path.basename(filename)}.${randomUUID()}.tmp`);
|
|
62
|
+
try {
|
|
63
|
+
await writeFile(temporary, source, { flag: 'wx', mode: (await stat(filename)).mode });
|
|
64
|
+
if (await readFile(filename, 'utf8') !== original) throw new Error(`文件在保存前发生变化,请重试:${filename}`);
|
|
65
|
+
await rename(temporary, filename);
|
|
66
|
+
} finally {
|
|
67
|
+
await unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error; });
|
|
68
|
+
}
|
|
69
|
+
return backup;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** @param {unknown} value @returns {string} Human-readable values or JSON for structured CLI output. */
|
|
73
|
+
export function display(value) { return typeof value === 'string' ? value : JSON.stringify(value, null, 2); }
|
|
74
|
+
|
|
75
|
+
/** @param {string} key Dotted field path. @returns {string[]} Safe property segments, including array indices. */
|
|
76
|
+
export function keyPath(key) {
|
|
77
|
+
const keys = key.split('.');
|
|
78
|
+
if (!key || keys.some(part => !/^(?:[A-Za-z_$][\w$-]*|0|[1-9]\d*)$/.test(part) || ['__proto__', 'prototype', 'constructor'].includes(part))) throw new Error(`无效配置路径:${key}`);
|
|
79
|
+
return keys;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** @param {unknown} object @param {string[]} keys @returns {unknown} Own-property lookup without executing expressions. */
|
|
83
|
+
export function getIn(object, keys) {
|
|
84
|
+
let current = object;
|
|
85
|
+
for (const key of keys) {
|
|
86
|
+
if (!current || typeof current !== 'object' || !Object.hasOwn(current, key)) return undefined;
|
|
87
|
+
current = current[key];
|
|
88
|
+
}
|
|
89
|
+
return current;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** @param {Record<string,unknown>} object JSON-compatible settings clone. @param {string[]} keys @param {unknown} value */
|
|
93
|
+
export function setIn(object, keys, value) {
|
|
94
|
+
let current = object;
|
|
95
|
+
for (const key of keys.slice(0, -1)) {
|
|
96
|
+
if (current[key] === undefined) current[key] = {};
|
|
97
|
+
if (!current[key] || typeof current[key] !== 'object') throw new Error(`不能在非对象字段下添加设置:${key}`);
|
|
98
|
+
current = current[key];
|
|
99
|
+
}
|
|
100
|
+
const last = keys.at(-1);
|
|
101
|
+
if (Array.isArray(current) && (!/^(0|[1-9]\d*)$/.test(last) || Number(last) >= current.length)) throw new Error('数组下标超出范围;请用 JSON 设置完整数组。');
|
|
102
|
+
current[last] = value;
|
|
103
|
+
}
|
package/bin/lib/init.mjs
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { syncThemeConfigs, reportThemeConfigs } from "../theme-config.mjs";
|
|
4
|
+
import { checkedPath } from './files.mjs';
|
|
5
|
+
|
|
6
|
+
/** Write scaffold files exclusively: initialization never overwrites site content. */
|
|
7
|
+
async function writeNew(root, relative, source) {
|
|
8
|
+
const destination = path.resolve(root, relative);
|
|
9
|
+
const within = path.relative(root, destination);
|
|
10
|
+
if (!within || within.startsWith('..') || path.isAbsolute(within)) throw new Error('Invalid scaffold destination');
|
|
11
|
+
await checkedPath(root, destination);
|
|
12
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
13
|
+
try { await writeFile(destination, source, { flag: 'wx' }); process.stdout.write(`Created ${relative}\n`); }
|
|
14
|
+
catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Scaffold a site without replacing existing files or npm scripts.
|
|
19
|
+
* @param {string} root Existing absolute site directory with Core installed.
|
|
20
|
+
* @returns {Promise<void>} Creates missing config/content files and theme defaults.
|
|
21
|
+
*/
|
|
22
|
+
export async function initialize(root) {
|
|
23
|
+
await writeNew(root, '.gitignore', 'node_modules/\ndist/\n.astro/\n.mintfolio/\n.cache/\n.env\n.env.*\n');
|
|
24
|
+
await writeNew(root, 'astro.config.mjs', `import { defineConfig } from 'astro/config';
|
|
25
|
+
import mintfolio from '@mintfolio/core';
|
|
26
|
+
import theme from './theme.config.mjs';
|
|
27
|
+
export default defineConfig({ integrations: [mintfolio(theme)] });
|
|
28
|
+
`);
|
|
29
|
+
await writeNew(root, 'theme.config.mjs', `/** Omit theme to use Core's Minimal, or select an installed theme package. */
|
|
30
|
+
export default {};
|
|
31
|
+
`);
|
|
32
|
+
await writeNew(root, 'site.config.ts', `import { defineSiteConfig } from '@mintfolio/core/config';
|
|
33
|
+
export default defineSiteConfig({
|
|
34
|
+
site: { title: '我的博客', description: '记录与分享', url: 'https://example.com', language: 'zh-CN' },
|
|
35
|
+
profile: { name: '作者', bio: '欢迎来到我的博客。' },
|
|
36
|
+
});
|
|
37
|
+
`);
|
|
38
|
+
await writeNew(root, 'src/content.config.ts', `import { createBlogCollection } from '@mintfolio/core/content';
|
|
39
|
+
export const collections = { blog: createBlogCollection() };
|
|
40
|
+
`);
|
|
41
|
+
await writeNew(root, 'tsconfig.json', JSON.stringify({ extends: 'astro/tsconfigs/strict', include: ['.astro/types.d.ts', 'src/**/*', 'site.config.ts'] }, null, 2) + '\n');
|
|
42
|
+
await mkdir(path.join(root, 'public'), { recursive: true });
|
|
43
|
+
await writeNew(root, 'content/blog/hello.md', `---
|
|
44
|
+
title: "你好,Mintfolio"
|
|
45
|
+
pubDate: 2026-01-01
|
|
46
|
+
description: "这是你的第一篇文章。"
|
|
47
|
+
category: "随笔"
|
|
48
|
+
tags: ["开始"]
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## 开始写作
|
|
52
|
+
|
|
53
|
+
把 Markdown 文章放到 \`content/blog/\`,Core 会生成文章、归档和订阅源。
|
|
54
|
+
|
|
55
|
+
安装主题包后,在 \`theme.config.mjs\` 中填写包名即可切换布局。
|
|
56
|
+
`);
|
|
57
|
+
const filename = path.join(root, 'package.json');
|
|
58
|
+
const existing = await readFile(filename, 'utf8').catch((error) => { if (error.code === 'ENOENT') return '{}'; throw error; });
|
|
59
|
+
const pkg = JSON.parse(existing);
|
|
60
|
+
pkg.name ??= 'my-mintfolio-blog';
|
|
61
|
+
pkg.private ??= true;
|
|
62
|
+
pkg.type = 'module';
|
|
63
|
+
pkg.scripts = { dev: 'mintfolio dev', build: 'mintfolio build', preview: 'mintfolio preview', ...pkg.scripts };
|
|
64
|
+
await writeFile(filename, JSON.stringify(pkg, null, 2) + '\n');
|
|
65
|
+
reportThemeConfigs(await syncThemeConfigs(root));
|
|
66
|
+
process.stdout.write('Ready. Edit site.config.ts, then run npm run dev.\n');
|
|
67
|
+
}
|