@bndynet/vue-site 1.2.1 → 1.3.1

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
+ - Custom shell actions for avatars, notifications, help menus, and other app controls
13
14
  - Built-in multi-language support (locale switcher, `LocalizedString` config, per-locale pages) — see [Internationalization](#internationalization-i18n)
14
15
  - Project `README.md` as Home
15
16
  - Full TypeScript types
@@ -29,6 +30,7 @@ import { defineConfig } from '@bndynet/vue-site'
29
30
 
30
31
  export default defineConfig({
31
32
  title: 'My Project',
33
+ favicon: '/favicon.ico',
32
34
  nav: [
33
35
  { label: 'Home', icon: 'home', page: () => import('./README.md?raw') },
34
36
  { label: 'Guide', icon: 'book-open', page: () => import('./pages/guide.md?raw') },
@@ -42,6 +44,7 @@ export default defineConfig({
42
44
  my-site/
43
45
  package.json
44
46
  site.config.ts
47
+ public/favicon.ico
45
48
  README.md
46
49
  pages/guide.md
47
50
  ```
@@ -72,13 +75,17 @@ Add `"dev": "vue-site dev"` (or `vs dev`) in `package.json` scripts if you like.
72
75
  |----------|-------------|
73
76
  | `title` | Site title (sidebar + tab). `LocalizedString` |
74
77
  | `nav` | `NavItem[]` |
78
+ | `navPosition` | Optional navigation placement: `'top'` forces the top/tiered layout, `'sidebar'` forces the full nav tree into the sidebar. Omit to keep automatic behavior based on nav depth |
75
79
  | `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 |
80
+ | `favicon` | Browser tab icon URL. Use a public asset path, remote URL, or imported asset URL |
76
81
  | `logo` | Logo URL or imported image |
77
82
  | `theme` | See `ThemeConfig` below; set to `false` to disable theming (hides the switcher, forces a fixed `light` palette, no localStorage persistence) |
78
83
  | `i18n` | Multi-language config (`I18nConfig`) — see [Internationalization](#internationalization-i18n) |
79
84
  | `footer` | Footer text. `LocalizedString` |
80
85
  | `readme` | Raw Home content if no `README.md` |
81
86
  | `links` | Header links: Lucide `icon` + `link`, optional `title` (`LocalizedString`) |
87
+ | `shell` | Shell action area config (`ShellConfig`) — see [Shell actions](#shell-actions-shellactions) |
88
+ | `icons` | Lucide icon registry. Generated automatically by the CLI; provide manually only when calling `createSiteApp` without the CLI |
82
89
  | `pages` | `StandalonePage[]` — full-screen routes outside the `nav` tree (no top bar/sidebar/footer) |
83
90
  | `auth` | Central authorization policy (`AuthConfig`) — see [Per-page authorization](#per-page-authorization-auth) |
84
91
  | `router` | History mode (`RouterConfig`) — `hash` (default) or HTML5 `web`; see [Router history](#router-history-router) |
@@ -95,11 +102,59 @@ Add `"dev": "vue-site dev"` (or `vs dev`) in `package.json` scripts if you like.
95
102
  | `icon` | [Lucide](https://lucide.dev/icons) name |
96
103
  | `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
104
  | `path` | Route path (derived from `label`'s default-locale value if omitted; stays stable across languages) |
105
+ | `layout` | Page content width: `'default'` keeps the standard centered reading width, `'wide'` uses a wider centered canvas, `'full'` fills the available parent container with no framework padding. Ignored for groups and links. |
98
106
  | `children` | Nested group |
99
107
  | `link` | Render as a hyperlink (internal route path or external URL) instead of a page route |
100
108
  | `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. |
101
109
  | `auth` | Authorization rule (`AuthRule`) interpreted by `auth.authorize`. Keeps the route registered and enforces it via a navigation guard (so direct URLs redirect to login). Requires `SiteConfig.auth`. See [Per-page authorization](#per-page-authorization-auth). |
102
110
 
111
+ ### `StandalonePage`
112
+
113
+ | Property | Description |
114
+ |----------|-------------|
115
+ | `path` | Route path, used as-is (e.g. `/landing`) |
116
+ | `page` | Page content. Same forms as `NavItem.page` |
117
+ | `layout` | Optional content width. Omit to keep the standalone default: a full-screen, unpadded canvas. |
118
+ | `visible` | `() => boolean \| Promise<boolean>`, awaited once at startup. Return `false` to skip registering the route. |
119
+ | `auth` | Authorization rule (`AuthRule`) enforced by the same navigation guard as `NavItem.auth`. |
120
+
121
+ ### Page layout
122
+
123
+ Set `layout` on a `NavItem` page when the default reading width is too narrow:
124
+
125
+ ```typescript
126
+ export default defineConfig({
127
+ nav: [
128
+ { label: 'Docs', icon: 'book', page: './pages/docs.md' },
129
+ { label: 'Reports', icon: 'table', layout: 'wide', page: './pages/reports.vue' },
130
+ { label: 'Dashboard', icon: 'gauge', layout: 'full', page: './pages/dashboard.vue' },
131
+ ],
132
+ })
133
+ ```
134
+
135
+ ### Icon registry
136
+
137
+ The CLI scans configured Lucide icon names in `nav`, `links`, `i18n.locales`, and `theme.extraThemes`
138
+ and generates an icon registry automatically. Only the icons referenced by the config are bundled.
139
+
140
+ When using the library directly without the CLI, pass the registry yourself:
141
+
142
+ ```typescript
143
+ import Home from 'lucide-vue-next/dist/esm/icons/home.js'
144
+ import BookOpen from 'lucide-vue-next/dist/esm/icons/book-open.js'
145
+
146
+ createSiteApp({
147
+ title: 'My Site',
148
+ icons: {
149
+ home: Home,
150
+ 'book-open': BookOpen,
151
+ },
152
+ nav: [
153
+ { label: 'Home', icon: 'home', page: () => import('./README.md?raw') },
154
+ ],
155
+ })
156
+ ```
157
+
103
158
  ### `ThemeConfig`
104
159
 
105
160
  Built-in themes are `light`, `dark`, plus the always-on extras `sepia` and `ocean` (shown in the switcher for every site). Set `theme: false` on `SiteConfig` to disable theming entirely.
@@ -111,6 +166,45 @@ Built-in themes are `light`, `dark`, plus the always-on extras `sepia` and `ocea
111
166
  | `palettes` | — | Partial overrides for built-in light/dark only |
112
167
  | `extraThemes` | — | Extra themes: `id`, `label`, `icon`, optional `basedOn`, `palette`; reuse a built-in id (`sepia`/`ocean`) to override it. Import `builtinThemePalettes` for full defaults |
113
168
 
169
+ ## Shell actions (`shell.actions`)
170
+
171
+ Use `shell.actions` to render app-specific Vue components in the active layout's global toolbar.
172
+ In top/tiered navigation they appear at the end of the top header; in sidebar-only navigation they
173
+ appear in the sidebar footer after the built-in links, locale switcher, and theme switcher. This is
174
+ the recommended place for a signed-in user avatar, notification button, help menu, or tenant
175
+ switcher.
176
+
177
+ ```typescript
178
+ export default defineConfig({
179
+ title: 'My Site',
180
+ shell: {
181
+ actions: ['./components/UserAvatar.vue'],
182
+ align: 'left',
183
+ },
184
+ nav: [/* ... */],
185
+ })
186
+ ```
187
+
188
+ With the CLI, each path-like string is rewritten to a dynamic import. In library mode, pass an
189
+ imported component or loader instead:
190
+
191
+ ```typescript
192
+ import UserAvatar from './components/UserAvatar.vue'
193
+
194
+ export default defineConfig({
195
+ shell: {
196
+ actions: [UserAvatar, () => import('./components/HelpMenu.vue')],
197
+ align: 'center',
198
+ },
199
+ nav: [/* ... */],
200
+ })
201
+ ```
202
+
203
+ The framework only renders the component; auth state, user data, menus, and logout behavior stay in
204
+ your component so the framework does not need to define a user model.
205
+ Set `align` to `'left'`, `'center'`, or `'right'` for sidebar-footer placement; it defaults to
206
+ `'center'`. Top-header placement keeps the built-in toolbar flow.
207
+
114
208
  ## Internationalization (`i18n`)
115
209
 
116
210
  Set `i18n` to enable multi-language support. The framework adds a locale switcher to the header,
@@ -461,7 +555,7 @@ app.mount('#app')
461
555
 
462
556
  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.
463
557
 
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`.
558
+ Exports: `createSiteApp`, `defineConfig`, `useTheme`, `useSiteConfig`, `useLocale`, `useLocalize`, `tk`, `resolveLocalized`, `resolveField`, `resolveMessage`, `mergeCatalog`, `flattenMessages`, `isMessageRef`, `localizedPage`, `builtinMessages`, `themeRefKey`, `localeRefKey`. Types: `SiteConfig`, `SiteEnvConfig`, `SiteViteConfig`, `SiteExternalLink`, `ShellConfig`, `ShellAction`, `ShellActionLoader`, `NavItem`, `StandalonePage`, `PageLayout`, `AuthRule`, `AuthContext`, `AuthConfig`, `RouterConfig`, `ThemeConfig`, `ThemeOption`, `ThemePaletteVars`, `ResolvedNavItem`, `I18nConfig`, `LocaleOption`, `LocaleCode`, `LocalizedString`, `IconRegistry`, `MessageRef`, `MessageTree`, `MessageCatalog`, `PageLoader`, `LocalizedPageOptions`.
465
559
 
466
560
  ### Theme in Vue pages (`useTheme`)
467
561
 
package/README.zh.md CHANGED
@@ -29,6 +29,7 @@ import { defineConfig } from '@bndynet/vue-site'
29
29
 
30
30
  export default defineConfig({
31
31
  title: 'My Project',
32
+ favicon: '/favicon.ico',
32
33
  nav: [
33
34
  { label: 'Home', icon: 'home', page: () => import('./README.md?raw') },
34
35
  { label: 'Guide', icon: 'book-open', page: () => import('./pages/guide.md?raw') },
@@ -42,6 +43,7 @@ export default defineConfig({
42
43
  my-site/
43
44
  package.json
44
45
  site.config.ts
46
+ public/favicon.ico
45
47
  README.md
46
48
  pages/guide.md
47
49
  ```
@@ -72,7 +74,9 @@ npx vue-site build --base /app/
72
74
  |----------|-------------|
73
75
  | `title` | 站点标题(侧边栏 + 标签页)。`LocalizedString` |
74
76
  | `nav` | `NavItem[]` |
77
+ | `navPosition` | 可选的导航位置:`'top'` 强制使用顶部/分层导航,`'sidebar'` 强制将完整导航树放在侧边栏。不传时保持基于导航深度的自动行为 |
75
78
  | `defaultPath` | 站点打开时的路径;`/` 和未知路径会重定向到此。必须匹配一个已注册的路由(某个 `nav` 条目解析后的路径,或某个 `pages` 条目的 `path`)。默认为第一个顶级 `nav` 条目 |
79
+ | `favicon` | 浏览器标签页图标 URL。可使用 public 资源路径、远程 URL 或导入后的资源 URL |
76
80
  | `logo` | Logo 的 URL 或导入的图片 |
77
81
  | `theme` | 见下方 `ThemeConfig`;设为 `false` 可禁用主题(隐藏切换器、强制使用固定的 `light` 调色板、不进行 localStorage 持久化) |
78
82
  | `i18n` | 多语言配置(`I18nConfig`)—— 参见[国际化](#国际化-i18n) |
@@ -95,11 +99,36 @@ npx vue-site build --base /app/
95
99
  | `icon` | [Lucide](https://lucide.dev/icons) 图标名 |
96
100
  | `page` | 页面内容。最简单的是一个**文件路径字符串**,如 `'./pages/AdminView.vue'` 或 `'./README.md'`(自动加载各语言的同名文件,找不到时回退到基础文件)。也接受加载器(`() => import('./Page.vue')` / `() => import('./page.md?raw')`)或 `localizedPage(...)` 的返回值。见[按语言区分的页面内容](#按语言区分的页面内容)与[高级页面加载器](#高级页面加载器) |
97
101
  | `path` | 路由路径(省略时从 `label` 的默认语言值派生;切换语言时保持稳定) |
102
+ | `layout` | 页面内容宽度:`'default'` 保持标准居中阅读宽度,`'wide'` 使用更宽的居中画布,`'full'` 以无框架内边距的方式填满可用父容器。对分组和链接无效。 |
98
103
  | `children` | 嵌套分组 |
99
104
  | `link` | 渲染为超链接(内部路由路径或外部 URL),而非页面路由 |
100
105
  | `visible` | `() => boolean \| Promise<boolean>`,在启动时等待执行一次。返回 `false` 会从导航中隐藏该条目并跳过其路由(无法通过直接 URL 访问)。被隐藏的父项会隐藏其整个子树;没有剩余子项的分组会被剪除。对后续变化不具响应性。 |
101
106
  | `auth` | 由 `auth.authorize` 解释的授权规则(`AuthRule`)。会保持路由注册并通过导航守卫强制执行(因此直接访问 URL 会重定向到登录页)。需要配置 `SiteConfig.auth`。参见[按页面授权](#按页面授权-auth)。 |
102
107
 
108
+ ### `StandalonePage`
109
+
110
+ | 属性 | 说明 |
111
+ |----------|-------------|
112
+ | `path` | 路由路径,按原样使用(如 `/landing`) |
113
+ | `page` | 页面内容。接受形式与 `NavItem.page` 相同 |
114
+ | `layout` | 可选内容宽度。省略时保持 standalone 默认行为:全屏、无内边距画布。 |
115
+ | `visible` | `() => boolean \| Promise<boolean>`,在启动时等待执行一次。返回 `false` 会跳过该路由注册。 |
116
+ | `auth` | 授权规则(`AuthRule`),由与 `NavItem.auth` 相同的导航守卫强制执行。 |
117
+
118
+ ### 页面布局
119
+
120
+ 当默认阅读宽度太窄时,可以在 `NavItem` 页面上设置 `layout`:
121
+
122
+ ```typescript
123
+ export default defineConfig({
124
+ nav: [
125
+ { label: 'Docs', icon: 'book', page: './pages/docs.md' },
126
+ { label: 'Reports', icon: 'table', layout: 'wide', page: './pages/reports.vue' },
127
+ { label: 'Dashboard', icon: 'gauge', layout: 'full', page: './pages/dashboard.vue' },
128
+ ],
129
+ })
130
+ ```
131
+
103
132
  ### `ThemeConfig`
104
133
 
105
134
  内置主题为 `light`、`dark`,外加始终可用的额外主题 `sepia` 和 `ocean`(在每个站点的切换器中都会显示)。在 `SiteConfig` 上设置 `theme: false` 可完全禁用主题。
@@ -468,7 +497,7 @@ app.mount('#app')
468
497
  返回 `Promise` 时会 **await** 它。如果你在配置中设置了可选的 `bootstrap`,该模块会在应用创建前加载;
469
498
  如果省略 `bootstrap`,则跳过该步骤。
470
499
 
471
- 导出:`createSiteApp`、`defineConfig`、`useTheme`、`useSiteConfig`、`useLocale`、`useLocalize`、`tk`、`resolveLocalized`、`resolveField`、`resolveMessage`、`mergeCatalog`、`flattenMessages`、`isMessageRef`、`localizedPage`、`builtinMessages`、`themeRefKey`、`localeRefKey`。类型:`SiteConfig`、`SiteEnvConfig`、`SiteViteConfig`、`SiteExternalLink`、`NavItem`、`StandalonePage`、`AuthRule`、`AuthContext`、`AuthConfig`、`RouterConfig`、`ThemeConfig`、`ThemeOption`、`ThemePaletteVars`、`ResolvedNavItem`、`I18nConfig`、`LocaleOption`、`LocaleCode`、`LocalizedString`、`MessageRef`、`MessageTree`、`MessageCatalog`、`PageLoader`、`LocalizedPageOptions`。
500
+ 导出:`createSiteApp`、`defineConfig`、`useTheme`、`useSiteConfig`、`useLocale`、`useLocalize`、`tk`、`resolveLocalized`、`resolveField`、`resolveMessage`、`mergeCatalog`、`flattenMessages`、`isMessageRef`、`localizedPage`、`builtinMessages`、`themeRefKey`、`localeRefKey`。类型:`SiteConfig`、`SiteEnvConfig`、`SiteViteConfig`、`SiteExternalLink`、`NavItem`、`StandalonePage`、`PageLayout`、`AuthRule`、`AuthContext`、`AuthConfig`、`RouterConfig`、`ThemeConfig`、`ThemeOption`、`ThemePaletteVars`、`ResolvedNavItem`、`I18nConfig`、`LocaleOption`、`LocaleCode`、`LocalizedString`、`MessageRef`、`MessageTree`、`MessageCatalog`、`PageLoader`、`LocalizedPageOptions`。
472
501
 
473
502
  ### 在 Vue 页面中使用主题(`useTheme`)
474
503
 
package/bin/vue-site.mjs CHANGED
@@ -2,6 +2,9 @@
2
2
 
3
3
  import { createServer, build, preview, mergeConfig } from 'vite'
4
4
  import vue from '@vitejs/plugin-vue'
5
+ import AutoImport from 'unplugin-auto-import/vite'
6
+ import Components from 'unplugin-vue-components/vite'
7
+ import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
5
8
  import { resolve, dirname, basename } from 'path'
6
9
  import { fileURLToPath, pathToFileURL } from 'url'
7
10
  import { createRequire } from 'module'
@@ -11,17 +14,29 @@ import fs from 'fs'
11
14
  const __filename = fileURLToPath(import.meta.url)
12
15
  const __dirname = dirname(__filename)
13
16
  const pkgDir = resolve(__dirname, '..')
14
- const require = createRequire(import.meta.url)
15
17
  const FRAMEWORK_PACKAGE = '@bndynet/vue-site'
16
18
  const frameworkEntry = resolve(pkgDir, 'dist/index.es.js')
17
19
  const frameworkStyle = resolve(pkgDir, 'dist/style.css')
18
20
 
19
- function resolvePkgDir(pkg) {
20
- return dirname(require.resolve(`${pkg}/package.json`))
21
+ function resolvePkgDir(pkg, from = import.meta.url) {
22
+ return dirname(createRequire(from).resolve(`${pkg}/package.json`))
23
+ }
24
+
25
+ function toVitePath(file) {
26
+ return file.replace(/\\/g, '/')
21
27
  }
22
28
 
23
29
  const vuePath = resolvePkgDir('vue')
24
30
  const vueRouterPath = resolvePkgDir('vue-router')
31
+ const lucidePath = resolvePkgDir('lucide-vue-next')
32
+ const elementPlusPath = resolvePkgDir('element-plus')
33
+ const elementPlusPackage = resolve(elementPlusPath, 'package.json')
34
+ const elementPlusIconsPath = resolvePkgDir('@element-plus/icons-vue', elementPlusPackage)
35
+ const dayjsPath = resolvePkgDir('dayjs', elementPlusPackage)
36
+ const lucideAliasPath = toVitePath(lucidePath)
37
+ const elementPlusAliasPath = toVitePath(elementPlusPath)
38
+ const elementPlusIconsAliasPath = toVitePath(elementPlusIconsPath)
39
+ const dayjsAliasPath = toVitePath(dayjsPath)
25
40
  const cwd = process.cwd()
26
41
  /** Lets `import('../file.md?raw')` work when `site.config` lives in a subfolder (README next to cwd). In-repo, ../ often falls under pkgDir; from npm install it does not, so we allow cwd's parent explicitly. */
27
42
  const cwdParent = resolve(cwd, '..')
@@ -110,6 +125,8 @@ const VIRTUAL_ENTRY = 'virtual:vue-site-entry'
110
125
  const RESOLVED_ENTRY = '\0' + VIRTUAL_ENTRY
111
126
  const VIRTUAL_PACKAGE = 'virtual:vue-site-package'
112
127
  const RESOLVED_PACKAGE = '\0' + VIRTUAL_PACKAGE
128
+ const VIRTUAL_ICONS = 'virtual:vue-site-icons'
129
+ const RESOLVED_ICONS = '\0' + VIRTUAL_ICONS
113
130
 
114
131
  function parseRepositoryUrl(pkg) {
115
132
  const r = pkg.repository
@@ -169,6 +186,25 @@ function resolveBootstrapUrl(path) {
169
186
  return '/' + t.replace(/^\.\//, '')
170
187
  }
171
188
 
189
+ function escapeHtmlAttr(value) {
190
+ return String(value)
191
+ .replace(/&/g, '&amp;')
192
+ .replace(/"/g, '&quot;')
193
+ .replace(/</g, '&lt;')
194
+ }
195
+
196
+ function getConfiguredFavicon(siteConfig) {
197
+ const favicon = siteConfig?.favicon
198
+ return typeof favicon === 'string' ? favicon.trim() : ''
199
+ }
200
+
201
+ function buildFaviconLink(siteConfig) {
202
+ const favicon = getConfiguredFavicon(siteConfig)
203
+ return favicon
204
+ ? ` <link rel="icon" href="${escapeHtmlAttr(favicon)}" />\n`
205
+ : ''
206
+ }
207
+
172
208
  // Friendly display names for auto-discovered locale files (`/locales/<code>.json`). Used only when
173
209
  // the config doesn't declare `i18n.locales`; unknown codes fall back to the code itself.
174
210
  const LOCALE_LABELS = {
@@ -213,12 +249,12 @@ function buildBootstrapScript({ siteConfig, siteConfigSpecifier }) {
213
249
  : ''
214
250
  return [
215
251
  bootstrapImport,
216
- `import 'element-plus/dist/index.css'`,
217
252
  `import 'element-plus/theme-chalk/dark/css-vars.css'`,
218
253
  `import { createSiteApp } from '${FRAMEWORK_PACKAGE}'`,
219
254
  `import '${FRAMEWORK_PACKAGE}/style.css'`,
220
255
  `import siteConfig from '${siteConfigSpecifier}'`,
221
256
  `import { repositoryUrl } from '${VIRTUAL_PACKAGE}'`,
257
+ `import { iconRegistry } from '${VIRTUAL_ICONS}'`,
222
258
  ``,
223
259
  `// Auto-discover translations: /locales/<code>.json -> { [code]: { ...messages } }.`,
224
260
  `const __localeFiles = import.meta.glob('/locales/*.json', { eager: true, import: 'default' })`,
@@ -274,6 +310,7 @@ function buildBootstrapScript({ siteConfig, siteConfigSpecifier }) {
274
310
  ` ...(hasThemeQuery ? { theme: { ...(siteConfig.theme || {}), default: resolvedTheme } } : {}),`,
275
311
  ` packageRepository: repositoryUrl,`,
276
312
  ` baseUrl: import.meta.env.BASE_URL,`,
313
+ ` icons: { ...iconRegistry, ...(siteConfig.icons || {}) },`,
277
314
  ` })`,
278
315
  ` app.mount('#app')`,
279
316
  `})()`,
@@ -287,13 +324,13 @@ function buildEntryCode(siteConfig) {
287
324
  })
288
325
  }
289
326
 
290
- function buildHtmlShell(scriptTag) {
327
+ function buildHtmlShell(scriptTag, siteConfig) {
291
328
  return `<!DOCTYPE html>
292
329
  <html lang="en">
293
330
  <head>
294
331
  <meta charset="UTF-8" />
295
332
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
296
- <title></title>
333
+ ${buildFaviconLink(siteConfig)} <title></title>
297
334
  </head>
298
335
  <body>
299
336
  <div id="app"></div>
@@ -302,10 +339,6 @@ function buildHtmlShell(scriptTag) {
302
339
  </html>`
303
340
  }
304
341
 
305
- const htmlTemplate = buildHtmlShell(
306
- `<script type="module" src="/@id/__x00__${VIRTUAL_ENTRY}"></script>`,
307
- )
308
-
309
342
  // esbuild plugin stubbing asset / SFC / `?raw` imports (static or dynamic) to an empty default
310
343
  // export, so bundling the config for pre-load doesn't choke on resources Node can't load. Page
311
344
  // loaders are never invoked during pre-load (only build-time settings are read).
@@ -434,19 +467,41 @@ function fileToLocaleGlobExpr(rawPath) {
434
467
  // per-locale files get bundled. Two shapes are rewritten in the user's JS/TS under `cwd`:
435
468
  // localizedPage('./file.md') -> localizedPage(import.meta.glob([...], { query: '?raw' }))
436
469
  // page: './file.md' -> page: import.meta.glob([...], { query: '?raw' })
470
+ // shell.actions: ['./User.vue'] -> shell.actions: [() => import('./User.vue')]
437
471
  // Loader functions, locale maps (`localizedPage({ en, zh })`) and explicit `import.meta.glob` are
438
- // left untouched; the `page:` form only rewrites path-like values (starting with `.` or `/`).
439
- function localizedPageSugarPlugin() {
472
+ // left untouched; the string forms only rewrite path-like values (starting with `.` or `/`).
473
+ function configSugarPlugin() {
440
474
  const CALL_STRING_ARG = /(\blocalizedPage\s*\(\s*)(['"`])((?:\\.|(?!\2).)*)\2/g
441
475
  const PAGE_STRING_FIELD = /(\bpage\s*:\s*)(['"`])((?:\\.|(?!\2).)*)\2/g
476
+ const SHELL_ACTIONS_ARRAY = /(\bshell\s*:\s*\{[\s\S]*?\bactions\s*:\s*\[)([\s\S]*?)(\])/g
477
+ const STRING_LITERAL = /(['"`])((?:\\.|(?!\1).)*)\1/g
478
+
479
+ function rewriteShellActionArray(body) {
480
+ return body.replace(STRING_LITERAL, (match, _q, rawPath, offset) => {
481
+ if (!/^[./]/.test(rawPath)) return match
482
+
483
+ const before = body.slice(0, offset)
484
+ const after = body.slice(offset + match.length)
485
+ if (!/(^|,)\s*$/.test(before) || !/^\s*(,|$)/.test(after)) return match
486
+
487
+ return `() => import(${JSON.stringify(rawPath)})`
488
+ })
489
+ }
490
+
442
491
  return {
443
- name: 'vue-site:localized-page-sugar',
492
+ name: 'vue-site:config-sugar',
444
493
  enforce: 'pre',
445
494
  transform(code, id) {
446
495
  const file = id.split('?')[0]
447
496
  if (!/\.(?:[cm]?[jt]sx?)$/.test(file)) return
448
497
  if (!file.startsWith(cwd) || file.includes('/node_modules/')) return
449
- if (!code.includes('localizedPage(') && !/\bpage\s*:\s*['"`]/.test(code)) return
498
+ if (
499
+ !code.includes('localizedPage(') &&
500
+ !/\bpage\s*:\s*['"`]/.test(code) &&
501
+ !/\bshell\s*:\s*\{/.test(code)
502
+ ) {
503
+ return
504
+ }
450
505
  let changed = false
451
506
  let out = code.replace(CALL_STRING_ARG, (match, head, _q, rawPath) => {
452
507
  const expr = fileToLocaleGlobExpr(rawPath)
@@ -462,18 +517,160 @@ function localizedPageSugarPlugin() {
462
517
  changed = true
463
518
  return `${head}${expr}`
464
519
  })
520
+ out = out.replace(SHELL_ACTIONS_ARRAY, (match, head, body, tail) => {
521
+ const nextBody = rewriteShellActionArray(body)
522
+ if (nextBody === body) return match
523
+ changed = true
524
+ return `${head}${nextBody}${tail}`
525
+ })
465
526
  return changed ? { code: out, map: null } : undefined
466
527
  },
467
528
  }
468
529
  }
469
530
 
470
- function vueSitePlugin(entryCode) {
531
+ const BUILTIN_ICON_NAMES = [
532
+ 'sun',
533
+ 'moon',
534
+ 'palette',
535
+ 'languages',
536
+ 'github',
537
+ 'coffee',
538
+ 'waves',
539
+ ]
540
+
541
+ function normalizeLucideIconName(name) {
542
+ const trimmed = String(name ?? '').trim()
543
+ if (!trimmed) return ''
544
+ return trimmed
545
+ .replace(/^lucide[\s_-]?/i, '')
546
+ .replace(/[\s_-]?icon$/i, '')
547
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
548
+ .replace(/[\s_]+/g, '-')
549
+ .toLowerCase()
550
+ }
551
+
552
+ function toPascalIconName(name) {
553
+ return normalizeLucideIconName(name)
554
+ .split('-')
555
+ .filter(Boolean)
556
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
557
+ .join('')
558
+ }
559
+
560
+ function readLucideIconExportMap() {
561
+ const map = new Map()
562
+ const entry = resolve(lucidePath, 'dist/esm/lucide-vue-next.js')
563
+ const code = fs.readFileSync(entry, 'utf-8')
564
+ const re = /export\s+\{([^}]+)\}\s+from\s+['"]\.\/icons\/([^'"]+)['"]/g
565
+ let match
566
+
567
+ while ((match = re.exec(code))) {
568
+ const exports = match[1]
569
+ const file = match[2]
570
+ for (const part of exports.split(',')) {
571
+ const alias = /default\s+as\s+([A-Za-z0-9_$]+)/.exec(part.trim())?.[1]
572
+ if (alias) map.set(alias, file)
573
+ }
574
+ }
575
+
576
+ return map
577
+ }
578
+
579
+ const lucideIconExportMap = readLucideIconExportMap()
580
+
581
+ function addIconName(set, value) {
582
+ if (typeof value !== 'string') return
583
+ const name = value.trim()
584
+ if (name) set.add(name)
585
+ }
586
+
587
+ function collectNavIconNames(set, nav = []) {
588
+ for (const item of nav) {
589
+ addIconName(set, item?.icon)
590
+ collectNavIconNames(set, item?.children ?? [])
591
+ }
592
+ }
593
+
594
+ function collectIconNames(siteConfig = {}) {
595
+ const names = new Set(BUILTIN_ICON_NAMES)
596
+ const themeConfig =
597
+ siteConfig.theme && typeof siteConfig.theme === 'object'
598
+ ? siteConfig.theme
599
+ : undefined
600
+
601
+ collectNavIconNames(names, siteConfig.nav ?? [])
602
+
603
+ for (const link of siteConfig.links ?? []) {
604
+ addIconName(names, link?.icon)
605
+ }
606
+ for (const locale of siteConfig.i18n?.locales ?? []) {
607
+ addIconName(names, locale?.icon)
608
+ }
609
+ for (const theme of themeConfig?.extraThemes ?? []) {
610
+ addIconName(names, theme?.icon)
611
+ }
612
+
613
+ return [...names]
614
+ }
615
+
616
+ function buildIconRegistryCode(siteConfig) {
617
+ const imports = []
618
+ const entries = []
619
+ const imported = new Map()
620
+ const addedEntries = new Set()
621
+
622
+ for (const name of collectIconNames(siteConfig)) {
623
+ const normalized = normalizeLucideIconName(name)
624
+ if (!normalized) continue
625
+
626
+ const pascal = toPascalIconName(name)
627
+ const iconSource =
628
+ lucideIconExportMap.get(pascal) ??
629
+ lucideIconExportMap.get(`${pascal}Icon`) ??
630
+ `${normalized}.js`
631
+ const iconFile = resolve(lucidePath, 'dist/esm/icons', iconSource)
632
+
633
+ if (!fs.existsSync(iconFile)) {
634
+ console.warn(
635
+ `[vue-site] Icon "${name}" was not found in lucide-vue-next. It will render empty.`,
636
+ )
637
+ continue
638
+ }
639
+
640
+ let local = imported.get(normalized)
641
+ if (!local) {
642
+ local = `Icon${imported.size}`
643
+ imported.set(normalized, local)
644
+ imports.push(
645
+ `import ${local} from ${JSON.stringify(
646
+ `lucide-vue-next/dist/esm/icons/${iconSource}`,
647
+ )}`,
648
+ )
649
+ }
650
+
651
+ for (const key of new Set([name, normalized])) {
652
+ if (addedEntries.has(key)) continue
653
+ addedEntries.add(key)
654
+ entries.push(` ${JSON.stringify(key)}: ${local},`)
655
+ }
656
+ }
657
+
658
+ return [
659
+ ...imports,
660
+ `export const iconRegistry = {`,
661
+ ...entries,
662
+ `}`,
663
+ ].join('\n')
664
+ }
665
+
666
+ function vueSitePlugin(entryCode, htmlTemplate, iconRegistryCode) {
471
667
  return [
472
668
  {
473
669
  name: 'vue-site:virtual-entry',
474
670
  resolveId(id) {
475
671
  if (id === VIRTUAL_ENTRY) return RESOLVED_ENTRY
476
672
  if (id === VIRTUAL_PACKAGE) return RESOLVED_PACKAGE
673
+ if (id === VIRTUAL_ICONS) return RESOLVED_ICONS
477
674
  },
478
675
  load(id) {
479
676
  if (id === RESOLVED_ENTRY) return entryCode
@@ -481,6 +678,7 @@ function vueSitePlugin(entryCode) {
481
678
  const url = readPackageRepositoryUrl()
482
679
  return `export const repositoryUrl = ${JSON.stringify(url)}`
483
680
  }
681
+ if (id === RESOLVED_ICONS) return iconRegistryCode
484
682
  },
485
683
  },
486
684
  {
@@ -619,6 +817,11 @@ async function buildViteConfig(options = {}) {
619
817
  }
620
818
 
621
819
  const entryCode = buildEntryCode(siteConfig)
820
+ const iconRegistryCode = buildIconRegistryCode(siteConfig)
821
+ const htmlTemplate = buildHtmlShell(
822
+ `<script type="module" src="/@id/__x00__${VIRTUAL_ENTRY}"></script>`,
823
+ siteConfig,
824
+ )
622
825
 
623
826
  // When watchPackages uses entryPath, SCSS files imported inside the watched
624
827
  // package source (e.g. Lit web components with `import styles from './foo.scss'`
@@ -651,10 +854,27 @@ async function buildViteConfig(options = {}) {
651
854
  },
652
855
  ]
653
856
  : []
857
+ const elementPlusResolver = ElementPlusResolver({
858
+ importStyle: 'css',
859
+ })
654
860
 
655
861
  const baseConfig = {
656
862
  root: cwd,
657
- plugins: [localizedPageSugarPlugin(), vue(vueOpts), ...watchedScssPlugin, ...vueSitePlugin(entryCode), ...(userPlugins || [])],
863
+ plugins: [
864
+ configSugarPlugin(),
865
+ AutoImport({
866
+ resolvers: [elementPlusResolver],
867
+ dts: false,
868
+ }),
869
+ Components({
870
+ resolvers: [elementPlusResolver],
871
+ dts: false,
872
+ }),
873
+ vue(vueOpts),
874
+ ...watchedScssPlugin,
875
+ ...vueSitePlugin(entryCode, htmlTemplate, iconRegistryCode),
876
+ ...(userPlugins || []),
877
+ ],
658
878
  resolve: {
659
879
  alias: [
660
880
  { find: /^@bndynet\/vue-site$/, replacement: frameworkEntry },
@@ -670,20 +890,50 @@ async function buildViteConfig(options = {}) {
670
890
  find: 'vue-router',
671
891
  replacement: resolve(vueRouterPath, 'dist/vue-router.mjs'),
672
892
  },
893
+ {
894
+ find: /^lucide-vue-next$/,
895
+ replacement: toVitePath(resolve(lucidePath, 'dist/esm/lucide-vue-next.js')),
896
+ },
897
+ {
898
+ find: /^lucide-vue-next\//,
899
+ replacement: `${lucideAliasPath}/`,
900
+ },
901
+ {
902
+ find: /^element-plus$/,
903
+ replacement: toVitePath(resolve(elementPlusPath, 'es/index.mjs')),
904
+ },
905
+ {
906
+ find: /^element-plus\//,
907
+ replacement: `${elementPlusAliasPath}/`,
908
+ },
909
+ {
910
+ find: /^@element-plus\/icons-vue$/,
911
+ replacement: toVitePath(resolve(elementPlusIconsPath, 'dist/index.js')),
912
+ },
913
+ {
914
+ find: /^@element-plus\/icons-vue\//,
915
+ replacement: `${elementPlusIconsAliasPath}/`,
916
+ },
917
+ {
918
+ find: /^dayjs$/,
919
+ replacement: dayjsAliasPath,
920
+ },
921
+ {
922
+ find: /^dayjs\//,
923
+ replacement: `${dayjsAliasPath}/`,
924
+ },
673
925
  ],
674
926
  },
675
927
  optimizeDeps: {
676
- // Pre-bundle element-plus so Vite crawls its dependency graph and
677
- // discovers dayjs (a CJS/UMD package with no ESM default export).
678
- // Without this, dayjs.min.js is served raw to the browser and
679
- // Element Plus throws "does not provide an export named 'default'".
680
- // The virtual entry only imports element-plus CSS, so Vite's static
681
- // analysis never sees the JS side — this include bridges that gap.
682
- include: ['element-plus'],
683
928
  // The virtual entry and user-authored pages both import the framework.
684
929
  // Keep it out of dependency pre-bundling so Vite does not instantiate
685
930
  // `dist/index.es.js` once via /@fs and again via node_modules/.vite.
686
931
  exclude: [FRAMEWORK_PACKAGE],
932
+ // Element Plus imports dayjs from ESM files, but dayjs itself ships as
933
+ // CommonJS. Force Vite's CJS interop for the package while letting
934
+ // discovered `dayjs/*` plugin imports be optimized normally.
935
+ include: ['dayjs'],
936
+ needsInterop: ['dayjs'],
687
937
  },
688
938
  server: {
689
939
  open: true,
@@ -723,6 +973,7 @@ async function run() {
723
973
  })
724
974
  const buildHtml = buildHtmlShell(
725
975
  `<script type="module">\n${bootstrapScript}\n </script>`,
976
+ siteConfig,
726
977
  )
727
978
  fs.writeFileSync(tempHtml, buildHtml)
728
979
  }