@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 +4 -2
- package/package.json +1 -1
- package/src/cli-new-post.mjs +326 -89
- package/src/components/BaseHead.astro +11 -15
- package/src/components/Giscus.astro +5 -1
- package/src/components/shared/CommonHeader.astro +6 -1
- package/src/i18n/config.ts +30 -29
- package/src/i18n/locales.mjs +1 -1
- package/src/i18n/messages.ts +13 -13
- package/src/layouts/BlogPost.astro +15 -6
- package/src/scaffold/new-post.mjs +77 -68
- package/src/scripts/about/modals.js +56 -2
- package/src/scripts/about/reading-ui.js +7 -6
- package/src/scripts/blogpost/read-progress.js +3 -3
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,
|
|
73
|
+
anglefeint-new-post my-first-post --locales en,fr
|
|
72
74
|
|
|
73
75
|
# or via environment variable
|
|
74
|
-
ANGLEFEINT_LOCALES=en,
|
|
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
package/src/cli-new-post.mjs
CHANGED
|
@@ -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 {
|
|
6
|
+
import { promisify } from 'node:util';
|
|
6
7
|
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
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.
|
|
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={
|
|
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={
|
|
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
|
-
<
|
|
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 {
|
package/src/i18n/config.ts
CHANGED
|
@@ -1,50 +1,51 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
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 =
|
|
7
|
-
export const SUPPORTED_LOCALES = SUPPORTED_LOCALES_RUNTIME as readonly
|
|
8
|
-
export const DEFAULT_LOCALE: Locale = DEFAULT_LOCALE_RUNTIME
|
|
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<
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
19
|
+
return (SUPPORTED_LOCALES as readonly string[]).includes(value);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
export function withLeadingSlash(path: string): string {
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
if (!path) return '/';
|
|
24
|
+
return path.startsWith('/') ? path : `/${path}`;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
export function localePath(locale:
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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:
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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:
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
export function alternatePathForLocale(locale: string, subpath: string): string {
|
|
49
|
+
if (locale === DEFAULT_LOCALE && (subpath === '/' || subpath === '')) return '/';
|
|
50
|
+
return localePath(locale, subpath);
|
|
50
51
|
}
|
package/src/i18n/locales.mjs
CHANGED
package/src/i18n/messages.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
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<
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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=
|
|
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}
|
|
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}
|
|
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
|
|
297
|
-
|
|
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=
|
|
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 {
|
|
3
|
+
import {
|
|
4
|
+
hashString,
|
|
5
|
+
normalizePathForFrontmatter,
|
|
6
|
+
toTitleFromSlug,
|
|
7
|
+
validatePostSlug,
|
|
8
|
+
} from './shared.mjs';
|
|
4
9
|
|
|
5
10
|
export function usageNewPost() {
|
|
6
|
-
|
|
11
|
+
return 'Usage: npm run new-post -- <slug> [--locales en,fr,...]';
|
|
7
12
|
}
|
|
8
13
|
|
|
9
14
|
export function parseNewPostArgs(argv) {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
15
|
+
const args = argv.slice(2);
|
|
16
|
+
const positional = [];
|
|
17
|
+
let locales = '';
|
|
13
18
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
29
|
+
return { slug: positional[0], locales };
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
export function resolveLocales({ cliLocales, envLocales, defaultLocales }) {
|
|
28
|
-
|
|
29
|
-
|
|
33
|
+
const raw = cliLocales || envLocales || '';
|
|
34
|
+
if (!raw) return [...defaultLocales];
|
|
30
35
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
const parsed = raw
|
|
37
|
+
.split(',')
|
|
38
|
+
.map((locale) => locale.trim())
|
|
39
|
+
.filter(Boolean)
|
|
40
|
+
.map((locale) => locale.toLowerCase());
|
|
36
41
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
42
|
+
if (parsed.length === 0) {
|
|
43
|
+
throw new Error('Locales list is empty. Example: --locales en,fr');
|
|
44
|
+
}
|
|
40
45
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
52
|
+
return Array.from(new Set(parsed));
|
|
48
53
|
}
|
|
49
54
|
|
|
50
55
|
export function buildNewPostTemplate(locale, slug, pubDate, heroImage) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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: '${
|
|
84
|
+
description: '${resolvedDescription}'
|
|
76
85
|
pubDate: '${pubDate}'
|
|
77
86
|
${heroImage ? `heroImage: '${heroImage}'` : ''}
|
|
78
87
|
---
|
|
79
88
|
|
|
80
|
-
${
|
|
89
|
+
${resolvedBody}
|
|
81
90
|
`;
|
|
82
91
|
}
|
|
83
92
|
|
|
84
93
|
export async function loadDefaultCovers(defaultCoversRoot) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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 (
|
|
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 =
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
},
|
|
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
|
-
},
|
|
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
|
}
|