@anglefeint/astro-theme 0.1.37 → 0.1.39

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
@@ -54,7 +54,7 @@ This package reads site-specific config from alias imports:
54
54
 
55
55
  In the starter/site project, map these aliases to `src/config/*` and `src/i18n/*` in both Vite and TS config.
56
56
 
57
- Giscus comments are configured from site-side `theme.comments` (core IDs + behavior fields like `mapping`, `inputPosition`, `theme`, and `lang`). If required core fields are not set, comments are not rendered.
57
+ Giscus comments are configured from site-side `theme.comments` (core IDs + behavior fields like `mapping`, `inputPosition`, `theme`, and `lang`). If required core fields are not set, comments are not rendered. When `mapping="specific"` set `term`; when `mapping="number"` set `number`.
58
58
 
59
59
  ## CLI
60
60
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anglefeint/astro-theme",
3
- "version": "0.1.37",
3
+ "version": "0.1.39",
4
4
  "type": "module",
5
5
  "description": "Anglefeint core theme package for Astro",
6
6
  "keywords": [
@@ -6,242 +6,248 @@ import HeaderLink from '../HeaderLink.astro';
6
6
  import LangSwitcher from './LangSwitcher.astro';
7
7
  import SocialMenu from './SocialMenu.astro';
8
8
  import {
9
- DEFAULT_LOCALE,
10
- LOCALE_LABELS,
11
- type Locale,
12
- SUPPORTED_LOCALES,
13
- alternatePathForLocale,
14
- isLocale,
15
- stripLocaleFromPath,
9
+ DEFAULT_LOCALE,
10
+ LOCALE_LABELS,
11
+ type Locale,
12
+ SUPPORTED_LOCALES,
13
+ alternatePathForLocale,
14
+ isLocale,
15
+ stripLocaleFromPath,
16
16
  } from '@anglefeint/site-i18n/config';
17
17
 
18
18
  interface Props {
19
- locale?: Locale;
20
- homeHref?: string;
21
- /** Optional per-locale overrides (used for existence-aware language fallback on some routes). */
22
- localeHrefs?: Partial<Record<Locale, string>>;
23
- /** Show Blade Runner scanlines overlay on ai-page (header/footer only) */
24
- scanlines?: boolean;
25
- labels?: {
26
- home: string;
27
- blog: string;
28
- about: string;
29
- status: string;
30
- language: string;
31
- };
19
+ locale?: Locale;
20
+ homeHref?: string;
21
+ /** Optional per-locale overrides (used for existence-aware language fallback on some routes). */
22
+ localeHrefs?: Partial<Record<Locale, string>>;
23
+ /** Show Blade Runner scanlines overlay on ai-page (header/footer only) */
24
+ scanlines?: boolean;
25
+ labels?: {
26
+ home: string;
27
+ blog: string;
28
+ about: string;
29
+ status: string;
30
+ statusAria: string;
31
+ language: string;
32
+ };
32
33
  }
33
34
 
34
35
  const props = Astro.props as Props;
35
36
  const locale: Locale = props.locale && isLocale(props.locale) ? props.locale : DEFAULT_LOCALE;
36
37
  const labels = {
37
- home: props.labels?.home ?? 'Home',
38
- blog: props.labels?.blog ?? 'Blog',
39
- about: props.labels?.about ?? 'About',
40
- status: props.labels?.status ?? 'system: online',
41
- language: props.labels?.language ?? 'Language',
38
+ home: props.labels?.home ?? 'Home',
39
+ blog: props.labels?.blog ?? 'Blog',
40
+ about: props.labels?.about ?? 'About',
41
+ status: props.labels?.status ?? 'system: online',
42
+ statusAria: props.labels?.statusAria ?? 'System status',
43
+ language: props.labels?.language ?? 'Language',
42
44
  };
43
45
  const showAbout = THEME.ENABLE_ABOUT_PAGE;
44
46
  const currentSubpath = stripLocaleFromPath(Astro.url.pathname, locale);
45
47
  const homeHref = props.homeHref ?? alternatePathForLocale(locale, '/');
46
48
  const buildLocaleHref = (targetLocale: Locale) => {
47
- // Language switcher: preserve current route when switching locales.
48
- // Only fall back for About when the feature is disabled.
49
- const sectionSubpath =
50
- currentSubpath.startsWith('/about') && !showAbout ? '/' : currentSubpath || '/';
51
- return alternatePathForLocale(targetLocale, sectionSubpath);
49
+ // Language switcher: preserve current route when switching locales.
50
+ // Only fall back for About when the feature is disabled.
51
+ const sectionSubpath =
52
+ currentSubpath.startsWith('/about') && !showAbout ? '/' : currentSubpath || '/';
53
+ return alternatePathForLocale(targetLocale, sectionSubpath);
52
54
  };
53
55
  const localeOptions = SUPPORTED_LOCALES.map((targetLocale) => ({
54
- locale: targetLocale,
55
- label: LOCALE_LABELS[targetLocale],
56
- href: props.localeHrefs?.[targetLocale] ?? buildLocaleHref(targetLocale),
56
+ locale: targetLocale,
57
+ label: LOCALE_LABELS[targetLocale],
58
+ href: props.localeHrefs?.[targetLocale] ?? buildLocaleHref(targetLocale),
57
59
  }));
58
60
  ---
59
61
 
60
62
  <header>
61
- <nav>
62
- <div class="nav-left">
63
- <h2><a href={homeHref}>{SITE_TITLE}</a></h2>
64
- </div>
65
- <div class="internal-links">
66
- <HeaderLink href={homeHref}>{labels.home}</HeaderLink>
67
- <HeaderLink href={alternatePathForLocale(locale, '/blog/')}>{labels.blog}</HeaderLink>
68
- {showAbout && <HeaderLink href={alternatePathForLocale(locale, '/about/')}>{labels.about}</HeaderLink>}
69
- </div>
70
- <div class="nav-right">
71
- <div class="social-links">
72
- <LangSwitcher label={labels.language} currentLocale={locale} options={localeOptions} />
73
- <div class="nav-status" aria-label="System status">
74
- <span class="nav-status-dot" aria-hidden="true"></span>
75
- <span class="nav-status-text">{labels.status}</span>
76
- </div>
77
- <SocialMenu links={SOCIAL_LINKS} />
78
- </div>
79
- </div>
80
- </nav>
81
- {props.scanlines && <div class="ai-scanlines" aria-hidden="true"></div>}
63
+ <nav>
64
+ <div class="nav-left">
65
+ <h2><a href={homeHref}>{SITE_TITLE}</a></h2>
66
+ </div>
67
+ <div class="internal-links">
68
+ <HeaderLink href={homeHref}>{labels.home}</HeaderLink>
69
+ <HeaderLink href={alternatePathForLocale(locale, '/blog/')}>{labels.blog}</HeaderLink>
70
+ {
71
+ showAbout && (
72
+ <HeaderLink href={alternatePathForLocale(locale, '/about/')}>{labels.about}</HeaderLink>
73
+ )
74
+ }
75
+ </div>
76
+ <div class="nav-right">
77
+ <div class="social-links">
78
+ <LangSwitcher label={labels.language} currentLocale={locale} options={localeOptions} />
79
+ <div class="nav-status" aria-label={labels.statusAria}>
80
+ <span class="nav-status-dot" aria-hidden="true"></span>
81
+ <span class="nav-status-text">{labels.status}</span>
82
+ </div>
83
+ <SocialMenu links={SOCIAL_LINKS} />
84
+ </div>
85
+ </div>
86
+ </nav>
87
+ {props.scanlines && <div class="ai-scanlines" aria-hidden="true" />}
82
88
  </header>
83
89
  <style>
84
- header {
85
- margin: 0;
86
- padding: 0 1em;
87
- background: var(--chrome-bg, var(--bg));
88
- border-bottom: 1px solid var(--chrome-border, rgb(var(--border)));
89
- }
90
- h2 {
91
- margin: 0;
92
- font-size: 1em;
93
- }
90
+ header {
91
+ margin: 0;
92
+ padding: 0 1em;
93
+ background: var(--chrome-bg, var(--bg));
94
+ border-bottom: 1px solid var(--chrome-border, rgb(var(--border)));
95
+ }
96
+ h2 {
97
+ margin: 0;
98
+ font-size: 1em;
99
+ }
94
100
 
95
- h2 a,
96
- h2 a.active {
97
- text-decoration: none;
98
- color: var(--chrome-link, rgb(var(--text)));
99
- border-bottom: none;
100
- }
101
- nav {
102
- display: grid;
103
- grid-template-columns: 1fr auto 1fr;
104
- align-items: center;
105
- gap: 0.6rem;
106
- min-height: 56px;
107
- width: 100%;
108
- }
109
- .nav-left {
110
- justify-self: start;
111
- }
112
- .internal-links {
113
- justify-self: center;
114
- display: flex;
115
- align-items: center;
116
- gap: 0.1rem;
117
- }
118
- .nav-right {
119
- justify-self: end;
120
- display: flex;
121
- align-items: center;
122
- gap: 0.35rem;
123
- min-width: max-content;
124
- }
125
- .internal-links :global(a) {
126
- padding: 1em 0.5em;
127
- color: var(--chrome-link, rgb(var(--text)));
128
- border-bottom: 4px solid transparent;
129
- text-decoration: none;
130
- }
131
- .internal-links :global(a.active) {
132
- text-decoration: none;
133
- border-bottom-color: var(--chrome-active, var(--accent));
134
- }
135
- .social-links a {
136
- color: var(--chrome-link, rgb(var(--text)));
137
- align-items: center;
138
- justify-content: center;
139
- padding: 0.6rem 0.35rem;
140
- border-bottom: none;
141
- }
142
- .social-links a:hover {
143
- color: var(--chrome-link-hover, var(--chrome-link, rgb(var(--text))));
144
- }
145
- .social-links,
146
- .social-links a {
147
- display: flex;
148
- }
149
- .social-links {
150
- align-items: center;
151
- gap: 0.4rem;
152
- position: relative;
153
- flex-wrap: nowrap;
154
- --lang-switcher-border: rgba(132, 214, 255, 0.2);
155
- --lang-switcher-bg: rgba(6, 16, 30, 0.35);
156
- --lang-label-color: rgba(190, 226, 248, 0.78);
157
- --lang-select-arrow: rgba(204, 236, 252, 0.82);
158
- --lang-select-bg: rgba(9, 22, 40, 0.68);
159
- --lang-select-border: rgba(132, 214, 255, 0.2);
160
- --lang-select-text: rgba(204, 236, 252, 0.86);
161
- --lang-select-focus-border: rgba(132, 214, 255, 0.5);
162
- --lang-select-focus-ring: rgba(98, 180, 228, 0.18);
163
- --lang-select-option-text: #ccecfb;
164
- --lang-select-option-bg: #0b1c32;
165
- }
166
- .nav-status {
167
- display: none;
168
- align-items: center;
169
- gap: 0.45rem;
170
- padding: 0.24rem 0.5rem;
171
- border-radius: 999px;
172
- font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
173
- font-size: 0.62rem;
174
- letter-spacing: 0.12em;
175
- text-transform: uppercase;
176
- color: rgba(186, 232, 252, 0.86);
177
- background: rgba(6, 16, 30, 0.52);
178
- border: 1px solid rgba(132, 214, 255, 0.22);
179
- box-shadow:
180
- 0 0 0 1px rgba(132, 214, 255, 0.08),
181
- 0 0 16px rgba(90, 180, 255, 0.16);
182
- white-space: nowrap;
183
- position: absolute;
184
- right: calc(100% + 0.5rem);
185
- top: 50%;
186
- transform: translateY(-50%);
187
- pointer-events: none;
188
- }
189
- .nav-status-dot {
190
- width: 0.44rem;
191
- height: 0.44rem;
192
- border-radius: 50%;
193
- background: rgba(150, 226, 255, 0.96);
194
- box-shadow: 0 0 10px rgba(122, 210, 255, 0.72);
195
- animation: nav-status-pulse 1.8s steps(1, end) infinite;
196
- }
197
- @keyframes nav-status-pulse {
198
- 0%,
199
- 78%,
200
- 100% {
201
- opacity: 1;
202
- transform: scale(1);
203
- }
204
- 82% {
205
- opacity: 0.2;
206
- transform: scale(0.7);
207
- }
208
- 86% {
209
- opacity: 1;
210
- transform: scale(1.05);
211
- }
212
- 90% {
213
- opacity: 0.28;
214
- transform: scale(0.78);
215
- }
216
- }
217
- body.ai-page .nav-status {
218
- display: inline-flex;
219
- }
220
- body.about-page .nav-status {
221
- display: none;
222
- }
223
- @media (max-width: 720px) {
224
- nav {
225
- grid-template-columns: 1fr;
226
- gap: 0;
227
- }
228
- .nav-left {
229
- display: none;
230
- }
231
- .internal-links {
232
- justify-self: start;
233
- }
234
- .nav-right {
235
- justify-self: end;
236
- }
237
- .nav-status {
238
- display: none !important;
239
- }
240
- .social-links {
241
- display: none;
242
- }
243
- .lang-switcher {
244
- margin-right: 0;
245
- }
246
- }
101
+ h2 a,
102
+ h2 a.active {
103
+ text-decoration: none;
104
+ color: var(--chrome-link, rgb(var(--text)));
105
+ border-bottom: none;
106
+ }
107
+ nav {
108
+ display: grid;
109
+ grid-template-columns: 1fr auto 1fr;
110
+ align-items: center;
111
+ gap: 0.6rem;
112
+ min-height: 56px;
113
+ width: 100%;
114
+ }
115
+ .nav-left {
116
+ justify-self: start;
117
+ }
118
+ .internal-links {
119
+ justify-self: center;
120
+ display: flex;
121
+ align-items: center;
122
+ gap: 0.1rem;
123
+ }
124
+ .nav-right {
125
+ justify-self: end;
126
+ display: flex;
127
+ align-items: center;
128
+ gap: 0.35rem;
129
+ min-width: max-content;
130
+ }
131
+ .internal-links :global(a) {
132
+ padding: 1em 0.5em;
133
+ color: var(--chrome-link, rgb(var(--text)));
134
+ border-bottom: 4px solid transparent;
135
+ text-decoration: none;
136
+ }
137
+ .internal-links :global(a.active) {
138
+ text-decoration: none;
139
+ border-bottom-color: var(--chrome-active, var(--accent));
140
+ }
141
+ .social-links a {
142
+ color: var(--chrome-link, rgb(var(--text)));
143
+ align-items: center;
144
+ justify-content: center;
145
+ padding: 0.6rem 0.35rem;
146
+ border-bottom: none;
147
+ }
148
+ .social-links a:hover {
149
+ color: var(--chrome-link-hover, var(--chrome-link, rgb(var(--text))));
150
+ }
151
+ .social-links,
152
+ .social-links a {
153
+ display: flex;
154
+ }
155
+ .social-links {
156
+ align-items: center;
157
+ gap: 0.4rem;
158
+ position: relative;
159
+ flex-wrap: nowrap;
160
+ --lang-switcher-border: rgba(132, 214, 255, 0.2);
161
+ --lang-switcher-bg: rgba(6, 16, 30, 0.35);
162
+ --lang-label-color: rgba(190, 226, 248, 0.78);
163
+ --lang-select-arrow: rgba(204, 236, 252, 0.82);
164
+ --lang-select-bg: rgba(9, 22, 40, 0.68);
165
+ --lang-select-border: rgba(132, 214, 255, 0.2);
166
+ --lang-select-text: rgba(204, 236, 252, 0.86);
167
+ --lang-select-focus-border: rgba(132, 214, 255, 0.5);
168
+ --lang-select-focus-ring: rgba(98, 180, 228, 0.18);
169
+ --lang-select-option-text: #ccecfb;
170
+ --lang-select-option-bg: #0b1c32;
171
+ }
172
+ .nav-status {
173
+ display: none;
174
+ align-items: center;
175
+ gap: 0.45rem;
176
+ padding: 0.24rem 0.5rem;
177
+ border-radius: 999px;
178
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
179
+ font-size: 0.62rem;
180
+ letter-spacing: 0.12em;
181
+ text-transform: uppercase;
182
+ color: rgba(186, 232, 252, 0.86);
183
+ background: rgba(6, 16, 30, 0.52);
184
+ border: 1px solid rgba(132, 214, 255, 0.22);
185
+ box-shadow:
186
+ 0 0 0 1px rgba(132, 214, 255, 0.08),
187
+ 0 0 16px rgba(90, 180, 255, 0.16);
188
+ white-space: nowrap;
189
+ position: absolute;
190
+ right: calc(100% + 0.5rem);
191
+ top: 50%;
192
+ transform: translateY(-50%);
193
+ pointer-events: none;
194
+ }
195
+ .nav-status-dot {
196
+ width: 0.44rem;
197
+ height: 0.44rem;
198
+ border-radius: 50%;
199
+ background: rgba(150, 226, 255, 0.96);
200
+ box-shadow: 0 0 10px rgba(122, 210, 255, 0.72);
201
+ animation: nav-status-pulse 1.8s steps(1, end) infinite;
202
+ }
203
+ @keyframes nav-status-pulse {
204
+ 0%,
205
+ 78%,
206
+ 100% {
207
+ opacity: 1;
208
+ transform: scale(1);
209
+ }
210
+ 82% {
211
+ opacity: 0.2;
212
+ transform: scale(0.7);
213
+ }
214
+ 86% {
215
+ opacity: 1;
216
+ transform: scale(1.05);
217
+ }
218
+ 90% {
219
+ opacity: 0.28;
220
+ transform: scale(0.78);
221
+ }
222
+ }
223
+ body.ai-page .nav-status {
224
+ display: inline-flex;
225
+ }
226
+ body.about-page .nav-status {
227
+ display: none;
228
+ }
229
+ @media (max-width: 720px) {
230
+ nav {
231
+ grid-template-columns: 1fr;
232
+ gap: 0;
233
+ }
234
+ .nav-left {
235
+ display: none;
236
+ }
237
+ .internal-links {
238
+ justify-self: start;
239
+ }
240
+ .nav-right {
241
+ justify-self: end;
242
+ }
243
+ .nav-status {
244
+ display: none !important;
245
+ }
246
+ .social-links {
247
+ display: none;
248
+ }
249
+ .lang-switcher {
250
+ margin-right: 0;
251
+ }
252
+ }
247
253
  </style>
@@ -3,46 +3,46 @@ import { SOCIAL_LINKS, type SocialLink } from '@anglefeint/site-config/social';
3
3
  import Icon from './Icon.astro';
4
4
 
5
5
  interface Props {
6
- links?: SocialLink[];
7
- iconSize?: number;
8
- showPlaceholdersWhenEmpty?: boolean;
6
+ links?: SocialLink[];
7
+ iconSize?: number;
8
+ showPlaceholdersWhenEmpty?: boolean;
9
9
  }
10
10
 
11
11
  const {
12
- links = SOCIAL_LINKS,
13
- iconSize = 32,
14
- showPlaceholdersWhenEmpty = true,
12
+ links = SOCIAL_LINKS,
13
+ iconSize = 32,
14
+ showPlaceholdersWhenEmpty = true,
15
15
  } = Astro.props as Props;
16
16
  const placeholderLinks: SocialLink[] = [
17
- { href: '', label: 'Mastodon', icon: 'mastodon' },
18
- { href: '', label: 'Twitter', icon: 'twitter' },
19
- { href: '', label: 'GitHub', icon: 'github' },
17
+ { href: '', label: 'Mastodon', icon: 'mastodon' },
18
+ { href: '', label: 'Twitter', icon: 'twitter' },
19
+ { href: '', label: 'GitHub', icon: 'github' },
20
20
  ];
21
21
  const resolvedLinks = links.length > 0 || !showPlaceholdersWhenEmpty ? links : placeholderLinks;
22
22
  ---
23
23
 
24
24
  {
25
- resolvedLinks.map((link) => (
26
- links.length > 0 ? (
27
- <a href={link.href} target="_blank" rel="noopener noreferrer">
28
- <span class="sr-only">{link.label}</span>
29
- {link.icon ? <Icon name={link.icon} size={iconSize} /> : <span>{link.label}</span>}
30
- </a>
31
- ) : (
32
- <span class="social-placeholder" aria-hidden="true" title="Configure social links in src/site.config.ts">
33
- {link.icon ? <Icon name={link.icon} size={iconSize} /> : <span>{link.label}</span>}
34
- </span>
35
- )
36
- ))
25
+ resolvedLinks.map((link) =>
26
+ links.length > 0 ? (
27
+ <a href={link.href} target="_blank" rel="noopener noreferrer">
28
+ <span class="sr-only">{link.label}</span>
29
+ {link.icon ? <Icon name={link.icon} size={iconSize} /> : <span>{link.label}</span>}
30
+ </a>
31
+ ) : (
32
+ <span class="social-placeholder" aria-hidden="true">
33
+ {link.icon ? <Icon name={link.icon} size={iconSize} /> : <span>{link.label}</span>}
34
+ </span>
35
+ )
36
+ )
37
37
  }
38
38
 
39
39
  <style>
40
- .social-placeholder {
41
- display: inline-flex;
42
- align-items: center;
43
- justify-content: center;
44
- opacity: 0.45;
45
- filter: saturate(0.7);
46
- cursor: default;
47
- }
40
+ .social-placeholder {
41
+ display: inline-flex;
42
+ align-items: center;
43
+ justify-content: center;
44
+ opacity: 0.45;
45
+ filter: saturate(0.7);
46
+ cursor: default;
47
+ }
48
48
  </style>
@@ -7,79 +7,80 @@ import { getMessages } from '@anglefeint/site-i18n/messages';
7
7
  import type { ImageMetadata } from 'astro';
8
8
 
9
9
  interface Props {
10
- locale?: Locale;
11
- title: string;
12
- description: string;
13
- image?: ImageMetadata;
14
- pageType?: 'website' | 'article';
15
- publishedTime?: Date;
16
- modifiedTime?: Date;
17
- author?: string;
18
- tags?: string[];
19
- schema?: Record<string, unknown> | Record<string, unknown>[];
20
- noindex?: boolean;
21
- bodyClass: string;
22
- mainClass?: string;
23
- scanlines?: boolean;
24
- localeHrefs?: Partial<Record<Locale, string>>;
10
+ locale?: Locale;
11
+ title: string;
12
+ description: string;
13
+ image?: ImageMetadata;
14
+ pageType?: 'website' | 'article';
15
+ publishedTime?: Date;
16
+ modifiedTime?: Date;
17
+ author?: string;
18
+ tags?: string[];
19
+ schema?: Record<string, unknown> | Record<string, unknown>[];
20
+ noindex?: boolean;
21
+ bodyClass: string;
22
+ mainClass?: string;
23
+ scanlines?: boolean;
24
+ localeHrefs?: Partial<Record<Locale, string>>;
25
25
  }
26
26
 
27
27
  const rawLocale = Astro.props.locale ?? DEFAULT_LOCALE;
28
28
  const locale: Locale = isLocale(rawLocale) ? rawLocale : DEFAULT_LOCALE;
29
29
  const {
30
- title,
31
- description,
32
- image,
33
- pageType,
34
- publishedTime,
35
- modifiedTime,
36
- author,
37
- tags,
38
- schema,
39
- noindex,
40
- bodyClass,
41
- mainClass = 'page-main',
42
- scanlines = false,
43
- localeHrefs,
30
+ title,
31
+ description,
32
+ image,
33
+ pageType,
34
+ publishedTime,
35
+ modifiedTime,
36
+ author,
37
+ tags,
38
+ schema,
39
+ noindex,
40
+ bodyClass,
41
+ mainClass = 'page-main',
42
+ scanlines = false,
43
+ localeHrefs,
44
44
  } = Astro.props as Props;
45
45
  const messages = getMessages(locale);
46
46
  ---
47
47
 
48
48
  <!doctype html>
49
49
  <html lang={locale}>
50
- <head>
51
- <BaseHead
52
- title={title}
53
- description={description}
54
- image={image}
55
- pageType={pageType}
56
- publishedTime={publishedTime}
57
- modifiedTime={modifiedTime}
58
- author={author}
59
- tags={tags}
60
- schema={schema}
61
- noindex={noindex}
62
- />
63
- <slot name="head" />
64
- </head>
65
- <body class={bodyClass}>
66
- <slot name="body-start" />
67
- <CommonHeader
68
- locale={locale}
69
- localeHrefs={localeHrefs}
70
- scanlines={scanlines}
71
- labels={{
72
- home: messages.nav.home,
73
- blog: messages.nav.blog,
74
- about: messages.nav.about,
75
- status: messages.nav.status,
76
- language: messages.langLabel,
77
- }}
78
- />
79
- <main class={mainClass}>
80
- <slot />
81
- </main>
82
- <CommonFooter scanlines={scanlines} />
83
- <slot name="body-end" />
84
- </body>
50
+ <head>
51
+ <BaseHead
52
+ title={title}
53
+ description={description}
54
+ image={image}
55
+ pageType={pageType}
56
+ publishedTime={publishedTime}
57
+ modifiedTime={modifiedTime}
58
+ author={author}
59
+ tags={tags}
60
+ schema={schema}
61
+ noindex={noindex}
62
+ />
63
+ <slot name="head" />
64
+ </head>
65
+ <body class={bodyClass}>
66
+ <slot name="body-start" />
67
+ <CommonHeader
68
+ locale={locale}
69
+ localeHrefs={localeHrefs}
70
+ scanlines={scanlines}
71
+ labels={{
72
+ home: messages.nav.home,
73
+ blog: messages.nav.blog,
74
+ about: messages.nav.about,
75
+ status: messages.nav.status,
76
+ statusAria: messages.nav.statusAria,
77
+ language: messages.langLabel,
78
+ }}
79
+ />
80
+ <main class={mainClass}>
81
+ <slot />
82
+ </main>
83
+ <CommonFooter scanlines={scanlines} />
84
+ <slot name="body-end" />
85
+ </body>
85
86
  </html>
@@ -35,6 +35,8 @@ export const THEME = {
35
35
  CATEGORY: '',
36
36
  CATEGORY_ID: '',
37
37
  MAPPING: 'pathname',
38
+ TERM: '',
39
+ NUMBER: '',
38
40
  STRICT: '0',
39
41
  REACTIONS_ENABLED: '1',
40
42
  EMIT_METADATA: '0',
@@ -9,6 +9,7 @@ export type Messages = {
9
9
  blog: string;
10
10
  about: string;
11
11
  status: string;
12
+ statusAria: string;
12
13
  };
13
14
  home: {
14
15
  hero: string;
@@ -41,10 +42,27 @@ export type Messages = {
41
42
  related: string;
42
43
  comments: string;
43
44
  responseOutput: string;
45
+ rqBadge: string;
46
+ rqReplayAria: string;
47
+ metaPublished: string;
48
+ metaUpdated: string;
49
+ metaReadMinutes: string;
50
+ systemStatusAria: string;
51
+ systemModelLabel: string;
52
+ systemModeLabel: string;
53
+ systemStateLabel: string;
54
+ promptContextLabel: string;
55
+ latencyLabel: string;
56
+ confidenceLabel: string;
57
+ statsWords: string;
58
+ statsTokens: string;
44
59
  heroMonitor: string;
45
60
  heroSignalSync: string;
46
61
  heroModelOnline: string;
47
62
  regenerate: string;
63
+ relatedAria: string;
64
+ backToBlogAria: string;
65
+ paginationAria: string;
48
66
  toastP10: string;
49
67
  toastP30: string;
50
68
  toastP60: string;
@@ -57,7 +75,13 @@ export const DEFAULT_MESSAGES: Record<Locale, Messages> = {
57
75
  siteTitle: 'Angle Feint',
58
76
  siteDescription: 'Cinematic web interfaces and AI-era engineering essays.',
59
77
  langLabel: 'Language',
60
- nav: { home: 'Home', blog: 'Blog', about: 'About', status: 'system: online' },
78
+ nav: {
79
+ home: 'Home',
80
+ blog: 'Blog',
81
+ about: 'About',
82
+ status: 'system: online',
83
+ statusAria: 'System status',
84
+ },
61
85
  home: {
62
86
  hero: 'Write a short introduction for your site and what readers can expect from your posts.',
63
87
  latest: 'Latest Posts',
@@ -89,10 +113,27 @@ export const DEFAULT_MESSAGES: Record<Locale, Messages> = {
89
113
  related: 'Related',
90
114
  comments: 'Comments',
91
115
  responseOutput: 'Output',
116
+ rqBadge: 'monitor feed',
117
+ rqReplayAria: 'Replay monitor feed',
118
+ metaPublished: 'published',
119
+ metaUpdated: 'updated',
120
+ metaReadMinutes: 'min read',
121
+ systemStatusAria: 'Model status',
122
+ systemModelLabel: 'model',
123
+ systemModeLabel: 'mode',
124
+ systemStateLabel: 'state',
125
+ promptContextLabel: 'Context',
126
+ latencyLabel: 'latency est',
127
+ confidenceLabel: 'confidence',
128
+ statsWords: 'words',
129
+ statsTokens: 'tokens',
92
130
  heroMonitor: 'neural monitor',
93
131
  heroSignalSync: 'signal sync active',
94
132
  heroModelOnline: 'model online',
95
133
  regenerate: 'Regenerate',
134
+ relatedAria: 'Related posts',
135
+ backToBlogAria: 'Back to blog',
136
+ paginationAria: 'Pagination',
96
137
  toastP10: 'context parsed 10%',
97
138
  toastP30: 'context parsed 30%',
98
139
  toastP60: 'inference stable 60%',
@@ -103,7 +144,13 @@ export const DEFAULT_MESSAGES: Record<Locale, Messages> = {
103
144
  siteTitle: 'Angle Feint',
104
145
  siteDescription: '映画的なWebインターフェースとAI時代のエンジニアリング考察。',
105
146
  langLabel: '言語',
106
- nav: { home: 'ホーム', blog: 'ブログ', about: 'プロフィール', status: 'system: online' },
147
+ nav: {
148
+ home: 'ホーム',
149
+ blog: 'ブログ',
150
+ about: 'プロフィール',
151
+ status: 'system: online',
152
+ statusAria: 'システム状態',
153
+ },
107
154
  home: {
108
155
  hero: 'このサイトの紹介文と、読者がどんな記事を期待できるかを書いてください。',
109
156
  latest: '最新記事',
@@ -135,10 +182,27 @@ export const DEFAULT_MESSAGES: Record<Locale, Messages> = {
135
182
  related: '関連記事',
136
183
  comments: 'コメント',
137
184
  responseOutput: '出力',
185
+ rqBadge: 'モニターフィード',
186
+ rqReplayAria: 'モニターフィードを再生',
187
+ metaPublished: '公開',
188
+ metaUpdated: '更新',
189
+ metaReadMinutes: '分で読了',
190
+ systemStatusAria: 'モデル状態',
191
+ systemModelLabel: 'モデル',
192
+ systemModeLabel: 'モード',
193
+ systemStateLabel: '状態',
194
+ promptContextLabel: 'コンテキスト',
195
+ latencyLabel: '推定レイテンシ',
196
+ confidenceLabel: '信頼度',
197
+ statsWords: '語',
198
+ statsTokens: 'トークン',
138
199
  heroMonitor: 'ニューラルモニター',
139
200
  heroSignalSync: 'シグナル同期中',
140
201
  heroModelOnline: 'モデルオンライン',
141
202
  regenerate: '再生成',
203
+ relatedAria: '関連記事',
204
+ backToBlogAria: 'ブログへ戻る',
205
+ paginationAria: 'ページネーション',
142
206
  toastP10: '文脈解析 10%',
143
207
  toastP30: '文脈解析 30%',
144
208
  toastP60: '推論安定 60%',
@@ -149,7 +213,13 @@ export const DEFAULT_MESSAGES: Record<Locale, Messages> = {
149
213
  siteTitle: 'Angle Feint',
150
214
  siteDescription: '시네마틱 웹 인터페이스와 AI 시대 엔지니어링 에세이.',
151
215
  langLabel: '언어',
152
- nav: { home: '홈', blog: '블로그', about: '소개', status: 'system: online' },
216
+ nav: {
217
+ home: '홈',
218
+ blog: '블로그',
219
+ about: '소개',
220
+ status: 'system: online',
221
+ statusAria: '시스템 상태',
222
+ },
153
223
  home: {
154
224
  hero: '사이트 소개와 방문자가 어떤 글을 기대할 수 있는지 간단히 작성하세요.',
155
225
  latest: '최신 글',
@@ -181,10 +251,27 @@ export const DEFAULT_MESSAGES: Record<Locale, Messages> = {
181
251
  related: '관련 글',
182
252
  comments: '댓글',
183
253
  responseOutput: '출력',
254
+ rqBadge: '모니터 피드',
255
+ rqReplayAria: '모니터 피드 다시 재생',
256
+ metaPublished: '게시',
257
+ metaUpdated: '수정',
258
+ metaReadMinutes: '분 읽기',
259
+ systemStatusAria: '모델 상태',
260
+ systemModelLabel: '모델',
261
+ systemModeLabel: '모드',
262
+ systemStateLabel: '상태',
263
+ promptContextLabel: '컨텍스트',
264
+ latencyLabel: '지연 추정',
265
+ confidenceLabel: '신뢰도',
266
+ statsWords: '단어',
267
+ statsTokens: '토큰',
184
268
  heroMonitor: '뉴럴 모니터',
185
269
  heroSignalSync: '신호 동기화 활성',
186
270
  heroModelOnline: '모델 온라인',
187
271
  regenerate: '재생성',
272
+ relatedAria: '관련 글',
273
+ backToBlogAria: '블로그로 돌아가기',
274
+ paginationAria: '페이지네이션',
188
275
  toastP10: '컨텍스트 파싱 10%',
189
276
  toastP30: '컨텍스트 파싱 30%',
190
277
  toastP60: '추론 안정화 60%',
@@ -195,7 +282,13 @@ export const DEFAULT_MESSAGES: Record<Locale, Messages> = {
195
282
  siteTitle: 'Angle Feint',
196
283
  siteDescription: 'Interfaces web cinematográficas y ensayos de ingeniería en la era de IA.',
197
284
  langLabel: 'Idioma',
198
- nav: { home: 'Inicio', blog: 'Blog', about: 'Sobre mí', status: 'system: online' },
285
+ nav: {
286
+ home: 'Inicio',
287
+ blog: 'Blog',
288
+ about: 'Sobre mí',
289
+ status: 'system: online',
290
+ statusAria: 'Estado del sistema',
291
+ },
199
292
  home: {
200
293
  hero: 'Escribe una breve presentación del sitio y qué tipo de contenido encontrarán tus lectores.',
201
294
  latest: 'Últimas publicaciones',
@@ -228,10 +321,27 @@ export const DEFAULT_MESSAGES: Record<Locale, Messages> = {
228
321
  related: 'Relacionados',
229
322
  comments: 'Comentarios',
230
323
  responseOutput: 'Salida',
324
+ rqBadge: 'monitor de señal',
325
+ rqReplayAria: 'Reproducir monitor de señal',
326
+ metaPublished: 'publicado',
327
+ metaUpdated: 'actualizado',
328
+ metaReadMinutes: 'min de lectura',
329
+ systemStatusAria: 'Estado del modelo',
330
+ systemModelLabel: 'modelo',
331
+ systemModeLabel: 'modo',
332
+ systemStateLabel: 'estado',
333
+ promptContextLabel: 'Contexto',
334
+ latencyLabel: 'latencia est',
335
+ confidenceLabel: 'confianza',
336
+ statsWords: 'palabras',
337
+ statsTokens: 'tokens',
231
338
  heroMonitor: 'monitor neural',
232
339
  heroSignalSync: 'sincronización de señal activa',
233
340
  heroModelOnline: 'modelo en línea',
234
341
  regenerate: 'Regenerar',
342
+ relatedAria: 'Publicaciones relacionadas',
343
+ backToBlogAria: 'Volver al blog',
344
+ paginationAria: 'Paginación',
235
345
  toastP10: 'contexto analizado 10%',
236
346
  toastP30: 'contexto analizado 30%',
237
347
  toastP60: 'inferencia estable 60%',
@@ -242,7 +352,13 @@ export const DEFAULT_MESSAGES: Record<Locale, Messages> = {
242
352
  siteTitle: 'Angle Feint',
243
353
  siteDescription: '电影感网页界面与 AI 时代工程实践文章。',
244
354
  langLabel: '语言',
245
- nav: { home: '首页', blog: '博客', about: '关于', status: 'system: online' },
355
+ nav: {
356
+ home: '首页',
357
+ blog: '博客',
358
+ about: '关于',
359
+ status: 'system: online',
360
+ statusAria: '系统状态',
361
+ },
246
362
  home: {
247
363
  hero: '在这里写一段站点简介,并告诉读者你将发布什么类型的内容。',
248
364
  latest: '最新文章',
@@ -274,10 +390,27 @@ export const DEFAULT_MESSAGES: Record<Locale, Messages> = {
274
390
  related: '相关文章',
275
391
  comments: '评论',
276
392
  responseOutput: '输出',
393
+ rqBadge: '监视器信号',
394
+ rqReplayAria: '重放监视器信号',
395
+ metaPublished: '发布',
396
+ metaUpdated: '更新',
397
+ metaReadMinutes: '分钟阅读',
398
+ systemStatusAria: '模型状态',
399
+ systemModelLabel: '模型',
400
+ systemModeLabel: '模式',
401
+ systemStateLabel: '状态',
402
+ promptContextLabel: '语境',
403
+ latencyLabel: '延迟估计',
404
+ confidenceLabel: '置信度',
405
+ statsWords: '词',
406
+ statsTokens: '令牌',
277
407
  heroMonitor: '神经监视器',
278
408
  heroSignalSync: '信号同步中',
279
409
  heroModelOnline: '模型在线',
280
410
  regenerate: '重新生成',
411
+ relatedAria: '相关文章',
412
+ backToBlogAria: '返回博客',
413
+ paginationAria: '分页导航',
281
414
  toastP10: '语境解析 10%',
282
415
  toastP30: '语境解析 30%',
283
416
  toastP60: '推理稳定 60%',
@@ -58,8 +58,24 @@ const hasStats = aiModel || wordCount !== undefined || tokenCount !== undefined;
58
58
  const confidenceText = aiConfidence !== undefined ? aiConfidence.toFixed(2) : undefined;
59
59
  const enableRedQueen = THEME.EFFECTS.ENABLE_RED_QUEEN;
60
60
  const comments = THEME.COMMENTS;
61
+ const normalizedCommentTerm = comments.TERM.trim();
62
+ const normalizedCommentNumber = comments.NUMBER.trim();
63
+ const hasValidCommentNumber =
64
+ /^[1-9]\d*$/.test(normalizedCommentNumber) &&
65
+ Number.isSafeInteger(Number(normalizedCommentNumber));
66
+ const hasMappingParam =
67
+ comments.MAPPING === 'specific'
68
+ ? Boolean(normalizedCommentTerm)
69
+ : comments.MAPPING === 'number'
70
+ ? hasValidCommentNumber
71
+ : true;
61
72
  const hasCommentsConfig = Boolean(
62
- comments.ENABLED && comments.REPO && comments.REPO_ID && comments.CATEGORY && comments.CATEGORY_ID
73
+ comments.ENABLED &&
74
+ comments.REPO &&
75
+ comments.REPO_ID &&
76
+ comments.CATEGORY &&
77
+ comments.CATEGORY_ID &&
78
+ hasMappingParam
63
79
  );
64
80
  ---
65
81
 
@@ -105,13 +121,13 @@ const hasCommentsConfig = Boolean(
105
121
  data-rq-src2={themeRedqueen2.src}
106
122
  />
107
123
  <div class="rq-tv-badge">
108
- monitor feed
124
+ {messages.blog.rqBadge}
109
125
  <span class="rq-tv-dot" />
110
126
  </div>
111
127
  <button
112
128
  type="button"
113
129
  class="rq-tv-toggle"
114
- aria-label="Replay monitor feed"
130
+ aria-label={messages.blog.rqReplayAria}
115
131
  aria-expanded="false"
116
132
  >
117
133
 
@@ -178,23 +194,51 @@ const hasCommentsConfig = Boolean(
178
194
  <div class="prose">
179
195
  <div class="title ai-title">
180
196
  <div class="ai-meta-terminal">
181
- $ published {fmt(pubDate)}
182
- {updatedDate && <> | updated {fmt(updatedDate)}</>}
183
- {readMinutes !== undefined && <> | ~{readMinutes} min read</>}
197
+ $ {messages.blog.metaPublished}
198
+ {fmt(pubDate)}
199
+ {
200
+ updatedDate && (
201
+ <>
202
+ {' '}
203
+ | {messages.blog.metaUpdated} {fmt(updatedDate)}
204
+ </>
205
+ )
206
+ }
207
+ {
208
+ readMinutes !== undefined && (
209
+ <>
210
+ {' '}
211
+ | ~{readMinutes} {messages.blog.metaReadMinutes}
212
+ </>
213
+ )
214
+ }
184
215
  </div>
185
216
  {
186
217
  hasSystemMeta && (
187
- <div class="ai-system-row" aria-label="Model status">
188
- {aiModel && <span class="ai-system-chip">model: {aiModel}</span>}
189
- {aiMode && <span class="ai-system-chip">mode: {aiMode}</span>}
190
- {aiState && <span class="ai-system-chip">state: {aiState}</span>}
218
+ <div class="ai-system-row" aria-label={messages.blog.systemStatusAria}>
219
+ {aiModel && (
220
+ <span class="ai-system-chip">
221
+ {messages.blog.systemModelLabel}: {aiModel}
222
+ </span>
223
+ )}
224
+ {aiMode && (
225
+ <span class="ai-system-chip">
226
+ {messages.blog.systemModeLabel}: {aiMode}
227
+ </span>
228
+ )}
229
+ {aiState && (
230
+ <span class="ai-system-chip">
231
+ {messages.blog.systemStateLabel}: {aiState}
232
+ </span>
233
+ )}
191
234
  </div>
192
235
  )
193
236
  }
194
237
  {
195
238
  contextText && (
196
239
  <div class="ai-prompt-line">
197
- Context: <span class="ai-prompt-topic">{contextText}</span>
240
+ {messages.blog.promptContextLabel}: <span class="ai-prompt-topic">{contextText}</span>{' '}
241
+
198
242
  </div>
199
243
  )
200
244
  }
@@ -212,12 +256,12 @@ const hasCommentsConfig = Boolean(
212
256
  <div class="ai-response-meta">
213
257
  {aiLatencyMs !== undefined && (
214
258
  <span>
215
- latency est <strong>{aiLatencyMs}</strong> ms
259
+ {messages.blog.latencyLabel} <strong>{aiLatencyMs}</strong> ms
216
260
  </span>
217
261
  )}
218
262
  {confidenceText !== undefined && (
219
263
  <span>
220
- confidence <strong>{confidenceText}</strong>
264
+ {messages.blog.confidenceLabel} <strong>{confidenceText}</strong>
221
265
  </span>
222
266
  )}
223
267
  </div>
@@ -234,9 +278,17 @@ const hasCommentsConfig = Boolean(
234
278
  <div class="ai-stats-corner">
235
279
  {aiModel && <span class="ai-model-id">{aiModel}</span>}
236
280
  {aiModel && (wordCount !== undefined || tokenCount !== undefined) && ' · '}
237
- {wordCount !== undefined && <span>{compact(wordCount)} words</span>}
281
+ {wordCount !== undefined && (
282
+ <span>
283
+ {compact(wordCount)} {messages.blog.statsWords}
284
+ </span>
285
+ )}
238
286
  {wordCount !== undefined && tokenCount !== undefined && ' · '}
239
- {tokenCount !== undefined && <span>{compact(tokenCount)} tokens</span>}
287
+ {tokenCount !== undefined && (
288
+ <span>
289
+ {compact(tokenCount)} {messages.blog.statsTokens}
290
+ </span>
291
+ )}
240
292
  </div>
241
293
  )
242
294
  }
@@ -247,7 +299,7 @@ const hasCommentsConfig = Boolean(
247
299
  </article>
248
300
  {
249
301
  related.length > 0 && (
250
- <section class="ai-related" aria-label="Related posts">
302
+ <section class="ai-related" aria-label={messages.blog.relatedAria}>
251
303
  <h2 class="ai-related-title">{messages.blog.related}</h2>
252
304
  <div class="ai-related-grid">
253
305
  {related.map((p) => (
@@ -274,7 +326,7 @@ const hasCommentsConfig = Boolean(
274
326
  </section>
275
327
  )
276
328
  }
277
- <nav class="ai-back-to-blog" aria-label="Back to blog">
329
+ <nav class="ai-back-to-blog" aria-label={messages.blog.backToBlogAria}>
278
330
  <a href={localePath(resolvedLocale, '/blog/')}>
279
331
  <span class="ai-back-prompt">$</span>
280
332
  <span class="ai-back-text">← {messages.blog.backToBlog}</span>
@@ -290,6 +342,8 @@ const hasCommentsConfig = Boolean(
290
342
  data-category={comments.CATEGORY}
291
343
  data-category-id={comments.CATEGORY_ID}
292
344
  data-mapping={comments.MAPPING}
345
+ data-term={comments.MAPPING === 'specific' ? normalizedCommentTerm : undefined}
346
+ data-number={comments.MAPPING === 'number' ? normalizedCommentNumber : undefined}
293
347
  data-strict={comments.STRICT}
294
348
  data-reactions-enabled={comments.REACTIONS_ENABLED}
295
349
  data-emit-metadata={comments.EMIT_METADATA}
@@ -25,7 +25,7 @@ export function initAboutModals(runtimeConfig, prefersReducedMotion) {
25
25
  },
26
26
  ai: {
27
27
  title: 'AI',
28
- body: '<pre>~ $ ai --status --verbose\n\nmodel: anglefeint-core\nmode: reasoning + builder\ncontext window: 128k\ntools: codex / cursor / claude-code\nlatency: 120-220ms\nsafety: guardrails enabled\n\n&gt;&gt; system online\n&gt;&gt; ready for execution</pre>',
28
+ body: '<pre>~ $ ai --status --verbose\n\nmodel: runtime-default\nmode: standard\ncontext window: 32k\nlatency: 100-250ms\nsafety: enabled\n\n&gt;&gt; system online\n&gt;&gt; ready</pre>',
29
29
  type: 'plain',
30
30
  },
31
31
  decryptor: {