@jasonshimmy/vite-plugin-cer-app 0.1.6 → 0.3.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/.github/workflows/publish.yml +56 -5
- package/CHANGELOG.md +8 -0
- package/README.md +2 -0
- package/commits.txt +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/build.js +19 -5
- package/dist/cli/commands/build.js.map +1 -1
- package/dist/cli/commands/dev.js +1 -1
- package/dist/cli/commands/dev.js.map +1 -1
- package/dist/cli/commands/preview.d.ts.map +1 -1
- package/dist/cli/commands/preview.js +0 -1
- package/dist/cli/commands/preview.js.map +1 -1
- package/dist/cli/create/index.js +7 -3
- package/dist/cli/create/index.js.map +1 -1
- package/dist/cli/create/templates/spa/.gitignore.tpl +25 -0
- package/dist/cli/create/templates/spa/index.html.tpl +1 -1
- package/dist/cli/create/templates/ssg/.gitignore.tpl +25 -0
- package/dist/cli/create/templates/ssg/index.html.tpl +1 -1
- package/dist/cli/create/templates/ssr/.gitignore.tpl +25 -0
- package/dist/cli/create/templates/ssr/cer.config.ts.tpl +0 -1
- package/dist/cli/create/templates/ssr/index.html.tpl +1 -1
- package/dist/plugin/build-ssg.d.ts.map +1 -1
- package/dist/plugin/build-ssg.js.map +1 -1
- package/dist/plugin/build-ssr.d.ts +10 -0
- package/dist/plugin/build-ssr.d.ts.map +1 -1
- package/dist/plugin/build-ssr.js +21 -8
- package/dist/plugin/build-ssr.js.map +1 -1
- package/dist/plugin/dev-server.d.ts +0 -1
- package/dist/plugin/dev-server.d.ts.map +1 -1
- package/dist/plugin/dev-server.js +0 -2
- package/dist/plugin/dev-server.js.map +1 -1
- package/dist/plugin/dts-generator.d.ts +4 -4
- package/dist/plugin/dts-generator.d.ts.map +1 -1
- package/dist/plugin/dts-generator.js +39 -19
- package/dist/plugin/dts-generator.js.map +1 -1
- package/dist/plugin/generated-dir.d.ts +28 -0
- package/dist/plugin/generated-dir.d.ts.map +1 -0
- package/dist/plugin/generated-dir.js +106 -0
- package/dist/plugin/generated-dir.js.map +1 -0
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +27 -1
- package/dist/plugin/index.js.map +1 -1
- package/dist/plugin/path-utils.js.map +1 -1
- package/dist/plugin/virtual/loading.d.ts.map +1 -1
- package/dist/plugin/virtual/loading.js.map +1 -1
- package/dist/runtime/app-template.d.ts +9 -0
- package/dist/runtime/app-template.d.ts.map +1 -0
- package/dist/runtime/app-template.js +159 -0
- package/dist/runtime/app-template.js.map +1 -0
- package/dist/types/config.d.ts +0 -1
- package/dist/types/config.d.ts.map +1 -1
- package/dist/types/config.js.map +1 -1
- package/docs/cli.md +1 -1
- package/docs/configuration.md +2 -11
- package/docs/getting-started.md +2 -100
- package/docs/rendering-modes.md +4 -5
- package/docs/routing.md +1 -1
- package/e2e/kitchen-sink/tsconfig.json +3 -0
- package/eslint.config.ts +22 -0
- package/package.json +6 -1
- package/src/__tests__/plugin/build-ssr.test.ts +24 -10
- package/src/__tests__/plugin/cer-app-plugin.test.ts +35 -0
- package/src/__tests__/plugin/dev-server.test.ts +1 -1
- package/src/__tests__/plugin/dts-generator.test.ts +15 -6
- package/src/__tests__/plugin/generated-dir.test.ts +137 -0
- package/src/__tests__/plugin/resolve-config.test.ts +0 -5
- package/src/__tests__/types/config.test.ts +1 -1
- package/src/cli/commands/build.ts +19 -5
- package/src/cli/commands/dev.ts +2 -2
- package/src/cli/commands/preview.ts +7 -5
- package/src/cli/create/index.ts +12 -8
- package/src/cli/create/templates/spa/.gitignore.tpl +25 -0
- package/src/cli/create/templates/spa/index.html.tpl +1 -1
- package/src/cli/create/templates/ssg/.gitignore.tpl +25 -0
- package/src/cli/create/templates/ssg/index.html.tpl +1 -1
- package/src/cli/create/templates/ssr/.gitignore.tpl +25 -0
- package/src/cli/create/templates/ssr/cer.config.ts.tpl +0 -1
- package/src/cli/create/templates/ssr/index.html.tpl +1 -1
- package/src/plugin/build-ssg.ts +2 -2
- package/src/plugin/build-ssr.ts +22 -8
- package/src/plugin/dev-server.ts +5 -4
- package/src/plugin/dts-generator.ts +43 -19
- package/src/plugin/generated-dir.ts +115 -0
- package/src/plugin/index.ts +32 -2
- package/src/plugin/path-utils.ts +1 -1
- package/src/plugin/virtual/loading.ts +0 -1
- package/{e2e/kitchen-sink/app/app.ts → src/runtime/app-template.ts} +24 -7
- package/src/types/config.ts +0 -1
- package/dist/cli/create/templates/spa/app/app.ts.tpl +0 -93
- package/dist/cli/create/templates/ssg/app/app.ts.tpl +0 -97
- package/dist/cli/create/templates/ssr/app/app.ts.tpl +0 -97
- package/e2e/kitchen-sink/index.html +0 -12
- package/src/cli/create/templates/spa/app/app.ts.tpl +0 -93
- package/src/cli/create/templates/ssg/app/app.ts.tpl +0 -97
- package/src/cli/create/templates/ssr/app/app.ts.tpl +0 -97
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Template string for `.cer/app.ts` — the framework client entry point.
|
|
3
|
+
*
|
|
4
|
+
* Always written to `.cer/app.ts` on every dev/build so consumers
|
|
5
|
+
* automatically receive the latest bootstrap code on plugin update.
|
|
6
|
+
* This file is gitignored and should never be edited directly.
|
|
7
|
+
*/
|
|
8
|
+
export const APP_ENTRY_TEMPLATE = `// AUTO-GENERATED by @jasonshimmy/vite-plugin-cer-app — do not edit.
|
|
9
|
+
// Regenerated automatically on every dev server start and build.
|
|
10
|
+
|
|
11
|
+
import '@jasonshimmy/custom-elements-runtime/css'
|
|
12
|
+
import 'virtual:cer-jit-css'
|
|
13
|
+
import 'virtual:cer-components'
|
|
14
|
+
import routes from 'virtual:cer-routes'
|
|
15
|
+
import layouts from 'virtual:cer-layouts'
|
|
16
|
+
import plugins from 'virtual:cer-plugins'
|
|
17
|
+
import { hasLoading, loadingTag } from 'virtual:cer-loading'
|
|
18
|
+
import { hasError, errorTag } from 'virtual:cer-error'
|
|
19
|
+
import {
|
|
20
|
+
component,
|
|
21
|
+
ref,
|
|
22
|
+
provide,
|
|
23
|
+
useOnConnected,
|
|
24
|
+
useOnDisconnected,
|
|
25
|
+
registerBuiltinComponents,
|
|
26
|
+
} from '@jasonshimmy/custom-elements-runtime'
|
|
27
|
+
import { initRouter } from '@jasonshimmy/custom-elements-runtime/router'
|
|
28
|
+
import { enableJITCSS } from '@jasonshimmy/custom-elements-runtime/jit-css'
|
|
29
|
+
|
|
30
|
+
registerBuiltinComponents()
|
|
31
|
+
enableJITCSS()
|
|
32
|
+
|
|
33
|
+
const router = initRouter({ routes })
|
|
34
|
+
|
|
35
|
+
// ─── Navigation state ────────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
const isNavigating = ref(false)
|
|
38
|
+
const currentError = ref(null)
|
|
39
|
+
|
|
40
|
+
const resetError = (): void => {
|
|
41
|
+
currentError.value = null
|
|
42
|
+
void router.replace(router.getCurrent().path)
|
|
43
|
+
}
|
|
44
|
+
;(globalThis as Record<string, unknown>).resetError = resetError
|
|
45
|
+
|
|
46
|
+
const _push = router.push.bind(router)
|
|
47
|
+
const _replace = router.replace.bind(router)
|
|
48
|
+
|
|
49
|
+
router.push = async (path) => {
|
|
50
|
+
isNavigating.value = true
|
|
51
|
+
currentError.value = null
|
|
52
|
+
try {
|
|
53
|
+
await _push(path)
|
|
54
|
+
} catch (err) {
|
|
55
|
+
currentError.value = err instanceof Error ? err.message : String(err)
|
|
56
|
+
} finally {
|
|
57
|
+
isNavigating.value = false
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
router.replace = async (path) => {
|
|
62
|
+
isNavigating.value = true
|
|
63
|
+
currentError.value = null
|
|
64
|
+
try {
|
|
65
|
+
await _replace(path)
|
|
66
|
+
} catch (err) {
|
|
67
|
+
currentError.value = err instanceof Error ? err.message : String(err)
|
|
68
|
+
} finally {
|
|
69
|
+
isNavigating.value = false
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ─── Plugins ─────────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
// Collect plugin-provided values so cer-layout-view can forward them into
|
|
76
|
+
// the component context tree via the real provide() hook (which inject() walks).
|
|
77
|
+
// Declared BEFORE component('cer-layout-view') to avoid a temporal dead zone
|
|
78
|
+
// ReferenceError: customElements.define() upgrades existing DOM elements
|
|
79
|
+
// synchronously, calling the render function immediately.
|
|
80
|
+
const _pluginProvides = new Map<string, unknown>()
|
|
81
|
+
// Expose plugin provides globally so page components can read them synchronously
|
|
82
|
+
// regardless of render order.
|
|
83
|
+
;(globalThis as Record<string, unknown>).__cerPluginProvides = _pluginProvides
|
|
84
|
+
|
|
85
|
+
// ─── <cer-layout-view> ───────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
component('cer-layout-view', () => {
|
|
88
|
+
// Forward plugin-provided values into the component context so inject() in
|
|
89
|
+
// any descendant component can resolve them by walking up the DOM tree.
|
|
90
|
+
for (const [key, value] of _pluginProvides) {
|
|
91
|
+
provide(key, value)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const current = ref(router.getCurrent())
|
|
95
|
+
let unsub: (() => void) | undefined
|
|
96
|
+
|
|
97
|
+
useOnConnected(() => {
|
|
98
|
+
unsub = router.subscribe((s: typeof current.value) => { current.value = s })
|
|
99
|
+
})
|
|
100
|
+
useOnDisconnected(() => { unsub?.(); unsub = undefined })
|
|
101
|
+
|
|
102
|
+
if (currentError.value !== null) {
|
|
103
|
+
if (hasError && errorTag) {
|
|
104
|
+
return { tag: errorTag, props: { attrs: { error: String(currentError.value) } }, children: [] }
|
|
105
|
+
}
|
|
106
|
+
return { tag: 'div', props: { attrs: { style: 'padding:2rem;font-family:monospace' } }, children: String(currentError.value) }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (isNavigating.value && hasLoading && loadingTag) {
|
|
110
|
+
return { tag: loadingTag, props: {}, children: [] }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const matched = router.matchRoute(current.value.path)
|
|
114
|
+
const routeMeta = matched?.route?.meta as { layout?: string } | undefined
|
|
115
|
+
const layoutName = routeMeta?.layout ?? 'default'
|
|
116
|
+
const layoutTag = (layouts as Record<string, string>)[layoutName]
|
|
117
|
+
const routerView = { tag: 'router-view', props: {}, children: [] }
|
|
118
|
+
|
|
119
|
+
if (layoutTag) return { tag: layoutTag, props: {}, children: [routerView] }
|
|
120
|
+
return routerView
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
for (const plugin of plugins) {
|
|
124
|
+
if (plugin && typeof plugin.setup === 'function') {
|
|
125
|
+
await plugin.setup({
|
|
126
|
+
router,
|
|
127
|
+
provide: (key: string, value: unknown) => { _pluginProvides.set(key, value) },
|
|
128
|
+
config: {},
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ─── Pre-load initial route ───────────────────────────────────────────────────
|
|
134
|
+
// Download the current page's route chunk AFTER plugins run so that
|
|
135
|
+
// cer-layout-view's first render (which calls provide()) completes before
|
|
136
|
+
// page components are defined and their renders are scheduled. This ensures
|
|
137
|
+
// inject() in child components can find values stored by provide().
|
|
138
|
+
|
|
139
|
+
if (typeof window !== 'undefined') {
|
|
140
|
+
const _initMatch = router.matchRoute(window.location.pathname)
|
|
141
|
+
if (_initMatch?.route?.load) {
|
|
142
|
+
try { await _initMatch.route.load() } catch { /* non-fatal */ }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ─── Initial navigation ──────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
if (typeof window !== 'undefined') {
|
|
149
|
+
// Use the original (unwrapped) replace so isNavigating stays false during
|
|
150
|
+
// the initial paint — the loading component must not flash over pre-rendered content.
|
|
151
|
+
await _replace(window.location.pathname + window.location.search + window.location.hash)
|
|
152
|
+
// Clear SSR loader data after initial navigation so subsequent client-side
|
|
153
|
+
// navigations don't accidentally reuse stale server data.
|
|
154
|
+
delete (globalThis as Record<string, unknown>).__CER_DATA__
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export { router }
|
|
158
|
+
`;
|
|
159
|
+
//# sourceMappingURL=app-template.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"app-template.js","sourceRoot":"","sources":["../../src/runtime/app-template.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAsJjC,CAAA"}
|
package/dist/types/config.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/types/config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,6CAA6C,CAAA;AAE/E,MAAM,WAAW,SAAS;IACxB,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;IAC1B,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAClB,cAAc,CAAC,EAAE,OAAO,CAAA;CACzB;AAED,MAAM,WAAW,SAAS;IACxB,GAAG,CAAC,EAAE,OAAO,CAAA;
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/types/config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,6CAA6C,CAAA;AAE/E,MAAM,WAAW,SAAS;IACxB,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,EAAE,CAAA;IAC1B,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,MAAM,WAAW,YAAY;IAC3B,OAAO,CAAC,EAAE,MAAM,EAAE,CAAA;IAClB,cAAc,CAAC,EAAE,OAAO,CAAA;CACzB;AAED,MAAM,WAAW,SAAS;IACxB,GAAG,CAAC,EAAE,OAAO,CAAA;CACd;AAED,MAAM,WAAW,iBAAiB;IAChC,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,OAAO,CAAC,EAAE,OAAO,CAAA;CAClB;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,CAAC,EAAE,KAAK,GAAG,KAAK,GAAG,KAAK,CAAA;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,GAAG,CAAC,EAAE,SAAS,CAAA;IACf,MAAM,CAAC,EAAE,IAAI,CAAC,YAAY,EAAE,MAAM,GAAG,kBAAkB,CAAC,CAAA;IACxD,MAAM,CAAC,EAAE,YAAY,CAAA;IACrB,GAAG,CAAC,EAAE,SAAS,CAAA;IACf,WAAW,CAAC,EAAE,iBAAiB,CAAA;IAC/B,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAED,wBAAgB,YAAY,CAAC,MAAM,EAAE,YAAY,GAAG,YAAY,CAE/D"}
|
package/dist/types/config.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/types/config.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"config.js","sourceRoot":"","sources":["../../src/types/config.ts"],"names":[],"mappings":"AAmCA,MAAM,UAAU,YAAY,CAAC,MAAoB;IAC/C,OAAO,MAAM,CAAA;AACf,CAAC"}
|
package/docs/cli.md
CHANGED
|
@@ -187,7 +187,7 @@ my-app/
|
|
|
187
187
|
| Mode | `cer.config.ts` | `package.json` scripts |
|
|
188
188
|
|---|---|---|
|
|
189
189
|
| SPA | `mode: 'spa'` | `dev`, `build`, `preview` |
|
|
190
|
-
| SSR | `mode: 'ssr'`, `ssr.
|
|
190
|
+
| SSR | `mode: 'ssr'`, `ssr.dsd: true` | `dev`, `build`, `preview --ssr` |
|
|
191
191
|
| SSG | `mode: 'ssg'`, `ssg.routes: 'auto'` | `dev`, `build`, `preview`, `generate` |
|
|
192
192
|
|
|
193
193
|
---
|
package/docs/configuration.md
CHANGED
|
@@ -13,7 +13,6 @@ export default defineConfig({
|
|
|
13
13
|
|
|
14
14
|
ssr: {
|
|
15
15
|
dsd: true,
|
|
16
|
-
streaming: false,
|
|
17
16
|
},
|
|
18
17
|
|
|
19
18
|
ssg: {
|
|
@@ -87,8 +86,7 @@ Controls SSR rendering behavior.
|
|
|
87
86
|
|
|
88
87
|
```ts
|
|
89
88
|
ssr: {
|
|
90
|
-
dsd: true,
|
|
91
|
-
streaming: false // Use streaming renderer (createStreamingSSRHandler)
|
|
89
|
+
dsd: true, // Emit Declarative Shadow DOM
|
|
92
90
|
}
|
|
93
91
|
```
|
|
94
92
|
|
|
@@ -99,13 +97,6 @@ ssr: {
|
|
|
99
97
|
|
|
100
98
|
When `true`, renders components with [Declarative Shadow DOM](https://developer.chrome.com/docs/css-ui/declarative-shadow-dom) markup. This eliminates Flash of Unstyled Content (FOUC) because styles are embedded directly in the HTML.
|
|
101
99
|
|
|
102
|
-
### `ssr.streaming`
|
|
103
|
-
|
|
104
|
-
**Type:** `boolean`
|
|
105
|
-
**Default:** `false`
|
|
106
|
-
|
|
107
|
-
When `true`, uses the streaming SSR renderer (`createStreamingSSRHandler`) to progressively flush HTML to the client. When `false`, collects the full HTML string before sending.
|
|
108
|
-
|
|
109
100
|
---
|
|
110
101
|
|
|
111
102
|
## `ssg` options
|
|
@@ -247,7 +238,7 @@ export default defineConfig({
|
|
|
247
238
|
plugins: [
|
|
248
239
|
cerApp({
|
|
249
240
|
mode: 'ssr',
|
|
250
|
-
ssr: { dsd: true
|
|
241
|
+
ssr: { dsd: true },
|
|
251
242
|
}),
|
|
252
243
|
],
|
|
253
244
|
})
|
package/docs/getting-started.md
CHANGED
|
@@ -128,110 +128,12 @@ component('layout-default', () => {
|
|
|
128
128
|
</head>
|
|
129
129
|
<body>
|
|
130
130
|
<cer-layout-view></cer-layout-view>
|
|
131
|
-
<script type="module" src="/app
|
|
131
|
+
<script type="module" src="/.cer/app.ts"></script>
|
|
132
132
|
</body>
|
|
133
133
|
</html>
|
|
134
134
|
```
|
|
135
135
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
The framework generates this file when you scaffold a new project. It bootstraps the router, registers all auto-discovered components, runs plugins, and mounts the app:
|
|
139
|
-
|
|
140
|
-
```ts
|
|
141
|
-
// app/app.ts
|
|
142
|
-
import '@jasonshimmy/custom-elements-runtime/css'
|
|
143
|
-
import 'virtual:cer-jit-css'
|
|
144
|
-
import 'virtual:cer-components'
|
|
145
|
-
import routes from 'virtual:cer-routes'
|
|
146
|
-
import layouts from 'virtual:cer-layouts'
|
|
147
|
-
import plugins from 'virtual:cer-plugins'
|
|
148
|
-
import { hasLoading, loadingTag } from 'virtual:cer-loading'
|
|
149
|
-
import { hasError, errorTag } from 'virtual:cer-error'
|
|
150
|
-
import {
|
|
151
|
-
component, ref, provide,
|
|
152
|
-
useOnConnected, useOnDisconnected,
|
|
153
|
-
registerBuiltinComponents,
|
|
154
|
-
} from '@jasonshimmy/custom-elements-runtime'
|
|
155
|
-
import { initRouter } from '@jasonshimmy/custom-elements-runtime/router'
|
|
156
|
-
import { enableJITCSS } from '@jasonshimmy/custom-elements-runtime/jit-css'
|
|
157
|
-
|
|
158
|
-
registerBuiltinComponents()
|
|
159
|
-
enableJITCSS()
|
|
160
|
-
|
|
161
|
-
const router = initRouter({ routes })
|
|
162
|
-
|
|
163
|
-
const isNavigating = ref(false)
|
|
164
|
-
const currentError = ref(null)
|
|
165
|
-
;(globalThis as any).resetError = () => {
|
|
166
|
-
currentError.value = null
|
|
167
|
-
router.replace(router.getCurrent().path)
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const _push = router.push.bind(router)
|
|
171
|
-
const _replace = router.replace.bind(router)
|
|
172
|
-
router.push = async (path) => {
|
|
173
|
-
isNavigating.value = true; currentError.value = null
|
|
174
|
-
try { await _push(path) } catch (err) { currentError.value = err instanceof Error ? err.message : String(err) } finally { isNavigating.value = false }
|
|
175
|
-
}
|
|
176
|
-
router.replace = async (path) => {
|
|
177
|
-
isNavigating.value = true; currentError.value = null
|
|
178
|
-
try { await _replace(path) } catch (err) { currentError.value = err instanceof Error ? err.message : String(err) } finally { isNavigating.value = false }
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// _pluginProvides is populated by plugin setup and forwarded into the component
|
|
182
|
-
// context tree via provide() inside cer-layout-view so inject() works in all modes.
|
|
183
|
-
// Also exposed on globalThis for the SSG timing edge case — see docs/plugins.md.
|
|
184
|
-
const _pluginProvides = new Map<string, unknown>()
|
|
185
|
-
;(globalThis as any).__cerPluginProvides = _pluginProvides
|
|
186
|
-
|
|
187
|
-
component('cer-layout-view', () => {
|
|
188
|
-
for (const [key, value] of _pluginProvides) { provide(key, value) }
|
|
189
|
-
|
|
190
|
-
const current = ref(router.getCurrent())
|
|
191
|
-
let unsub: (() => void) | undefined
|
|
192
|
-
useOnConnected(() => { unsub = router.subscribe((s) => { current.value = s }) })
|
|
193
|
-
useOnDisconnected(() => { unsub?.(); unsub = undefined })
|
|
194
|
-
|
|
195
|
-
if (currentError.value !== null) {
|
|
196
|
-
if (hasError && errorTag) return { tag: errorTag, props: { attrs: { error: String(currentError.value) } }, children: [] }
|
|
197
|
-
return { tag: 'div', props: { attrs: { style: 'padding:2rem;font-family:monospace' } }, children: String(currentError.value) }
|
|
198
|
-
}
|
|
199
|
-
if (isNavigating.value && hasLoading && loadingTag) return { tag: loadingTag, props: {}, children: [] }
|
|
200
|
-
|
|
201
|
-
const matched = router.matchRoute(current.value.path)
|
|
202
|
-
const layoutName = (matched?.route as any)?.meta?.layout ?? 'default'
|
|
203
|
-
const layoutTag = (layouts as Record<string, string>)[layoutName]
|
|
204
|
-
const routerView = { tag: 'router-view', props: {}, children: [] }
|
|
205
|
-
return layoutTag ? { tag: layoutTag, props: {}, children: [routerView] } : routerView
|
|
206
|
-
})
|
|
207
|
-
|
|
208
|
-
// Plugins run AFTER cer-layout-view is defined so provide() calls from plugins
|
|
209
|
-
// are forwarded into the component tree on the very first render.
|
|
210
|
-
for (const plugin of plugins ?? []) {
|
|
211
|
-
if (plugin && typeof plugin.setup === 'function') {
|
|
212
|
-
await plugin.setup({ router, provide: (key, value) => { _pluginProvides.set(key, value) }, config: {} })
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// Pre-load the current page's route chunk AFTER plugins run.
|
|
217
|
-
// This ensures cer-layout-view's first render (and its provide() calls) completes
|
|
218
|
-
// before page component modules are imported and their renders are scheduled.
|
|
219
|
-
if (typeof window !== 'undefined') {
|
|
220
|
-
const _initMatch = router.matchRoute(window.location.pathname)
|
|
221
|
-
if (_initMatch?.route?.load) {
|
|
222
|
-
try { await _initMatch.route.load() } catch { /* non-fatal */ }
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
if (typeof window !== 'undefined') {
|
|
227
|
-
await _replace(window.location.pathname + window.location.search + window.location.hash)
|
|
228
|
-
delete (globalThis as any).__CER_DATA__
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
export { router }
|
|
232
|
-
```
|
|
233
|
-
|
|
234
|
-
> **Note:** Do not move the plugin loop before `component('cer-layout-view', …)`. The layout component must be defined first so that when plugins call `app.provide()`, the values are available to the component tree from the very first render. See [Plugins](plugins.md) for details.
|
|
136
|
+
> **Note:** The framework bootstrap lives in `.cer/app.ts` and is regenerated automatically on every dev server start and build. You never edit or own this file — updates to the plugin propagate to it immediately, just like Nuxt's `.nuxt/` directory.
|
|
235
137
|
|
|
236
138
|
---
|
|
237
139
|
|
package/docs/rendering-modes.md
CHANGED
|
@@ -63,9 +63,9 @@ The server renders HTML for each request. Uses Declarative Shadow DOM (DSD) to e
|
|
|
63
63
|
3. API route handlers run if the URL matches `/api/`
|
|
64
64
|
4. For HTML requests, the router matches the URL to a page
|
|
65
65
|
5. The page's `loader` is called (if present)
|
|
66
|
-
6. The component tree is rendered to HTML via `
|
|
66
|
+
6. The component tree is rendered to HTML with Declarative Shadow DOM via `renderToStringWithJITCSSDSD`
|
|
67
67
|
7. `useHead()` calls are collected and injected before `</head>`
|
|
68
|
-
8. The
|
|
68
|
+
8. The rendered HTML is merged with the Vite client bundle shell and sent as a full response
|
|
69
69
|
|
|
70
70
|
### Build output
|
|
71
71
|
|
|
@@ -93,8 +93,7 @@ export default handler
|
|
|
93
93
|
export default defineConfig({
|
|
94
94
|
mode: 'ssr',
|
|
95
95
|
ssr: {
|
|
96
|
-
dsd: true,
|
|
97
|
-
streaming: false, // true = stream response; false = buffer full HTML
|
|
96
|
+
dsd: true, // Declarative Shadow DOM (eliminates FOUC)
|
|
98
97
|
},
|
|
99
98
|
})
|
|
100
99
|
```
|
|
@@ -242,7 +241,7 @@ Any CDN or static host. Upload the entire `dist/` directory (excluding `dist/ser
|
|
|
242
241
|
| Dynamic routes | Yes | Yes | Requires `ssg.paths` |
|
|
243
242
|
| API routes | Separate deploy | Same process | Separate deploy |
|
|
244
243
|
| `useHead()` SSR injection | No | Yes | Yes |
|
|
245
|
-
| Streaming | No |
|
|
244
|
+
| Streaming | No | No | No |
|
|
246
245
|
|
|
247
246
|
---
|
|
248
247
|
|
package/docs/routing.md
CHANGED
|
@@ -10,7 +10,7 @@ Routes are automatically derived from files in the `app/pages/` directory. No ma
|
|
|
10
10
|
|---|---|---|
|
|
11
11
|
| `app/pages/index.ts` | `/` | `page-index` |
|
|
12
12
|
| `app/pages/about.ts` | `/about` | `page-about` |
|
|
13
|
-
| `app/pages/blog/index.ts` | `/blog` | `page-blog
|
|
13
|
+
| `app/pages/blog/index.ts` | `/blog` | `page-blog` |
|
|
14
14
|
| `app/pages/blog/[slug].ts` | `/blog/:slug` | `page-blog-slug` |
|
|
15
15
|
| `app/pages/users/[id]/edit.ts` | `/users/:id/edit` | `page-users-id-edit` |
|
|
16
16
|
| `app/pages/404.ts` | `/:all*` (catch-all) | `page-404` |
|
package/eslint.config.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import tseslint from 'typescript-eslint'
|
|
2
|
+
|
|
3
|
+
export default tseslint.config(
|
|
4
|
+
{
|
|
5
|
+
ignores: [
|
|
6
|
+
'dist/**',
|
|
7
|
+
'node_modules/**',
|
|
8
|
+
'coverage/**',
|
|
9
|
+
'e2e/**',
|
|
10
|
+
'src/__tests__/**',
|
|
11
|
+
'src/cli/create/templates/**',
|
|
12
|
+
],
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
files: ['src/**/*.ts'],
|
|
16
|
+
extends: [...tseslint.configs.recommended],
|
|
17
|
+
rules: {
|
|
18
|
+
'@typescript-eslint/no-explicit-any': 'error',
|
|
19
|
+
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jasonshimmy/vite-plugin-cer-app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Nuxt-style meta-framework for @jasonshimmy/custom-elements-runtime",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -44,8 +44,10 @@
|
|
|
44
44
|
"create-cer-app": "./dist/cli/create/index.js"
|
|
45
45
|
},
|
|
46
46
|
"scripts": {
|
|
47
|
+
"validate": "npm run lint && npm test && npm run build && npm run e2e",
|
|
47
48
|
"build": "tsc -p tsconfig.build.json && cp -r src/cli/create/templates dist/cli/create/templates",
|
|
48
49
|
"dev": "tsc -p tsconfig.build.json --watch",
|
|
50
|
+
"lint": "eslint",
|
|
49
51
|
"test": "vitest run",
|
|
50
52
|
"test:watch": "vitest",
|
|
51
53
|
"test:coverage": "vitest run --coverage",
|
|
@@ -80,9 +82,12 @@
|
|
|
80
82
|
"@types/node": "^25.5.0",
|
|
81
83
|
"@vitest/coverage-v8": "^4.1.0",
|
|
82
84
|
"cypress": "^15.12.0",
|
|
85
|
+
"eslint": "^10.0.3",
|
|
83
86
|
"happy-dom": "^20.8.4",
|
|
87
|
+
"jiti": "^2.6.1",
|
|
84
88
|
"start-server-and-test": "^2.1.5",
|
|
85
89
|
"typescript": "^5.9.3",
|
|
90
|
+
"typescript-eslint": "^8.57.1",
|
|
86
91
|
"vite": "^8.0.1",
|
|
87
92
|
"vitest": "^4.1.0"
|
|
88
93
|
},
|
|
@@ -6,11 +6,16 @@ import { resolve } from 'pathe'
|
|
|
6
6
|
// The `buildSSR` function itself invokes Vite's `build` API which we don't
|
|
7
7
|
// need to exercise in unit tests (it's an integration concern).
|
|
8
8
|
vi.mock('vite', () => ({ build: vi.fn().mockResolvedValue(undefined) }))
|
|
9
|
+
vi.mock('../../plugin/generated-dir.js', () => ({
|
|
10
|
+
writeGeneratedDir: vi.fn(),
|
|
11
|
+
getGeneratedDir: vi.fn().mockReturnValue('/project/.cer'),
|
|
12
|
+
GENERATED_DIR_NAME: '.cer',
|
|
13
|
+
}))
|
|
9
14
|
// Partial mock: keep the real readFileSync/existsSync but allow overrides in
|
|
10
15
|
// individual describe blocks if needed.
|
|
11
16
|
vi.mock('node:fs', async (importOriginal) => {
|
|
12
17
|
const actual = await importOriginal<typeof import('node:fs')>()
|
|
13
|
-
return { ...actual, existsSync: vi.fn().mockReturnValue(true) }
|
|
18
|
+
return { ...actual, existsSync: vi.fn().mockReturnValue(true), renameSync: vi.fn() }
|
|
14
19
|
})
|
|
15
20
|
|
|
16
21
|
import type { ResolvedCerConfig } from '../../plugin/dev-server.js'
|
|
@@ -202,24 +207,33 @@ describe('buildSSR — resolveClientEntry fallbacks', () => {
|
|
|
202
207
|
})
|
|
203
208
|
|
|
204
209
|
it('uses index.html when it exists', async () => {
|
|
205
|
-
existsSyncMock.
|
|
210
|
+
existsSyncMock.mockImplementation((p: unknown) => String(p).endsWith('index.html'))
|
|
206
211
|
await buildSSR(makeConfig())
|
|
207
|
-
const clientInput = (buildMock.mock.calls[0][0] as
|
|
208
|
-
expect(clientInput).toMatch(/index\.html$/)
|
|
212
|
+
const clientInput = (buildMock.mock.calls[0][0] as Record<string, unknown>)
|
|
213
|
+
expect(((clientInput.build as Record<string, unknown>).rollupOptions as Record<string, unknown>).input).toMatch(/(?<!\.cer\/)index\.html$/)
|
|
209
214
|
})
|
|
210
215
|
|
|
211
|
-
it('falls back to
|
|
216
|
+
it('falls back to .cer/index.html when root index.html is absent', async () => {
|
|
217
|
+
existsSyncMock.mockImplementation((p: unknown) =>
|
|
218
|
+
String(p).endsWith('.cer/index.html'),
|
|
219
|
+
)
|
|
220
|
+
await buildSSR(makeConfig())
|
|
221
|
+
const clientInput = (buildMock.mock.calls[0][0] as Record<string, unknown>)
|
|
222
|
+
expect(((clientInput.build as Record<string, unknown>).rollupOptions as Record<string, unknown>).input).toMatch(/\.cer\/index\.html$/)
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
it('falls back to entry-client.ts when no index.html exists', async () => {
|
|
212
226
|
existsSyncMock.mockImplementation((p: unknown) => String(p).endsWith('entry-client.ts'))
|
|
213
227
|
await buildSSR(makeConfig())
|
|
214
|
-
const clientInput = (buildMock.mock.calls[0][0] as
|
|
215
|
-
expect(clientInput).toMatch(/entry-client\.ts$/)
|
|
228
|
+
const clientInput = (buildMock.mock.calls[0][0] as Record<string, unknown>)
|
|
229
|
+
expect(((clientInput.build as Record<string, unknown>).rollupOptions as Record<string, unknown>).input).toMatch(/entry-client\.ts$/)
|
|
216
230
|
})
|
|
217
231
|
|
|
218
|
-
it('falls back to app.ts when
|
|
232
|
+
it('falls back to app.ts when nothing else exists', async () => {
|
|
219
233
|
existsSyncMock.mockReturnValue(false)
|
|
220
234
|
await buildSSR(makeConfig())
|
|
221
|
-
const clientInput = (buildMock.mock.calls[0][0] as
|
|
222
|
-
expect(clientInput).toMatch(/app\.ts$/)
|
|
235
|
+
const clientInput = (buildMock.mock.calls[0][0] as Record<string, unknown>)
|
|
236
|
+
expect(((clientInput.build as Record<string, unknown>).rollupOptions as Record<string, unknown>).input).toMatch(/app\.ts$/)
|
|
223
237
|
})
|
|
224
238
|
})
|
|
225
239
|
|
|
@@ -3,6 +3,10 @@ import { vi, describe, it, expect, beforeEach } from 'vitest'
|
|
|
3
3
|
vi.mock('@jasonshimmy/custom-elements-runtime/vite-plugin', () => ({
|
|
4
4
|
cerPlugin: vi.fn().mockReturnValue([{ name: 'cer-runtime-plugin' }]),
|
|
5
5
|
}))
|
|
6
|
+
vi.mock('node:fs', async (importOriginal) => {
|
|
7
|
+
const actual = await importOriginal<typeof import('node:fs')>()
|
|
8
|
+
return { ...actual, existsSync: vi.fn().mockReturnValue(true), readFileSync: vi.fn().mockReturnValue('') }
|
|
9
|
+
})
|
|
6
10
|
vi.mock('../../plugin/dev-server.js', () => ({
|
|
7
11
|
configureCerDevServer: vi.fn().mockResolvedValue(undefined),
|
|
8
12
|
}))
|
|
@@ -15,6 +19,11 @@ vi.mock('../../plugin/dts-generator.js', () => ({
|
|
|
15
19
|
writeAutoImportDts: vi.fn().mockResolvedValue(undefined),
|
|
16
20
|
writeTsconfigPaths: vi.fn(),
|
|
17
21
|
}))
|
|
22
|
+
vi.mock('../../plugin/generated-dir.js', () => ({
|
|
23
|
+
writeGeneratedDir: vi.fn(),
|
|
24
|
+
getGeneratedDir: vi.fn().mockReturnValue('/project/.cer'),
|
|
25
|
+
GENERATED_DIR_NAME: '.cer',
|
|
26
|
+
}))
|
|
18
27
|
vi.mock('../../plugin/virtual/routes.js', () => ({ generateRoutesCode: vi.fn().mockResolvedValue('// routes') }))
|
|
19
28
|
vi.mock('../../plugin/virtual/layouts.js', () => ({ generateLayoutsCode: vi.fn().mockResolvedValue('// layouts') }))
|
|
20
29
|
vi.mock('../../plugin/virtual/components.js', () => ({ generateComponentsCode: vi.fn().mockResolvedValue('// components') }))
|
|
@@ -263,6 +272,16 @@ describe('cerApp plugin — transform hook', () => {
|
|
|
263
272
|
})
|
|
264
273
|
|
|
265
274
|
describe('cerApp plugin — buildStart hook', () => {
|
|
275
|
+
it('calls writeGeneratedDir on build start', async () => {
|
|
276
|
+
const { writeGeneratedDir } = await import('../../plugin/generated-dir.js')
|
|
277
|
+
vi.mocked(writeGeneratedDir).mockClear()
|
|
278
|
+
const plugin = getCerPlugin()
|
|
279
|
+
plugin.config({ root: '/project' }, { command: 'build', mode: 'production' })
|
|
280
|
+
plugin.configResolved(FAKE_RESOLVED)
|
|
281
|
+
await plugin.buildStart()
|
|
282
|
+
expect(writeGeneratedDir).toHaveBeenCalledTimes(1)
|
|
283
|
+
})
|
|
284
|
+
|
|
266
285
|
it('calls scanComposableExports on build start', async () => {
|
|
267
286
|
const { scanComposableExports } = await import('../../plugin/dts-generator.js')
|
|
268
287
|
vi.mocked(scanComposableExports).mockClear()
|
|
@@ -295,6 +314,22 @@ describe('cerApp plugin — buildStart hook', () => {
|
|
|
295
314
|
})
|
|
296
315
|
|
|
297
316
|
describe('cerApp plugin — configureServer hook', () => {
|
|
317
|
+
it('calls writeGeneratedDir on server configure', async () => {
|
|
318
|
+
const { writeGeneratedDir } = await import('../../plugin/generated-dir.js')
|
|
319
|
+
vi.mocked(writeGeneratedDir).mockClear()
|
|
320
|
+
const plugin = getCerPlugin()
|
|
321
|
+
plugin.config({ root: '/project' }, { command: 'serve', mode: 'development' })
|
|
322
|
+
plugin.configResolved(FAKE_RESOLVED)
|
|
323
|
+
const mockServer = {
|
|
324
|
+
watcher: { on: vi.fn() },
|
|
325
|
+
moduleGraph: { getModuleById: vi.fn().mockReturnValue(null), invalidateModule: vi.fn() },
|
|
326
|
+
ws: { send: vi.fn() },
|
|
327
|
+
middlewares: { use: vi.fn() },
|
|
328
|
+
}
|
|
329
|
+
await plugin.configureServer(mockServer)
|
|
330
|
+
expect(writeGeneratedDir).toHaveBeenCalledTimes(1)
|
|
331
|
+
})
|
|
332
|
+
|
|
298
333
|
it('calls scanComposableExports on server configure', async () => {
|
|
299
334
|
const { scanComposableExports } = await import('../../plugin/dts-generator.js')
|
|
300
335
|
vi.mocked(scanComposableExports).mockClear()
|
|
@@ -48,7 +48,7 @@ function makeConfig(overrides: Partial<ResolvedCerConfig> = {}): ResolvedCerConf
|
|
|
48
48
|
serverApiDir: '/project/server/api',
|
|
49
49
|
serverMiddlewareDir: '/project/server/middleware',
|
|
50
50
|
port: 3000,
|
|
51
|
-
ssr: { dsd: true
|
|
51
|
+
ssr: { dsd: true },
|
|
52
52
|
ssg: { routes: 'auto', concurrency: 2, fallback: false },
|
|
53
53
|
router: {},
|
|
54
54
|
jitCss: { content: [], extendedColors: false },
|
|
@@ -6,10 +6,12 @@ vi.mock('node:fs', async (importOriginal) => {
|
|
|
6
6
|
...actual,
|
|
7
7
|
existsSync: vi.fn().mockReturnValue(false),
|
|
8
8
|
writeFileSync: vi.fn(),
|
|
9
|
+
mkdirSync: vi.fn(),
|
|
9
10
|
readFileSync: vi.fn().mockReturnValue(''),
|
|
10
11
|
}
|
|
11
12
|
})
|
|
12
13
|
vi.mock('../../plugin/scanner.js', () => ({ scanDirectory: vi.fn().mockResolvedValue([]) }))
|
|
14
|
+
vi.mock('../../plugin/generated-dir.js', () => ({ GENERATED_DIR_NAME: '.cer' }))
|
|
13
15
|
|
|
14
16
|
import { existsSync, writeFileSync, readFileSync } from 'node:fs'
|
|
15
17
|
import { scanDirectory } from '../../plugin/scanner.js'
|
|
@@ -32,10 +34,10 @@ beforeEach(() => {
|
|
|
32
34
|
})
|
|
33
35
|
|
|
34
36
|
describe('writeTsconfigPaths', () => {
|
|
35
|
-
it('writes
|
|
37
|
+
it('writes tsconfig.json to the .cer directory', () => {
|
|
36
38
|
writeTsconfigPaths(ROOT, `${ROOT}/app`)
|
|
37
39
|
expect(writeFileSync).toHaveBeenCalledWith(
|
|
38
|
-
`${ROOT}/
|
|
40
|
+
`${ROOT}/.cer/tsconfig.json`,
|
|
39
41
|
expect.any(String),
|
|
40
42
|
'utf-8',
|
|
41
43
|
)
|
|
@@ -65,6 +67,13 @@ describe('writeTsconfigPaths', () => {
|
|
|
65
67
|
const json = JSON.parse(content)
|
|
66
68
|
expect(json).toHaveProperty('compilerOptions.paths')
|
|
67
69
|
})
|
|
70
|
+
|
|
71
|
+
it('includes project source directories in include array', () => {
|
|
72
|
+
writeTsconfigPaths(ROOT, `${ROOT}/app`)
|
|
73
|
+
const content = vi.mocked(writeFileSync).mock.calls[0][1] as string
|
|
74
|
+
const json = JSON.parse(content) as { include?: string[] }
|
|
75
|
+
expect(Array.isArray(json.include)).toBe(true)
|
|
76
|
+
})
|
|
68
77
|
})
|
|
69
78
|
|
|
70
79
|
describe('scanComposableExports', () => {
|
|
@@ -227,16 +236,16 @@ describe('generateVirtualModuleDts', () => {
|
|
|
227
236
|
})
|
|
228
237
|
|
|
229
238
|
describe('writeAutoImportDts', () => {
|
|
230
|
-
it('writes
|
|
239
|
+
it('writes auto-imports.d.ts to .cer/', async () => {
|
|
231
240
|
await writeAutoImportDts(ROOT, COMPOSABLES_DIR)
|
|
232
241
|
const paths = vi.mocked(writeFileSync).mock.calls.map(([p]) => String(p))
|
|
233
|
-
expect(paths.some(p => p.includes('cer
|
|
242
|
+
expect(paths.some(p => p.includes('.cer/auto-imports.d.ts'))).toBe(true)
|
|
234
243
|
})
|
|
235
244
|
|
|
236
|
-
it('writes
|
|
245
|
+
it('writes env.d.ts to .cer/', async () => {
|
|
237
246
|
await writeAutoImportDts(ROOT, COMPOSABLES_DIR)
|
|
238
247
|
const paths = vi.mocked(writeFileSync).mock.calls.map(([p]) => String(p))
|
|
239
|
-
expect(paths.some(p => p.includes('cer
|
|
248
|
+
expect(paths.some(p => p.includes('.cer/env.d.ts'))).toBe(true)
|
|
240
249
|
})
|
|
241
250
|
|
|
242
251
|
it('writes exactly two files', async () => {
|