@bakery-framework/plugin-vue 1.2.3 → 2.0.0-alpha.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakery-framework/plugin-vue",
3
- "version": "1.2.3",
3
+ "version": "2.0.0-alpha.11",
4
4
  "description": "Bakery vue plugin.",
5
5
  "keywords": [
6
6
  "bakery",
@@ -25,6 +25,7 @@
25
25
  "main": "./src/index.ts",
26
26
  "exports": {
27
27
  ".": "./src/index.ts",
28
+ "./client": "./src/client.ts",
28
29
  "./package.json": "./package.json",
29
30
  "./vue.d.ts": "./src/vue.d.ts"
30
31
  },
@@ -42,9 +43,9 @@
42
43
  "vue": "^3.5.38"
43
44
  },
44
45
  "dependencies": {
45
- "@bakery-framework/core": "^1.0.0"
46
+ "@bakery-framework/core": "^2.0.0-alpha.11"
46
47
  },
47
48
  "engines": {
48
- "bun": ">=1.3.14"
49
+ "bun": ">=1.4.0"
49
50
  }
50
51
  }
package/src/chunks.ts CHANGED
@@ -2,6 +2,7 @@ import { Bakery } from '@bakery-framework/core/core/bakery'
2
2
  import { Logger } from '@bakery-framework/core/logger'
3
3
  import { fs, response } from '@bakery-framework/core/utils'
4
4
  import { ETag } from '@bakery-framework/core/utils/http'
5
+ import { vueBuildVariant } from './compile'
5
6
  import { VUE_VERSION } from './utils'
6
7
 
7
8
  const logger = new Logger('vue')
@@ -14,21 +15,43 @@ const BUNDLE_DEFINES = {
14
15
  __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false',
15
16
  }
16
17
 
18
+ const VUE_ENTRIES = {
19
+ runtime: 'vue/dist/vue.runtime.esm-bundler.js',
20
+ full: 'vue/dist/vue.esm-bundler.js',
21
+ } as const
22
+
23
+ /**
24
+ * The one canonical URL for the served Vue build — the import-map alias
25
+ * (`setup.ts`) and the request check below both read it, so they cannot drift.
26
+ *
27
+ * The variant is part of the filename, not just of the entry choice, because
28
+ * the chunk cache is keyed on this name with the *source's* mtime: flipping
29
+ * `build` in `server.config.ts` does not touch `vue.esm-bundler.js` on disk,
30
+ * so a shared name would keep serving the previous variant out of cache
31
+ * indefinitely.
32
+ */
33
+ export function vueChunkPath(): string {
34
+ return `${VUE_CHUNK_PREFIX}${VUE_VERSION}.${vueBuildVariant()}.js`
35
+ }
36
+
17
37
  /** Serves the self-hosted Vue runtime that `Bakery.config.importMap` points at. */
18
38
  export async function serveVueChunk(
19
39
  path: string,
20
40
  req: Request,
21
41
  ): Promise<Response> {
22
- if (path !== `${VUE_CHUNK_PREFIX}${VUE_VERSION}.js`) {
42
+ if (path !== vueChunkPath()) {
23
43
  return response.error('Not Found', 404)
24
44
  }
25
45
 
46
+ const variant = vueBuildVariant()
26
47
  const dir = fs.resolve(Bakery.cacheDir, 'vue-official', 'chunks')
27
- const fileName = `${VUE_VERSION}.js`
48
+ // Derived from the URL, not restated: the cache name and the request path
49
+ // must agree on the variant, and one writer is how they keep agreeing.
50
+ const fileName = path.slice(VUE_CHUNK_PREFIX.length)
28
51
 
29
52
  let sourcePath = ''
30
53
  try {
31
- sourcePath = Bun.resolveSync('vue/dist/vue.esm-bundler.js', Bakery.root)
54
+ sourcePath = Bun.resolveSync(VUE_ENTRIES[variant], Bakery.root)
32
55
  } catch {
33
56
  return response.error('Vue not found', 404)
34
57
  }
@@ -43,7 +66,12 @@ export async function serveVueChunk(
43
66
  entrypoints: [sourcePath],
44
67
  target: 'browser',
45
68
  format: 'esm',
46
- minify: import.meta.env.PROD,
69
+ // `Boolean(...)`, not the flag directly: the mode flags are `'1'`/`''`
70
+ // strings (`core/init.ts`), and `Bun.build` **rejects** a non-boolean
71
+ // `minify` rather than coercing it. Core's `compiler.ts` already wrapped
72
+ // its three; this one did not, and the build would have thrown on every
73
+ // production SFC chunk.
74
+ minify: Boolean(import.meta.env.PROD),
47
75
  define: BUNDLE_DEFINES,
48
76
  })
49
77
 
package/src/client.ts ADDED
@@ -0,0 +1,205 @@
1
+ /**
2
+ * `defineLayout()` — browser-side navigation for catch-all pages.
3
+ *
4
+ * A catch-all page (`[...slug].vue`, `[...slug!].vue`) owns every URL under
5
+ * its directory: whatever the path, the server serves the same file. That
6
+ * invariant is what makes client-side navigation *safe* here — swapping
7
+ * content on a URL change can never disagree with what a hard reload would
8
+ * serve — and it is why this API is only available on catch-all pages: on any
9
+ * other page, two URLs mean two different files, and intercepting the
10
+ * navigation would show the wrong one. The server stamps the route's shape
11
+ * into `globalThis.__vue_route`; `defineLayout()` throws without it.
12
+ *
13
+ * The page becomes its subtree's layout: it reads `segments` and renders
14
+ * whichever of its own components the path means — no `<slot />`, no extra
15
+ * file. Clicks on same-origin links under the base are intercepted and become
16
+ * a `pushState` plus a reactive update; links that leave the base navigate
17
+ * normally; back/forward is handled the same way, falling back to a real
18
+ * navigation when history leaves the subtree.
19
+ *
20
+ * Imported from `@bakery-framework/plugin-vue/client`, which the browser
21
+ * resolves through the import map like any installed package. `vue` stays a
22
+ * bare import, so this shares the page's Vue instance.
23
+ */
24
+ import { type Ref, ref } from 'vue'
25
+
26
+ /** What the server stamps on the page — see `handleHtml` in `handler.ts`. */
27
+ type StampedRoute = {
28
+ catchAll: boolean
29
+ /** URL prefix owned by the page: '' for a root catch-all, else '/admin'. */
30
+ base: string
31
+ /** The catch-all's param name (`slug` in `[...slug!]`). */
32
+ param: string | null
33
+ /**
34
+ * First path segments the catch-all's sibling *routes* claim — `faculty`
35
+ * when `faculty/[id].vue` sits beside the catch-all. Those URLs belong to
36
+ * more specific routes, so they get real navigations, not soft ones. Route
37
+ * names only, and only on catch-all pages: the stamp is readable by every
38
+ * visitor, so it must never be a directory listing.
39
+ */
40
+ claimed?: string[]
41
+ /** True when a `[param]` sibling claims every single-segment path. */
42
+ claimedSingle?: boolean
43
+ }
44
+
45
+ export type LayoutNavigation = {
46
+ /** Path segments under the base — `[]` on the bare directory. */
47
+ readonly segments: Ref<string[]>
48
+ /** The URL prefix this page owns. */
49
+ readonly base: string
50
+ /** Navigate within the subtree; segments or a path, `/`-prefixed or not. */
51
+ navigate(to: string | string[]): void
52
+ /**
53
+ * Listen for navigations. Return `false` from a listener to cancel one —
54
+ * cancellation applies to clicks and `navigate()`; back/forward cannot be
55
+ * cancelled, only observed, because the history entry has already moved.
56
+ */
57
+ on(listener: LayoutListener): () => void
58
+ }
59
+
60
+ export type LayoutListener = (
61
+ next: string[],
62
+ prev: string[],
63
+ cause: 'click' | 'navigate' | 'history',
64
+ ) => boolean | undefined | void
65
+
66
+ /** Is `path` the base itself or inside it? Prefix-safe: `/admin` ≠ `/admini`. */
67
+ export function isUnderBase(base: string, path: string): boolean {
68
+ if (base === '') return path.startsWith('/')
69
+ return path === base || path.startsWith(`${base}/`)
70
+ }
71
+
72
+ /** The segments of `path` below `base` — `[]` for the base itself. */
73
+ export function segmentsUnder(base: string, path: string): string[] {
74
+ const rest = base === '' ? path : path.slice(base.length)
75
+ return rest.split('/').filter(Boolean)
76
+ }
77
+
78
+ function pathFor(base: string, to: string | string[]): string {
79
+ if (Array.isArray(to)) {
80
+ const joined = to.filter(Boolean).join('/')
81
+ return joined ? `${base}/${joined}` : base || '/'
82
+ }
83
+ if (to.startsWith('/')) return to
84
+ return to ? `${base}/${to}` : base || '/'
85
+ }
86
+
87
+ export function defineLayout(): LayoutNavigation {
88
+ const route = (globalThis as any).__vue_route as StampedRoute | undefined
89
+
90
+ // The guard is the contract, not a formality — see the module comment.
91
+ if (!route?.catchAll) {
92
+ throw new Error(
93
+ 'defineLayout() is only available on catch-all pages ' +
94
+ '([...slug].vue or [...slug!].vue): only there does every URL under ' +
95
+ 'the page resolve back to the same file on a full load.',
96
+ )
97
+ }
98
+
99
+ const base = route.base
100
+ const claimed = new Set(route.claimed ?? [])
101
+ const claimedSingle = Boolean(route.claimedSingle)
102
+ const listeners = new Set<LayoutListener>()
103
+
104
+ // The catch-all owns only what nothing else claims. A sibling route under
105
+ // the base — `faculty/[id].vue` beside `[...slug].vue` — wins those URLs on
106
+ // the server, so a soft-nav there would render this page where a hard load
107
+ // renders that one.
108
+ function claimedElsewhere(next: string[]): boolean {
109
+ if (next.length === 1 && claimedSingle) return true
110
+ return next.length > 0 && claimed.has(next[0])
111
+ }
112
+
113
+ const initial =
114
+ typeof location !== 'undefined'
115
+ ? segmentsUnder(base, location.pathname)
116
+ : []
117
+ const segments = ref<string[]>(initial)
118
+
119
+ function fire(next: string[], cause: Parameters<LayoutListener>[2]): boolean {
120
+ const prev = segments.value
121
+ let allowed = true
122
+ for (const listener of listeners) {
123
+ if (listener(next, prev, cause) === false) allowed = false
124
+ }
125
+ return allowed
126
+ }
127
+
128
+ function go(to: string | string[], cause: 'click' | 'navigate'): void {
129
+ const path = pathFor(base, to)
130
+ if (!isUnderBase(base, path)) {
131
+ // Leaving the subtree is a real navigation — the next URL belongs to a
132
+ // different file, and pretending otherwise would render a lie.
133
+ if (typeof location !== 'undefined') location.href = path
134
+ return
135
+ }
136
+ const next = segmentsUnder(base, path)
137
+ if (claimedElsewhere(next)) {
138
+ // Under the base, but a more specific route's territory — real
139
+ // navigation, same reasoning as leaving the base.
140
+ if (typeof location !== 'undefined') location.href = path
141
+ return
142
+ }
143
+ if (!fire(next, cause)) return
144
+ if (typeof history !== 'undefined') history.pushState(null, '', path)
145
+ segments.value = next
146
+ }
147
+
148
+ if (typeof document !== 'undefined') {
149
+ document.addEventListener('click', event => {
150
+ if (event.defaultPrevented) return
151
+ if (event.button !== 0) return
152
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey)
153
+ return
154
+
155
+ const anchor = (event.target as Element | null)?.closest?.('a[href]')
156
+ if (!anchor) return
157
+ if (anchor.getAttribute('target')) return
158
+ if (anchor.hasAttribute('download')) return
159
+
160
+ const href = anchor.getAttribute('href') ?? ''
161
+ // Same-document and external schemes stay the browser's business.
162
+ if (href.startsWith('#')) return
163
+ const url = new URL(href, location.href)
164
+ if (url.origin !== location.origin) return
165
+ if (!isUnderBase(base, url.pathname)) return
166
+ if (
167
+ url.pathname === location.pathname &&
168
+ url.search === location.search
169
+ ) {
170
+ event.preventDefault()
171
+ return
172
+ }
173
+
174
+ event.preventDefault()
175
+ go(url.pathname + url.search, 'click')
176
+ })
177
+
178
+ window.addEventListener('popstate', () => {
179
+ const path = location.pathname
180
+ if (!isUnderBase(base, path)) {
181
+ // History walked out of the subtree; the entry is already current, so
182
+ // the only honest move is loading what that URL actually serves.
183
+ location.reload()
184
+ return
185
+ }
186
+ const next = segmentsUnder(base, path)
187
+ if (claimedElsewhere(next)) {
188
+ location.reload()
189
+ return
190
+ }
191
+ fire(next, 'history') // observable, not cancellable — see `on`
192
+ segments.value = next
193
+ })
194
+ }
195
+
196
+ return {
197
+ segments,
198
+ base,
199
+ navigate: to => go(to, 'navigate'),
200
+ on(listener) {
201
+ listeners.add(listener)
202
+ return () => listeners.delete(listener)
203
+ },
204
+ }
205
+ }
package/src/compile.ts CHANGED
@@ -1,9 +1,6 @@
1
1
  import { compileText } from '@bakery-framework/core/compiler'
2
- import { Logger } from '@bakery-framework/core/logger'
3
2
  import type { SFCStyleCompileResults } from '@vue/compiler-sfc'
4
3
 
5
- const logger = new Logger('vue')
6
-
7
4
  import type {
8
5
  AssembleComponentOptions,
9
6
  CompileScriptOptions,
@@ -36,6 +33,21 @@ export function setVuePluginOptions(opts?: VuePluginOptions) {
36
33
  if (opts) vuePluginOptions = opts
37
34
  }
38
35
 
36
+ /**
37
+ * `'runtime'` unless the app opted into the full build.
38
+ *
39
+ * `customElements` deliberately does *not* force `'full'`: for SFCs the
40
+ * custom-element decision is made server-side, in `compileTemplateBlock`'s
41
+ * `isCustomElement`, and arrives in the browser already baked into the render
42
+ * function. Verified against a live runtime-only page: a configured tag
43
+ * renders as a plain element, reactively, with no "Failed to resolve
44
+ * component" warning. Only browser-compiled `template:` strings need the full
45
+ * build, and only the app knows whether it has any.
46
+ */
47
+ export function vueBuildVariant(): 'runtime' | 'full' {
48
+ return vuePluginOptions.build === 'full' ? 'full' : 'runtime'
49
+ }
50
+
39
51
  export function resolveIsCustomElement(tag: string): boolean {
40
52
  const ce = vuePluginOptions?.customElements
41
53
  const userFn = vuePluginOptions?.compilerOptions?.isCustomElement
@@ -102,7 +114,7 @@ export async function compileScriptBlock(
102
114
 
103
115
  export async function compileTemplateBlock(
104
116
  options: CompileTemplateOptions,
105
- ): Promise<string | null> {
117
+ ): Promise<{ code: string; errors: string[] } | null> {
106
118
  const { descriptor, id, filename, bindings } = options
107
119
  if (!descriptor.template) return null
108
120
  const { compileTemplate } = await loadCompiler()
@@ -122,7 +134,16 @@ export async function compileTemplateBlock(
122
134
  if (node.type !== 5) return
123
135
  const content = node.content
124
136
 
125
- // Simple expression: `{{ value }}`
137
+ // Simple expression: `{{ value }}`.
138
+ //
139
+ // Comments inside an interpolation are not handled specially,
140
+ // deliberately: Vue itself cannot parse `{{ total // pesos }}` —
141
+ // measured against bare `compileTemplate`, which reports the same
142
+ // SyntaxError and emits the raw broken expression. Stripping the
143
+ // comment here just traded that for an unprefixed binding, because
144
+ // Vue's own expression pass had already failed. What Bakery adds
145
+ // instead is failing the compile loudly (see `compileVueFile`)
146
+ // rather than serving a module that cannot parse.
126
147
  if (content.type === 4) {
127
148
  const rawContent = content.content.trim()
128
149
  if (rawContent && !rawContent.startsWith('_ctx.$fmt(')) {
@@ -143,14 +164,19 @@ export async function compileTemplateBlock(
143
164
  },
144
165
  })
145
166
 
146
- if (result.errors?.length) {
147
- logger.log(`Template compile errors: ${result.errors.join(', ')}`, 'error')
148
- }
167
+ // Reported to the caller, not merely logged: a template Vue cannot compile
168
+ // emits the raw unparseable expression into the render function, so the
169
+ // module *cannot run* — `{{ total // pesos }}` is the measured example, and
170
+ // Vue-alone behaves identically. Serving it anyway was a browser-side
171
+ // SyntaxError with a healthy-looking 200.
172
+ const errors = (result.errors ?? []).map(e =>
173
+ e instanceof Error ? e.message : String(e),
174
+ )
149
175
 
150
176
  let code = result.code
151
177
  code = code.replace(/^export\s+/m, '')
152
178
  code = await compileText(code)
153
- return code
179
+ return { code, errors }
154
180
  }
155
181
 
156
182
  export async function compileStyleBlock(
@@ -167,7 +193,7 @@ export async function compileStyleBlock(
167
193
  }
168
194
 
169
195
  export function assembleComponent(options: AssembleComponentOptions): string {
170
- const { scriptCode, renderCode, isRoot, scopeId } = options
196
+ const { scriptCode, renderCode, isRoot, scopeId, layoutRoute } = options
171
197
  const COMPONENT_VAR = '__sfc__'
172
198
  let output = scriptCode.replace(
173
199
  /\bexport\s+default\s*/,
@@ -186,10 +212,30 @@ export function assembleComponent(options: AssembleComponentOptions): string {
186
212
  output += `\nexport default ${COMPONENT_VAR};`
187
213
 
188
214
  if (isRoot) {
215
+ if (layoutRoute) {
216
+ // The page renders into the layout's default <slot />. The import is a
217
+ // plain `.vue` specifier so `rewriteVueImports` gives it the same
218
+ // `?__vue_script=module` treatment as any component import — the layout
219
+ // is just a component that happens to be discovered by convention.
220
+ output +=
221
+ `\nimport { createApp, h as __h } from 'vue';` +
222
+ `\nimport __layout from ${JSON.stringify(layoutRoute)};` +
223
+ `\nconst __app = createApp({ render: () => __h(__layout, null, { default: () => __h(${COMPONENT_VAR}) }) });`
224
+ } else {
225
+ output +=
226
+ `\nimport { createApp } from 'vue';` +
227
+ `\nconst __app = createApp(${COMPONENT_VAR});`
228
+ }
229
+
230
+ // Full build only. `app.config.compilerOptions` is read exclusively by the
231
+ // in-browser template compiler, which the runtime build does not carry —
232
+ // there, the assignment does nothing except make Vue log a warning about
233
+ // itself on every page, even for apps that configured nothing.
234
+ if (vueBuildVariant() === 'full') {
235
+ output += `\n__app.config.compilerOptions.isCustomElement = ${buildRuntimeCustomElementCheck()};`
236
+ }
237
+
189
238
  output +=
190
- `\nimport { createApp } from 'vue';` +
191
- `\nconst __app = createApp(${COMPONENT_VAR});` +
192
- `\n__app.config.compilerOptions.isCustomElement = ${buildRuntimeCustomElementCheck()};` +
193
239
  `\n__app.config.globalProperties.$fmt = (v) => globalThis.$fmt ? globalThis.$fmt(v) : v;` +
194
240
  `\n__app.mount('#app');`
195
241
  }
@@ -216,7 +262,7 @@ function buildRuntimeCustomElementCheck(): string {
216
262
  export async function compileVueFile(
217
263
  options: CompileVueFileOptions,
218
264
  ): Promise<CompileVueFileResult> {
219
- const { content, filename, id, isRootScript } = options
265
+ const { content, filename, id, isRootScript, layoutRoute } = options
220
266
  const { descriptor, errors: parseErrors } = await parseVue({
221
267
  content,
222
268
  filename,
@@ -226,7 +272,7 @@ export async function compileVueFile(
226
272
  descriptor,
227
273
  id,
228
274
  })
229
- const renderCode = await compileTemplateBlock({
275
+ const template = await compileTemplateBlock({
230
276
  descriptor,
231
277
  id,
232
278
  filename,
@@ -234,14 +280,19 @@ export async function compileVueFile(
234
280
  })
235
281
  const code = assembleComponent({
236
282
  scriptCode,
237
- renderCode,
283
+ renderCode: template?.code ?? null,
238
284
  isRoot: isRootScript,
239
285
  scopeId: hasScoped ? id : undefined,
286
+ layoutRoute,
240
287
  })
241
288
 
242
289
  const styles = await Promise.all(
243
290
  descriptor.styles.map(style => compileStyleBlock({ style, id })),
244
291
  )
245
292
 
246
- return { code, styles, errors: parseErrors }
293
+ return {
294
+ code,
295
+ styles,
296
+ errors: [...parseErrors, ...(template?.errors ?? [])],
297
+ }
247
298
  }
package/src/handler.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { readdirSync } from 'node:fs'
1
2
  import { LRUCache } from '@bakery-framework/core/cache/lru'
2
3
  import { Bakery, hostKey } from '@bakery-framework/core/core/bakery'
3
4
  import type { Handler } from '@bakery-framework/core/handlers'
@@ -5,8 +6,10 @@ import {
5
6
  beginPageRoute,
6
7
  DynamicErrorHandler,
7
8
  DynamicHandler,
9
+ RX_CATCHALL,
10
+ RX_DYNAMIC,
11
+ RX_OPT_CATCHALL,
8
12
  } from '@bakery-framework/core/handlers'
9
- import { Logger } from '@bakery-framework/core/logger'
10
13
  import {
11
14
  fs,
12
15
  JsonResponseData,
@@ -15,17 +18,20 @@ import {
15
18
  } from '@bakery-framework/core/utils'
16
19
  import { ETag, injectIfHtml } from '@bakery-framework/core/utils/http'
17
20
 
18
- const logger = new Logger('vue')
19
-
20
21
  import {
21
22
  resolveActionTarget,
22
23
  validateActionRequest,
23
24
  validateActionTarget,
24
25
  } from './actions'
25
26
  import { serveVueChunk, VUE_CHUNK_PREFIX } from './chunks'
26
- import { compileStyleBlock, compileVueFile, parseVue } from './compile'
27
+ import {
28
+ compileStyleBlock,
29
+ compileVueFile,
30
+ parseVue,
31
+ vueBuildVariant,
32
+ } from './compile'
27
33
  import { VUE_HTML_SHELL } from './shell'
28
- import type { ParsedCacheEntry } from './types'
34
+ import type { ParsedCacheEntry, VueMeta } from './types'
29
35
  import {
30
36
  cacheDir,
31
37
  collectExportedFunctionNames,
@@ -34,6 +40,7 @@ import {
34
40
  extractServerScripts,
35
41
  getServerResponse,
36
42
  parsedCache,
43
+ parseSkeleton,
37
44
  parseVueMeta,
38
45
  RX_EXPORT_BRACE,
39
46
  RX_EXPORT_HANGING,
@@ -75,6 +82,150 @@ function buildActionStub(fn: string, relPath: string) {
75
82
  )
76
83
  }
77
84
 
85
+ /**
86
+ * Route path of the nearest `layout.vue`, walking from the page **file** up
87
+ * to the serve root — the file, not the URL, so a catch-all page
88
+ * (`admin/[...slug!].vue`) is wrapped by `admin/layout.vue` no matter how
89
+ * deep the request path goes. Null when nothing is found, when the page
90
+ * opted out with `<meta no-layout />`, or when the page *is* a layout —
91
+ * layouts do not nest in v1, deliberately: nesting needs an ordering story
92
+ * (which slot, whose styles win) that should be designed, not implied.
93
+ */
94
+ function findLayoutRoute(filePath: string, meta: VueMeta): string | null {
95
+ if (!meta.layout) return null
96
+
97
+ const root = fs.resolve(Bakery.serveRoot)
98
+ const file = fs.resolve(filePath)
99
+ if (!file.startsWith(`${root}/`)) return null
100
+ if (file.endsWith('/layout.vue')) return null
101
+
102
+ let dir = file.slice(0, file.lastIndexOf('/'))
103
+ while (dir === root || dir.startsWith(`${root}/`)) {
104
+ const candidate = `${dir}/layout.vue`
105
+ if (fs.isFileSync(candidate)) {
106
+ return `/${fs.relative(root, candidate)}`
107
+ }
108
+ if (dir === root) break
109
+ dir = dir.slice(0, dir.lastIndexOf('/'))
110
+ }
111
+
112
+ return null
113
+ }
114
+
115
+ /**
116
+ * Does `dir` hold a file this handler routes, at any depth? A sibling
117
+ * directory claims its first segment only when it does — the claim exists for
118
+ * `faculty/[id].vue`-shaped subtrees, and a directory of assets or helpers
119
+ * routes nowhere more specific than the catch-all, so stamping its name would
120
+ * disclose it for no navigational gain. Files at each level are checked
121
+ * before any subdirectory is entered, so the common shallow layout answers
122
+ * without recursing. `Dirent.isDirectory()` is false for symlinks, which is
123
+ * what keeps the walk from cycling.
124
+ */
125
+ function containsRouteFile(dir: string, exts: string[]): boolean {
126
+ let entries: import('node:fs').Dirent[]
127
+ try {
128
+ entries = readdirSync(dir, { withFileTypes: true })
129
+ } catch {
130
+ // Unreadable: treated as holding no routes — same reasoning as the catch
131
+ // in claimedBeside below.
132
+ return false
133
+ }
134
+
135
+ const dirs: string[] = []
136
+ for (const entry of entries) {
137
+ if (entry.name.startsWith('.')) continue
138
+ if (entry.isDirectory()) dirs.push(`${dir}/${entry.name}`)
139
+ else if (exts.some(ext => entry.name.endsWith(ext))) return true
140
+ }
141
+ return dirs.some(sub => containsRouteFile(sub, exts))
142
+ }
143
+
144
+ /**
145
+ * What the catch-all's sibling *routes* claim, for the `defineLayout()` stamp.
146
+ *
147
+ * A catch-all owns only *what nothing else claims*: with
148
+ * `admin/[...slug].vue` beside `admin/faculty/[id].vue`, the URL
149
+ * `/admin/faculty/7` is under the base but belongs to `[id].vue` — so the
150
+ * client-side router must yield it to a real navigation, or a soft-nav shows
151
+ * the catch-all's rendering where a hard reload shows a different page.
152
+ *
153
+ * First-level granularity is exactly the server's precedence boundary: every
154
+ * more-specific route — an exact sibling file, a child index, a deeper
155
+ * catch-all — lives inside some sibling entry, so excluding the entry
156
+ * excludes the whole claim. A `[param]` sibling claims *every* single-segment
157
+ * path, which is what `claimedSingle` carries. `layout.vue` claims nothing (it
158
+ * is not routable), and the catch-all file itself is the page being served.
159
+ *
160
+ * Only entries the handler's own extension table routes are claims. The stamp
161
+ * is serialized into the HTML of every served page, so each name in it is
162
+ * published to any visitor — and this function used to list *every* sibling
163
+ * stem, which put non-route file names (`sample.bin`, `script.ts`,
164
+ * `index.tsx`) from the source directory into production responses: a
165
+ * directory listing of `src/`, observed in a smoke test of the published
166
+ * alpha. A file sibling therefore counts only with a routed extension, and a
167
+ * directory sibling only when a route file exists somewhere under it.
168
+ *
169
+ * The boundary that filter accepts: a *non-route* file under the base is
170
+ * served by core's real-file-beats-catch-all rule (`findDynamicRoute`), and
171
+ * the stamp no longer names it, so a plain anchor to one soft-navigates into
172
+ * the catch-all's view. An anchor carrying `target` or `download` is never
173
+ * intercepted — that is the spelling for linking a raw file out of a
174
+ * catch-all's subtree, and what `docs/plugins/vue.md` prescribes.
175
+ *
176
+ * Computed per page request, so files added or removed in dev are seen on the
177
+ * next load without cache ceremony.
178
+ */
179
+ export function claimedBeside(catchAllFile: string): {
180
+ claimed: string[]
181
+ claimedSingle: boolean
182
+ } {
183
+ const claimed = new Set<string>()
184
+ let claimedSingle = false
185
+
186
+ const dir = fs.resolve(catchAllFile).replace(/\/[^/]*$/, '')
187
+ const self = fs.resolve(catchAllFile).slice(dir.length + 1)
188
+ // The handler's own table (`['vue']`), read at call time so the two cannot
189
+ // drift apart.
190
+ const exts = VueHandler.config.ext.map(ext => `.${ext}`)
191
+
192
+ let entries: import('node:fs').Dirent[]
193
+ try {
194
+ entries = readdirSync(dir, { withFileTypes: true })
195
+ } catch {
196
+ // Unreadable directory: no visible siblings means nothing extra claimed,
197
+ // and a hard load still routes correctly — the stamp is an optimisation
198
+ // of honesty, not the source of it.
199
+ return { claimed: [], claimedSingle: false }
200
+ }
201
+
202
+ for (const entry of entries) {
203
+ const name = entry.name
204
+ if (name === self || name === 'layout.vue') continue
205
+ if (name.startsWith('.')) continue
206
+
207
+ if (RX_CATCHALL.test(name) || RX_OPT_CATCHALL.test(name)) continue
208
+
209
+ const isRoute = entry.isDirectory()
210
+ ? containsRouteFile(`${dir}/${name}`, exts)
211
+ : exts.some(ext => name.endsWith(ext))
212
+ if (!isRoute) continue
213
+
214
+ if (RX_DYNAMIC.test(name)) {
215
+ claimedSingle = true
216
+ continue
217
+ }
218
+
219
+ claimed.add(name)
220
+ // `reports.vue` also claims `/base/reports` — the extensionless spelling
221
+ // is the one links actually use.
222
+ const stem = name.replace(/\.[^.]+$/, '')
223
+ if (stem && stem !== name) claimed.add(stem)
224
+ }
225
+
226
+ return { claimed: [...claimed], claimedSingle }
227
+ }
228
+
78
229
  export class VueHandler extends DynamicHandler {
79
230
  static get config() {
80
231
  return {
@@ -108,8 +259,9 @@ export class VueHandler extends DynamicHandler {
108
259
 
109
260
  const rawText = await diskFile.text()
110
261
  const { meta, clean: metaCleaned } = parseVueMeta(rawText)
262
+ const { skeleton, clean: withoutSkeleton } = parseSkeleton(metaCleaned)
111
263
  const { script: serverScript, clean: withoutServer } =
112
- extractServerScripts(metaCleaned)
264
+ extractServerScripts(withoutSkeleton)
113
265
  let cleanContent = withoutServer
114
266
 
115
267
  if (serverScript.trim()) {
@@ -151,6 +303,20 @@ export class VueHandler extends DynamicHandler {
151
303
  cleanContent = `<script${langAttr}>\n${scriptInjections.join(
152
304
  '\n',
153
305
  )}\n</script>\n${cleanContent}`
306
+
307
+ // A component with a server block but no `<script setup>` used to
308
+ // render blank: the injected block above has no `export default`, so
309
+ // `assembleComponent` had nothing to rewrite into `const __sfc__ =`
310
+ // and the module died with `ReferenceError: __sfc__ is not defined`.
311
+ // The documented workaround was a setup block — so inject one. The
312
+ // comment inside is load-bearing: the SFC parser *discards* a block
313
+ // whose content is only whitespace, which is also why the workaround
314
+ // had to be a non-empty block. It also makes the server exports
315
+ // template-visible — compileScript only records plain-script bindings
316
+ // when a setup block exists.
317
+ if (!/<script\s[^>]*\bsetup\b|<script\s+setup/i.test(cleanContent)) {
318
+ cleanContent += `\n<script setup${langAttr}>\n// injected: carries the server-data bindings above\n</script>\n`
319
+ }
154
320
  }
155
321
  }
156
322
 
@@ -182,6 +348,8 @@ export class VueHandler extends DynamicHandler {
182
348
  styles: descriptor.styles,
183
349
  hasCss: descriptor.styles.length > 0,
184
350
  meta,
351
+ skeleton,
352
+ layoutRoute: findLayoutRoute(filePath, meta),
185
353
  }
186
354
  parsedCache.set(id, parsed)
187
355
  return parsed
@@ -200,10 +368,18 @@ export class VueHandler extends DynamicHandler {
200
368
  filename: routePath,
201
369
  id: scopeId || id,
202
370
  isRootScript,
371
+ layoutRoute: isRootScript ? parsed.layoutRoute : null,
203
372
  })
204
373
 
205
374
  if (compiled.errors.length) {
206
- logger.log(`Compile errors: ${compiled.errors.join(', ')}`, 'error')
375
+ // Thrown, not logged-and-served: a template Vue could not compile has
376
+ // the raw unparseable expression in its render function, so serving it
377
+ // is a browser-side SyntaxError behind a 200 and an empty page — the
378
+ // report that surfaced this described exactly that. The throw lands in
379
+ // the error registry as a 500 that names the file and the error.
380
+ throw new Error(
381
+ `Vue compile failed (${routePath}): ${compiled.errors.join('; ')}`,
382
+ )
207
383
  }
208
384
 
209
385
  let code = compiled.code
@@ -246,7 +422,21 @@ export class VueHandler extends DynamicHandler {
246
422
  // data, so the built file can live on disk.
247
423
  if (!hasServerScript || isRootScript) {
248
424
  const dir = fs.resolve(cacheDir, 'js')
249
- const fileName = `${id}${isRootScript ? '.root' : ''}.js`
425
+ // Root scripts carry the build variant in their name for the same reason
426
+ // the chunk does (`vueChunkPath`): the cache is keyed on the *source's*
427
+ // mtime, and flipping `build` in server.config.ts touches no source file
428
+ // — measured serving a root compiled under 'runtime' after the flip to
429
+ // 'full', missing the isCustomElement bridge the full build exists for.
430
+ // Only roots: the variant changes nothing in a subcomponent's output.
431
+ // The layout joins the name for the same reason the variant does: the
432
+ // cache is keyed on the page's mtime, and creating, deleting or moving
433
+ // a layout.vue touches no page file.
434
+ const layoutTag = parsed.layoutRoute
435
+ ? `.${toHash(parsed.layoutRoute)}`
436
+ : ''
437
+ const fileName = isRootScript
438
+ ? `${id}.root.${vueBuildVariant()}${layoutTag}.js`
439
+ : `${id}.js`
250
440
  const replacement =
251
441
  isRootScript && hasServerScript
252
442
  ? '(globalThis.__vue_server || {})'
@@ -295,12 +485,42 @@ export class VueHandler extends DynamicHandler {
295
485
  })
296
486
  }
297
487
 
488
+ /**
489
+ * The layout's stylesheet link, or ''. Emitted ahead of the page's own link
490
+ * so a page can override its layout the way source order implies.
491
+ */
492
+ private static async layoutCssLink(parsed: ParsedCacheEntry) {
493
+ if (!parsed.layoutRoute) return ''
494
+
495
+ const layoutFile = fs.resolve(Bakery.serveRoot, `.${parsed.layoutRoute}`)
496
+ const layoutBun = Bun.file(layoutFile)
497
+ if (!fs.exists(layoutBun)) return ''
498
+
499
+ const layoutId = toHash(hostKey(parsed.layoutRoute.slice(1)))
500
+ const layoutParsed = await VueHandler.parseVueFile(
501
+ layoutId,
502
+ layoutBun,
503
+ layoutFile,
504
+ layoutBun.lastModified,
505
+ )
506
+ if (!layoutParsed.hasCss) return ''
507
+
508
+ return `<link rel="stylesheet" id="__vu_css_${layoutId}" href="${parsed.layoutRoute}?__vue_css=true">\n`
509
+ }
510
+
298
511
  static async handleHtml(
299
512
  id: string,
300
513
  params: any,
301
514
  routePath: string,
302
515
  serverParams: any,
303
516
  parsed: ParsedCacheEntry,
517
+ route?: {
518
+ catchAll: boolean
519
+ base: string
520
+ param: string | null
521
+ claimed?: string[]
522
+ claimedSingle?: boolean
523
+ },
304
524
  ) {
305
525
  const { hasCss, serverScript } = parsed
306
526
  const hasServerData =
@@ -317,11 +537,30 @@ export class VueHandler extends DynamicHandler {
317
537
  ? `<script>globalThis.__vue_server = ${escapeScriptJson(payload)};</script>`
318
538
  : ''
319
539
 
540
+ // The route's shape, for `defineLayout()` (`client.ts`): the guard that
541
+ // restricts it to catch-all pages reads `catchAll` from here, so the
542
+ // stamp is the enforcement, not a convenience. Stamped on every page —
543
+ // a non-catch-all page carries `catchAll: false`, which is what makes
544
+ // the client-side error message possible instead of a bare undefined.
545
+ const routeDecl = route
546
+ ? `<script>globalThis.__vue_route = ${escapeScriptJson(route)};</script>`
547
+ : ''
548
+
320
549
  let hydrated = VUE_HTML_SHELL.replace(
321
550
  '/*__SERVER_VARIABLES__*/',
322
- () => serverDecl,
551
+ () => serverDecl + routeDecl,
323
552
  )
324
553
 
554
+ // Static markup, injected verbatim — see parseSkeleton for why it is
555
+ // never rendered. mount() replaces the container children, so it
556
+ // disappears the moment the real component is up.
557
+ if (parsed.skeleton) {
558
+ hydrated = hydrated.replace(
559
+ '<div id="app"></div>',
560
+ () => `<div id="app">${parsed.skeleton}</div>`,
561
+ )
562
+ }
563
+
325
564
  if (parsed.meta.title) {
326
565
  const title = escapeHtml(parsed.meta.title)
327
566
  hydrated = hydrated.replace(
@@ -331,6 +570,7 @@ export class VueHandler extends DynamicHandler {
331
570
  }
332
571
 
333
572
  const prio =
573
+ (await VueHandler.layoutCssLink(parsed)) +
334
574
  (hasCss
335
575
  ? `<link rel="stylesheet" id="__vu_css_${id}" href="${routePath}?__vue_css=true">\n`
336
576
  : '') +
@@ -432,6 +672,13 @@ async function sharedHandler(
432
672
  return response.error('Not Found', 404)
433
673
  }
434
674
 
675
+ // A layout is scaffolding, not a destination: /admin/layout must not render
676
+ // as a page. Script and css requests pass — they are how the root script of
677
+ // every page under it imports the thing.
678
+ if (routePath.endsWith('/layout.vue') && !isScript && !isCss) {
679
+ return response.error('Not Found', 404)
680
+ }
681
+
435
682
  // page-only: block module imports (allow root scripts, page, and css)
436
683
  if (parsed.meta.pageOnly && isScript && vueScriptParam === 'module') {
437
684
  return response.error('Not Found', 404)
@@ -534,5 +781,24 @@ async function sharedHandler(
534
781
  return VueHandler.handleScript(id, routePath, false, parsed, serverValues)
535
782
  }
536
783
 
537
- return VueHandler.handleHtml(id, finalParams, routePath, serverParams, parsed)
784
+ // `base` is the URL prefix the page owns: the file's directory. For
785
+ // `wiki/[...page!].vue` that is `/wiki`; for a root-level catch-all it is
786
+ // the empty string, which `defineLayout` treats as "everything".
787
+ const catchAll = Boolean(info.catchAll)
788
+ return VueHandler.handleHtml(
789
+ id,
790
+ finalParams,
791
+ routePath,
792
+ serverParams,
793
+ parsed,
794
+ {
795
+ catchAll,
796
+ base: routePath.slice(0, routePath.lastIndexOf('/')),
797
+ param: info.params.length ? info.params[info.params.length - 1] : null,
798
+ // Catch-all pages only: `defineLayout()` refuses every other page, so
799
+ // on those the claims would be sibling names published in the HTML with
800
+ // no reader. Skipping the stamp also skips the directory scan.
801
+ ...(catchAll ? claimedBeside(diskFile.name ?? '') : null),
802
+ },
803
+ )
538
804
  }
package/src/setup.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { Bakery } from '@bakery-framework/core/core/bakery'
2
2
  import { Logger } from '@bakery-framework/core/logger'
3
+ import { vueChunkPath } from './chunks'
3
4
  import { VueErrorHandler, VueHandler } from './handler'
4
- import { initVueVersion, VUE_VERSION } from './utils'
5
+ import { initVueVersion } from './utils'
5
6
 
6
7
  const logger = new Logger('vue')
7
8
 
@@ -19,5 +20,8 @@ export function setupVue() {
19
20
  initVueVersion()
20
21
  Bakery.handlers.fetch.set(VueHandler, 58)
21
22
  Bakery.handlers.error.set(VueErrorHandler, 18)
22
- Bakery.config.importMap.vue = `/_vue/${VUE_VERSION}.js`
23
+ // `vueChunkPath` is the single writer of this URL — it carries the build
24
+ // variant (`<version>.runtime.js` / `<version>.full.js`), and the serving
25
+ // check in `chunks.ts` reads the same function.
26
+ Bakery.config.importMap.vue = vueChunkPath()
23
27
  }
package/src/types.d.ts CHANGED
@@ -41,6 +41,8 @@ export interface AssembleComponentOptions {
41
41
  renderCode: string | null
42
42
  isRoot: boolean
43
43
  scopeId?: string
44
+ /** Route path of the layout to wrap a root component in, if any. */
45
+ layoutRoute?: string | null
44
46
  }
45
47
 
46
48
  export interface CompileVueFileOptions {
@@ -48,6 +50,8 @@ export interface CompileVueFileOptions {
48
50
  filename: string
49
51
  id: string
50
52
  isRootScript: boolean
53
+ /** Route path of the nearest layout.vue; only read for root scripts. */
54
+ layoutRoute?: string | null
51
55
  }
52
56
 
53
57
  export interface CompileVueFileResult {
@@ -60,6 +64,8 @@ export interface VueMeta {
60
64
  moduleOnly: boolean
61
65
  pageOnly: boolean
62
66
  title: string | null
67
+ /** False when the page opted out with `<meta no-layout />`. */
68
+ layout: boolean
63
69
  }
64
70
 
65
71
  export interface ParsedCacheEntry {
@@ -71,6 +77,17 @@ export interface ParsedCacheEntry {
71
77
  styles: SFCStyleBlock[]
72
78
  hasCss: boolean
73
79
  meta: VueMeta
80
+ /**
81
+ * Inner markup of a `<template skeleton>` block, or null. Static by
82
+ * construction — extracted before compilation, never rendered on the
83
+ * server — so nothing request- or user-derived can reach it.
84
+ */
85
+ skeleton: string | null
86
+ /**
87
+ * Route path of the nearest `layout.vue` (e.g. `/admin/layout.vue`), or
88
+ * null when there is none or the page opted out.
89
+ */
90
+ layoutRoute: string | null
74
91
  }
75
92
 
76
93
  export interface ServerResponseOptions {
@@ -89,4 +106,16 @@ export type CustomElementsOption = string[] | ((tag: string) => boolean)
89
106
  export interface VuePluginOptions {
90
107
  customElements?: CustomElementsOption
91
108
  compilerOptions?: Record<string, any>
109
+ /**
110
+ * Which Vue build the plugin serves at `/_vue/<version>.<build>.js`.
111
+ *
112
+ * `'runtime'` (the default) is ~170KB smaller and is all a Bakery app
113
+ * normally needs: SFC templates are compiled to render functions on the
114
+ * server, and `customElements` is applied there too, so the browser never
115
+ * compiles a template. Opt into `'full'` only for components that hand Vue a
116
+ * raw `template:` string at runtime — those are compiled in the browser and
117
+ * fail on the runtime build with Vue's "runtime compilation is not
118
+ * supported" error.
119
+ */
120
+ build?: 'runtime' | 'full'
92
121
  }
package/src/utils.ts CHANGED
@@ -570,8 +570,44 @@ export function rewriteVueImports(code: string): string {
570
570
  export const RX_VUE_META = /^<meta(?![\w-])((?:"[^"]*"|'[^']*'|[^>])*?)\/>/i
571
571
  const RX_META_SKIPPABLE = /^\s+|^<!--[\s\S]*?-->/
572
572
 
573
+ /**
574
+ * Extract a `<template skeleton>` block: its inner markup goes into the HTML
575
+ * shell's `#app` so the user sees something before the bundle hydrates, and
576
+ * the block is removed from the SFC — the compiler allows only one template.
577
+ *
578
+ * **Static by design, and the design is a security decision.** The markup is
579
+ * injected verbatim: never compiled, never rendered on the server, so
580
+ * interpolations do not evaluate and nothing request- or session-derived can
581
+ * end up in it. A server-rendered skeleton cached across requests would serve
582
+ * one user's data to another. Scoped styles do not reach it either — the
583
+ * scope attributes are stamped by the compiler this block never meets.
584
+ *
585
+ * One block per file; nested `<template>` elements inside it are not
586
+ * supported (the lazy match ends at the first closing tag).
587
+ */
588
+ export function parseSkeleton(raw: string): {
589
+ skeleton: string | null
590
+ clean: string
591
+ } {
592
+ const match = raw.match(
593
+ /<template\s+skeleton(?:\s(?:"[^"]*"|'[^']*'|[^>])*)?>([\s\S]*?)<\/template>/i,
594
+ )
595
+ if (!match) return { skeleton: null, clean: raw }
596
+
597
+ const skeleton = match[1].trim()
598
+ return {
599
+ skeleton: skeleton || null,
600
+ clean: raw.replace(match[0], ''),
601
+ }
602
+ }
603
+
573
604
  export function parseVueMeta(raw: string): { meta: VueMeta; clean: string } {
574
- const meta: VueMeta = { moduleOnly: false, pageOnly: false, title: null }
605
+ const meta: VueMeta = {
606
+ moduleOnly: false,
607
+ pageOnly: false,
608
+ title: null,
609
+ layout: true,
610
+ }
575
611
 
576
612
  // Directives live in the file prologue only. Walking forward from the start
577
613
  // (rather than scanning the whole file) keeps a `<meta />` inside a template
@@ -595,6 +631,7 @@ export function parseVueMeta(raw: string): { meta: VueMeta; clean: string } {
595
631
  const attrs = tag[1]
596
632
  if (/\bmodule-only\b/i.test(attrs)) meta.moduleOnly = true
597
633
  if (/\bpage-only\b/i.test(attrs)) meta.pageOnly = true
634
+ if (/\bno-layout\b/i.test(attrs)) meta.layout = false
598
635
 
599
636
  const titleMatch = attrs.match(
600
637
  /\btitle\s*=\s*"([^"]*)"|\btitle\s*=\s*'([^']*)'/i,