@bndynet/vue-site 1.0.4 → 1.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
@@ -10,6 +10,7 @@ Configurable Vue 3 site framework: one package, `site.config.ts`, and Markdown p
10
10
  - Hash or HTML5 (`web`) router history, configurable in `site.config.ts`
11
11
  - Markdown (`?raw`) or Vue pages
12
12
  - highlight.js, light/dark theme + localStorage
13
+ - Built-in multi-language support (locale switcher, `LocalizedString` config, per-locale pages) — see [Internationalization](#internationalization-i18n)
13
14
  - Project `README.md` as Home
14
15
  - Full TypeScript types
15
16
 
@@ -69,14 +70,15 @@ Add `"dev": "vue-site dev"` (or `vs dev`) in `package.json` scripts if you like.
69
70
 
70
71
  | Property | Description |
71
72
  |----------|-------------|
72
- | `title` | Site title (sidebar + tab) |
73
+ | `title` | Site title (sidebar + tab). `LocalizedString` |
73
74
  | `nav` | `NavItem[]` |
74
75
  | `defaultPath` | Path the site opens at; `/` and unknown paths redirect here. Must match a registered route (a `nav` item's resolved path or a `pages` entry's `path`). Defaults to the first top-level `nav` item |
75
76
  | `logo` | Logo URL or imported image |
76
77
  | `theme` | See `ThemeConfig` below; set to `false` to disable theming (hides the switcher, forces a fixed `light` palette, no localStorage persistence) |
77
- | `footer` | Footer text |
78
+ | `i18n` | Multi-language config (`I18nConfig`) — see [Internationalization](#internationalization-i18n) |
79
+ | `footer` | Footer text. `LocalizedString` |
78
80
  | `readme` | Raw Home content if no `README.md` |
79
- | `links` | Header links: Lucide `icon` + `link`, optional `title` |
81
+ | `links` | Header links: Lucide `icon` + `link`, optional `title` (`LocalizedString`) |
80
82
  | `pages` | `StandalonePage[]` — full-screen routes outside the `nav` tree (no top bar/sidebar/footer) |
81
83
  | `auth` | Central authorization policy (`AuthConfig`) — see [Per-page authorization](#per-page-authorization-auth) |
82
84
  | `router` | History mode (`RouterConfig`) — `hash` (default) or HTML5 `web`; see [Router history](#router-history-router) |
@@ -89,10 +91,10 @@ Add `"dev": "vue-site dev"` (or `vs dev`) in `package.json` scripts if you like.
89
91
 
90
92
  | Property | Description |
91
93
  |----------|-------------|
92
- | `label` | Sidebar text |
94
+ | `label` | Sidebar text. `LocalizedString` |
93
95
  | `icon` | [Lucide](https://lucide.dev/icons) name |
94
- | `page` | `() => import('./page.md?raw')` or `() => import('./Page.vue')` |
95
- | `path` | Route path (derived from `label` if omitted) |
96
+ | `page` | Page content. Simplest is a **file-path string** like `'./pages/AdminView.vue'` or `'./README.md'` (auto-loads per-locale siblings, falls back to the base file). Also accepts a loader (`() => import('./Page.vue')` / `() => import('./page.md?raw')`) or a `localizedPage(...)` result. See [Per-locale page content](#per-locale-page-content) and [Advanced page loaders](#advanced-page-loaders) |
97
+ | `path` | Route path (derived from `label`'s default-locale value if omitted; stays stable across languages) |
96
98
  | `children` | Nested group |
97
99
  | `link` | Render as a hyperlink (internal route path or external URL) instead of a page route |
98
100
  | `visible` | `() => boolean \| Promise<boolean>`, awaited once at startup. Return `false` to hide the item from the nav and skip its route (not reachable by direct URL). A hidden parent hides its subtree; a group with no remaining children is pruned. Not reactive to later changes. |
@@ -109,6 +111,220 @@ Built-in themes are `light`, `dark`, plus the always-on extras `sepia` and `ocea
109
111
  | `palettes` | — | Partial overrides for built-in light/dark only |
110
112
  | `extraThemes` | — | Extra themes: `id`, `label`, `icon`, optional `basedOn`, `palette`; reuse a built-in id (`sepia`/`ocean`) to override it. Import `builtinThemePalettes` for full defaults |
111
113
 
114
+ ## Internationalization (`i18n`)
115
+
116
+ Set `i18n` to enable multi-language support. The framework adds a locale switcher to the header,
117
+ resolves every `LocalizedString` field (`title`, `nav[].label`, `footer`, `links[].title`) against
118
+ the active locale, and exposes the current locale via `useLocale()` / `useLocalize()`.
119
+
120
+ ```typescript
121
+ import { defineConfig } from '@bndynet/vue-site'
122
+
123
+ export default defineConfig({
124
+ i18n: {
125
+ locales: [
126
+ { code: 'en', label: 'English' },
127
+ { code: 'zh', label: '简体中文', icon: 'languages' },
128
+ ],
129
+ defaultLocale: 'en',
130
+ },
131
+ title: { en: 'My Site', zh: '我的站点' }, // a LocalizedString
132
+ footer: { en: '© 2026', zh: '© 2026 版权所有' },
133
+ nav: [
134
+ { label: { en: 'Home', zh: '首页' }, icon: 'home', page: '../README.md' },
135
+ {
136
+ label: { en: 'Guide', zh: '指南' },
137
+ icon: 'book',
138
+ // Per-locale content: ./pages/guide.md (base) + guide.zh.md, auto-discovered by the CLI.
139
+ page: './pages/guide.md',
140
+ },
141
+ ],
142
+ })
143
+ ```
144
+
145
+ ### Per-locale page content
146
+
147
+ Point `page` at a **file-path string** and the framework serves the right file for the active
148
+ locale — no extra wiring:
149
+
150
+ ```typescript
151
+ import { defineConfig, tk } from '@bndynet/vue-site'
152
+
153
+ export default defineConfig({
154
+ i18n: { locales: [{ code: 'en' }, { code: 'zh' }], defaultLocale: 'en' },
155
+ nav: [
156
+ // Loads ../README.md, and auto-uses ../README.zh.md when the active locale is `zh`.
157
+ { label: tk('nav.home'), icon: 'home', page: '../README.md' },
158
+ // Vue pages work the same way: Dashboard.vue + Dashboard.zh.vue.
159
+ { label: tk('nav.dash'), icon: 'gauge', page: './pages/Dashboard.vue' },
160
+ ],
161
+ })
162
+ ```
163
+
164
+ - Name the variants `name.<code>.<ext>` next to the base file — `README.zh.md`, `Dashboard.zh.vue`, …
165
+ - A locale with no matching file falls back to the **base file** (`README.md`). Resolution order:
166
+ exact → primary-subtag (`zh-TW` → `zh`) → base file.
167
+ - **Add a language by dropping in a `name.<code>` file — no config changes.**
168
+ - Works for Markdown (`.md`) and Vue (`.vue`). The base name must not contain dots (`README.md` ✓,
169
+ `my.page.md` ✗).
170
+
171
+ > The string form is resolved by the `vue-site` CLI at build time. If you embed the library yourself
172
+ > (no CLI — see [library mode](#library-mode)), use a loader from
173
+ > [Advanced page loaders](#advanced-page-loaders) instead.
174
+
175
+ ### Advanced page loaders
176
+
177
+ > **Rarely needed.** The file-path string above covers most sites. Reach for these only when you
178
+ > want a plain single-file loader, files that **don't** share a base name, or you run **without** the
179
+ > CLI (library mode).
180
+
181
+ Besides a string, `page` accepts a **loader function** or a `localizedPage(...)` result:
182
+
183
+ - **Single file, no localization** — a plain dynamic import:
184
+
185
+ ```typescript
186
+ page: () => import('./pages/Dashboard.vue') // or () => import('./guide.md?raw') for Markdown
187
+ ```
188
+
189
+ - **Explicit locale map** — for files that don't share a base name (so the string form can't infer
190
+ them):
191
+
192
+ ```typescript
193
+ import { localizedPage } from '@bndynet/vue-site'
194
+
195
+ page: localizedPage({
196
+ en: () => import('./pages/guide-en.md?raw'),
197
+ zh: () => import('./pages/guide-zh.md?raw'),
198
+ })
199
+ ```
200
+
201
+ - **Glob (library mode)** — the same auto-discovery as the string form, written out so it works
202
+ without the CLI:
203
+
204
+ ```typescript
205
+ page: localizedPage(import.meta.glob(['../README.md', '../README.*.md'], { query: '?raw' }))
206
+ ```
207
+
208
+ `page: '../README.md'` is exactly this, generated for you by the CLI. (For Vue pages drop the
209
+ `{ query: '?raw' }`.)
210
+
211
+ All `localizedPage` forms fall back when the active locale has no file (exact → primary-subtag →
212
+ base file → first entry).
213
+
214
+ ### How it works
215
+
216
+ - **Initial locale**: stored choice (localStorage) > browser language (`navigator.language`, when
217
+ `detectBrowser` is on) > `defaultLocale` > first entry.
218
+ - **`LocalizedString`** is `string | Record<LocaleCode, string> | MessageRef`. A plain string is
219
+ returned as-is (single-language configs keep working), a locale map holds inline per-language
220
+ text, and a `MessageRef` (built with `tk('id')`) references a key from a central message file —
221
+ see [Centralized message files](#centralized-message-files-tk--t).
222
+ - **Stable URLs**: route paths derive from the **default-locale** label (or an explicit `path`), so
223
+ switching language never changes URLs.
224
+ - **Reactive**: switching language updates labels, the title, the footer, and page content live
225
+ (page content reloads via `localizedPage`). The switcher only appears when `locales.length > 1`.
226
+ - **UI strings**: the framework ships built-in strings (currently `en`, `zh`) for the theme/locale
227
+ switchers and page errors; override or extend them per locale via `i18n.messages`.
228
+
229
+ ### `I18nConfig`
230
+
231
+ | Property | Default | Description |
232
+ |----------|---------|-------------|
233
+ | `locales` | discovered `locales/*.json` | `{ code, label, icon? }[]` — supported languages, in display order. Optional: derived from the auto-loaded file names (with built-in labels) when omitted. First entry is the fallback |
234
+ | `defaultLocale` | `locales[0].code` | Initial locale when nothing is stored and detection finds no match |
235
+ | `detectBrowser` | `true` | Detect the initial locale from `navigator.language(s)` on first visit |
236
+ | `storageKey` | `vue-site-locale` | localStorage key for the chosen locale |
237
+ | `messages` | auto-loaded from `locales/<code>.json` | `Record<LocaleCode, Record<string, string>>` — extra/override translations, merged over the auto-loaded files and built-in UI strings |
238
+
239
+ ### Message files & keys (`tk` / `t`)
240
+
241
+ Instead of inlining `{ en, zh }` everywhere, keep all translations in plain JSON — **one file per
242
+ language** — and reference them by key. This is zero-config: the CLI auto-discovers
243
+ `locales/<code>.json` next to your `site.config.ts`. You don't write any glue code (no `index.ts`,
244
+ no `messages` field) and you don't even have to list the languages.
245
+
246
+ ```jsonc
247
+ // locales/en.json — nested groups (recommended), flattened to dotted ids
248
+ {
249
+ "site": { "title": "My Site" },
250
+ "nav": { "home": "Home", "guide": "Guide" }
251
+ }
252
+ ```
253
+
254
+ ```jsonc
255
+ // locales/zh.json
256
+ {
257
+ "site": { "title": "我的站点" },
258
+ "nav": { "home": "首页", "guide": "指南" }
259
+ }
260
+ ```
261
+
262
+ > Files may be **nested** (above) or **flat** (`{ "site.title": "My Site" }`) — nested groups are
263
+ > flattened to dotted ids, so `tk('site.title')` / `t('site.title')` work either way.
264
+
265
+ ```typescript
266
+ // site.config.ts — reference keys with tk() in config and t() in pages
267
+ import { defineConfig, tk } from '@bndynet/vue-site'
268
+
269
+ export default defineConfig({
270
+ // `i18n` can be omitted entirely: the language list is derived from the file names
271
+ // (en, zh, ...) with friendly built-in labels. Declare it only to customize label/icon/order.
272
+ i18n: {
273
+ locales: [
274
+ { code: 'en', label: 'English' },
275
+ { code: 'zh', label: '简体中文', icon: 'languages' },
276
+ ],
277
+ defaultLocale: 'en',
278
+ },
279
+ title: tk('site.title'),
280
+ nav: [
281
+ { label: tk('nav.home'), icon: 'home', page: '../README.md' },
282
+ {
283
+ label: tk('nav.guide'),
284
+ icon: 'book',
285
+ page: './pages/guide.md',
286
+ },
287
+ ],
288
+ })
289
+ ```
290
+
291
+ Key resolution falls back through the active locale's primary subtag → `defaultLocale` → `en` → the
292
+ id itself, and `{name}` placeholders are interpolated. `tk()` and inline `{ en, zh }` maps can be
293
+ mixed freely — use `tk()` for shared/centrally managed text and an inline map for one-off strings.
294
+
295
+ **Auto-discovery details & overrides**
296
+
297
+ - The convention is `locales/<code>.json` (e.g. `locales/en.json`, `locales/zh.json`), resolved
298
+ relative to the config directory. `code` is the file name (a `LocaleCode` like `en` or `zh-TW`).
299
+ - An explicit `i18n.messages` is still supported and **overrides** auto-loaded keys (per id); an
300
+ explicit `i18n.locales` controls the label/icon/order. Both are optional.
301
+ - Auto-discovery is a **CLI** feature. If you embed the library yourself (calling `createSiteApp`
302
+ without the `vue-site` CLI), pass `i18n.messages` directly — e.g. build it from JSON with explicit
303
+ imports (avoid `import.meta.glob` inside `site.config.ts`, which the CLI pre-loads in Node).
304
+
305
+ ### Localizing in your own pages
306
+
307
+ `useLocalize()` returns `t(id, params?)` (resolve a message id from the central catalog with
308
+ `{name}` interpolation), `localize(value)` (resolve any `LocalizedString` — a `tk()` ref, an inline
309
+ map, or a plain string), and the reactive `locale` ref. `useLocale()` returns
310
+ `{ locale, setLocale, locales }` for building a custom switcher. All work inside any component
311
+ rendered by `createSiteApp`.
312
+
313
+ ```vue
314
+ <script setup lang="ts">
315
+ import { useLocalize } from '@bndynet/vue-site'
316
+
317
+ const { t, localize, locale } = useLocalize()
318
+ </script>
319
+
320
+ <template>
321
+ <!-- key from the central message file -->
322
+ <h1>{{ t('nav.home') }}</h1>
323
+ <!-- or inline, for one-off text -->
324
+ <p>{{ localize({ en: 'Hello', zh: '你好' }) }} — {{ locale }}</p>
325
+ </template>
326
+ ```
327
+
112
328
  ## Per-page authorization (`auth`)
113
329
 
114
330
  Gate individual pages on the current user. Add an `auth` rule to any `NavItem` or `StandalonePage`, and a single `auth.authorize` policy in `SiteConfig` to decide access.
@@ -245,7 +461,7 @@ app.mount('#app')
245
461
 
246
462
  Use a top-level `await` in your entry (or an async IIFE): `createSiteApp` is async and **awaits** `configureApp` when it returns a `Promise`. If you set optional `bootstrap` in config, that module loads before the app is created; if you omit `bootstrap`, that step is skipped.
247
463
 
248
- Exports: `createSiteApp`, `defineConfig`, `useTheme`, `useSiteConfig`, `themeRefKey`. Types: `SiteConfig`, `SiteEnvConfig`, `SiteViteConfig`, `SiteExternalLink`, `NavItem`, `StandalonePage`, `AuthRule`, `AuthContext`, `AuthConfig`, `RouterConfig`, `ThemeConfig`, `ThemeOption`, `ThemePaletteVars`, `ResolvedNavItem`.
464
+ Exports: `createSiteApp`, `defineConfig`, `useTheme`, `useSiteConfig`, `useLocale`, `useLocalize`, `tk`, `resolveLocalized`, `resolveField`, `resolveMessage`, `mergeCatalog`, `flattenMessages`, `isMessageRef`, `localizedPage`, `builtinMessages`, `themeRefKey`, `localeRefKey`. Types: `SiteConfig`, `SiteEnvConfig`, `SiteViteConfig`, `SiteExternalLink`, `NavItem`, `StandalonePage`, `AuthRule`, `AuthContext`, `AuthConfig`, `RouterConfig`, `ThemeConfig`, `ThemeOption`, `ThemePaletteVars`, `ResolvedNavItem`, `I18nConfig`, `LocaleOption`, `LocaleCode`, `LocalizedString`, `MessageRef`, `MessageTree`, `MessageCatalog`, `PageLoader`, `LocalizedPageOptions`.
249
465
 
250
466
  ### Theme in Vue pages (`useTheme`)
251
467