@anglefeint/astro-theme 0.1.39 → 0.2.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.
package/README.md CHANGED
@@ -42,6 +42,8 @@ For content schema:
42
42
  export { collections } from '@anglefeint/astro-theme/content-schema';
43
43
  ```
44
44
 
45
+ `sourceLinks` in blog frontmatter accepts standard `http(s)` URLs and bare domains such as `github.com/anglefeint/astro-theme-anglefeint`. Bare domains are normalized to `https://...` during schema parsing.
46
+
45
47
  ## Site Config Injection
46
48
 
47
49
  This package reads site-specific config from alias imports:
@@ -68,10 +70,10 @@ Examples:
68
70
  anglefeint-new-post my-first-post
69
71
 
70
72
  # create post only for selected locales
71
- anglefeint-new-post my-first-post --locales en,ja
73
+ anglefeint-new-post my-first-post --locales en,fr
72
74
 
73
75
  # or via environment variable
74
- ANGLEFEINT_LOCALES=en,ja anglefeint-new-post my-first-post
76
+ ANGLEFEINT_LOCALES=en,fr anglefeint-new-post my-first-post
75
77
 
76
78
  # create a custom page with theme variant
77
79
  anglefeint-new-page projects --theme base
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anglefeint/astro-theme",
3
- "version": "0.1.39",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Anglefeint core theme package for Astro",
6
6
  "keywords": [
@@ -1,113 +1,350 @@
1
1
  import { mkdir, writeFile, access } from 'node:fs/promises';
2
2
  import { constants } from 'node:fs';
3
+ import { execFile as execFileCallback } from 'node:child_process';
3
4
  import { readFile } from 'node:fs/promises';
4
5
  import path from 'node:path';
5
- import { SUPPORTED_LOCALES } from './i18n/locales.mjs';
6
+ import { promisify } from 'node:util';
6
7
  import {
7
- buildNewPostTemplate,
8
- loadDefaultCovers,
9
- parseNewPostArgs,
10
- pickDefaultCoverBySlug,
11
- resolveLocales,
12
- usageNewPost,
13
- validatePostSlug,
8
+ buildNewPostTemplate,
9
+ loadDefaultCovers,
10
+ parseNewPostArgs,
11
+ pickDefaultCoverBySlug,
12
+ resolveLocales,
13
+ usageNewPost,
14
+ validatePostSlug,
14
15
  } from './scaffold/new-post.mjs';
15
16
 
16
17
  const CONTENT_ROOT = path.resolve(process.cwd(), 'src/content/blog');
17
18
  const DEFAULT_COVERS_ROOT = path.resolve(process.cwd(), 'src/assets/blog/default-covers');
18
19
  const SITE_CONFIG_PATH = path.resolve(process.cwd(), 'src/site.config.ts');
20
+ const FALLBACK_LOCALES = ['en'];
21
+ const execFile = promisify(execFileCallback);
19
22
 
20
23
  async function exists(filePath) {
21
- try {
22
- await access(filePath, constants.F_OK);
23
- return true;
24
- } catch {
25
- return false;
26
- }
24
+ try {
25
+ await access(filePath, constants.F_OK);
26
+ return true;
27
+ } catch {
28
+ return false;
29
+ }
27
30
  }
28
31
 
29
- function parseSupportedLocalesFromConfig(configSource) {
30
- const localeArrayPattern = /supportedLocales\s*:\s*\[([\s\S]*?)\]/g;
31
- let match = null;
32
- let picked = '';
33
-
34
- while (true) {
35
- const next = localeArrayPattern.exec(configSource);
36
- if (!next) break;
37
- const body = next[1] ?? '';
38
- if (body.includes("'") || body.includes('"')) {
39
- match = next;
40
- picked = body;
41
- }
42
- }
43
-
44
- if (!match || !picked) return [];
45
- const localeMatches = picked.match(/['"]([a-z]{2,3}(?:-[a-z0-9]+)?)['"]/gi) || [];
46
- return localeMatches.map((token) => token.slice(1, -1).toLowerCase());
32
+ function extractObjectLiteral(source, marker) {
33
+ const markerIndex = source.indexOf(marker);
34
+ if (markerIndex === -1) return '';
35
+
36
+ const start = source.indexOf('{', markerIndex);
37
+ return extractObjectLiteralFromIndex(source, start);
38
+ }
39
+
40
+ function extractObjectLiteralFromIndex(source, start) {
41
+ if (start === -1) return '';
42
+
43
+ let depth = 0;
44
+ let inString = false;
45
+ let quote = '';
46
+ let escaped = false;
47
+
48
+ for (let index = start; index < source.length; index += 1) {
49
+ const char = source[index];
50
+
51
+ if (inString) {
52
+ if (escaped) {
53
+ escaped = false;
54
+ continue;
55
+ }
56
+ if (char === '\\') {
57
+ escaped = true;
58
+ continue;
59
+ }
60
+ if (char === quote) {
61
+ inString = false;
62
+ quote = '';
63
+ }
64
+ continue;
65
+ }
66
+
67
+ if (char === '"' || char === "'" || char === '`') {
68
+ inString = true;
69
+ quote = char;
70
+ continue;
71
+ }
72
+
73
+ if (char === '{') depth += 1;
74
+ if (char === '}') {
75
+ depth -= 1;
76
+ if (depth === 0) return source.slice(start, index + 1);
77
+ }
78
+ }
79
+
80
+ return '';
81
+ }
82
+
83
+ function extractThemeConfigObject(configSource) {
84
+ const themeConfigMarkers = ['export const THEME_CONFIG', 'const THEME_CONFIG'];
85
+
86
+ for (const marker of themeConfigMarkers) {
87
+ const markerIndex = configSource.indexOf(marker);
88
+ if (markerIndex === -1) continue;
89
+
90
+ const assignmentIndex = configSource.indexOf('=', markerIndex);
91
+ if (assignmentIndex === -1) continue;
92
+
93
+ const objectStart = configSource.indexOf('{', assignmentIndex);
94
+ if (objectStart === -1) continue;
95
+
96
+ let depth = 0;
97
+ let inString = false;
98
+ let quote = '';
99
+ let escaped = false;
100
+
101
+ for (let index = objectStart; index < configSource.length; index += 1) {
102
+ const char = configSource[index];
103
+
104
+ if (inString) {
105
+ if (escaped) {
106
+ escaped = false;
107
+ continue;
108
+ }
109
+ if (char === '\\') {
110
+ escaped = true;
111
+ continue;
112
+ }
113
+ if (char === quote) {
114
+ inString = false;
115
+ quote = '';
116
+ }
117
+ continue;
118
+ }
119
+
120
+ if (char === '"' || char === "'" || char === '`') {
121
+ inString = true;
122
+ quote = char;
123
+ continue;
124
+ }
125
+
126
+ if (char === '{') depth += 1;
127
+ if (char === '}') {
128
+ depth -= 1;
129
+ if (depth === 0) return configSource.slice(objectStart, index + 1);
130
+ }
131
+ }
132
+ }
133
+
134
+ return '';
135
+ }
136
+
137
+ function localeKeyFromToken(key) {
138
+ return /^[a-z]{2,3}(?:-[a-z0-9]+)?$/i.test(key) ? key.toLowerCase() : '';
139
+ }
140
+
141
+ function localeObjectIsEnabled(localeObjectSource) {
142
+ const metaObject = extractObjectLiteral(localeObjectSource, 'meta');
143
+ if (!metaObject) return true;
144
+
145
+ return !/\benabled\s*:\s*false\b/.test(metaObject);
146
+ }
147
+
148
+ function parseLocaleRegistryKeys(configSource) {
149
+ const themeConfigObject = extractThemeConfigObject(configSource);
150
+ if (!themeConfigObject) return [];
151
+
152
+ const i18nObject = extractObjectLiteral(themeConfigObject, 'i18n');
153
+ if (!i18nObject) return [];
154
+
155
+ const localesObject = extractObjectLiteral(i18nObject, 'locales');
156
+ if (!localesObject) return [];
157
+
158
+ const keys = [];
159
+ let depth = 0;
160
+
161
+ for (let index = 0; index < localesObject.length; index += 1) {
162
+ const char = localesObject[index];
163
+
164
+ if (char === '{') {
165
+ depth += 1;
166
+ if (depth === 1) continue;
167
+ }
168
+ if (char === '}') {
169
+ depth -= 1;
170
+ continue;
171
+ }
172
+
173
+ if (depth !== 1) continue;
174
+
175
+ if (char === '"' || char === "'" || char === '`') {
176
+ let key = '';
177
+ let escaped = false;
178
+ const quote = char;
179
+ let cursor = index + 1;
180
+
181
+ for (; cursor < localesObject.length; cursor += 1) {
182
+ const next = localesObject[cursor];
183
+ if (escaped) {
184
+ key += next;
185
+ escaped = false;
186
+ continue;
187
+ }
188
+ if (next === '\\') {
189
+ escaped = true;
190
+ continue;
191
+ }
192
+ if (next === quote) break;
193
+ key += next;
194
+ }
195
+
196
+ let colonIndex = cursor + 1;
197
+ while (colonIndex < localesObject.length && /\s/.test(localesObject[colonIndex])) {
198
+ colonIndex += 1;
199
+ }
200
+
201
+ const normalizedKey = localeKeyFromToken(key);
202
+ if (localesObject[colonIndex] === ':' && normalizedKey) {
203
+ const localeObject = extractObjectLiteralFromIndex(
204
+ localesObject,
205
+ localesObject.indexOf('{', colonIndex)
206
+ );
207
+ if (!localeObject || localeObjectIsEnabled(localeObject)) {
208
+ keys.push(normalizedKey);
209
+ }
210
+ }
211
+
212
+ index = cursor;
213
+ continue;
214
+ }
215
+
216
+ if (/[A-Za-z_$]/.test(char)) {
217
+ let cursor = index + 1;
218
+ while (cursor < localesObject.length && /[A-Za-z0-9_$-]/.test(localesObject[cursor])) {
219
+ cursor += 1;
220
+ }
221
+ const key = localesObject.slice(index, cursor).trim();
222
+
223
+ let colonIndex = cursor;
224
+ while (colonIndex < localesObject.length && /\s/.test(localesObject[colonIndex])) {
225
+ colonIndex += 1;
226
+ }
227
+
228
+ const normalizedKey = localeKeyFromToken(key);
229
+ if (localesObject[colonIndex] === ':' && normalizedKey) {
230
+ const localeObject = extractObjectLiteralFromIndex(
231
+ localesObject,
232
+ localesObject.indexOf('{', colonIndex)
233
+ );
234
+ if (!localeObject || localeObjectIsEnabled(localeObject)) {
235
+ keys.push(normalizedKey);
236
+ }
237
+ }
238
+
239
+ index = cursor - 1;
240
+ }
241
+ }
242
+
243
+ return Array.from(new Set(keys));
244
+ }
245
+
246
+ async function loadNormalizedProjectI18n() {
247
+ const loaderSource = `
248
+ import { pathToFileURL } from 'node:url';
249
+
250
+ const mod = await import(pathToFileURL(process.argv[1]).href);
251
+ const config = mod.THEME_CONFIG?.i18n;
252
+
253
+ if (!config) process.exit(2);
254
+
255
+ const normalized =
256
+ typeof mod.normalizeI18nConfig === 'function' ? mod.normalizeI18nConfig(config) : config;
257
+
258
+ process.stdout.write(JSON.stringify(normalized));
259
+ `;
260
+
261
+ const { stdout } = await execFile(
262
+ process.execPath,
263
+ ['--experimental-strip-types', '--input-type=module', '--eval', loaderSource, SITE_CONFIG_PATH],
264
+ {
265
+ cwd: process.cwd(),
266
+ encoding: 'utf8',
267
+ }
268
+ );
269
+
270
+ return JSON.parse(stdout);
47
271
  }
48
272
 
49
273
  async function resolveProjectLocales() {
50
- try {
51
- const configSource = await readFile(SITE_CONFIG_PATH, 'utf8');
52
- const locales = parseSupportedLocalesFromConfig(configSource);
53
- if (locales.length === 0) return [];
54
- return Array.from(new Set(locales));
55
- } catch {
56
- return [];
57
- }
274
+ try {
275
+ const normalized = await loadNormalizedProjectI18n();
276
+ const locales = Object.values(normalized.locales ?? {})
277
+ .filter((locale) => locale?.meta?.enabled)
278
+ .map((locale) => locale.code)
279
+ .filter((locale) => typeof locale === 'string');
280
+ if (locales.length > 0) {
281
+ return Array.from(new Set(locales));
282
+ }
283
+ } catch {
284
+ // Fall through to the source parser fallback for environments without TS loading support.
285
+ }
286
+
287
+ try {
288
+ const configSource = await readFile(SITE_CONFIG_PATH, 'utf8');
289
+ const locales = parseLocaleRegistryKeys(configSource);
290
+ if (locales.length === 0) return [];
291
+ return Array.from(new Set(locales));
292
+ } catch {
293
+ return [];
294
+ }
58
295
  }
59
296
 
60
297
  async function main() {
61
- const { slug, locales: cliLocales } = parseNewPostArgs(process.argv);
62
- if (!slug) {
63
- console.error(usageNewPost());
64
- process.exit(1);
65
- }
66
-
67
- if (!validatePostSlug(slug)) {
68
- console.error('Invalid slug. Use lowercase letters, numbers, and hyphens only.');
69
- process.exit(1);
70
- }
71
-
72
- const pubDate = new Date().toISOString().slice(0, 10);
73
- const defaultCovers = await loadDefaultCovers(DEFAULT_COVERS_ROOT);
74
- const projectLocales = await resolveProjectLocales();
75
- const locales = resolveLocales({
76
- cliLocales,
77
- envLocales: process.env.ANGLEFEINT_LOCALES ?? '',
78
- defaultLocales: projectLocales.length > 0 ? projectLocales : SUPPORTED_LOCALES,
79
- });
80
- const created = [];
81
- const skipped = [];
82
-
83
- for (const locale of locales) {
84
- const localeDir = path.join(CONTENT_ROOT, locale);
85
- const filePath = path.join(localeDir, `${slug}.md`);
86
- await mkdir(localeDir, { recursive: true });
87
-
88
- if (await exists(filePath)) {
89
- skipped.push(filePath);
90
- continue;
91
- }
92
-
93
- const heroImage = pickDefaultCoverBySlug(slug, localeDir, defaultCovers);
94
-
95
- await writeFile(filePath, buildNewPostTemplate(locale, slug, pubDate, heroImage), 'utf8');
96
- created.push(filePath);
97
- }
98
-
99
- if (created.length > 0) {
100
- console.log('Created files:');
101
- for (const filePath of created) console.log(`- ${filePath}`);
102
- }
103
-
104
- if (skipped.length > 0) {
105
- console.log('Skipped existing files:');
106
- for (const filePath of skipped) console.log(`- ${filePath}`);
107
- }
298
+ const { slug, locales: cliLocales } = parseNewPostArgs(process.argv);
299
+ if (!slug) {
300
+ console.error(usageNewPost());
301
+ process.exit(1);
302
+ }
303
+
304
+ if (!validatePostSlug(slug)) {
305
+ console.error('Invalid slug. Use lowercase letters, numbers, and hyphens only.');
306
+ process.exit(1);
307
+ }
308
+
309
+ const pubDate = new Date().toISOString().slice(0, 10);
310
+ const defaultCovers = await loadDefaultCovers(DEFAULT_COVERS_ROOT);
311
+ const projectLocales = await resolveProjectLocales();
312
+ const locales = resolveLocales({
313
+ cliLocales,
314
+ envLocales: process.env.ANGLEFEINT_LOCALES ?? '',
315
+ defaultLocales: projectLocales.length > 0 ? projectLocales : FALLBACK_LOCALES,
316
+ });
317
+ const created = [];
318
+ const skipped = [];
319
+
320
+ for (const locale of locales) {
321
+ const localeDir = path.join(CONTENT_ROOT, locale);
322
+ const filePath = path.join(localeDir, `${slug}.md`);
323
+ await mkdir(localeDir, { recursive: true });
324
+
325
+ if (await exists(filePath)) {
326
+ skipped.push(filePath);
327
+ continue;
328
+ }
329
+
330
+ const heroImage = pickDefaultCoverBySlug(slug, localeDir, defaultCovers);
331
+
332
+ await writeFile(filePath, buildNewPostTemplate(locale, slug, pubDate, heroImage), 'utf8');
333
+ created.push(filePath);
334
+ }
335
+
336
+ if (created.length > 0) {
337
+ console.log('Created files:');
338
+ for (const filePath of created) console.log(`- ${filePath}`);
339
+ }
340
+
341
+ if (skipped.length > 0) {
342
+ console.log('Skipped existing files:');
343
+ for (const filePath of skipped) console.log(`- ${filePath}`);
344
+ }
108
345
  }
109
346
 
110
347
  main().catch((error) => {
111
- console.error(error);
112
- process.exit(1);
348
+ console.error(error);
349
+ process.exit(1);
113
350
  });
@@ -9,6 +9,8 @@ import {
9
9
  DEFAULT_LOCALE,
10
10
  SUPPORTED_LOCALES,
11
11
  alternatePathForLocale,
12
+ getLocaleHreflang,
13
+ getLocaleOgLocale,
12
14
  isLocale,
13
15
  stripLocaleFromPath,
14
16
  } from '@anglefeint/site-i18n/config';
@@ -52,21 +54,14 @@ const rssURL = new URL(`/${currentLocale}/rss.xml`, siteURL);
52
54
  const localeSubpath = stripLocaleFromPath(Astro.url.pathname, currentLocale);
53
55
  const alternatePaths = SUPPORTED_LOCALES.map((locale) => ({
54
56
  locale,
57
+ hreflang: getLocaleHreflang(locale),
55
58
  href: new URL(alternatePathForLocale(locale, localeSubpath), siteURL).toString(),
56
59
  }));
57
60
  const xDefaultHref = new URL(alternatePathForLocale(DEFAULT_LOCALE, localeSubpath), siteURL);
58
-
59
- const OG_LOCALE: Record<string, string> = {
60
- en: 'en_US',
61
- ja: 'ja_JP',
62
- ko: 'ko_KR',
63
- es: 'es_ES',
64
- zh: 'zh_CN',
65
- };
66
- const ogLocale = OG_LOCALE[currentLocale] ?? 'en_US';
67
- const ogLocaleAlternates = SUPPORTED_LOCALES.filter((l) => l !== currentLocale).map(
68
- (l) => OG_LOCALE[l]
69
- );
61
+ const ogLocale = getLocaleOgLocale(currentLocale);
62
+ const ogLocaleAlternates = SUPPORTED_LOCALES.filter((locale) => locale !== currentLocale)
63
+ .map((locale) => getLocaleOgLocale(locale))
64
+ .filter(Boolean);
70
65
  const articleModifiedISO = (modifiedTime ?? publishedTime)?.toISOString();
71
66
 
72
67
  const websiteSchema = {
@@ -133,7 +128,7 @@ const jsonLdSchemas = [websiteSchema, personSchema, articleSchema, ...extraSchem
133
128
  <link rel="canonical" href={canonicalURL} />
134
129
  {
135
130
  alternatePaths.map((alternate) => (
136
- <link rel="alternate" hreflang={alternate.locale} href={alternate.href} />
131
+ <link rel="alternate" hreflang={alternate.hreflang} href={alternate.href} />
137
132
  ))
138
133
  }
139
134
  <link rel="alternate" hreflang="x-default" href={xDefaultHref} />
@@ -160,7 +155,7 @@ const jsonLdSchemas = [websiteSchema, personSchema, articleSchema, ...extraSchem
160
155
  <meta property="og:image:height" content={String(image.height)} />
161
156
  )
162
157
  }
163
- <meta property="og:locale" content={ogLocale} />
158
+ {ogLocale && <meta property="og:locale" content={ogLocale} />}
164
159
  {ogLocaleAlternates.map((loc) => <meta property="og:locale:alternate" content={loc} />)}
165
160
  {
166
161
  isArticle && publishedTime && (
@@ -0,0 +1,48 @@
1
+ ---
2
+ type GiscusComments = {
3
+ REPO: string;
4
+ REPO_ID: string;
5
+ CATEGORY: string;
6
+ CATEGORY_ID: string;
7
+ MAPPING: string;
8
+ TERM: string;
9
+ NUMBER: string;
10
+ STRICT: string;
11
+ REACTIONS_ENABLED: string;
12
+ EMIT_METADATA: string;
13
+ INPUT_POSITION: string;
14
+ THEME: string;
15
+ LANG: string;
16
+ LOADING: string;
17
+ CROSSORIGIN: string;
18
+ };
19
+
20
+ type Props = {
21
+ comments: GiscusComments;
22
+ resolvedLocale: string;
23
+ };
24
+
25
+ const { comments, resolvedLocale } = Astro.props;
26
+ const normalizedCommentTerm = comments.TERM.trim();
27
+ const normalizedCommentNumber = comments.NUMBER.trim();
28
+ ---
29
+
30
+ <script
31
+ is:inline
32
+ src="https://giscus.app/client.js"
33
+ data-repo={comments.REPO}
34
+ data-repo-id={comments.REPO_ID}
35
+ data-category={comments.CATEGORY}
36
+ data-category-id={comments.CATEGORY_ID}
37
+ data-mapping={comments.MAPPING}
38
+ data-term={comments.MAPPING === 'specific' ? normalizedCommentTerm : undefined}
39
+ data-number={comments.MAPPING === 'number' ? normalizedCommentNumber : undefined}
40
+ data-strict={comments.STRICT}
41
+ data-reactions-enabled={comments.REACTIONS_ENABLED}
42
+ data-emit-metadata={comments.EMIT_METADATA}
43
+ data-input-position={comments.INPUT_POSITION}
44
+ data-theme={comments.THEME}
45
+ data-lang={comments.LANG || resolvedLocale}
46
+ data-loading={comments.LOADING}
47
+ crossorigin={comments.CROSSORIGIN}
48
+ async></script>
@@ -2,33 +2,58 @@ import { defineCollection } from 'astro:content';
2
2
  import { glob } from 'astro/loaders';
3
3
  import { z } from 'astro/zod';
4
4
 
5
+ const ABSOLUTE_URL_SCHEME_REGEX = /^[a-z][a-z\d+.-]*:/i;
6
+
7
+ function normalizeSourceLink(value: string): string {
8
+ const trimmed = value.trim();
9
+ return ABSOLUTE_URL_SCHEME_REGEX.test(trimmed) ? trimmed : `https://${trimmed}`;
10
+ }
11
+
12
+ function isValidSourceLink(value: string): boolean {
13
+ try {
14
+ const url = new URL(value);
15
+ return url.protocol === 'http:' || url.protocol === 'https:';
16
+ } catch {
17
+ return false;
18
+ }
19
+ }
20
+
21
+ const sourceLinkSchema = z
22
+ .string()
23
+ .trim()
24
+ .min(1)
25
+ .transform(normalizeSourceLink)
26
+ .refine(isValidSourceLink, {
27
+ message: 'sourceLinks entries must be valid HTTP(S) URLs or bare domains.',
28
+ });
29
+
5
30
  const blog = defineCollection({
6
- // Load Markdown and MDX files in the `src/content/blog/` directory.
7
- loader: glob({ base: './src/content/blog', pattern: '**/*.{md,mdx}' }),
8
- // Type-check frontmatter using a schema
9
- schema: ({ image }) =>
10
- z.object({
11
- title: z.string(),
12
- subtitle: z.string().optional(),
13
- description: z.string(),
14
- // Transform string to Date object
15
- pubDate: z.coerce.date(),
16
- updatedDate: z.coerce.date().optional(),
17
- heroImage: image().optional(),
18
- context: z.string().optional(),
19
- readMinutes: z.number().int().positive().optional(),
20
- aiModel: z.string().optional(),
21
- aiMode: z.string().optional(),
22
- aiState: z.string().optional(),
23
- aiLatencyMs: z.number().int().nonnegative().optional(),
24
- aiConfidence: z.number().min(0).max(1).optional(),
25
- wordCount: z.number().int().nonnegative().optional(),
26
- tokenCount: z.number().int().nonnegative().optional(),
27
- author: z.string().optional(),
28
- tags: z.array(z.string()).optional(),
29
- canonicalTopic: z.string().optional(),
30
- sourceLinks: z.array(z.url()).optional(),
31
- }),
31
+ // Load Markdown and MDX files in the `src/content/blog/` directory.
32
+ loader: glob({ base: './src/content/blog', pattern: '**/*.{md,mdx}' }),
33
+ // Type-check frontmatter using a schema
34
+ schema: ({ image }) =>
35
+ z.object({
36
+ title: z.string(),
37
+ subtitle: z.string().optional(),
38
+ description: z.string(),
39
+ // Transform string to Date object
40
+ pubDate: z.coerce.date(),
41
+ updatedDate: z.coerce.date().optional(),
42
+ heroImage: image().optional(),
43
+ context: z.string().optional(),
44
+ readMinutes: z.number().int().positive().optional(),
45
+ aiModel: z.string().optional(),
46
+ aiMode: z.string().optional(),
47
+ aiState: z.string().optional(),
48
+ aiLatencyMs: z.number().int().nonnegative().optional(),
49
+ aiConfidence: z.number().min(0).max(1).optional(),
50
+ wordCount: z.number().int().nonnegative().optional(),
51
+ tokenCount: z.number().int().nonnegative().optional(),
52
+ author: z.string().optional(),
53
+ tags: z.array(z.string()).optional(),
54
+ canonicalTopic: z.string().optional(),
55
+ sourceLinks: z.array(sourceLinkSchema).optional(),
56
+ }),
32
57
  });
33
58
 
34
59
  export const collections = { blog };
@@ -1,50 +1,50 @@
1
1
  import {
2
- SUPPORTED_LOCALES as SUPPORTED_LOCALES_RUNTIME,
3
- DEFAULT_LOCALE as DEFAULT_LOCALE_RUNTIME,
2
+ SUPPORTED_LOCALES as SUPPORTED_LOCALES_RUNTIME,
3
+ DEFAULT_LOCALE as DEFAULT_LOCALE_RUNTIME,
4
4
  } from './locales.mjs';
5
5
 
6
- export type Locale = 'en' | 'ja' | 'ko' | 'es' | 'zh';
7
- export const SUPPORTED_LOCALES = SUPPORTED_LOCALES_RUNTIME as readonly Locale[];
8
- export const DEFAULT_LOCALE: Locale = DEFAULT_LOCALE_RUNTIME as Locale;
6
+ export type Locale = string;
7
+ export const SUPPORTED_LOCALES = SUPPORTED_LOCALES_RUNTIME as readonly string[];
8
+ export const DEFAULT_LOCALE: Locale = DEFAULT_LOCALE_RUNTIME;
9
9
 
10
- export const LOCALE_LABELS: Record<Locale, string> = {
11
- en: 'English',
12
- ja: '日本語',
13
- ko: '한국어',
14
- es: 'Español',
15
- zh: '中文',
10
+ export const LOCALE_LABELS: Record<string, string> = {
11
+ en: 'English',
12
+ ja: '日本語',
13
+ ko: '한국어',
14
+ es: 'Español',
15
+ zh: '中文',
16
16
  };
17
17
 
18
18
  export function isLocale(value: string): value is Locale {
19
- return (SUPPORTED_LOCALES as readonly string[]).includes(value);
19
+ return (SUPPORTED_LOCALES as readonly string[]).includes(value);
20
20
  }
21
21
 
22
22
  export function withLeadingSlash(path: string): string {
23
- if (!path) return '/';
24
- return path.startsWith('/') ? path : `/${path}`;
23
+ if (!path) return '/';
24
+ return path.startsWith('/') ? path : `/${path}`;
25
25
  }
26
26
 
27
- export function localePath(locale: Locale, path = '/'): string {
28
- const normalized = withLeadingSlash(path);
29
- if (normalized === '/') return `/${locale}/`;
30
- return `/${locale}${normalized.endsWith('/') ? normalized : `${normalized}/`}`;
27
+ export function localePath(locale: string, path = '/'): string {
28
+ const normalized = withLeadingSlash(path);
29
+ if (normalized === '/') return `/${locale}/`;
30
+ return `/${locale}${normalized.endsWith('/') ? normalized : `${normalized}/`}`;
31
31
  }
32
32
 
33
- export function stripLocaleFromPath(pathname: string, locale: Locale): string {
34
- const prefix = `/${locale}`;
35
- if (!pathname.startsWith(prefix)) return pathname;
36
- const withoutLocale = pathname.slice(prefix.length);
37
- return withoutLocale || '/';
33
+ export function stripLocaleFromPath(pathname: string, locale: string): string {
34
+ const prefix = `/${locale}`;
35
+ if (!pathname.startsWith(prefix)) return pathname;
36
+ const withoutLocale = pathname.slice(prefix.length);
37
+ return withoutLocale || '/';
38
38
  }
39
39
 
40
40
  export function blogIdToSlugAnyLocale(id: string): string {
41
- const parts = id.split('/');
42
- if (parts.length > 1 && isLocale(parts[0])) return parts.slice(1).join('/');
43
- return id;
41
+ const parts = id.split('/');
42
+ if (parts.length > 1 && isLocale(parts[0])) return parts.slice(1).join('/');
43
+ return id;
44
44
  }
45
45
 
46
46
  /** URL path for a locale's version of a page. For default locale home, returns / instead of /en/. */
47
- export function alternatePathForLocale(locale: Locale, subpath: string): string {
48
- if (locale === DEFAULT_LOCALE && (subpath === '/' || subpath === '')) return '/';
49
- return localePath(locale, subpath);
47
+ export function alternatePathForLocale(locale: string, subpath: string): string {
48
+ if (locale === DEFAULT_LOCALE && (subpath === '/' || subpath === '')) return '/';
49
+ return localePath(locale, subpath);
50
50
  }
@@ -1,3 +1,3 @@
1
+ // Fallback locale defaults for direct package consumers without a starter-level i18n registry.
1
2
  export const SUPPORTED_LOCALES = ['en', 'ja', 'ko', 'es', 'zh'];
2
3
  export const DEFAULT_LOCALE = 'en';
3
-
@@ -1,4 +1,4 @@
1
- import type { Locale } from './config';
1
+ import { DEFAULT_LOCALE, type Locale } from './config';
2
2
 
3
3
  export type Messages = {
4
4
  siteTitle: string;
@@ -70,7 +70,7 @@ export type Messages = {
70
70
  };
71
71
  };
72
72
 
73
- export const DEFAULT_MESSAGES: Record<Locale, Messages> = {
73
+ export const DEFAULT_MESSAGES: Record<string, Messages> = {
74
74
  en: {
75
75
  siteTitle: 'Angle Feint',
76
76
  siteDescription: 'Cinematic web interfaces and AI-era engineering essays.',
@@ -420,5 +420,5 @@ export const DEFAULT_MESSAGES: Record<Locale, Messages> = {
420
420
  };
421
421
 
422
422
  export function getMessages(locale: Locale): Messages {
423
- return DEFAULT_MESSAGES[locale];
423
+ return DEFAULT_MESSAGES[locale] ?? DEFAULT_MESSAGES[DEFAULT_LOCALE];
424
424
  }
@@ -4,6 +4,7 @@ import type { CollectionEntry } from 'astro:content';
4
4
  import themeRedqueen1 from '../assets/theme/red-queen/theme-redqueen1.webp';
5
5
  import themeRedqueen2 from '../assets/theme/red-queen/theme-redqueen2.gif';
6
6
  import FormattedDate from '../components/FormattedDate.astro';
7
+ import Giscus from '../components/Giscus.astro';
7
8
  import AiShell from './shells/AiShell.astro';
8
9
  import blogPostCssUrl from '../styles/blog-post.css?url';
9
10
  import { SITE_AUTHOR } from '@anglefeint/site-config/site';
@@ -335,25 +336,7 @@ const hasCommentsConfig = Boolean(
335
336
  {
336
337
  hasCommentsConfig && (
337
338
  <section class="prose ai-comments" aria-label={messages.blog.comments}>
338
- <script
339
- src="https://giscus.app/client.js"
340
- data-repo={comments.REPO}
341
- data-repo-id={comments.REPO_ID}
342
- data-category={comments.CATEGORY}
343
- data-category-id={comments.CATEGORY_ID}
344
- data-mapping={comments.MAPPING}
345
- data-term={comments.MAPPING === 'specific' ? normalizedCommentTerm : undefined}
346
- data-number={comments.MAPPING === 'number' ? normalizedCommentNumber : undefined}
347
- data-strict={comments.STRICT}
348
- data-reactions-enabled={comments.REACTIONS_ENABLED}
349
- data-emit-metadata={comments.EMIT_METADATA}
350
- data-input-position={comments.INPUT_POSITION}
351
- data-theme={comments.THEME}
352
- data-lang={comments.LANG || resolvedLocale}
353
- data-loading={comments.LOADING}
354
- crossorigin={comments.CROSSORIGIN}
355
- async
356
- />
339
+ <Giscus comments={comments} resolvedLocale={resolvedLocale} />
357
340
  </section>
358
341
  )
359
342
  }
@@ -1,102 +1,111 @@
1
1
  import path from 'node:path';
2
2
  import { readdir } from 'node:fs/promises';
3
- import { hashString, normalizePathForFrontmatter, toTitleFromSlug, validatePostSlug } from './shared.mjs';
3
+ import {
4
+ hashString,
5
+ normalizePathForFrontmatter,
6
+ toTitleFromSlug,
7
+ validatePostSlug,
8
+ } from './shared.mjs';
4
9
 
5
10
  export function usageNewPost() {
6
- return 'Usage: npm run new-post -- <slug> [--locales en,ja,ko,es,zh]';
11
+ return 'Usage: npm run new-post -- <slug> [--locales en,fr,...]';
7
12
  }
8
13
 
9
14
  export function parseNewPostArgs(argv) {
10
- const args = argv.slice(2);
11
- const positional = [];
12
- let locales = '';
15
+ const args = argv.slice(2);
16
+ const positional = [];
17
+ let locales = '';
13
18
 
14
- for (let i = 0; i < args.length; i += 1) {
15
- const token = args[i];
16
- if (token === '--locales') {
17
- locales = args[i + 1] ?? '';
18
- i += 1;
19
- continue;
20
- }
21
- positional.push(token);
22
- }
19
+ for (let i = 0; i < args.length; i += 1) {
20
+ const token = args[i];
21
+ if (token === '--locales') {
22
+ locales = args[i + 1] ?? '';
23
+ i += 1;
24
+ continue;
25
+ }
26
+ positional.push(token);
27
+ }
23
28
 
24
- return { slug: positional[0], locales };
29
+ return { slug: positional[0], locales };
25
30
  }
26
31
 
27
32
  export function resolveLocales({ cliLocales, envLocales, defaultLocales }) {
28
- const raw = cliLocales || envLocales || '';
29
- if (!raw) return [...defaultLocales];
33
+ const raw = cliLocales || envLocales || '';
34
+ if (!raw) return [...defaultLocales];
30
35
 
31
- const parsed = raw
32
- .split(',')
33
- .map((locale) => locale.trim())
34
- .filter(Boolean)
35
- .map((locale) => locale.toLowerCase());
36
+ const parsed = raw
37
+ .split(',')
38
+ .map((locale) => locale.trim())
39
+ .filter(Boolean)
40
+ .map((locale) => locale.toLowerCase());
36
41
 
37
- if (parsed.length === 0) {
38
- throw new Error('Locales list is empty. Example: --locales en,ja,ko');
39
- }
42
+ if (parsed.length === 0) {
43
+ throw new Error('Locales list is empty. Example: --locales en,fr');
44
+ }
40
45
 
41
- const localePattern = /^[a-z]{2,3}(?:-[a-z0-9]+)?$/;
42
- const invalid = parsed.find((locale) => !localePattern.test(locale));
43
- if (invalid) {
44
- throw new Error(`Invalid locale "${invalid}".`);
45
- }
46
+ const localePattern = /^[a-z]{2,3}(?:-[a-z0-9]+)?$/;
47
+ const invalid = parsed.find((locale) => !localePattern.test(locale));
48
+ if (invalid) {
49
+ throw new Error(`Invalid locale "${invalid}".`);
50
+ }
46
51
 
47
- return Array.from(new Set(parsed));
52
+ return Array.from(new Set(parsed));
48
53
  }
49
54
 
50
55
  export function buildNewPostTemplate(locale, slug, pubDate, heroImage) {
51
- const titleByLocale = {
52
- en: toTitleFromSlug(slug),
53
- ja: '新しい記事タイトル',
54
- ko: '새 글 제목',
55
- es: 'Título del nuevo artículo',
56
- zh: '新文章标题',
57
- };
58
- const descriptionByLocale = {
59
- en: `A short EN post scaffold for "${slug}".`,
60
- ja: `「${slug}」用の短い日本語記事テンプレートです。`,
61
- ko: `"${slug}"용 한국어 글 템플릿입니다.`,
62
- es: `Plantilla breve en español para "${slug}".`,
63
- zh: `“${slug}”的中文文章模板。`,
64
- };
65
- const bodyByLocale = {
66
- en: `Write your EN content for "${slug}" here.`,
67
- ja: `ここに「${slug}」の日本語本文を書いてください。`,
68
- ko: `여기에 "${slug}" 한국어 본문을 작성하세요.`,
69
- es: `Escribe aquí el contenido en español para "${slug}".`,
70
- zh: `请在这里填写“${slug}”的中文正文。`,
71
- };
72
- return `---
73
- title: '${titleByLocale[locale]}'
56
+ const titleByLocale = {
57
+ en: toTitleFromSlug(slug),
58
+ ja: '新しい記事タイトル',
59
+ ko: '새 글 제목',
60
+ es: 'Título del nuevo artículo',
61
+ zh: '新文章标题',
62
+ };
63
+ const descriptionByLocale = {
64
+ en: `A short EN post scaffold for "${slug}".`,
65
+ ja: `「${slug}」用の短い日本語記事テンプレートです。`,
66
+ ko: `"${slug}"용 한국어 글 템플릿입니다.`,
67
+ es: `Plantilla breve en español para "${slug}".`,
68
+ zh: `“${slug}”的中文文章模板。`,
69
+ };
70
+ const bodyByLocale = {
71
+ en: `Write your EN content for "${slug}" here.`,
72
+ ja: `ここに「${slug}」の日本語本文を書いてください。`,
73
+ ko: `여기에 "${slug}" 한국어 본문을 작성하세요.`,
74
+ es: `Escribe aquí el contenido en español para "${slug}".`,
75
+ zh: `请在这里填写“${slug}”的中文正文。`,
76
+ };
77
+ const resolvedTitle = titleByLocale[locale] ?? toTitleFromSlug(slug);
78
+ const resolvedDescription =
79
+ descriptionByLocale[locale] ?? `Localized post scaffold for "${slug}" (${locale}).`;
80
+ const resolvedBody = bodyByLocale[locale] ?? `Write your ${locale} content for "${slug}" here.`;
81
+ return `---
82
+ title: '${resolvedTitle}'
74
83
  subtitle: ''
75
- description: '${descriptionByLocale[locale]}'
84
+ description: '${resolvedDescription}'
76
85
  pubDate: '${pubDate}'
77
86
  ${heroImage ? `heroImage: '${heroImage}'` : ''}
78
87
  ---
79
88
 
80
- ${bodyByLocale[locale]}
89
+ ${resolvedBody}
81
90
  `;
82
91
  }
83
92
 
84
93
  export async function loadDefaultCovers(defaultCoversRoot) {
85
- try {
86
- const entries = await readdir(defaultCoversRoot, { withFileTypes: true });
87
- return entries
88
- .filter((entry) => entry.isFile() && /\.(webp|png|jpe?g)$/i.test(entry.name))
89
- .map((entry) => path.join(defaultCoversRoot, entry.name))
90
- .sort((a, b) => a.localeCompare(b));
91
- } catch {
92
- return [];
93
- }
94
+ try {
95
+ const entries = await readdir(defaultCoversRoot, { withFileTypes: true });
96
+ return entries
97
+ .filter((entry) => entry.isFile() && /\.(webp|png|jpe?g)$/i.test(entry.name))
98
+ .map((entry) => path.join(defaultCoversRoot, entry.name))
99
+ .sort((a, b) => a.localeCompare(b));
100
+ } catch {
101
+ return [];
102
+ }
94
103
  }
95
104
 
96
105
  export function pickDefaultCoverBySlug(slug, localeDir, defaultCovers) {
97
- if (defaultCovers.length === 0) return '';
98
- const coverPath = defaultCovers[hashString(slug) % defaultCovers.length];
99
- return normalizePathForFrontmatter(path.relative(localeDir, coverPath));
106
+ if (defaultCovers.length === 0) return '';
107
+ const coverPath = defaultCovers[hashString(slug) % defaultCovers.length];
108
+ return normalizePathForFrontmatter(path.relative(localeDir, coverPath));
100
109
  }
101
110
 
102
111
  export { validatePostSlug };
@@ -2,13 +2,18 @@ import { createDecryptorController } from './modal-decryptor.js';
2
2
  import { mountHelpKeyboard } from './modal-keyboard.js';
3
3
  import { renderProgressModal } from './modal-progress.js';
4
4
 
5
+ let cleanupAboutModals = null;
6
+
5
7
  export function initAboutModals(runtimeConfig, prefersReducedMotion) {
8
+ if (cleanupAboutModals) cleanupAboutModals();
9
+
6
10
  const modalOverlay = document.getElementById('hacker-modal');
7
11
  const modalBody = document.getElementById('hacker-modal-body');
8
12
  const modalTitle = document.querySelector('.hacker-modal-title');
9
13
  if (!modalOverlay || !modalBody || !modalTitle) return;
10
14
 
11
- const decryptorKeysLabel = runtimeConfig.decryptorKeysLabel || 'keys tested';
15
+ const decryptorKeysLabel =
16
+ typeof runtimeConfig.decryptorKeysLabel === 'string' ? runtimeConfig.decryptorKeysLabel : '';
12
17
  const decryptor = createDecryptorController(
13
18
  modalOverlay,
14
19
  prefersReducedMotion,
@@ -17,26 +22,8 @@ export function initAboutModals(runtimeConfig, prefersReducedMotion) {
17
22
  let cleanupKeyboard = null;
18
23
 
19
24
  const scriptsTpl = document.getElementById('hacker-scripts-folders-tpl');
20
- const fallbackModalContent = {
21
- 'dl-data': {
22
- title: 'Downloading...',
23
- body: '<div class="hacker-modal-download"><div class="modal-subtitle">Critical Data</div><div class="hacker-modal-progress" id="dl-progress"></div></div>',
24
- type: 'progress',
25
- },
26
- ai: {
27
- title: 'AI',
28
- body: '<pre>~ $ ai --status --verbose\n\nmodel: runtime-default\nmode: standard\ncontext window: 32k\nlatency: 100-250ms\nsafety: enabled\n\n&gt;&gt; system online\n&gt;&gt; ready</pre>',
29
- type: 'plain',
30
- },
31
- decryptor: {
32
- title: 'Password Decryptor',
33
- body: '<pre class="hacker-decryptor-pre">Calculating Hashes\n\n<span id="dec-keys">[00:00:01] 0 keys tested</span>\n\nCurrent passphrase: <span id="dec-pass">********</span>\n\nMaster key\n<span id="dec-master1"></span>\n<span id="dec-master2"></span>\n\nTransient key\n<span id="dec-trans1"></span>\n<span id="dec-trans2"></span>\n<span id="dec-trans3"></span>\n<span id="dec-trans4"></span></pre>',
34
- type: 'decryptor',
35
- },
36
- help: { title: 'Help', body: '', type: 'keyboard' },
37
- 'all-scripts': { title: '/root/bash/scripts', body: '', type: 'scripts' },
38
- };
39
- const modalContent = runtimeConfig.modalContent || fallbackModalContent;
25
+ const modalContent = runtimeConfig.modalContent;
26
+ if (!modalContent || typeof modalContent !== 'object') return;
40
27
 
41
28
  const closeModal = () => {
42
29
  decryptor.stop();
@@ -99,22 +86,43 @@ export function initAboutModals(runtimeConfig, prefersReducedMotion) {
99
86
  modalOverlay.setAttribute('aria-hidden', 'false');
100
87
  };
101
88
 
102
- document.querySelectorAll('.hacker-folder[data-modal]').forEach((button) => {
103
- button.addEventListener('click', () => {
89
+ const folderButtons = Array.from(document.querySelectorAll('.hacker-folder[data-modal]'));
90
+ const buttonHandlers = folderButtons.map((button) => {
91
+ const onClick = () => {
104
92
  const id = button.getAttribute('data-modal');
105
93
  if (!id) return;
106
94
  openModal(modalContent[id]);
107
- });
95
+ };
96
+ button.addEventListener('click', onClick);
97
+ return [button, onClick];
108
98
  });
109
99
 
110
100
  const closeButton = document.querySelector('.hacker-modal-close');
111
101
  if (closeButton) closeButton.addEventListener('click', closeModal);
112
102
 
113
- modalOverlay.addEventListener('click', (event) => {
103
+ const onOverlayClick = (event) => {
114
104
  if (event.target === modalOverlay) closeModal();
115
- });
105
+ };
106
+ modalOverlay.addEventListener('click', onOverlayClick);
116
107
 
117
- document.addEventListener('keydown', (event) => {
108
+ const onDocumentKeydown = (event) => {
118
109
  if (event.key === 'Escape' && modalOverlay.classList.contains('open')) closeModal();
119
- });
110
+ };
111
+ document.addEventListener('keydown', onDocumentKeydown);
112
+
113
+ cleanupAboutModals = () => {
114
+ decryptor.stop();
115
+ if (cleanupKeyboard) {
116
+ cleanupKeyboard();
117
+ cleanupKeyboard = null;
118
+ }
119
+ if (closeButton) closeButton.removeEventListener('click', closeModal);
120
+ modalOverlay.removeEventListener('click', onOverlayClick);
121
+ document.removeEventListener('keydown', onDocumentKeydown);
122
+ buttonHandlers.forEach(([button, handler]) => {
123
+ button.removeEventListener('click', handler);
124
+ });
125
+ };
126
+
127
+ return cleanupAboutModals;
120
128
  }
@@ -1,14 +1,15 @@
1
1
  export function initPostInteractions(prefersReducedMotion) {
2
- var glow = document.querySelector('.ai-mouse-glow');
3
- if (glow) {
4
- var raf;
5
- var x = 0;
6
- var y = 0;
7
- document.addEventListener('mousemove', function(e) {
2
+ const glow = document.querySelector('.ai-mouse-glow');
3
+ if (glow && window.__anglefeintPostGlowBound__ !== true) {
4
+ window.__anglefeintPostGlowBound__ = true;
5
+ let raf = 0;
6
+ let x = 0;
7
+ let y = 0;
8
+ document.addEventListener('mousemove', function (e) {
8
9
  x = e.clientX;
9
10
  y = e.clientY;
10
11
  if (!raf) {
11
- raf = requestAnimationFrame(function() {
12
+ raf = requestAnimationFrame(function () {
12
13
  glow.style.setProperty('--mouse-x', x + 'px');
13
14
  glow.style.setProperty('--mouse-y', y + 'px');
14
15
  raf = 0;
@@ -17,42 +18,56 @@ export function initPostInteractions(prefersReducedMotion) {
17
18
  });
18
19
  }
19
20
 
20
- document.querySelectorAll('.ai-prose-body a[href]').forEach(function(a) {
21
+ document.querySelectorAll('.ai-prose-body a[href]').forEach(function (a) {
22
+ if (a.dataset.aiLinkPreviewBound === 'true') return;
21
23
  var href = a.getAttribute('href') || '';
22
24
  if (!href || href.startsWith('#')) return;
25
+ a.dataset.aiLinkPreviewBound = 'true';
23
26
  a.classList.add('ai-link-preview');
24
27
  try {
25
- a.setAttribute('data-preview', href.startsWith('http') ? new URL(href, location.origin).hostname : href);
28
+ a.setAttribute(
29
+ 'data-preview',
30
+ href.startsWith('http') ? new URL(href, location.origin).hostname : href
31
+ );
26
32
  } catch (_err) {
27
33
  a.setAttribute('data-preview', href);
28
34
  }
29
35
  });
30
36
 
31
- var paras = document.querySelectorAll('.ai-prose-body p, .ai-prose-body h2, .ai-prose-body h3, .ai-prose-body pre, .ai-prose-body blockquote, .ai-prose-body ul, .ai-prose-body ol');
37
+ const paras = document.querySelectorAll(
38
+ '.ai-prose-body p, .ai-prose-body h2, .ai-prose-body h3, .ai-prose-body pre, .ai-prose-body blockquote, .ai-prose-body ul, .ai-prose-body ol'
39
+ );
32
40
  if (window.IntersectionObserver) {
33
- var io = new IntersectionObserver(function(entries) {
34
- entries.forEach(function(entry) {
35
- if (entry.isIntersecting) {
36
- entry.target.classList.add('ai-para-visible');
37
- io.unobserve(entry.target);
38
- }
39
- });
40
- }, { rootMargin: '0px 0px -60px 0px', threshold: 0.1 });
41
+ const io = new IntersectionObserver(
42
+ function (entries) {
43
+ entries.forEach(function (entry) {
44
+ if (entry.isIntersecting) {
45
+ entry.target.classList.add('ai-para-visible');
46
+ io.unobserve(entry.target);
47
+ }
48
+ });
49
+ },
50
+ { rootMargin: '0px 0px -60px 0px', threshold: 0.1 }
51
+ );
41
52
 
42
- paras.forEach(function(p) {
53
+ paras.forEach(function (p) {
54
+ if (p.dataset.aiRevealObserved === 'true') return;
55
+ p.dataset.aiRevealObserved = 'true';
43
56
  io.observe(p);
44
57
  });
45
58
  } else {
46
- paras.forEach(function(p) {
59
+ paras.forEach(function (p) {
47
60
  p.classList.add('ai-para-visible');
48
61
  });
49
62
  }
50
63
 
51
- var regen = document.querySelector('.ai-regenerate');
52
- var article = document.querySelector('.ai-article');
53
- var scan = document.querySelector('.ai-load-scan');
64
+ const regen = document.querySelector('.ai-regenerate');
65
+ const article = document.querySelector('.ai-article');
66
+ const scan = document.querySelector('.ai-load-scan');
54
67
  if (regen && article) {
55
- regen.addEventListener('click', function() {
68
+ if (regen.dataset.aiRegenerateBound === 'true') return;
69
+ regen.dataset.aiRegenerateBound = 'true';
70
+ regen.addEventListener('click', function () {
56
71
  regen.disabled = true;
57
72
  regen.classList.add('ai-regenerating');
58
73
  article.classList.add('ai-regenerate-flash');
@@ -63,11 +78,14 @@ export function initPostInteractions(prefersReducedMotion) {
63
78
  scan.style.top = '0';
64
79
  scan.style.opacity = '1';
65
80
  }
66
- setTimeout(function() {
67
- article.classList.remove('ai-regenerate-flash');
68
- regen.classList.remove('ai-regenerating');
69
- regen.disabled = false;
70
- }, prefersReducedMotion ? 120 : 1200);
81
+ setTimeout(
82
+ function () {
83
+ article.classList.remove('ai-regenerate-flash');
84
+ regen.classList.remove('ai-regenerating');
85
+ regen.disabled = false;
86
+ },
87
+ prefersReducedMotion ? 120 : 1200
88
+ );
71
89
  });
72
90
  }
73
91
  }