@stealthscale/vite-plugin-product 0.1.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/LICENSE +21 -0
- package/README.md +157 -0
- package/client.d.ts +17 -0
- package/dist/cyclonedx/bom.json +1 -0
- package/dist/index.d.mts +78 -0
- package/dist/index.mjs +947 -0
- package/package.json +69 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Stealth Scale B.V.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# @stealthscale/vite-plugin-product
|
|
2
|
+
|
|
3
|
+
`@stealthscale/vite-plugin-product` composes a product from its plugins while the product builds. It
|
|
4
|
+
checks every installed plugin against the others, serves the resolved product as `virtual:product`,
|
|
5
|
+
builds each plugin's lazy modules into a chunk of its own, and writes the catalogues the product's
|
|
6
|
+
services read.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pnpm add -D @stealthscale/vite-plugin-product
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
The package peers on `vite`, `@stealthscale/sdk-core`, `@stealthscale/vite-plugin-base` and
|
|
15
|
+
`@stealthscale/vite-plugin-i18n`. A product adds it through `@stealthscale/vite-config-product`,
|
|
16
|
+
beside the layers of `@stealthscale/vite-config-i18n`.
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
The product states its plugins in `src/product.ts`, whose default export is the definition:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { defineProduct, installed } from "@stealthscale/sdk-core";
|
|
24
|
+
import { manifest as timeOff } from "@acme/plugin-time-off";
|
|
25
|
+
|
|
26
|
+
export default defineProduct({
|
|
27
|
+
name: "product.name",
|
|
28
|
+
plugins: [installed(timeOff)],
|
|
29
|
+
productId: "people",
|
|
30
|
+
version: "1.4.0",
|
|
31
|
+
});
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The application reads the resolved product from `virtual:product`:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { product } from "virtual:product";
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Add the module's type with a triple-slash directive from a file the project compiles:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
/// <reference types="@stealthscale/vite-plugin-product/client" />
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`product({ definition })` names another module. `src/product.ts` is the default.
|
|
47
|
+
`product({ standalone })` serves the standalone page of a plugin, described in
|
|
48
|
+
[The standalone page](#the-standalone-page).
|
|
49
|
+
|
|
50
|
+
## The composition
|
|
51
|
+
|
|
52
|
+
When a build or a dev server starts, the plugin composes the product:
|
|
53
|
+
|
|
54
|
+
1. It imports the definition through a Vite environment of its own, with `noExternal: true` and the
|
|
55
|
+
application's server conditions. Every module the import reads is evaluated there, so a contract
|
|
56
|
+
and a manifest entry load in Node without React.
|
|
57
|
+
2. It finds each installed plugin's web package and contract package among the modules the import
|
|
58
|
+
evaluated. The package that contains the first module to export the manifest, in evaluation
|
|
59
|
+
order, is the web package. The same rule finds the contract package. A manifest the definition
|
|
60
|
+
builds itself, which no module exports, belongs to the definition module's package. No package is
|
|
61
|
+
imported for the search alone.
|
|
62
|
+
3. It leaves out a web package outside the product's own package and its dependencies. The resolver
|
|
63
|
+
then reports the plugin.
|
|
64
|
+
4. It reads the words from the `stealth:i18n` plugin: the fallback language's catalogue of each
|
|
65
|
+
namespace, and the packages that publish each one. The plugin's contract package and the
|
|
66
|
+
application's own catalogues are left out of the publishers.
|
|
67
|
+
5. It calls `resolveProduct` with the definition, the web packages, the words and the
|
|
68
|
+
`validateHotkey` of `@tanstack/hotkeys` 0.10.0, the version `@tanstack/react-hotkeys` binds keys
|
|
69
|
+
with.
|
|
70
|
+
|
|
71
|
+
A configuration without the `stealth:i18n` plugin fails the build and names
|
|
72
|
+
`@stealthscale/vite-config-i18n`.
|
|
73
|
+
|
|
74
|
+
## Faults
|
|
75
|
+
|
|
76
|
+
A build that finds a problem fails with every problem as `<path>: <reason>`, followed by a hint for
|
|
77
|
+
each contract package whose catalogues the i18n layer does not follow. The hint states the scope to
|
|
78
|
+
add. The bundler prints each warning, and the build continues.
|
|
79
|
+
|
|
80
|
+
Under a dev server, `virtual:product` serves the last product that resolved. It throws the problems
|
|
81
|
+
while no product has resolved yet.
|
|
82
|
+
|
|
83
|
+
A definition that fails to load fails with its error and the rule a web package's main entry
|
|
84
|
+
follows: it imports its components and React through lazy importers alone. A definition without an
|
|
85
|
+
object as its default export fails and names the file.
|
|
86
|
+
|
|
87
|
+
## Chunks
|
|
88
|
+
|
|
89
|
+
The plugin adds one code-splitting group, at priority 4: above the shared group and below the vendor
|
|
90
|
+
group of `@stealthscale/vite-config`. The group assigns every module of an installed plugin's web
|
|
91
|
+
package to the chunk `plugin-<id>`, so each plugin's pages, extensions, commands and settings
|
|
92
|
+
sections build into one chunk. A module that an entry imports statically is left out of the group.
|
|
93
|
+
The web package's main entry and its manifest load with the entry chunk.
|
|
94
|
+
|
|
95
|
+
## Catalogues
|
|
96
|
+
|
|
97
|
+
The client environment of a build writes three files under `.product` in its output:
|
|
98
|
+
|
|
99
|
+
| File | Contains |
|
|
100
|
+
| -------------------------- | --------------------------------------------------------- |
|
|
101
|
+
| `.product/access.json` | Every permission, resource kind, role and entitlement |
|
|
102
|
+
| `.product/flags.json` | Every flag, one kill switch per installed plugin included |
|
|
103
|
+
| `.product/operations.json` | Every query and mutation, in the order of the plugins |
|
|
104
|
+
|
|
105
|
+
Each description is translated into every language the declaring plugin's catalogues contain. The
|
|
106
|
+
access and flag lists are sorted by qualified id. A dev server and a server environment write none.
|
|
107
|
+
|
|
108
|
+
## Changes on a dev server
|
|
109
|
+
|
|
110
|
+
The plugin watches every file the composition read. A change to one of them composes the product
|
|
111
|
+
again:
|
|
112
|
+
|
|
113
|
+
- Under a dev server that serves one module per file, `hotUpdate` invalidates `virtual:product` and
|
|
114
|
+
reloads the page.
|
|
115
|
+
- Under a dev server that bundles, `watchChange` reloads the page. `virtual:product` lists every
|
|
116
|
+
composition file as a watched file, so the rebuild loads it again.
|
|
117
|
+
- A composition with problems shows them in the error overlay and keeps the last product that
|
|
118
|
+
resolved.
|
|
119
|
+
- One composition runs at a time, in the order of the changes. The changes reported during a
|
|
120
|
+
composition compose the product once more after it ends.
|
|
121
|
+
- Every composition imports the definition through one environment, which the plugin closes when the
|
|
122
|
+
bundle closes. A composition after a change transforms only the changed files again: 18 ms at 30
|
|
123
|
+
plugins and 45 ms at 100, measured on generated products.
|
|
124
|
+
|
|
125
|
+
A watching build composes the product again at its next build start.
|
|
126
|
+
|
|
127
|
+
## The standalone page
|
|
128
|
+
|
|
129
|
+
`product({ standalone })` serves a plugin's standalone page from the plugin's own web package. The
|
|
130
|
+
package adds it through `product.standalone()` of `@stealthscale/vite-config-product`, which applies
|
|
131
|
+
it under a dev server alone.
|
|
132
|
+
|
|
133
|
+
- Once the configuration resolves, the plugin writes the definition of the product that runs the
|
|
134
|
+
plugin to `node_modules/.stealth/standalone/product.ts`. The definition passes the module that
|
|
135
|
+
`manifest` names, `src/manifest.ts` by default, and each contract package in `beside` to
|
|
136
|
+
`standaloneFrom` of `@stealthscale/sdk-host/standalone`. The plugin leaves a definition with the
|
|
137
|
+
same source untouched, so the watcher does not report a change. Where `definition` names a module,
|
|
138
|
+
the plugin writes none and composes that module.
|
|
139
|
+
- The definition module's package is the web package of the plugin, and of each plugin installed
|
|
140
|
+
from its contract alone.
|
|
141
|
+
- The plugin serves the page's document as `index.html` at the project root, and its entry as
|
|
142
|
+
`virtual:standalone`. The entry imports `@stealthscale/theme/styles.css`. It loads `virtual:i18n`,
|
|
143
|
+
`virtual:product`, `@stealthscale/sdk-host/standalone/app` and the module `glyphs` names through
|
|
144
|
+
`import()`, then calls `renderStandalone` with the page's `locales` and `themes`.
|
|
145
|
+
- The page loads the definition as `virtual:standalone-product`, the generated source with the
|
|
146
|
+
manifest module imported from the project root. An application's chunks place every module under
|
|
147
|
+
`node_modules` in the vendor chunk, and the generated file there would import the entry chunk that
|
|
148
|
+
imports it.
|
|
149
|
+
- The entry runs every component after the React refresh runtime installs. The bundling dev server
|
|
150
|
+
installs the runtime in the entry's own body, after the chunks the entry imports statically, and
|
|
151
|
+
the bundler may move a component the entry shares with a lazy module into such a chunk.
|
|
152
|
+
- A server that bundles serves the document at every address without a file on disk. A server that
|
|
153
|
+
serves one module per file reads `index.html` from disk, so it does not serve the page.
|
|
154
|
+
|
|
155
|
+
## Licence
|
|
156
|
+
|
|
157
|
+
MIT. See [LICENSE](LICENSE).
|
package/client.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declares the module the product plugin serves, so an import of it type-checks.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* An application loads this declaration through a triple-slash directive that names
|
|
6
|
+
* `@stealthscale/vite-plugin-product/client`, in a file it compiles. The module exists only in a
|
|
7
|
+
* build that includes the plugin.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
declare module "virtual:product" {
|
|
11
|
+
import { type Product } from "@stealthscale/sdk-core";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The product as the build resolved it, with each installed plugin's manifest.
|
|
15
|
+
*/
|
|
16
|
+
export const product: Product;
|
|
17
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"$schema":"http://cyclonedx.org/schema/bom-1.7.schema.json","bomFormat":"CycloneDX","specVersion":"1.7","version":1,"metadata":{"lifecycles":[{"phase":"build"}],"tools":{"components":[{"type":"application","name":"rolldown","version":"1.2.11"},{"type":"application","name":"vite","version":"8.3.1"},{"type":"library","name":"core","group":"@arethetypeswrong","version":"0.18.5","bom-ref":"pkg:npm/%40arethetypeswrong/core@0.18.5","author":"Andrew Branch","description":"The API behind arethetypeswrong.github.io","hashes":[{"alg":"SHA-512","content":"f72b63cc6c318e6f54cfb23d6af7dbb79be542deae93db914444b3263aabce797a58abc8e9dc184e8faf277534d96aeafe64778aa97f3f386f1e129d2480230d"}],"licenses":[{"license":{"id":"MIT"}}],"purl":"pkg:npm/%40arethetypeswrong/core@0.18.5","externalReferences":[{"url":"https://github.com/arethetypeswrong/arethetypeswrong.github.io.git#packages/core","type":"vcs","comment":"as detected from PackageJson property \"repository.url\" and \"repository.directory\""}],"properties":[{"name":"cdx:npm:package:constraint:engine:node","value":">=20"}]},{"type":"library","name":"sdk-core","group":"@stealthscale","version":"0.1.0","bom-ref":"pkg:npm/%40stealthscale/sdk-core@0.1.0","description":"Declares the contracts and manifests of plugins, evaluates their conditions, and resolves a product from them, without loading another package at run time.","licenses":[{"license":{"id":"MIT"}}],"purl":"pkg:npm/%40stealthscale/sdk-core@0.1.0","externalReferences":[{"url":"https://github.com/stealth-scale/scale/issues","type":"issue-tracker","comment":"as detected from PackageJson property \"bugs\""},{"url":"git+https://github.com/stealth-scale/scale.git#sdk/core","type":"vcs","comment":"as detected from PackageJson property \"repository.url\" and \"repository.directory\""},{"url":"https://github.com/stealth-scale/scale/tree/main/sdk/core#readme","type":"website","comment":"as detected from PackageJson property \"homepage\""}],"properties":[{"name":"cdx:npm:package:constraint:engine:node","value":">=26.0.0"}]},{"type":"library","name":"testing-config","group":"@stealthscale","version":"0.5.0","bom-ref":"pkg:npm/%40stealthscale/testing-config@0.5.0","description":"Lists every part of the house contract a config, plugin or library package breaks.","licenses":[{"license":{"id":"MIT"}}],"purl":"pkg:npm/%40stealthscale/testing-config@0.5.0","externalReferences":[{"url":"https://github.com/stealth-scale/scale/issues","type":"issue-tracker","comment":"as detected from PackageJson property \"bugs\""},{"url":"git+https://github.com/stealth-scale/scale.git#packages/testing-config","type":"vcs","comment":"as detected from PackageJson property \"repository.url\" and \"repository.directory\""},{"url":"https://github.com/stealth-scale/scale/tree/main/packages/testing-config#readme","type":"website","comment":"as detected from PackageJson property \"homepage\""}],"properties":[{"name":"cdx:npm:package:constraint:engine:node","value":">=26.0.0"}]},{"type":"library","name":"testing","group":"@stealthscale","version":"0.3.0","bom-ref":"pkg:npm/%40stealthscale/testing@0.3.0","description":"Supplies the scratch workspaces, manifests, element measurements and plugin drivers a specification needs.","licenses":[{"license":{"id":"MIT"}}],"purl":"pkg:npm/%40stealthscale/testing@0.3.0","externalReferences":[{"url":"git+https://github.com/stealth-scale/scale.git#packages/testing","type":"vcs","comment":"as detected from PackageJson property \"repository.url\" and \"repository.directory\""}],"properties":[{"name":"cdx:npm:package:constraint:engine:node","value":">=26.0.0"}]},{"type":"library","name":"vite-config-typescript","group":"@stealthscale","version":"0.3.0","bom-ref":"pkg:npm/%40stealthscale/vite-config-typescript@0.3.0","description":"Sets the TypeScript options every stealthscale package compiles with.","licenses":[{"license":{"id":"MIT"}}],"purl":"pkg:npm/%40stealthscale/vite-config-typescript@0.3.0","externalReferences":[{"url":"https://github.com/stealth-scale/scale/issues","type":"issue-tracker","comment":"as detected from PackageJson property \"bugs\""},{"url":"git+https://github.com/stealth-scale/scale.git#packages/vite-config-typescript","type":"vcs","comment":"as detected from PackageJson property \"repository.url\" and \"repository.directory\""},{"url":"https://github.com/stealth-scale/scale/tree/main/packages/vite-config-typescript#readme","type":"website","comment":"as detected from PackageJson property \"homepage\""}],"properties":[{"name":"cdx:npm:package:constraint:engine:node","value":">=26.0.0"}]},{"type":"library","name":"vite-config","group":"@stealthscale","version":"0.6.0","bom-ref":"pkg:npm/%40stealthscale/vite-config@0.6.0","description":"Configures a package from five Vite+ tiers and a named layer for every block.","licenses":[{"license":{"id":"MIT"}}],"purl":"pkg:npm/%40stealthscale/vite-config@0.6.0","externalReferences":[{"url":"https://github.com/stealth-scale/scale/issues","type":"issue-tracker","comment":"as detected from PackageJson property \"bugs\""},{"url":"git+https://github.com/stealth-scale/scale.git#packages/vite-config","type":"vcs","comment":"as detected from PackageJson property \"repository.url\" and \"repository.directory\""},{"url":"https://github.com/stealth-scale/scale/tree/main/packages/vite-config#readme","type":"website","comment":"as detected from PackageJson property \"homepage\""}],"properties":[{"name":"cdx:npm:package:constraint:engine:node","value":">=26.0.0"}]},{"type":"library","name":"vite-plugin-base","group":"@stealthscale","version":"0.2.0","bom-ref":"pkg:npm/%40stealthscale/vite-plugin-base@0.2.0","description":"Turns a name and one write step into a bundler plugin that reads the module graph.","licenses":[{"license":{"id":"MIT"}}],"purl":"pkg:npm/%40stealthscale/vite-plugin-base@0.2.0","externalReferences":[{"url":"https://github.com/stealth-scale/scale/issues","type":"issue-tracker","comment":"as detected from PackageJson property \"bugs\""},{"url":"git+https://github.com/stealth-scale/scale.git#packages/vite-plugin-base","type":"vcs","comment":"as detected from PackageJson property \"repository.url\" and \"repository.directory\""},{"url":"https://github.com/stealth-scale/scale/tree/main/packages/vite-plugin-base#readme","type":"website","comment":"as detected from PackageJson property \"homepage\""}],"properties":[{"name":"cdx:npm:package:constraint:engine:node","value":">=26.0.0"}]},{"type":"library","name":"vite-plugin-i18n","group":"@stealthscale","version":"0.1.0","bom-ref":"pkg:npm/%40stealthscale/vite-plugin-i18n@0.1.0","description":"Finds every catalogue an application can reach, types their keys, and answers the module it loads them from.","licenses":[{"license":{"id":"MIT"}}],"purl":"pkg:npm/%40stealthscale/vite-plugin-i18n@0.1.0","externalReferences":[{"url":"https://github.com/stealth-scale/scale/issues","type":"issue-tracker","comment":"as detected from PackageJson property \"bugs\""},{"url":"git+https://github.com/stealth-scale/scale.git#packages/vite-plugin-i18n","type":"vcs","comment":"as detected from PackageJson property \"repository.url\" and \"repository.directory\""},{"url":"https://github.com/stealth-scale/scale/tree/main/packages/vite-plugin-i18n#readme","type":"website","comment":"as detected from PackageJson property \"homepage\""}],"properties":[{"name":"cdx:npm:package:constraint:engine:node","value":">=26.0.0"}]},{"type":"library","name":"node","group":"@types","version":"26.6.3","bom-ref":"pkg:npm/%40types/node@26.6.3","description":"TypeScript definitions for node","hashes":[{"alg":"SHA-512","content":"76ca8c410a1e4cbaaef70ca70c3d34ab9ef798dceca3721d40e01f1d126a2c27260853e81a8f40d5b0e951cbfff6d9caa44ad05aff6e29e19f977ec4212d3662"}],"licenses":[{"license":{"id":"MIT"}}],"purl":"pkg:npm/%40types/node@26.6.3","externalReferences":[{"url":"https://github.com/DefinitelyTyped/DefinitelyTyped.git#types/node","type":"vcs","comment":"as detected from PackageJson property \"repository.url\" and \"repository.directory\""},{"url":"https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/node","type":"website","comment":"as detected from PackageJson property \"homepage\""}]},{"type":"library","name":"coverage-v8","group":"@vitest","version":"5.0.1","bom-ref":"pkg:npm/%40vitest/coverage-v8@5.0.1","author":"Anthony Fu <anthonyfu117@hotmail.com>","description":"V8 coverage provider for Vitest","hashes":[{"alg":"SHA-512","content":"1510bc0028ae742ddd23a313a65cd14985870d19c8bf620f7dbcece0576824db0c6a5ff7e6c595f21c087d5f1972acd23f2fae5c778c54e36df4212ab4e535ef"}],"licenses":[{"license":{"id":"MIT"}}],"purl":"pkg:npm/%40vitest/coverage-v8@5.0.1","externalReferences":[{"url":"https://github.com/vitest-dev/vitest/issues","type":"issue-tracker","comment":"as detected from PackageJson property \"bugs.url\""},{"url":"git+https://github.com/vitest-dev/vitest.git#packages/coverage-v8","type":"vcs","comment":"as detected from PackageJson property \"repository.url\" and \"repository.directory\""},{"url":"https://vitest.dev/guide/coverage","type":"website","comment":"as detected from PackageJson property \"homepage\""}]},{"type":"library","name":"vite-plus-core","group":"@voidzero-dev","version":"1.0.0","bom-ref":"pkg:npm/%40voidzero-dev/vite-plus-core@1.0.0","author":"VoidZero Inc.","description":"The Unified Toolchain for the Web","licenses":[{"license":{"id":"MIT"}}],"purl":"pkg:npm/%40voidzero-dev/vite-plus-core@1.0.0","externalReferences":[{"url":"https://github.com/voidzero-dev/vite-plus/issues","type":"issue-tracker","comment":"as detected from PackageJson property \"bugs.url\""},{"url":"git+https://github.com/voidzero-dev/vite-plus.git#packages/core","type":"vcs","comment":"as detected from PackageJson property \"repository.url\" and \"repository.directory\""},{"url":"https://viteplus.dev/guide","type":"website","comment":"as detected from PackageJson property \"homepage\""}],"properties":[{"name":"cdx:npm:package:constraint:engine:node","value":"^20.19.0 || ^22.18.0 || >=24.11.0"}]},{"type":"library","name":"typescript","version":"7.0.2","bom-ref":"pkg:npm/typescript@7.0.2","author":"Microsoft Corp.","description":"TypeScript is a language for application scale JavaScript development","hashes":[{"alg":"SHA-512","content":"f0561abbdea8dcd28e85b8ca8bfa8dbc6fd6e63873c646dd9b9b23f406d9ff94f9b16aa7de12602df1b1dbbb11299593bf2cc23fc74b4414dfe6d05349155434"}],"licenses":[{"license":{"id":"Apache-2.0"}}],"purl":"pkg:npm/typescript@7.0.2","externalReferences":[{"url":"https://github.com/microsoft/TypeScript/issues","type":"issue-tracker","comment":"as detected from PackageJson property \"bugs.url\""},{"url":"https://github.com/microsoft/TypeScript.git","type":"vcs","comment":"as detected from PackageJson property \"repository.url\""},{"url":"https://www.typescriptlang.org/","type":"website","comment":"as detected from PackageJson property \"homepage\""}],"properties":[{"name":"cdx:npm:package:constraint:engine:node","value":">=16.20.0"}]},{"type":"library","name":"vitest","version":"5.0.1","bom-ref":"pkg:npm/vitest@5.0.1","author":"Anthony Fu <anthonyfu117@hotmail.com>","description":"Next generation testing framework powered by Vite","hashes":[{"alg":"SHA-512","content":"880f799506ca124bebb5391d0209d66d77db8a95a28967bf8439763f9b4c8ba585c03efa1b43715c01a9fccf4439c62ea5e18946beb0a6931cec2a00e354d08e"}],"licenses":[{"license":{"id":"MIT"}}],"purl":"pkg:npm/vitest@5.0.1","externalReferences":[{"url":"https://github.com/vitest-dev/vitest/issues","type":"issue-tracker","comment":"as detected from PackageJson property \"bugs.url\""},{"url":"git+https://github.com/vitest-dev/vitest.git#packages/vitest","type":"vcs","comment":"as detected from PackageJson property \"repository.url\" and \"repository.directory\""},{"url":"https://vitest.dev","type":"website","comment":"as detected from PackageJson property \"homepage\""}],"properties":[{"name":"cdx:npm:package:constraint:engine:node","value":"^22.12.0 || ^24.0.0 || >=26.0.0"}]}]},"component":{"type":"library","name":"vite-plugin-product","group":"@stealthscale","version":"0.1.0","bom-ref":"pkg:npm/%40stealthscale/vite-plugin-product@0.1.0","description":"Composes a product from its plugins while it builds: checks every plugin against the others, resolves the product, and serves it as virtual:product.","licenses":[{"license":{"id":"MIT"}}],"purl":"pkg:npm/%40stealthscale/vite-plugin-product@0.1.0","externalReferences":[{"url":"https://github.com/stealth-scale/scale/issues","type":"issue-tracker","comment":"as detected from PackageJson property \"bugs\""},{"url":"git+https://github.com/stealth-scale/scale.git#packages/vite-plugin-product","type":"vcs","comment":"as detected from PackageJson property \"repository.url\" and \"repository.directory\""},{"url":"https://github.com/stealth-scale/scale/tree/main/packages/vite-plugin-product#readme","type":"website","comment":"as detected from PackageJson property \"homepage\""}],"properties":[{"name":"cdx:npm:package:constraint:engine:node","value":">=26.0.0"}],"evidence":{"licenses":[{"license":{"name":"file: LICENSE","text":{"content":"TUlUIExpY2Vuc2UKCkNvcHlyaWdodCAoYykgMjAyNiBTdGVhbHRoIFNjYWxlIEIuVi4KClBlcm1pc3Npb24gaXMgaGVyZWJ5IGdyYW50ZWQsIGZyZWUgb2YgY2hhcmdlLCB0byBhbnkgcGVyc29uIG9idGFpbmluZyBhIGNvcHkKb2YgdGhpcyBzb2Z0d2FyZSBhbmQgYXNzb2NpYXRlZCBkb2N1bWVudGF0aW9uIGZpbGVzICh0aGUgIlNvZnR3YXJlIiksIHRvIGRlYWwKaW4gdGhlIFNvZnR3YXJlIHdpdGhvdXQgcmVzdHJpY3Rpb24sIGluY2x1ZGluZyB3aXRob3V0IGxpbWl0YXRpb24gdGhlIHJpZ2h0cwp0byB1c2UsIGNvcHksIG1vZGlmeSwgbWVyZ2UsIHB1Ymxpc2gsIGRpc3RyaWJ1dGUsIHN1YmxpY2Vuc2UsIGFuZC9vciBzZWxsCmNvcGllcyBvZiB0aGUgU29mdHdhcmUsIGFuZCB0byBwZXJtaXQgcGVyc29ucyB0byB3aG9tIHRoZSBTb2Z0d2FyZSBpcwpmdXJuaXNoZWQgdG8gZG8gc28sIHN1YmplY3QgdG8gdGhlIGZvbGxvd2luZyBjb25kaXRpb25zOgoKVGhlIGFib3ZlIGNvcHlyaWdodCBub3RpY2UgYW5kIHRoaXMgcGVybWlzc2lvbiBub3RpY2Ugc2hhbGwgYmUgaW5jbHVkZWQgaW4gYWxsCmNvcGllcyBvciBzdWJzdGFudGlhbCBwb3J0aW9ucyBvZiB0aGUgU29mdHdhcmUuCgpUSEUgU09GVFdBUkUgSVMgUFJPVklERUQgIkFTIElTIiwgV0lUSE9VVCBXQVJSQU5UWSBPRiBBTlkgS0lORCwgRVhQUkVTUyBPUgpJTVBMSUVELCBJTkNMVURJTkcgQlVUIE5PVCBMSU1JVEVEIFRPIFRIRSBXQVJSQU5USUVTIE9GIE1FUkNIQU5UQUJJTElUWSwKRklUTkVTUyBGT1IgQSBQQVJUSUNVTEFSIFBVUlBPU0UgQU5EIE5PTklORlJJTkdFTUVOVC4gSU4gTk8gRVZFTlQgU0hBTEwgVEhFCkFVVEhPUlMgT1IgQ09QWVJJR0hUIEhPTERFUlMgQkUgTElBQkxFIEZPUiBBTlkgQ0xBSU0sIERBTUFHRVMgT1IgT1RIRVIKTElBQklMSVRZLCBXSEVUSEVSIElOIEFOIEFDVElPTiBPRiBDT05UUkFDVCwgVE9SVCBPUiBPVEhFUldJU0UsIEFSSVNJTkcgRlJPTSwKT1VUIE9GIE9SIElOIENPTk5FQ1RJT04gV0lUSCBUSEUgU09GVFdBUkUgT1IgVEhFIFVTRSBPUiBPVEhFUiBERUFMSU5HUyBJTiBUSEUKU09GVFdBUkUuCg==","contentType":"text/plain","encoding":"base64"}}}]}},"supplier":{"name":"Stealth Scale B.V.","url":["https://stealthscale.io"]}},"components":[],"dependencies":[{"ref":"pkg:npm/%40stealthscale/vite-plugin-product@0.1.0"}]}
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Plugin } from "vite";
|
|
2
|
+
import "@stealthscale/sdk-core";
|
|
3
|
+
//#region src/module.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* The specifier an application imports the product from.
|
|
6
|
+
*/
|
|
7
|
+
export declare const ID = "virtual:product";
|
|
8
|
+
//#endregion
|
|
9
|
+
//#region src/standalone.d.ts
|
|
10
|
+
/**
|
|
11
|
+
* Writes the standalone page of a plugin: the definition of the product that runs the plugin, the
|
|
12
|
+
* page's document and the page's entry.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Lists what the standalone page of a plugin runs and offers.
|
|
16
|
+
*/
|
|
17
|
+
interface StandalonePage {
|
|
18
|
+
/**
|
|
19
|
+
* Package names of the contracts installed beside the plugin, each from its contract alone. None
|
|
20
|
+
* where left out.
|
|
21
|
+
*/
|
|
22
|
+
readonly beside?: readonly string[] | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* Path of a module, from the project root, whose `glyphs` export the page renders with. None
|
|
25
|
+
* where left out.
|
|
26
|
+
*/
|
|
27
|
+
readonly glyphs?: string | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* The locales the page offers, the first its fallback. American English alone where left out.
|
|
30
|
+
*/
|
|
31
|
+
readonly locales?: readonly [string, ...string[]] | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* Path of the module that exports the plugin's manifest, from the project root.
|
|
34
|
+
* `src/manifest.ts` where left out.
|
|
35
|
+
*/
|
|
36
|
+
readonly manifest?: string | undefined;
|
|
37
|
+
/**
|
|
38
|
+
* The themes the page offers, by name. The application's one theme where left out.
|
|
39
|
+
*/
|
|
40
|
+
readonly themes?: readonly string[] | undefined;
|
|
41
|
+
}
|
|
42
|
+
//#endregion
|
|
43
|
+
//#region src/plugin.d.ts
|
|
44
|
+
/**
|
|
45
|
+
* Lists the options of the product plugin.
|
|
46
|
+
*/
|
|
47
|
+
export interface ProductOptions {
|
|
48
|
+
/**
|
|
49
|
+
* Path of the module whose default export is the product's definition, from the project root.
|
|
50
|
+
* `src/product.ts` by default, and the generated definition under `standalone`.
|
|
51
|
+
*/
|
|
52
|
+
readonly definition?: string | undefined;
|
|
53
|
+
/**
|
|
54
|
+
* The standalone page of a plugin, which the plugin serves at every address without a file. No
|
|
55
|
+
* page where left out.
|
|
56
|
+
*/
|
|
57
|
+
readonly standalone?: StandalonePage | undefined;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Creates the plugin that composes a product from its plugins, serves `virtual:product`, builds
|
|
61
|
+
* each plugin's lazy modules into one chunk, and writes the access, flag and operation catalogues
|
|
62
|
+
* of a build.
|
|
63
|
+
*
|
|
64
|
+
* @remarks
|
|
65
|
+
* The product is composed when the build starts, and when a dev server starts. A build fails on
|
|
66
|
+
* a problem and prints each warning. A dev server serves no product until the definition
|
|
67
|
+
* resolves, and `virtual:product` then fails with the problems. A change to a file the
|
|
68
|
+
* composition read composes the product again: the page reloads, or shows the problems in the
|
|
69
|
+
* error overlay and keeps the last product. The client build writes the catalogues under
|
|
70
|
+
* `.product` in its output. Under `standalone`, the plugin writes the definition of the product
|
|
71
|
+
* that runs the plugin at `node_modules/.stealth/standalone/product.ts` once the configuration
|
|
72
|
+
* resolves, unless `definition` names one. It serves the page's document as `index.html`, its
|
|
73
|
+
* entry as `virtual:standalone`, and the generated definition as `virtual:standalone-product`.
|
|
74
|
+
* @param options - The definition's path, and the standalone page.
|
|
75
|
+
*/
|
|
76
|
+
export declare function product(options?: ProductOptions): Plugin;
|
|
77
|
+
//#endregion
|
|
78
|
+
export type { StandalonePage };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,947 @@
|
|
|
1
|
+
import { dirname, join, posix, relative, resolve, sep } from "node:path";
|
|
2
|
+
import { normalizePath } from "vite";
|
|
3
|
+
import { lineOf, resolveProduct } from "@stealthscale/sdk-core";
|
|
4
|
+
import { dependencies, importer, literal, manifestAt, owning, quoted, text } from "@stealthscale/vite-plugin-base";
|
|
5
|
+
import { LOCALES, cataloguesOf } from "@stealthscale/vite-plugin-i18n";
|
|
6
|
+
import { validateHotkey } from "@tanstack/hotkeys";
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
8
|
+
//#region src/catalogues.ts
|
|
9
|
+
/**
|
|
10
|
+
* Returns true where a value is an object whose members can be read by name.
|
|
11
|
+
*/
|
|
12
|
+
function isRecord$1(value) {
|
|
13
|
+
return typeof value === "object" && value !== null;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Returns the text at a dotted key of a catalogue, or undefined where the catalogue has none.
|
|
17
|
+
*/
|
|
18
|
+
function textAt(words, key) {
|
|
19
|
+
let node = words;
|
|
20
|
+
for (const part of key.split(".")) node = isRecord$1(node) ? node[part] : void 0;
|
|
21
|
+
return typeof node === "string" ? node : void 0;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Returns a function that translates a plugin's key into each language of its catalogues, reading
|
|
25
|
+
* each pair of language and namespace from the catalogue plugin once.
|
|
26
|
+
*
|
|
27
|
+
* @throws {@link Error} From the returned function, when a catalogue cannot be read, naming the
|
|
28
|
+
* plugin and the language.
|
|
29
|
+
*/
|
|
30
|
+
function describerOf(api) {
|
|
31
|
+
const languages = /* @__PURE__ */ new Map();
|
|
32
|
+
const read = /* @__PURE__ */ new Map();
|
|
33
|
+
for (const { language, namespace } of api.catalogues()) languages.set(namespace, /* @__PURE__ */ new Set([...languages.get(namespace) ?? [], language]));
|
|
34
|
+
/**
|
|
35
|
+
* Returns the words of one pair, read once.
|
|
36
|
+
*
|
|
37
|
+
* @throws {@link Error} When the catalogue plugin cannot read the pair.
|
|
38
|
+
*/
|
|
39
|
+
const wordsOf = (language, plugin) => {
|
|
40
|
+
const pair = `${language}/${plugin}`;
|
|
41
|
+
try {
|
|
42
|
+
if (!read.has(pair)) read.set(pair, api.words(language, plugin));
|
|
43
|
+
} catch (error) {
|
|
44
|
+
throw new Error(`The catalogue of ${plugin} in ${language} cannot be read: ${String(error)}`, { cause: error });
|
|
45
|
+
}
|
|
46
|
+
return read.get(pair);
|
|
47
|
+
};
|
|
48
|
+
return (plugin, key) => Object.fromEntries([...languages.get(plugin) ?? []].toSorted().flatMap((language) => {
|
|
49
|
+
const text = textAt(wordsOf(language, plugin), key);
|
|
50
|
+
return text === void 0 ? [] : [[language, text]];
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Returns the catalogue entry of a resolved name.
|
|
55
|
+
*/
|
|
56
|
+
function entryOf$1(name, describe) {
|
|
57
|
+
return {
|
|
58
|
+
deprecated: name.deprecated,
|
|
59
|
+
description: describe(name.plugin, name.description),
|
|
60
|
+
id: name.id,
|
|
61
|
+
plugin: name.plugin
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Orders two entries by their qualified ids.
|
|
66
|
+
*/
|
|
67
|
+
function byId(first, second) {
|
|
68
|
+
return first.id < second.id ? -1 : 1;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Returns the catalogue entry of a query or a mutation.
|
|
72
|
+
*
|
|
73
|
+
* @param declaration - The resolved query or mutation.
|
|
74
|
+
* @param kind - Whether the declaration is a query or a mutation.
|
|
75
|
+
*/
|
|
76
|
+
function operationOf(declaration, kind) {
|
|
77
|
+
return {
|
|
78
|
+
id: declaration.operation.id,
|
|
79
|
+
kind,
|
|
80
|
+
name: declaration.id.slice(declaration.plugin.length + 1),
|
|
81
|
+
plugin: declaration.plugin
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Builds the access, flag and operation catalogues of a resolved product.
|
|
86
|
+
*
|
|
87
|
+
* @remarks
|
|
88
|
+
* The access and flag lists are sorted by qualified id, so a diff of two releases' catalogues
|
|
89
|
+
* lists every added and removed name. The operations follow the order of the installed plugins.
|
|
90
|
+
* @param product - The resolved product.
|
|
91
|
+
* @param api - The catalogue plugin's api, which the descriptions are translated from.
|
|
92
|
+
* @throws {@link Error} When a catalogue cannot be read for a description, naming the plugin and
|
|
93
|
+
* the language.
|
|
94
|
+
*/
|
|
95
|
+
function cataloguesFor(product, api) {
|
|
96
|
+
const describe = describerOf(api);
|
|
97
|
+
const identity = {
|
|
98
|
+
id: product.productId,
|
|
99
|
+
version: product.version
|
|
100
|
+
};
|
|
101
|
+
return {
|
|
102
|
+
access: {
|
|
103
|
+
entitlements: product.entitlements.map((one) => entryOf$1(one, describe)).toSorted(byId),
|
|
104
|
+
permissions: product.permissions.map((one) => ({
|
|
105
|
+
...entryOf$1(one, describe),
|
|
106
|
+
resource: one.resource
|
|
107
|
+
})).toSorted(byId),
|
|
108
|
+
product: identity,
|
|
109
|
+
resources: product.resources.map((one) => entryOf$1(one, describe)).toSorted(byId),
|
|
110
|
+
roles: product.roles.map((one) => ({
|
|
111
|
+
...entryOf$1(one, describe),
|
|
112
|
+
permissions: one.permissions
|
|
113
|
+
})).toSorted(byId)
|
|
114
|
+
},
|
|
115
|
+
flags: {
|
|
116
|
+
flags: product.flags.map((one) => ({
|
|
117
|
+
...entryOf$1(one, describe),
|
|
118
|
+
default: one.default,
|
|
119
|
+
expires: one.expires,
|
|
120
|
+
kind: one.kind,
|
|
121
|
+
product: one.product,
|
|
122
|
+
variants: one.variants
|
|
123
|
+
})).toSorted(byId),
|
|
124
|
+
product: identity
|
|
125
|
+
},
|
|
126
|
+
operations: {
|
|
127
|
+
operations: product.plugins.flatMap(({ id }) => [...product.queries.filter(({ plugin }) => plugin === id).map((one) => operationOf(one, "query")), ...product.mutations.filter(({ plugin }) => plugin === id).map((one) => operationOf(one, "mutation"))]),
|
|
128
|
+
product: identity
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
//#endregion
|
|
133
|
+
//#region src/chunks.ts
|
|
134
|
+
/**
|
|
135
|
+
* Names the chunk each lazy module of an installed plugin's web package builds into: one chunk per
|
|
136
|
+
* plugin, which every page, extension, command and settings section of the plugin shares.
|
|
137
|
+
*
|
|
138
|
+
* @remarks
|
|
139
|
+
* The product imports each web package's main entry statically, through its definition. The main
|
|
140
|
+
* entry and every module it imports statically build into the chunks the entry loads, because in
|
|
141
|
+
* the plugin's chunk they would make the first load fetch the whole plugin.
|
|
142
|
+
*/
|
|
143
|
+
/**
|
|
144
|
+
* Returns true where an entry of the build imports a module statically, directly or through other
|
|
145
|
+
* modules.
|
|
146
|
+
*
|
|
147
|
+
* @remarks
|
|
148
|
+
* The walk follows each module's static importers back towards the entries. A dynamic import
|
|
149
|
+
* ends the walk, because a module behind one loads after the entry.
|
|
150
|
+
*/
|
|
151
|
+
function initial(id, chunking) {
|
|
152
|
+
const seen = /* @__PURE__ */ new Set();
|
|
153
|
+
const queue = [id];
|
|
154
|
+
for (let next = queue.shift(); next !== void 0; next = queue.shift()) {
|
|
155
|
+
const info = seen.has(next) ? null : chunking.getModuleInfo(next);
|
|
156
|
+
seen.add(next);
|
|
157
|
+
if (info?.isEntry === true) return true;
|
|
158
|
+
queue.push(...info?.importers ?? []);
|
|
159
|
+
}
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Returns the chunk a module builds into: `plugin-<id>` for a module of an installed plugin's web
|
|
164
|
+
* package that no entry imports statically, and null for every other module.
|
|
165
|
+
*
|
|
166
|
+
* @param id - The module's id, its absolute path.
|
|
167
|
+
* @param chunking - The context rolldown passes a group's name function.
|
|
168
|
+
* @param packages - Each installed plugin's web package, by plugin id.
|
|
169
|
+
*/
|
|
170
|
+
function pluginChunkOf(id, chunking, packages) {
|
|
171
|
+
const owner = Object.entries(packages).find(([, { directory }]) => id.startsWith(`${directory}${sep}`));
|
|
172
|
+
return owner === void 0 || initial(id, chunking) ? null : `plugin-${owner[0]}`;
|
|
173
|
+
}
|
|
174
|
+
//#endregion
|
|
175
|
+
//#region src/discover.ts
|
|
176
|
+
/**
|
|
177
|
+
* Finds each installed plugin's web package and contract package among the modules the product's
|
|
178
|
+
* definition loaded.
|
|
179
|
+
*
|
|
180
|
+
* @remarks
|
|
181
|
+
* The definition imports every installed plugin's manifest, and each manifest names its contract,
|
|
182
|
+
* so the modules its evaluation loaded already contain both. A package is the one that contains
|
|
183
|
+
* the module defining the object, matched by identity. A manifest the definition builds itself,
|
|
184
|
+
* which no module exports, belongs to the definition module's package. No package is imported for
|
|
185
|
+
* the search alone, so no component package and no React is loaded in Node.
|
|
186
|
+
*/
|
|
187
|
+
/**
|
|
188
|
+
* Returns true where a value is an object whose members can be read by name.
|
|
189
|
+
*/
|
|
190
|
+
function isRecord(value) {
|
|
191
|
+
return typeof value === "object" && value !== null;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Returns each installed plugin the definition states with a readable manifest and contract.
|
|
195
|
+
*
|
|
196
|
+
* @remarks
|
|
197
|
+
* A definition of another shape installs nothing here, and the resolver's shape check reports it.
|
|
198
|
+
* @param definition - The definition's default export.
|
|
199
|
+
*/
|
|
200
|
+
function installationsOf(definition) {
|
|
201
|
+
const plugins = Reflect.get(definition, "plugins");
|
|
202
|
+
return (Array.isArray(plugins) ? plugins : []).flatMap((plugin) => {
|
|
203
|
+
const manifest = isRecord(plugin) ? plugin["manifest"] : void 0;
|
|
204
|
+
const contract = isRecord(manifest) ? manifest["contract"] : void 0;
|
|
205
|
+
const pluginId = isRecord(contract) ? contract["pluginId"] : void 0;
|
|
206
|
+
return isRecord(manifest) && isRecord(contract) && typeof pluginId === "string" ? [{
|
|
207
|
+
contract,
|
|
208
|
+
manifest,
|
|
209
|
+
pluginId
|
|
210
|
+
}] : [];
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Returns the directory of the package that defines each object a loaded module exports.
|
|
215
|
+
*
|
|
216
|
+
* @remarks
|
|
217
|
+
* A module evaluates after every module it imports, and a module that re-exports an object
|
|
218
|
+
* imports the module that defines it. The first module that exports an object, in evaluation
|
|
219
|
+
* order, is therefore the one that defines it.
|
|
220
|
+
* @param loaded - Every module the definition's evaluation loaded, in evaluation order.
|
|
221
|
+
*/
|
|
222
|
+
function ownersOf(loaded) {
|
|
223
|
+
const owners = /* @__PURE__ */ new Map();
|
|
224
|
+
for (const { exports, file } of loaded) {
|
|
225
|
+
const directory = owning(file);
|
|
226
|
+
for (const value of Object.values(exports)) if (directory !== void 0 && isRecord(value) && !owners.has(value)) owners.set(value, directory);
|
|
227
|
+
}
|
|
228
|
+
return owners;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Returns the package at a directory, or undefined where its manifest states no name.
|
|
232
|
+
*/
|
|
233
|
+
function packageIn(directory) {
|
|
234
|
+
const manifest = directory === void 0 ? void 0 : manifestAt(directory);
|
|
235
|
+
const name = manifest === void 0 ? void 0 : text(manifest, "name");
|
|
236
|
+
return directory === void 0 || name === void 0 ? void 0 : {
|
|
237
|
+
directory,
|
|
238
|
+
name
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Finds each installed plugin's web package and contract package.
|
|
243
|
+
*
|
|
244
|
+
* @remarks
|
|
245
|
+
* A web package outside the product's own package and its dependencies is left out, so the
|
|
246
|
+
* resolver reports the plugin. A contract package is recorded wherever it is, because the build
|
|
247
|
+
* reads it to leave the plugin's own catalogue out of the namespaces another package publishes.
|
|
248
|
+
* @param definition - The definition's default export.
|
|
249
|
+
* @param loaded - Every module the definition's evaluation loaded, in evaluation order.
|
|
250
|
+
* @param root - The product's directory, whose dependencies are walked.
|
|
251
|
+
* @param file - The definition module, whose package a manifest the definition builds belongs to.
|
|
252
|
+
*/
|
|
253
|
+
function discover(definition, loaded, root, file) {
|
|
254
|
+
const owners = ownersOf(loaded);
|
|
255
|
+
const own = owning(realpathSync(file));
|
|
256
|
+
const depended = /* @__PURE__ */ new Set([realpathSync(root), ...dependencies(root).map(({ at }) => at)]);
|
|
257
|
+
const contracts = {};
|
|
258
|
+
const packages = {};
|
|
259
|
+
for (const { contract, manifest, pluginId } of installationsOf(definition)) {
|
|
260
|
+
const web = packageIn(owners.get(manifest) ?? own);
|
|
261
|
+
const declaring = packageIn(owners.get(contract));
|
|
262
|
+
if (web !== void 0 && depended.has(web.directory)) packages[pluginId] = web;
|
|
263
|
+
if (declaring !== void 0) contracts[pluginId] = declaring;
|
|
264
|
+
}
|
|
265
|
+
return {
|
|
266
|
+
contracts,
|
|
267
|
+
packages
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
//#endregion
|
|
271
|
+
//#region src/words.ts
|
|
272
|
+
/**
|
|
273
|
+
* Builds the words the resolver checks from the catalogue plugin's api, so the build checks the
|
|
274
|
+
* words the page loads, found under the scopes the catalogue plugin follows.
|
|
275
|
+
*/
|
|
276
|
+
/**
|
|
277
|
+
* Returns the fallback catalogue of each namespace, and the packages that publish each namespace
|
|
278
|
+
* other than the plugin's contract package.
|
|
279
|
+
*
|
|
280
|
+
* @remarks
|
|
281
|
+
* The resolver reports a plugin whose namespace has no entry in `catalogues`. The application's
|
|
282
|
+
* own catalogues are left out of the publishers, because an application may change the words of
|
|
283
|
+
* any package it installs.
|
|
284
|
+
* @param api - The catalogue plugin's api.
|
|
285
|
+
* @param contracts - Each installed plugin's contract package, by plugin id.
|
|
286
|
+
*/
|
|
287
|
+
function wordsOf(api, contracts) {
|
|
288
|
+
const catalogues = api.catalogues();
|
|
289
|
+
const fallback = new Set(catalogues.filter(({ language }) => language === api.fallback).map(({ namespace }) => namespace));
|
|
290
|
+
const publishers = /* @__PURE__ */ new Map();
|
|
291
|
+
for (const { namespace, own, owner } of catalogues) if (!own && owner !== contracts[namespace]?.name) publishers.set(namespace, /* @__PURE__ */ new Set([...publishers.get(namespace) ?? [], owner]));
|
|
292
|
+
return {
|
|
293
|
+
catalogues: Object.fromEntries([...fallback].map((namespace) => [namespace, api.words(api.fallback, namespace)])),
|
|
294
|
+
namespaces: Object.fromEntries([...publishers].map(([namespace, owners]) => [namespace, [...owners]]))
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Returns the line that tells a product how the catalogue plugin finds a package's catalogues.
|
|
299
|
+
*
|
|
300
|
+
* @remarks
|
|
301
|
+
* The catalogue plugin follows the packages of the scopes it was given alone, so a package
|
|
302
|
+
* without a scope is never followed.
|
|
303
|
+
* @param name - Name of the package whose catalogues the catalogue plugin did not find.
|
|
304
|
+
*/
|
|
305
|
+
function hintOf(name) {
|
|
306
|
+
const [scope] = name.split("/");
|
|
307
|
+
return name.startsWith("@") ? `${name} has catalogues the i18n layer does not follow. Add ${scope} to the scopes of the i18n layer.` : `${name} has catalogues the i18n layer does not follow, because the layer follows scoped packages alone.`;
|
|
308
|
+
}
|
|
309
|
+
/**
|
|
310
|
+
* Returns one line per installed plugin whose contract package has catalogues the catalogue plugin
|
|
311
|
+
* did not find, naming the scope the product adds to the i18n layer.
|
|
312
|
+
*
|
|
313
|
+
* @param api - The catalogue plugin's api.
|
|
314
|
+
* @param contracts - Each installed plugin's contract package, by plugin id.
|
|
315
|
+
*/
|
|
316
|
+
function hintsOf(api, contracts) {
|
|
317
|
+
const found = new Set(api.catalogues().map(({ owner }) => owner));
|
|
318
|
+
return Object.values(contracts).filter(({ directory, name }) => !found.has(name) && existsSync(join(directory, LOCALES))).map(({ name }) => hintOf(name));
|
|
319
|
+
}
|
|
320
|
+
//#endregion
|
|
321
|
+
//#region src/compose.ts
|
|
322
|
+
/**
|
|
323
|
+
* Composes a product: imports its definition through Vite, finds its plugins' packages, and
|
|
324
|
+
* resolves it with the words the catalogue plugin found.
|
|
325
|
+
*/
|
|
326
|
+
/**
|
|
327
|
+
* Returns the message of an error, or the value written as text.
|
|
328
|
+
*/
|
|
329
|
+
function messageOf(error) {
|
|
330
|
+
return error instanceof Error ? error.message : String(error);
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Imports the definition module through the importer.
|
|
334
|
+
*
|
|
335
|
+
* @throws {@link Error} When the module or anything it imports fails to load in Node, with the rule
|
|
336
|
+
* a web package's main entry follows.
|
|
337
|
+
*/
|
|
338
|
+
async function evaluated(through, file, definition) {
|
|
339
|
+
try {
|
|
340
|
+
return await through.import(file);
|
|
341
|
+
} catch (error) {
|
|
342
|
+
throw new Error(`${definition} failed to load in Node: ${messageOf(error)}. The build evaluates the definition, every contract and every manifest entry in Node. A web package's main entry imports its components and React through lazy importers alone.`, { cause: error });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Returns the definition a module exports by default.
|
|
347
|
+
*
|
|
348
|
+
* @throws {@link Error} When the module has no default export that is an object.
|
|
349
|
+
*/
|
|
350
|
+
function definitionOf$1(module, definition) {
|
|
351
|
+
const exported = module["default"];
|
|
352
|
+
if (typeof exported !== "object" || exported === null) throw new Error(`${definition} has no default export, and the build reads the product's definition from it.`);
|
|
353
|
+
return exported;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Composes a product from its definition.
|
|
357
|
+
*
|
|
358
|
+
* @remarks
|
|
359
|
+
* The importer resolves every package through Vite under the product's conditions. The resolver
|
|
360
|
+
* checks the shape of every member of the definition before it reads one.
|
|
361
|
+
* @param composing - The definition's path, the project root, the importer and the catalogue
|
|
362
|
+
* plugin's api.
|
|
363
|
+
* @returns The packages found, the files read, the hints and the resolution.
|
|
364
|
+
* @throws {@link Error} When the definition fails to load, or has no default export.
|
|
365
|
+
*/
|
|
366
|
+
async function compose(composing) {
|
|
367
|
+
const { definition, root, through, words } = composing;
|
|
368
|
+
const file = join(root, definition);
|
|
369
|
+
const imported = await evaluated(through, file, definition);
|
|
370
|
+
const stated = definitionOf$1(imported.module, definition);
|
|
371
|
+
const discovery = discover(stated, imported.loaded, root, file);
|
|
372
|
+
const resolution = resolveProduct(stated, discovery.packages, {
|
|
373
|
+
...wordsOf(words, discovery.contracts),
|
|
374
|
+
validateHotkey
|
|
375
|
+
});
|
|
376
|
+
return {
|
|
377
|
+
discovery,
|
|
378
|
+
files: imported.files,
|
|
379
|
+
hints: hintsOf(words, discovery.contracts),
|
|
380
|
+
resolution
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
//#endregion
|
|
384
|
+
//#region src/module.ts
|
|
385
|
+
/**
|
|
386
|
+
* The specifier an application imports the product from.
|
|
387
|
+
*/
|
|
388
|
+
const ID = "virtual:product";
|
|
389
|
+
/**
|
|
390
|
+
* The resolved id of the module, behind the NUL that marks it as generated.
|
|
391
|
+
*/
|
|
392
|
+
const RESOLVED = `\0${ID}`;
|
|
393
|
+
/**
|
|
394
|
+
* Returns the source of `virtual:product`.
|
|
395
|
+
*
|
|
396
|
+
* @remarks
|
|
397
|
+
* The module imports the definition. The page receives each manifest from it, keyed by plugin id,
|
|
398
|
+
* with its lazy importers and its contract's search validators. Every member the build resolved
|
|
399
|
+
* is a literal in the module, and the page runs no check of its own.
|
|
400
|
+
* @param definition - The definition module's path from the project root, such as
|
|
401
|
+
* `/src/product.ts`.
|
|
402
|
+
* @param product - The resolved product.
|
|
403
|
+
* @throws {@link Error} When a member of the product cannot be written as source, naming its path.
|
|
404
|
+
*/
|
|
405
|
+
function moduleOf(definition, product) {
|
|
406
|
+
return [
|
|
407
|
+
`import definition from ${quoted(definition)};`,
|
|
408
|
+
"",
|
|
409
|
+
`const resolved = ${literal(product, "product")};`,
|
|
410
|
+
"",
|
|
411
|
+
"export const product = {",
|
|
412
|
+
" ...resolved,",
|
|
413
|
+
" manifests: Object.fromEntries(",
|
|
414
|
+
" definition.plugins.map(({ manifest }) => [manifest.contract.pluginId, manifest]),",
|
|
415
|
+
" ),",
|
|
416
|
+
"};",
|
|
417
|
+
""
|
|
418
|
+
].join("\n");
|
|
419
|
+
}
|
|
420
|
+
//#endregion
|
|
421
|
+
//#region src/standalone.ts
|
|
422
|
+
/**
|
|
423
|
+
* Writes the standalone page of a plugin: the definition of the product that runs the plugin, the
|
|
424
|
+
* page's document and the page's entry.
|
|
425
|
+
*/
|
|
426
|
+
/**
|
|
427
|
+
* Path of the generated definition, from the project root.
|
|
428
|
+
*/
|
|
429
|
+
const GENERATED = "node_modules/.stealth/standalone/product.ts";
|
|
430
|
+
/**
|
|
431
|
+
* The specifier the page imports the generated definition by.
|
|
432
|
+
*/
|
|
433
|
+
const PRODUCT = "virtual:standalone-product";
|
|
434
|
+
/**
|
|
435
|
+
* Path of the manifest module where the page states none.
|
|
436
|
+
*/
|
|
437
|
+
const MANIFEST = "src/manifest.ts";
|
|
438
|
+
/**
|
|
439
|
+
* The page's document, from the project root.
|
|
440
|
+
*/
|
|
441
|
+
const DOCUMENT = "index.html";
|
|
442
|
+
/**
|
|
443
|
+
* The specifier of the page's entry.
|
|
444
|
+
*/
|
|
445
|
+
const ENTRY = "virtual:standalone";
|
|
446
|
+
/**
|
|
447
|
+
* The resolved id of the page's entry, behind the NUL that marks it as generated.
|
|
448
|
+
*/
|
|
449
|
+
const RESOLVED_ENTRY = `\0${ENTRY}`;
|
|
450
|
+
/**
|
|
451
|
+
* The resolved id of the definition the page imports, behind the NUL that marks it as generated.
|
|
452
|
+
*/
|
|
453
|
+
const RESOLVED_PRODUCT = `\0${PRODUCT}`;
|
|
454
|
+
/**
|
|
455
|
+
* The resolved id of each module the page imports by a specifier of its own.
|
|
456
|
+
*/
|
|
457
|
+
const SPECIFIED = /* @__PURE__ */ new Map([
|
|
458
|
+
[`/@id/${ENTRY}`, RESOLVED_ENTRY],
|
|
459
|
+
[ENTRY, RESOLVED_ENTRY],
|
|
460
|
+
[PRODUCT, RESOLVED_PRODUCT]
|
|
461
|
+
]);
|
|
462
|
+
/**
|
|
463
|
+
* The language of the document where the page states no locale.
|
|
464
|
+
*/
|
|
465
|
+
const LANGUAGE = "en-US";
|
|
466
|
+
/**
|
|
467
|
+
* Returns the source of a definition that passes the plugin's manifest module and each contract
|
|
468
|
+
* package beside it to `standaloneFrom`.
|
|
469
|
+
*
|
|
470
|
+
* @param from - The specifier the definition imports the manifest module by.
|
|
471
|
+
* @param page - The manifest module and the contract packages beside the plugin.
|
|
472
|
+
*/
|
|
473
|
+
function sourceOf(from, page) {
|
|
474
|
+
const beside = page.beside ?? [];
|
|
475
|
+
return [
|
|
476
|
+
"import { standaloneFrom } from \"@stealthscale/sdk-host/standalone\";",
|
|
477
|
+
"",
|
|
478
|
+
`import * as plugin from ${quoted(from)};`,
|
|
479
|
+
...beside.map((name, index) => `import * as beside${String(index)} from ${quoted(name)};`),
|
|
480
|
+
"",
|
|
481
|
+
"export default standaloneFrom(",
|
|
482
|
+
` { from: ${quoted(page.manifest ?? MANIFEST)}, module: plugin },`,
|
|
483
|
+
" [",
|
|
484
|
+
...beside.map((name, index) => ` { from: ${quoted(name)}, module: beside${String(index)} },`),
|
|
485
|
+
" ],",
|
|
486
|
+
");",
|
|
487
|
+
""
|
|
488
|
+
].join("\n");
|
|
489
|
+
}
|
|
490
|
+
/**
|
|
491
|
+
* Returns the source of the generated definition, which the build imports in Node.
|
|
492
|
+
*
|
|
493
|
+
* @remarks
|
|
494
|
+
* The definition imports the manifest module by a path relative to its own directory, three
|
|
495
|
+
* levels under the project root, so the path starts with `../`.
|
|
496
|
+
* @param root - The project root.
|
|
497
|
+
* @param page - The manifest module and the contract packages beside the plugin.
|
|
498
|
+
*/
|
|
499
|
+
function definitionOf(root, page) {
|
|
500
|
+
const manifest = join(root, page.manifest ?? MANIFEST);
|
|
501
|
+
return sourceOf(normalizePath(relative(dirname(join(root, GENERATED)), manifest)), page);
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Writes the generated definition under the project root, and leaves a file with the same source
|
|
505
|
+
* untouched.
|
|
506
|
+
*
|
|
507
|
+
* @remarks
|
|
508
|
+
* A file left untouched keeps its time of change, so the watcher reports no change and the
|
|
509
|
+
* product is not composed again.
|
|
510
|
+
* @param root - The project root.
|
|
511
|
+
* @param page - The manifest module and the contract packages beside the plugin.
|
|
512
|
+
*/
|
|
513
|
+
function generate(root, page) {
|
|
514
|
+
const file = join(root, GENERATED);
|
|
515
|
+
const source = definitionOf(root, page);
|
|
516
|
+
if (existsSync(file) && readFileSync(file, "utf8") === source) return;
|
|
517
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
518
|
+
writeFileSync(file, source);
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Returns the source of the page's document: an element with the id `root`, and the entry.
|
|
522
|
+
*/
|
|
523
|
+
function documentOf(page) {
|
|
524
|
+
const [language = LANGUAGE] = page.locales ?? [];
|
|
525
|
+
return [
|
|
526
|
+
"<!doctype html>",
|
|
527
|
+
`<html lang=${quoted(language)}>`,
|
|
528
|
+
" <head>",
|
|
529
|
+
" <meta charset=\"utf-8\" />",
|
|
530
|
+
" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />",
|
|
531
|
+
" <title>Standalone page</title>",
|
|
532
|
+
" </head>",
|
|
533
|
+
" <body>",
|
|
534
|
+
" <div id=\"root\"></div>",
|
|
535
|
+
` <script type="module" src="/@id/${ENTRY}"><\/script>`,
|
|
536
|
+
" </body>",
|
|
537
|
+
"</html>",
|
|
538
|
+
""
|
|
539
|
+
].join("\n");
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Returns the source of the page's entry, which renders the page over `virtual:product` and
|
|
543
|
+
* `virtual:i18n`.
|
|
544
|
+
*
|
|
545
|
+
* @remarks
|
|
546
|
+
* The entry imports the stylesheet alone, and the page's modules through `import()`. The bundled
|
|
547
|
+
* dev server installs the React refresh runtime in the entry's own body, and a chunk the entry
|
|
548
|
+
* imports statically runs before that body, so a component in a chunk the bundler shares between
|
|
549
|
+
* the entry and a lazy module would run before the runtime and throw. Behind `import()`, every
|
|
550
|
+
* component runs after it. The entry awaits nothing at its top level, because a module with a
|
|
551
|
+
* top-level await makes the bundler move every module it shares with a lazy module into a chunk
|
|
552
|
+
* of its own.
|
|
553
|
+
*/
|
|
554
|
+
function entryOf(page) {
|
|
555
|
+
const { glyphs, locales, themes } = page;
|
|
556
|
+
const members = [
|
|
557
|
+
"catalogues",
|
|
558
|
+
...glyphs === void 0 ? [] : ["glyphs"],
|
|
559
|
+
...locales === void 0 ? [] : [`locales: ${literal(locales, "standalone.locales")}`],
|
|
560
|
+
"product",
|
|
561
|
+
...themes === void 0 ? [] : [`themes: ${literal(themes, "standalone.themes")}`]
|
|
562
|
+
];
|
|
563
|
+
const modules = [
|
|
564
|
+
["{ renderStandalone }", quoted("@stealthscale/sdk-host/standalone/app")],
|
|
565
|
+
["{ catalogues }", quoted("virtual:i18n")],
|
|
566
|
+
["{ product }", quoted("virtual:product")],
|
|
567
|
+
...glyphs === void 0 ? [] : [["{ glyphs }", quoted(posix.join("/", normalizePath(glyphs)))]]
|
|
568
|
+
];
|
|
569
|
+
return [
|
|
570
|
+
"import \"@stealthscale/theme/styles.css\";",
|
|
571
|
+
"",
|
|
572
|
+
"async function start() {",
|
|
573
|
+
` const [${modules.map(([names]) => names).join(", ")}] = await Promise.all([`,
|
|
574
|
+
...modules.map(([, from]) => ` import(${String(from)}),`),
|
|
575
|
+
" ]);",
|
|
576
|
+
"",
|
|
577
|
+
` await renderStandalone({ ${members.join(", ")} });`,
|
|
578
|
+
"}",
|
|
579
|
+
"",
|
|
580
|
+
"void start();",
|
|
581
|
+
""
|
|
582
|
+
].join("\n");
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Returns the id the page's document, its entry or its definition resolves to, and undefined for
|
|
586
|
+
* any other specifier.
|
|
587
|
+
*
|
|
588
|
+
* @param root - The project root.
|
|
589
|
+
* @param id - The specifier.
|
|
590
|
+
*/
|
|
591
|
+
function pageIdOf(root, id) {
|
|
592
|
+
const document = normalizePath(join(root, DOCUMENT));
|
|
593
|
+
if (id === DOCUMENT || id === `/${DOCUMENT}` || id === document) return document;
|
|
594
|
+
return SPECIFIED.get(id);
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Returns the source of the page's document, its entry or its definition, and undefined for any
|
|
598
|
+
* other id.
|
|
599
|
+
*
|
|
600
|
+
* @remarks
|
|
601
|
+
* The definition the page imports is the generated definition with its manifest module imported
|
|
602
|
+
* from the project root. An application's chunks place every module under `node_modules` in the
|
|
603
|
+
* vendor chunk, and the generated file there would import the entry chunk that imports it, so the
|
|
604
|
+
* definition would run before the manifest module.
|
|
605
|
+
* @param root - The project root.
|
|
606
|
+
* @param page - The page's manifest module, contracts, locales, themes and glyphs.
|
|
607
|
+
* @param id - The resolved id.
|
|
608
|
+
*/
|
|
609
|
+
function pageSourceOf(root, page, id) {
|
|
610
|
+
if (id === normalizePath(join(root, DOCUMENT))) return documentOf(page);
|
|
611
|
+
if (id === RESOLVED_ENTRY) return entryOf(page);
|
|
612
|
+
return id === RESOLVED_PRODUCT ? sourceOf(posix.join("/", normalizePath(page.manifest ?? MANIFEST)), page) : void 0;
|
|
613
|
+
}
|
|
614
|
+
//#endregion
|
|
615
|
+
//#region src/plugin.ts
|
|
616
|
+
/**
|
|
617
|
+
* The Vite plugin that composes a product from its plugins while the product builds, serves the
|
|
618
|
+
* result as `virtual:product`, and writes the catalogues the product's services read. Under the
|
|
619
|
+
* `standalone` option it serves a plugin's standalone page as well.
|
|
620
|
+
*/
|
|
621
|
+
/**
|
|
622
|
+
* Directory of the build's output the catalogues are written to.
|
|
623
|
+
*/
|
|
624
|
+
const CATALOGUES = ".product";
|
|
625
|
+
/**
|
|
626
|
+
* Path of the definition module where the options state none.
|
|
627
|
+
*/
|
|
628
|
+
const DEFINITION = "src/product.ts";
|
|
629
|
+
/**
|
|
630
|
+
* Priority of the plugin chunks' group: above the shared chunk's 3, below the vendor chunk's 5.
|
|
631
|
+
*/
|
|
632
|
+
const PRIORITY = 4;
|
|
633
|
+
/**
|
|
634
|
+
* Returns the message a product that does not resolve fails with: every problem, then the hints.
|
|
635
|
+
*/
|
|
636
|
+
function failureOf({ hints, resolution }) {
|
|
637
|
+
return [
|
|
638
|
+
"The product does not resolve:",
|
|
639
|
+
...resolution.problems.map(lineOf),
|
|
640
|
+
...hints
|
|
641
|
+
].join("\n");
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Composes the product through the plugin's importer, after dropping the transforms of every file
|
|
645
|
+
* that changed since the composition before.
|
|
646
|
+
*
|
|
647
|
+
* @remarks
|
|
648
|
+
* The first composition opens the importer, and every later one reuses its environment. Vite
|
|
649
|
+
* keeps a closed environment reachable, so an environment per composition would leave every
|
|
650
|
+
* module it transformed on the heap.
|
|
651
|
+
*/
|
|
652
|
+
async function composedThrough(state, definition, words) {
|
|
653
|
+
state.through ??= importer({
|
|
654
|
+
conditions: state.conditions,
|
|
655
|
+
root: state.root
|
|
656
|
+
});
|
|
657
|
+
const through = await state.through;
|
|
658
|
+
through.invalidate([...state.changed]);
|
|
659
|
+
state.changed.clear();
|
|
660
|
+
return compose({
|
|
661
|
+
definition,
|
|
662
|
+
root: state.root,
|
|
663
|
+
through,
|
|
664
|
+
words
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Starts the product's composition once, and returns the one started before on a later call.
|
|
669
|
+
*
|
|
670
|
+
* @throws {@link Error} When the configuration has no catalogue plugin, whose words the
|
|
671
|
+
* composition checks.
|
|
672
|
+
*/
|
|
673
|
+
function composed(state, definition) {
|
|
674
|
+
const { words } = state;
|
|
675
|
+
if (words === void 0) throw new Error("The configuration has no stealth:i18n plugin, whose catalogues the product plugin checks. Add the layers of @stealthscale/vite-config-i18n.");
|
|
676
|
+
state.composing ??= composedThrough(state, definition, words);
|
|
677
|
+
return state.composing;
|
|
678
|
+
}
|
|
679
|
+
/**
|
|
680
|
+
* Closes the importer the compositions share and forgets it, so a later composition opens a new
|
|
681
|
+
* one.
|
|
682
|
+
*/
|
|
683
|
+
async function closed(state) {
|
|
684
|
+
const { through } = state;
|
|
685
|
+
state.through = void 0;
|
|
686
|
+
await (await through)?.close();
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Returns the configuration whose group builds each installed plugin's lazy modules into a chunk
|
|
690
|
+
* of its own.
|
|
691
|
+
*
|
|
692
|
+
* @remarks
|
|
693
|
+
* The group names a chunk from the web packages the composition found. A build start records
|
|
694
|
+
* them before the bundler forms its chunks.
|
|
695
|
+
*/
|
|
696
|
+
function chunked(state) {
|
|
697
|
+
return { build: { rolldownOptions: { output: { codeSplitting: { groups: [{
|
|
698
|
+
debugName: "plugins",
|
|
699
|
+
name: (id, chunking) => pluginChunkOf(id, chunking, state.packages),
|
|
700
|
+
priority: PRIORITY
|
|
701
|
+
}] } } } } };
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Returns the catalogue files of a client build, and none under a dev server or for another
|
|
705
|
+
* environment.
|
|
706
|
+
*
|
|
707
|
+
* @param state - The command, the resolved product and the catalogue plugin's api.
|
|
708
|
+
* @param consumer - The consumer of the environment whose bundle is written.
|
|
709
|
+
* @throws {@link Error} When a catalogue cannot be read for a description.
|
|
710
|
+
*/
|
|
711
|
+
function catalogueFiles(state, consumer) {
|
|
712
|
+
const { command, product: resolved, words } = state;
|
|
713
|
+
if (consumer !== "client" || command !== "build" || resolved === void 0 || words === void 0) return [];
|
|
714
|
+
return Object.entries(cataloguesFor(resolved, words)).map(([name, catalogue]) => ({
|
|
715
|
+
fileName: `${CATALOGUES}/${name}.json`,
|
|
716
|
+
source: `${JSON.stringify(catalogue, null, 2)}\n`,
|
|
717
|
+
type: "asset"
|
|
718
|
+
}));
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Records a composition: its files, its web packages, its problems, and its product where it
|
|
722
|
+
* resolved, so a composition with problems keeps the last product that resolved.
|
|
723
|
+
*
|
|
724
|
+
* @param state - The plugin's state, which the composition replaces.
|
|
725
|
+
* @param composition - The files read, the web packages found and the resolution.
|
|
726
|
+
* @param warn - Prints one warning of the resolution.
|
|
727
|
+
*/
|
|
728
|
+
function applied(state, composition, warn) {
|
|
729
|
+
const { problems, product: resolved, warnings } = composition.resolution;
|
|
730
|
+
for (const warning of warnings) warn(lineOf(warning));
|
|
731
|
+
state.files = new Set(composition.files.map((file) => normalizePath(file)));
|
|
732
|
+
state.packages = composition.discovery.packages;
|
|
733
|
+
state.product = resolved ?? state.product;
|
|
734
|
+
state.failure = problems.length === 0 ? void 0 : failureOf(composition);
|
|
735
|
+
}
|
|
736
|
+
/**
|
|
737
|
+
* Returns what the page receives after a change: a reload where the product resolved, and the
|
|
738
|
+
* problems in the error overlay where it did not.
|
|
739
|
+
*/
|
|
740
|
+
function noticeOf(state) {
|
|
741
|
+
return state.failure === void 0 ? {
|
|
742
|
+
path: "*",
|
|
743
|
+
type: "full-reload"
|
|
744
|
+
} : {
|
|
745
|
+
err: {
|
|
746
|
+
message: state.failure,
|
|
747
|
+
stack: ""
|
|
748
|
+
},
|
|
749
|
+
type: "error"
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Composes the product again and records the composition, or its load error as the failure, unless
|
|
754
|
+
* a later change superseded the change.
|
|
755
|
+
*
|
|
756
|
+
* @param state - The plugin's state.
|
|
757
|
+
* @param definition - The definition's path from the project root.
|
|
758
|
+
* @param context - The watch hook's context, whose bundler prints the warnings.
|
|
759
|
+
* @param change - The number of the change the composition is for.
|
|
760
|
+
*/
|
|
761
|
+
async function recorded(state, definition, context, change) {
|
|
762
|
+
if (change !== state.changes) return;
|
|
763
|
+
try {
|
|
764
|
+
applied(state, await composed(state, definition), (message) => {
|
|
765
|
+
context.warn(message);
|
|
766
|
+
});
|
|
767
|
+
} catch (error) {
|
|
768
|
+
state.failure = error.message;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Follows a change to a file the last composition read.
|
|
773
|
+
*
|
|
774
|
+
* @remarks
|
|
775
|
+
* A watching build composes again at its next build start. A dev server runs one composition at
|
|
776
|
+
* a time, in the order of the changes. It skips the composition of a change that a later change
|
|
777
|
+
* superseded before its turn. The composition recorded last starts after the last change. A
|
|
778
|
+
* composition that fails to load records its error as the failure and keeps the last product. A
|
|
779
|
+
* server that bundles runs no hot update, so the page receives the notice here.
|
|
780
|
+
* @param state - The plugin's state.
|
|
781
|
+
* @param definition - The definition's path from the project root.
|
|
782
|
+
* @param context - The watch hook's context.
|
|
783
|
+
*/
|
|
784
|
+
async function followed(state, definition, context) {
|
|
785
|
+
state.composing = void 0;
|
|
786
|
+
if (state.command === "build") return;
|
|
787
|
+
state.changes += 1;
|
|
788
|
+
const change = state.changes;
|
|
789
|
+
state.following = state.following.then(() => recorded(state, definition, context, change));
|
|
790
|
+
await state.following;
|
|
791
|
+
const { config, hot } = context.environment;
|
|
792
|
+
if (config.isBundled && hot !== void 0) hot.send(noticeOf(state));
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* Brings a page served one module per file up to date after a change to a file the last
|
|
796
|
+
* composition read: invalidates `virtual:product` in the environment, and sends the client's
|
|
797
|
+
* page a notice.
|
|
798
|
+
*
|
|
799
|
+
* @returns An empty list, so the server handles the change no further.
|
|
800
|
+
*/
|
|
801
|
+
function updated(state, context) {
|
|
802
|
+
const { hot, moduleGraph, name } = context.environment;
|
|
803
|
+
const node = moduleGraph.getModuleById(RESOLVED);
|
|
804
|
+
if (node !== void 0) moduleGraph.invalidateModule(node);
|
|
805
|
+
if (name === "client") hot.send(noticeOf(state));
|
|
806
|
+
return [];
|
|
807
|
+
}
|
|
808
|
+
/**
|
|
809
|
+
* Returns the specifier `virtual:product` imports the definition by: `virtual:standalone-product`
|
|
810
|
+
* for a definition the plugin generates, and the definition's path from the project root for any
|
|
811
|
+
* other.
|
|
812
|
+
*
|
|
813
|
+
* @param state - The project root.
|
|
814
|
+
* @param definition - The definition's path from the project root.
|
|
815
|
+
* @param generated - Whether the plugin generates the definition of a standalone page.
|
|
816
|
+
*/
|
|
817
|
+
function importedOf(state, definition, generated) {
|
|
818
|
+
return generated ? PRODUCT : `/${normalizePath(relative(state.root, resolve(state.root, definition)))}`;
|
|
819
|
+
}
|
|
820
|
+
/**
|
|
821
|
+
* Returns the source of `virtual:product`, which depends on every file the composition read.
|
|
822
|
+
*
|
|
823
|
+
* @param state - The resolved product and the files the composition read.
|
|
824
|
+
* @param imported - The specifier the module imports the definition by.
|
|
825
|
+
* @param context - The load hook's context, which watches the files.
|
|
826
|
+
* @throws {@link Error} When the product did not resolve, with every problem.
|
|
827
|
+
*/
|
|
828
|
+
function served(state, imported, context) {
|
|
829
|
+
if (state.product === void 0) throw new Error(state.failure ?? "The product has not been composed yet.");
|
|
830
|
+
for (const file of state.files) context.addWatchFile(file);
|
|
831
|
+
return moduleOf(imported, state.product);
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* Creates the plugin that composes a product from its plugins, serves `virtual:product`, builds
|
|
835
|
+
* each plugin's lazy modules into one chunk, and writes the access, flag and operation catalogues
|
|
836
|
+
* of a build.
|
|
837
|
+
*
|
|
838
|
+
* @remarks
|
|
839
|
+
* The product is composed when the build starts, and when a dev server starts. A build fails on
|
|
840
|
+
* a problem and prints each warning. A dev server serves no product until the definition
|
|
841
|
+
* resolves, and `virtual:product` then fails with the problems. A change to a file the
|
|
842
|
+
* composition read composes the product again: the page reloads, or shows the problems in the
|
|
843
|
+
* error overlay and keeps the last product. The client build writes the catalogues under
|
|
844
|
+
* `.product` in its output. Under `standalone`, the plugin writes the definition of the product
|
|
845
|
+
* that runs the plugin at `node_modules/.stealth/standalone/product.ts` once the configuration
|
|
846
|
+
* resolves, unless `definition` names one. It serves the page's document as `index.html`, its
|
|
847
|
+
* entry as `virtual:standalone`, and the generated definition as `virtual:standalone-product`.
|
|
848
|
+
* @param options - The definition's path, and the standalone page.
|
|
849
|
+
*/
|
|
850
|
+
function product(options = {}) {
|
|
851
|
+
const state = {
|
|
852
|
+
changed: /* @__PURE__ */ new Set(),
|
|
853
|
+
changes: 0,
|
|
854
|
+
command: "serve",
|
|
855
|
+
files: /* @__PURE__ */ new Set(),
|
|
856
|
+
following: Promise.resolve(),
|
|
857
|
+
packages: {},
|
|
858
|
+
root: process.cwd()
|
|
859
|
+
};
|
|
860
|
+
const page = options.standalone;
|
|
861
|
+
const generated = page !== void 0 && options.definition === void 0;
|
|
862
|
+
const definition = options.definition ?? (generated ? "node_modules/.stealth/standalone/product.ts" : DEFINITION);
|
|
863
|
+
return {
|
|
864
|
+
/**
|
|
865
|
+
* Composes the product, and reports its warnings and its problems.
|
|
866
|
+
*
|
|
867
|
+
* @throws {@link Error} When the build's product does not resolve.
|
|
868
|
+
*/
|
|
869
|
+
async buildStart() {
|
|
870
|
+
applied(state, await composed(state, definition), (message) => {
|
|
871
|
+
this.warn(message);
|
|
872
|
+
});
|
|
873
|
+
for (const file of state.files) this.addWatchFile(file);
|
|
874
|
+
if (state.failure !== void 0 && state.command === "build") throw new Error(state.failure);
|
|
875
|
+
},
|
|
876
|
+
/**
|
|
877
|
+
* Closes the importer the compositions share, at the end of a build and when a dev server
|
|
878
|
+
* closes.
|
|
879
|
+
*/
|
|
880
|
+
closeBundle() {
|
|
881
|
+
return closed(state);
|
|
882
|
+
},
|
|
883
|
+
/**
|
|
884
|
+
* Adds the group that builds each installed plugin's lazy modules into a chunk of its own.
|
|
885
|
+
*/
|
|
886
|
+
config() {
|
|
887
|
+
return chunked(state);
|
|
888
|
+
},
|
|
889
|
+
/**
|
|
890
|
+
* Records the project root, the command, the conditions and the catalogue plugin's api, and
|
|
891
|
+
* writes the generated definition of a standalone page.
|
|
892
|
+
*/
|
|
893
|
+
configResolved(config) {
|
|
894
|
+
state.command = config.command;
|
|
895
|
+
state.conditions = config.ssr.resolve?.conditions;
|
|
896
|
+
state.root = config.root;
|
|
897
|
+
state.words = cataloguesOf(config.plugins);
|
|
898
|
+
if (page !== void 0 && options.definition === void 0) generate(config.root, page);
|
|
899
|
+
},
|
|
900
|
+
/**
|
|
901
|
+
* Writes the access, flag and operation catalogues into the client build's output, and nothing
|
|
902
|
+
* under a dev server or for another environment.
|
|
903
|
+
*
|
|
904
|
+
* @throws {@link Error} When a catalogue cannot be read for a description.
|
|
905
|
+
*/
|
|
906
|
+
generateBundle() {
|
|
907
|
+
for (const file of catalogueFiles(state, this.environment.config.consumer)) this.emitFile(file);
|
|
908
|
+
},
|
|
909
|
+
/**
|
|
910
|
+
* Brings a page served one module per file up to date after a change to a file the last
|
|
911
|
+
* composition read, and leaves every other change to the server.
|
|
912
|
+
*/
|
|
913
|
+
hotUpdate({ file }) {
|
|
914
|
+
return state.files.has(normalizePath(file)) ? updated(state, this) : void 0;
|
|
915
|
+
},
|
|
916
|
+
/**
|
|
917
|
+
* Serves `virtual:product`, and the standalone page's document, entry and definition.
|
|
918
|
+
*
|
|
919
|
+
* @throws {@link Error} When the product did not resolve, with every problem.
|
|
920
|
+
*/
|
|
921
|
+
load(id) {
|
|
922
|
+
if (id === RESOLVED) return served(state, importedOf(state, definition, generated), this);
|
|
923
|
+
return page === void 0 ? void 0 : pageSourceOf(state.root, page, id);
|
|
924
|
+
},
|
|
925
|
+
name: "stealth:product",
|
|
926
|
+
/**
|
|
927
|
+
* Claims `virtual:product`, and the standalone page's document, entry and definition, leaving
|
|
928
|
+
* every other import alone.
|
|
929
|
+
*/
|
|
930
|
+
resolveId(id) {
|
|
931
|
+
if (id === "virtual:product") return RESOLVED;
|
|
932
|
+
return page === void 0 ? void 0 : pageIdOf(state.root, id);
|
|
933
|
+
},
|
|
934
|
+
/**
|
|
935
|
+
* Follows a change to a file the last composition read, which the next composition transforms
|
|
936
|
+
* again, and leaves every other change alone.
|
|
937
|
+
*/
|
|
938
|
+
async watchChange(id) {
|
|
939
|
+
const file = normalizePath(id);
|
|
940
|
+
if (!state.files.has(file)) return;
|
|
941
|
+
state.changed.add(file);
|
|
942
|
+
await followed(state, definition, this);
|
|
943
|
+
}
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
//#endregion
|
|
947
|
+
export { ID, product };
|
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stealthscale/vite-plugin-product",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Composes a product from its plugins while it builds: checks every plugin against the others, resolves the product, and serves it as virtual:product.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"plugins",
|
|
7
|
+
"vite-plugin"
|
|
8
|
+
],
|
|
9
|
+
"homepage": "https://github.com/stealth-scale/scale/tree/main/packages/vite-plugin-product#readme",
|
|
10
|
+
"bugs": "https://github.com/stealth-scale/scale/issues",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/stealth-scale/scale.git",
|
|
15
|
+
"directory": "packages/vite-plugin-product"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"client.d.ts",
|
|
19
|
+
"dist",
|
|
20
|
+
"LICENSE",
|
|
21
|
+
"README.md"
|
|
22
|
+
],
|
|
23
|
+
"type": "module",
|
|
24
|
+
"sideEffects": false,
|
|
25
|
+
"imports": {
|
|
26
|
+
"#*": "./src/*"
|
|
27
|
+
},
|
|
28
|
+
"exports": {
|
|
29
|
+
".": "./dist/index.mjs",
|
|
30
|
+
"./package.json": "./package.json",
|
|
31
|
+
"./client": "./client.d.ts"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@tanstack/hotkeys": "0.10.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@arethetypeswrong/core": "^0.18",
|
|
41
|
+
"@stealthscale/sdk-core": "^0.1.0",
|
|
42
|
+
"@stealthscale/testing": "^0.3.0",
|
|
43
|
+
"@stealthscale/testing-config": "^0.5.0",
|
|
44
|
+
"@stealthscale/vite-config": "^0.6.0",
|
|
45
|
+
"@stealthscale/vite-config-typescript": "^0.3.0",
|
|
46
|
+
"@stealthscale/vite-plugin-base": "^0.2.0",
|
|
47
|
+
"@stealthscale/vite-plugin-i18n": "^0.1.0",
|
|
48
|
+
"@types/node": "^26.6",
|
|
49
|
+
"@vitest/coverage-v8": "5.0.1",
|
|
50
|
+
"publint": "^0.3",
|
|
51
|
+
"typescript": "^7.0",
|
|
52
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@1.0.0",
|
|
53
|
+
"vitest": "5.0.1"
|
|
54
|
+
},
|
|
55
|
+
"peerDependencies": {
|
|
56
|
+
"@stealthscale/sdk-core": "^0.1.0",
|
|
57
|
+
"@stealthscale/vite-plugin-base": "^0.2.0",
|
|
58
|
+
"@stealthscale/vite-plugin-i18n": "^0.1.0",
|
|
59
|
+
"vite": "^8.3"
|
|
60
|
+
},
|
|
61
|
+
"engines": {
|
|
62
|
+
"node": ">=26.0.0"
|
|
63
|
+
},
|
|
64
|
+
"scripts": {
|
|
65
|
+
"build": "vp pack",
|
|
66
|
+
"check": "vp check",
|
|
67
|
+
"test": "vp test"
|
|
68
|
+
}
|
|
69
|
+
}
|