@greendrake/vite-preset 0.5.0 → 0.7.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
@@ -34,12 +34,13 @@ export default defineConfig(
34
34
  | `scssLoadPaths` | Extra `@use` resolution roots for Sass (e.g. the app's `src/scss`). |
35
35
  | `port` | `'env'` (default): `VITE_PORT` is required whenever a server starts. A number pins that port. `false` disables pinning, for an app served on any free port. |
36
36
  | `htmlPlaceholders` | Literal substitutions applied to `index.html` before Vite processes it (see `htmlPlaceholders` below). |
37
+ | `htmlData` | JSON data elements written into `index.html`, by element id, for the app to read at runtime (see `htmlData` below). |
37
38
  | `staticRoots` | Directories outside the app served at public URL prefixes (see `staticRoots` below). |
38
39
  | `overrides` | A `UserConfig` deep-merged last — the escape hatch for `server.proxy`, extra plugins, build tweaks. |
39
40
 
40
41
  What the preset sets:
41
42
 
42
- - plugins: `@vitejs/plugin-vue` and the HMR circular-import guard, plus the `staticRoots` and `htmlPlaceholders` plugins when their options are given;
43
+ - plugins: `@vitejs/plugin-vue` and the HMR circular-import guard, plus the `staticRoots`, `htmlPlaceholders` and `htmlData` plugins when their options are given;
43
44
  - `server.port`/`preview.port` from `VITE_PORT` with `strictPort`, for `vite dev` and `vite preview` only — builds run in deploy contexts that never define a dev port;
44
45
  - `server.fs.allow`: the parent of the app root (a workspace), and every static root's directory;
45
46
  - `resolve.alias`: `@` → `<dirname>/src`; `resolve.dedupe`: `vue`, `pinia`, `vue-router` — each must also be a direct dependency of the app, or Vite fails to resolve it;
@@ -87,6 +88,10 @@ import { loadAppEnv, requirePort, envBool, deriveImageBaseUrl, apiTargetEnv, bui
87
88
 
88
89
  Substitutes build-time values into `index.html` before Vite processes it — a generated inline script, a build hash, PWA metadata. Keys are matched literally (convention: `__NAME__`); an empty map is an error. `vueSpa`'s `htmlPlaceholders` option installs it.
89
90
 
91
+ ### `htmlData(map)`
92
+
93
+ Writes `<script type="application/json" id="<id>">JSON</script>` into the head of `index.html` per entry — settings the app reads at runtime (`JSON.parse` of the element's text) rather than through a compile-time define. `<` in the JSON is escaped, so no value ends the element. A `null` value leaves the element empty for a Go binary that serves the build to fill from its own configuration: `greendrake/spa`'s `Data` matches the element in exactly this form. `vueSpa`'s `htmlData` option installs it.
94
+
90
95
  ### `staticRoots(roots)` and `staticRootFiles(root)`
91
96
 
92
97
  Some assets belong to the product rather than to the frontend — a locale catalogue a backend renders from too, prose a content page shows. A `StaticRoot` is `{ dir, urlPath, transform? }`: an absolute directory, the URL prefix it appears under (e.g. `/i18n`), and an optional per-file rewrite applied on the way out. The plugin serves the root by middleware in dev and copies it into `dist/` at build, so the two modes publish the same bytes; `.yaml`/`.yml` and `.json` get their content types, anything else is `text/plain`. Requests cannot climb out of the root. `staticRootFiles(root)` lists what a root publishes as URL paths, for a service worker's precache list. Installed by the `staticRoots` option.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@greendrake/vite-preset",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Vite config factory and build-env helpers for Vue 3 single-page apps: plugin set, strict dev port, alias, dedupe and source-package pre-bundling rules",
5
5
  "license": "MIT",
6
6
  "author": "Eugene Greendrake <eugene@greendrake.nz>",
@@ -44,7 +44,7 @@
44
44
  "vite": ">=8"
45
45
  },
46
46
  "devDependencies": {
47
- "@greendrake/dev-config": "0.5.0",
47
+ "@greendrake/dev-config": "0.7.0",
48
48
  "@types/bun": "^1.3.5",
49
49
  "@types/node": "^24.0.0",
50
50
  "typescript": "6.0.3",
package/src/html.ts CHANGED
@@ -1,5 +1,22 @@
1
1
  import type { Plugin } from 'vite'
2
2
 
3
+ // Writes a data element per entry into index.html's head —
4
+ // <script type="application/json" id="<id>">JSON</script> — that the app
5
+ // reads at runtime instead of a compile-time define. A null value leaves the
6
+ // element empty, for the Go binary that serves the build to fill
7
+ // (greendrake/spa's Data, which matches the element in exactly this form).
8
+ // `<` is escaped so no value can end the script element.
9
+ export const htmlData = (map: Record<string, unknown>): Plugin => ({
10
+ name: 'greendrake-html-data',
11
+ transformIndexHtml: () =>
12
+ Object.entries(map).map(([id, value]) => ({
13
+ tag: 'script',
14
+ attrs: { type: 'application/json', id },
15
+ children: value === null ? '' : JSON.stringify(value).replace(/</g, '\\u003c'),
16
+ injectTo: 'head-prepend' as const
17
+ }))
18
+ })
19
+
3
20
  // Substitutes build-time values into index.html before Vite processes it —
4
21
  // e.g. a generated bootstrap snippet, a build hash, PWA metadata. Keys are
5
22
  // matched literally (convention: __NAME__).
package/src/main.ts CHANGED
@@ -8,11 +8,11 @@ export { envBool, deriveImageBaseUrl, loadAppEnv, requirePort, apiTargetEnv, bui
8
8
  // vite types: a second vite copy resolved downstream is a structurally
9
9
  // unrelated set of the same names.
10
10
  export type { ConfigEnv, UserConfig, UserConfigFnObject } from 'vite'
11
- export { htmlPlaceholders } from './html'
11
+ export { htmlData, htmlPlaceholders } from './html'
12
12
  export { resolveApiTarget, browserRuntime, type ApiTarget, type ApiTargetEnv, type ApiTargetRuntime } from './apiTarget'
13
13
  export { staticRoots, staticRootFiles, type StaticRoot } from './staticRoots'
14
14
  import { requirePort } from './env'
15
- import { htmlPlaceholders } from './html'
15
+ import { htmlData, htmlPlaceholders } from './html'
16
16
  import { hmrCircularImportGuard } from './hmr'
17
17
  import { nestedDependencies, type DeclaredDependencies } from './sourceDeps'
18
18
  import { staticRoots as staticRootsPlugin, type StaticRoot } from './staticRoots'
@@ -34,6 +34,9 @@ export interface VueSpaOptions {
34
34
  // Build-time placeholder substitution in index.html (see the
35
35
  // htmlPlaceholders plugin) — e.g. injecting an inline bootstrap script.
36
36
  htmlPlaceholders?: Record<string, string>
37
+ // Data elements in index.html the app reads at runtime, by element id
38
+ // (see the htmlData plugin).
39
+ htmlData?: Record<string, unknown> | undefined
37
40
  // Directories outside the app served at a public URL prefix — product
38
41
  // assets both stacks own (locale catalogues, content prose). Served by
39
42
  // middleware in dev and copied into dist/ at build, so the two modes
@@ -88,7 +91,13 @@ export const vueSpa =
88
91
  const portPin = command === 'serve' && options.port !== false ? { port: typeof options.port === 'number' ? options.port : requirePort(), strictPort: true } : undefined
89
92
  const base: UserConfig = {
90
93
  ...(options.define && { define: Object.fromEntries(Object.entries(options.define).map(([key, value]) => [key, JSON.stringify(value)])) }),
91
- plugins: [vue(), hmrCircularImportGuard(), ...(options.staticRoots?.length ? [staticRootsPlugin(options.staticRoots)] : []), ...(options.htmlPlaceholders ? [htmlPlaceholders(options.htmlPlaceholders)] : [])],
94
+ plugins: [
95
+ vue(),
96
+ hmrCircularImportGuard(),
97
+ ...(options.staticRoots?.length ? [staticRootsPlugin(options.staticRoots)] : []),
98
+ ...(options.htmlPlaceholders ? [htmlPlaceholders(options.htmlPlaceholders)] : []),
99
+ ...(options.htmlData ? [htmlData(options.htmlData)] : [])
100
+ ],
92
101
  ...(command === 'serve'
93
102
  ? {
94
103
  server: {
@@ -25,17 +25,24 @@ const contentTypeFor = (file: string): string => {
25
25
  return 'text/plain; charset=utf-8'
26
26
  }
27
27
 
28
- // Every file under dir, as paths relative to it.
28
+ // What a root publishes: every file under it that is not a dotfile, nor under
29
+ // a dot-directory. A dotfile is never a product asset — it is what Finder
30
+ // (.DS_Store) and editors leave in a directory uninvited — and published it
31
+ // would be served as one, or handed to the transform and parsed as a catalogue.
32
+ // Split on either separator: the dev middleware passes URL paths.
33
+ const published = (relativePath: string): boolean => !relativePath.split(/[\\/]/).some(segment => segment.startsWith('.'))
34
+
35
+ // Every published file under dir, as paths relative to it.
29
36
  const walk = (dir: string): string[] =>
30
37
  readdirSync(dir, { recursive: true, withFileTypes: true })
31
38
  .filter(entry => entry.isFile())
32
39
  .map(entry => path.relative(dir, path.join(entry.parentPath, entry.name)))
40
+ .filter(published)
33
41
 
34
42
  // staticRootFiles lists what a root publishes, as scope-relative URL paths —
35
43
  // the service worker's precache list needs to name these without duplicating
36
44
  // the walk.
37
- export const staticRootFiles = (root: StaticRoot): string[] =>
38
- walk(root.dir).map(rel => `${root.urlPath}/${rel.split(path.sep).join('/')}`)
45
+ export const staticRootFiles = (root: StaticRoot): string[] => walk(root.dir).map(rel => `${root.urlPath}/${rel.split(path.sep).join('/')}`)
39
46
 
40
47
  export const staticRoots = (roots: StaticRoot[]): Plugin => ({
41
48
  name: 'greendrake-static-roots',
@@ -52,7 +59,7 @@ export const staticRoots = (roots: StaticRoot[]): Plugin => ({
52
59
  // Nothing climbs out of the root: the URL is attacker-controlled in
53
60
  // dev too, and the roots sit outside the workspace fs.allow list.
54
61
  const file = path.resolve(root.dir, rel)
55
- if (!file.startsWith(path.resolve(root.dir) + path.sep) || !existsSync(file)) {
62
+ if (!published(rel) || !file.startsWith(path.resolve(root.dir) + path.sep) || !existsSync(file)) {
56
63
  next()
57
64
  return
58
65
  }
@@ -70,7 +77,7 @@ export const staticRoots = (roots: StaticRoot[]): Plugin => ({
70
77
  const dest = path.resolve(this.environment.config.build.outDir, root.urlPath.replace(/^\//, ''))
71
78
  mkdirSync(dest, { recursive: true })
72
79
  if (!root.transform) {
73
- cpSync(root.dir, dest, { recursive: true })
80
+ cpSync(root.dir, dest, { recursive: true, filter: source => published(path.relative(root.dir, source)) })
74
81
  continue
75
82
  }
76
83
  for (const rel of walk(root.dir)) {