create-portfoliable 1.0.3
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/bin/create-portfoliable.mjs +425 -0
- package/cli/portfoliable.mjs +836 -0
- package/index.html +18 -0
- package/package.json +102 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/scripts/check-updates.mjs +31 -0
- package/scripts/ensure-valence-index-css.mjs +245 -0
- package/scripts/generate-password-hash.mjs +25 -0
- package/scripts/release-orchestrator.mjs +363 -0
- package/scripts/scaffold-case.mjs +460 -0
- package/scripts/scaffold-consumer.mjs +87 -0
- package/scripts/smoke-homeview.mjs +133 -0
- package/scripts/smoke-initializer.mjs +129 -0
- package/scripts/smoke-packed.mjs +184 -0
- package/scripts/switch-valence-source.mjs +162 -0
- package/scripts/sync-template-shared.mjs +136 -0
- package/scripts/terminal-ui.mjs +274 -0
- package/scripts/verify-integration.mjs +72 -0
- package/src/App.js +10015 -0
- package/src/header-visibility.contract.js +31 -0
- package/src/header-visibility.contract.test.js +41 -0
- package/src/main.js +12 -0
- package/src/parser/markdown.js +855 -0
- package/src/style.css +88 -0
- package/templates/README.md +242 -0
- package/templates/configs/i18n/i18n.config.js +230 -0
- package/templates/configs/i18n/i18n.labels.js +188 -0
- package/templates/configs/portfoliable.a11y.config.js +262 -0
- package/templates/configs/portfoliable.design.config.js +651 -0
- package/templates/gitignore +9 -0
- package/templates/index.html +20 -0
- package/templates/public/api/.htaccess +4 -0
- package/templates/public/api/README.md +27 -0
- package/templates/public/api/password.config.example.json +16 -0
- package/templates/public/api/password.config.json +16 -0
- package/templates/public/api/unlock-case.php +56 -0
- package/templates/public/favicon.ico +0 -0
- package/templates/public/favicon.png +0 -0
- package/templates/public/portfoliable-logo.svg +3 -0
- package/templates/scripts/add-language.mjs +253 -0
- package/templates/scripts/convert-video-to-mp4.mjs +207 -0
- package/templates/scripts/convert-wav-to-mp3.mjs +184 -0
- package/templates/scripts/delete-case.mjs +320 -0
- package/templates/scripts/delete-language.mjs +195 -0
- package/templates/scripts/generate-password-hash.mjs +98 -0
- package/templates/scripts/i18n-config-utils.mjs +123 -0
- package/templates/scripts/scaffold-case.mjs +444 -0
- package/templates/scripts/sync-locales.mjs +1048 -0
- package/templates/scripts/validate-content.mjs +257 -0
- package/templates/scripts/validate-protection.mjs +187 -0
- package/templates/src/cases/index.js +188 -0
- package/templates/src/content/about/ABOUTME.md +202 -0
- package/templates/src/content/cases/template-case/case.md +479 -0
- package/templates/src/content/cases/template-case/cover.png +0 -0
- package/templates/src/content/cases/template-case/he-cover.png +0 -0
- package/templates/src/content/cases/template-case/template-audio.mp3 +0 -0
- package/templates/src/content/cases/template-case/template-video.mp4 +0 -0
- package/templates/src/content/thumbnail-options.generated.json +1044 -0
- package/templates/src/main.js +23 -0
- package/templates/src/parser/markdown.js +805 -0
- package/vite.config.js +111 -0
package/src/style.css
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/* File: create-portfoliable/src/style.css */
|
|
2
|
+
/* Purpose: Define global runtime style resets and baseline viewport/body behavior. */
|
|
3
|
+
/* Author: Lio Schimanko */
|
|
4
|
+
|
|
5
|
+
/* MARK: NOTES */
|
|
6
|
+
/* This stylesheet intentionally avoids local @import dependencies. */
|
|
7
|
+
|
|
8
|
+
/* MARK: GLOBAL RESET */
|
|
9
|
+
*, *::before, *::after {
|
|
10
|
+
box-sizing: border-box;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/* MARK: ROOT VIEWPORT SURFACE */
|
|
14
|
+
body {
|
|
15
|
+
margin: 0;
|
|
16
|
+
background-color: var(--color-bg, var(--color-player-bg, #101218));
|
|
17
|
+
color: var(--color-black);
|
|
18
|
+
font-family: var(--font-family);
|
|
19
|
+
-webkit-font-smoothing: antialiased;
|
|
20
|
+
overflow: hidden;
|
|
21
|
+
height: 100vh;
|
|
22
|
+
width: 100vw;
|
|
23
|
+
transition: background-color 280ms ease;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
html {
|
|
27
|
+
background-color: var(--color-bg, var(--color-player-bg, #101218));
|
|
28
|
+
transition: background-color 280ms ease;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@media (prefers-reduced-motion: reduce) {
|
|
32
|
+
html,
|
|
33
|
+
body {
|
|
34
|
+
transition: none;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* MARK: A11Y DARK THEME TOKENS */
|
|
39
|
+
/* Mirrors Valence accessibility token roles so dark-mode toggles always affect the full app shell. */
|
|
40
|
+
html.a11y-dark-mode {
|
|
41
|
+
--color-bg: var(--color-player-bg);
|
|
42
|
+
--color-surface: #1C1C1E;
|
|
43
|
+
--color-surface-border: rgba(255, 255, 255, 0.12);
|
|
44
|
+
|
|
45
|
+
--color-black: #FFFFFF;
|
|
46
|
+
--color-text-primary: #FFFFFF;
|
|
47
|
+
--color-text-secondary: #F5F5F5;
|
|
48
|
+
--color-text-muted: #B2B2B2;
|
|
49
|
+
|
|
50
|
+
--color-card-bg: var(--color-surface);
|
|
51
|
+
--color-card-border: var(--color-surface-border);
|
|
52
|
+
--color-kbd-border: rgba(255, 255, 255, 0.2);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* MARK: A11Y HIGH CONTRAST TOKENS */
|
|
56
|
+
/* Forces maximum contrast token values for users who opt into high-contrast mode. */
|
|
57
|
+
html.a11y-high-contrast {
|
|
58
|
+
--color-bg: #000000;
|
|
59
|
+
--color-surface: #000000;
|
|
60
|
+
--color-surface-border: #FFFFFF;
|
|
61
|
+
|
|
62
|
+
--color-black: #FFFFFF;
|
|
63
|
+
--color-text-primary: #FFFFFF;
|
|
64
|
+
--color-text-secondary: #FFFFFF;
|
|
65
|
+
--color-text-muted: #FFFFFF;
|
|
66
|
+
|
|
67
|
+
--color-gray-dark: #FFFFFF;
|
|
68
|
+
--color-gray-med: #FFFFFF;
|
|
69
|
+
--color-gray-light: #FFFFFF;
|
|
70
|
+
|
|
71
|
+
--color-card-bg: #000000;
|
|
72
|
+
--color-card-border: #FFFFFF;
|
|
73
|
+
--color-kbd-border: #FFFFFF;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/* MARK: A11Y FORCED COLORS TOKENS */
|
|
77
|
+
/* Maps runtime tokens to system color keywords when forced-colors is active. */
|
|
78
|
+
html.a11y-forced-colors {
|
|
79
|
+
--color-bg: Canvas;
|
|
80
|
+
--color-surface: Canvas;
|
|
81
|
+
--color-text-primary: CanvasText;
|
|
82
|
+
--color-text-secondary: CanvasText;
|
|
83
|
+
--color-text-muted: GrayText;
|
|
84
|
+
|
|
85
|
+
--color-card-bg: Canvas;
|
|
86
|
+
--color-card-border: CanvasText;
|
|
87
|
+
--color-surface-border: CanvasText;
|
|
88
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# Portfoliable user manual
|
|
2
|
+
|
|
3
|
+
This is the short end-user guide for a project created with:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm create portfoliable@latest
|
|
7
|
+
# or
|
|
8
|
+
npm create portfoliable
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Use it after scaffolding to run, edit, validate, and publish your portfolio.
|
|
12
|
+
|
|
13
|
+
For a more detailed user-manual, please visit the official [Portfoliable documentation](https://www.portfoliable.js.org/docs/)
|
|
14
|
+
|
|
15
|
+
## 1. Create and run a project
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm create portfoliable@latest my-portfolio
|
|
19
|
+
# or
|
|
20
|
+
npm create portfoliable my-portfolio
|
|
21
|
+
cd my-portfolio
|
|
22
|
+
npm run portfoliable
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The app starts in local development mode and opens the browser by default.
|
|
26
|
+
|
|
27
|
+
## 2. Main commands
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm run portfoliable
|
|
31
|
+
npm run build
|
|
32
|
+
npm run preview
|
|
33
|
+
npm run validate:content
|
|
34
|
+
npm run convert:audio
|
|
35
|
+
npm run convert:video
|
|
36
|
+
npm run portfoliable-create-case -- --name "My First Case"
|
|
37
|
+
npm run add:language -- --code es --name Español --html-lang es-ES
|
|
38
|
+
npm run portfoliable-thumbnail-options
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## 3. Generated project structure
|
|
42
|
+
|
|
43
|
+
A generated project usually includes:
|
|
44
|
+
|
|
45
|
+
- `src/content/cases/` for case markdown files
|
|
46
|
+
- `src/content/about/` for About content
|
|
47
|
+
- `configs/` for runtime and locale configuration
|
|
48
|
+
- `public/` for static assets and backend endpoints
|
|
49
|
+
|
|
50
|
+
## 4. Create and edit a case
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
npm run portfoliable-create-case -- --name "My Case"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Each case should include:
|
|
57
|
+
|
|
58
|
+
- a stable `id`
|
|
59
|
+
- localized `title` and `shortDesc`
|
|
60
|
+
- valid thumbnail metadata
|
|
61
|
+
- readable localized markdown sections
|
|
62
|
+
- correct share and link metadata when needed
|
|
63
|
+
|
|
64
|
+
Example:
|
|
65
|
+
|
|
66
|
+
```md
|
|
67
|
+
<!-- lang:en -->
|
|
68
|
+
## Project context
|
|
69
|
+
This is the English case body.
|
|
70
|
+
|
|
71
|
+
<!-- lang:pt -->
|
|
72
|
+
## Contexto do projeto
|
|
73
|
+
Este é o corpo do caso em português.
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## 5. Add and remove languages
|
|
77
|
+
|
|
78
|
+
To add a language, you can use the following command:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
npm run add:language -- --code es --name Español --html-lang es-ES
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
To add locale-specific languages, you can use `--rtl` or `--ltf`, which will set the `dir` to left-to-right or right-to-left.
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
npm run add:language -- --code ar --name العربية --html-lang ar-SA --rtl
|
|
88
|
+
npm run add:language -- --code it --name Italiano --html-lang it-IT --ltr
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
To remove a locale, use:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
npm run delete:language -- --code es
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
You can also delete a default locale using:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
npx portfoliable delete-language --code es --force
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Then fill in translated metadata and content blocks for the new locale.
|
|
104
|
+
|
|
105
|
+
## 6. Thumbnail device catalog
|
|
106
|
+
|
|
107
|
+
Inspect supported device combinations:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
npm run portfoliable-thumbnail-options
|
|
111
|
+
npm run portfoliable-thumbnail-options -- --full
|
|
112
|
+
npm run portfoliable-thumbnail-options -- --json
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Use the generated tuple in your case config, for example:
|
|
116
|
+
|
|
117
|
+
```text
|
|
118
|
+
thumbCategory=mobile thumbBrand=apple thumbModel=Apple iPhone 15 thumbColor=Black
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## 7. Validate before shipping
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
npm run validate:content
|
|
125
|
+
npm run build
|
|
126
|
+
npm run preview
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Validate early so missing metadata and broken locale sections are caught before production builds.
|
|
130
|
+
|
|
131
|
+
## 8. Protected cases
|
|
132
|
+
|
|
133
|
+
If a case needs a secure unlock flow, configure the backend endpoint in your app config and keep the real secret server-side.
|
|
134
|
+
|
|
135
|
+
Do not store passwords directly in markdown files.
|
|
136
|
+
|
|
137
|
+
### The setup flow
|
|
138
|
+
|
|
139
|
+
1. Mark the case as protected in its frontmatter:
|
|
140
|
+
|
|
141
|
+
```js
|
|
142
|
+
{
|
|
143
|
+
id: 'mobile-product-launch',
|
|
144
|
+
isProtected: true
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
2. Keep the unlock endpoint in the generated project config:
|
|
149
|
+
|
|
150
|
+
```js
|
|
151
|
+
export default {
|
|
152
|
+
protection: {
|
|
153
|
+
unlockEndpoint: '/api/unlock-case.php'
|
|
154
|
+
}
|
|
155
|
+
};
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
3. Copy the example backend config into your project and add a hash for each protected case:
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
cp public/api/password.config.example.json public/api/password.config.json
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Then add a record like:
|
|
165
|
+
|
|
166
|
+
```json
|
|
167
|
+
{
|
|
168
|
+
"cases": {
|
|
169
|
+
"mobile-product-launch": {
|
|
170
|
+
"hash": "$argon2id$v=19$m=65536,t=4,p=1$...generated-hash..."
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
4. Generate a secure password hash:
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
npm run password:hash -- --case-id mobile-product-launch --password "your-secret"
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
5. Keep the real file private and protected by the server rules in `public/api/.htaccess`.
|
|
183
|
+
|
|
184
|
+
### Important protections
|
|
185
|
+
|
|
186
|
+
- `public/api/password.config.json` should stay out of git
|
|
187
|
+
- the app should unlock the case only through the PHP endpoint
|
|
188
|
+
- the unlock response should be treated as non-cacheable
|
|
189
|
+
- frontend markdown is never the place to keep the real secret
|
|
190
|
+
|
|
191
|
+
When a protected case loads, the app prompts for a passcode before revealing the locked content.
|
|
192
|
+
|
|
193
|
+
## 9. Useful behavior to know
|
|
194
|
+
|
|
195
|
+
- the home view renders case cards from metadata
|
|
196
|
+
- cases support localized routes and localized metadata
|
|
197
|
+
- the app supports visibility rules for web, crawler, and AI exposure
|
|
198
|
+
- the reader can include summaries, audio, table of contents, and navigation controls
|
|
199
|
+
|
|
200
|
+
## 10. Convert WAV files to MP3
|
|
201
|
+
|
|
202
|
+
If you keep narration or voice assets as WAV while editing, you can convert them to MP3 before shipping:
|
|
203
|
+
|
|
204
|
+
```bash
|
|
205
|
+
npm run convert:audio
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
This command scans your project for `.wav` files and writes `.mp3` files in the same folders.
|
|
209
|
+
|
|
210
|
+
Use this optional flag to target one directory:
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
npm run convert:audio -- --dir public/audio
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
`ffmpeg` is required (on macOS: `brew install ffmpeg`).
|
|
217
|
+
|
|
218
|
+
## 11. Convert videos to MP4
|
|
219
|
+
|
|
220
|
+
If you keep source footage in mixed formats while editing, you can convert videos to MP4 before shipping:
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
npm run convert:video
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
This command scans your project for video files (for example `.mov`, `.mkv`, `.avi`, `.webm`) and writes `.mp4` files in the same folders.
|
|
227
|
+
|
|
228
|
+
Use this optional flag to target one directory:
|
|
229
|
+
|
|
230
|
+
```bash
|
|
231
|
+
npm run convert:video -- --dir public/video
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Use `--force` to overwrite existing `.mp4` outputs:
|
|
235
|
+
|
|
236
|
+
```bash
|
|
237
|
+
npm run convert:video -- --dir public/video --force
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
`ffmpeg` is required (on macOS: `brew install ffmpeg`).
|
|
241
|
+
|
|
242
|
+
This is the user-facing contract. For maintainer internals, use the repository root README instead.
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
// File: templates/configs/i18n/i18n.config.js
|
|
2
|
+
// Purpose: Provide runtime language configuration, translation resolution, and i18n helpers.
|
|
3
|
+
// Author: Lio Schimanko
|
|
4
|
+
|
|
5
|
+
// MARK: IMPORTS
|
|
6
|
+
// Loads static translation labels and keyboard shortcut bindings used for runtime labels.
|
|
7
|
+
import labelsByLocale from './i18n.labels.js';
|
|
8
|
+
import { getShortcutDisplay, shortcutLabelBindings } from '../portfoliable.a11y.config.js';
|
|
9
|
+
|
|
10
|
+
// MARK: LOCALE SOURCE OF TRUTH
|
|
11
|
+
// Add/remove locales here. Keep keys quoted so Node scripts can parse this object reliably.
|
|
12
|
+
export const LANGUAGE_CONFIG = Object.freeze({
|
|
13
|
+
"en": {
|
|
14
|
+
"name": "English",
|
|
15
|
+
"htmlLang": "en-US"
|
|
16
|
+
},
|
|
17
|
+
"he": {
|
|
18
|
+
"name": "עברית",
|
|
19
|
+
"htmlLang": "he-IL"
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const DEFAULT_LOCALE = 'en';
|
|
24
|
+
export const SUPPORTED_LOCALES = Object.freeze(Object.keys(LANGUAGE_CONFIG));
|
|
25
|
+
|
|
26
|
+
// Canonical list of RTL locale codes for direction detection.
|
|
27
|
+
export const RTL_LOCALE_CODES = Object.freeze([
|
|
28
|
+
"arc",
|
|
29
|
+
"az-arab",
|
|
30
|
+
"azb",
|
|
31
|
+
"bal-arab",
|
|
32
|
+
"ckb",
|
|
33
|
+
"dv",
|
|
34
|
+
"fa",
|
|
35
|
+
"ff-adlm",
|
|
36
|
+
"glk",
|
|
37
|
+
"ha-arab",
|
|
38
|
+
"he",
|
|
39
|
+
"khw",
|
|
40
|
+
"ks-arab",
|
|
41
|
+
"ku-arab",
|
|
42
|
+
"lrc",
|
|
43
|
+
"mzn",
|
|
44
|
+
"nqo",
|
|
45
|
+
"pa-arab",
|
|
46
|
+
"pnb",
|
|
47
|
+
"ps",
|
|
48
|
+
"sd",
|
|
49
|
+
"syr",
|
|
50
|
+
"ug",
|
|
51
|
+
"ur",
|
|
52
|
+
"yi"
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
// MARK: LOCALE NORMALIZATION
|
|
56
|
+
// Normalizes arbitrary locale input into a lowercased canonical code.
|
|
57
|
+
export function normalizeLocaleCode(value) {
|
|
58
|
+
return String(value || '').trim().toLowerCase();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Resolves a locale to one of SUPPORTED_LOCALES or falls back to DEFAULT_LOCALE.
|
|
62
|
+
export function resolveLocaleCode(value, fallback = DEFAULT_LOCALE) {
|
|
63
|
+
const normalized = normalizeLocaleCode(value);
|
|
64
|
+
if (SUPPORTED_LOCALES.includes(normalized)) {
|
|
65
|
+
return normalized;
|
|
66
|
+
}
|
|
67
|
+
return fallback;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Returns true when a locale or its base language code is RTL.
|
|
71
|
+
export function isRtlLocaleCode(value) {
|
|
72
|
+
const normalized = normalizeLocaleCode(value);
|
|
73
|
+
if (!normalized) return false;
|
|
74
|
+
if (RTL_LOCALE_CODES.includes(normalized)) return true;
|
|
75
|
+
const baseLocale = normalized.split('-')[0];
|
|
76
|
+
return RTL_LOCALE_CODES.includes(baseLocale);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Resolves text direction for a locale with safe fallback behavior.
|
|
80
|
+
export function resolveLocaleDirection(value, fallback = 'ltr') {
|
|
81
|
+
const normalized = normalizeLocaleCode(value);
|
|
82
|
+
if (isRtlLocaleCode(normalized)) {
|
|
83
|
+
return 'rtl';
|
|
84
|
+
}
|
|
85
|
+
if (SUPPORTED_LOCALES.includes(normalized)) {
|
|
86
|
+
return 'ltr';
|
|
87
|
+
}
|
|
88
|
+
return fallback === 'rtl' ? 'rtl' : 'ltr';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// MARK: LABEL RESOLUTION
|
|
92
|
+
// Returns a human-readable language name from config, Intl, or locale code fallback.
|
|
93
|
+
export function getLanguageDisplayName(localeCode) {
|
|
94
|
+
const normalized = resolveLocaleCode(localeCode, DEFAULT_LOCALE);
|
|
95
|
+
const configured = LANGUAGE_CONFIG[normalized]?.name;
|
|
96
|
+
if (typeof configured === 'string' && configured.trim().length > 0) {
|
|
97
|
+
return configured.trim();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
if (typeof Intl !== 'undefined' && typeof Intl.DisplayNames === 'function') {
|
|
102
|
+
const displayNames = new Intl.DisplayNames([normalized], { type: 'language' });
|
|
103
|
+
const resolved = displayNames.of(normalized);
|
|
104
|
+
if (resolved && resolved !== normalized) {
|
|
105
|
+
return resolved;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// Uses locale code fallback below.
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return normalized;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// MARK: ACTIVE LOCALE RESOLUTION
|
|
116
|
+
// Resolves current locale from path segment, then query param, then default.
|
|
117
|
+
function resolveActiveLanguage() {
|
|
118
|
+
const pathSegments = String(window.location.pathname || '')
|
|
119
|
+
.split('/')
|
|
120
|
+
.filter(Boolean)
|
|
121
|
+
.map((segment) => normalizeLocaleCode(segment));
|
|
122
|
+
const pathLocale = pathSegments[0] || '';
|
|
123
|
+
if (SUPPORTED_LOCALES.includes(pathLocale)) {
|
|
124
|
+
return pathLocale;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const urlParams = new URLSearchParams(window.location.search);
|
|
128
|
+
const urlLang = normalizeLocaleCode(urlParams.get('lang'));
|
|
129
|
+
if (SUPPORTED_LOCALES.includes(urlLang)) {
|
|
130
|
+
return urlLang;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return DEFAULT_LOCALE;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// MARK: SHORTCUT MARKUP HELPERS
|
|
137
|
+
// Appends kbd markup to a base label when a shortcut is available.
|
|
138
|
+
function appendShortcutLabel(baseLabel, shortcutValue, kbdClass = '') {
|
|
139
|
+
if (!baseLabel || !shortcutValue) return baseLabel;
|
|
140
|
+
const classMarkup = kbdClass ? ` class="${kbdClass}"` : '';
|
|
141
|
+
return `${baseLabel} <kbd${classMarkup}>${shortcutValue}</kbd>`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Keys that remain plain text without runtime kbd markup.
|
|
145
|
+
const SKIP_SHORTCUT_MARKUP_KEYS = new Set([
|
|
146
|
+
'btn_return',
|
|
147
|
+
'a11y_size',
|
|
148
|
+
'a11y_dark',
|
|
149
|
+
'a11y_contrast',
|
|
150
|
+
'a11y_motion',
|
|
151
|
+
'a11y_tab',
|
|
152
|
+
'a11y_dyslexia'
|
|
153
|
+
]);
|
|
154
|
+
|
|
155
|
+
// MARK: TRANSLATION MAP BOOTSTRAP
|
|
156
|
+
// Builds localized runtime labels by merging default-locale labels with per-locale overrides.
|
|
157
|
+
function buildRuntimeTranslations() {
|
|
158
|
+
const baseTranslations = labelsByLocale && typeof labelsByLocale === 'object'
|
|
159
|
+
? labelsByLocale
|
|
160
|
+
: {};
|
|
161
|
+
const runtime = {};
|
|
162
|
+
|
|
163
|
+
SUPPORTED_LOCALES.forEach((localeCode) => {
|
|
164
|
+
runtime[localeCode] = {
|
|
165
|
+
...(baseTranslations[DEFAULT_LOCALE] || {}),
|
|
166
|
+
...(baseTranslations[localeCode] || {})
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
// Always expose explicit localized language labels for contextual menu rendering.
|
|
170
|
+
runtime[localeCode][`lang_${localeCode}`] = getLanguageDisplayName(localeCode);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
Object.entries(shortcutLabelBindings).forEach(([translationKey, binding]) => {
|
|
174
|
+
if (SKIP_SHORTCUT_MARKUP_KEYS.has(translationKey)) return;
|
|
175
|
+
|
|
176
|
+
const shortcutValue = getShortcutDisplay(binding.shortcutId);
|
|
177
|
+
if (!shortcutValue) return;
|
|
178
|
+
|
|
179
|
+
SUPPORTED_LOCALES.forEach((localeCode) => {
|
|
180
|
+
const baseLabel = runtime[localeCode]?.[translationKey];
|
|
181
|
+
runtime[localeCode][translationKey] = appendShortcutLabel(baseLabel, shortcutValue, binding.kbdClass || '');
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
return runtime;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Builds the immutable runtime translation cache once at module load.
|
|
189
|
+
const runtimeTranslations = buildRuntimeTranslations();
|
|
190
|
+
|
|
191
|
+
// MARK: DOCUMENT LANGUAGE BOOTSTRAP
|
|
192
|
+
// Initializes the active locale and applies lang/dir metadata to the document element.
|
|
193
|
+
window.currentLang = resolveActiveLanguage();
|
|
194
|
+
const htmlLangTag = LANGUAGE_CONFIG[window.currentLang]?.htmlLang || window.currentLang;
|
|
195
|
+
document.documentElement.lang = htmlLangTag;
|
|
196
|
+
document.documentElement.dir = resolveLocaleDirection(window.currentLang, 'ltr');
|
|
197
|
+
|
|
198
|
+
// MARK: PUBLIC TRANSLATION API
|
|
199
|
+
// Exposes runtime translation maps and keys requiring plain text insertion.
|
|
200
|
+
export const translations = runtimeTranslations;
|
|
201
|
+
const PLAIN_TEXT_I18N_KEYS = new Set(['h1_title']);
|
|
202
|
+
|
|
203
|
+
// Returns translated value for the active locale with fallback to default locale and key.
|
|
204
|
+
export function t(key) {
|
|
205
|
+
const activeLocale = resolveLocaleCode(window.currentLang, DEFAULT_LOCALE);
|
|
206
|
+
return translations[activeLocale]?.[key] || translations[DEFAULT_LOCALE]?.[key] || key;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// MARK: DOM TRANSLATION APPLIER
|
|
210
|
+
// Applies translations to data-i18n nodes.
|
|
211
|
+
export function applyTranslations() {
|
|
212
|
+
// Applies runtime labels to any document node annotated with data-i18n.
|
|
213
|
+
document.querySelectorAll('[data-i18n]').forEach((el) => {
|
|
214
|
+
const key = el.getAttribute('data-i18n');
|
|
215
|
+
if (!key) return;
|
|
216
|
+
|
|
217
|
+
const activeLocale = resolveLocaleCode(window.currentLang, DEFAULT_LOCALE);
|
|
218
|
+
if (translations[activeLocale] && translations[activeLocale][key]) {
|
|
219
|
+
const localizedValue = translations[activeLocale][key];
|
|
220
|
+
if (PLAIN_TEXT_I18N_KEYS.has(key)) {
|
|
221
|
+
el.textContent = localizedValue;
|
|
222
|
+
} else {
|
|
223
|
+
el.innerHTML = localizedValue;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Runs one translation pass once the initial document is ready.
|
|
230
|
+
document.addEventListener('DOMContentLoaded', applyTranslations);
|