@wippy-fe/vite-plugin 0.0.27

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 ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "@wippy-fe/vite-plugin",
3
+ "type": "module",
4
+ "version": "0.0.27",
5
+ "description": "Vite plugins for Wippy apps and web components — package metadata injection, future build-time helpers.",
6
+ "license": "UNLICENSED",
7
+ "sideEffects": false,
8
+ "main": "src/index.ts",
9
+ "types": "src/index.ts",
10
+ "files": [
11
+ "package.json",
12
+ "src/"
13
+ ],
14
+ "peerDependencies": {
15
+ "vite": "^5 || ^6 || ^7"
16
+ },
17
+ "dependencies": {
18
+ "@wippy-fe/shared": "0.0.27"
19
+ }
20
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @vitest-environment node
3
+ *
4
+ * Vite plugin runs at build time and reads the file system — node env,
5
+ * not jsdom. We mock `node:fs.readFileSync` so the test doesn't touch
6
+ * the actual repo's package.json.
7
+ */
8
+ import type { IndexHtmlTransformContext } from 'vite'
9
+ import { describe, expect, it, vi } from 'vitest'
10
+ import { wippyPackagePlugin } from './index'
11
+
12
+ vi.mock('node:fs', () => ({
13
+ readFileSync: vi.fn(),
14
+ }))
15
+
16
+ const { readFileSync } = await import('node:fs')
17
+
18
+ interface InvokablePlugin {
19
+ configResolved?: (config: unknown) => void
20
+ transformIndexHtml: {
21
+ handler: (html: string, ctx?: IndexHtmlTransformContext) => string
22
+ }
23
+ }
24
+
25
+ function invokePlugin(html: string, packageJson: string | Error): string {
26
+ if (packageJson instanceof Error) {
27
+ vi.mocked(readFileSync).mockImplementationOnce(() => {
28
+ throw packageJson
29
+ })
30
+ }
31
+ else {
32
+ vi.mocked(readFileSync).mockReturnValueOnce(packageJson)
33
+ }
34
+
35
+ const plugin = wippyPackagePlugin() as unknown as InvokablePlugin
36
+ plugin.configResolved?.({ root: '/fake' })
37
+ return plugin.transformIndexHtml.handler(html)
38
+ }
39
+
40
+ describe('wippyPackagePlugin', () => {
41
+ it('injects the package JSON as a data-role script at the top of <head>', () => {
42
+ const out = invokePlugin('<html><head><meta charset="utf-8"></head></html>', '{"name":"demo"}')
43
+ expect(out).toContain('<script type="application/json" data-role="@wippy/package">{"name":"demo"}</script>')
44
+ expect(out.indexOf('data-role="@wippy/package"')).toBeLessThan(out.indexOf('charset'))
45
+ })
46
+
47
+ it('defangs `</script>` sequences inside the JSON so the tag cannot be terminated early', () => {
48
+ const malicious = '{"trick": "</script><img src=x onerror=alert(1)>"}'
49
+ const out = invokePlugin('<html><head></head></html>', malicious)
50
+ // The literal `</script` must not survive intact between the script
51
+ // tag we opened and the matching close — otherwise the browser
52
+ // would parse the rest of the JSON as HTML.
53
+ const inside = out.split('data-role="@wippy/package">')[1].split('</script>')[0]
54
+ expect(inside).not.toContain('</script')
55
+ expect(inside).toContain('<\\/script')
56
+ })
57
+
58
+ it('defangs case-insensitively (`</SCRIPT`, `</Script`)', () => {
59
+ const out = invokePlugin('<html><head></head></html>', '{"a":"</SCRIPT</Script"}')
60
+ const inside = out.split('data-role="@wippy/package">')[1].split('</script>')[0]
61
+ expect(inside).not.toMatch(/<\/script/i)
62
+ })
63
+
64
+ it('silently no-ops when package.json is missing and required is false (default)', () => {
65
+ const html = '<html><head></head></html>'
66
+ const out = invokePlugin(html, new Error('ENOENT'))
67
+ expect(out).toBe(html)
68
+ expect(out).not.toContain('@wippy/package')
69
+ })
70
+
71
+ it('throws when package.json is missing and required: true', () => {
72
+ vi.mocked(readFileSync).mockImplementationOnce(() => {
73
+ throw new Error('ENOENT: missing')
74
+ })
75
+ const plugin = wippyPackagePlugin({ required: true }) as unknown as InvokablePlugin
76
+ plugin.configResolved?.({ root: '/fake' })
77
+ expect(() => plugin.transformIndexHtml.handler('<html><head></head></html>')).toThrow(/ENOENT/)
78
+ })
79
+
80
+ it('best-effort prepends the script tag when <head> is missing', () => {
81
+ const out = invokePlugin('<html><body>no head</body></html>', '{"name":"x"}')
82
+ expect(out.startsWith('<script type="application/json" data-role="@wippy/package">')).toBe(true)
83
+ })
84
+ })
package/src/index.ts ADDED
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Vite plugins shared across Wippy apps and web components.
3
+ *
4
+ * **Status: Draft 1 (preview).** API may change between minor releases.
5
+ */
6
+
7
+ import type { Plugin, ResolvedConfig } from 'vite'
8
+ import { readFileSync } from 'node:fs'
9
+ import { resolve } from 'node:path'
10
+ import { WIPPY_PACKAGE_DATA_ROLE } from '@wippy-fe/shared'
11
+
12
+ export { WIPPY_PACKAGE_DATA_ROLE }
13
+
14
+ export interface WippyPackagePluginOptions {
15
+ /**
16
+ * Path to the project's `package.json`. Defaults to `package.json`
17
+ * relative to Vite's resolved root (which is the directory containing
18
+ * `vite.config.ts` for typical app layouts).
19
+ */
20
+ packageJsonPath?: string
21
+ /**
22
+ * If `true`, fail the build when `package.json` cannot be read. If
23
+ * `false` (default), the plugin silently no-ops — useful for shared
24
+ * configs where some target apps don't need the injection.
25
+ */
26
+ required?: boolean
27
+ }
28
+
29
+ /**
30
+ * Injects the consuming app's `package.json` content into `app.html` as
31
+ * `<script type="application/json" data-role="@wippy/package">`. The
32
+ * script is placed at the top of `<head>` so it lands in the DOM before
33
+ * `dev-proxy.js` runs in host-less mode.
34
+ *
35
+ * dev-proxy reads the JSON synchronously at boot to seed proxy injection
36
+ * defaults from `wippy.proxy.injections` and per-page theme defaults
37
+ * from `wippy.configOverrides.customization`. Under a real Wippy host
38
+ * the script tag is harmless metadata — the host reads `package.json`
39
+ * from the registry server-side and ignores the inline copy.
40
+ *
41
+ * Usage:
42
+ *
43
+ * ```ts
44
+ * // vite.config.ts
45
+ * import { defineConfig } from 'vite'
46
+ * import vue from '@vitejs/plugin-vue'
47
+ * import { wippyPackagePlugin } from '@wippy-fe/vite-plugin'
48
+ *
49
+ * export default defineConfig({
50
+ * plugins: [vue(), wippyPackagePlugin()],
51
+ * // …
52
+ * })
53
+ * ```
54
+ */
55
+ export function wippyPackagePlugin(options: WippyPackagePluginOptions = {}): Plugin {
56
+ let root: string
57
+
58
+ return {
59
+ name: '@wippy-fe/vite-plugin:wippy-package',
60
+ enforce: 'pre',
61
+ configResolved(config: ResolvedConfig) {
62
+ root = config.root
63
+ },
64
+ transformIndexHtml: {
65
+ order: 'pre',
66
+ handler(html) {
67
+ const pkgPath = options.packageJsonPath ?? resolve(root, 'package.json')
68
+ let raw: string
69
+ try {
70
+ raw = readFileSync(pkgPath, 'utf-8')
71
+ }
72
+ catch (err) {
73
+ if (options.required)
74
+ throw new Error(`[@wippy-fe/vite-plugin] failed to read ${pkgPath}: ${(err as Error).message}`)
75
+ return html
76
+ }
77
+ // Defang `</script>` sequences inside the JSON so the script tag
78
+ // can't be terminated early. JSON.stringify(JSON.parse(raw))
79
+ // would also collapse whitespace; we keep formatting for
80
+ // readability and just escape the close tag.
81
+ const safeJson = raw.replace(/<\/script/gi, '<\\/script')
82
+ const tag = `<script type="application/json" data-role="${WIPPY_PACKAGE_DATA_ROLE}">${safeJson}</script>`
83
+ if (html.includes('<head>'))
84
+ return html.replace('<head>', `<head>\n ${tag}`)
85
+ // No <head> tag — best effort prepend.
86
+ return tag + html
87
+ },
88
+ },
89
+ }
90
+ }