@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 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.16",
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
  },
@@ -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 } = parseArgs(process.argv);
24
+ const { slug, theme: rawTheme } = parseNewPageArgs(process.argv);
86
25
  const theme = String(rawTheme || '').toLowerCase();
87
26
 
88
27
  if (!slug) {
89
- usage();
28
+ console.error(usageNewPage());
90
29
  process.exit(1);
91
30
  }
92
- if (!validateSlug(slug)) {
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 (!THEMES.includes(theme)) {
97
- console.error(`Invalid theme "${rawTheme}". Use one of: ${THEMES.join(', ')}.`);
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
- templateFor({ slug, theme }),
51
+ buildNewPageTemplate({ slug, theme }),
113
52
  'utf8',
114
53
  );
115
54
 
@@ -1,47 +1,20 @@
1
- import { mkdir, writeFile, access, readdir } from 'node:fs/promises';
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[2];
28
+ const { slug, locales: cliLocales } = parseNewPostArgs(process.argv);
90
29
  if (!slug) {
91
- console.error('Usage: npm run new-post -- <slug>');
30
+ console.error(usageNewPost());
92
31
  process.exit(1);
93
32
  }
94
33
 
95
- if (!validateSlug(slug)) {
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 SUPPORTED_LOCALES) {
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
- let heroImage = '';
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, templateFor(locale, slug, pubDate, heroImage), 'utf8');
61
+ await writeFile(filePath, buildNewPostTemplate(locale, slug, pubDate, heroImage), 'utf8');
122
62
  created.push(filePath);
123
63
  }
124
64
 
@@ -8,4 +8,8 @@ export const THEME = {
8
8
  HOME_LATEST_COUNT: 3,
9
9
  /** Whether to enable the About page (disable to hide from nav/routes if needed) */
10
10
  ENABLE_ABOUT_PAGE: true,
11
+ /** Optional visual effects switches */
12
+ EFFECTS: {
13
+ ENABLE_RED_QUEEN: true,
14
+ },
11
15
  } as const;
@@ -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
- <aside class="rq-tv rq-tv-collapsed">
83
- <div class="rq-tv-stage" data-rq-src={themeRedqueen1.src} data-rq-src2={themeRedqueen2.src}></div>
84
- <div class="rq-tv-badge">monitor feed<span class="rq-tv-dot"></span></div>
85
- <button type="button" class="rq-tv-toggle" aria-label="Replay monitor feed" aria-expanded="false">▶</button>
86
- </aside>
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
- initRedQueenTv(prefersReducedMotion);
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
+ }