@anglefeint/astro-theme 0.1.40 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -42,6 +42,8 @@ For content schema:
42
42
  export { collections } from '@anglefeint/astro-theme/content-schema';
43
43
  ```
44
44
 
45
+ `sourceLinks` in blog frontmatter accepts standard `http(s)` URLs and bare domains such as `github.com/anglefeint/astro-theme-anglefeint`. Bare domains are normalized to `https://...` during schema parsing.
46
+
45
47
  ## Site Config Injection
46
48
 
47
49
  This package reads site-specific config from alias imports:
@@ -68,10 +70,10 @@ Examples:
68
70
  anglefeint-new-post my-first-post
69
71
 
70
72
  # create post only for selected locales
71
- anglefeint-new-post my-first-post --locales en,ja
73
+ anglefeint-new-post my-first-post --locales en,fr
72
74
 
73
75
  # or via environment variable
74
- ANGLEFEINT_LOCALES=en,ja anglefeint-new-post my-first-post
76
+ ANGLEFEINT_LOCALES=en,fr anglefeint-new-post my-first-post
75
77
 
76
78
  # create a custom page with theme variant
77
79
  anglefeint-new-page projects --theme base
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anglefeint/astro-theme",
3
- "version": "0.1.40",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Anglefeint core theme package for Astro",
6
6
  "keywords": [
@@ -1,113 +1,350 @@
1
1
  import { mkdir, writeFile, access } from 'node:fs/promises';
2
2
  import { constants } from 'node:fs';
3
+ import { execFile as execFileCallback } from 'node:child_process';
3
4
  import { readFile } from 'node:fs/promises';
4
5
  import path from 'node:path';
5
- import { SUPPORTED_LOCALES } from './i18n/locales.mjs';
6
+ import { promisify } from 'node:util';
6
7
  import {
7
- buildNewPostTemplate,
8
- loadDefaultCovers,
9
- parseNewPostArgs,
10
- pickDefaultCoverBySlug,
11
- resolveLocales,
12
- usageNewPost,
13
- validatePostSlug,
8
+ buildNewPostTemplate,
9
+ loadDefaultCovers,
10
+ parseNewPostArgs,
11
+ pickDefaultCoverBySlug,
12
+ resolveLocales,
13
+ usageNewPost,
14
+ validatePostSlug,
14
15
  } from './scaffold/new-post.mjs';
15
16
 
16
17
  const CONTENT_ROOT = path.resolve(process.cwd(), 'src/content/blog');
17
18
  const DEFAULT_COVERS_ROOT = path.resolve(process.cwd(), 'src/assets/blog/default-covers');
18
19
  const SITE_CONFIG_PATH = path.resolve(process.cwd(), 'src/site.config.ts');
20
+ const FALLBACK_LOCALES = ['en'];
21
+ const execFile = promisify(execFileCallback);
19
22
 
20
23
  async function exists(filePath) {
21
- try {
22
- await access(filePath, constants.F_OK);
23
- return true;
24
- } catch {
25
- return false;
26
- }
24
+ try {
25
+ await access(filePath, constants.F_OK);
26
+ return true;
27
+ } catch {
28
+ return false;
29
+ }
27
30
  }
28
31
 
29
- function parseSupportedLocalesFromConfig(configSource) {
30
- const localeArrayPattern = /supportedLocales\s*:\s*\[([\s\S]*?)\]/g;
31
- let match = null;
32
- let picked = '';
33
-
34
- while (true) {
35
- const next = localeArrayPattern.exec(configSource);
36
- if (!next) break;
37
- const body = next[1] ?? '';
38
- if (body.includes("'") || body.includes('"')) {
39
- match = next;
40
- picked = body;
41
- }
42
- }
43
-
44
- if (!match || !picked) return [];
45
- const localeMatches = picked.match(/['"]([a-z]{2,3}(?:-[a-z0-9]+)?)['"]/gi) || [];
46
- return localeMatches.map((token) => token.slice(1, -1).toLowerCase());
32
+ function extractObjectLiteral(source, marker) {
33
+ const markerIndex = source.indexOf(marker);
34
+ if (markerIndex === -1) return '';
35
+
36
+ const start = source.indexOf('{', markerIndex);
37
+ return extractObjectLiteralFromIndex(source, start);
38
+ }
39
+
40
+ function extractObjectLiteralFromIndex(source, start) {
41
+ if (start === -1) return '';
42
+
43
+ let depth = 0;
44
+ let inString = false;
45
+ let quote = '';
46
+ let escaped = false;
47
+
48
+ for (let index = start; index < source.length; index += 1) {
49
+ const char = source[index];
50
+
51
+ if (inString) {
52
+ if (escaped) {
53
+ escaped = false;
54
+ continue;
55
+ }
56
+ if (char === '\\') {
57
+ escaped = true;
58
+ continue;
59
+ }
60
+ if (char === quote) {
61
+ inString = false;
62
+ quote = '';
63
+ }
64
+ continue;
65
+ }
66
+
67
+ if (char === '"' || char === "'" || char === '`') {
68
+ inString = true;
69
+ quote = char;
70
+ continue;
71
+ }
72
+
73
+ if (char === '{') depth += 1;
74
+ if (char === '}') {
75
+ depth -= 1;
76
+ if (depth === 0) return source.slice(start, index + 1);
77
+ }
78
+ }
79
+
80
+ return '';
81
+ }
82
+
83
+ function extractThemeConfigObject(configSource) {
84
+ const themeConfigMarkers = ['export const THEME_CONFIG', 'const THEME_CONFIG'];
85
+
86
+ for (const marker of themeConfigMarkers) {
87
+ const markerIndex = configSource.indexOf(marker);
88
+ if (markerIndex === -1) continue;
89
+
90
+ const assignmentIndex = configSource.indexOf('=', markerIndex);
91
+ if (assignmentIndex === -1) continue;
92
+
93
+ const objectStart = configSource.indexOf('{', assignmentIndex);
94
+ if (objectStart === -1) continue;
95
+
96
+ let depth = 0;
97
+ let inString = false;
98
+ let quote = '';
99
+ let escaped = false;
100
+
101
+ for (let index = objectStart; index < configSource.length; index += 1) {
102
+ const char = configSource[index];
103
+
104
+ if (inString) {
105
+ if (escaped) {
106
+ escaped = false;
107
+ continue;
108
+ }
109
+ if (char === '\\') {
110
+ escaped = true;
111
+ continue;
112
+ }
113
+ if (char === quote) {
114
+ inString = false;
115
+ quote = '';
116
+ }
117
+ continue;
118
+ }
119
+
120
+ if (char === '"' || char === "'" || char === '`') {
121
+ inString = true;
122
+ quote = char;
123
+ continue;
124
+ }
125
+
126
+ if (char === '{') depth += 1;
127
+ if (char === '}') {
128
+ depth -= 1;
129
+ if (depth === 0) return configSource.slice(objectStart, index + 1);
130
+ }
131
+ }
132
+ }
133
+
134
+ return '';
135
+ }
136
+
137
+ function localeKeyFromToken(key) {
138
+ return /^[a-z]{2,3}(?:-[a-z0-9]+)?$/i.test(key) ? key.toLowerCase() : '';
139
+ }
140
+
141
+ function localeObjectIsEnabled(localeObjectSource) {
142
+ const metaObject = extractObjectLiteral(localeObjectSource, 'meta');
143
+ if (!metaObject) return true;
144
+
145
+ return !/\benabled\s*:\s*false\b/.test(metaObject);
146
+ }
147
+
148
+ function parseLocaleRegistryKeys(configSource) {
149
+ const themeConfigObject = extractThemeConfigObject(configSource);
150
+ if (!themeConfigObject) return [];
151
+
152
+ const i18nObject = extractObjectLiteral(themeConfigObject, 'i18n');
153
+ if (!i18nObject) return [];
154
+
155
+ const localesObject = extractObjectLiteral(i18nObject, 'locales');
156
+ if (!localesObject) return [];
157
+
158
+ const keys = [];
159
+ let depth = 0;
160
+
161
+ for (let index = 0; index < localesObject.length; index += 1) {
162
+ const char = localesObject[index];
163
+
164
+ if (char === '{') {
165
+ depth += 1;
166
+ if (depth === 1) continue;
167
+ }
168
+ if (char === '}') {
169
+ depth -= 1;
170
+ continue;
171
+ }
172
+
173
+ if (depth !== 1) continue;
174
+
175
+ if (char === '"' || char === "'" || char === '`') {
176
+ let key = '';
177
+ let escaped = false;
178
+ const quote = char;
179
+ let cursor = index + 1;
180
+
181
+ for (; cursor < localesObject.length; cursor += 1) {
182
+ const next = localesObject[cursor];
183
+ if (escaped) {
184
+ key += next;
185
+ escaped = false;
186
+ continue;
187
+ }
188
+ if (next === '\\') {
189
+ escaped = true;
190
+ continue;
191
+ }
192
+ if (next === quote) break;
193
+ key += next;
194
+ }
195
+
196
+ let colonIndex = cursor + 1;
197
+ while (colonIndex < localesObject.length && /\s/.test(localesObject[colonIndex])) {
198
+ colonIndex += 1;
199
+ }
200
+
201
+ const normalizedKey = localeKeyFromToken(key);
202
+ if (localesObject[colonIndex] === ':' && normalizedKey) {
203
+ const localeObject = extractObjectLiteralFromIndex(
204
+ localesObject,
205
+ localesObject.indexOf('{', colonIndex)
206
+ );
207
+ if (!localeObject || localeObjectIsEnabled(localeObject)) {
208
+ keys.push(normalizedKey);
209
+ }
210
+ }
211
+
212
+ index = cursor;
213
+ continue;
214
+ }
215
+
216
+ if (/[A-Za-z_$]/.test(char)) {
217
+ let cursor = index + 1;
218
+ while (cursor < localesObject.length && /[A-Za-z0-9_$-]/.test(localesObject[cursor])) {
219
+ cursor += 1;
220
+ }
221
+ const key = localesObject.slice(index, cursor).trim();
222
+
223
+ let colonIndex = cursor;
224
+ while (colonIndex < localesObject.length && /\s/.test(localesObject[colonIndex])) {
225
+ colonIndex += 1;
226
+ }
227
+
228
+ const normalizedKey = localeKeyFromToken(key);
229
+ if (localesObject[colonIndex] === ':' && normalizedKey) {
230
+ const localeObject = extractObjectLiteralFromIndex(
231
+ localesObject,
232
+ localesObject.indexOf('{', colonIndex)
233
+ );
234
+ if (!localeObject || localeObjectIsEnabled(localeObject)) {
235
+ keys.push(normalizedKey);
236
+ }
237
+ }
238
+
239
+ index = cursor - 1;
240
+ }
241
+ }
242
+
243
+ return Array.from(new Set(keys));
244
+ }
245
+
246
+ async function loadNormalizedProjectI18n() {
247
+ const loaderSource = `
248
+ import { pathToFileURL } from 'node:url';
249
+
250
+ const mod = await import(pathToFileURL(process.argv[1]).href);
251
+ const config = mod.THEME_CONFIG?.i18n;
252
+
253
+ if (!config) process.exit(2);
254
+
255
+ const normalized =
256
+ typeof mod.normalizeI18nConfig === 'function' ? mod.normalizeI18nConfig(config) : config;
257
+
258
+ process.stdout.write(JSON.stringify(normalized));
259
+ `;
260
+
261
+ const { stdout } = await execFile(
262
+ process.execPath,
263
+ ['--experimental-strip-types', '--input-type=module', '--eval', loaderSource, SITE_CONFIG_PATH],
264
+ {
265
+ cwd: process.cwd(),
266
+ encoding: 'utf8',
267
+ }
268
+ );
269
+
270
+ return JSON.parse(stdout);
47
271
  }
48
272
 
49
273
  async function resolveProjectLocales() {
50
- try {
51
- const configSource = await readFile(SITE_CONFIG_PATH, 'utf8');
52
- const locales = parseSupportedLocalesFromConfig(configSource);
53
- if (locales.length === 0) return [];
54
- return Array.from(new Set(locales));
55
- } catch {
56
- return [];
57
- }
274
+ try {
275
+ const normalized = await loadNormalizedProjectI18n();
276
+ const locales = Object.values(normalized.locales ?? {})
277
+ .filter((locale) => locale?.meta?.enabled)
278
+ .map((locale) => locale.code)
279
+ .filter((locale) => typeof locale === 'string');
280
+ if (locales.length > 0) {
281
+ return Array.from(new Set(locales));
282
+ }
283
+ } catch {
284
+ // Fall through to the source parser fallback for environments without TS loading support.
285
+ }
286
+
287
+ try {
288
+ const configSource = await readFile(SITE_CONFIG_PATH, 'utf8');
289
+ const locales = parseLocaleRegistryKeys(configSource);
290
+ if (locales.length === 0) return [];
291
+ return Array.from(new Set(locales));
292
+ } catch {
293
+ return [];
294
+ }
58
295
  }
59
296
 
60
297
  async function main() {
61
- const { slug, locales: cliLocales } = parseNewPostArgs(process.argv);
62
- if (!slug) {
63
- console.error(usageNewPost());
64
- process.exit(1);
65
- }
66
-
67
- if (!validatePostSlug(slug)) {
68
- console.error('Invalid slug. Use lowercase letters, numbers, and hyphens only.');
69
- process.exit(1);
70
- }
71
-
72
- const pubDate = new Date().toISOString().slice(0, 10);
73
- const defaultCovers = await loadDefaultCovers(DEFAULT_COVERS_ROOT);
74
- const projectLocales = await resolveProjectLocales();
75
- const locales = resolveLocales({
76
- cliLocales,
77
- envLocales: process.env.ANGLEFEINT_LOCALES ?? '',
78
- defaultLocales: projectLocales.length > 0 ? projectLocales : SUPPORTED_LOCALES,
79
- });
80
- const created = [];
81
- const skipped = [];
82
-
83
- for (const locale of locales) {
84
- const localeDir = path.join(CONTENT_ROOT, locale);
85
- const filePath = path.join(localeDir, `${slug}.md`);
86
- await mkdir(localeDir, { recursive: true });
87
-
88
- if (await exists(filePath)) {
89
- skipped.push(filePath);
90
- continue;
91
- }
92
-
93
- const heroImage = pickDefaultCoverBySlug(slug, localeDir, defaultCovers);
94
-
95
- await writeFile(filePath, buildNewPostTemplate(locale, slug, pubDate, heroImage), 'utf8');
96
- created.push(filePath);
97
- }
98
-
99
- if (created.length > 0) {
100
- console.log('Created files:');
101
- for (const filePath of created) console.log(`- ${filePath}`);
102
- }
103
-
104
- if (skipped.length > 0) {
105
- console.log('Skipped existing files:');
106
- for (const filePath of skipped) console.log(`- ${filePath}`);
107
- }
298
+ const { slug, locales: cliLocales } = parseNewPostArgs(process.argv);
299
+ if (!slug) {
300
+ console.error(usageNewPost());
301
+ process.exit(1);
302
+ }
303
+
304
+ if (!validatePostSlug(slug)) {
305
+ console.error('Invalid slug. Use lowercase letters, numbers, and hyphens only.');
306
+ process.exit(1);
307
+ }
308
+
309
+ const pubDate = new Date().toISOString().slice(0, 10);
310
+ const defaultCovers = await loadDefaultCovers(DEFAULT_COVERS_ROOT);
311
+ const projectLocales = await resolveProjectLocales();
312
+ const locales = resolveLocales({
313
+ cliLocales,
314
+ envLocales: process.env.ANGLEFEINT_LOCALES ?? '',
315
+ defaultLocales: projectLocales.length > 0 ? projectLocales : FALLBACK_LOCALES,
316
+ });
317
+ const created = [];
318
+ const skipped = [];
319
+
320
+ for (const locale of locales) {
321
+ const localeDir = path.join(CONTENT_ROOT, locale);
322
+ const filePath = path.join(localeDir, `${slug}.md`);
323
+ await mkdir(localeDir, { recursive: true });
324
+
325
+ if (await exists(filePath)) {
326
+ skipped.push(filePath);
327
+ continue;
328
+ }
329
+
330
+ const heroImage = pickDefaultCoverBySlug(slug, localeDir, defaultCovers);
331
+
332
+ await writeFile(filePath, buildNewPostTemplate(locale, slug, pubDate, heroImage), 'utf8');
333
+ created.push(filePath);
334
+ }
335
+
336
+ if (created.length > 0) {
337
+ console.log('Created files:');
338
+ for (const filePath of created) console.log(`- ${filePath}`);
339
+ }
340
+
341
+ if (skipped.length > 0) {
342
+ console.log('Skipped existing files:');
343
+ for (const filePath of skipped) console.log(`- ${filePath}`);
344
+ }
108
345
  }
109
346
 
110
347
  main().catch((error) => {
111
- console.error(error);
112
- process.exit(1);
348
+ console.error(error);
349
+ process.exit(1);
113
350
  });
@@ -9,6 +9,8 @@ import {
9
9
  DEFAULT_LOCALE,
10
10
  SUPPORTED_LOCALES,
11
11
  alternatePathForLocale,
12
+ getLocaleHreflang,
13
+ getLocaleOgLocale,
12
14
  isLocale,
13
15
  stripLocaleFromPath,
14
16
  } from '@anglefeint/site-i18n/config';
@@ -52,21 +54,14 @@ const rssURL = new URL(`/${currentLocale}/rss.xml`, siteURL);
52
54
  const localeSubpath = stripLocaleFromPath(Astro.url.pathname, currentLocale);
53
55
  const alternatePaths = SUPPORTED_LOCALES.map((locale) => ({
54
56
  locale,
57
+ hreflang: getLocaleHreflang(locale),
55
58
  href: new URL(alternatePathForLocale(locale, localeSubpath), siteURL).toString(),
56
59
  }));
57
60
  const xDefaultHref = new URL(alternatePathForLocale(DEFAULT_LOCALE, localeSubpath), siteURL);
58
-
59
- const OG_LOCALE: Record<string, string> = {
60
- en: 'en_US',
61
- ja: 'ja_JP',
62
- ko: 'ko_KR',
63
- es: 'es_ES',
64
- zh: 'zh_CN',
65
- };
66
- const ogLocale = OG_LOCALE[currentLocale] ?? 'en_US';
67
- const ogLocaleAlternates = SUPPORTED_LOCALES.filter((l) => l !== currentLocale).map(
68
- (l) => OG_LOCALE[l]
69
- );
61
+ const ogLocale = getLocaleOgLocale(currentLocale);
62
+ const ogLocaleAlternates = SUPPORTED_LOCALES.filter((locale) => locale !== currentLocale)
63
+ .map((locale) => getLocaleOgLocale(locale))
64
+ .filter(Boolean);
70
65
  const articleModifiedISO = (modifiedTime ?? publishedTime)?.toISOString();
71
66
 
72
67
  const websiteSchema = {
@@ -133,7 +128,7 @@ const jsonLdSchemas = [websiteSchema, personSchema, articleSchema, ...extraSchem
133
128
  <link rel="canonical" href={canonicalURL} />
134
129
  {
135
130
  alternatePaths.map((alternate) => (
136
- <link rel="alternate" hreflang={alternate.locale} href={alternate.href} />
131
+ <link rel="alternate" hreflang={alternate.hreflang} href={alternate.href} />
137
132
  ))
138
133
  }
139
134
  <link rel="alternate" hreflang="x-default" href={xDefaultHref} />
@@ -160,7 +155,7 @@ const jsonLdSchemas = [websiteSchema, personSchema, articleSchema, ...extraSchem
160
155
  <meta property="og:image:height" content={String(image.height)} />
161
156
  )
162
157
  }
163
- <meta property="og:locale" content={ogLocale} />
158
+ {ogLocale && <meta property="og:locale" content={ogLocale} />}
164
159
  {ogLocaleAlternates.map((loc) => <meta property="og:locale:alternate" content={loc} />)}
165
160
  {
166
161
  isArticle && publishedTime && (
@@ -1,50 +1,50 @@
1
1
  import {
2
- SUPPORTED_LOCALES as SUPPORTED_LOCALES_RUNTIME,
3
- DEFAULT_LOCALE as DEFAULT_LOCALE_RUNTIME,
2
+ SUPPORTED_LOCALES as SUPPORTED_LOCALES_RUNTIME,
3
+ DEFAULT_LOCALE as DEFAULT_LOCALE_RUNTIME,
4
4
  } from './locales.mjs';
5
5
 
6
- export type Locale = 'en' | 'ja' | 'ko' | 'es' | 'zh';
7
- export const SUPPORTED_LOCALES = SUPPORTED_LOCALES_RUNTIME as readonly Locale[];
8
- export const DEFAULT_LOCALE: Locale = DEFAULT_LOCALE_RUNTIME as Locale;
6
+ export type Locale = string;
7
+ export const SUPPORTED_LOCALES = SUPPORTED_LOCALES_RUNTIME as readonly string[];
8
+ export const DEFAULT_LOCALE: Locale = DEFAULT_LOCALE_RUNTIME;
9
9
 
10
- export const LOCALE_LABELS: Record<Locale, string> = {
11
- en: 'English',
12
- ja: '日本語',
13
- ko: '한국어',
14
- es: 'Español',
15
- zh: '中文',
10
+ export const LOCALE_LABELS: Record<string, string> = {
11
+ en: 'English',
12
+ ja: '日本語',
13
+ ko: '한국어',
14
+ es: 'Español',
15
+ zh: '中文',
16
16
  };
17
17
 
18
18
  export function isLocale(value: string): value is Locale {
19
- return (SUPPORTED_LOCALES as readonly string[]).includes(value);
19
+ return (SUPPORTED_LOCALES as readonly string[]).includes(value);
20
20
  }
21
21
 
22
22
  export function withLeadingSlash(path: string): string {
23
- if (!path) return '/';
24
- return path.startsWith('/') ? path : `/${path}`;
23
+ if (!path) return '/';
24
+ return path.startsWith('/') ? path : `/${path}`;
25
25
  }
26
26
 
27
- export function localePath(locale: Locale, path = '/'): string {
28
- const normalized = withLeadingSlash(path);
29
- if (normalized === '/') return `/${locale}/`;
30
- return `/${locale}${normalized.endsWith('/') ? normalized : `${normalized}/`}`;
27
+ export function localePath(locale: string, path = '/'): string {
28
+ const normalized = withLeadingSlash(path);
29
+ if (normalized === '/') return `/${locale}/`;
30
+ return `/${locale}${normalized.endsWith('/') ? normalized : `${normalized}/`}`;
31
31
  }
32
32
 
33
- export function stripLocaleFromPath(pathname: string, locale: Locale): string {
34
- const prefix = `/${locale}`;
35
- if (!pathname.startsWith(prefix)) return pathname;
36
- const withoutLocale = pathname.slice(prefix.length);
37
- return withoutLocale || '/';
33
+ export function stripLocaleFromPath(pathname: string, locale: string): string {
34
+ const prefix = `/${locale}`;
35
+ if (!pathname.startsWith(prefix)) return pathname;
36
+ const withoutLocale = pathname.slice(prefix.length);
37
+ return withoutLocale || '/';
38
38
  }
39
39
 
40
40
  export function blogIdToSlugAnyLocale(id: string): string {
41
- const parts = id.split('/');
42
- if (parts.length > 1 && isLocale(parts[0])) return parts.slice(1).join('/');
43
- return id;
41
+ const parts = id.split('/');
42
+ if (parts.length > 1 && isLocale(parts[0])) return parts.slice(1).join('/');
43
+ return id;
44
44
  }
45
45
 
46
46
  /** URL path for a locale's version of a page. For default locale home, returns / instead of /en/. */
47
- export function alternatePathForLocale(locale: Locale, subpath: string): string {
48
- if (locale === DEFAULT_LOCALE && (subpath === '/' || subpath === '')) return '/';
49
- return localePath(locale, subpath);
47
+ export function alternatePathForLocale(locale: string, subpath: string): string {
48
+ if (locale === DEFAULT_LOCALE && (subpath === '/' || subpath === '')) return '/';
49
+ return localePath(locale, subpath);
50
50
  }
@@ -1,3 +1,3 @@
1
+ // Fallback locale defaults for direct package consumers without a starter-level i18n registry.
1
2
  export const SUPPORTED_LOCALES = ['en', 'ja', 'ko', 'es', 'zh'];
2
3
  export const DEFAULT_LOCALE = 'en';
3
-
@@ -1,4 +1,4 @@
1
- import type { Locale } from './config';
1
+ import { DEFAULT_LOCALE, type Locale } from './config';
2
2
 
3
3
  export type Messages = {
4
4
  siteTitle: string;
@@ -70,7 +70,7 @@ export type Messages = {
70
70
  };
71
71
  };
72
72
 
73
- export const DEFAULT_MESSAGES: Record<Locale, Messages> = {
73
+ export const DEFAULT_MESSAGES: Record<string, Messages> = {
74
74
  en: {
75
75
  siteTitle: 'Angle Feint',
76
76
  siteDescription: 'Cinematic web interfaces and AI-era engineering essays.',
@@ -420,5 +420,5 @@ export const DEFAULT_MESSAGES: Record<Locale, Messages> = {
420
420
  };
421
421
 
422
422
  export function getMessages(locale: Locale): Messages {
423
- return DEFAULT_MESSAGES[locale];
423
+ return DEFAULT_MESSAGES[locale] ?? DEFAULT_MESSAGES[DEFAULT_LOCALE];
424
424
  }
@@ -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 };