@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,71 @@
1
+ import { emptyFilters, searchPosts } from '@mintfolio/theme-api/search';
2
+ function positiveInteger(value, field) {
3
+ if (!Number.isSafeInteger(value) || value < 1)
4
+ throw new Error(`${field} must be a positive safe integer`);
5
+ return value;
6
+ }
7
+ /**
8
+ * Shared search, combined tag/category filtering, facets, and load-more state.
9
+ * @returns A controller whose subscriptions receive the initial state immediately.
10
+ */
11
+ export function createPostListController(options) {
12
+ const indexed = options.items.map((item) => ({ ...options.index(item), item }));
13
+ const pageSize = positiveInteger(options.pageSize ?? (indexed.length || 1), 'pageSize');
14
+ let limit = positiveInteger(options.initialLimit ?? pageSize, 'initialLimit');
15
+ let filters = { ...emptyFilters(), ...options.initialFilters };
16
+ if (Object.values(filters).some((item) => typeof item !== 'string'))
17
+ throw new Error('Filters must be strings');
18
+ let disposed = false;
19
+ const listeners = new Set();
20
+ const facets = () => ({
21
+ tags: [...new Set(searchPosts(indexed, { category: filters.category }).flatMap((entry) => entry.tags))],
22
+ categories: [...new Set(searchPosts(indexed, { tag: filters.tag }).map((entry) => entry.category))],
23
+ });
24
+ const reconcile = () => {
25
+ if (!options.reconcileFacets)
26
+ return;
27
+ if (filters.tag && !facets().tags.includes(filters.tag.toLowerCase()))
28
+ filters.tag = '';
29
+ if (filters.category && !facets().categories.includes(filters.category.toLowerCase()))
30
+ filters.category = '';
31
+ };
32
+ reconcile();
33
+ const value = () => {
34
+ const matches = searchPosts(indexed, filters).map((entry) => entry.item);
35
+ return { filters: { ...filters }, matches, visible: matches.slice(0, limit), total: matches.length, limit, hasMore: matches.length > limit, facets: facets() };
36
+ };
37
+ const emit = () => { if (!disposed)
38
+ for (const listener of listeners)
39
+ listener(value()); };
40
+ return {
41
+ value,
42
+ setFilters(next) {
43
+ if (disposed)
44
+ return;
45
+ const changed = { ...filters, ...next };
46
+ if (Object.values(changed).some((item) => typeof item !== 'string'))
47
+ throw new Error('Filters must be strings');
48
+ if (changed.q !== filters.q || changed.tag !== filters.tag || changed.category !== filters.category)
49
+ limit = pageSize;
50
+ filters = changed;
51
+ reconcile();
52
+ emit();
53
+ },
54
+ loadMore() { if (!disposed) {
55
+ limit = Math.min(Number.MAX_SAFE_INTEGER, limit + pageSize);
56
+ emit();
57
+ } },
58
+ setLimit(next) { if (!disposed) {
59
+ limit = positiveInteger(next, 'limit');
60
+ emit();
61
+ } },
62
+ subscribe(listener) {
63
+ if (disposed)
64
+ return () => { };
65
+ listeners.add(listener);
66
+ listener(value());
67
+ return () => { listeners.delete(listener); };
68
+ },
69
+ dispose() { disposed = true; listeners.clear(); },
70
+ };
71
+ }
@@ -0,0 +1,26 @@
1
+ import { type ArticleUnlockError, type UnlockedArticle } from '@mintfolio/theme-api/client';
2
+ import type { Cleanup } from './lifecycle.js';
3
+ export type ProtectedArticleStatus = 'locked' | 'unlocking' | 'unlocked';
4
+ /** The theme owns DOM rendering; Core owns the short-lived unlock operation. */
5
+ export interface ProtectedArticleOptions {
6
+ postId: string;
7
+ payload: unknown;
8
+ /** Mount the returned fragment once; do not persist or retain plaintext. */
9
+ onUnlock(content: UnlockedArticle): void;
10
+ /** Clear article nodes, TOC nodes, preview images, password values and error UI. */
11
+ onClear(): void;
12
+ onState?(state: ProtectedArticleStatus): void;
13
+ onError?(error: ArticleUnlockError | Error): void;
14
+ signal?: AbortSignal;
15
+ }
16
+ export interface ProtectedArticleController {
17
+ /** Returns false if disposed, already busy, rejected, or superseded by a lock. */
18
+ unlock(password: string): Promise<boolean>;
19
+ lock(): void;
20
+ dispose: Cleanup;
21
+ }
22
+ /**
23
+ * Own authentication races and navigation cleanup once for all themes. No secret
24
+ * enters storage, history, callbacks other than onUnlock, or error messages.
25
+ */
26
+ export declare function createProtectedArticleController(options: ProtectedArticleOptions): ProtectedArticleController;
@@ -0,0 +1,64 @@
1
+ import { unlockArticle } from '@mintfolio/theme-api/client';
2
+ /**
3
+ * Own authentication races and navigation cleanup once for all themes. No secret
4
+ * enters storage, history, callbacks other than onUnlock, or error messages.
5
+ */
6
+ export function createProtectedArticleController(options) {
7
+ let generation = 0;
8
+ let disposed = false;
9
+ let busy = false;
10
+ let active = true;
11
+ const lifetime = new AbortController();
12
+ const lock = () => {
13
+ generation += 1;
14
+ busy = false;
15
+ options.onClear();
16
+ options.onState?.('locked');
17
+ };
18
+ const dispose = () => {
19
+ if (disposed)
20
+ return;
21
+ disposed = true;
22
+ lifetime.abort();
23
+ options.signal?.removeEventListener('abort', dispose);
24
+ lock();
25
+ };
26
+ window.addEventListener('pagehide', () => { active = false; lock(); }, { signal: lifetime.signal });
27
+ window.addEventListener('pageshow', () => { active = true; lock(); }, { signal: lifetime.signal });
28
+ document.addEventListener('astro:before-swap', dispose, { signal: lifetime.signal });
29
+ options.signal?.addEventListener('abort', dispose, { once: true });
30
+ if (options.signal?.aborted)
31
+ dispose();
32
+ return {
33
+ async unlock(password) {
34
+ if (disposed || !active || busy || !password)
35
+ return false;
36
+ const operation = ++generation;
37
+ busy = true;
38
+ options.onState?.('unlocking');
39
+ try {
40
+ const content = await unlockArticle(options.payload, password, options.postId);
41
+ if (disposed || !active || operation !== generation)
42
+ return false;
43
+ options.onUnlock(content);
44
+ options.onState?.('unlocked');
45
+ return true;
46
+ }
47
+ catch (error) {
48
+ if (disposed || !active || operation !== generation)
49
+ return false;
50
+ // Also clear any partial mount if the theme's onUnlock callback fails.
51
+ options.onClear();
52
+ options.onState?.('locked');
53
+ options.onError?.(error instanceof Error ? error : new Error('Article unlocking failed'));
54
+ return false;
55
+ }
56
+ finally {
57
+ if (operation === generation)
58
+ busy = false;
59
+ }
60
+ },
61
+ lock,
62
+ dispose,
63
+ };
64
+ }
@@ -0,0 +1,22 @@
1
+ import { type Cleanup } from './lifecycle.js';
2
+ /** Element references make TOC behavior independent of layout and class names. */
3
+ export interface TocOptions {
4
+ headings: readonly HTMLElement[];
5
+ links?: readonly HTMLAnchorElement[];
6
+ /** Sticky header offset in pixels; use a function for responsive layouts. */
7
+ offset?: number | (() => number);
8
+ activeClass?: string;
9
+ scrollActiveLink?: boolean;
10
+ onActive?(headingId: string): void;
11
+ /** Document reading progress from 0 to 100. */
12
+ onProgress?(percentage: number): void;
13
+ onNavigate?(headingId: string): void;
14
+ signal?: AbortSignal;
15
+ }
16
+ export interface TocController {
17
+ refresh(): void;
18
+ scrollTo(headingId: string): void;
19
+ dispose: Cleanup;
20
+ }
21
+ /** Track headings and navigate without adding a history entry for each section. */
22
+ export declare function createTocController(options: TocOptions): TocController;
@@ -0,0 +1,90 @@
1
+ import { createPageScope } from './lifecycle.js';
2
+ /** Track headings and navigate without adding a history entry for each section. */
3
+ export function createTocController(options) {
4
+ const scope = createPageScope();
5
+ let activeId = '';
6
+ let pending = false;
7
+ const offset = () => typeof options.offset === 'function' ? options.offset() : options.offset ?? 0;
8
+ const headingFor = (link) => {
9
+ let id;
10
+ try {
11
+ id = decodeURIComponent(new URL(link.href).hash.slice(1));
12
+ }
13
+ catch {
14
+ return undefined;
15
+ }
16
+ return options.headings.find((heading) => heading.id === id);
17
+ };
18
+ const setActive = (id) => {
19
+ if (!id || activeId === id)
20
+ return;
21
+ activeId = id;
22
+ for (const link of options.links ?? []) {
23
+ const active = headingFor(link)?.id === id;
24
+ if (options.activeClass)
25
+ link.classList.toggle(options.activeClass, active);
26
+ if (active) {
27
+ link.setAttribute('aria-current', 'true');
28
+ if (options.scrollActiveLink)
29
+ link.scrollIntoView({ block: 'nearest', inline: 'nearest' });
30
+ }
31
+ else
32
+ link.removeAttribute('aria-current');
33
+ }
34
+ options.onActive?.(id);
35
+ };
36
+ const refresh = () => {
37
+ if (scope.signal.aborted)
38
+ return;
39
+ const entries = options.headings.filter((heading) => heading.isConnected);
40
+ let current = entries[0];
41
+ let nearest = Infinity;
42
+ for (const heading of entries) {
43
+ const distance = heading.getBoundingClientRect().top - offset();
44
+ if (distance <= 0 && Math.abs(distance) < nearest) {
45
+ nearest = Math.abs(distance);
46
+ current = heading;
47
+ }
48
+ }
49
+ const height = document.documentElement.scrollHeight - window.innerHeight;
50
+ if (window.scrollY >= height - 50)
51
+ current = entries.at(-1);
52
+ if (current)
53
+ setActive(current.id);
54
+ options.onProgress?.(height > 0 ? Math.max(0, Math.min(100, window.scrollY / height * 100)) : 0);
55
+ };
56
+ const schedule = () => {
57
+ if (pending)
58
+ return;
59
+ pending = true;
60
+ scope.frame(() => { pending = false; refresh(); });
61
+ };
62
+ const scrollTo = (id) => {
63
+ const heading = options.headings.find((entry) => entry.id === id);
64
+ if (!heading || scope.signal.aborted)
65
+ return;
66
+ window.scrollTo({ top: heading.getBoundingClientRect().top + window.scrollY - offset(), behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth' });
67
+ const url = new URL(location.href);
68
+ url.hash = id;
69
+ history.replaceState(history.state, '', url);
70
+ setActive(id);
71
+ options.onNavigate?.(id);
72
+ };
73
+ for (const link of options.links ?? [])
74
+ link.addEventListener('click', (event) => {
75
+ const heading = headingFor(link);
76
+ if (!heading)
77
+ return;
78
+ event.preventDefault();
79
+ scrollTo(heading.id);
80
+ }, { signal: scope.signal });
81
+ window.addEventListener('scroll', schedule, { signal: scope.signal, passive: true });
82
+ window.addEventListener('resize', schedule, { signal: scope.signal });
83
+ options.signal?.addEventListener('abort', scope.dispose, { once: true });
84
+ scope.add(() => options.signal?.removeEventListener('abort', scope.dispose));
85
+ if (options.signal?.aborted)
86
+ scope.dispose();
87
+ else
88
+ schedule();
89
+ return { refresh, scrollTo, dispose: scope.dispose };
90
+ }
@@ -0,0 +1,2 @@
1
+ /** Renderer props and page semantics shared by every theme. */
2
+ export * from '@mintfolio/theme-api/astro';
@@ -0,0 +1,2 @@
1
+ /** Renderer props and page semantics shared by every theme. */
2
+ export * from '@mintfolio/theme-api/astro';
@@ -0,0 +1,10 @@
1
+ /** Optional browser behavior; importing a controller does not mount global UI. */
2
+ export * from '@mintfolio/theme-api/client';
3
+ export * from '../client/lifecycle.js';
4
+ export * from '../client/postList.js';
5
+ export * from '../client/protectedArticle.js';
6
+ export * from '../client/toc.js';
7
+ export * from '../client/lightbox.js';
8
+ export * from '../client/archive.js';
9
+ export * from '../client/code.js';
10
+ export * from '../client/navigation.js';
@@ -0,0 +1,10 @@
1
+ /** Optional browser behavior; importing a controller does not mount global UI. */
2
+ export * from '@mintfolio/theme-api/client';
3
+ export * from '../client/lifecycle.js';
4
+ export * from '../client/postList.js';
5
+ export * from '../client/protectedArticle.js';
6
+ export * from '../client/toc.js';
7
+ export * from '../client/lightbox.js';
8
+ export * from '../client/archive.js';
9
+ export * from '../client/code.js';
10
+ export * from '../client/navigation.js';
@@ -0,0 +1,32 @@
1
+ import type { ContactInfo, Project, PublicProfile, SkillCategory, SocialLink } from '@mintfolio/theme-api';
2
+ /** Host-owned content. Theme-specific display settings belong in theme.config.mjs. */
3
+ export interface SiteConfig {
4
+ site: {
5
+ title: string;
6
+ url: string;
7
+ description: string;
8
+ language: string;
9
+ };
10
+ profile: PublicProfile;
11
+ social: SocialLink[];
12
+ skills: SkillCategory[];
13
+ projects: Project[];
14
+ contact: ContactInfo;
15
+ icp?: string;
16
+ }
17
+ /** Only a title and absolute HTTP(S) site URL are required to create a blog. */
18
+ export interface SiteConfigInput {
19
+ site: Pick<SiteConfig['site'], 'title' | 'url'> & Partial<Omit<SiteConfig['site'], 'title' | 'url'>>;
20
+ profile?: Partial<PublicProfile>;
21
+ social?: SocialLink[];
22
+ skills?: SkillCategory[];
23
+ projects?: Project[];
24
+ contact?: Partial<ContactInfo>;
25
+ icp?: string;
26
+ }
27
+ /**
28
+ * Complete optional author fields without exposing any theme-specific defaults.
29
+ * @param input Public site content, including the deployment URL.
30
+ * @returns Fully populated content suitable for Core's explicit public projection.
31
+ */
32
+ export declare function defineSiteConfig(input: SiteConfigInput): SiteConfig;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Complete optional author fields without exposing any theme-specific defaults.
3
+ * @param input Public site content, including the deployment URL.
4
+ * @returns Fully populated content suitable for Core's explicit public projection.
5
+ */
6
+ export function defineSiteConfig(input) {
7
+ const url = new URL(input.site.url);
8
+ if (!['http:', 'https:'].includes(url.protocol))
9
+ throw new Error('[mintfolio:config] site.url must use HTTP or HTTPS');
10
+ if (!input.site.title.trim())
11
+ throw new Error('[mintfolio:config] site.title must not be empty');
12
+ return {
13
+ site: { description: '', language: 'zh-CN', ...input.site },
14
+ profile: { name: input.site.title, avatar: '', bio: '', location: '', signature: '', ...input.profile },
15
+ social: input.social ?? [],
16
+ skills: input.skills ?? [],
17
+ projects: input.projects ?? [],
18
+ contact: { email: '', social: [], ...input.contact },
19
+ ...(input.icp ? { icp: input.icp } : {}),
20
+ };
21
+ }
@@ -0,0 +1,2 @@
1
+ /** Pure matching and URL helpers are safe in both build and browser code. */
2
+ export * from '@mintfolio/theme-api/search';
@@ -0,0 +1,2 @@
1
+ /** Pure matching and URL helpers are safe in both build and browser code. */
2
+ export * from '@mintfolio/theme-api/search';
@@ -0,0 +1,2 @@
1
+ /** Theme authors depend on this contract, never on Core's routes or content readers. */
2
+ export * from '@mintfolio/theme-api';
@@ -0,0 +1,2 @@
1
+ /** Theme authors depend on this contract, never on Core's routes or content readers. */
2
+ export * from '@mintfolio/theme-api';
package/docs/cli.md ADDED
@@ -0,0 +1,118 @@
1
+ # Mintfolio 命令行
2
+
3
+ CLI 随 `@mintfolio/core` 发布,包提供 `mintfolio` 可执行命令。支持 Windows、macOS 和 Linux,要求 Node.js >= 22.12.0。
4
+
5
+ ## 安装和入口
6
+
7
+ 在已安装 Core 的站点里,使用 `npx mintfolio`;全局安装后可直接使用 `mintfolio`:
8
+
9
+ ```sh
10
+ npm install -g @mintfolio/core
11
+ mintfolio --help
12
+ mintfolio --version
13
+ ```
14
+
15
+ 全局安装只提供命令;每个站点仍独立安装并锁定自己的 Core、Astro 和主题。开发及构建使用该站点安装的 Astro。`mintfolio doctor` 可同时查看 CLI 和站点的版本。已有站点可使用 `mintfolio upgrade` 更新 Core;更新前先停止开发服务,更新后重新启动。
16
+
17
+ 多数命令会从当前目录向上找到站点根目录,因此在 `content/blog` 子目录也能操作。`mintfolio --cwd <站点目录> ...` 或 `mintfolio -C <站点目录> ...` 可指定站点;该选项放在命令前。
18
+
19
+ ## 新站点与日常运行
20
+
21
+ ```sh
22
+ mintfolio create my-blog
23
+ mintfolio create my-blog --theme verdant
24
+ cd my-blog
25
+ mintfolio dev
26
+ mintfolio build
27
+ mintfolio preview
28
+ mintfolio check
29
+ mintfolio doctor --json
30
+ ```
31
+
32
+ `create` 需要空目录,会安装 Core、生成网站配置与示例文章,可同时安装并启用主题。
33
+
34
+ 已有目录按传统流程安装 Core 后,执行 `mintfolio init` 补齐文件。初始化不会覆盖已有配置、文章或同名 npm scripts。若安装中断,可在新目录内继续 `npm install` 和 `mintfolio init`。
35
+
36
+ `dev/build/preview/sync` 的附加参数传给 Astro,例如 `mintfolio dev --port 4322`。`check` 运行站点/主题检查和 Astro sync;生产构建仍使用 `build`,它们不代替编辑器或项目自己的 TypeScript 检查。CLI 不自动部署站点。
37
+
38
+ ## 文章
39
+
40
+ ```sh
41
+ mintfolio post new "我的第一篇文章" --slug first-post
42
+ mintfolio post new "一次旅行" --slug life/travel --description "沿途见闻" --category 生活 --tags 旅行,随笔
43
+ mintfolio post new "准备发布的文章" --slug ready --date 2026-09-11 --publish
44
+ mintfolio post list
45
+ mintfolio post list --draft --json
46
+ mintfolio post publish first-post
47
+ mintfolio post draft first-post
48
+ ```
49
+
50
+ 新文章位于 `content/blog`,默认 `draft: true`。省略 `--slug` 时按标题生成名称,保留中文;显式 ID 可以包含目录,例如 `life/travel`。同名文件不会被覆盖。`--date` 使用本地当天日期作为默认值。
51
+
52
+ `publish` 只把草稿字段设为 false,使文章可进入下一次构建;它不会执行部署。`draft` 将文章移回草稿状态。修改保留其他 frontmatter 字段、注释及 Markdown 正文,并备份原文件。`list` 只显示 ID、标题、日期和草稿状态,不输出密码或正文。
53
+
54
+ CLI 的文章命令采用默认 `content/blog` 目录。自行更换内容集合 loader/base 的站点,应直接管理其自定义目录中的文章。
55
+
56
+ ## 主题
57
+
58
+ ```sh
59
+ mintfolio theme list
60
+ mintfolio theme current
61
+ mintfolio theme install default
62
+ mintfolio theme install @example/theme@1.2.3 --use
63
+ mintfolio theme use default
64
+ mintfolio theme use minimal
65
+ mintfolio theme use ./my-theme
66
+ mintfolio theme init default
67
+ mintfolio theme sync
68
+ mintfolio theme check
69
+ ```
70
+
71
+ `install` 接收 npm 包名,可附加版本、范围或标签;`default` 和 `happyhues` 都映射到 `@mintfolio/theme-default`。安装后生成完整主题配置,只有加 `--use` 才同时切换。再次生成配置保留已有文件。
72
+
73
+ `use` 校验目标主题并修改 `theme.config.mjs`,保留显式页面 overrides。旧的内联 `settings` 会先迁移到旧主题自己的配置文件,再清空内联覆盖,因此切回旧主题时仍保留个性化设置。动态内联表达式不能自动迁移,CLI 会明确报错且不切换主题。
74
+
75
+ `theme list` 显示内置 Minimal、直接依赖中的主题包及当前本地主题。没有删除或卸载命令;这版聚焦创建、安装、选择和编辑。
76
+
77
+ 兼容旧命令:`theme:add` → `theme install`,`theme:init` → `theme init`,`theme:sync` → `theme sync`,`theme:check` → `theme check`。
78
+
79
+ ## 配置
80
+
81
+ ```sh
82
+ mintfolio config get site
83
+ mintfolio config get site site.title
84
+ mintfolio config set site site.title "新的博客名称"
85
+ mintfolio config set site profile.bio "记录与分享"
86
+ mintfolio config get theme
87
+ mintfolio config set theme initialPalette 3
88
+ mintfolio config set theme homePageSize 9
89
+ mintfolio config set theme sidebar.quote.enabled true
90
+ mintfolio config set theme sidebar.quote.text "保持好奇。"
91
+ mintfolio config set theme maxWidth 900 --theme minimal
92
+ mintfolio config schema theme
93
+ mintfolio config path theme
94
+ mintfolio config edit theme
95
+ ```
96
+
97
+ `site` 修改 `site.config.ts`,也识别主题选择配置中明确指定的 `siteConfig` 路径;`theme` 修改当前主题的专属文件。添加 `--theme <主题>` 可编辑未启用主题。主题配置文件支持要求站点 Core >= 0.1.1,旧版请先 `mintfolio upgrade`。
98
+
99
+ 字段用点号分隔,例如 `sidebar.quote.enabled`;已存在的数组元素可用 `social.0.url`。数组和对象整体传 JSON,示例:
100
+
101
+ ```sh
102
+ mintfolio config set site contact.social '["github","email"]'
103
+ mintfolio config set theme sidebar.tools '[{"name":"示例工具","description":"工具说明","url":"https://example.com"}]'
104
+ ```
105
+
106
+ 默认按照字段类型读取值:配色编号 `3` 保持字符串,文章数量 `9` 是数字,开关 `true` 是布尔值。`--json` 强制将参数作为 JSON;不同终端有自己的引号规则,复杂数组也可通过 `config edit` 修改。
107
+
108
+ 站点配置按语法读取,不执行图片导入、函数或其他表达式;动态叶子会显示为 `{ "$expression": "原表达式" }`。支持直接导出的对象、顶层 `const` 别名、`defineSiteConfig({...})`、TypeScript `as`/`satisfies`。含展开、计算属性或不明确容器的字段不能自动改写,可用 `config edit` 编辑。普通字段修改保留 imports、注释和其他字段,只替换目标值。
109
+
110
+ 主题 `get` 返回合并默认值后的有效设置。若旧的 `theme.config.mjs.settings` 正在覆盖某个字段,`set` 会修改该内联字段并输出实际文件路径,避免写入一个会被忽略的值。
111
+
112
+ `config edit` 默认在 Windows 使用记事本、macOS 使用默认文本编辑器、Linux 使用 `xdg-open`。可设置 `VISUAL`/`EDITOR`,或传 `--editor <可执行文件路径>`。编辑器选项只接收程序路径,不接受 shell 命令或附加参数。
113
+
114
+ ## 保留与恢复
115
+
116
+ 配置和草稿状态修改前,会将原文件完整备份到 `.mintfolio/backups/<时间和唯一编号>/`,命令输出具体位置。需要恢复时,把备份复制回对应原路径即可。新站点的 `.gitignore` 已忽略 `.mintfolio/`;旧站点可把该目录加入自己的忽略文件。
117
+
118
+ 未知命令、无效配置值、重复文章和失败的 npm/Astro 命令都会以非零状态退出,可用于脚本。CLI 不覆盖已有文章、不移除主题包、不操作 Git 提交,也不自动发布到远程服务器。
package/package.json ADDED
@@ -0,0 +1,88 @@
1
+ {
2
+ "name": "@mintfolio/core",
3
+ "version": "0.1.5",
4
+ "description": "A Markdown blog and personal site toolkit, built with Astro.",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=22.12.0"
8
+ },
9
+ "bin": {
10
+ "mintfolio": "./bin/mintfolio.mjs"
11
+ },
12
+ "files": [
13
+ "bin",
14
+ "dist",
15
+ "src",
16
+ "README.md",
17
+ "docs/cli.md",
18
+ "LICENSE",
19
+ "THIRD_PARTY_NOTICES.md"
20
+ ],
21
+ "exports": {
22
+ ".": {
23
+ "types": "./src/integration.d.ts",
24
+ "import": "./src/integration.mjs"
25
+ },
26
+ "./config": {
27
+ "types": "./dist/public/config.d.ts",
28
+ "import": "./dist/public/config.js"
29
+ },
30
+ "./theme": {
31
+ "types": "./dist/public/theme.d.ts",
32
+ "import": "./dist/public/theme.js"
33
+ },
34
+ "./astro": {
35
+ "types": "./dist/public/astro.d.ts",
36
+ "import": "./dist/public/astro.js"
37
+ },
38
+ "./client": {
39
+ "types": "./dist/public/client.d.ts",
40
+ "import": "./dist/public/client.js"
41
+ },
42
+ "./search": {
43
+ "types": "./dist/public/search.d.ts",
44
+ "import": "./dist/public/search.js"
45
+ },
46
+ "./content": {
47
+ "types": "./src/content.d.ts",
48
+ "import": "./src/content.mjs"
49
+ },
50
+ "./components/*": "./src/components/*",
51
+ "./package.json": "./package.json"
52
+ },
53
+ "dependencies": {
54
+ "@astrojs/compiler": "^2.13.1",
55
+ "@babel/parser": "^7.29.8",
56
+ "@mintfolio/theme-api": "^1.0.0",
57
+ "astro": "^7.3.2",
58
+ "es-module-lexer": "^1.7.0",
59
+ "import-meta-resolve": "^4.2.0",
60
+ "semver": "^7.8.5",
61
+ "yaml": "^2.9.0"
62
+ },
63
+ "devDependencies": {
64
+ "@types/node": "^22.20.2",
65
+ "@types/semver": "^7.8.0",
66
+ "@typescript/native": "npm:typescript@^7.0.2",
67
+ "typescript": "npm:@typescript/typescript6@^6.0.2"
68
+ },
69
+ "scripts": {
70
+ "build": "node node_modules/@typescript/native/bin/tsc -p tsconfig.json",
71
+ "check": "npm run build",
72
+ "prepack": "npm run build",
73
+ "test": "npm run test:unit && npm run test:boundaries",
74
+ "test:unit": "npm run build && node --experimental-strip-types --test tests/*.test.mjs",
75
+ "test:boundaries": "npm run build && node tools/verify-theme-boundaries.mjs",
76
+ "deps:update": "node tools/update-local-deps.mjs"
77
+ },
78
+ "license": "GPL-3.0-only",
79
+ "author": "CNFLWZH",
80
+ "repository": {
81
+ "type": "git",
82
+ "url": "git+https://github.com/cnflwzh/mintfolio.git"
83
+ },
84
+ "homepage": "https://github.com/cnflwzh/mintfolio/wiki",
85
+ "bugs": {
86
+ "url": "https://github.com/cnflwzh/mintfolio/issues"
87
+ }
88
+ }
@@ -0,0 +1,45 @@
1
+ import { createSearchEntry, readFiltersFromUrl, writeFiltersToUrl } from '@mintfolio/theme-api/search';
2
+ import type { PostSummary } from '@mintfolio/theme-api';
3
+ import { createPostListController } from './postList.js';
4
+ import type { PageScope } from './lifecycle.js';
5
+
6
+ /**
7
+ * Default semantic binding for Core's optional PostArchive component. Custom
8
+ * themes can use createPostListController directly and supply their own markup.
9
+ * The root owns all selectors; multiple unrelated forms never get global handlers.
10
+ */
11
+ export function bindPostArchive(root: HTMLElement, scope: PageScope): void {
12
+ const form = root.querySelector<HTMLFormElement>('[data-filter-form]');
13
+ const query = form?.querySelector<HTMLInputElement>('[name="q"]');
14
+ const tag = form?.querySelector<HTMLSelectElement>('[name="tag"]');
15
+ const category = form?.querySelector<HTMLSelectElement>('[name="category"]');
16
+ const status = root.querySelector<HTMLElement>('[data-filter-status]');
17
+ const more = root.querySelector<HTMLButtonElement>('[data-load-more]');
18
+ if (!form || !query || !tag || !category) return;
19
+ const posts: PostSummary[] = JSON.parse(root.dataset.posts ?? '[]');
20
+ const controller = createPostListController({ items: posts, index: createSearchEntry, initialFilters: readFiltersFromUrl(location.href), ...(root.dataset.pageSize ? { pageSize: Number(root.dataset.pageSize) } : {}) });
21
+ scope.add(controller.dispose);
22
+ const rows = Array.from(root.querySelectorAll<HTMLElement>('[data-post-id]'));
23
+ scope.add(controller.subscribe((state) => {
24
+ // Preserve a URL filter even if no select option exists: a miss is zero matches.
25
+ for (const [select, value] of [[tag, state.filters.tag], [category, state.filters.category]] as const) {
26
+ if (value && !Array.from(select.options).some((option) => option.value === value)) select.add(new Option(value, value));
27
+ select.value = value;
28
+ }
29
+ if (document.activeElement !== query) query.value = state.filters.q;
30
+ const ids = new Set(state.visible.map((post) => post.id));
31
+ rows.forEach((row) => { row.hidden = !ids.has(row.dataset.postId ?? ''); });
32
+ if (status) status.textContent = `共 ${state.total} 篇文章`;
33
+ if (more) more.hidden = !state.hasMore;
34
+ }));
35
+ const update = (): void => {
36
+ controller.setFilters({ q: query.value.trim(), tag: tag.value, category: category.value });
37
+ history.replaceState(history.state, '', writeFiltersToUrl(controller.value().filters, new URL(location.href)));
38
+ };
39
+ const eventOptions = { signal: scope.signal };
40
+ form.addEventListener('submit', (event) => { event.preventDefault(); update(); }, eventOptions);
41
+ form.addEventListener('input', update, eventOptions);
42
+ form.addEventListener('change', update, eventOptions);
43
+ more?.addEventListener('click', controller.loadMore, eventOptions);
44
+ window.addEventListener('popstate', () => controller.setFilters(readFiltersFromUrl(location.href)), eventOptions);
45
+ }