@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 +4 -2
- package/package.json +1 -1
- package/src/cli-new-post.mjs +326 -89
- package/src/components/BaseHead.astro +9 -14
- package/src/components/Giscus.astro +48 -0
- package/src/content-schema.ts +51 -26
- package/src/i18n/config.ts +29 -29
- package/src/i18n/locales.mjs +1 -1
- package/src/i18n/messages.ts +3 -3
- package/src/layouts/BlogPost.astro +2 -19
- package/src/scaffold/new-post.mjs +77 -68
- package/src/scripts/about/modals.js +36 -28
- package/src/scripts/blogpost/interactions.js +47 -29
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.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
|
-
|
|
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 = {
|
|
@@ -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.
|
|
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>
|
package/src/content-schema.ts
CHANGED
|
@@ -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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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 };
|
package/src/i18n/config.ts
CHANGED
|
@@ -1,50 +1,50 @@
|
|
|
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.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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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:
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
export function alternatePathForLocale(locale: string, subpath: string): string {
|
|
48
|
+
if (locale === DEFAULT_LOCALE && (subpath === '/' || subpath === '')) return '/';
|
|
49
|
+
return localePath(locale, subpath);
|
|
50
50
|
}
|
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.',
|
|
@@ -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
|
-
<
|
|
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 {
|
|
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 };
|
|
@@ -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 =
|
|
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
|
|
21
|
-
|
|
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>> system online\n>> 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]')
|
|
103
|
-
|
|
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
|
-
|
|
103
|
+
const onOverlayClick = (event) => {
|
|
114
104
|
if (event.target === modalOverlay) closeModal();
|
|
115
|
-
}
|
|
105
|
+
};
|
|
106
|
+
modalOverlay.addEventListener('click', onOverlayClick);
|
|
116
107
|
|
|
117
|
-
|
|
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
|
-
|
|
3
|
-
if (glow) {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
entry.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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.
|
|
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(
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
}
|