@mintfolio/core 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +104 -0
  3. package/THIRD_PARTY_NOTICES.md +5 -0
  4. package/bin/lib/config-source.mjs +147 -0
  5. package/bin/lib/config.mjs +144 -0
  6. package/bin/lib/files.mjs +103 -0
  7. package/bin/lib/init.mjs +67 -0
  8. package/bin/lib/posts.mjs +114 -0
  9. package/bin/lib/process.mjs +67 -0
  10. package/bin/lib/site.mjs +65 -0
  11. package/bin/lib/themes.mjs +119 -0
  12. package/bin/mintfolio.mjs +244 -0
  13. package/bin/theme-config.mjs +111 -0
  14. package/dist/client/archive.d.ts +7 -0
  15. package/dist/client/archive.js +47 -0
  16. package/dist/client/code.d.ts +4 -0
  17. package/dist/client/code.js +126 -0
  18. package/dist/client/lifecycle.d.ts +18 -0
  19. package/dist/client/lifecycle.js +82 -0
  20. package/dist/client/lightbox.d.ts +24 -0
  21. package/dist/client/lightbox.js +142 -0
  22. package/dist/client/navigation.d.ts +22 -0
  23. package/dist/client/navigation.js +29 -0
  24. package/dist/client/postList.d.ts +41 -0
  25. package/dist/client/postList.js +71 -0
  26. package/dist/client/protectedArticle.d.ts +26 -0
  27. package/dist/client/protectedArticle.js +64 -0
  28. package/dist/client/toc.d.ts +22 -0
  29. package/dist/client/toc.js +90 -0
  30. package/dist/public/astro.d.ts +2 -0
  31. package/dist/public/astro.js +2 -0
  32. package/dist/public/client.d.ts +10 -0
  33. package/dist/public/client.js +10 -0
  34. package/dist/public/config.d.ts +32 -0
  35. package/dist/public/config.js +21 -0
  36. package/dist/public/search.d.ts +2 -0
  37. package/dist/public/search.js +2 -0
  38. package/dist/public/theme.d.ts +2 -0
  39. package/dist/public/theme.js +2 -0
  40. package/docs/cli.md +118 -0
  41. package/package.json +88 -0
  42. package/src/client/archive.ts +45 -0
  43. package/src/client/code.ts +141 -0
  44. package/src/client/lifecycle.ts +76 -0
  45. package/src/client/lightbox.ts +163 -0
  46. package/src/client/navigation.ts +46 -0
  47. package/src/client/postList.ts +92 -0
  48. package/src/client/protectedArticle.ts +80 -0
  49. package/src/client/toc.ts +90 -0
  50. package/src/components/Image.astro +28 -0
  51. package/src/components/PostArchive.astro +48 -0
  52. package/src/components/ProtectedArticle.astro +56 -0
  53. package/src/components/SeoHead.astro +7 -0
  54. package/src/content.d.ts +15 -0
  55. package/src/content.mjs +19 -0
  56. package/src/engine/context.ts +55 -0
  57. package/src/engine/import-boundary.mjs +154 -0
  58. package/src/engine/integration.mjs +166 -0
  59. package/src/engine/loader.mjs +114 -0
  60. package/src/engine/runtime/post-page.astro +35 -0
  61. package/src/engine/schema.mjs +125 -0
  62. package/src/engine/theme-config.mjs +67 -0
  63. package/src/engine/virtual.d.ts +12 -0
  64. package/src/fallback/layouts/MinimalLayout.astro +45 -0
  65. package/src/fallback/pages/archive.astro +13 -0
  66. package/src/fallback/pages/home.astro +44 -0
  67. package/src/fallback/pages/not-found.astro +18 -0
  68. package/src/fallback/pages/page.astro +39 -0
  69. package/src/fallback/pages/post.astro +41 -0
  70. package/src/fallback/settings.ts +8 -0
  71. package/src/fallback/styles/minimal.css +109 -0
  72. package/src/fallback/theme.mjs +48 -0
  73. package/src/integration.d.ts +10 -0
  74. package/src/integration.mjs +10 -0
  75. package/src/public/astro.ts +2 -0
  76. package/src/public/client.ts +10 -0
  77. package/src/public/config.ts +43 -0
  78. package/src/public/search.ts +2 -0
  79. package/src/public/theme.ts +2 -0
  80. package/src/routes/404.astro +9 -0
  81. package/src/routes/about.astro +9 -0
  82. package/src/routes/blog/[...slug].astro +17 -0
  83. package/src/routes/blog/index.astro +9 -0
  84. package/src/routes/index.astro +9 -0
  85. package/src/routes/rss.xml.ts +34 -0
  86. package/src/routes/sitemap.xml.ts +42 -0
  87. package/src/server/pages.ts +34 -0
  88. package/src/server/postModel.ts +98 -0
  89. package/src/server/posts.ts +15 -0
  90. package/src/server/routing.ts +32 -0
  91. package/src/server/seo.ts +14 -0
  92. package/src/server/site.ts +28 -0
  93. package/src/server/xml.ts +8 -0
@@ -0,0 +1,114 @@
1
+ // @ts-check
2
+ import { realpath, readFile, stat } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { createRequire } from 'node:module';
5
+ import { pathToFileURL, fileURLToPath } from 'node:url';
6
+ import { resolve as resolveImport } from 'import-meta-resolve';
7
+ import semver from 'semver';
8
+ import { PAGE_KINDS, validateTheme, resolveSettings } from './schema.mjs';
9
+ import { checkManifestImports } from './import-boundary.mjs';
10
+ import { mergeThemeSettings, readThemeSettings, themeConfigPath } from './theme-config.mjs';
11
+
12
+ let manifestRevision = 0;
13
+
14
+ /** @param {string} filename @param {string} directory @returns {boolean} */
15
+ export function isWithin(filename, directory) {
16
+ const relative = path.relative(directory, filename);
17
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
18
+ }
19
+
20
+ /**
21
+ * Locate the explicit theme entry. Packages must export './theme'; there is no
22
+ * implicit search through dependencies and no automatic same-name override.
23
+ * @param {string | undefined} specifier Omitted/Minimal, directory/manifest path, or installed npm name.
24
+ * @param {string} root Host project root.
25
+ * @returns {Promise<string>} Real manifest filename.
26
+ */
27
+ async function resolveManifest(specifier, root) {
28
+ let filename;
29
+ if (!specifier || specifier === 'minimal') {
30
+ filename = fileURLToPath(new URL('../fallback/theme.mjs', import.meta.url));
31
+ } else if (['verdant', 'default', 'happyhues'].includes(specifier)) {
32
+ return resolveManifest('@mintfolio/theme-default', root);
33
+ } else if (specifier.startsWith('.') || path.isAbsolute(specifier)) {
34
+ filename = path.resolve(root, specifier);
35
+ if ((await stat(filename)).isDirectory()) filename = path.join(filename, 'theme.mjs');
36
+ } else {
37
+ const require = createRequire(path.join(root, 'package.json'));
38
+ filename = fileURLToPath(resolveImport(`${specifier}/theme`, pathToFileURL(path.join(root, 'package.json')).href));
39
+ // A package's renderer version is separate from its Theme API engine range.
40
+ const manifestDirectory = path.dirname(filename);
41
+ let directory = manifestDirectory;
42
+ while (directory !== path.dirname(directory)) {
43
+ try {
44
+ const packageData = JSON.parse(await readFile(path.join(directory, 'package.json'), 'utf8'));
45
+ if (packageData.name === specifier) {
46
+ const astroRange = packageData.peerDependencies?.astro;
47
+ const installedAstro = JSON.parse(await readFile(require.resolve('astro/package.json'), 'utf8')).version;
48
+ if (astroRange && !semver.satisfies(installedAstro, astroRange)) throw new Error(`[theme:renderer] ${specifier} requires Astro ${astroRange}; installed ${installedAstro}`);
49
+ break;
50
+ }
51
+ } catch (error) {
52
+ if (error instanceof Error && error.message.startsWith('[theme:renderer]')) throw error;
53
+ }
54
+ directory = path.dirname(directory);
55
+ }
56
+ }
57
+ return realpath(filename);
58
+ }
59
+
60
+ /**
61
+ * Validate one renderer path after resolving symlinks. Theme declarations stay
62
+ * within the theme; explicit user overrides stay within the host workspace.
63
+ * @param {string} source Relative renderer filename.
64
+ * @param {string} directory Allowed root and resolution base.
65
+ * @param {string} kind Semantic page key for diagnostics.
66
+ * @returns {Promise<string>}
67
+ */
68
+ async function resolvePage(source, directory, kind) {
69
+ const filename = await realpath(path.resolve(directory, source));
70
+ if (!isWithin(filename, await realpath(directory)) || path.extname(filename) !== '.astro' || !(await stat(filename)).isFile()) {
71
+ throw new Error(`[theme:page] ${kind}: renderer must be an Astro file inside ${directory}`);
72
+ }
73
+ return filename;
74
+ }
75
+
76
+ /**
77
+ * Load a single theme for the current build. The caller owns routes and Vite
78
+ * compilation; this function never runs Astro components or writes resources.
79
+ * @param {{root:string, theme?:string, settings?:unknown, overrides?:{pages?:Record<string,string>}, fresh?:boolean, readUserConfig?:boolean}} options
80
+ * readUserConfig is disabled only while creating configuration files; normal loads
81
+ * always combine the host's theme-specific file with explicit inline settings.
82
+ */
83
+ export async function loadTheme({ root, theme, settings = {}, overrides = {}, fresh = false, readUserConfig = true }) {
84
+ const manifestPath = await resolveManifest(theme, root);
85
+ const themeRoot = path.dirname(manifestPath);
86
+ // Native ESM loading does not run Vite's hooks. Inspect its local dependency
87
+ // graph first so a forbidden helper cannot execute before schema validation.
88
+ const manifestDependencies = await checkManifestImports(manifestPath, root);
89
+ const entryUrl = pathToFileURL(manifestPath);
90
+ // Development restarts need a fresh manifest, not Node's old ESM module entry.
91
+ if (fresh) entryUrl.searchParams.set('revision', `${Date.now()}-${++manifestRevision}`);
92
+ const definition = validateTheme((await import(entryUrl.href)).default, manifestPath);
93
+ const userConfig = readUserConfig ? await readThemeSettings(root, definition.manifest.id)
94
+ : { filename: themeConfigPath(root, definition.manifest.id), settings: {} };
95
+ const resolvedSettings = resolveSettings(definition, mergeThemeSettings(userConfig.settings, settings));
96
+ /** @type {Record<string,string>} */
97
+ const pages = {};
98
+ for (const [kind, source] of Object.entries(definition.pages)) {
99
+ if (source) pages[kind] = await resolvePage(source, themeRoot, kind);
100
+ }
101
+ const overrideRoots = [];
102
+ const overrideEntries = [];
103
+ if (Object.keys(overrides).some((key) => key !== 'pages')) throw new Error('[theme:overrides] Only explicit pages overrides are supported');
104
+ for (const [kind, source] of Object.entries(overrides.pages ?? {})) {
105
+ if (!PAGE_KINDS.includes(kind) || typeof source !== 'string') throw new Error(`[theme:overrides] Invalid page ${kind}`);
106
+ const filename = await resolvePage(source, root, kind);
107
+ const relative = path.relative(root, filename).replaceAll('\\', '/');
108
+ if (relative.startsWith('src/core/') || relative.startsWith('src/pages/') || relative.startsWith('src/theme/') || relative.startsWith('packages/core/src/engine/') || relative.startsWith('packages/core/src/server/') || relative.startsWith('packages/core/src/routes/')) throw new Error(`[theme:overrides] ${kind}: Core files cannot be used as theme overrides`);
109
+ pages[kind] = filename;
110
+ overrideRoots.push(path.dirname(filename));
111
+ overrideEntries.push(filename);
112
+ }
113
+ return { definition, settings: resolvedSettings, pages, manifestPath, manifestDependencies, themeRoot, overrideRoots, overrideEntries, themeConfigFile: userConfig.filename };
114
+ }
@@ -0,0 +1,35 @@
1
+ ---
2
+ import type { CollectionEntry } from 'astro:content';
3
+ import type { ArticleHeading, ArticleBody, PostPageData } from '@mintfolio/theme-api/astro';
4
+ import { encryptArticle } from '@mintfolio/theme-api/crypto';
5
+ import { activeTheme, getRenderer } from 'virtual:mintfolio/theme';
6
+ import { createThemeContext } from '../context';
7
+ import { toPostSummary, isProtectedPost } from '../../server/posts';
8
+ import { pageSeo } from '../../server/seo';
9
+
10
+ /** Private adapter props; only the Core article route can supply collection data. */
11
+ interface Props {
12
+ entry: CollectionEntry<'blog'>;
13
+ headings: ArticleHeading[];
14
+ }
15
+ const { entry, headings } = Astro.props;
16
+ const theme = await createThemeContext(activeTheme);
17
+ const post = toPostSummary(entry);
18
+ // The slot is compiled by Astro before any theme sees the result. Protected
19
+ // content and headings are encrypted together and never become renderer props.
20
+ const html = await Astro.slots.render('default');
21
+ const body: ArticleBody = isProtectedPost(entry)
22
+ ? { kind: 'protected', postId: entry.id, payload: await encryptArticle(JSON.stringify({ version: 1, html, headings }), entry.data.password!, entry.id) }
23
+ : { kind: 'public', html, headings };
24
+ const posts = (await theme.content.posts()).items;
25
+ const index = posts.findIndex((item) => item.id === entry.id);
26
+ const page: PostPageData = {
27
+ kind: 'post', post, body,
28
+ title: post.title, description: post.description, url: post.url,
29
+ seo: pageSeo(theme.site, post.title, post.description, post.url, post),
30
+ previous: posts[index - 1] ?? null,
31
+ next: posts[index + 1] ?? null,
32
+ };
33
+ const Renderer = getRenderer('post');
34
+ ---
35
+ <Renderer theme={theme} page={page} />
@@ -0,0 +1,125 @@
1
+ // @ts-check
2
+ import { z } from 'astro/zod';
3
+ import semver from 'semver';
4
+
5
+ /** Theme contract version; independent of the application and Astro versions. */
6
+ export const THEME_ENGINE_VERSION = '1.0.0';
7
+ export const PAGE_KINDS = ['home', 'post', 'page', 'archive', 'notFound'];
8
+
9
+ const label = { label: z.string().min(1), description: z.string().optional() };
10
+ const color = z.string().regex(/^#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i, 'Expected a hexadecimal CSS color');
11
+ /** @type {import('astro/zod').z.ZodType<import('@mintfolio/theme-api').SettingDefinition>} */
12
+ const settingSchema = z.lazy(() => z.discriminatedUnion('type', [
13
+ z.object({ ...label, type: z.literal('string'), default: z.string() }).strict(),
14
+ z.object({ ...label, type: z.literal('boolean'), default: z.boolean() }).strict(),
15
+ z.object({ ...label, type: z.literal('number'), default: z.number(), min: z.number().optional(), max: z.number().optional() }).strict(),
16
+ z.object({ ...label, type: z.literal('select'), default: z.string(), options: z.array(z.string()).min(1) }).strict(),
17
+ z.object({ ...label, type: z.literal('color'), default: color }).strict(),
18
+ z.object({ ...label, type: z.literal('object'), default: z.record(z.string(), z.unknown()), properties: z.record(z.string(), settingSchema) }).strict(),
19
+ z.object({ ...label, type: z.literal('array'), default: z.array(z.unknown()), items: settingSchema }).strict(),
20
+ ]).superRefine((setting, context) => {
21
+ if (setting.type === 'number') {
22
+ if ((setting.min !== undefined && setting.default < setting.min) || (setting.max !== undefined && setting.default > setting.max) || (setting.min !== undefined && setting.max !== undefined && setting.min > setting.max)) {
23
+ context.addIssue({ code: 'custom', message: 'The default and bounds must describe a valid number interval' });
24
+ }
25
+ }
26
+ if (setting.type === 'select' && (!setting.options.includes(setting.default) || new Set(setting.options).size !== setting.options.length)) {
27
+ context.addIssue({ code: 'custom', message: 'Select options must be unique and include the default' });
28
+ }
29
+ }));
30
+
31
+ const pagePath = z.string().startsWith('./').endsWith('.astro');
32
+ const pagesSchema = z.object({
33
+ home: pagePath, post: pagePath,
34
+ page: pagePath.optional(), archive: pagePath.optional(), notFound: pagePath.optional(),
35
+ }).strict();
36
+
37
+ const definitionSchema = z.object({
38
+ manifest: z.object({
39
+ id: z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
40
+ name: z.string().min(1), version: z.string().refine((value) => semver.valid(value) !== null, 'Invalid semantic version'),
41
+ author: z.string().min(1), description: z.string().min(1),
42
+ engine: z.string().refine((value) => semver.validRange(value) !== null, 'Invalid engine range'),
43
+ }).strict(),
44
+ capabilities: z.object({
45
+ search: z.boolean().default(false), tags: z.boolean().default(false), categories: z.boolean().default(false),
46
+ toc: z.boolean().default(false), darkMode: z.boolean().default(false), encryptedPosts: z.boolean().default(false),
47
+ comments: z.boolean().default(false), i18n: z.boolean().default(false),
48
+ }).strict().default({ search: false, tags: false, categories: false, toc: false, darkMode: false, encryptedPosts: false, comments: false, i18n: false }),
49
+ pages: pagesSchema,
50
+ settings: z.record(z.string(), settingSchema).default({}),
51
+ build: z.object({ react: z.boolean().optional(), tailwind: z.boolean().optional() }).strict().optional(),
52
+ }).strict();
53
+
54
+ /**
55
+ * Parse untrusted theme metadata before any renderer is imported.
56
+ * Errors include the field path and source so both CLI and build logs are useful.
57
+ * @param {unknown} input Module default export.
58
+ * @param {string} source Manifest path used in diagnostics.
59
+ * @returns {import('@mintfolio/theme-api').ThemeDefinition}
60
+ */
61
+ export function validateTheme(input, source) {
62
+ const result = definitionSchema.safeParse(input);
63
+ if (!result.success) {
64
+ const details = result.error.issues.map((issue) => `${issue.path.join('.') || 'theme'}: ${issue.message}`).join('\n');
65
+ throw new Error(`[theme:manifest] ${source}\n${details}`);
66
+ }
67
+ if (!semver.satisfies(THEME_ENGINE_VERSION, result.data.manifest.engine)) {
68
+ throw new Error(`[theme:engine] ${result.data.manifest.id} requires ${result.data.manifest.engine}; runtime is ${THEME_ENGINE_VERSION}`);
69
+ }
70
+ // Validate nested defaults through the same path used for user overrides.
71
+ resolveSettings(result.data, {});
72
+ return result.data;
73
+ }
74
+
75
+ /**
76
+ * Resolve declarative visual settings. Unknown keys and invalid values fail early
77
+ * instead of silently producing a partially configured theme.
78
+ * @param {import('@mintfolio/theme-api').ThemeDefinition} definition Validated theme.
79
+ * @param {unknown} input User overrides; defaults are applied when absent.
80
+ * @returns {Record<string, unknown>}
81
+ */
82
+ export function resolveSettings(definition, input = {}) {
83
+ const user = z.record(z.string(), z.unknown()).parse(input);
84
+ return resolveObject(definition.settings, user, definition.manifest.id);
85
+ }
86
+
87
+ /**
88
+ * Recursively merge validated object defaults, preserving explicit array order.
89
+ * @param {import('@mintfolio/theme-api').SettingsSchema} schema
90
+ * @param {Record<string, unknown>} user
91
+ * @param {string} field
92
+ * @returns {Record<string, unknown>}
93
+ */
94
+ function resolveObject(schema, user, field) {
95
+ /** @type {Record<string, unknown>} */
96
+ const values = {};
97
+ for (const key of Object.keys(user)) {
98
+ if (!Object.hasOwn(schema, key)) throw new Error(`[theme:settings] ${field}.${key}: unknown setting`);
99
+ }
100
+ for (const [key, setting] of Object.entries(schema)) {
101
+ const value = resolveValue(setting, Object.hasOwn(user, key) ? user[key] : setting.default, `${field}.${key}`);
102
+ Object.defineProperty(values, key, { value, enumerable: true });
103
+ }
104
+ return values;
105
+ }
106
+
107
+ /** @param {import('@mintfolio/theme-api').SettingDefinition} setting @param {unknown} value @param {string} field @returns {unknown} */
108
+ function resolveValue(setting, value, field) {
109
+ let valid = false;
110
+ switch (setting.type) {
111
+ case 'boolean': valid = typeof value === 'boolean'; break;
112
+ case 'number': valid = typeof value === 'number' && Number.isFinite(value) && (setting.min === undefined || value >= setting.min) && (setting.max === undefined || value <= setting.max); break;
113
+ case 'select': valid = typeof value === 'string' && setting.options.includes(value); break;
114
+ case 'color': valid = color.safeParse(value).success; break;
115
+ case 'string': valid = typeof value === 'string'; break;
116
+ case 'array':
117
+ if (Array.isArray(value)) return value.map((item, index) => resolveValue(setting.items, item, `${field}[${index}]`));
118
+ break;
119
+ case 'object':
120
+ if (value && typeof value === 'object' && !Array.isArray(value)) return resolveObject(setting.properties, { ...setting.default, ...value }, field);
121
+ break;
122
+ }
123
+ if (!valid) throw new Error(`[theme:settings] ${field}: invalid ${setting.type} value`);
124
+ return value;
125
+ }
@@ -0,0 +1,67 @@
1
+ // @ts-check
2
+ import { stat } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+
6
+ let revision = 0;
7
+
8
+ /** @param {unknown} value @returns {value is Record<string, unknown>} */
9
+ function isRecord(value) {
10
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
11
+ && [Object.prototype, null].includes(Object.getPrototypeOf(value));
12
+ }
13
+
14
+ /**
15
+ * A theme gets one predictable settings file in the host root. Theme ids must
16
+ * already satisfy the manifest rules; never accept directory components here.
17
+ * @param {string} root Absolute host project root.
18
+ * @param {string} id Validated theme manifest id.
19
+ * @returns {string} Absolute host-owned settings filename.
20
+ */
21
+ export function themeConfigPath(root, id) {
22
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(id)) throw new Error('[theme:config] Invalid theme id');
23
+ return path.join(root, `theme-${id}.config.mjs`);
24
+ }
25
+
26
+ /**
27
+ * Merge a theme settings file with legacy inline settings. Inline values win;
28
+ * nested objects merge recursively, while arrays are replaced as complete lists.
29
+ * @param {unknown} fileSettings Default export of the theme-specific config.
30
+ * @param {unknown} inlineSettings Explicit theme.config.mjs settings.
31
+ * @returns {Record<string, unknown>} Unvalidated combined settings, without mutating either input.
32
+ */
33
+ export function mergeThemeSettings(fileSettings, inlineSettings) {
34
+ if (!isRecord(fileSettings) || !isRecord(inlineSettings)) throw new Error('[theme:config] Settings must be a plain object');
35
+ return Object.fromEntries([...new Set([...Object.keys(fileSettings), ...Object.keys(inlineSettings)])].map((key) => {
36
+ if (!Object.hasOwn(inlineSettings, key)) return [key, fileSettings[key]];
37
+ const fromFile = fileSettings[key];
38
+ const inline = inlineSettings[key];
39
+ return [key, isRecord(fromFile) && isRecord(inline) ? mergeThemeSettings(fromFile, inline) : inline];
40
+ }));
41
+ }
42
+
43
+ /**
44
+ * Read the site's own ESM settings. Missing files preserve existing defaults;
45
+ * syntax errors and invalid exports fail with the exact editable filename.
46
+ * @param {string} root Absolute host root.
47
+ * @param {string} id Validated manifest id.
48
+ * @returns {Promise<{filename:string, settings:Record<string,unknown>}>}
49
+ */
50
+ export async function readThemeSettings(root, id) {
51
+ const filename = themeConfigPath(root, id);
52
+ try { await stat(filename); }
53
+ catch (error) {
54
+ if (/** @type {NodeJS.ErrnoException} */ (error).code === 'ENOENT') return { filename, settings: {} };
55
+ throw error;
56
+ }
57
+ try {
58
+ // A config watcher restarts the integration; do not reuse the previous ESM value.
59
+ const entry = pathToFileURL(filename);
60
+ entry.searchParams.set('revision', `${Date.now()}-${++revision}`);
61
+ const value = (await import(entry.href)).default;
62
+ if (!isRecord(value)) throw new Error('default export must be a plain settings object');
63
+ return { filename, settings: value };
64
+ } catch (error) {
65
+ throw new Error(`[theme:config] ${filename}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
66
+ }
67
+ }
@@ -0,0 +1,12 @@
1
+ declare module 'virtual:mintfolio/theme' {
2
+ /** Host-only selected theme; theme packages consume injected public props. */
3
+ export const activeTheme: import('./context').ActiveTheme;
4
+ /** Each concrete renderer narrows page/settings; the dispatcher accepts the public union. */
5
+ export function getRenderer(kind: import('@mintfolio/theme-api').PageKind):
6
+ (props: import('@mintfolio/theme-api/astro').PageProps) => ReturnType<typeof import('../fallback/pages/not-found.astro').default>;
7
+ }
8
+
9
+ declare module 'virtual:mintfolio/site-config' {
10
+ const config: import('../public/config').SiteConfigInput;
11
+ export default config;
12
+ }
@@ -0,0 +1,45 @@
1
+ ---
2
+ import SeoHead from '@mintfolio/theme-api/SeoHead.astro';
3
+ import '../styles/minimal.css';
4
+ import type { MinimalSettings } from '../settings';
5
+ import type { PublicSite, NavigationLink } from '@mintfolio/theme-api';
6
+ import type { SeoData } from '@mintfolio/theme-api/astro';
7
+
8
+ interface Props {
9
+ /** Public, projected site information supplied by the runtime. */
10
+ readonly site: Pick<PublicSite, 'title' | 'profile'>;
11
+ /** Core-owned destinations that the theme may render in its own navigation. */
12
+ readonly navigation: readonly NavigationLink[];
13
+ /** Core-owned canonical home address; never reconstructed from a route string. */
14
+ readonly homeUrl: string;
15
+ /** Metadata calculated by Core; the theme does not infer canonical URLs itself. */
16
+ readonly seo: SeoData;
17
+ /** Validated Minimal theme settings. */
18
+ readonly settings: Partial<MinimalSettings> | Record<string, unknown>;
19
+ }
20
+
21
+ const { site, navigation, homeUrl, seo, settings } = Astro.props;
22
+ // An optional-page fallback can receive another theme's settings. Use only
23
+ // Minimal's own validated primitives and apply its defaults when absent.
24
+ const width = typeof settings.maxWidth === 'number' && Number.isFinite(settings.maxWidth) ? Math.max(480, Math.min(1200, settings.maxWidth)) : 760;
25
+ const accent = typeof settings.accentColor === 'string' && /^#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i.test(settings.accentColor) ? settings.accentColor : '#2255aa';
26
+ ---
27
+
28
+ <!doctype html>
29
+ <html lang={seo.language} class="mintfolio-minimal">
30
+ <head>
31
+ <meta charset="utf-8" />
32
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
33
+ <SeoHead seo={seo} />
34
+ </head>
35
+ <body style={`--minimal-width: ${width}px; --minimal-accent: ${accent};`}>
36
+ <div class="minimal-shell">
37
+ <a class="minimal-site-name" href={homeUrl}>{site.title}</a>
38
+ <nav class="minimal-navigation" aria-label="网站导航">
39
+ {navigation.map((item) => <a href={item.url}>{item.label}</a>)}
40
+ </nav>
41
+ <main class="minimal-main"><slot /></main>
42
+ <footer class="minimal-footer">{site.profile.signature ?? site.profile.name}</footer>
43
+ </div>
44
+ </body>
45
+ </html>
@@ -0,0 +1,13 @@
1
+ ---
2
+ import type { ListPageData, PageProps } from '@mintfolio/core/astro';
3
+ import PostArchive from '@mintfolio/core/components/PostArchive.astro';
4
+ import MinimalLayout from '../layouts/MinimalLayout.astro';
5
+ import type { MinimalSettings } from '../settings';
6
+ interface Props extends PageProps<MinimalSettings, ListPageData> {}
7
+ const { theme, page } = Astro.props;
8
+ const [tags, categories] = await Promise.all([theme.taxonomy.tags(), theme.taxonomy.categories()]);
9
+ ---
10
+ <MinimalLayout site={theme.site} navigation={theme.navigation} homeUrl={theme.urls.home()} seo={page.seo} settings={theme.settings}>
11
+ <header><p class="minimal-kicker">文章归档</p><h1 class="minimal-title">{page.title}</h1><p class="minimal-description">{page.description}</p></header>
12
+ <PostArchive posts={page.posts} tags={tags} categories={categories} filters={page.filters} language={theme.site.language} />
13
+ </MinimalLayout>
@@ -0,0 +1,44 @@
1
+ ---
2
+ import type { HomePageData, PageProps } from '@mintfolio/theme-api/astro';
3
+ import MinimalLayout from '../layouts/MinimalLayout.astro';
4
+ import type { MinimalSettings } from '../settings';
5
+
6
+ interface Props extends PageProps<MinimalSettings, HomePageData> {}
7
+
8
+ const { theme, page } = Astro.props;
9
+
10
+ /** Converts the Core ISO date DTO to the visitor's readable locale date. */
11
+ const formatDate = (value: string): string => new Intl.DateTimeFormat(theme.site.language, {
12
+ dateStyle: 'medium',
13
+ }).format(new Date(value));
14
+ ---
15
+
16
+ <MinimalLayout site={theme.site} navigation={theme.navigation} homeUrl={theme.urls.home()} seo={page.seo} settings={theme.settings}>
17
+ <section aria-labelledby="minimal-home-title">
18
+ <p class="minimal-kicker">{theme.site.profile.name}</p>
19
+ <h1 id="minimal-home-title" class="minimal-title">{page.title}</h1>
20
+ <p class="minimal-description">{page.description || theme.site.description}</p>
21
+ </section>
22
+
23
+ <section aria-label="文章">
24
+ {page.posts.length === 0 ? (
25
+ <p class="minimal-empty">暂时还没有发布文章。</p>
26
+ ) : (
27
+ <ol class="minimal-post-list">
28
+ {page.posts.map((post) => (
29
+ <li>
30
+ <h2><a href={post.url}>{post.title}</a></h2>
31
+ <p class="minimal-meta"><time datetime={post.publishedAt}>{formatDate(post.publishedAt)}</time>{post.protected ? ' · 受保护' : ''}</p>
32
+ <p>{post.description}</p>
33
+ <div class="minimal-terms" aria-label="Post taxonomy">
34
+ <a href={post.category.url}>{post.category.label}</a>
35
+ {post.tags.map((tag) => <a href={tag.url}>#{tag.label}</a>)}
36
+ </div>
37
+ </li>
38
+ ))}
39
+ </ol>
40
+ )}
41
+ </section>
42
+
43
+ <p><a href={theme.urls.archive()}>浏览文章归档 →</a></p>
44
+ </MinimalLayout>
@@ -0,0 +1,18 @@
1
+ ---
2
+ import type { NotFoundPageData, PageProps } from '@mintfolio/theme-api/astro';
3
+ import MinimalLayout from '../layouts/MinimalLayout.astro';
4
+ import type { MinimalSettings } from '../settings';
5
+
6
+ interface Props extends PageProps<MinimalSettings, NotFoundPageData> {}
7
+
8
+ const { theme, page } = Astro.props;
9
+ ---
10
+
11
+ <MinimalLayout site={theme.site} navigation={theme.navigation} homeUrl={theme.urls.home()} seo={page.seo} settings={theme.settings}>
12
+ <section aria-labelledby="minimal-not-found-title">
13
+ <p class="minimal-kicker">404</p>
14
+ <h1 id="minimal-not-found-title" class="minimal-title">{page.title}</h1>
15
+ <p class="minimal-description">{page.description}</p>
16
+ <p><a href={theme.urls.home()}>返回首页</a> · <a href={theme.urls.archive()}>浏览文章归档</a></p>
17
+ </section>
18
+ </MinimalLayout>
@@ -0,0 +1,39 @@
1
+ ---
2
+ import type { PageProps, StaticPageData } from '@mintfolio/theme-api/astro';
3
+ import MinimalLayout from '../layouts/MinimalLayout.astro';
4
+ import type { MinimalSettings } from '../settings';
5
+
6
+ interface Props extends PageProps<MinimalSettings, StaticPageData> {}
7
+
8
+ const { theme, page } = Astro.props;
9
+ const profile = page.profile;
10
+ ---
11
+
12
+ <MinimalLayout site={theme.site} navigation={theme.navigation} homeUrl={theme.urls.home()} seo={page.seo} settings={theme.settings}>
13
+ <article class="minimal-article">
14
+ <header>
15
+ <p class="minimal-kicker">{page.id}</p>
16
+ <h1>{page.title}</h1>
17
+ <p>{page.description}</p>
18
+ </header>
19
+ <div class="minimal-prose">
20
+ <p>{profile.bio}</p>
21
+ <dl>
22
+ <dt>姓名</dt>
23
+ <dd>{profile.name}</dd>
24
+ <dt>所在地</dt>
25
+ <dd>{profile.location}</dd>
26
+ <dt>联系方式</dt>
27
+ <dd><a href={`mailto:${theme.site.contact.email}`}>{theme.site.contact.email}</a></dd>
28
+ </dl>
29
+ {theme.site.social.length > 0 && (
30
+ <section aria-labelledby="minimal-social-title">
31
+ <h2 id="minimal-social-title">其他平台</h2>
32
+ <ul>
33
+ {theme.site.social.map((social) => <li><a href={social.url}>{social.platform}</a></li>)}
34
+ </ul>
35
+ </section>
36
+ )}
37
+ </div>
38
+ </article>
39
+ </MinimalLayout>
@@ -0,0 +1,41 @@
1
+ ---
2
+ import type { PageProps, PostPageData } from '@mintfolio/theme-api/astro';
3
+ import ProtectedArticle from '@mintfolio/core/components/ProtectedArticle.astro';
4
+ import MinimalLayout from '../layouts/MinimalLayout.astro';
5
+ import type { MinimalSettings } from '../settings';
6
+
7
+ interface Props extends PageProps<MinimalSettings, PostPageData> {}
8
+
9
+ const { theme, page } = Astro.props;
10
+ const formatDate = (value: string): string => new Intl.DateTimeFormat(theme.site.language, {
11
+ dateStyle: 'long',
12
+ }).format(new Date(value));
13
+ const protectedBody = page.body.kind === 'protected' ? page.body : undefined;
14
+ ---
15
+
16
+ <MinimalLayout site={theme.site} navigation={theme.navigation} homeUrl={theme.urls.home()} seo={page.seo} settings={theme.settings}>
17
+ <article class="minimal-article">
18
+ <header>
19
+ <p class="minimal-kicker"><a href={page.post.category.url}>{page.post.category.label}</a></p>
20
+ <h1>{page.post.title}</h1>
21
+ <p class="minimal-meta"><time datetime={page.post.publishedAt}>{formatDate(page.post.publishedAt)}</time></p>
22
+ <p>{page.post.description}</p>
23
+ {page.post.tags.length > 0 && (
24
+ <div class="minimal-terms" aria-label="标签">
25
+ {page.post.tags.map((tag) => <a href={tag.url}>#{tag.label}</a>)}
26
+ </div>
27
+ )}
28
+ </header>
29
+
30
+ {page.body.kind === 'public' ? (
31
+ <div class="minimal-prose" set:html={page.body.html} />
32
+ ) : protectedBody && (
33
+ <ProtectedArticle body={protectedBody} />
34
+ )}
35
+ </article>
36
+
37
+ <nav class="minimal-pager" aria-label="相邻文章">
38
+ <span>{page.previous ? <a href={page.previous.url}>← {page.previous.title}</a> : ''}</span>
39
+ <span>{page.next ? <a href={page.next.url}>{page.next.title} →</a> : ''}</span>
40
+ </nav>
41
+ </MinimalLayout>
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Values validated from Minimal's manifest settings before a page renderer runs.
3
+ * maxWidth is a pixel value; accentColor is a validated hexadecimal CSS color.
4
+ */
5
+ export interface MinimalSettings {
6
+ readonly maxWidth: number;
7
+ readonly accentColor: string;
8
+ }