@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,109 @@
1
+ .mintfolio-minimal {
2
+ color-scheme: light;
3
+ font-family: ui-monospace, 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
4
+ background: #fcfcfa;
5
+ color: #171717;
6
+ }
7
+
8
+ .mintfolio-minimal * { box-sizing: border-box; }
9
+
10
+ html.mintfolio-minimal { min-width: 320px; }
11
+
12
+ .mintfolio-minimal body {
13
+ margin: 0;
14
+ min-height: 100vh;
15
+ background: #fcfcfa;
16
+ color: #171717;
17
+ font-size: 16px;
18
+ line-height: 1.7;
19
+ }
20
+
21
+ .mintfolio-minimal a { color: var(--minimal-accent); text-underline-offset: 0.18em; }
22
+ .mintfolio-minimal a:hover { text-decoration-thickness: 2px; }
23
+
24
+ .mintfolio-minimal :is(button, input, select) { font: inherit; }
25
+
26
+ .mintfolio-minimal .sr-only {
27
+ position: absolute;
28
+ width: 1px;
29
+ height: 1px;
30
+ overflow: hidden;
31
+ clip: rect(0, 0, 0, 0);
32
+ white-space: nowrap;
33
+ }
34
+
35
+ .minimal-shell {
36
+ width: min(calc(100% - 2rem), var(--minimal-width));
37
+ margin: 0 auto;
38
+ padding: 2.5rem 0 4rem;
39
+ }
40
+
41
+ .minimal-site-name { color: inherit; font-weight: 700; text-decoration: none; }
42
+
43
+ .minimal-navigation {
44
+ display: flex;
45
+ flex-wrap: wrap;
46
+ gap: 0.35rem 1rem;
47
+ margin: 0.65rem 0 3.25rem;
48
+ }
49
+
50
+ .minimal-navigation a { color: #4c4c4c; }
51
+
52
+ .minimal-main > * + * { margin-top: 2.25rem; }
53
+
54
+ .minimal-kicker, .minimal-meta, .minimal-empty, .minimal-footer {
55
+ color: #666;
56
+ font-size: 0.9rem;
57
+ }
58
+
59
+ .minimal-title {
60
+ max-width: 22ch;
61
+ margin: 0;
62
+ font-size: clamp(2rem, 7vw, 3.8rem);
63
+ line-height: 1.08;
64
+ letter-spacing: -0.065em;
65
+ }
66
+
67
+ .minimal-description { max-width: 58ch; margin: 1.25rem 0 0; }
68
+
69
+ .minimal-post-list { list-style: none; margin: 0; padding: 0; }
70
+ .minimal-post-list > li { border-top: 1px solid #d8d8d4; padding: 1.1rem 0 1.2rem; }
71
+ .minimal-post-list > li:last-child { border-bottom: 1px solid #d8d8d4; }
72
+ .minimal-post-list h2 { margin: 0; font-size: 1.1rem; line-height: 1.45; }
73
+ .minimal-post-list p { margin: 0.35rem 0 0; color: #4c4c4c; }
74
+
75
+ .minimal-terms { display: flex; flex-wrap: wrap; gap: 0.35rem 0.75rem; margin-top: 0.45rem; font-size: 0.85rem; }
76
+ .minimal-terms a { color: #5b5b5b; }
77
+
78
+ .minimal-article header { border-bottom: 1px solid #d8d8d4; padding-bottom: 1.5rem; }
79
+ .minimal-article h1 { margin: 0; font-size: clamp(2rem, 6vw, 3.25rem); line-height: 1.15; letter-spacing: -0.055em; }
80
+ .minimal-article header p { margin: 0.85rem 0 0; }
81
+
82
+ .minimal-prose { margin-top: 2.25rem; }
83
+ .minimal-prose :is(h2, h3, h4) { line-height: 1.25; margin-top: 2.4em; }
84
+ .minimal-prose img { height: auto; max-width: 100%; }
85
+ .minimal-prose pre { max-width: 100%; overflow: auto; padding: 1rem; background: #efefea; }
86
+ .minimal-prose blockquote { border-left: 2px solid #aaa; margin-left: 0; padding-left: 1rem; color: #4c4c4c; }
87
+
88
+ .minimal-protected { border: 1px solid #c8c8c2; padding: 1.25rem; }
89
+ .minimal-protected p { margin-top: 0; }
90
+ .minimal-protected form { display: flex; flex-wrap: wrap; gap: 0.7rem; }
91
+ .minimal-protected input { min-width: min(100%, 18rem); border: 1px solid #999; border-radius: 0; padding: 0.45rem 0.55rem; }
92
+ .minimal-protected button { border: 1px solid var(--minimal-accent); border-radius: 0; background: var(--minimal-accent); color: white; cursor: pointer; padding: 0.45rem 0.75rem; }
93
+ .minimal-protected button[hidden], .minimal-protected [hidden] { display: none; }
94
+ .minimal-protected .minimal-relock { background: transparent; color: var(--minimal-accent); }
95
+ .minimal-protected-error { color: #a60000; }
96
+
97
+ .minimal-filter-form { display: grid; gap: 0.8rem; grid-template-columns: repeat(3, minmax(0, 1fr)); }
98
+ .minimal-filter-form label { display: grid; gap: 0.25rem; font-size: 0.9rem; }
99
+ .minimal-filter-form input, .minimal-filter-form select { min-width: 0; border: 1px solid #999; border-radius: 0; background: #fff; padding: 0.45rem; }
100
+ .minimal-filter-status { color: #666; font-size: 0.9rem; }
101
+
102
+ .minimal-pager { display: flex; justify-content: space-between; gap: 1rem; border-top: 1px solid #d8d8d4; padding-top: 1.25rem; }
103
+ .minimal-footer { border-top: 1px solid #d8d8d4; margin-top: 4rem; padding-top: 1rem; }
104
+
105
+ @media (max-width: 620px) {
106
+ .minimal-shell { padding-top: 1.5rem; }
107
+ .minimal-navigation { margin-bottom: 2.25rem; }
108
+ .minimal-filter-form { grid-template-columns: 1fr; }
109
+ }
@@ -0,0 +1,48 @@
1
+ import { defineTheme } from '@mintfolio/theme-api';
2
+
3
+ /**
4
+ * The Minimal theme deliberately keeps presentation to a text-first reading
5
+ * experience. Content, routing, and protected-article handling remain owned
6
+ * by the Mintfolio runtime and are supplied through the public theme API.
7
+ */
8
+ export default defineTheme({
9
+ manifest: {
10
+ id: 'minimal',
11
+ name: 'Minimal',
12
+ version: '1.0.0',
13
+ author: 'Mintfolio contributors',
14
+ description: '用于阅读和文字归档的单栏博客主题。',
15
+ engine: '^1.0.0',
16
+ },
17
+ capabilities: {
18
+ search: true,
19
+ tags: true,
20
+ categories: true,
21
+ toc: false,
22
+ darkMode: false,
23
+ encryptedPosts: true,
24
+ },
25
+ pages: {
26
+ home: './pages/home.astro',
27
+ post: './pages/post.astro',
28
+ archive: './pages/archive.astro',
29
+ page: './pages/page.astro',
30
+ notFound: './pages/not-found.astro',
31
+ },
32
+ settings: {
33
+ maxWidth: {
34
+ type: 'number',
35
+ label: '正文宽度',
36
+ description: '阅读栏的最大宽度,单位为像素。',
37
+ default: 760,
38
+ min: 480,
39
+ max: 1200,
40
+ },
41
+ accentColor: {
42
+ type: 'color',
43
+ label: '强调色',
44
+ description: '链接和交互控件使用的颜色。',
45
+ default: '#2255aa',
46
+ },
47
+ },
48
+ });
@@ -0,0 +1,10 @@
1
+ import type { AstroIntegration } from 'astro';
2
+ import type { ThemeConfiguration } from '@mintfolio/theme-api';
3
+
4
+ /** Build-time engine options. Omitted theme selects Core's lightweight Minimal. */
5
+ export interface MintfolioOptions extends Partial<ThemeConfiguration> {
6
+ /** Site module is compiled by Astro, so local image imports remain supported. */
7
+ siteConfig?: string;
8
+ }
9
+ /** Inject Core-owned blog routes and compile exactly one selected theme. */
10
+ export default function mintfolio(options?: MintfolioOptions): AstroIntegration;
@@ -0,0 +1,10 @@
1
+ import themeRuntime from './engine/integration.mjs';
2
+
3
+ /**
4
+ * Public engine entry: a consuming site owns content/config, Core injects routes.
5
+ * @param {import('./integration').MintfolioOptions} [options]
6
+ * @returns {import('astro').AstroIntegration}
7
+ */
8
+ export default function mintfolio(options = {}) {
9
+ return themeRuntime(options, { routes: true, siteConfig: options.siteConfig });
10
+ }
@@ -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,43 @@
1
+ import type { ContactInfo, Project, PublicProfile, SkillCategory, SocialLink } from '@mintfolio/theme-api';
2
+
3
+ /** Host-owned content. Theme-specific display settings belong in theme.config.mjs. */
4
+ export interface SiteConfig {
5
+ site: { title: string; url: string; description: string; language: string };
6
+ profile: PublicProfile;
7
+ social: SocialLink[];
8
+ skills: SkillCategory[];
9
+ projects: Project[];
10
+ contact: ContactInfo;
11
+ icp?: string;
12
+ }
13
+
14
+ /** Only a title and absolute HTTP(S) site URL are required to create a blog. */
15
+ export interface SiteConfigInput {
16
+ site: Pick<SiteConfig['site'], 'title' | 'url'> & Partial<Omit<SiteConfig['site'], 'title' | 'url'>>;
17
+ profile?: Partial<PublicProfile>;
18
+ social?: SocialLink[];
19
+ skills?: SkillCategory[];
20
+ projects?: Project[];
21
+ contact?: Partial<ContactInfo>;
22
+ icp?: string;
23
+ }
24
+
25
+ /**
26
+ * Complete optional author fields without exposing any theme-specific defaults.
27
+ * @param input Public site content, including the deployment URL.
28
+ * @returns Fully populated content suitable for Core's explicit public projection.
29
+ */
30
+ export function defineSiteConfig(input: SiteConfigInput): SiteConfig {
31
+ const url = new URL(input.site.url);
32
+ if (!['http:', 'https:'].includes(url.protocol)) throw new Error('[mintfolio:config] site.url must use HTTP or HTTPS');
33
+ if (!input.site.title.trim()) throw new Error('[mintfolio:config] site.title must not be empty');
34
+ return {
35
+ site: { description: '', language: 'zh-CN', ...input.site },
36
+ profile: { name: input.site.title, avatar: '', bio: '', location: '', signature: '', ...input.profile },
37
+ social: input.social ?? [],
38
+ skills: input.skills ?? [],
39
+ projects: input.projects ?? [],
40
+ contact: { email: '', social: [], ...input.contact },
41
+ ...(input.icp ? { icp: input.icp } : {}),
42
+ };
43
+ }
@@ -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,9 @@
1
+ ---
2
+ import { activeTheme, getRenderer } from 'virtual:mintfolio/theme';
3
+ import { createThemeContext } from '../engine/context';
4
+ import { notFoundPage } from '../server/pages';
5
+ const theme = await createThemeContext(activeTheme);
6
+ const page = notFoundPage(theme);
7
+ const Renderer = getRenderer('notFound');
8
+ ---
9
+ <Renderer theme={theme} page={page} />
@@ -0,0 +1,9 @@
1
+ ---
2
+ import { activeTheme, getRenderer } from 'virtual:mintfolio/theme';
3
+ import { createThemeContext } from '../engine/context';
4
+ import { aboutPage } from '../server/pages';
5
+ const theme = await createThemeContext(activeTheme);
6
+ const page = aboutPage(theme);
7
+ const Renderer = getRenderer('page');
8
+ ---
9
+ <Renderer theme={theme} page={page} />
@@ -0,0 +1,17 @@
1
+ ---
2
+ import { render, type CollectionEntry } from 'astro:content';
3
+ import { getPublishedPosts } from '../../server/posts';
4
+ import PostPage from '../../engine/runtime/post-page.astro';
5
+
6
+ /** Core generates article routes; a theme supplies only the page renderer. */
7
+ export async function getStaticPaths() {
8
+ return (await getPublishedPosts()).map((post) => ({
9
+ params: { slug: post.id },
10
+ props: { post },
11
+ }));
12
+ }
13
+ interface Props { post: CollectionEntry<'blog'> }
14
+ const { post } = Astro.props;
15
+ const { Content, headings } = await render(post);
16
+ ---
17
+ <PostPage entry={post} headings={headings}><Content /></PostPage>
@@ -0,0 +1,9 @@
1
+ ---
2
+ import { activeTheme, getRenderer } from 'virtual:mintfolio/theme';
3
+ import { createThemeContext } from '../../engine/context';
4
+ import { archivePage } from '../../server/pages';
5
+ const theme = await createThemeContext(activeTheme);
6
+ const page = await archivePage(theme);
7
+ const Renderer = getRenderer('archive');
8
+ ---
9
+ <Renderer theme={theme} page={page} />
@@ -0,0 +1,9 @@
1
+ ---
2
+ import { activeTheme, getRenderer } from 'virtual:mintfolio/theme';
3
+ import { createThemeContext } from '../engine/context';
4
+ import { homePage } from '../server/pages';
5
+ const theme = await createThemeContext(activeTheme);
6
+ const page = await homePage(theme);
7
+ const Renderer = getRenderer('home');
8
+ ---
9
+ <Renderer theme={theme} page={page} />
@@ -0,0 +1,34 @@
1
+ /**
2
+ * @fileoverview RSS feed generator for blog posts.
3
+ *
4
+ * Generates an RSS 2.0 XML feed at /rss.xml containing the 50 most recent
5
+ * published blog posts with title, link, description, and publication date.
6
+ */
7
+
8
+ import { getPublishedPosts, isProtectedPost } from '../server/posts';
9
+ import { urls } from '../server/routing';
10
+ import { xmlEscape } from '../server/xml';
11
+ import { getPublicSite } from '../server/site';
12
+ const site = getPublicSite();
13
+
14
+ export async function GET(): Promise<Response> {
15
+ const siteUrl = site.url.replace(/\/$/, '');
16
+ const posts = (await getPublishedPosts()).filter((post) => !isProtectedPost(post)).slice(0, 50);
17
+
18
+ const itemsXml = posts.map((post) => {
19
+ const link = xmlEscape(`${siteUrl}${urls.post(post.id)}`);
20
+ const title = xmlEscape(post.data.title);
21
+ const description = xmlEscape(post.data.description || '');
22
+ const pubDate = post.data.pubDate.toUTCString();
23
+
24
+ return `<item><title>${title}</title><link>${link}</link><guid>${link}</guid><description>${description}</description><pubDate>${pubDate}</pubDate></item>`;
25
+ }).join('');
26
+
27
+ const rssXml = `<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>${xmlEscape(site.title)}</title><link>${xmlEscape(siteUrl)}</link><description>${xmlEscape(site.description)}</description><language>${xmlEscape(site.language)}</language>${itemsXml}</channel></rss>`;
28
+
29
+ return new Response(rssXml, {
30
+ headers: {
31
+ 'Content-Type': 'application/rss+xml; charset=utf-8',
32
+ },
33
+ });
34
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * @fileoverview Sitemap generator for SEO.
3
+ *
4
+ * Generates an XML sitemap at /sitemap.xml containing all public pages:
5
+ * - Static pages (home, about, blog index)
6
+ * - All published blog posts
7
+ */
8
+
9
+ import { getPublishedPosts, isProtectedPost } from '../server/posts';
10
+ import { urls } from '../server/routing';
11
+ import { xmlEscape } from '../server/xml';
12
+ import { getPublicSite } from '../server/site';
13
+ const site = getPublicSite();
14
+
15
+ export async function GET(): Promise<Response> {
16
+ const siteUrl = site.url.replace(/\/$/, '');
17
+ const now = new Date().toISOString();
18
+ const posts = (await getPublishedPosts()).filter((post) => !isProtectedPost(post));
19
+
20
+ const staticUrls = [
21
+ { loc: `${siteUrl}${urls.home()}`, lastmod: now },
22
+ { loc: `${siteUrl}${urls.page('about')}/`, lastmod: now },
23
+ { loc: `${siteUrl}${urls.archive()}/`, lastmod: now },
24
+ ];
25
+
26
+ const postUrls = posts.map((post) => ({
27
+ loc: `${siteUrl}${urls.post(post.id)}/`,
28
+ lastmod: post.data.pubDate.toISOString(),
29
+ }));
30
+
31
+ const allUrls = [...staticUrls, ...postUrls]
32
+ .map((entry) => `<url><loc>${xmlEscape(entry.loc)}</loc><lastmod>${entry.lastmod}</lastmod></url>`)
33
+ .join('');
34
+
35
+ const xml = `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${allUrls}</urlset>`;
36
+
37
+ return new Response(xml, {
38
+ headers: {
39
+ 'Content-Type': 'application/xml; charset=utf-8',
40
+ },
41
+ });
42
+ }
@@ -0,0 +1,34 @@
1
+ import type { ThemeContext } from '@mintfolio/theme-api';
2
+ import type { ArchivePageData, HomePageData, NotFoundPageData, StaticPageData } from '@mintfolio/theme-api/astro';
3
+ import { pageSeo } from './seo';
4
+
5
+ /** Page semantics are independent of the chosen renderer and visual structure. */
6
+ export async function homePage(theme: ThemeContext): Promise<HomePageData> {
7
+ const { title, description } = theme.site;
8
+ const url = theme.urls.home();
9
+ return { kind: 'home', title, description, url, seo: pageSeo(theme.site, title, description, url), posts: (await theme.content.posts()).items };
10
+ }
11
+
12
+ /** Query parameters are applied by the browser because this is a static route. */
13
+ export async function archivePage(theme: ThemeContext): Promise<ArchivePageData> {
14
+ const title = '全部文章';
15
+ const description = '记录开发、设计与生活中的探索。';
16
+ const url = theme.urls.archive();
17
+ return { kind: 'archive', title, description, url, seo: pageSeo(theme.site, title, description, url), posts: (await theme.content.posts()).items, filters: { q: '', tag: '', category: '' } };
18
+ }
19
+
20
+ /** The first standalone page is derived from existing author configuration. */
21
+ export function aboutPage(theme: ThemeContext): StaticPageData {
22
+ const title = `关于我 - ${theme.site.title}`;
23
+ const description = theme.site.description;
24
+ const url = theme.urls.page('about');
25
+ return { kind: 'page', id: 'about', title, description, url, seo: pageSeo(theme.site, title, description, url), profile: theme.site.profile };
26
+ }
27
+
28
+ /** Static hosting must serve this artifact with an actual 404 status. */
29
+ export function notFoundPage(theme: ThemeContext): NotFoundPageData {
30
+ const title = '页面未找到';
31
+ const description = '这个地址没有对应的页面。';
32
+ const url = '/404.html';
33
+ return { kind: 'notFound', title, description, url, seo: pageSeo(theme.site, title, description, url, undefined, true) };
34
+ }
@@ -0,0 +1,98 @@
1
+ import type { PostSummary, PublicImage, TaxonomyTerm, TaxonomyCount, ArchivePeriod } from '@mintfolio/theme-api';
2
+ import { urls } from './routing.ts';
3
+
4
+ /**
5
+ * The fields Core needs from a validated content entry. Astro's collection entry
6
+ * structurally satisfies this interface, but raw content remains private. Keeping
7
+ * the projection independent of Astro lets policy tests use in-memory records.
8
+ */
9
+ export interface PostSource {
10
+ id: string;
11
+ data: {
12
+ title: string;
13
+ description: string;
14
+ pubDate: Date;
15
+ tags?: string[];
16
+ category?: string;
17
+ cover?: string | PublicImage;
18
+ draft: boolean;
19
+ password?: string;
20
+ };
21
+ }
22
+
23
+ export const UNCATEGORIZED = '未分类';
24
+ export const PROTECTED_POST_DESCRIPTION = '这是一篇加密文章,请输入密码后阅读。';
25
+
26
+ /** A present password enables protection, including during development. */
27
+ export function isProtectedPost(post: Pick<PostSource, 'data'>): boolean {
28
+ return post.data.password !== undefined;
29
+ }
30
+
31
+ /** Public previews never expose the protected frontmatter description. */
32
+ export function getPostDescription(post: Pick<PostSource, 'data'>): string {
33
+ return isProtectedPost(post) ? PROTECTED_POST_DESCRIPTION : post.data.description;
34
+ }
35
+
36
+ /**
37
+ * Shared publication policy used by the real collection reader. Drafts never
38
+ * enter public content services; newest articles come first. Input order and
39
+ * records are untouched, including when multiple articles share a date.
40
+ */
41
+ export function selectPublishedPosts<T extends PostSource>(posts: readonly T[]): T[] {
42
+ return posts.filter((post) => !post.data.draft)
43
+ .toSorted((left, right) => right.data.pubDate.valueOf() - left.data.pubDate.valueOf());
44
+ }
45
+
46
+ /** Convert one taxonomy label into a link using the host's route policy. */
47
+ function term(label: string, kind: 'tag' | 'category'): TaxonomyTerm {
48
+ return { id: label, label, url: urls[kind](label) };
49
+ }
50
+
51
+ /**
52
+ * Explicit allowlist projection, not `{ ...entry.data }`. Future private
53
+ * frontmatter fields stay private unless this function deliberately exposes them.
54
+ * @param post Validated private record selected by the publication policy.
55
+ * @returns Serializable public metadata, with a safe protected description.
56
+ */
57
+ export function toPostSummary(post: PostSource): PostSummary {
58
+ return {
59
+ id: post.id,
60
+ url: urls.post(post.id),
61
+ title: post.data.title,
62
+ description: getPostDescription(post),
63
+ publishedAt: post.data.pubDate.toISOString(),
64
+ tags: (post.data.tags ?? []).map((label) => term(label, 'tag')),
65
+ category: term(post.data.category || UNCATEGORIZED, 'category'),
66
+ ...(post.data.cover ? { cover: post.data.cover } : {}),
67
+ protected: isProtectedPost(post),
68
+ };
69
+ }
70
+
71
+ /** Count labels once per article and keep the existing frequency-first order. */
72
+ export function collectTaxonomy(posts: readonly PostSummary[], kind: 'tags' | 'categories'): TaxonomyCount[] {
73
+ const counts = new Map<string, TaxonomyCount>();
74
+ for (const post of posts) {
75
+ const terms = kind === 'tags' ? post.tags : [post.category];
76
+ for (const entry of new Map(terms.map((item) => [item.id, item])).values()) {
77
+ const previous = counts.get(entry.id);
78
+ counts.set(entry.id, { ...entry, count: (previous?.count ?? 0) + 1 });
79
+ }
80
+ }
81
+ return [...counts.values()].toSorted((left, right) => right.count - left.count || left.label.localeCompare(right.label, 'zh-CN'));
82
+ }
83
+
84
+ /** UTC calendar groups are deterministic across Windows and Linux build hosts. */
85
+ export function collectArchives(posts: readonly PostSummary[]): ArchivePeriod[] {
86
+ const groups = new Map<string, ArchivePeriod>();
87
+ for (const post of posts) {
88
+ const date = new Date(post.publishedAt);
89
+ const year = date.getUTCFullYear();
90
+ const month = date.getUTCMonth() + 1;
91
+ const id = `${year}-${String(month).padStart(2, '0')}`;
92
+ const group = groups.get(id) ?? { id, label: `${year}年${month}月`, year, month, count: 0, posts: [] };
93
+ group.posts.push(post);
94
+ group.count += 1;
95
+ groups.set(id, group);
96
+ }
97
+ return [...groups.values()].toSorted((left, right) => right.id.localeCompare(left.id));
98
+ }
@@ -0,0 +1,15 @@
1
+ import { getCollection, type CollectionEntry } from 'astro:content';
2
+ import { selectPublishedPosts } from './postModel';
3
+
4
+ export {
5
+ UNCATEGORIZED, PROTECTED_POST_DESCRIPTION, isProtectedPost, getPostDescription,
6
+ toPostSummary, collectTaxonomy, collectArchives, selectPublishedPosts,
7
+ } from './postModel';
8
+
9
+ /** Private collection entry. Never pass this type or its data object to a theme. */
10
+ export type InternalPost = CollectionEntry<'blog'>;
11
+
12
+ /** Reuse Astro's collection, then apply the shared publication and ordering policy. */
13
+ export async function getPublishedPosts(): Promise<InternalPost[]> {
14
+ return selectPublishedPosts(await getCollection('blog'));
15
+ }
@@ -0,0 +1,32 @@
1
+ import type { PostFilters, ThemeUrls } from '@mintfolio/theme-api';
2
+
3
+ /**
4
+ * The site's route policy lives here. Themes receive this service and resolved
5
+ * links; they never need to know how nested article ids or filters are encoded.
6
+ * Root hosting and the existing /blog URLs are intentionally preserved.
7
+ */
8
+ export const urls: ThemeUrls = {
9
+ home: (): string => '/',
10
+ archive: (filters: Partial<PostFilters> = {}): string => {
11
+ const params = new URLSearchParams();
12
+ for (const key of ['q', 'tag', 'category'] as const) {
13
+ const value = filters[key]?.trim();
14
+ if (value) params.set(key, value);
15
+ }
16
+ return params.size ? `/blog?${params}` : '/blog';
17
+ },
18
+ page: (id: string): string => {
19
+ if (id !== 'about') throw new Error(`Unknown Core page: ${id}`);
20
+ return '/about';
21
+ },
22
+ post: (id: string): string => `/blog/${id.split('/').map(encodeURIComponent).join('/')}`,
23
+ tag: (label: string): string => urls.archive({ tag: label }),
24
+ category: (label: string): string => urls.archive({ category: label }),
25
+ rss: (): string => '/rss.xml',
26
+ sitemap: (): string => '/sitemap.xml',
27
+ };
28
+
29
+ /** Resolve a Core-relative URL against the configured public origin. */
30
+ export function absoluteUrl(relative: string, siteUrl: string): string {
31
+ return new URL(relative, siteUrl).href;
32
+ }
@@ -0,0 +1,14 @@
1
+ import type { PostSummary, PublicSite } from '@mintfolio/theme-api';
2
+ import type { SeoData } from '@mintfolio/theme-api/astro';
3
+ import { absoluteUrl } from './routing';
4
+
5
+ /** Core decides indexability; themes can render this data without copying policy. */
6
+ export function pageSeo(site: PublicSite, title: string, description: string, url: string, post?: PostSummary, notFound = false): SeoData {
7
+ const image = typeof post?.cover === 'string' ? post.cover : post?.cover?.src;
8
+ return {
9
+ title, description, language: site.language,
10
+ canonical: absoluteUrl(url, site.url),
11
+ robots: post?.protected || notFound ? 'noindex,nofollow' : 'index,follow',
12
+ ...(image ? { image: absoluteUrl(image, site.url) } : {}),
13
+ };
14
+ }
@@ -0,0 +1,28 @@
1
+ import type { PublicImage, PublicSite } from '@mintfolio/theme-api';
2
+ import type { ImageMetadata } from 'astro';
3
+ import input from 'virtual:mintfolio/site-config';
4
+ import { defineSiteConfig } from '../../dist/public/config';
5
+
6
+ const config = defineSiteConfig(input);
7
+
8
+ /** Strip build-only image details while preserving Astro's optimization inputs. */
9
+ export function publicImage(source: string | ImageMetadata): string | PublicImage {
10
+ if (typeof source === 'string') return source;
11
+ return { src: source.src, width: source.width, height: source.height, format: source.format };
12
+ }
13
+
14
+ /** Shared author/site content, independent of Default's sidebar and other UI. */
15
+ export function getPublicSite(): PublicSite {
16
+ return {
17
+ title: config.site.title,
18
+ description: config.site.description,
19
+ url: config.site.url,
20
+ language: config.site.language,
21
+ profile: { ...config.profile, avatar: publicImage(config.profile.avatar) },
22
+ social: config.social.map((link) => ({ ...link })),
23
+ skills: config.skills.map((group) => ({ category: group.category, items: group.items.map((item) => ({ ...item })) })),
24
+ projects: config.projects.map((project) => ({ ...project, tags: [...project.tags] })),
25
+ contact: { ...config.contact, social: [...config.contact.social] },
26
+ icp: config.icp,
27
+ };
28
+ }
@@ -0,0 +1,8 @@
1
+ export function xmlEscape(value: string): string {
2
+ return value
3
+ .replace(/&/g, '&amp;')
4
+ .replace(/</g, '&lt;')
5
+ .replace(/>/g, '&gt;')
6
+ .replace(/"/g, '&quot;')
7
+ .replace(/'/g, '&apos;');
8
+ }