@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,111 @@
|
|
|
1
|
+
import { readFile, writeFile, realpath, stat } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import { loadTheme, isWithin } from '../src/engine/loader.mjs';
|
|
5
|
+
import { resolveSettings } from '../src/engine/schema.mjs';
|
|
6
|
+
import { themeConfigPath } from '../src/engine/theme-config.mjs';
|
|
7
|
+
|
|
8
|
+
const aliases = { verdant: '@mintfolio/theme-default', default: '@mintfolio/theme-default', happyhues: '@mintfolio/theme-default' };
|
|
9
|
+
|
|
10
|
+
/** @param {string} name Installed npm package or built-in theme selector. @returns {string} */
|
|
11
|
+
export function normalizeThemeName(name) { return Object.hasOwn(aliases, name) ? aliases[name] : name; }
|
|
12
|
+
|
|
13
|
+
/** Read package metadata without importing theme code or its configuration template. */
|
|
14
|
+
async function packageAt(directory) {
|
|
15
|
+
try { return JSON.parse(await readFile(path.join(directory, 'package.json'), 'utf8')); }
|
|
16
|
+
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Theme packages opt into automatic discovery with mintfolio.configTemplate.
|
|
21
|
+
* The path is relative to the owning package, checked after resolving symlinks.
|
|
22
|
+
* @param {string} manifestPath Resolved theme.mjs path.
|
|
23
|
+
* @returns {Promise<string|null>} Safe template filename, or generic schema generation.
|
|
24
|
+
*/
|
|
25
|
+
async function templateFor(manifestPath) {
|
|
26
|
+
let directory = path.dirname(manifestPath);
|
|
27
|
+
while (directory !== path.dirname(directory)) {
|
|
28
|
+
const pkg = await packageAt(directory);
|
|
29
|
+
if (pkg) {
|
|
30
|
+
const relative = pkg.mintfolio?.configTemplate;
|
|
31
|
+
if (relative === undefined) return null;
|
|
32
|
+
if (typeof relative !== 'string' || !relative.startsWith('./') || !relative.endsWith('.mjs')) throw new Error('[theme:config] configTemplate must be a relative .mjs file');
|
|
33
|
+
const filename = await realpath(path.resolve(directory, relative));
|
|
34
|
+
if (!isWithin(filename, await realpath(directory)) || !(await stat(filename)).isFile()) throw new Error('[theme:config] Template escapes its package');
|
|
35
|
+
return filename;
|
|
36
|
+
}
|
|
37
|
+
directory = path.dirname(directory);
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Render all schema fields, including commented examples for initially empty arrays. */
|
|
43
|
+
function schemaSource(schema, values, depth=1) {
|
|
44
|
+
const indent = ' '.repeat(depth);
|
|
45
|
+
return Object.entries(schema).flatMap(([name, setting]) => {
|
|
46
|
+
const help = [setting.label, setting.description,
|
|
47
|
+
setting.type === 'select' ? `可选值:${setting.options.join(' / ')}` : null,
|
|
48
|
+
setting.type === 'number' ? `范围:${setting.min ?? '不限'} 至 ${setting.max ?? '不限'}` : null,
|
|
49
|
+
].filter(Boolean).join(';').replace(/[\r\n\u2028\u2029]+/g, ' ');
|
|
50
|
+
const value = Object.hasOwn(values, name) ? values[name] : setting.default;
|
|
51
|
+
const key = JSON.stringify(name);
|
|
52
|
+
if (setting.type === 'object') return [`${indent}// ${help}`, `${indent}${key}: {`, schemaSource(setting.properties, value, depth+1), `${indent}},`];
|
|
53
|
+
if (setting.type === 'array' && value.length === 0 && setting.items.type === 'object') {
|
|
54
|
+
const sample = [`${indent} {`,schemaSource(setting.items.properties,setting.items.default,depth+2),`${indent} },`].join('\n').split('\n').map((line)=>`${indent} // ${line.slice(indent.length+2)}`).join('\n');
|
|
55
|
+
return [`${indent}// ${help}`,`${indent}${key}: [`,sample,`${indent}],`];
|
|
56
|
+
}
|
|
57
|
+
return [`${indent}// ${help}`, `${indent}${key}: ${JSON.stringify(value)},`];
|
|
58
|
+
}).join('\n');
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Create one editable host config from a theme-owned template or its full schema.
|
|
63
|
+
* Existing files are never changed, including invalid or manually edited files.
|
|
64
|
+
* @param {string} root Host root with installed dependencies.
|
|
65
|
+
* @param {string} theme Installed package, local theme selector, or Minimal.
|
|
66
|
+
* @returns {Promise<{filename:string,created:boolean}>} The host file and whether it was created.
|
|
67
|
+
*/
|
|
68
|
+
export async function createThemeConfig(root, theme) {
|
|
69
|
+
const active = await loadTheme({root,theme:normalizeThemeName(theme),readUserConfig:false});
|
|
70
|
+
const filename = themeConfigPath(root, active.definition.manifest.id);
|
|
71
|
+
const template = await templateFor(active.manifestPath);
|
|
72
|
+
let source;
|
|
73
|
+
if (template) {
|
|
74
|
+
// Templates are plain ESM settings owned by the installed package. Validate
|
|
75
|
+
// their defaults with the same manifest that will validate host edits.
|
|
76
|
+
resolveSettings(active.definition, (await import(pathToFileURL(template).href)).default);
|
|
77
|
+
source = await readFile(template, 'utf8');
|
|
78
|
+
} else {
|
|
79
|
+
source = `/** ${active.definition.manifest.name.replace(/\*\//g,'')} 的设置。仅在选择此主题时生效;重复生成不会覆盖本文件。 */\nexport default {\n${schemaSource(active.definition.settings,active.settings)}\n};\n`;
|
|
80
|
+
}
|
|
81
|
+
try { await writeFile(filename, source, {flag:'wx'}); return {filename,created:true}; }
|
|
82
|
+
catch (error) { if (error.code === 'EEXIST') return {filename,created:false}; throw error; }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Find theme packages declared by this site, not arbitrary transitive packages.
|
|
87
|
+
* Used by init/dev/build and the explicit sync command, independently of npm
|
|
88
|
+
* dependency lifecycle permissions. It never changes the selected layout.
|
|
89
|
+
* @param {string} root Host root.
|
|
90
|
+
* @returns {Promise<Array<{filename:string,created:boolean}>>}
|
|
91
|
+
*/
|
|
92
|
+
export async function syncThemeConfigs(root) {
|
|
93
|
+
try { await stat(path.join(root,'site.config.ts')); }
|
|
94
|
+
catch (error) { if (error.code === 'ENOENT') return []; throw error; }
|
|
95
|
+
const pkg = await packageAt(root);
|
|
96
|
+
if (!pkg) return [];
|
|
97
|
+
const results = [await createThemeConfig(root,'minimal')];
|
|
98
|
+
const dependencies = {...pkg.dependencies,...pkg.devDependencies,...pkg.optionalDependencies};
|
|
99
|
+
for (const name of Object.keys(dependencies)) {
|
|
100
|
+
const directory = path.resolve(root,'node_modules',name);
|
|
101
|
+
if (!isWithin(directory,path.join(root,'node_modules'))) continue;
|
|
102
|
+
const installed = await packageAt(directory);
|
|
103
|
+
if (installed?.mintfolio?.configTemplate) results.push(await createThemeConfig(root,name));
|
|
104
|
+
}
|
|
105
|
+
return results;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** @param {Array<{filename:string,created:boolean}>} results Report only new files during automatic sync. */
|
|
109
|
+
export function reportThemeConfigs(results) {
|
|
110
|
+
for (const result of results) if (result.created) process.stdout.write(`Created ${path.basename(result.filename)}\n`);
|
|
111
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { PageScope } from './lifecycle.js';
|
|
2
|
+
/**
|
|
3
|
+
* Default semantic binding for Core's optional PostArchive component. Custom
|
|
4
|
+
* themes can use createPostListController directly and supply their own markup.
|
|
5
|
+
* The root owns all selectors; multiple unrelated forms never get global handlers.
|
|
6
|
+
*/
|
|
7
|
+
export declare function bindPostArchive(root: HTMLElement, scope: PageScope): void;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { createSearchEntry, readFiltersFromUrl, writeFiltersToUrl } from '@mintfolio/theme-api/search';
|
|
2
|
+
import { createPostListController } from './postList.js';
|
|
3
|
+
/**
|
|
4
|
+
* Default semantic binding for Core's optional PostArchive component. Custom
|
|
5
|
+
* themes can use createPostListController directly and supply their own markup.
|
|
6
|
+
* The root owns all selectors; multiple unrelated forms never get global handlers.
|
|
7
|
+
*/
|
|
8
|
+
export function bindPostArchive(root, scope) {
|
|
9
|
+
const form = root.querySelector('[data-filter-form]');
|
|
10
|
+
const query = form?.querySelector('[name="q"]');
|
|
11
|
+
const tag = form?.querySelector('[name="tag"]');
|
|
12
|
+
const category = form?.querySelector('[name="category"]');
|
|
13
|
+
const status = root.querySelector('[data-filter-status]');
|
|
14
|
+
const more = root.querySelector('[data-load-more]');
|
|
15
|
+
if (!form || !query || !tag || !category)
|
|
16
|
+
return;
|
|
17
|
+
const posts = JSON.parse(root.dataset.posts ?? '[]');
|
|
18
|
+
const controller = createPostListController({ items: posts, index: createSearchEntry, initialFilters: readFiltersFromUrl(location.href), ...(root.dataset.pageSize ? { pageSize: Number(root.dataset.pageSize) } : {}) });
|
|
19
|
+
scope.add(controller.dispose);
|
|
20
|
+
const rows = Array.from(root.querySelectorAll('[data-post-id]'));
|
|
21
|
+
scope.add(controller.subscribe((state) => {
|
|
22
|
+
// Preserve a URL filter even if no select option exists: a miss is zero matches.
|
|
23
|
+
for (const [select, value] of [[tag, state.filters.tag], [category, state.filters.category]]) {
|
|
24
|
+
if (value && !Array.from(select.options).some((option) => option.value === value))
|
|
25
|
+
select.add(new Option(value, value));
|
|
26
|
+
select.value = value;
|
|
27
|
+
}
|
|
28
|
+
if (document.activeElement !== query)
|
|
29
|
+
query.value = state.filters.q;
|
|
30
|
+
const ids = new Set(state.visible.map((post) => post.id));
|
|
31
|
+
rows.forEach((row) => { row.hidden = !ids.has(row.dataset.postId ?? ''); });
|
|
32
|
+
if (status)
|
|
33
|
+
status.textContent = `共 ${state.total} 篇文章`;
|
|
34
|
+
if (more)
|
|
35
|
+
more.hidden = !state.hasMore;
|
|
36
|
+
}));
|
|
37
|
+
const update = () => {
|
|
38
|
+
controller.setFilters({ q: query.value.trim(), tag: tag.value, category: category.value });
|
|
39
|
+
history.replaceState(history.state, '', writeFiltersToUrl(controller.value().filters, new URL(location.href)));
|
|
40
|
+
};
|
|
41
|
+
const eventOptions = { signal: scope.signal };
|
|
42
|
+
form.addEventListener('submit', (event) => { event.preventDefault(); update(); }, eventOptions);
|
|
43
|
+
form.addEventListener('input', update, eventOptions);
|
|
44
|
+
form.addEventListener('change', update, eventOptions);
|
|
45
|
+
more?.addEventListener('click', controller.loadMore, eventOptions);
|
|
46
|
+
window.addEventListener('popstate', () => controller.setFilters(readFiltersFromUrl(location.href)), eventOptions);
|
|
47
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare function formatLanguageLabel(language: string): string;
|
|
2
|
+
export declare function resolveCodeLanguage(pre: HTMLPreElement, code: HTMLElement): string;
|
|
3
|
+
export declare function copyText(text: string): Promise<boolean>;
|
|
4
|
+
export declare function extractCodeText(code: HTMLElement, pre: HTMLPreElement): string;
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/** Shared code-language labels, rendered-text extraction, and clipboard access. No UI is mounted on import. */
|
|
2
|
+
const languageLabels = {
|
|
3
|
+
bash: 'Bash',
|
|
4
|
+
c: 'C',
|
|
5
|
+
cpp: 'C++',
|
|
6
|
+
csharp: 'C#',
|
|
7
|
+
cs: 'C#',
|
|
8
|
+
css: 'CSS',
|
|
9
|
+
docker: 'Docker',
|
|
10
|
+
dockerfile: 'Dockerfile',
|
|
11
|
+
go: 'Go',
|
|
12
|
+
gql: 'GraphQL',
|
|
13
|
+
graphql: 'GraphQL',
|
|
14
|
+
html: 'HTML',
|
|
15
|
+
java: 'Java',
|
|
16
|
+
javascript: 'JavaScript',
|
|
17
|
+
js: 'JavaScript',
|
|
18
|
+
json: 'JSON',
|
|
19
|
+
jsx: 'JSX',
|
|
20
|
+
kotlin: 'Kotlin',
|
|
21
|
+
kt: 'Kotlin',
|
|
22
|
+
less: 'Less',
|
|
23
|
+
markdown: 'Markdown',
|
|
24
|
+
md: 'Markdown',
|
|
25
|
+
mdx: 'MDX',
|
|
26
|
+
php: 'PHP',
|
|
27
|
+
powershell: 'PowerShell',
|
|
28
|
+
ps1: 'PowerShell',
|
|
29
|
+
py: 'Python',
|
|
30
|
+
python: 'Python',
|
|
31
|
+
rb: 'Ruby',
|
|
32
|
+
ruby: 'Ruby',
|
|
33
|
+
rs: 'Rust',
|
|
34
|
+
rust: 'Rust',
|
|
35
|
+
sass: 'Sass',
|
|
36
|
+
scss: 'SCSS',
|
|
37
|
+
shell: 'Shell',
|
|
38
|
+
sh: 'Shell',
|
|
39
|
+
sql: 'SQL',
|
|
40
|
+
svelte: 'Svelte',
|
|
41
|
+
swift: 'Swift',
|
|
42
|
+
text: 'Text',
|
|
43
|
+
toml: 'TOML',
|
|
44
|
+
ts: 'TypeScript',
|
|
45
|
+
tsx: 'TSX',
|
|
46
|
+
typescript: 'TypeScript',
|
|
47
|
+
vue: 'Vue',
|
|
48
|
+
xml: 'XML',
|
|
49
|
+
yaml: 'YAML',
|
|
50
|
+
yml: 'YAML',
|
|
51
|
+
zsh: 'Zsh',
|
|
52
|
+
};
|
|
53
|
+
export function formatLanguageLabel(language) {
|
|
54
|
+
const normalized = (language ?? '').trim().toLowerCase();
|
|
55
|
+
if (!normalized) {
|
|
56
|
+
return 'Text';
|
|
57
|
+
}
|
|
58
|
+
if (languageLabels[normalized]) {
|
|
59
|
+
return languageLabels[normalized];
|
|
60
|
+
}
|
|
61
|
+
const words = normalized
|
|
62
|
+
.replace(/[._-]+/g, ' ')
|
|
63
|
+
.split(/\s+/)
|
|
64
|
+
.filter(Boolean);
|
|
65
|
+
if (words.length === 0) {
|
|
66
|
+
return 'Text';
|
|
67
|
+
}
|
|
68
|
+
return words
|
|
69
|
+
.map((word) => {
|
|
70
|
+
if (word.length <= 4) {
|
|
71
|
+
return word.toUpperCase();
|
|
72
|
+
}
|
|
73
|
+
return `${word.charAt(0).toUpperCase()}${word.slice(1)}`;
|
|
74
|
+
})
|
|
75
|
+
.join(' ');
|
|
76
|
+
}
|
|
77
|
+
export function resolveCodeLanguage(pre, code) {
|
|
78
|
+
const preLanguage = pre.getAttribute('data-language') ?? pre.dataset.language;
|
|
79
|
+
if (preLanguage) {
|
|
80
|
+
return formatLanguageLabel(preLanguage);
|
|
81
|
+
}
|
|
82
|
+
const className = Array.from(code.classList).find((value) => value.startsWith('language-'));
|
|
83
|
+
if (className) {
|
|
84
|
+
return formatLanguageLabel(className.replace('language-', ''));
|
|
85
|
+
}
|
|
86
|
+
return 'Text';
|
|
87
|
+
}
|
|
88
|
+
function fallbackCopyText(text) {
|
|
89
|
+
const textarea = document.createElement('textarea');
|
|
90
|
+
textarea.value = text;
|
|
91
|
+
textarea.setAttribute('readonly', 'true');
|
|
92
|
+
textarea.style.cssText = 'position:fixed;top:-9999px;left:-9999px';
|
|
93
|
+
document.body.appendChild(textarea);
|
|
94
|
+
textarea.select();
|
|
95
|
+
let copied = false;
|
|
96
|
+
try {
|
|
97
|
+
copied = document.execCommand('copy');
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
copied = false;
|
|
101
|
+
}
|
|
102
|
+
textarea.remove();
|
|
103
|
+
return copied;
|
|
104
|
+
}
|
|
105
|
+
export async function copyText(text) {
|
|
106
|
+
if (window.isSecureContext && navigator.clipboard?.writeText) {
|
|
107
|
+
try {
|
|
108
|
+
await navigator.clipboard.writeText(text);
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return fallbackCopyText(text);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return fallbackCopyText(text);
|
|
116
|
+
}
|
|
117
|
+
export function extractCodeText(code, pre) {
|
|
118
|
+
const renderedLines = Array.from(code.children).filter((child) => child.classList?.contains('line'));
|
|
119
|
+
if (renderedLines.length > 0) {
|
|
120
|
+
return renderedLines
|
|
121
|
+
.map((line) => line.textContent ?? '')
|
|
122
|
+
.join('\n')
|
|
123
|
+
.replace(/\n$/, '');
|
|
124
|
+
}
|
|
125
|
+
return (code.textContent ?? pre.textContent ?? '').replace(/\n$/, '');
|
|
126
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type Cleanup = () => void;
|
|
2
|
+
/** Resources owned by one mounted page or widget; disposal is idempotent. */
|
|
3
|
+
export interface PageScope {
|
|
4
|
+
readonly signal: AbortSignal;
|
|
5
|
+
add(cleanup: Cleanup): void;
|
|
6
|
+
timeout(callback: () => void, delay: number): number;
|
|
7
|
+
frame(callback: FrameRequestCallback): number;
|
|
8
|
+
dispose(): void;
|
|
9
|
+
}
|
|
10
|
+
/** Create a scope for listeners, timers, animation frames, and caller cleanups. */
|
|
11
|
+
export declare function createPageScope(): PageScope;
|
|
12
|
+
/**
|
|
13
|
+
* Mount behavior on ordinary documents, Astro swaps, and BFCache restores.
|
|
14
|
+
* @param target A theme selector or a function resolving its current root.
|
|
15
|
+
* @param setup Attach behavior to that root and register cleanup with its scope.
|
|
16
|
+
* @returns Stop listening and dispose the current mount.
|
|
17
|
+
*/
|
|
18
|
+
export declare function onPage(target: string | (() => HTMLElement | null), setup: (root: HTMLElement, scope: PageScope) => void): Cleanup;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** Create a scope for listeners, timers, animation frames, and caller cleanups. */
|
|
2
|
+
export function createPageScope() {
|
|
3
|
+
const controller = new AbortController();
|
|
4
|
+
const cleanups = [];
|
|
5
|
+
const timers = new Set();
|
|
6
|
+
const frames = new Set();
|
|
7
|
+
return {
|
|
8
|
+
signal: controller.signal,
|
|
9
|
+
add(cleanup) { if (controller.signal.aborted)
|
|
10
|
+
cleanup();
|
|
11
|
+
else
|
|
12
|
+
cleanups.push(cleanup); },
|
|
13
|
+
timeout(callback, delay) {
|
|
14
|
+
const id = window.setTimeout(() => { timers.delete(id); if (!controller.signal.aborted)
|
|
15
|
+
callback(); }, delay);
|
|
16
|
+
timers.add(id);
|
|
17
|
+
return id;
|
|
18
|
+
},
|
|
19
|
+
frame(callback) {
|
|
20
|
+
const id = window.requestAnimationFrame((time) => { frames.delete(id); if (!controller.signal.aborted)
|
|
21
|
+
callback(time); });
|
|
22
|
+
frames.add(id);
|
|
23
|
+
return id;
|
|
24
|
+
},
|
|
25
|
+
dispose() {
|
|
26
|
+
if (controller.signal.aborted)
|
|
27
|
+
return;
|
|
28
|
+
controller.abort();
|
|
29
|
+
timers.forEach((id) => window.clearTimeout(id));
|
|
30
|
+
frames.forEach((id) => window.cancelAnimationFrame(id));
|
|
31
|
+
// Run every cleanup even if one widget fails; protected content must still clear.
|
|
32
|
+
const errors = [];
|
|
33
|
+
for (const cleanup of cleanups.reverse()) {
|
|
34
|
+
try {
|
|
35
|
+
cleanup();
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
errors.push(error);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
cleanups.length = 0;
|
|
42
|
+
timers.clear();
|
|
43
|
+
frames.clear();
|
|
44
|
+
if (errors.length)
|
|
45
|
+
console.error('[mintfolio:lifecycle] Widget cleanup failed', errors);
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Mount behavior on ordinary documents, Astro swaps, and BFCache restores.
|
|
51
|
+
* @param target A theme selector or a function resolving its current root.
|
|
52
|
+
* @param setup Attach behavior to that root and register cleanup with its scope.
|
|
53
|
+
* @returns Stop listening and dispose the current mount.
|
|
54
|
+
*/
|
|
55
|
+
export function onPage(target, setup) {
|
|
56
|
+
const lifetime = new AbortController();
|
|
57
|
+
let root = null;
|
|
58
|
+
let scope = null;
|
|
59
|
+
const dispose = () => { scope?.dispose(); scope = null; root = null; };
|
|
60
|
+
const mount = () => {
|
|
61
|
+
const next = typeof target === 'string' ? document.querySelector(target) : target();
|
|
62
|
+
if (root === next)
|
|
63
|
+
return;
|
|
64
|
+
dispose();
|
|
65
|
+
if (!next)
|
|
66
|
+
return;
|
|
67
|
+
root = next;
|
|
68
|
+
scope = createPageScope();
|
|
69
|
+
setup(root, scope);
|
|
70
|
+
};
|
|
71
|
+
const options = { signal: lifetime.signal };
|
|
72
|
+
document.addEventListener('astro:before-swap', dispose, options);
|
|
73
|
+
document.addEventListener('astro:page-load', mount, options);
|
|
74
|
+
document.addEventListener('article:content-changed', mount, options);
|
|
75
|
+
window.addEventListener('pagehide', dispose, options);
|
|
76
|
+
window.addEventListener('pageshow', mount, options);
|
|
77
|
+
if (document.readyState === 'loading')
|
|
78
|
+
document.addEventListener('DOMContentLoaded', mount, { ...options, once: true });
|
|
79
|
+
else
|
|
80
|
+
mount();
|
|
81
|
+
return () => { lifetime.abort(); dispose(); };
|
|
82
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type Cleanup } from './lifecycle.js';
|
|
2
|
+
/** Theme-owned image-preview elements. Core supplies zoom, pan, keyboard and cleanup behavior. */
|
|
3
|
+
export interface LightboxOptions {
|
|
4
|
+
root: HTMLElement;
|
|
5
|
+
dialog: HTMLDialogElement;
|
|
6
|
+
image: HTMLImageElement;
|
|
7
|
+
viewport: HTMLElement;
|
|
8
|
+
zoomIn: HTMLButtonElement;
|
|
9
|
+
zoomOut: HTMLButtonElement;
|
|
10
|
+
zoomLevel: HTMLOutputElement;
|
|
11
|
+
reset: HTMLElement;
|
|
12
|
+
close?: HTMLElement | null;
|
|
13
|
+
minScale?: number;
|
|
14
|
+
maxScale?: number;
|
|
15
|
+
step?: number;
|
|
16
|
+
signal?: AbortSignal;
|
|
17
|
+
}
|
|
18
|
+
export interface LightboxController {
|
|
19
|
+
close(): void;
|
|
20
|
+
reset(): void;
|
|
21
|
+
dispose: Cleanup;
|
|
22
|
+
}
|
|
23
|
+
/** Mount one preview; linked article images keep their original navigation. */
|
|
24
|
+
export declare function createLightboxController(input: LightboxOptions): LightboxController;
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { createPageScope } from './lifecycle.js';
|
|
2
|
+
/** Mount one preview; linked article images keep their original navigation. */
|
|
3
|
+
export function createLightboxController(input) {
|
|
4
|
+
const { root, dialog, image, viewport, zoomIn, zoomOut, zoomLevel, reset } = input;
|
|
5
|
+
const scope = createPageScope();
|
|
6
|
+
const options = { signal: scope.signal };
|
|
7
|
+
const minScale = input.minScale ?? 0.5;
|
|
8
|
+
const maxScale = input.maxScale ?? 4;
|
|
9
|
+
const step = input.step ?? 0.25;
|
|
10
|
+
if (![minScale, maxScale, step].every((value) => Number.isFinite(value) && value > 0) || minScale > 1 || maxScale < 1) {
|
|
11
|
+
throw new Error('Lightbox scale bounds must include 1 and the step must be positive and finite');
|
|
12
|
+
}
|
|
13
|
+
let scale = 1;
|
|
14
|
+
let x = 0;
|
|
15
|
+
let y = 0;
|
|
16
|
+
let drag = null;
|
|
17
|
+
const render = () => {
|
|
18
|
+
const maxX = Math.max(0, (image.offsetWidth * scale - viewport.clientWidth) / 2);
|
|
19
|
+
const maxY = Math.max(0, (image.offsetHeight * scale - viewport.clientHeight) / 2);
|
|
20
|
+
x = Math.max(-maxX, Math.min(maxX, x));
|
|
21
|
+
y = Math.max(-maxY, Math.min(maxY, y));
|
|
22
|
+
image.style.transform = `translate(${x}px, ${y}px) scale(${scale})`;
|
|
23
|
+
image.style.cursor = maxX || maxY ? (drag ? 'grabbing' : 'grab') : 'default';
|
|
24
|
+
zoomLevel.value = `${Math.round(scale * 100)}%`;
|
|
25
|
+
zoomIn.disabled = scale >= maxScale;
|
|
26
|
+
zoomOut.disabled = scale <= minScale;
|
|
27
|
+
};
|
|
28
|
+
const stopDragging = () => {
|
|
29
|
+
const pointerId = drag?.id;
|
|
30
|
+
drag = null;
|
|
31
|
+
if (pointerId !== undefined && image.hasPointerCapture(pointerId))
|
|
32
|
+
image.releasePointerCapture(pointerId);
|
|
33
|
+
render();
|
|
34
|
+
};
|
|
35
|
+
const resetView = () => {
|
|
36
|
+
scale = 1;
|
|
37
|
+
x = 0;
|
|
38
|
+
y = 0;
|
|
39
|
+
stopDragging();
|
|
40
|
+
};
|
|
41
|
+
const setScale = (value) => {
|
|
42
|
+
const next = Math.max(minScale, Math.min(maxScale, value));
|
|
43
|
+
x *= next / scale;
|
|
44
|
+
y *= next / scale;
|
|
45
|
+
scale = next;
|
|
46
|
+
render();
|
|
47
|
+
};
|
|
48
|
+
zoomIn.addEventListener('click', () => setScale(scale + step), options);
|
|
49
|
+
zoomOut.addEventListener('click', () => setScale(scale - step), options);
|
|
50
|
+
reset.addEventListener('click', resetView, options);
|
|
51
|
+
image.addEventListener('load', render, options);
|
|
52
|
+
window.addEventListener('resize', () => { if (dialog.open)
|
|
53
|
+
render(); }, options);
|
|
54
|
+
viewport.addEventListener('wheel', (event) => {
|
|
55
|
+
event.preventDefault();
|
|
56
|
+
if (event.deltaY)
|
|
57
|
+
setScale(scale - Math.sign(event.deltaY) * step);
|
|
58
|
+
}, { ...options, passive: false });
|
|
59
|
+
dialog.addEventListener('keydown', (event) => {
|
|
60
|
+
if (event.ctrlKey || event.metaKey || event.altKey)
|
|
61
|
+
return;
|
|
62
|
+
if (['+', '=', '-', '0'].includes(event.key)) {
|
|
63
|
+
event.preventDefault();
|
|
64
|
+
if (event.key === '0')
|
|
65
|
+
resetView();
|
|
66
|
+
else
|
|
67
|
+
setScale(scale + (event.key === '-' ? -step : step));
|
|
68
|
+
}
|
|
69
|
+
}, options);
|
|
70
|
+
image.addEventListener('pointerdown', (event) => {
|
|
71
|
+
if (!event.isPrimary || event.button !== 0 || scale <= 1)
|
|
72
|
+
return;
|
|
73
|
+
event.preventDefault();
|
|
74
|
+
drag = { id: event.pointerId, clientX: event.clientX, clientY: event.clientY, x, y };
|
|
75
|
+
image.setPointerCapture(event.pointerId);
|
|
76
|
+
render();
|
|
77
|
+
}, options);
|
|
78
|
+
image.addEventListener('pointermove', (event) => {
|
|
79
|
+
if (!drag || event.pointerId !== drag.id)
|
|
80
|
+
return;
|
|
81
|
+
x = drag.x + event.clientX - drag.clientX;
|
|
82
|
+
y = drag.y + event.clientY - drag.clientY;
|
|
83
|
+
render();
|
|
84
|
+
}, options);
|
|
85
|
+
image.addEventListener('pointerup', stopDragging, options);
|
|
86
|
+
image.addEventListener('pointercancel', stopDragging, options);
|
|
87
|
+
image.addEventListener('lostpointercapture', stopDragging, options);
|
|
88
|
+
for (const source of root.querySelectorAll('img')) {
|
|
89
|
+
// Linked images retain their author's navigation behavior.
|
|
90
|
+
if (source.closest('a'))
|
|
91
|
+
continue;
|
|
92
|
+
const attributes = ['tabindex', 'role', 'aria-haspopup', 'aria-label'].map((name) => [name, source.getAttribute(name)]);
|
|
93
|
+
const cursor = source.style.cursor;
|
|
94
|
+
scope.add(() => {
|
|
95
|
+
for (const [name, value] of attributes) {
|
|
96
|
+
if (value === null)
|
|
97
|
+
source.removeAttribute(name);
|
|
98
|
+
else
|
|
99
|
+
source.setAttribute(name, value);
|
|
100
|
+
}
|
|
101
|
+
source.style.cursor = cursor;
|
|
102
|
+
});
|
|
103
|
+
source.tabIndex = 0;
|
|
104
|
+
source.setAttribute('role', 'button');
|
|
105
|
+
source.setAttribute('aria-haspopup', 'dialog');
|
|
106
|
+
source.setAttribute('aria-label', `放大图片:${source.alt || '文章插图'}`);
|
|
107
|
+
source.style.cursor = 'zoom-in';
|
|
108
|
+
const open = () => {
|
|
109
|
+
image.src = source.currentSrc || source.src;
|
|
110
|
+
image.alt = source.alt;
|
|
111
|
+
dialog.showModal();
|
|
112
|
+
resetView();
|
|
113
|
+
};
|
|
114
|
+
source.addEventListener('click', open, options);
|
|
115
|
+
source.addEventListener('keydown', (event) => {
|
|
116
|
+
if (['Enter', ' '].includes(event.key)) {
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
open();
|
|
119
|
+
}
|
|
120
|
+
}, options);
|
|
121
|
+
}
|
|
122
|
+
input.close?.addEventListener('click', () => dialog.close(), options);
|
|
123
|
+
dialog.addEventListener('click', (event) => {
|
|
124
|
+
if (event.target === dialog || event.target === viewport)
|
|
125
|
+
dialog.close();
|
|
126
|
+
}, options);
|
|
127
|
+
const clearImage = () => {
|
|
128
|
+
image.removeAttribute('src');
|
|
129
|
+
image.alt = '图片预览';
|
|
130
|
+
resetView();
|
|
131
|
+
};
|
|
132
|
+
dialog.addEventListener('close', clearImage, options);
|
|
133
|
+
scope.add(() => {
|
|
134
|
+
dialog.close();
|
|
135
|
+
clearImage();
|
|
136
|
+
});
|
|
137
|
+
input.signal?.addEventListener('abort', scope.dispose, { once: true });
|
|
138
|
+
scope.add(() => input.signal?.removeEventListener('abort', scope.dispose));
|
|
139
|
+
if (input.signal?.aborted)
|
|
140
|
+
scope.dispose();
|
|
141
|
+
return { close: () => dialog.close(), reset: resetView, dispose: scope.dispose };
|
|
142
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface ResolveStoredArticleBackHrefInput {
|
|
2
|
+
/** Optional saved list URL, including its filter query and content anchor. */
|
|
3
|
+
storedHref: string | null;
|
|
4
|
+
currentPath: string;
|
|
5
|
+
currentOrigin: string;
|
|
6
|
+
/** The archive destination injected by Core into this page's document. */
|
|
7
|
+
fallbackHref: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Return a saved same-origin list destination or Core's archive URL. Reject
|
|
11
|
+
* external targets and self-navigation without knowing the host's route shape.
|
|
12
|
+
*/
|
|
13
|
+
export declare function resolveStoredArticleBackHref({ storedHref, currentPath, currentOrigin, fallbackHref, }: ResolveStoredArticleBackHrefInput): string;
|
|
14
|
+
export interface ShouldPersistArticleBackHrefInput {
|
|
15
|
+
linkHref: string;
|
|
16
|
+
currentOrigin: string;
|
|
17
|
+
currentPath: string;
|
|
18
|
+
/** Only links rendered from public PostSummary.url carry this theme marker. */
|
|
19
|
+
isArticleLink: boolean;
|
|
20
|
+
}
|
|
21
|
+
/** Save list position only when the visitor follows an internal article link. */
|
|
22
|
+
export declare function shouldPersistArticleBackHref({ linkHref, currentOrigin, currentPath, isArticleLink, }: ShouldPersistArticleBackHrefInput): boolean;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Return a saved same-origin list destination or Core's archive URL. Reject
|
|
3
|
+
* external targets and self-navigation without knowing the host's route shape.
|
|
4
|
+
*/
|
|
5
|
+
export function resolveStoredArticleBackHref({ storedHref, currentPath, currentOrigin, fallbackHref, }) {
|
|
6
|
+
if (!storedHref)
|
|
7
|
+
return fallbackHref;
|
|
8
|
+
try {
|
|
9
|
+
const target = new URL(storedHref, currentOrigin);
|
|
10
|
+
if (target.origin !== currentOrigin || target.pathname === currentPath)
|
|
11
|
+
return fallbackHref;
|
|
12
|
+
return `${target.pathname}${target.search}${target.hash}`;
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return fallbackHref;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** Save list position only when the visitor follows an internal article link. */
|
|
19
|
+
export function shouldPersistArticleBackHref({ linkHref, currentOrigin, currentPath, isArticleLink, }) {
|
|
20
|
+
if (!isArticleLink)
|
|
21
|
+
return false;
|
|
22
|
+
try {
|
|
23
|
+
const target = new URL(linkHref, currentOrigin);
|
|
24
|
+
return target.origin === currentOrigin && target.pathname !== currentPath;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { PostFilters, SearchEntry } from '@mintfolio/theme-api';
|
|
2
|
+
import type { Cleanup } from './lifecycle.js';
|
|
3
|
+
/** Filtered content and pagination state; UI structure is entirely caller-owned. */
|
|
4
|
+
export interface PostListState<T> {
|
|
5
|
+
filters: PostFilters;
|
|
6
|
+
matches: T[];
|
|
7
|
+
visible: T[];
|
|
8
|
+
total: number;
|
|
9
|
+
limit: number;
|
|
10
|
+
hasMore: boolean;
|
|
11
|
+
/** Facets ignore q, matching the blog's existing taxonomy navigation behavior. */
|
|
12
|
+
facets: {
|
|
13
|
+
tags: string[];
|
|
14
|
+
categories: string[];
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export interface PostListOptions<T> {
|
|
18
|
+
/** Safe public DTOs or theme models derived exclusively from those DTOs. */
|
|
19
|
+
items: readonly T[];
|
|
20
|
+
/** Map each model to Core's normalized public search fields. Called once. */
|
|
21
|
+
index: (item: T) => SearchEntry;
|
|
22
|
+
initialFilters?: Partial<PostFilters>;
|
|
23
|
+
pageSize?: number;
|
|
24
|
+
initialLimit?: number;
|
|
25
|
+
/** Optional radio-group UX: clear a selection made unavailable by the other facet. */
|
|
26
|
+
reconcileFacets?: boolean;
|
|
27
|
+
}
|
|
28
|
+
/** No DOM, storage, history, sorting policy, or theme classes are hidden here. */
|
|
29
|
+
export interface PostListController<T> {
|
|
30
|
+
value(): PostListState<T>;
|
|
31
|
+
setFilters(filters: Partial<PostFilters>): void;
|
|
32
|
+
loadMore(): void;
|
|
33
|
+
setLimit(limit: number): void;
|
|
34
|
+
subscribe(listener: (state: PostListState<T>) => void): Cleanup;
|
|
35
|
+
dispose(): void;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Shared search, combined tag/category filtering, facets, and load-more state.
|
|
39
|
+
* @returns A controller whose subscriptions receive the initial state immediately.
|
|
40
|
+
*/
|
|
41
|
+
export declare function createPostListController<T>(options: PostListOptions<T>): PostListController<T>;
|