@anglefeint/astro-theme 0.1.16 → 0.1.18
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/README.md +1 -0
- package/package.json +4 -1
- package/src/cli-new-page.mjs +13 -74
- package/src/cli-new-post.mjs +22 -82
- package/src/config/theme.ts +4 -0
- package/src/layouts/BlogPost.astro +9 -5
- package/src/scripts/blogpost-effects.js +3 -1
- package/src/utils/merge.ts +31 -0
package/README.md
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
doc_id: package_readme
|
|
3
3
|
doc_role: package-guide
|
|
4
|
+
doc_purpose: Package-level install, upgrade, and manual integration reference for advanced users.
|
|
4
5
|
doc_scope: [package-install, package-upgrade, package-usage]
|
|
5
6
|
update_triggers: [package-change, command-change, export-change]
|
|
6
7
|
source_of_truth: true
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anglefeint/astro-theme",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.18",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Anglefeint core theme package for Astro",
|
|
6
6
|
"keywords": [
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"src/scripts",
|
|
27
27
|
"src/i18n",
|
|
28
28
|
"src/styles",
|
|
29
|
+
"src/utils",
|
|
29
30
|
"src/assets/theme",
|
|
30
31
|
"src/cli-new-post.mjs",
|
|
31
32
|
"src/cli-new-page.mjs"
|
|
@@ -42,6 +43,8 @@
|
|
|
42
43
|
"./i18n/*": "./src/i18n/*",
|
|
43
44
|
"./styles/*": "./src/styles/*",
|
|
44
45
|
"./assets/*": "./src/assets/*",
|
|
46
|
+
"./utils/merge": "./src/utils/merge.ts",
|
|
47
|
+
"./utils/*": "./src/utils/*",
|
|
45
48
|
"./content-schema": "./src/content-schema.ts",
|
|
46
49
|
"./consts": "./src/consts.ts"
|
|
47
50
|
},
|
package/src/cli-new-page.mjs
CHANGED
|
@@ -1,54 +1,15 @@
|
|
|
1
1
|
import { access, mkdir, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import {
|
|
5
|
+
buildNewPageTemplate,
|
|
6
|
+
isValidNewPageTheme,
|
|
7
|
+
parseNewPageArgs,
|
|
8
|
+
usageNewPage,
|
|
9
|
+
validatePageSlug,
|
|
10
|
+
} from './scaffold/new-page.mjs';
|
|
4
11
|
|
|
5
|
-
const THEMES = ['base', 'cyber', 'ai', 'hacker', 'matrix'];
|
|
6
12
|
const PAGES_ROOT = path.resolve(process.cwd(), 'src/pages/[lang]');
|
|
7
|
-
const LAYOUT_BY_THEME = {
|
|
8
|
-
base: 'BasePageLayout',
|
|
9
|
-
ai: 'AiPageLayout',
|
|
10
|
-
cyber: 'CyberPageLayout',
|
|
11
|
-
hacker: 'HackerPageLayout',
|
|
12
|
-
matrix: 'MatrixPageLayout',
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
function usage() {
|
|
16
|
-
console.error('Usage: npm run new-page -- <slug> [--theme <base|cyber|ai|hacker|matrix>]');
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function parseArgs(argv) {
|
|
20
|
-
const args = argv.slice(2);
|
|
21
|
-
const positional = [];
|
|
22
|
-
let theme = 'base';
|
|
23
|
-
|
|
24
|
-
for (let i = 0; i < args.length; i += 1) {
|
|
25
|
-
const token = args[i];
|
|
26
|
-
if (token === '--theme') {
|
|
27
|
-
theme = args[i + 1] ?? '';
|
|
28
|
-
i += 1;
|
|
29
|
-
continue;
|
|
30
|
-
}
|
|
31
|
-
positional.push(token);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
return { slug: positional[0], theme };
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function validateSlug(slug) {
|
|
38
|
-
if (!slug) return false;
|
|
39
|
-
if (slug.startsWith('/') || slug.endsWith('/')) return false;
|
|
40
|
-
const parts = slug.split('/');
|
|
41
|
-
return parts.every((part) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(part));
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function toTitleFromSlug(slug) {
|
|
45
|
-
const leaf = slug.split('/').pop() ?? slug;
|
|
46
|
-
return leaf
|
|
47
|
-
.split('-')
|
|
48
|
-
.filter(Boolean)
|
|
49
|
-
.map((segment) => segment[0].toUpperCase() + segment.slice(1))
|
|
50
|
-
.join(' ');
|
|
51
|
-
}
|
|
52
13
|
|
|
53
14
|
async function exists(filePath) {
|
|
54
15
|
try {
|
|
@@ -59,42 +20,20 @@ async function exists(filePath) {
|
|
|
59
20
|
}
|
|
60
21
|
}
|
|
61
22
|
|
|
62
|
-
function templateFor({ slug, theme }) {
|
|
63
|
-
const title = toTitleFromSlug(slug) || 'New Page';
|
|
64
|
-
const layoutName = LAYOUT_BY_THEME[theme];
|
|
65
|
-
return `---
|
|
66
|
-
import type { GetStaticPaths } from 'astro';
|
|
67
|
-
import ${layoutName} from '@anglefeint/astro-theme/layouts/${layoutName}.astro';
|
|
68
|
-
import { SUPPORTED_LOCALES } from '@anglefeint/astro-theme/i18n/config';
|
|
69
|
-
|
|
70
|
-
export const getStaticPaths = (() => SUPPORTED_LOCALES.map((lang) => ({ params: { lang } }))) satisfies GetStaticPaths;
|
|
71
|
-
|
|
72
|
-
const locale = Astro.params.lang;
|
|
73
|
-
const pageTitle = '${title}';
|
|
74
|
-
const pageDescription = 'A custom page built from the ${theme} theme shell.';
|
|
75
|
-
---
|
|
76
|
-
|
|
77
|
-
<${layoutName} locale={locale} title={pageTitle} description={pageDescription}>
|
|
78
|
-
\t<h1>${title}</h1>
|
|
79
|
-
\t<p>Replace this content with your own page content.</p>
|
|
80
|
-
</${layoutName}>
|
|
81
|
-
`;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
23
|
async function main() {
|
|
85
|
-
const { slug, theme: rawTheme } =
|
|
24
|
+
const { slug, theme: rawTheme } = parseNewPageArgs(process.argv);
|
|
86
25
|
const theme = String(rawTheme || '').toLowerCase();
|
|
87
26
|
|
|
88
27
|
if (!slug) {
|
|
89
|
-
|
|
28
|
+
console.error(usageNewPage());
|
|
90
29
|
process.exit(1);
|
|
91
30
|
}
|
|
92
|
-
if (!
|
|
31
|
+
if (!validatePageSlug(slug)) {
|
|
93
32
|
console.error('Invalid page slug. Use lowercase letters, numbers, hyphens, and optional nested paths.');
|
|
94
33
|
process.exit(1);
|
|
95
34
|
}
|
|
96
|
-
if (!
|
|
97
|
-
console.error(
|
|
35
|
+
if (!isValidNewPageTheme(theme)) {
|
|
36
|
+
console.error('Invalid theme. Use one of: base, cyber, ai, hacker, matrix.');
|
|
98
37
|
process.exit(1);
|
|
99
38
|
}
|
|
100
39
|
|
|
@@ -109,7 +48,7 @@ async function main() {
|
|
|
109
48
|
await mkdir(targetDir, { recursive: true });
|
|
110
49
|
await writeFile(
|
|
111
50
|
targetPath,
|
|
112
|
-
|
|
51
|
+
buildNewPageTemplate({ slug, theme }),
|
|
113
52
|
'utf8',
|
|
114
53
|
);
|
|
115
54
|
|
package/src/cli-new-post.mjs
CHANGED
|
@@ -1,47 +1,20 @@
|
|
|
1
|
-
import { mkdir, writeFile, access
|
|
1
|
+
import { mkdir, writeFile, access } from 'node:fs/promises';
|
|
2
2
|
import { constants } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { SUPPORTED_LOCALES } from './i18n/locales.mjs';
|
|
5
|
+
import {
|
|
6
|
+
buildNewPostTemplate,
|
|
7
|
+
loadDefaultCovers,
|
|
8
|
+
parseNewPostArgs,
|
|
9
|
+
pickDefaultCoverBySlug,
|
|
10
|
+
resolveLocales,
|
|
11
|
+
usageNewPost,
|
|
12
|
+
validatePostSlug,
|
|
13
|
+
} from './scaffold/new-post.mjs';
|
|
5
14
|
|
|
6
15
|
const CONTENT_ROOT = path.resolve(process.cwd(), 'src/content/blog');
|
|
7
16
|
const DEFAULT_COVERS_ROOT = path.resolve(process.cwd(), 'src/assets/blog/default-covers');
|
|
8
17
|
|
|
9
|
-
function toTitleFromSlug(slug) {
|
|
10
|
-
return slug
|
|
11
|
-
.split('-')
|
|
12
|
-
.filter(Boolean)
|
|
13
|
-
.map((segment) => segment[0].toUpperCase() + segment.slice(1))
|
|
14
|
-
.join(' ');
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function validateSlug(slug) {
|
|
18
|
-
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function hashString(input) {
|
|
22
|
-
let hash = 5381;
|
|
23
|
-
for (let i = 0; i < input.length; i += 1) {
|
|
24
|
-
hash = ((hash << 5) + hash + input.charCodeAt(i)) >>> 0;
|
|
25
|
-
}
|
|
26
|
-
return hash >>> 0;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function normalizePathForFrontmatter(filePath) {
|
|
30
|
-
return filePath.split(path.sep).join('/');
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
async function loadDefaultCovers() {
|
|
34
|
-
try {
|
|
35
|
-
const entries = await readdir(DEFAULT_COVERS_ROOT, { withFileTypes: true });
|
|
36
|
-
return entries
|
|
37
|
-
.filter((entry) => entry.isFile() && /\.(webp|png|jpe?g)$/i.test(entry.name))
|
|
38
|
-
.map((entry) => path.join(DEFAULT_COVERS_ROOT, entry.name))
|
|
39
|
-
.sort((a, b) => a.localeCompare(b));
|
|
40
|
-
} catch {
|
|
41
|
-
return [];
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
18
|
async function exists(filePath) {
|
|
46
19
|
try {
|
|
47
20
|
await access(filePath, constants.F_OK);
|
|
@@ -51,58 +24,29 @@ async function exists(filePath) {
|
|
|
51
24
|
}
|
|
52
25
|
}
|
|
53
26
|
|
|
54
|
-
function templateFor(locale, slug, pubDate, heroImage) {
|
|
55
|
-
const titleByLocale = {
|
|
56
|
-
en: toTitleFromSlug(slug),
|
|
57
|
-
ja: '新しい記事タイトル',
|
|
58
|
-
ko: '새 글 제목',
|
|
59
|
-
es: 'Título del nuevo artículo',
|
|
60
|
-
zh: '新文章标题',
|
|
61
|
-
};
|
|
62
|
-
const descriptionByLocale = {
|
|
63
|
-
en: `A short EN post scaffold for "${slug}".`,
|
|
64
|
-
ja: `「${slug}」用の短い日本語記事テンプレートです。`,
|
|
65
|
-
ko: `"${slug}"용 한국어 글 템플릿입니다.`,
|
|
66
|
-
es: `Plantilla breve en español para "${slug}".`,
|
|
67
|
-
zh: `“${slug}”的中文文章模板。`,
|
|
68
|
-
};
|
|
69
|
-
const bodyByLocale = {
|
|
70
|
-
en: `Write your EN content for "${slug}" here.`,
|
|
71
|
-
ja: `ここに「${slug}」の日本語本文を書いてください。`,
|
|
72
|
-
ko: `여기에 "${slug}" 한국어 본문을 작성하세요.`,
|
|
73
|
-
es: `Escribe aquí el contenido en español para "${slug}".`,
|
|
74
|
-
zh: `请在这里填写“${slug}”的中文正文。`,
|
|
75
|
-
};
|
|
76
|
-
return `---
|
|
77
|
-
title: '${titleByLocale[locale]}'
|
|
78
|
-
subtitle: ''
|
|
79
|
-
description: '${descriptionByLocale[locale]}'
|
|
80
|
-
pubDate: '${pubDate}'
|
|
81
|
-
${heroImage ? `heroImage: '${heroImage}'` : ''}
|
|
82
|
-
---
|
|
83
|
-
|
|
84
|
-
${bodyByLocale[locale]}
|
|
85
|
-
`;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
27
|
async function main() {
|
|
89
|
-
const slug = process.argv
|
|
28
|
+
const { slug, locales: cliLocales } = parseNewPostArgs(process.argv);
|
|
90
29
|
if (!slug) {
|
|
91
|
-
console.error(
|
|
30
|
+
console.error(usageNewPost());
|
|
92
31
|
process.exit(1);
|
|
93
32
|
}
|
|
94
33
|
|
|
95
|
-
if (!
|
|
34
|
+
if (!validatePostSlug(slug)) {
|
|
96
35
|
console.error('Invalid slug. Use lowercase letters, numbers, and hyphens only.');
|
|
97
36
|
process.exit(1);
|
|
98
37
|
}
|
|
99
38
|
|
|
100
39
|
const pubDate = new Date().toISOString().slice(0, 10);
|
|
101
|
-
const defaultCovers = await loadDefaultCovers();
|
|
40
|
+
const defaultCovers = await loadDefaultCovers(DEFAULT_COVERS_ROOT);
|
|
41
|
+
const locales = resolveLocales({
|
|
42
|
+
cliLocales,
|
|
43
|
+
envLocales: process.env.ANGLEFEINT_LOCALES ?? '',
|
|
44
|
+
defaultLocales: SUPPORTED_LOCALES,
|
|
45
|
+
});
|
|
102
46
|
const created = [];
|
|
103
47
|
const skipped = [];
|
|
104
48
|
|
|
105
|
-
for (const locale of
|
|
49
|
+
for (const locale of locales) {
|
|
106
50
|
const localeDir = path.join(CONTENT_ROOT, locale);
|
|
107
51
|
const filePath = path.join(localeDir, `${slug}.md`);
|
|
108
52
|
await mkdir(localeDir, { recursive: true });
|
|
@@ -112,13 +56,9 @@ async function main() {
|
|
|
112
56
|
continue;
|
|
113
57
|
}
|
|
114
58
|
|
|
115
|
-
|
|
116
|
-
if (defaultCovers.length > 0) {
|
|
117
|
-
const coverPath = defaultCovers[hashString(slug) % defaultCovers.length];
|
|
118
|
-
heroImage = normalizePathForFrontmatter(path.relative(localeDir, coverPath));
|
|
119
|
-
}
|
|
59
|
+
const heroImage = pickDefaultCoverBySlug(slug, localeDir, defaultCovers);
|
|
120
60
|
|
|
121
|
-
await writeFile(filePath,
|
|
61
|
+
await writeFile(filePath, buildNewPostTemplate(locale, slug, pubDate, heroImage), 'utf8');
|
|
122
62
|
created.push(filePath);
|
|
123
63
|
}
|
|
124
64
|
|
package/src/config/theme.ts
CHANGED
|
@@ -7,6 +7,7 @@ import FormattedDate from '../components/FormattedDate.astro';
|
|
|
7
7
|
import AiShell from './shells/AiShell.astro';
|
|
8
8
|
import blogPostCssUrl from '../styles/blog-post.css?url';
|
|
9
9
|
import { SITE_AUTHOR } from '@anglefeint/site-config/site';
|
|
10
|
+
import { THEME } from '@anglefeint/site-config/theme';
|
|
10
11
|
import { DEFAULT_LOCALE, type Locale, isLocale, localePath, blogIdToSlugAnyLocale } from '@anglefeint/site-i18n/config';
|
|
11
12
|
import { getMessages } from '@anglefeint/site-i18n/messages';
|
|
12
13
|
|
|
@@ -49,6 +50,7 @@ const hasSystemMeta = Boolean(aiModel || aiMode || aiState);
|
|
|
49
50
|
const hasResponseMeta = aiLatencyMs !== undefined || aiConfidence !== undefined;
|
|
50
51
|
const hasStats = aiModel || wordCount !== undefined || tokenCount !== undefined;
|
|
51
52
|
const confidenceText = aiConfidence !== undefined ? aiConfidence.toFixed(2) : undefined;
|
|
53
|
+
const enableRedQueen = THEME.EFFECTS.ENABLE_RED_QUEEN;
|
|
52
54
|
---
|
|
53
55
|
|
|
54
56
|
<AiShell
|
|
@@ -79,11 +81,13 @@ const confidenceText = aiConfidence !== undefined ? aiConfidence.toFixed(2) : un
|
|
|
79
81
|
</div>
|
|
80
82
|
<canvas class="ai-network-canvas" aria-hidden="true"></canvas>
|
|
81
83
|
</div>
|
|
82
|
-
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
{enableRedQueen && (
|
|
85
|
+
<aside class="rq-tv rq-tv-collapsed">
|
|
86
|
+
<div class="rq-tv-stage" data-rq-src={themeRedqueen1.src} data-rq-src2={themeRedqueen2.src}></div>
|
|
87
|
+
<div class="rq-tv-badge">monitor feed<span class="rq-tv-dot"></span></div>
|
|
88
|
+
<button type="button" class="rq-tv-toggle" aria-label="Replay monitor feed" aria-expanded="false">▶</button>
|
|
89
|
+
</aside>
|
|
90
|
+
)}
|
|
87
91
|
<div class="ai-read-progress" aria-hidden="true"></div>
|
|
88
92
|
<button type="button" class="ai-back-to-top" aria-label="Back to top" title="Back to top">↑</button>
|
|
89
93
|
<div class="ai-stage-toast" aria-live="polite" aria-atomic="true"></div>
|
|
@@ -17,6 +17,8 @@ export function initBlogpostEffects() {
|
|
|
17
17
|
initReadProgressAndBackToTop(prefersReducedMotion);
|
|
18
18
|
initNetworkCanvas(prefersReducedMotion);
|
|
19
19
|
initHeroCanvas(prefersReducedMotion);
|
|
20
|
-
|
|
20
|
+
if (document.querySelector('.rq-tv-stage')) {
|
|
21
|
+
initRedQueenTv(prefersReducedMotion);
|
|
22
|
+
}
|
|
21
23
|
initPostInteractions(prefersReducedMotion);
|
|
22
24
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type DeepPartial<T> = {
|
|
2
|
+
[K in keyof T]?: T[K] extends Array<infer U>
|
|
3
|
+
? Array<DeepPartial<U>>
|
|
4
|
+
: T[K] extends object
|
|
5
|
+
? DeepPartial<T[K]>
|
|
6
|
+
: T[K];
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
10
|
+
return Object.prototype.toString.call(value) === '[object Object]';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function deepMerge<T>(base: T, override: DeepPartial<T>): T {
|
|
14
|
+
if (!isPlainObject(base) || !isPlainObject(override)) return (override as T) ?? base;
|
|
15
|
+
|
|
16
|
+
const result: Record<string, unknown> = { ...(base as Record<string, unknown>) };
|
|
17
|
+
for (const [key, value] of Object.entries(override)) {
|
|
18
|
+
if (value === undefined) continue;
|
|
19
|
+
const existing = result[key];
|
|
20
|
+
if (Array.isArray(value)) {
|
|
21
|
+
result[key] = value;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (isPlainObject(existing) && isPlainObject(value)) {
|
|
25
|
+
result[key] = deepMerge(existing, value);
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
result[key] = value;
|
|
29
|
+
}
|
|
30
|
+
return result as T;
|
|
31
|
+
}
|