@anglefeint/astro-theme 0.1.0-alpha.0

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.
Files changed (33) hide show
  1. package/README.md +50 -0
  2. package/package.json +38 -0
  3. package/public/scripts/about-effects.js +535 -0
  4. package/public/scripts/blogpost-effects.js +956 -0
  5. package/public/scripts/home-matrix.js +117 -0
  6. package/public/styles/about-page.css +756 -0
  7. package/public/styles/blog-post.css +390 -0
  8. package/public/styles/home-page.css +186 -0
  9. package/src/assets/theme/placeholders/theme-placeholder-1.jpg +0 -0
  10. package/src/assets/theme/placeholders/theme-placeholder-2.jpg +0 -0
  11. package/src/assets/theme/placeholders/theme-placeholder-3.jpg +0 -0
  12. package/src/assets/theme/placeholders/theme-placeholder-4.jpg +0 -0
  13. package/src/assets/theme/placeholders/theme-placeholder-5.jpg +0 -0
  14. package/src/assets/theme/placeholders/theme-placeholder-about.jpg +0 -0
  15. package/src/assets/theme/red-queen/theme-redqueen1.webp +0 -0
  16. package/src/assets/theme/red-queen/theme-redqueen2.gif +0 -0
  17. package/src/cli-new-page.mjs +118 -0
  18. package/src/cli-new-post.mjs +139 -0
  19. package/src/components/BaseHead.astro +177 -0
  20. package/src/components/Footer.astro +74 -0
  21. package/src/components/FormattedDate.astro +17 -0
  22. package/src/components/Header.astro +328 -0
  23. package/src/components/HeaderLink.astro +35 -0
  24. package/src/consts.ts +12 -0
  25. package/src/content-schema.ts +33 -0
  26. package/src/i18n/config.ts +46 -0
  27. package/src/i18n/messages.ts +220 -0
  28. package/src/i18n/posts.ts +11 -0
  29. package/src/index.ts +5 -0
  30. package/src/layouts/BasePageLayout.astro +67 -0
  31. package/src/layouts/BlogPost.astro +279 -0
  32. package/src/layouts/HomePage.astro +73 -0
  33. package/src/styles/global.css +1867 -0
@@ -0,0 +1,139 @@
1
+ import { mkdir, writeFile, access, readdir } from 'node:fs/promises';
2
+ import { constants } from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ const SUPPORTED_LOCALES = ['en', 'ja', 'ko', 'es', 'zh'];
6
+ const CONTENT_ROOT = path.resolve(process.cwd(), 'src/content/blog');
7
+ const DEFAULT_COVERS_ROOT = path.resolve(process.cwd(), 'src/assets/blog/default-covers');
8
+
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
+ async function exists(filePath) {
46
+ try {
47
+ await access(filePath, constants.F_OK);
48
+ return true;
49
+ } catch {
50
+ return false;
51
+ }
52
+ }
53
+
54
+ function templateFor(locale, slug, pubDate, heroImage) {
55
+ const titleByLocale = {
56
+ en: toTitleFromSlug(slug),
57
+ ja: '新しい記事タイトル',
58
+ ko: '새 글 제목',
59
+ es: 'Titulo del nuevo articulo',
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 espanol 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 aqui el contenido en espanol 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
+ async function main() {
89
+ const slug = process.argv[2];
90
+ if (!slug) {
91
+ console.error('Usage: npm run new-post -- <slug>');
92
+ process.exit(1);
93
+ }
94
+
95
+ if (!validateSlug(slug)) {
96
+ console.error('Invalid slug. Use lowercase letters, numbers, and hyphens only.');
97
+ process.exit(1);
98
+ }
99
+
100
+ const pubDate = new Date().toISOString().slice(0, 10);
101
+ const defaultCovers = await loadDefaultCovers();
102
+ const created = [];
103
+ const skipped = [];
104
+
105
+ for (const locale of SUPPORTED_LOCALES) {
106
+ const localeDir = path.join(CONTENT_ROOT, locale);
107
+ const filePath = path.join(localeDir, `${slug}.md`);
108
+ await mkdir(localeDir, { recursive: true });
109
+
110
+ if (await exists(filePath)) {
111
+ skipped.push(filePath);
112
+ continue;
113
+ }
114
+
115
+ let heroImage = '';
116
+ if (defaultCovers.length > 0) {
117
+ const coverPath = defaultCovers[hashString(slug) % defaultCovers.length];
118
+ heroImage = normalizePathForFrontmatter(path.relative(localeDir, coverPath));
119
+ }
120
+
121
+ await writeFile(filePath, templateFor(locale, slug, pubDate, heroImage), 'utf8');
122
+ created.push(filePath);
123
+ }
124
+
125
+ if (created.length > 0) {
126
+ console.log('Created files:');
127
+ for (const filePath of created) console.log(`- ${filePath}`);
128
+ }
129
+
130
+ if (skipped.length > 0) {
131
+ console.log('Skipped existing files:');
132
+ for (const filePath of skipped) console.log(`- ${filePath}`);
133
+ }
134
+ }
135
+
136
+ main().catch((error) => {
137
+ console.error(error);
138
+ process.exit(1);
139
+ });
@@ -0,0 +1,177 @@
1
+ ---
2
+ // Import the global.css file here so that it is included on
3
+ // all pages through the use of the <BaseHead /> component.
4
+ import '../styles/global.css';
5
+ import type { ImageMetadata } from 'astro';
6
+ import FallbackImage from '../assets/theme/placeholders/theme-placeholder-1.jpg';
7
+ import { SITE_AUTHOR, SITE_TITLE, SITE_URL } from '../consts';
8
+ import {
9
+ DEFAULT_LOCALE,
10
+ SUPPORTED_LOCALES,
11
+ alternatePathForLocale,
12
+ isLocale,
13
+ stripLocaleFromPath,
14
+ } from '../i18n/config';
15
+
16
+ interface Props {
17
+ title: string;
18
+ description: string;
19
+ image?: ImageMetadata;
20
+ pageType?: 'website' | 'article';
21
+ publishedTime?: Date;
22
+ modifiedTime?: Date;
23
+ author?: string;
24
+ tags?: string[];
25
+ schema?: Record<string, unknown> | Record<string, unknown>[];
26
+ noindex?: boolean;
27
+ }
28
+
29
+ const siteURL = Astro.site ?? new URL(SITE_URL);
30
+ const canonicalURL = new URL(Astro.url.pathname, siteURL);
31
+
32
+ const {
33
+ title,
34
+ description,
35
+ image = FallbackImage,
36
+ pageType = 'website',
37
+ publishedTime,
38
+ modifiedTime,
39
+ author = SITE_AUTHOR,
40
+ tags,
41
+ schema,
42
+ noindex = false,
43
+ } = Astro.props;
44
+
45
+ const imageURL = new URL(image.src, siteURL).toString();
46
+ const robotsContent = noindex ? 'noindex, nofollow' : 'index, follow, max-image-preview:large';
47
+ const isArticle = pageType === 'article';
48
+ const pathParts = Astro.url.pathname.split('/').filter(Boolean);
49
+ const currentLocale = pathParts.length > 0 && isLocale(pathParts[0]) ? pathParts[0] : DEFAULT_LOCALE;
50
+ const rssURL = new URL(`/${currentLocale}/rss.xml`, siteURL);
51
+ const localeSubpath = stripLocaleFromPath(Astro.url.pathname, currentLocale);
52
+ const alternatePaths = SUPPORTED_LOCALES.map((locale) => ({
53
+ locale,
54
+ href: new URL(alternatePathForLocale(locale, localeSubpath), siteURL).toString(),
55
+ }));
56
+ const xDefaultHref = new URL(alternatePathForLocale(DEFAULT_LOCALE, localeSubpath), siteURL);
57
+
58
+ const OG_LOCALE: Record<string, string> = {
59
+ en: 'en_US',
60
+ ja: 'ja_JP',
61
+ ko: 'ko_KR',
62
+ es: 'es_ES',
63
+ zh: 'zh_CN',
64
+ };
65
+ const ogLocale = OG_LOCALE[currentLocale] ?? 'en_US';
66
+ const ogLocaleAlternates = SUPPORTED_LOCALES.filter((l) => l !== currentLocale).map((l) => OG_LOCALE[l]);
67
+
68
+ const websiteSchema = {
69
+ '@context': 'https://schema.org',
70
+ '@type': 'WebSite',
71
+ name: SITE_TITLE,
72
+ url: siteURL.toString(),
73
+ description,
74
+ publisher: {
75
+ '@type': 'Person',
76
+ name: SITE_AUTHOR,
77
+ },
78
+ };
79
+
80
+ const personSchema = {
81
+ '@context': 'https://schema.org',
82
+ '@type': 'Person',
83
+ name: SITE_AUTHOR,
84
+ url: siteURL.toString(),
85
+ };
86
+
87
+ const articleSchema = isArticle
88
+ ? {
89
+ '@context': 'https://schema.org',
90
+ '@type': 'BlogPosting',
91
+ headline: title,
92
+ description,
93
+ image: [imageURL],
94
+ author: {
95
+ '@type': 'Person',
96
+ name: author,
97
+ },
98
+ publisher: {
99
+ '@type': 'Person',
100
+ name: SITE_AUTHOR,
101
+ },
102
+ mainEntityOfPage: canonicalURL.toString(),
103
+ datePublished: publishedTime?.toISOString(),
104
+ dateModified: (modifiedTime ?? publishedTime)?.toISOString(),
105
+ keywords: tags?.length ? tags.join(', ') : undefined,
106
+ }
107
+ : undefined;
108
+
109
+ const extraSchemas = schema ? (Array.isArray(schema) ? schema : [schema]) : [];
110
+ const jsonLdSchemas = [websiteSchema, personSchema, articleSchema, ...extraSchemas].filter(Boolean);
111
+ ---
112
+
113
+ <!-- Global Metadata -->
114
+ <meta charset="utf-8" />
115
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
116
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
117
+ <link rel="icon" href="/favicon.ico" />
118
+ <link rel="sitemap" href="/sitemap-index.xml" />
119
+ <link
120
+ rel="alternate"
121
+ type="application/rss+xml"
122
+ title={SITE_TITLE}
123
+ href={rssURL}
124
+ />
125
+ <meta name="generator" content={Astro.generator} />
126
+ <meta name="robots" content={robotsContent} />
127
+ <meta name="author" content={author} />
128
+
129
+ <!-- Font preloads -->
130
+ <link rel="preload" href="/fonts/atkinson-regular.woff" as="font" type="font/woff" crossorigin />
131
+ <link rel="preload" href="/fonts/atkinson-bold.woff" as="font" type="font/woff" crossorigin />
132
+
133
+ <!-- Canonical URL -->
134
+ <link rel="canonical" href={canonicalURL} />
135
+ {
136
+ alternatePaths.map((alternate) => (
137
+ <link rel="alternate" hreflang={alternate.locale} href={alternate.href} />
138
+ ))
139
+ }
140
+ <link rel="alternate" hreflang="x-default" href={xDefaultHref} />
141
+
142
+ <!-- Primary Meta Tags -->
143
+ <title>{title}</title>
144
+ <meta name="title" content={title} />
145
+ <meta name="description" content={description} />
146
+ {tags && tags.length > 0 && <meta name="keywords" content={tags.join(', ')} />}
147
+
148
+ <!-- Open Graph / Facebook -->
149
+ <meta property="og:type" content={isArticle ? 'article' : 'website'} />
150
+ <meta property="og:url" content={canonicalURL} />
151
+ <meta property="og:title" content={title} />
152
+ <meta property="og:description" content={description} />
153
+ <meta property="og:image" content={imageURL} />
154
+ {typeof image?.width === 'number' && <meta property="og:image:width" content={String(image.width)} />}
155
+ {typeof image?.height === 'number' && <meta property="og:image:height" content={String(image.height)} />}
156
+ <meta property="og:locale" content={ogLocale} />
157
+ {ogLocaleAlternates.map((loc) => (
158
+ <meta property="og:locale:alternate" content={loc} />
159
+ ))}
160
+ {isArticle && publishedTime && <meta property="article:published_time" content={publishedTime.toISOString()} />}
161
+ {isArticle && (
162
+ <meta property="article:modified_time" content={(modifiedTime ?? publishedTime)?.toISOString()} />
163
+ )}
164
+ {isArticle && <meta property="article:author" content={author} />}
165
+
166
+ <!-- Twitter -->
167
+ <meta name="twitter:card" content="summary_large_image" />
168
+ <meta name="twitter:url" content={canonicalURL} />
169
+ <meta name="twitter:title" content={title} />
170
+ <meta name="twitter:description" content={description} />
171
+ <meta name="twitter:image" content={imageURL} />
172
+
173
+ {
174
+ jsonLdSchemas.map((schemaItem) => (
175
+ <script is:inline type="application/ld+json" set:html={JSON.stringify(schemaItem)} />
176
+ ))
177
+ }
@@ -0,0 +1,74 @@
1
+ ---
2
+ import { SITE_TAGLINE, SITE_TITLE } from '@anglefeint/site-config/site';
3
+ import { SOCIAL_LINKS } from '@anglefeint/site-config/social';
4
+
5
+ const today = new Date();
6
+ interface Props {
7
+ tagline?: string;
8
+ /** Show Blade Runner scanlines overlay on mesh-page (header/footer only) */
9
+ scanlines?: boolean;
10
+ }
11
+
12
+ const { tagline = SITE_TAGLINE, scanlines = false } = Astro.props as Props;
13
+ ---
14
+
15
+ <footer>
16
+ &copy; {today.getFullYear()} {SITE_TITLE}. {tagline}
17
+ {SOCIAL_LINKS.length > 0 && (
18
+ <div class="social-links">
19
+ {SOCIAL_LINKS.map((link) => (
20
+ <a href={link.href} target="_blank" rel="noopener noreferrer">
21
+ <span class="sr-only">{link.label}</span>
22
+ {link.icon === 'mastodon' && (
23
+ <svg viewBox="0 0 16 16" aria-hidden="true" width="32" height="32">
24
+ <path
25
+ fill="currentColor"
26
+ d="M11.19 12.195c2.016-.24 3.77-1.475 3.99-2.603.348-1.778.32-4.339.32-4.339 0-3.47-2.286-4.488-2.286-4.488C12.062.238 10.083.017 8.027 0h-.05C5.92.017 3.942.238 2.79.765c0 0-2.285 1.017-2.285 4.488l-.002.662c-.004.64-.007 1.35.011 2.091.083 3.394.626 6.74 3.78 7.57 1.454.383 2.703.463 3.709.408 1.823-.1 2.847-.647 2.847-.647l-.06-1.317s-1.303.41-2.767.36c-1.45-.05-2.98-.156-3.215-1.928a3.614 3.614 0 0 1-.033-.496s1.424.346 3.228.428c1.103.05 2.137-.064 3.188-.189zm1.613-2.47H11.13v-4.08c0-.859-.364-1.295-1.091-1.295-.804 0-1.207.517-1.207 1.541v2.233H7.168V5.89c0-1.024-.403-1.541-1.207-1.541-.727 0-1.091.436-1.091 1.296v4.079H3.197V5.522c0-.859.22-1.541.66-2.046.456-.505 1.052-.764 1.793-.764.856 0 1.504.328 1.933.983L8 4.39l.417-.695c.429-.655 1.077-.983 1.934-.983.74 0 1.336.259 1.791.764.442.505.661 1.187.661 2.046v4.203z"
27
+ />
28
+ </svg>
29
+ )}
30
+ {link.icon === 'twitter' && (
31
+ <svg viewBox="0 0 16 16" aria-hidden="true" width="32" height="32">
32
+ <path
33
+ fill="currentColor"
34
+ d="M5.026 15c6.038 0 9.341-5.003 9.341-9.334 0-.14 0-.282-.006-.422A6.685 6.685 0 0 0 16 3.542a6.658 6.658 0 0 1-1.889.518 3.301 3.301 0 0 0 1.447-1.817 6.533 6.533 0 0 1-2.087.793A3.286 3.286 0 0 0 7.875 6.03a9.325 9.325 0 0 1-6.767-3.429 3.289 3.289 0 0 0 1.018 4.382A3.323 3.323 0 0 1 .64 6.575v.045a3.288 3.288 0 0 0 2.632 3.218 3.203 3.203 0 0 1-.865.115 3.23 3.23 0 0 1-.614-.057 3.283 3.283 0 0 0 3.067 2.277A6.588 6.588 0 0 1 .78 13.58a6.32 6.32 0 0 1-.78-.045A9.344 9.344 0 0 0 5.026 15z"
35
+ />
36
+ </svg>
37
+ )}
38
+ {link.icon === 'github' && (
39
+ <svg viewBox="0 0 16 16" aria-hidden="true" width="32" height="32">
40
+ <path
41
+ fill="currentColor"
42
+ d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"
43
+ />
44
+ </svg>
45
+ )}
46
+ {!link.icon && <span>{link.label}</span>}
47
+ </a>
48
+ ))}
49
+ </div>
50
+ )}
51
+ {scanlines && <div class="mesh-scanlines" aria-hidden="true"></div>}
52
+ </footer>
53
+ <style>
54
+ footer {
55
+ padding: 2em 1em 3em 1em;
56
+ background: var(--chrome-bg, var(--bg));
57
+ border-top: 1px solid var(--chrome-border, rgb(var(--border)));
58
+ color: var(--chrome-text-muted, rgb(var(--text-muted)));
59
+ text-align: center;
60
+ }
61
+ .social-links {
62
+ display: flex;
63
+ justify-content: center;
64
+ gap: 1em;
65
+ margin-top: 1em;
66
+ }
67
+ .social-links a {
68
+ text-decoration: none;
69
+ color: var(--chrome-text-muted, rgb(var(--text-muted)));
70
+ }
71
+ .social-links a:hover {
72
+ color: var(--chrome-link-hover, var(--chrome-link, rgb(var(--text))));
73
+ }
74
+ </style>
@@ -0,0 +1,17 @@
1
+ ---
2
+ interface Props {
3
+ date: Date;
4
+ }
5
+
6
+ const { date } = Astro.props;
7
+ ---
8
+
9
+ <time datetime={date.toISOString()}>
10
+ {
11
+ date.toLocaleDateString('en-us', {
12
+ year: 'numeric',
13
+ month: 'short',
14
+ day: 'numeric',
15
+ })
16
+ }
17
+ </time>