@mintfolio/core 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +674 -0
- package/README.md +104 -0
- package/THIRD_PARTY_NOTICES.md +5 -0
- package/bin/lib/config-source.mjs +147 -0
- package/bin/lib/config.mjs +144 -0
- package/bin/lib/files.mjs +103 -0
- package/bin/lib/init.mjs +67 -0
- package/bin/lib/posts.mjs +114 -0
- package/bin/lib/process.mjs +67 -0
- package/bin/lib/site.mjs +65 -0
- package/bin/lib/themes.mjs +119 -0
- package/bin/mintfolio.mjs +244 -0
- package/bin/theme-config.mjs +111 -0
- package/dist/client/archive.d.ts +7 -0
- package/dist/client/archive.js +47 -0
- package/dist/client/code.d.ts +4 -0
- package/dist/client/code.js +126 -0
- package/dist/client/lifecycle.d.ts +18 -0
- package/dist/client/lifecycle.js +82 -0
- package/dist/client/lightbox.d.ts +24 -0
- package/dist/client/lightbox.js +142 -0
- package/dist/client/navigation.d.ts +22 -0
- package/dist/client/navigation.js +29 -0
- package/dist/client/postList.d.ts +41 -0
- package/dist/client/postList.js +71 -0
- package/dist/client/protectedArticle.d.ts +26 -0
- package/dist/client/protectedArticle.js +64 -0
- package/dist/client/toc.d.ts +22 -0
- package/dist/client/toc.js +90 -0
- package/dist/public/astro.d.ts +2 -0
- package/dist/public/astro.js +2 -0
- package/dist/public/client.d.ts +10 -0
- package/dist/public/client.js +10 -0
- package/dist/public/config.d.ts +32 -0
- package/dist/public/config.js +21 -0
- package/dist/public/search.d.ts +2 -0
- package/dist/public/search.js +2 -0
- package/dist/public/theme.d.ts +2 -0
- package/dist/public/theme.js +2 -0
- package/docs/cli.md +118 -0
- package/package.json +88 -0
- package/src/client/archive.ts +45 -0
- package/src/client/code.ts +141 -0
- package/src/client/lifecycle.ts +76 -0
- package/src/client/lightbox.ts +163 -0
- package/src/client/navigation.ts +46 -0
- package/src/client/postList.ts +92 -0
- package/src/client/protectedArticle.ts +80 -0
- package/src/client/toc.ts +90 -0
- package/src/components/Image.astro +28 -0
- package/src/components/PostArchive.astro +48 -0
- package/src/components/ProtectedArticle.astro +56 -0
- package/src/components/SeoHead.astro +7 -0
- package/src/content.d.ts +15 -0
- package/src/content.mjs +19 -0
- package/src/engine/context.ts +55 -0
- package/src/engine/import-boundary.mjs +154 -0
- package/src/engine/integration.mjs +166 -0
- package/src/engine/loader.mjs +114 -0
- package/src/engine/runtime/post-page.astro +35 -0
- package/src/engine/schema.mjs +125 -0
- package/src/engine/theme-config.mjs +67 -0
- package/src/engine/virtual.d.ts +12 -0
- package/src/fallback/layouts/MinimalLayout.astro +45 -0
- package/src/fallback/pages/archive.astro +13 -0
- package/src/fallback/pages/home.astro +44 -0
- package/src/fallback/pages/not-found.astro +18 -0
- package/src/fallback/pages/page.astro +39 -0
- package/src/fallback/pages/post.astro +41 -0
- package/src/fallback/settings.ts +8 -0
- package/src/fallback/styles/minimal.css +109 -0
- package/src/fallback/theme.mjs +48 -0
- package/src/integration.d.ts +10 -0
- package/src/integration.mjs +10 -0
- package/src/public/astro.ts +2 -0
- package/src/public/client.ts +10 -0
- package/src/public/config.ts +43 -0
- package/src/public/search.ts +2 -0
- package/src/public/theme.ts +2 -0
- package/src/routes/404.astro +9 -0
- package/src/routes/about.astro +9 -0
- package/src/routes/blog/[...slug].astro +17 -0
- package/src/routes/blog/index.astro +9 -0
- package/src/routes/index.astro +9 -0
- package/src/routes/rss.xml.ts +34 -0
- package/src/routes/sitemap.xml.ts +42 -0
- package/src/server/pages.ts +34 -0
- package/src/server/postModel.ts +98 -0
- package/src/server/posts.ts +15 -0
- package/src/server/routing.ts +32 -0
- package/src/server/seo.ts +14 -0
- package/src/server/site.ts +28 -0
- package/src/server/xml.ts +8 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { Image, type LocalImageProps } from 'astro:assets';
|
|
3
|
+
import type { HTMLAttributes } from 'astro/types';
|
|
4
|
+
import type { PublicImage } from '@mintfolio/theme-api';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Images declare display dimensions, including
|
|
8
|
+
* remote URLs. Keep the SDK's local metadata intact so Astro can optimize it.
|
|
9
|
+
* Responsive widths and the remaining transform options are forwarded to Astro.
|
|
10
|
+
*/
|
|
11
|
+
type Props = Omit<HTMLAttributes<'img'>, 'src' | 'alt' | 'width' | 'height' | 'srcset'>
|
|
12
|
+
& Pick<LocalImageProps, 'layout' | 'widths' | 'quality' | 'format' | 'priority' | 'fit' | 'position' | 'background'>
|
|
13
|
+
& {
|
|
14
|
+
src: string | PublicImage;
|
|
15
|
+
alt: string;
|
|
16
|
+
width: number;
|
|
17
|
+
height: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Forward every other attribute, including parent-generated scoped CSS markers.
|
|
21
|
+
// Narrowing each branch lets Astro select its remote/local prop contract safely.
|
|
22
|
+
const { src, ...attributes } = Astro.props;
|
|
23
|
+
const placeholder = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200"><rect width="200" height="200" fill="#eee"/><circle cx="100" cy="75" r="30" fill="#aaa"/><path d="M40 185v-15a60 60 0 0 1 120 0v15" fill="#aaa"/></svg>');
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
{!src ? <img src={placeholder} {...attributes} /> : typeof src === 'string'
|
|
27
|
+
? <Image src={src} {...attributes} />
|
|
28
|
+
: <Image src={src} {...attributes} />}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { PostFilters, PostSummary, TaxonomyCount } from '@mintfolio/theme-api';
|
|
3
|
+
interface Props {
|
|
4
|
+
/** Safe public metadata supplied by Core's page/context APIs. */
|
|
5
|
+
posts: PostSummary[];
|
|
6
|
+
tags: TaxonomyCount[];
|
|
7
|
+
categories: TaxonomyCount[];
|
|
8
|
+
filters?: Partial<PostFilters>;
|
|
9
|
+
language?: string;
|
|
10
|
+
/** Omit to display every match; supply a positive integer to enable load more. */
|
|
11
|
+
pageSize?: number;
|
|
12
|
+
}
|
|
13
|
+
const { posts, tags, categories, filters = {}, language = 'zh-CN', pageSize } = Astro.props;
|
|
14
|
+
const formatDate = (value: string): string => new Intl.DateTimeFormat(language, { dateStyle: 'medium' }).format(new Date(value));
|
|
15
|
+
---
|
|
16
|
+
<section class="mintfolio-archive" data-core-archive data-archive data-posts={JSON.stringify(posts)} data-page-size={pageSize}>
|
|
17
|
+
<form class="mintfolio-filters" data-filter-form aria-label="筛选文章">
|
|
18
|
+
<label>搜索<input name="q" type="search" value={filters.q ?? ''} autocomplete="off" /></label>
|
|
19
|
+
<label>标签<select name="tag"><option value="">全部标签</option>{tags.map((tag) => <option value={tag.label} selected={filters.tag === tag.label}>{tag.label} ({tag.count})</option>)}</select></label>
|
|
20
|
+
<label>分类<select name="category"><option value="">全部分类</option>{categories.map((term) => <option value={term.label} selected={filters.category === term.label}>{term.label} ({term.count})</option>)}</select></label>
|
|
21
|
+
</form>
|
|
22
|
+
<p data-filter-status aria-live="polite"></p>
|
|
23
|
+
<ol class="mintfolio-posts">
|
|
24
|
+
{posts.map((post) => <li data-post-id={post.id}>
|
|
25
|
+
<h2><a href={post.url}>{post.title}</a></h2>
|
|
26
|
+
<p><time datetime={post.publishedAt}>{formatDate(post.publishedAt)}</time>{post.protected ? ' · 受保护' : ''}</p>
|
|
27
|
+
<p>{post.description}</p>
|
|
28
|
+
<div class="mintfolio-terms"><a href={post.category.url}>{post.category.label}</a>{post.tags.map((tag) => <a href={tag.url}>#{tag.label}</a>)}</div>
|
|
29
|
+
</li>)}
|
|
30
|
+
</ol>
|
|
31
|
+
<button type="button" data-load-more hidden>加载更多</button>
|
|
32
|
+
</section>
|
|
33
|
+
<style>
|
|
34
|
+
.mintfolio-filters { display: grid; gap: .8rem; grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
|
35
|
+
.mintfolio-filters label { display: grid; gap: .25rem; }
|
|
36
|
+
.mintfolio-filters :is(input,select) { min-width: 0; font: inherit; padding: .45rem; }
|
|
37
|
+
.mintfolio-posts { list-style: none; margin: 0; padding: 0; }
|
|
38
|
+
.mintfolio-posts > li { padding: 1rem 0; border-top: 1px solid #8886; }
|
|
39
|
+
.mintfolio-posts h2 { font-size: 1.15rem; margin: 0; }
|
|
40
|
+
.mintfolio-posts p { margin: .4rem 0; }
|
|
41
|
+
.mintfolio-terms { display: flex; flex-wrap: wrap; gap: .75rem; }
|
|
42
|
+
.mintfolio-archive [hidden] { display: none; }
|
|
43
|
+
@media (max-width: 620px) { .mintfolio-filters { grid-template-columns: 1fr; } }
|
|
44
|
+
</style>
|
|
45
|
+
<script>
|
|
46
|
+
import { bindPostArchive, onPage } from '@mintfolio/core/client';
|
|
47
|
+
onPage('[data-core-archive]', (root, scope) => bindPostArchive(root, scope));
|
|
48
|
+
</script>
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { ArticleBody } from '@mintfolio/theme-api/astro';
|
|
3
|
+
interface Props {
|
|
4
|
+
/** Core supplies only authenticated ciphertext, never the password or plaintext. */
|
|
5
|
+
body: Extract<ArticleBody, { kind: 'protected' }>;
|
|
6
|
+
}
|
|
7
|
+
const { body } = Astro.props;
|
|
8
|
+
---
|
|
9
|
+
<section class="mintfolio-protected" data-core-protected data-protected-article data-payload={JSON.stringify(body.payload)} data-post-id={body.postId} aria-label="受保护的文章">
|
|
10
|
+
<p data-protected-intro>这篇文章受保护。请输入密码以在当前标签页中阅读。</p>
|
|
11
|
+
<form data-protected-form>
|
|
12
|
+
<label>文章密码<input data-protected-password type="password" autocomplete="current-password" required /></label>
|
|
13
|
+
<button type="submit">解锁文章</button>
|
|
14
|
+
</form>
|
|
15
|
+
<p data-protected-error role="alert" hidden></p>
|
|
16
|
+
<div class="mintfolio-prose" data-protected-output hidden tabindex="-1"></div>
|
|
17
|
+
<button type="button" data-protected-relock hidden>重新锁定</button>
|
|
18
|
+
</section>
|
|
19
|
+
<style>
|
|
20
|
+
.mintfolio-protected { border: 1px solid #8888; padding: 1.25rem; }
|
|
21
|
+
.mintfolio-protected form, .mintfolio-protected label { display: flex; flex-wrap: wrap; align-items: center; gap: .7rem; }
|
|
22
|
+
.mintfolio-protected :is(input,button) { font: inherit; max-width: 100%; padding: .4rem .65rem; }
|
|
23
|
+
.mintfolio-protected [hidden] { display: none; }
|
|
24
|
+
.mintfolio-protected [role="alert"] { color: #a60000; }
|
|
25
|
+
.mintfolio-prose { overflow-wrap: anywhere; }
|
|
26
|
+
.mintfolio-prose :global(img) { max-width: 100%; height: auto; }
|
|
27
|
+
.mintfolio-prose :global(pre) { overflow: auto; }
|
|
28
|
+
</style>
|
|
29
|
+
<script>
|
|
30
|
+
import { createProtectedArticleController, onPage } from '@mintfolio/core/client';
|
|
31
|
+
onPage('[data-core-protected]', (root, scope) => {
|
|
32
|
+
const form = root.querySelector<HTMLFormElement>('[data-protected-form]');
|
|
33
|
+
const input = root.querySelector<HTMLInputElement>('[data-protected-password]');
|
|
34
|
+
const output = root.querySelector<HTMLElement>('[data-protected-output]');
|
|
35
|
+
const status = root.querySelector<HTMLElement>('[data-protected-error]');
|
|
36
|
+
const button = form?.querySelector<HTMLButtonElement>('button');
|
|
37
|
+
const relock = root.querySelector<HTMLButtonElement>('[data-protected-relock]');
|
|
38
|
+
if (!form || !input || !output || !status || !button || !relock) return;
|
|
39
|
+
const controller = createProtectedArticleController({
|
|
40
|
+
payload: JSON.parse(root.dataset.payload ?? 'null'), postId: root.dataset.postId ?? '', signal: scope.signal,
|
|
41
|
+
onUnlock: ({ fragment }) => { output.replaceChildren(fragment); input.value = ''; },
|
|
42
|
+
onClear: () => { output.replaceChildren(); input.value = ''; status.textContent = ''; status.hidden = true; },
|
|
43
|
+
onState: (state) => {
|
|
44
|
+
input.disabled = button.disabled = state === 'unlocking';
|
|
45
|
+
button.textContent = state === 'unlocking' ? '正在解锁…' : '解锁文章';
|
|
46
|
+
form.hidden = state === 'unlocked';
|
|
47
|
+
output.hidden = relock.hidden = state !== 'unlocked';
|
|
48
|
+
if (state === 'unlocked') output.focus({ preventScroll: true });
|
|
49
|
+
form.setAttribute('aria-busy', String(state === 'unlocking'));
|
|
50
|
+
},
|
|
51
|
+
onError: () => { status.textContent = '无法解锁文章,请检查密码后重试。'; status.hidden = false; },
|
|
52
|
+
});
|
|
53
|
+
form.addEventListener('submit', (event) => { event.preventDefault(); void controller.unlock(input.value); }, { signal: scope.signal });
|
|
54
|
+
relock.addEventListener('click', () => { controller.lock(); input.focus(); }, { signal: scope.signal });
|
|
55
|
+
});
|
|
56
|
+
</script>
|
package/src/content.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Loader } from 'astro/loaders';
|
|
2
|
+
import type { z } from 'astro/zod';
|
|
3
|
+
/** The validated frontmatter shape; raw records remain accessible only to the host. */
|
|
4
|
+
export interface BlogFrontmatter {
|
|
5
|
+
title: string;
|
|
6
|
+
pubDate: Date;
|
|
7
|
+
description: string;
|
|
8
|
+
category?: string;
|
|
9
|
+
tags?: string[];
|
|
10
|
+
cover?: string;
|
|
11
|
+
draft: boolean;
|
|
12
|
+
password?: string;
|
|
13
|
+
}
|
|
14
|
+
/** Markdown location is relative to the consuming site's project root. */
|
|
15
|
+
export function createBlogCollection(options?: { base?: string }): { loader: Loader; schema: z.ZodType<BlogFrontmatter> };
|
package/src/content.mjs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { defineCollection } from 'astro:content';
|
|
2
|
+
import { z } from 'astro/zod';
|
|
3
|
+
import { glob } from 'astro/loaders';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Blog schema and Markdown loader owned by Core. Call from src/content.config.ts.
|
|
7
|
+
* @param {{base?:string}} [options] Markdown directory relative to the site root.
|
|
8
|
+
*/
|
|
9
|
+
export function createBlogCollection({ base = './content/blog' } = {}) {
|
|
10
|
+
return defineCollection({
|
|
11
|
+
loader: glob({ pattern: '**/*.md', base }),
|
|
12
|
+
schema: z.object({
|
|
13
|
+
title: z.string(), pubDate: z.coerce.date(), description: z.string(),
|
|
14
|
+
category: z.string().optional(), tags: z.array(z.string()).optional(),
|
|
15
|
+
cover: z.string().optional(), draft: z.boolean().default(false),
|
|
16
|
+
password: z.string().refine((value) => value.trim().length > 0, '文章密码不能为空').optional(),
|
|
17
|
+
}),
|
|
18
|
+
});
|
|
19
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { PostQuery, PostSummary, ThemeContext, ThemeDefinition } from '@mintfolio/theme-api';
|
|
2
|
+
import { createSearchEntry, filterPosts } from '@mintfolio/theme-api/search';
|
|
3
|
+
import { getPublishedPosts, toPostSummary, collectTaxonomy, collectArchives } from '../server/posts';
|
|
4
|
+
import { getPublicSite } from '../server/site';
|
|
5
|
+
import { urls } from '../server/routing';
|
|
6
|
+
|
|
7
|
+
/** Only validated manifest data and recursively validated settings cross this boundary. */
|
|
8
|
+
export interface ActiveTheme {
|
|
9
|
+
definition: ThemeDefinition;
|
|
10
|
+
settings: Record<string, unknown>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Reject invalid paging values before slicing, including NaN and fractions. */
|
|
14
|
+
function pageNumber(value: number | undefined, field: string, fallback: number): number {
|
|
15
|
+
if (value === undefined) return fallback;
|
|
16
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new Error(`content.posts(): ${field} must be a non-negative safe integer`);
|
|
17
|
+
return value;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Construct one immutable-by-convention render context. A snapshot belongs to
|
|
22
|
+
* this render, so dev content edits cannot be hidden by a process-wide cache.
|
|
23
|
+
* Neither functions nor internal collection entries are serialized to browsers.
|
|
24
|
+
*/
|
|
25
|
+
export async function createThemeContext(active: ActiveTheme): Promise<ThemeContext> {
|
|
26
|
+
const posts = (await getPublishedPosts()).map(toPostSummary);
|
|
27
|
+
if (posts.some((post) => post.protected) && !active.definition.capabilities.encryptedPosts) {
|
|
28
|
+
throw new Error(`[theme:capability] ${active.definition.manifest.id} does not support encryptedPosts, but the site has protected articles`);
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
site: getPublicSite(),
|
|
32
|
+
navigation: [
|
|
33
|
+
{ id: 'home', label: '首页', url: urls.home() },
|
|
34
|
+
{ id: 'archive', label: '全部文章', url: urls.archive() },
|
|
35
|
+
{ id: 'about', label: '关于我', url: urls.page('about') },
|
|
36
|
+
],
|
|
37
|
+
settings: active.settings,
|
|
38
|
+
content: {
|
|
39
|
+
posts: async (query: PostQuery = {}): Promise<{ items: PostSummary[]; total: number }> => {
|
|
40
|
+
const matches = filterPosts(posts, query);
|
|
41
|
+
const offset = pageNumber(query.offset, 'offset', 0);
|
|
42
|
+
const limit = pageNumber(query.limit, 'limit', matches.length);
|
|
43
|
+
return { items: matches.slice(offset, offset + limit), total: matches.length };
|
|
44
|
+
},
|
|
45
|
+
post: async (id: string): Promise<PostSummary | null> => posts.find((post) => post.id === id) ?? null,
|
|
46
|
+
},
|
|
47
|
+
taxonomy: {
|
|
48
|
+
tags: async () => collectTaxonomy(posts, 'tags'),
|
|
49
|
+
categories: async () => collectTaxonomy(posts, 'categories'),
|
|
50
|
+
archives: async () => collectArchives(posts),
|
|
51
|
+
},
|
|
52
|
+
urls,
|
|
53
|
+
search: { index: async () => posts.map(createSearchEntry) },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import { readFile, realpath } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
5
|
+
import { init, parse } from 'es-module-lexer';
|
|
6
|
+
import { resolve as resolveImport } from 'import-meta-resolve';
|
|
7
|
+
|
|
8
|
+
const PRIVATE_SPECIFIERS = new Set(['astro:content', 'astro/loaders', 'node:fs', 'node:fs/promises', 'fs', 'fs/promises']);
|
|
9
|
+
const PRIVATE_DIRECTORIES = ['src/core', 'src/theme', 'src/lib', 'src/utils', 'src/scripts', 'src/pages', 'content', 'src/content'];
|
|
10
|
+
const PRIVATE_CONFIGS = ['site.config.ts', 'theme.config.mjs', 'astro.config.mjs', 'src/content.config.ts'];
|
|
11
|
+
const CORE_ROOT = fileURLToPath(new URL('../../', import.meta.url));
|
|
12
|
+
|
|
13
|
+
/** @param {string} filename @param {string} directory @returns {boolean} */
|
|
14
|
+
function isWithin(filename, directory) {
|
|
15
|
+
const relative = path.relative(directory, filename);
|
|
16
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Read static imports, re-exports, and literal dynamic imports without executing
|
|
21
|
+
* the module. Vite callers may ignore computed imports that Vite expands later;
|
|
22
|
+
* native manifest loading rejects them because it has no such expansion step.
|
|
23
|
+
* This inspects module dependencies, not arbitrary JavaScript execution.
|
|
24
|
+
* @param {string} source JavaScript module source (Astro callers extract scripts first).
|
|
25
|
+
* @param {string} importer Filename used in actionable diagnostics.
|
|
26
|
+
* @param {{rejectComputedImports?:boolean}} options
|
|
27
|
+
* @returns {Promise<string[]>} Decoded import specifiers, excluding import.meta.
|
|
28
|
+
*/
|
|
29
|
+
export async function readModuleImports(source, importer, { rejectComputedImports = false } = {}) {
|
|
30
|
+
await init;
|
|
31
|
+
let imports;
|
|
32
|
+
try {
|
|
33
|
+
[imports] = parse(source, importer);
|
|
34
|
+
} catch {
|
|
35
|
+
throw new Error(`[theme:boundary] Cannot inspect module imports in ${importer}; use valid ES module syntax`);
|
|
36
|
+
}
|
|
37
|
+
const specifiers = [];
|
|
38
|
+
for (const entry of imports) {
|
|
39
|
+
if (entry.d === -2) continue; // import.meta is metadata, not a dependency.
|
|
40
|
+
if (entry.n !== undefined) specifiers.push(entry.n);
|
|
41
|
+
else if (rejectComputedImports) {
|
|
42
|
+
throw new Error(`[theme:boundary] ${importer} uses a computed import; manifest helpers must use literal module specifiers`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return specifiers;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Reject private entry points before resolver plugins consume them.
|
|
50
|
+
* @param {string} specifier Module identifier from the theme source.
|
|
51
|
+
* @param {string} importer Filename used in diagnostics.
|
|
52
|
+
*/
|
|
53
|
+
export function assertPublicThemeSpecifier(specifier, importer) {
|
|
54
|
+
if ((specifier === '@mintfolio/core' || specifier.startsWith('@mintfolio/core/')) &&
|
|
55
|
+
!['@mintfolio/core/theme', '@mintfolio/core/astro', '@mintfolio/core/client', '@mintfolio/core/search'].includes(specifier) &&
|
|
56
|
+
!specifier.startsWith('@mintfolio/core/components/')) {
|
|
57
|
+
throw new Error(`[theme:boundary] ${importer} cannot import host-only Core entry ${specifier}; use its public theme API`);
|
|
58
|
+
}
|
|
59
|
+
if (PRIVATE_SPECIFIERS.has(specifier) || specifier.startsWith('virtual:mintfolio/')) {
|
|
60
|
+
throw new Error(`[theme:boundary] ${importer} cannot import ${specifier}; consume the public SDK and page props`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Apply the same host-private path policy to native manifests and Vite modules.
|
|
66
|
+
* Resolvers must supply a real absolute path, with Vite queries already removed.
|
|
67
|
+
* path.relative also treats equivalent casing consistently on Windows.
|
|
68
|
+
* @param {string} filename Resolved dependency filename.
|
|
69
|
+
* @param {string} root Host project root.
|
|
70
|
+
* @param {string} importer Theme module that requested the dependency.
|
|
71
|
+
* @param {string} specifier Original import text for diagnostics.
|
|
72
|
+
*/
|
|
73
|
+
export function assertPublicThemeFile(filename, root, importer, specifier = filename) {
|
|
74
|
+
const privateDirectory = PRIVATE_DIRECTORIES.some((directory) => isWithin(filename, path.join(root, directory)));
|
|
75
|
+
const privateConfig = PRIVATE_CONFIGS.some((config) => path.relative(path.join(root, config), filename) === '');
|
|
76
|
+
const corePrivate = ['src/server', 'src/engine', 'src/routes'].some((directory) => isWithin(filename, path.join(CORE_ROOT, directory)));
|
|
77
|
+
if (privateDirectory || privateConfig || corePrivate) {
|
|
78
|
+
throw new Error(`[theme:boundary] ${importer} imports private host module ${specifier}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* .js/.ts helpers need an explicit ESM package scope; CommonJS require() is not
|
|
84
|
+
* part of this static import contract. Explicit .mjs/.mts helpers need no scope.
|
|
85
|
+
* @param {string} filename Real helper filename.
|
|
86
|
+
* @returns {Promise<boolean>}
|
|
87
|
+
*/
|
|
88
|
+
async function isEsmHelper(filename) {
|
|
89
|
+
const extension = path.extname(filename).toLowerCase();
|
|
90
|
+
if (extension === '.mjs' || extension === '.mts') return true;
|
|
91
|
+
if (extension !== '.js' && extension !== '.ts') return false;
|
|
92
|
+
let directory = path.dirname(filename);
|
|
93
|
+
while (true) {
|
|
94
|
+
const packageSource = await readFile(path.join(directory, 'package.json'), 'utf8').catch(() => null);
|
|
95
|
+
if (packageSource !== null) {
|
|
96
|
+
try { return JSON.parse(packageSource).type === 'module'; }
|
|
97
|
+
catch { return false; }
|
|
98
|
+
}
|
|
99
|
+
const parent = path.dirname(directory);
|
|
100
|
+
if (parent === directory) return false;
|
|
101
|
+
directory = parent;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Check the manifest's local ESM graph before native import evaluates any of it.
|
|
107
|
+
* Literal package imports use their public exports; local helpers, including
|
|
108
|
+
* helpers outside the theme directory, are followed after realpath resolution.
|
|
109
|
+
* Package implementations remain dependencies rather than host/theme internals.
|
|
110
|
+
* This is a development contract check, not a sandbox for untrusted JavaScript.
|
|
111
|
+
* @param {string} manifestPath Real filename of the selected theme.mjs.
|
|
112
|
+
* @param {string} root Host project root used for private-path checks.
|
|
113
|
+
* @returns {Promise<string[]>} Inspected source files, for diagnostics/dev tooling.
|
|
114
|
+
*/
|
|
115
|
+
export async function checkManifestImports(manifestPath, root) {
|
|
116
|
+
/** @type {Set<string>} */
|
|
117
|
+
const visited = new Set();
|
|
118
|
+
|
|
119
|
+
/** @param {string} filename */
|
|
120
|
+
async function inspect(filename) {
|
|
121
|
+
if (visited.has(filename)) return;
|
|
122
|
+
visited.add(filename);
|
|
123
|
+
const source = await readFile(filename, 'utf8');
|
|
124
|
+
for (const specifier of await readModuleImports(source, filename, { rejectComputedImports: true })) {
|
|
125
|
+
assertPublicThemeSpecifier(specifier, filename);
|
|
126
|
+
let resolved;
|
|
127
|
+
try {
|
|
128
|
+
resolved = resolveImport(specifier, pathToFileURL(filename).href);
|
|
129
|
+
} catch {
|
|
130
|
+
throw new Error(`[theme:boundary] Cannot resolve ${specifier} imported by ${filename}`);
|
|
131
|
+
}
|
|
132
|
+
if (resolved.startsWith('node:')) continue;
|
|
133
|
+
if (!resolved.startsWith('file:')) {
|
|
134
|
+
throw new Error(`[theme:boundary] ${filename} must import local modules or installed packages, received ${specifier}`);
|
|
135
|
+
}
|
|
136
|
+
const dependency = await realpath(fileURLToPath(resolved));
|
|
137
|
+
assertPublicThemeFile(dependency, root, filename, specifier);
|
|
138
|
+
|
|
139
|
+
const packageImport = !specifier.startsWith('.') && !specifier.startsWith('/') && !specifier.startsWith('file:') && !specifier.startsWith('#') && !path.isAbsolute(specifier);
|
|
140
|
+
// Resolving a package's public export is sufficient here. Following the
|
|
141
|
+
// SDK's or a framework's internals would incorrectly classify them as themes.
|
|
142
|
+
if (packageImport) continue;
|
|
143
|
+
const extension = path.extname(dependency).toLowerCase();
|
|
144
|
+
if (extension === '.json') continue; // Data cannot import another module.
|
|
145
|
+
if (!await isEsmHelper(dependency)) {
|
|
146
|
+
throw new Error(`[theme:boundary] ${filename} imports ${specifier}; local manifest helpers must use .mjs/.mts or a package with type: "module"`);
|
|
147
|
+
}
|
|
148
|
+
await inspect(dependency);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
await inspect(manifestPath);
|
|
153
|
+
return [...visited];
|
|
154
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { resolve as resolveImport } from 'import-meta-resolve';
|
|
6
|
+
import { parse as parseAstro } from '@astrojs/compiler';
|
|
7
|
+
import { loadTheme, isWithin } from './loader.mjs';
|
|
8
|
+
import { readModuleImports, assertPublicThemeSpecifier, assertPublicThemeFile } from './import-boundary.mjs';
|
|
9
|
+
|
|
10
|
+
const VIRTUAL_ID = 'virtual:mintfolio/theme';
|
|
11
|
+
const RESOLVED_ID = `\0${VIRTUAL_ID}`;
|
|
12
|
+
const SITE_ID = 'virtual:mintfolio/site-config';
|
|
13
|
+
const RESOLVED_SITE_ID = `\0${SITE_ID}`;
|
|
14
|
+
|
|
15
|
+
/** @param {string} id Vite module id, possibly including an Astro script query. */
|
|
16
|
+
function filenameFromId(id) {
|
|
17
|
+
const filename = id.split('?')[0].replace(/^\/@fs\//, '');
|
|
18
|
+
return filename.startsWith('file:') ? fileURLToPath(filename) : path.normalize(filename);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Inspect authored frontmatter/scripts before Astro rewrites special imports.
|
|
23
|
+
* Using the official parser avoids treating examples in HTML or JSON as code.
|
|
24
|
+
* @param {string} filename Absolute path to an authored Astro component.
|
|
25
|
+
* @returns {Promise<string[]>} Executable module fragments only.
|
|
26
|
+
*/
|
|
27
|
+
async function astroModuleSources(filename) {
|
|
28
|
+
const { ast } = await parseAstro(await readFile(filename, 'utf8'));
|
|
29
|
+
/** @type {string[]} */
|
|
30
|
+
const modules = [];
|
|
31
|
+
/** @param {import('@astrojs/compiler/types').RootNode['children'][number]} node */
|
|
32
|
+
const visit = (node) => {
|
|
33
|
+
if (node.type === 'frontmatter') modules.push(node.value);
|
|
34
|
+
if (node.type === 'element' && node.name === 'script') {
|
|
35
|
+
const type = node.attributes.find((attribute) => attribute.name === 'type')?.value;
|
|
36
|
+
if (!type || ['module', 'text/javascript', 'application/javascript'].includes(type)) {
|
|
37
|
+
modules.push(node.children.filter((child) => child.type === 'text').map((child) => child.value).join('\n'));
|
|
38
|
+
}
|
|
39
|
+
} else if ('children' in node) node.children.forEach(visit);
|
|
40
|
+
};
|
|
41
|
+
ast.children.forEach(visit);
|
|
42
|
+
return modules;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Only Core installs this integration. Themes declare page paths; they receive
|
|
47
|
+
* neither Astro integration hooks nor route injection callbacks.
|
|
48
|
+
* @param {import('@mintfolio/theme-api').ThemeConfiguration} selection
|
|
49
|
+
* @param {{routes?:boolean,siteConfig?:string}} [engine] Only the public Core entry enables owned routes.
|
|
50
|
+
* @returns {import('astro').AstroIntegration}
|
|
51
|
+
*/
|
|
52
|
+
export default function themeRuntime(selection = {}, engine = {}) {
|
|
53
|
+
return {
|
|
54
|
+
name: 'mintfolio:theme-runtime',
|
|
55
|
+
hooks: {
|
|
56
|
+
'astro:config:setup': async ({ config, updateConfig, addWatchFile, injectRoute, command, logger }) => {
|
|
57
|
+
const root = fileURLToPath(config.root);
|
|
58
|
+
const themeName = process.env.MINTFOLIO_THEME || selection.theme;
|
|
59
|
+
const changedSelection = Boolean(process.env.MINTFOLIO_THEME) && themeName !== selection.theme;
|
|
60
|
+
const active = await loadTheme({ root, theme: themeName, settings: changedSelection ? {} : selection.settings, overrides: changedSelection ? {} : selection.overrides, fresh: command === 'dev' });
|
|
61
|
+
const minimal = active.definition.manifest.id === 'minimal' ? active : await loadTheme({ root, theme: 'minimal', readUserConfig: false });
|
|
62
|
+
const siteConfig = path.resolve(root, engine.siteConfig ?? './site.config.ts');
|
|
63
|
+
if (engine.routes) {
|
|
64
|
+
addWatchFile(siteConfig);
|
|
65
|
+
for (const [pattern, source] of [
|
|
66
|
+
['/', 'index.astro'], ['/about', 'about.astro'], ['/blog', 'blog/index.astro'],
|
|
67
|
+
['/blog/[...slug]', 'blog/[...slug].astro'], ['/404', '404.astro'],
|
|
68
|
+
['/rss.xml', 'rss.xml.ts'], ['/sitemap.xml', 'sitemap.xml.ts'],
|
|
69
|
+
]) injectRoute({ pattern, entrypoint: new URL(`../routes/${source}`, import.meta.url) });
|
|
70
|
+
}
|
|
71
|
+
// Themes declare supported toolchains, not arbitrary engine hooks. The
|
|
72
|
+
// dependencies are resolved from the selected theme, so Core stays lean.
|
|
73
|
+
if (active.definition.build?.react) {
|
|
74
|
+
const react = (await import(resolveImport('@astrojs/react', pathToFileURL(active.manifestPath).href))).default;
|
|
75
|
+
updateConfig({ integrations: [react()] });
|
|
76
|
+
}
|
|
77
|
+
if (active.definition.build?.tailwind) {
|
|
78
|
+
const tailwind = (await import(resolveImport('@tailwindcss/vite', pathToFileURL(active.manifestPath).href))).default;
|
|
79
|
+
updateConfig({ vite: { plugins: [tailwind()] } });
|
|
80
|
+
}
|
|
81
|
+
// Classify explicit override modules, never their entire parent directory:
|
|
82
|
+
// a valid ./custom-post.astro must not classify the host itself as a theme.
|
|
83
|
+
const themeModules = new Set(active.overrideEntries);
|
|
84
|
+
const manifestPath = path.join(root, 'theme.config.mjs');
|
|
85
|
+
addWatchFile(manifestPath);
|
|
86
|
+
addWatchFile(active.themeConfigFile);
|
|
87
|
+
for (const filename of active.manifestDependencies) addWatchFile(filename);
|
|
88
|
+
logger.info(`Theme: ${active.definition.manifest.name} ${active.definition.manifest.version}`);
|
|
89
|
+
if (themeName === 'happyhues' || themeName === 'default') logger.warn('Use the installed package name "@mintfolio/theme-default". Only Minimal is bundled with Core.');
|
|
90
|
+
const optionalMissing = ['page', 'archive', 'notFound'].filter((kind) => !active.pages[kind]);
|
|
91
|
+
if (optionalMissing.length) logger.info(`Using lightweight Minimal renderers for: ${optionalMissing.join(', ')}`);
|
|
92
|
+
/** @type {Array<keyof import('@mintfolio/theme-api').ThemeCapabilities>} */
|
|
93
|
+
const features = ['search', 'tags', 'categories'];
|
|
94
|
+
for (const capability of features) {
|
|
95
|
+
if (!active.definition.capabilities[capability]) logger.warn(`${active.definition.manifest.id} does not declare ${capability}; its UI may omit that feature.`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** @type {import('vite').Plugin} */
|
|
99
|
+
const plugin = {
|
|
100
|
+
name: 'mintfolio:theme-module',
|
|
101
|
+
enforce: 'pre',
|
|
102
|
+
async transform(code, id) {
|
|
103
|
+
const filename = filenameFromId(id);
|
|
104
|
+
if (!(isWithin(filename, active.themeRoot) || themeModules.has(filename))) return null;
|
|
105
|
+
if (!/\.(?:astro|[cm]?[jt]sx?)$/.test(filename)) return null;
|
|
106
|
+
// A resolveId hook alone is insufficient: Astro can resolve its own
|
|
107
|
+
// virtual collection module before a user plugin sees the specifier.
|
|
108
|
+
const modules = filename.endsWith('.astro') ? await astroModuleSources(filename) : [code];
|
|
109
|
+
for (const source of modules) {
|
|
110
|
+
for (const specifier of await readModuleImports(source, filename)) assertPublicThemeSpecifier(specifier, filename);
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
},
|
|
114
|
+
async resolveId(source, importer, options) {
|
|
115
|
+
const importerFile = importer ? filenameFromId(importer) : '';
|
|
116
|
+
const isTheme = Boolean(importer) && (isWithin(importerFile, active.themeRoot) || themeModules.has(importerFile));
|
|
117
|
+
if (isTheme) assertPublicThemeSpecifier(source, importerFile);
|
|
118
|
+
if (source === VIRTUAL_ID) return RESOLVED_ID;
|
|
119
|
+
if (source === SITE_ID) return RESOLVED_SITE_ID;
|
|
120
|
+
if (!isTheme) return null;
|
|
121
|
+
const resolved = await this.resolve(source, importer, { ...options, skipSelf: true });
|
|
122
|
+
if (!resolved) return null;
|
|
123
|
+
const filename = filenameFromId(resolved.id);
|
|
124
|
+
assertPublicThemeFile(filename, root, importerFile, source);
|
|
125
|
+
// Continue the boundary through user helpers. SDK/framework packages
|
|
126
|
+
// own their implementation; theme-local helpers cannot launder a Core import.
|
|
127
|
+
if (!resolved.external && path.isAbsolute(filename) && !isWithin(filename, path.join(root, 'node_modules')) && !isWithin(filename, path.join(root, 'packages/theme-api'))) {
|
|
128
|
+
themeModules.add(filename);
|
|
129
|
+
}
|
|
130
|
+
return resolved;
|
|
131
|
+
},
|
|
132
|
+
load(id) {
|
|
133
|
+
if (id === RESOLVED_SITE_ID) return `export { default } from ${JSON.stringify(siteConfig.replaceAll('\\', '/'))};`;
|
|
134
|
+
if (id !== RESOLVED_ID) return null;
|
|
135
|
+
// Explicit imports preserve Astro compilation and include only the
|
|
136
|
+
// chosen renderer's dependency graph, including its own CSS/assets.
|
|
137
|
+
const imports = [];
|
|
138
|
+
const renderers = [];
|
|
139
|
+
const fallbacks = [];
|
|
140
|
+
for (const [kind, filename] of Object.entries(active.pages)) {
|
|
141
|
+
imports.push(`import Page_${kind} from ${JSON.stringify(filename.replaceAll('\\', '/'))};`);
|
|
142
|
+
renderers.push(`${JSON.stringify(kind)}: Page_${kind}`);
|
|
143
|
+
}
|
|
144
|
+
// Import only missing semantic pages. A full theme does not pay for
|
|
145
|
+
// Minimal's document, article components, or client-side behaviors.
|
|
146
|
+
for (const kind of ['page', 'archive', 'notFound']) {
|
|
147
|
+
if (active.pages[kind]) continue;
|
|
148
|
+
imports.push(`import Fallback_${kind} from ${JSON.stringify(minimal.pages[kind].replaceAll('\\', '/'))};`);
|
|
149
|
+
fallbacks.push(`${JSON.stringify(kind)}: Fallback_${kind}`);
|
|
150
|
+
}
|
|
151
|
+
return `${imports.join('\n')}
|
|
152
|
+
export const activeTheme = ${JSON.stringify({ definition: active.definition, settings: active.settings })};
|
|
153
|
+
const pages = {${renderers.join(',')}};
|
|
154
|
+
const fallbacks = {${fallbacks.join(',')}};
|
|
155
|
+
export function getRenderer(kind) {
|
|
156
|
+
if (pages[kind]) return pages[kind];
|
|
157
|
+
if (kind === 'home' || kind === 'post') throw new Error('Missing required theme renderer: ' + kind);
|
|
158
|
+
return fallbacks[kind] || fallbacks.archive;
|
|
159
|
+
}`;
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
updateConfig({ vite: { plugins: [plugin] } });
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|