@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,141 @@
|
|
|
1
|
+
/** Shared code-language labels, rendered-text extraction, and clipboard access. No UI is mounted on import. */
|
|
2
|
+
const languageLabels: Record<string, string> = {
|
|
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
|
+
|
|
54
|
+
export function formatLanguageLabel(language: string): string {
|
|
55
|
+
const normalized = (language ?? '').trim().toLowerCase();
|
|
56
|
+
if (!normalized) {
|
|
57
|
+
return 'Text';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (languageLabels[normalized]) {
|
|
61
|
+
return languageLabels[normalized];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const words = normalized
|
|
65
|
+
.replace(/[._-]+/g, ' ')
|
|
66
|
+
.split(/\s+/)
|
|
67
|
+
.filter(Boolean);
|
|
68
|
+
|
|
69
|
+
if (words.length === 0) {
|
|
70
|
+
return 'Text';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return words
|
|
74
|
+
.map((word) => {
|
|
75
|
+
if (word.length <= 4) {
|
|
76
|
+
return word.toUpperCase();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return `${word.charAt(0).toUpperCase()}${word.slice(1)}`;
|
|
80
|
+
})
|
|
81
|
+
.join(' ');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function resolveCodeLanguage(pre: HTMLPreElement, code: HTMLElement): string {
|
|
85
|
+
const preLanguage = pre.getAttribute('data-language') ?? pre.dataset.language;
|
|
86
|
+
if (preLanguage) {
|
|
87
|
+
return formatLanguageLabel(preLanguage);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const className = Array.from(code.classList).find((value) => value.startsWith('language-'));
|
|
91
|
+
if (className) {
|
|
92
|
+
return formatLanguageLabel(className.replace('language-', ''));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return 'Text';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function fallbackCopyText(text: string): boolean {
|
|
99
|
+
const textarea = document.createElement('textarea');
|
|
100
|
+
textarea.value = text;
|
|
101
|
+
textarea.setAttribute('readonly', 'true');
|
|
102
|
+
textarea.style.cssText = 'position:fixed;top:-9999px;left:-9999px';
|
|
103
|
+
document.body.appendChild(textarea);
|
|
104
|
+
textarea.select();
|
|
105
|
+
|
|
106
|
+
let copied = false;
|
|
107
|
+
try {
|
|
108
|
+
copied = document.execCommand('copy');
|
|
109
|
+
} catch {
|
|
110
|
+
copied = false;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
textarea.remove();
|
|
114
|
+
return copied;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function copyText(text: string): Promise<boolean> {
|
|
118
|
+
if (window.isSecureContext && navigator.clipboard?.writeText) {
|
|
119
|
+
try {
|
|
120
|
+
await navigator.clipboard.writeText(text);
|
|
121
|
+
return true;
|
|
122
|
+
} catch {
|
|
123
|
+
return fallbackCopyText(text);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return fallbackCopyText(text);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function extractCodeText(code: HTMLElement, pre: HTMLPreElement): string {
|
|
131
|
+
const renderedLines = Array.from(code.children).filter((child) => child.classList?.contains('line'));
|
|
132
|
+
|
|
133
|
+
if (renderedLines.length > 0) {
|
|
134
|
+
return renderedLines
|
|
135
|
+
.map((line) => line.textContent ?? '')
|
|
136
|
+
.join('\n')
|
|
137
|
+
.replace(/\n$/, '');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return (code.textContent ?? pre.textContent ?? '').replace(/\n$/, '');
|
|
141
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export type Cleanup = () => void;
|
|
2
|
+
|
|
3
|
+
/** Resources owned by one mounted page or widget; disposal is idempotent. */
|
|
4
|
+
export interface PageScope {
|
|
5
|
+
readonly signal: AbortSignal;
|
|
6
|
+
add(cleanup: Cleanup): void;
|
|
7
|
+
timeout(callback: () => void, delay: number): number;
|
|
8
|
+
frame(callback: FrameRequestCallback): number;
|
|
9
|
+
dispose(): void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Create a scope for listeners, timers, animation frames, and caller cleanups. */
|
|
13
|
+
export function createPageScope(): PageScope {
|
|
14
|
+
const controller = new AbortController();
|
|
15
|
+
const cleanups: Cleanup[] = [];
|
|
16
|
+
const timers = new Set<number>();
|
|
17
|
+
const frames = new Set<number>();
|
|
18
|
+
return {
|
|
19
|
+
signal: controller.signal,
|
|
20
|
+
add(cleanup): void { if (controller.signal.aborted) cleanup(); else cleanups.push(cleanup); },
|
|
21
|
+
timeout(callback, delay): number {
|
|
22
|
+
const id = window.setTimeout(() => { timers.delete(id); if (!controller.signal.aborted) callback(); }, delay);
|
|
23
|
+
timers.add(id);
|
|
24
|
+
return id;
|
|
25
|
+
},
|
|
26
|
+
frame(callback): number {
|
|
27
|
+
const id = window.requestAnimationFrame((time) => { frames.delete(id); if (!controller.signal.aborted) callback(time); });
|
|
28
|
+
frames.add(id);
|
|
29
|
+
return id;
|
|
30
|
+
},
|
|
31
|
+
dispose(): void {
|
|
32
|
+
if (controller.signal.aborted) return;
|
|
33
|
+
controller.abort();
|
|
34
|
+
timers.forEach((id) => window.clearTimeout(id));
|
|
35
|
+
frames.forEach((id) => window.cancelAnimationFrame(id));
|
|
36
|
+
// Run every cleanup even if one widget fails; protected content must still clear.
|
|
37
|
+
const errors: unknown[] = [];
|
|
38
|
+
for (const cleanup of cleanups.reverse()) { try { cleanup(); } catch (error) { errors.push(error); } }
|
|
39
|
+
cleanups.length = 0;
|
|
40
|
+
timers.clear();
|
|
41
|
+
frames.clear();
|
|
42
|
+
if (errors.length) console.error('[mintfolio:lifecycle] Widget cleanup failed', errors);
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Mount behavior on ordinary documents, Astro swaps, and BFCache restores.
|
|
49
|
+
* @param target A theme selector or a function resolving its current root.
|
|
50
|
+
* @param setup Attach behavior to that root and register cleanup with its scope.
|
|
51
|
+
* @returns Stop listening and dispose the current mount.
|
|
52
|
+
*/
|
|
53
|
+
export function onPage(target: string | (() => HTMLElement | null), setup: (root: HTMLElement, scope: PageScope) => void): Cleanup {
|
|
54
|
+
const lifetime = new AbortController();
|
|
55
|
+
let root: HTMLElement | null = null;
|
|
56
|
+
let scope: PageScope | null = null;
|
|
57
|
+
const dispose = (): void => { scope?.dispose(); scope = null; root = null; };
|
|
58
|
+
const mount = (): void => {
|
|
59
|
+
const next = typeof target === 'string' ? document.querySelector<HTMLElement>(target) : target();
|
|
60
|
+
if (root === next) return;
|
|
61
|
+
dispose();
|
|
62
|
+
if (!next) return;
|
|
63
|
+
root = next;
|
|
64
|
+
scope = createPageScope();
|
|
65
|
+
setup(root, scope);
|
|
66
|
+
};
|
|
67
|
+
const options = { signal: lifetime.signal };
|
|
68
|
+
document.addEventListener('astro:before-swap', dispose, options);
|
|
69
|
+
document.addEventListener('astro:page-load', mount, options);
|
|
70
|
+
document.addEventListener('article:content-changed', mount, options);
|
|
71
|
+
window.addEventListener('pagehide', dispose, options);
|
|
72
|
+
window.addEventListener('pageshow', mount, options);
|
|
73
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mount, { ...options, once: true });
|
|
74
|
+
else mount();
|
|
75
|
+
return (): void => { lifetime.abort(); dispose(); };
|
|
76
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { createPageScope, type Cleanup } from './lifecycle.js';
|
|
2
|
+
|
|
3
|
+
/** Theme-owned image-preview elements. Core supplies zoom, pan, keyboard and cleanup behavior. */
|
|
4
|
+
export interface LightboxOptions {
|
|
5
|
+
root: HTMLElement;
|
|
6
|
+
dialog: HTMLDialogElement;
|
|
7
|
+
image: HTMLImageElement;
|
|
8
|
+
viewport: HTMLElement;
|
|
9
|
+
zoomIn: HTMLButtonElement;
|
|
10
|
+
zoomOut: HTMLButtonElement;
|
|
11
|
+
zoomLevel: HTMLOutputElement;
|
|
12
|
+
reset: HTMLElement;
|
|
13
|
+
close?: HTMLElement | null;
|
|
14
|
+
minScale?: number;
|
|
15
|
+
maxScale?: number;
|
|
16
|
+
step?: number;
|
|
17
|
+
signal?: AbortSignal;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface LightboxController { close(): void; reset(): void; dispose: Cleanup; }
|
|
21
|
+
|
|
22
|
+
/** Mount one preview; linked article images keep their original navigation. */
|
|
23
|
+
export function createLightboxController(input: LightboxOptions): LightboxController {
|
|
24
|
+
const { root, dialog, image, viewport, zoomIn, zoomOut, zoomLevel, reset } = input;
|
|
25
|
+
const scope = createPageScope();
|
|
26
|
+
const options = { signal: scope.signal };
|
|
27
|
+
const minScale = input.minScale ?? 0.5;
|
|
28
|
+
const maxScale = input.maxScale ?? 4;
|
|
29
|
+
const step = input.step ?? 0.25;
|
|
30
|
+
if (![minScale, maxScale, step].every((value) => Number.isFinite(value) && value > 0) || minScale > 1 || maxScale < 1) {
|
|
31
|
+
throw new Error('Lightbox scale bounds must include 1 and the step must be positive and finite');
|
|
32
|
+
}
|
|
33
|
+
let scale = 1;
|
|
34
|
+
let x = 0;
|
|
35
|
+
let y = 0;
|
|
36
|
+
let drag: { id: number; clientX: number; clientY: number; x: number; y: number } | null = null;
|
|
37
|
+
|
|
38
|
+
const render = (): void => {
|
|
39
|
+
const maxX = Math.max(0, (image.offsetWidth * scale - viewport.clientWidth) / 2);
|
|
40
|
+
const maxY = Math.max(0, (image.offsetHeight * scale - viewport.clientHeight) / 2);
|
|
41
|
+
x = Math.max(-maxX, Math.min(maxX, x));
|
|
42
|
+
y = Math.max(-maxY, Math.min(maxY, y));
|
|
43
|
+
image.style.transform = `translate(${x}px, ${y}px) scale(${scale})`;
|
|
44
|
+
image.style.cursor = maxX || maxY ? (drag ? 'grabbing' : 'grab') : 'default';
|
|
45
|
+
zoomLevel.value = `${Math.round(scale * 100)}%`;
|
|
46
|
+
zoomIn.disabled = scale >= maxScale;
|
|
47
|
+
zoomOut.disabled = scale <= minScale;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const stopDragging = (): void => {
|
|
51
|
+
const pointerId = drag?.id;
|
|
52
|
+
drag = null;
|
|
53
|
+
if (pointerId !== undefined && image.hasPointerCapture(pointerId)) image.releasePointerCapture(pointerId);
|
|
54
|
+
render();
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const resetView = (): void => {
|
|
58
|
+
scale = 1;
|
|
59
|
+
x = 0;
|
|
60
|
+
y = 0;
|
|
61
|
+
stopDragging();
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const setScale = (value: number): void => {
|
|
65
|
+
const next = Math.max(minScale, Math.min(maxScale, value));
|
|
66
|
+
x *= next / scale;
|
|
67
|
+
y *= next / scale;
|
|
68
|
+
scale = next;
|
|
69
|
+
render();
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
zoomIn.addEventListener('click', () => setScale(scale + step), options);
|
|
73
|
+
zoomOut.addEventListener('click', () => setScale(scale - step), options);
|
|
74
|
+
reset.addEventListener('click', resetView, options);
|
|
75
|
+
image.addEventListener('load', render, options);
|
|
76
|
+
window.addEventListener('resize', () => { if (dialog.open) render(); }, options);
|
|
77
|
+
viewport.addEventListener('wheel', (event) => {
|
|
78
|
+
event.preventDefault();
|
|
79
|
+
if (event.deltaY) setScale(scale - Math.sign(event.deltaY) * step);
|
|
80
|
+
}, { ...options, passive: false });
|
|
81
|
+
dialog.addEventListener('keydown', (event) => {
|
|
82
|
+
if (event.ctrlKey || event.metaKey || event.altKey) return;
|
|
83
|
+
if (['+', '=', '-', '0'].includes(event.key)) {
|
|
84
|
+
event.preventDefault();
|
|
85
|
+
if (event.key === '0') resetView();
|
|
86
|
+
else setScale(scale + (event.key === '-' ? -step : step));
|
|
87
|
+
}
|
|
88
|
+
}, options);
|
|
89
|
+
|
|
90
|
+
image.addEventListener('pointerdown', (event) => {
|
|
91
|
+
if (!event.isPrimary || event.button !== 0 || scale <= 1) return;
|
|
92
|
+
event.preventDefault();
|
|
93
|
+
drag = { id: event.pointerId, clientX: event.clientX, clientY: event.clientY, x, y };
|
|
94
|
+
image.setPointerCapture(event.pointerId);
|
|
95
|
+
render();
|
|
96
|
+
}, options);
|
|
97
|
+
image.addEventListener('pointermove', (event) => {
|
|
98
|
+
if (!drag || event.pointerId !== drag.id) return;
|
|
99
|
+
x = drag.x + event.clientX - drag.clientX;
|
|
100
|
+
y = drag.y + event.clientY - drag.clientY;
|
|
101
|
+
render();
|
|
102
|
+
}, options);
|
|
103
|
+
image.addEventListener('pointerup', stopDragging, options);
|
|
104
|
+
image.addEventListener('pointercancel', stopDragging, options);
|
|
105
|
+
image.addEventListener('lostpointercapture', stopDragging, options);
|
|
106
|
+
|
|
107
|
+
for (const source of root.querySelectorAll('img')) {
|
|
108
|
+
// Linked images retain their author's navigation behavior.
|
|
109
|
+
if (source.closest('a')) continue;
|
|
110
|
+
const attributes = ['tabindex', 'role', 'aria-haspopup', 'aria-label'].map((name) => [name, source.getAttribute(name)] as const);
|
|
111
|
+
const cursor = source.style.cursor;
|
|
112
|
+
scope.add(() => {
|
|
113
|
+
for (const [name, value] of attributes) {
|
|
114
|
+
if (value === null) source.removeAttribute(name); else source.setAttribute(name, value);
|
|
115
|
+
}
|
|
116
|
+
source.style.cursor = cursor;
|
|
117
|
+
});
|
|
118
|
+
source.tabIndex = 0;
|
|
119
|
+
source.setAttribute('role', 'button');
|
|
120
|
+
source.setAttribute('aria-haspopup', 'dialog');
|
|
121
|
+
source.setAttribute('aria-label', `放大图片:${source.alt || '文章插图'}`);
|
|
122
|
+
source.style.cursor = 'zoom-in';
|
|
123
|
+
const open = (): void => {
|
|
124
|
+
image.src = source.currentSrc || source.src;
|
|
125
|
+
image.alt = source.alt;
|
|
126
|
+
dialog.showModal();
|
|
127
|
+
resetView();
|
|
128
|
+
};
|
|
129
|
+
source.addEventListener('click', open, options);
|
|
130
|
+
source.addEventListener(
|
|
131
|
+
'keydown',
|
|
132
|
+
(event) => {
|
|
133
|
+
if (['Enter', ' '].includes(event.key)) {
|
|
134
|
+
event.preventDefault();
|
|
135
|
+
open();
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
options,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
input.close?.addEventListener('click', () => dialog.close(), options);
|
|
142
|
+
dialog.addEventListener(
|
|
143
|
+
'click',
|
|
144
|
+
(event) => {
|
|
145
|
+
if (event.target === dialog || event.target === viewport) dialog.close();
|
|
146
|
+
},
|
|
147
|
+
options,
|
|
148
|
+
);
|
|
149
|
+
const clearImage = (): void => {
|
|
150
|
+
image.removeAttribute('src');
|
|
151
|
+
image.alt = '图片预览';
|
|
152
|
+
resetView();
|
|
153
|
+
};
|
|
154
|
+
dialog.addEventListener('close', clearImage, options);
|
|
155
|
+
scope.add(() => {
|
|
156
|
+
dialog.close();
|
|
157
|
+
clearImage();
|
|
158
|
+
});
|
|
159
|
+
input.signal?.addEventListener('abort', scope.dispose, { once: true });
|
|
160
|
+
scope.add(() => input.signal?.removeEventListener('abort', scope.dispose));
|
|
161
|
+
if (input.signal?.aborted) scope.dispose();
|
|
162
|
+
return { close: () => dialog.close(), reset: resetView, dispose: scope.dispose };
|
|
163
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
/**
|
|
11
|
+
* Return a saved same-origin list destination or Core's archive URL. Reject
|
|
12
|
+
* external targets and self-navigation without knowing the host's route shape.
|
|
13
|
+
*/
|
|
14
|
+
export function resolveStoredArticleBackHref({
|
|
15
|
+
storedHref, currentPath, currentOrigin, fallbackHref,
|
|
16
|
+
}: ResolveStoredArticleBackHrefInput): string {
|
|
17
|
+
if (!storedHref) return fallbackHref;
|
|
18
|
+
try {
|
|
19
|
+
const target = new URL(storedHref, currentOrigin);
|
|
20
|
+
if (target.origin !== currentOrigin || target.pathname === currentPath) return fallbackHref;
|
|
21
|
+
return `${target.pathname}${target.search}${target.hash}`;
|
|
22
|
+
} catch {
|
|
23
|
+
return fallbackHref;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ShouldPersistArticleBackHrefInput {
|
|
28
|
+
linkHref: string;
|
|
29
|
+
currentOrigin: string;
|
|
30
|
+
currentPath: string;
|
|
31
|
+
/** Only links rendered from public PostSummary.url carry this theme marker. */
|
|
32
|
+
isArticleLink: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Save list position only when the visitor follows an internal article link. */
|
|
36
|
+
export function shouldPersistArticleBackHref({
|
|
37
|
+
linkHref, currentOrigin, currentPath, isArticleLink,
|
|
38
|
+
}: ShouldPersistArticleBackHrefInput): boolean {
|
|
39
|
+
if (!isArticleLink) return false;
|
|
40
|
+
try {
|
|
41
|
+
const target = new URL(linkHref, currentOrigin);
|
|
42
|
+
return target.origin === currentOrigin && target.pathname !== currentPath;
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { emptyFilters, searchPosts } from '@mintfolio/theme-api/search';
|
|
2
|
+
import type { PostFilters, SearchEntry } from '@mintfolio/theme-api';
|
|
3
|
+
import type { Cleanup } from './lifecycle.js';
|
|
4
|
+
|
|
5
|
+
/** Filtered content and pagination state; UI structure is entirely caller-owned. */
|
|
6
|
+
export interface PostListState<T> {
|
|
7
|
+
filters: PostFilters;
|
|
8
|
+
matches: T[];
|
|
9
|
+
visible: T[];
|
|
10
|
+
total: number;
|
|
11
|
+
limit: number;
|
|
12
|
+
hasMore: boolean;
|
|
13
|
+
/** Facets ignore q, matching the blog's existing taxonomy navigation behavior. */
|
|
14
|
+
facets: { tags: string[]; 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
|
+
|
|
29
|
+
/** No DOM, storage, history, sorting policy, or theme classes are hidden here. */
|
|
30
|
+
export interface PostListController<T> {
|
|
31
|
+
value(): PostListState<T>;
|
|
32
|
+
setFilters(filters: Partial<PostFilters>): void;
|
|
33
|
+
loadMore(): void;
|
|
34
|
+
setLimit(limit: number): void;
|
|
35
|
+
subscribe(listener: (state: PostListState<T>) => void): Cleanup;
|
|
36
|
+
dispose(): void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function positiveInteger(value: number, field: string): number {
|
|
40
|
+
if (!Number.isSafeInteger(value) || value < 1) throw new Error(`${field} must be a positive safe integer`);
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Shared search, combined tag/category filtering, facets, and load-more state.
|
|
46
|
+
* @returns A controller whose subscriptions receive the initial state immediately.
|
|
47
|
+
*/
|
|
48
|
+
export function createPostListController<T>(options: PostListOptions<T>): PostListController<T> {
|
|
49
|
+
const indexed = options.items.map((item) => ({ ...options.index(item), item }));
|
|
50
|
+
const pageSize = positiveInteger(options.pageSize ?? (indexed.length || 1), 'pageSize');
|
|
51
|
+
let limit = positiveInteger(options.initialLimit ?? pageSize, 'initialLimit');
|
|
52
|
+
let filters: PostFilters = { ...emptyFilters(), ...options.initialFilters };
|
|
53
|
+
if (Object.values(filters).some((item) => typeof item !== 'string')) throw new Error('Filters must be strings');
|
|
54
|
+
let disposed = false;
|
|
55
|
+
const listeners = new Set<(state: PostListState<T>) => void>();
|
|
56
|
+
const facets = (): PostListState<T>['facets'] => ({
|
|
57
|
+
tags: [...new Set(searchPosts(indexed, { category: filters.category }).flatMap((entry) => entry.tags))],
|
|
58
|
+
categories: [...new Set(searchPosts(indexed, { tag: filters.tag }).map((entry) => entry.category))],
|
|
59
|
+
});
|
|
60
|
+
const reconcile = (): void => {
|
|
61
|
+
if (!options.reconcileFacets) return;
|
|
62
|
+
if (filters.tag && !facets().tags.includes(filters.tag.toLowerCase())) filters.tag = '';
|
|
63
|
+
if (filters.category && !facets().categories.includes(filters.category.toLowerCase())) filters.category = '';
|
|
64
|
+
};
|
|
65
|
+
reconcile();
|
|
66
|
+
const value = (): PostListState<T> => {
|
|
67
|
+
const matches = searchPosts(indexed, filters).map((entry) => entry.item);
|
|
68
|
+
return { filters: { ...filters }, matches, visible: matches.slice(0, limit), total: matches.length, limit, hasMore: matches.length > limit, facets: facets() };
|
|
69
|
+
};
|
|
70
|
+
const emit = (): void => { if (!disposed) for (const listener of listeners) listener(value()); };
|
|
71
|
+
return {
|
|
72
|
+
value,
|
|
73
|
+
setFilters(next): void {
|
|
74
|
+
if (disposed) return;
|
|
75
|
+
const changed = { ...filters, ...next };
|
|
76
|
+
if (Object.values(changed).some((item) => typeof item !== 'string')) throw new Error('Filters must be strings');
|
|
77
|
+
if (changed.q !== filters.q || changed.tag !== filters.tag || changed.category !== filters.category) limit = pageSize;
|
|
78
|
+
filters = changed;
|
|
79
|
+
reconcile();
|
|
80
|
+
emit();
|
|
81
|
+
},
|
|
82
|
+
loadMore(): void { if (!disposed) { limit = Math.min(Number.MAX_SAFE_INTEGER, limit + pageSize); emit(); } },
|
|
83
|
+
setLimit(next): void { if (!disposed) { limit = positiveInteger(next, 'limit'); emit(); } },
|
|
84
|
+
subscribe(listener): Cleanup {
|
|
85
|
+
if (disposed) return () => {};
|
|
86
|
+
listeners.add(listener);
|
|
87
|
+
listener(value());
|
|
88
|
+
return (): void => { listeners.delete(listener); };
|
|
89
|
+
},
|
|
90
|
+
dispose(): void { disposed = true; listeners.clear(); },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { unlockArticle, type ArticleUnlockError, type UnlockedArticle } from '@mintfolio/theme-api/client';
|
|
2
|
+
import type { Cleanup } from './lifecycle.js';
|
|
3
|
+
|
|
4
|
+
export type ProtectedArticleStatus = 'locked' | 'unlocking' | 'unlocked';
|
|
5
|
+
|
|
6
|
+
/** The theme owns DOM rendering; Core owns the short-lived unlock operation. */
|
|
7
|
+
export interface ProtectedArticleOptions {
|
|
8
|
+
postId: string;
|
|
9
|
+
payload: unknown;
|
|
10
|
+
/** Mount the returned fragment once; do not persist or retain plaintext. */
|
|
11
|
+
onUnlock(content: UnlockedArticle): void;
|
|
12
|
+
/** Clear article nodes, TOC nodes, preview images, password values and error UI. */
|
|
13
|
+
onClear(): void;
|
|
14
|
+
onState?(state: ProtectedArticleStatus): void;
|
|
15
|
+
onError?(error: ArticleUnlockError | Error): void;
|
|
16
|
+
signal?: AbortSignal;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ProtectedArticleController {
|
|
20
|
+
/** Returns false if disposed, already busy, rejected, or superseded by a lock. */
|
|
21
|
+
unlock(password: string): Promise<boolean>;
|
|
22
|
+
lock(): void;
|
|
23
|
+
dispose: Cleanup;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Own authentication races and navigation cleanup once for all themes. No secret
|
|
28
|
+
* enters storage, history, callbacks other than onUnlock, or error messages.
|
|
29
|
+
*/
|
|
30
|
+
export function createProtectedArticleController(options: ProtectedArticleOptions): ProtectedArticleController {
|
|
31
|
+
let generation = 0;
|
|
32
|
+
let disposed = false;
|
|
33
|
+
let busy = false;
|
|
34
|
+
let active = true;
|
|
35
|
+
const lifetime = new AbortController();
|
|
36
|
+
const lock = (): void => {
|
|
37
|
+
generation += 1;
|
|
38
|
+
busy = false;
|
|
39
|
+
options.onClear();
|
|
40
|
+
options.onState?.('locked');
|
|
41
|
+
};
|
|
42
|
+
const dispose = (): void => {
|
|
43
|
+
if (disposed) return;
|
|
44
|
+
disposed = true;
|
|
45
|
+
lifetime.abort();
|
|
46
|
+
options.signal?.removeEventListener('abort', dispose);
|
|
47
|
+
lock();
|
|
48
|
+
};
|
|
49
|
+
window.addEventListener('pagehide', () => { active = false; lock(); }, { signal: lifetime.signal });
|
|
50
|
+
window.addEventListener('pageshow', () => { active = true; lock(); }, { signal: lifetime.signal });
|
|
51
|
+
document.addEventListener('astro:before-swap', dispose, { signal: lifetime.signal });
|
|
52
|
+
options.signal?.addEventListener('abort', dispose, { once: true });
|
|
53
|
+
if (options.signal?.aborted) dispose();
|
|
54
|
+
return {
|
|
55
|
+
async unlock(password): Promise<boolean> {
|
|
56
|
+
if (disposed || !active || busy || !password) return false;
|
|
57
|
+
const operation = ++generation;
|
|
58
|
+
busy = true;
|
|
59
|
+
options.onState?.('unlocking');
|
|
60
|
+
try {
|
|
61
|
+
const content = await unlockArticle(options.payload, password, options.postId);
|
|
62
|
+
if (disposed || !active || operation !== generation) return false;
|
|
63
|
+
options.onUnlock(content);
|
|
64
|
+
options.onState?.('unlocked');
|
|
65
|
+
return true;
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (disposed || !active || operation !== generation) return false;
|
|
68
|
+
// Also clear any partial mount if the theme's onUnlock callback fails.
|
|
69
|
+
options.onClear();
|
|
70
|
+
options.onState?.('locked');
|
|
71
|
+
options.onError?.(error instanceof Error ? error : new Error('Article unlocking failed'));
|
|
72
|
+
return false;
|
|
73
|
+
} finally {
|
|
74
|
+
if (operation === generation) busy = false;
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
lock,
|
|
78
|
+
dispose,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createPageScope, type Cleanup } from './lifecycle.js';
|
|
2
|
+
|
|
3
|
+
/** Element references make TOC behavior independent of layout and class names. */
|
|
4
|
+
export interface TocOptions {
|
|
5
|
+
headings: readonly HTMLElement[];
|
|
6
|
+
links?: readonly HTMLAnchorElement[];
|
|
7
|
+
/** Sticky header offset in pixels; use a function for responsive layouts. */
|
|
8
|
+
offset?: number | (() => number);
|
|
9
|
+
activeClass?: string;
|
|
10
|
+
scrollActiveLink?: boolean;
|
|
11
|
+
onActive?(headingId: string): void;
|
|
12
|
+
/** Document reading progress from 0 to 100. */
|
|
13
|
+
onProgress?(percentage: number): void;
|
|
14
|
+
onNavigate?(headingId: string): void;
|
|
15
|
+
signal?: AbortSignal;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TocController {
|
|
19
|
+
refresh(): void;
|
|
20
|
+
scrollTo(headingId: string): void;
|
|
21
|
+
dispose: Cleanup;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Track headings and navigate without adding a history entry for each section. */
|
|
25
|
+
export function createTocController(options: TocOptions): TocController {
|
|
26
|
+
const scope = createPageScope();
|
|
27
|
+
let activeId = '';
|
|
28
|
+
let pending = false;
|
|
29
|
+
const offset = (): number => typeof options.offset === 'function' ? options.offset() : options.offset ?? 0;
|
|
30
|
+
const headingFor = (link: HTMLAnchorElement): HTMLElement | undefined => {
|
|
31
|
+
let id: string;
|
|
32
|
+
try { id = decodeURIComponent(new URL(link.href).hash.slice(1)); } catch { return undefined; }
|
|
33
|
+
return options.headings.find((heading) => heading.id === id);
|
|
34
|
+
};
|
|
35
|
+
const setActive = (id: string): void => {
|
|
36
|
+
if (!id || activeId === id) return;
|
|
37
|
+
activeId = id;
|
|
38
|
+
for (const link of options.links ?? []) {
|
|
39
|
+
const active = headingFor(link)?.id === id;
|
|
40
|
+
if (options.activeClass) link.classList.toggle(options.activeClass, active);
|
|
41
|
+
if (active) {
|
|
42
|
+
link.setAttribute('aria-current', 'true');
|
|
43
|
+
if (options.scrollActiveLink) link.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
|
44
|
+
} else link.removeAttribute('aria-current');
|
|
45
|
+
}
|
|
46
|
+
options.onActive?.(id);
|
|
47
|
+
};
|
|
48
|
+
const refresh = (): void => {
|
|
49
|
+
if (scope.signal.aborted) return;
|
|
50
|
+
const entries = options.headings.filter((heading) => heading.isConnected);
|
|
51
|
+
let current: HTMLElement | undefined = entries[0];
|
|
52
|
+
let nearest = Infinity;
|
|
53
|
+
for (const heading of entries) {
|
|
54
|
+
const distance = heading.getBoundingClientRect().top - offset();
|
|
55
|
+
if (distance <= 0 && Math.abs(distance) < nearest) { nearest = Math.abs(distance); current = heading; }
|
|
56
|
+
}
|
|
57
|
+
const height = document.documentElement.scrollHeight - window.innerHeight;
|
|
58
|
+
if (window.scrollY >= height - 50) current = entries.at(-1);
|
|
59
|
+
if (current) setActive(current.id);
|
|
60
|
+
options.onProgress?.(height > 0 ? Math.max(0, Math.min(100, window.scrollY / height * 100)) : 0);
|
|
61
|
+
};
|
|
62
|
+
const schedule = (): void => {
|
|
63
|
+
if (pending) return;
|
|
64
|
+
pending = true;
|
|
65
|
+
scope.frame(() => { pending = false; refresh(); });
|
|
66
|
+
};
|
|
67
|
+
const scrollTo = (id: string): void => {
|
|
68
|
+
const heading = options.headings.find((entry) => entry.id === id);
|
|
69
|
+
if (!heading || scope.signal.aborted) return;
|
|
70
|
+
window.scrollTo({ top: heading.getBoundingClientRect().top + window.scrollY - offset(), behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth' });
|
|
71
|
+
const url = new URL(location.href);
|
|
72
|
+
url.hash = id;
|
|
73
|
+
history.replaceState(history.state, '', url);
|
|
74
|
+
setActive(id);
|
|
75
|
+
options.onNavigate?.(id);
|
|
76
|
+
};
|
|
77
|
+
for (const link of options.links ?? []) link.addEventListener('click', (event) => {
|
|
78
|
+
const heading = headingFor(link);
|
|
79
|
+
if (!heading) return;
|
|
80
|
+
event.preventDefault();
|
|
81
|
+
scrollTo(heading.id);
|
|
82
|
+
}, { signal: scope.signal });
|
|
83
|
+
window.addEventListener('scroll', schedule, { signal: scope.signal, passive: true });
|
|
84
|
+
window.addEventListener('resize', schedule, { signal: scope.signal });
|
|
85
|
+
options.signal?.addEventListener('abort', scope.dispose, { once: true });
|
|
86
|
+
scope.add(() => options.signal?.removeEventListener('abort', scope.dispose));
|
|
87
|
+
if (options.signal?.aborted) scope.dispose();
|
|
88
|
+
else schedule();
|
|
89
|
+
return { refresh, scrollTo, dispose: scope.dispose };
|
|
90
|
+
}
|