@anglefeint/astro-theme 0.1.40 → 0.2.1

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.40",
3
+ "version": "0.2.1",
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 : '';
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 = {
@@ -112,6 +107,7 @@ const articleSchema = isArticle
112
107
 
113
108
  const extraSchemas = schema ? (Array.isArray(schema) ? schema : [schema]) : [];
114
109
  const jsonLdSchemas = [websiteSchema, personSchema, articleSchema, ...extraSchemas].filter(Boolean);
110
+ const serializeJsonLd = (value: unknown) => JSON.stringify(value).replace(/</g, '\\u003c');
115
111
  ---
116
112
 
117
113
  <!-- Global Metadata -->
@@ -133,7 +129,7 @@ const jsonLdSchemas = [websiteSchema, personSchema, articleSchema, ...extraSchem
133
129
  <link rel="canonical" href={canonicalURL} />
134
130
  {
135
131
  alternatePaths.map((alternate) => (
136
- <link rel="alternate" hreflang={alternate.locale} href={alternate.href} />
132
+ <link rel="alternate" hreflang={alternate.hreflang} href={alternate.href} />
137
133
  ))
138
134
  }
139
135
  <link rel="alternate" hreflang="x-default" href={xDefaultHref} />
@@ -160,7 +156,7 @@ const jsonLdSchemas = [websiteSchema, personSchema, articleSchema, ...extraSchem
160
156
  <meta property="og:image:height" content={String(image.height)} />
161
157
  )
162
158
  }
163
- <meta property="og:locale" content={ogLocale} />
159
+ {ogLocale && <meta property="og:locale" content={ogLocale} />}
164
160
  {ogLocaleAlternates.map((loc) => <meta property="og:locale:alternate" content={loc} />)}
165
161
  {
166
162
  isArticle && publishedTime && (
@@ -183,6 +179,6 @@ const jsonLdSchemas = [websiteSchema, personSchema, articleSchema, ...extraSchem
183
179
 
184
180
  {
185
181
  jsonLdSchemas.map((schemaItem) => (
186
- <script is:inline type="application/ld+json" set:html={JSON.stringify(schemaItem)} />
182
+ <script is:inline type="application/ld+json" set:html={serializeJsonLd(schemaItem)} />
187
183
  ))
188
184
  }
@@ -25,6 +25,10 @@ type Props = {
25
25
  const { comments, resolvedLocale } = Astro.props;
26
26
  const normalizedCommentTerm = comments.TERM.trim();
27
27
  const normalizedCommentNumber = comments.NUMBER.trim();
28
+ const GISCUS_LOCALE_MAP: Record<string, string> = {
29
+ zh: 'zh-CN',
30
+ };
31
+ const giscusLocale = comments.LANG || GISCUS_LOCALE_MAP[resolvedLocale] || resolvedLocale;
28
32
  ---
29
33
 
30
34
  <script
@@ -42,7 +46,7 @@ const normalizedCommentNumber = comments.NUMBER.trim();
42
46
  data-emit-metadata={comments.EMIT_METADATA}
43
47
  data-input-position={comments.INPUT_POSITION}
44
48
  data-theme={comments.THEME}
45
- data-lang={comments.LANG || resolvedLocale}
49
+ data-lang={giscusLocale}
46
50
  data-loading={comments.LOADING}
47
51
  crossorigin={comments.CROSSORIGIN}
48
52
  async></script>
@@ -80,7 +80,9 @@ const localeOptions = SUPPORTED_LOCALES.map((targetLocale) => ({
80
80
  <span class="nav-status-dot" aria-hidden="true"></span>
81
81
  <span class="nav-status-text">{labels.status}</span>
82
82
  </div>
83
- <SocialMenu links={SOCIAL_LINKS} />
83
+ <div class="social-menu-wrap">
84
+ <SocialMenu links={SOCIAL_LINKS} />
85
+ </div>
84
86
  </div>
85
87
  </div>
86
88
  </nav>
@@ -244,6 +246,9 @@ const localeOptions = SUPPORTED_LOCALES.map((targetLocale) => ({
244
246
  display: none !important;
245
247
  }
246
248
  .social-links {
249
+ display: flex;
250
+ }
251
+ .social-menu-wrap {
247
252
  display: none;
248
253
  }
249
254
  .lang-switcher {
@@ -1,50 +1,51 @@
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 === prefix) return '/';
36
+ if (!pathname.startsWith(`${prefix}/`)) return pathname;
37
+ const withoutLocale = pathname.slice(prefix.length);
38
+ return withoutLocale || '/';
38
39
  }
39
40
 
40
41
  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;
42
+ const parts = id.split('/');
43
+ if (parts.length > 1 && isLocale(parts[0])) return parts.slice(1).join('/');
44
+ return id;
44
45
  }
45
46
 
46
47
  /** 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);
48
+ export function alternatePathForLocale(locale: string, subpath: string): string {
49
+ if (locale === DEFAULT_LOCALE && (subpath === '/' || subpath === '')) return '/';
50
+ return localePath(locale, subpath);
50
51
  }
@@ -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.',
@@ -96,7 +96,7 @@ export const DEFAULT_MESSAGES: Record<Locale, Messages> = {
96
96
  ethos: 'Hacker Ethos',
97
97
  now: 'Now',
98
98
  contact: 'Contact',
99
- regenerate: 'Regenerate',
99
+ regenerate: 'Replay scan',
100
100
  },
101
101
  blog: {
102
102
  title: 'Blog',
@@ -130,7 +130,7 @@ export const DEFAULT_MESSAGES: Record<Locale, Messages> = {
130
130
  heroMonitor: 'neural monitor',
131
131
  heroSignalSync: 'signal sync active',
132
132
  heroModelOnline: 'model online',
133
- regenerate: 'Regenerate',
133
+ regenerate: 'Replay scan',
134
134
  relatedAria: 'Related posts',
135
135
  backToBlogAria: 'Back to blog',
136
136
  paginationAria: 'Pagination',
@@ -165,7 +165,7 @@ export const DEFAULT_MESSAGES: Record<Locale, Messages> = {
165
165
  ethos: 'ハッカー精神',
166
166
  now: '現在',
167
167
  contact: '連絡先',
168
- regenerate: '再生成',
168
+ regenerate: 'スキャン再生',
169
169
  },
170
170
  blog: {
171
171
  title: 'ブログ',
@@ -199,7 +199,7 @@ export const DEFAULT_MESSAGES: Record<Locale, Messages> = {
199
199
  heroMonitor: 'ニューラルモニター',
200
200
  heroSignalSync: 'シグナル同期中',
201
201
  heroModelOnline: 'モデルオンライン',
202
- regenerate: '再生成',
202
+ regenerate: 'スキャン再生',
203
203
  relatedAria: '関連記事',
204
204
  backToBlogAria: 'ブログへ戻る',
205
205
  paginationAria: 'ページネーション',
@@ -234,7 +234,7 @@ export const DEFAULT_MESSAGES: Record<Locale, Messages> = {
234
234
  ethos: '해커 정신',
235
235
  now: '지금',
236
236
  contact: '연락처',
237
- regenerate: '재생성',
237
+ regenerate: '스캔 재생',
238
238
  },
239
239
  blog: {
240
240
  title: '블로그',
@@ -268,7 +268,7 @@ export const DEFAULT_MESSAGES: Record<Locale, Messages> = {
268
268
  heroMonitor: '뉴럴 모니터',
269
269
  heroSignalSync: '신호 동기화 활성',
270
270
  heroModelOnline: '모델 온라인',
271
- regenerate: '재생성',
271
+ regenerate: '스캔 재생',
272
272
  relatedAria: '관련 글',
273
273
  backToBlogAria: '블로그로 돌아가기',
274
274
  paginationAria: '페이지네이션',
@@ -303,7 +303,7 @@ export const DEFAULT_MESSAGES: Record<Locale, Messages> = {
303
303
  ethos: 'Ethos hacker',
304
304
  now: 'Ahora',
305
305
  contact: 'Contacto',
306
- regenerate: 'Regenerar',
306
+ regenerate: 'Repetir escaneo',
307
307
  },
308
308
  blog: {
309
309
  title: 'Blog',
@@ -338,7 +338,7 @@ export const DEFAULT_MESSAGES: Record<Locale, Messages> = {
338
338
  heroMonitor: 'monitor neural',
339
339
  heroSignalSync: 'sincronización de señal activa',
340
340
  heroModelOnline: 'modelo en línea',
341
- regenerate: 'Regenerar',
341
+ regenerate: 'Repetir escaneo',
342
342
  relatedAria: 'Publicaciones relacionadas',
343
343
  backToBlogAria: 'Volver al blog',
344
344
  paginationAria: 'Paginación',
@@ -373,7 +373,7 @@ export const DEFAULT_MESSAGES: Record<Locale, Messages> = {
373
373
  ethos: '黑客精神',
374
374
  now: '现在',
375
375
  contact: '联系',
376
- regenerate: '重新生成',
376
+ regenerate: '重播扫描',
377
377
  },
378
378
  blog: {
379
379
  title: '博客',
@@ -407,7 +407,7 @@ export const DEFAULT_MESSAGES: Record<Locale, Messages> = {
407
407
  heroMonitor: '神经监视器',
408
408
  heroSignalSync: '信号同步中',
409
409
  heroModelOnline: '模型在线',
410
- regenerate: '重新生成',
410
+ regenerate: '重播扫描',
411
411
  relatedAria: '相关文章',
412
412
  backToBlogAria: '返回博客',
413
413
  paginationAria: '分页导航',
@@ -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
  }
@@ -22,6 +22,8 @@ type Props = CollectionEntry<'blog'>['data'] & {
22
22
  locale?: string;
23
23
  related?: CollectionEntry<'blog'>[];
24
24
  localeHrefs?: Partial<Record<Locale, string>>;
25
+ aiLatencyEstimated?: boolean;
26
+ aiConfidenceEstimated?: boolean;
25
27
  };
26
28
 
27
29
  const {
@@ -37,7 +39,9 @@ const {
37
39
  aiMode,
38
40
  aiState,
39
41
  aiLatencyMs,
42
+ aiLatencyEstimated = false,
40
43
  aiConfidence,
44
+ aiConfidenceEstimated = false,
41
45
  wordCount,
42
46
  tokenCount,
43
47
  author,
@@ -169,7 +173,7 @@ const hasCommentsConfig = Boolean(
169
173
  <Image
170
174
  class="hero-base-image"
171
175
  src={heroImage}
172
- alt={title}
176
+ alt=""
173
177
  loading="eager"
174
178
  decoding="async"
175
179
  fetchpriority="high"
@@ -257,12 +261,14 @@ const hasCommentsConfig = Boolean(
257
261
  <div class="ai-response-meta">
258
262
  {aiLatencyMs !== undefined && (
259
263
  <span>
260
- {messages.blog.latencyLabel} <strong>{aiLatencyMs}</strong> ms
264
+ {messages.blog.latencyLabel}{' '}
265
+ <strong>{aiLatencyEstimated ? `~${aiLatencyMs}` : aiLatencyMs}</strong> ms
261
266
  </span>
262
267
  )}
263
268
  {confidenceText !== undefined && (
264
269
  <span>
265
- {messages.blog.confidenceLabel} <strong>{confidenceText}</strong>
270
+ {messages.blog.confidenceLabel}{' '}
271
+ <strong>{aiConfidenceEstimated ? `~${confidenceText}` : confidenceText}</strong>
266
272
  </span>
267
273
  )}
268
274
  </div>
@@ -293,8 +299,11 @@ const hasCommentsConfig = Boolean(
293
299
  </div>
294
300
  )
295
301
  }
296
- <button type="button" class="ai-regenerate" aria-label={messages.blog.regenerate}
297
- >{messages.blog.regenerate}</button
302
+ <button
303
+ type="button"
304
+ class="ai-regenerate"
305
+ aria-label={messages.blog.regenerate}
306
+ title={messages.blog.regenerate}>{messages.blog.regenerate}</button
298
307
  >
299
308
  </div>
300
309
  </article>
@@ -310,7 +319,7 @@ const hasCommentsConfig = Boolean(
310
319
  >
311
320
  {p.data.heroImage ? (
312
321
  <div class="ai-related-img">
313
- <Image width={320} height={180} src={p.data.heroImage} alt={p.data.title} />
322
+ <Image width={320} height={180} src={p.data.heroImage} alt="" />
314
323
  </div>
315
324
  ) : (
316
325
  <div class="ai-related-placeholder">
@@ -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 };
@@ -10,7 +10,19 @@ export function initAboutModals(runtimeConfig, prefersReducedMotion) {
10
10
  const modalOverlay = document.getElementById('hacker-modal');
11
11
  const modalBody = document.getElementById('hacker-modal-body');
12
12
  const modalTitle = document.querySelector('.hacker-modal-title');
13
+ const closeButton = document.querySelector('.hacker-modal-close');
13
14
  if (!modalOverlay || !modalBody || !modalTitle) return;
15
+ let lastFocusedElement = null;
16
+
17
+ function getFocusableElements() {
18
+ return Array.from(
19
+ modalOverlay.querySelectorAll(
20
+ 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
21
+ )
22
+ ).filter(
23
+ (element) => !element.hasAttribute('hidden') && element.getAttribute('aria-hidden') !== 'true'
24
+ );
25
+ }
14
26
 
15
27
  const decryptorKeysLabel =
16
28
  typeof runtimeConfig.decryptorKeysLabel === 'string' ? runtimeConfig.decryptorKeysLabel : '';
@@ -36,10 +48,15 @@ export function initAboutModals(runtimeConfig, prefersReducedMotion) {
36
48
  if (modalEl) modalEl.classList.remove('hacker-modal-wide');
37
49
  modalOverlay.classList.remove('open');
38
50
  modalOverlay.setAttribute('aria-hidden', 'true');
51
+ if (lastFocusedElement && typeof lastFocusedElement.focus === 'function') {
52
+ lastFocusedElement.focus();
53
+ }
39
54
  };
40
55
 
41
56
  const openModal = (data) => {
42
57
  if (!data) return;
58
+ lastFocusedElement =
59
+ document.activeElement instanceof HTMLElement ? document.activeElement : null;
43
60
 
44
61
  decryptor.stop();
45
62
  if (cleanupKeyboard) {
@@ -84,6 +101,13 @@ export function initAboutModals(runtimeConfig, prefersReducedMotion) {
84
101
 
85
102
  modalOverlay.classList.add('open');
86
103
  modalOverlay.setAttribute('aria-hidden', 'false');
104
+ window.requestAnimationFrame(() => {
105
+ const focusables = getFocusableElements();
106
+ const nextFocus = focusables[0] || closeButton || modalOverlay;
107
+ if (nextFocus && typeof nextFocus.focus === 'function') {
108
+ nextFocus.focus();
109
+ }
110
+ });
87
111
  };
88
112
 
89
113
  const folderButtons = Array.from(document.querySelectorAll('.hacker-folder[data-modal]'));
@@ -97,7 +121,6 @@ export function initAboutModals(runtimeConfig, prefersReducedMotion) {
97
121
  return [button, onClick];
98
122
  });
99
123
 
100
- const closeButton = document.querySelector('.hacker-modal-close');
101
124
  if (closeButton) closeButton.addEventListener('click', closeModal);
102
125
 
103
126
  const onOverlayClick = (event) => {
@@ -106,7 +129,38 @@ export function initAboutModals(runtimeConfig, prefersReducedMotion) {
106
129
  modalOverlay.addEventListener('click', onOverlayClick);
107
130
 
108
131
  const onDocumentKeydown = (event) => {
109
- if (event.key === 'Escape' && modalOverlay.classList.contains('open')) closeModal();
132
+ if (!modalOverlay.classList.contains('open')) return;
133
+ if (event.key === 'Escape') {
134
+ closeModal();
135
+ return;
136
+ }
137
+ if (event.key !== 'Tab') return;
138
+
139
+ const focusables = getFocusableElements();
140
+ if (focusables.length === 0) {
141
+ event.preventDefault();
142
+ if (closeButton && typeof closeButton.focus === 'function') {
143
+ closeButton.focus();
144
+ }
145
+ return;
146
+ }
147
+
148
+ const currentIndex = focusables.indexOf(document.activeElement);
149
+ const first = focusables[0];
150
+ const last = focusables[focusables.length - 1];
151
+
152
+ if (event.shiftKey) {
153
+ if (document.activeElement === first || currentIndex === -1) {
154
+ event.preventDefault();
155
+ last.focus();
156
+ }
157
+ return;
158
+ }
159
+
160
+ if (document.activeElement === last) {
161
+ event.preventDefault();
162
+ first.focus();
163
+ }
110
164
  };
111
165
  document.addEventListener('keydown', onDocumentKeydown);
112
166
 
@@ -6,9 +6,10 @@ export function initAboutReadingUi(runtimeConfig, prefersReducedMotion) {
6
6
  p60: 'inference stable',
7
7
  p90: 'output finalized',
8
8
  };
9
- var toastMessages = runtimeConfig.effects && runtimeConfig.effects.scrollToasts
10
- ? runtimeConfig.effects.scrollToasts
11
- : fallbackToasts;
9
+ var toastMessages =
10
+ runtimeConfig.effects && runtimeConfig.effects.scrollToasts
11
+ ? runtimeConfig.effects.scrollToasts
12
+ : fallbackToasts;
12
13
  var stageSeen = { p30: false, p60: false, p90: false };
13
14
  var toastTimer = 0;
14
15
  var hasScrolled = false;
@@ -18,9 +19,9 @@ export function initAboutReadingUi(runtimeConfig, prefersReducedMotion) {
18
19
  toast.textContent = '> ' + msg;
19
20
  toast.classList.add('visible');
20
21
  clearTimeout(toastTimer);
21
- toastTimer = setTimeout(function() {
22
+ toastTimer = setTimeout(function () {
22
23
  toast.classList.remove('visible');
23
- }, 900);
24
+ }, 1800);
24
25
  }
25
26
 
26
27
  if (progress) {
@@ -53,7 +54,7 @@ export function initAboutReadingUi(runtimeConfig, prefersReducedMotion) {
53
54
 
54
55
  var backTop = document.querySelector('.hacker-back-to-top');
55
56
  if (backTop) {
56
- backTop.addEventListener('click', function() {
57
+ backTop.addEventListener('click', function () {
57
58
  window.scrollTo({ top: 0, behavior: prefersReducedMotion ? 'auto' : 'smooth' });
58
59
  });
59
60
  }
@@ -23,9 +23,9 @@ export function initReadProgressAndBackToTop(prefersReducedMotion) {
23
23
  toast.textContent = msg;
24
24
  toast.classList.add('visible');
25
25
  clearTimeout(toastTimer);
26
- toastTimer = setTimeout(function() {
26
+ toastTimer = setTimeout(function () {
27
27
  toast.classList.remove('visible');
28
- }, 900);
28
+ }, 1800);
29
29
  }
30
30
 
31
31
  if (progress) {
@@ -62,7 +62,7 @@ export function initReadProgressAndBackToTop(prefersReducedMotion) {
62
62
 
63
63
  var backTop = document.querySelector('.ai-back-to-top');
64
64
  if (backTop) {
65
- backTop.addEventListener('click', function() {
65
+ backTop.addEventListener('click', function () {
66
66
  window.scrollTo({ top: 0, behavior: prefersReducedMotion ? 'auto' : 'smooth' });
67
67
  });
68
68
  }