jsswift 2.0.0 → 2.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/AI.md +38 -73
- package/README.md +2 -0
- package/dist/build/build-configured-runtime.d.mts +29 -0
- package/dist/build/build-configured-runtime.mjs +92 -0
- package/dist/build/runtime-layout.mjs +13 -0
- package/dist/build/select-ui.mjs +167 -0
- package/dist/build/vite-jss.d.mts +2 -0
- package/dist/build/vite-jss.mjs +73 -0
- package/dist/cms.js +3522 -0
- package/dist/documentions/AI.md +38 -73
- package/dist/documentions/README.md +4 -2
- package/dist/documentions/components/table.md +13 -0
- package/dist/documentions/configured-runtime.md +82 -0
- package/dist/documentions/examples/crud.md +18 -7
- package/dist/documentions/getting-started.md +2 -0
- package/dist/documentions/knowledge-map.json +20 -1
- package/dist/documentions/ui/data-feedback-overlays.md +2 -0
- package/dist/documentions/ui/forms.md +2 -0
- package/dist/ui-src/00-bootstrap.js +330 -0
- package/dist/ui-src/01-foundation-helpers.js +809 -0
- package/dist/ui-src/10-primitives-layout.js +5091 -0
- package/dist/ui-src/20-display-content.js +780 -0
- package/dist/ui-src/21-list-content.js +246 -0
- package/dist/ui-src/22-banner-content.js +376 -0
- package/dist/ui-src/23-tooltip.js +276 -0
- package/dist/ui-src/31-form-advanced.js +2784 -0
- package/dist/ui-src/40-navigation.js +1262 -0
- package/dist/ui-src/41-tab-panel.js +896 -0
- package/dist/ui-src/50-feedback.js +999 -0
- package/dist/ui-src/51-feedback-service.js +166 -0
- package/dist/ui-src/60-shell-app.js +1223 -0
- package/dist/ui-src/61-form-service.js +368 -0
- package/dist/ui-src/70-dialog.js +333 -0
- package/dist/ui-src/71-menu-overlays.js +1217 -0
- package/dist/ui-src/80-data.js +437 -0
- package/dist/ui-src/modules.json +212 -0
- package/dist/ui.js +12565 -0
- package/package.json +26 -2
package/AI.md
CHANGED
|
@@ -16,6 +16,7 @@ The installed package exposes this same guide as `AI.md` at its root and through
|
|
|
16
16
|
| --- | --- | --- |
|
|
17
17
|
| Small page with the six components below | This guide | Implement the supported subset without opening component contracts. |
|
|
18
18
|
| Product CRUD with validation, persistence and focus | [CRUD example](examples/crud.md) | Reuse the example; do not also concatenate Input, Select, Dialog and Table contracts. |
|
|
19
|
+
| jss.config.js, selective runtime, Vite plugin or optional extras | [Configured runtime](configured-runtime.md) (`guide:configured-runtime`) | Follow the existing app configuration; do not also import the complete runtime/CSS. |
|
|
19
20
|
| One missing prop or callback | Map → exact document, or selective MCP read | Read only the needed section; do not search the runtime bundle. |
|
|
20
21
|
| Custom appearance | Supported props below, then one relevant contract | Keep component styles; add CSS for page layout and task-specific visuals. |
|
|
21
22
|
|
|
@@ -23,52 +24,34 @@ The map locates a contract; it does not replace it. Avoid reading all of `ui/com
|
|
|
23
24
|
|
|
24
25
|
## Import, create, mount
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
For an existing app, preserve its loading method. With the configured Vite plugin, import `jss:runtime` instead of the two traditional imports below: it includes the configured JavaScript, CSS and extras. Read [configured-runtime.md](configured-runtime.md) for setup and package availability; older installed releases may not expose this tooling.
|
|
28
|
+
|
|
29
|
+
With Vite, provide `<div id="app"></div>` and a module entrypoint:
|
|
27
30
|
|
|
28
31
|
```js
|
|
29
32
|
import 'jsswift';
|
|
30
33
|
import 'jsswift/css/ui-light.css';
|
|
31
34
|
const _ = window._;
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
if (!name.value.trim()) {
|
|
48
|
-
errors.replaceChildren(_.p('Il nome è obbligatorio.'));
|
|
49
|
-
return;
|
|
50
|
-
}
|
|
51
|
-
errors.replaceChildren();
|
|
52
|
-
console.log({ name: name.value, category: category.value });
|
|
53
|
-
dialog.close();
|
|
54
|
-
}}, 'Salva');
|
|
55
|
-
dialog.update({ actions: [
|
|
56
|
-
_.Button({ onClick: () => dialog.close() }, 'Annulla'), save
|
|
57
|
-
] });
|
|
58
|
-
const edit = _.Button({ onClick: () => dialog.open() }, 'Modifica');
|
|
59
|
-
const table = _.Table({
|
|
60
|
-
rows: [{ id: 1, name: 'Prodotto 01', price: 10 }], rowKey: 'id',
|
|
61
|
-
columns: [
|
|
62
|
-
{ key: 'name', label: 'Nome', render: (row, { value }) => _.strong(value) },
|
|
63
|
-
{ key: 'price', label: 'Prezzo', format: value => `${value} EUR` }
|
|
64
|
-
],
|
|
65
|
-
pagination: false, hideFooter: true, hideStatus: true, minTableWidth: 700
|
|
66
|
-
});
|
|
67
|
-
const page = _.Page({}, _.div({}, _.h1('Prodotti'), edit, table));
|
|
68
|
-
_.mount('#app', page);
|
|
35
|
+
const name = _.rod('Prodotto 01'), category = _.rod('Casa');
|
|
36
|
+
const input = _.Input({label:'Nome', required:true, model:name});
|
|
37
|
+
const select = _.Select({label:'Categoria', model:category,
|
|
38
|
+
options:[{value:'Casa',label:'Casa'},{value:'Ufficio',label:'Ufficio'}]});
|
|
39
|
+
const dialog = _.Dialog({title:'Prodotto', ariaLabel:'Prodotto',
|
|
40
|
+
content:_.div({}, input, select), trapFocus:true, closeOnEsc:true,
|
|
41
|
+
onOpen:() => input.focus()});
|
|
42
|
+
dialog.update({actions:[_.Button({onClick:() => {
|
|
43
|
+
console.log({name:name.value, category:category.value}); dialog.close();
|
|
44
|
+
}}, 'Salva')]});
|
|
45
|
+
const table = _.Table({rows:[{id:1,name:'Prodotto 01'}], rowKey:'id',
|
|
46
|
+
columns:[{key:'name',label:'Nome',sortable:false,render:(row,ctx) => _.strong(ctx.value)}],
|
|
47
|
+
pagination:false, hideFooter:true, hideStatus:true, minTableWidth:600});
|
|
48
|
+
_.mount('#app', _.div({}, _.Card({title:'Prodotti'},
|
|
49
|
+
_.Button({color:'primary',onClick:() => dialog.open()}, 'Modifica')), table));
|
|
69
50
|
```
|
|
70
51
|
|
|
71
|
-
This
|
|
52
|
+
This logs values; it does not implement validation or persistence. For product editing use [the executable CRUD example](examples/crud.md) instead of reading forms, overlays or component contracts first.
|
|
53
|
+
|
|
54
|
+
Prefer `_.rod(initial)` for bidirectional fields: read/write `model.value`. `_.signal(initial)` remains supported. Input/Select expose `focus(options?)`; avoid private `_input`/`_select`. Use `label`, `required`, `ariaLabel`, `ariaLabelledBy`, `ariaDescribedBy`, `ariaInvalid`, and `error: _.rod('')`. Input accepts `min`, `max`, `step`, `pattern`, `minLength`, `maxLength`.
|
|
72
55
|
|
|
73
56
|
## Exact callback arguments
|
|
74
57
|
|
|
@@ -87,67 +70,49 @@ Dialog returns an API object, not a node to mount: `open(overrides?)`, `close()`
|
|
|
87
70
|
|
|
88
71
|
## Existing CSS and optional assets
|
|
89
72
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
The UI stylesheet includes component styles, theme variables, and responsive rules. Start with native components. Prefer documented `color`, `dense`, `padding`, `radius`, `shadow`, and breakpoint props to overrides of internal `.jss-*` classes. Read [common props](ui/common-props.md) only for an additional inherited prop; component-specific definitions take precedence. Use custom CSS for remaining page requirements.
|
|
73
|
+
The light import includes base tokens, colors, component styles, dense/outline and unprefixed layout utilities. Add `jsswift/css/responsive.css` **after** it for tablet/pc/desktop or legacy breakpoint classes. `ui.css` is the complete alternative; do not import full and light together. Component-specific mobile rules remain in light.
|
|
93
74
|
|
|
94
|
-
|
|
75
|
+
Prefer public props to rebuilding controls: `_.Button({color:'primary',outline:true,dense:true}, 'Modifica')`. For breakpoint props use `_.Row({gap:'md',tablet:{gap:'lg'}})` with the responsive addon. Consult common props only for an additional inherited API.
|
|
95
76
|
|
|
96
|
-
|
|
77
|
+
Classes and theme variables use `jss-`; migrate old `cms-` overrides. Fonts are system defaults. Import `jsswift/css/fonts-nunito.css`, `fonts-material.css`, or `jsswift/tabler` only when required; Material Symbols downloads a remote font.
|
|
97
78
|
|
|
98
|
-
|
|
79
|
+
With `jss:runtime`, choose assets in `jss.config.js` through `extras` instead of adding these imports. `ui: {all:true}` includes the complete UI and services. For a selective build, use `ui: {components:['Button','Input','Select','Card','Table'],services:['Dialog']}` for the example above; dependencies are automatic. Services also support Notify, Loading and Form (useForm). Add every component used through dynamic lookup explicitly. `responsive:false` retains base utilities and component mobile CSS but excludes breakpoint utilities; use true for tablet/pc props. Shared and complex CSS rules are retained conservatively.
|
|
99
80
|
|
|
100
81
|
## Accessibility and verification
|
|
101
82
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
Run the app's build after implementation and check values, validation, dialog keyboard behavior, and persistence when required. Start one development server on an explicit available port; with Vite use `npm run dev -- --port <port> --strictPort`. Report checks actually performed.
|
|
105
|
-
|
|
106
|
-
## Read only what the task needs
|
|
107
|
-
|
|
108
|
-
- Exact APIs: [Input](components/input.md), [Select](components/select.md), [Button](components/btn.md), [Card](components/card.md), [Table](components/table.md), [Dialog](components/dialog.md).
|
|
109
|
-
- Extra behavior: [forms](ui/forms.md), [reactivity](reactivity.md), [layout](ui/layout-and-responsive.md), [overlays](ui/data-feedback-overlays.md).
|
|
110
|
-
- Other components: [index](components/README.md). MCP: read `guide:AI`, then search only for additional features.
|
|
83
|
+
String labels name Input/Select; set Dialog `ariaLabel`. Field errors are associated automatically. Dialog preserves focus assigned in `onOpen`. If rendering replaces the trigger, restore its current replacement in `onClose`; see CRUD.
|
|
111
84
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
## Verification evidence and environment
|
|
115
|
-
|
|
116
|
-
Check whether browser tooling and server permissions are available before launching checks. Run Git commands only in a Git worktree; for example test `git rev-parse --is-inside-work-tree` before requesting a diff. Keep build, pure-logic tests and server startup results separate. An HTTP response confirms a reachable server, not that your startup command succeeded or that browser interactions work. Report environment failures separately from application failures. Test the implemented functions rather than rebuilding equivalent data inside an assertion.
|
|
117
|
-
|
|
118
|
-
Dialog autofocus preserves focus assigned inside `onOpen`; verify initial focus after the deferred autofocus has run, separately from validation focus and return focus. Record each check separately: initial field, Tab/Shift+Tab containment, Escape, and return to the current trigger. A focus-trap result does not prove initial focus. Field messages are associated with Input/Select through `aria-describedby` automatically.
|
|
119
|
-
|
|
120
|
-
## Focus CSS and targeted reads
|
|
121
|
-
|
|
122
|
-
Apply focus indicators to every selector through `:is`, rather than placing the pseudo-class on only the final selector in a comma-separated list:
|
|
85
|
+
Build is not a browser test. Verify initial focus after deferred autofocus, Tab/Shift+Tab containment, Escape, return focus, validation, persistence, mobile overflow and borders separately. Report unverified checks. Use this custom focus CSS:
|
|
123
86
|
|
|
124
87
|
```css
|
|
125
|
-
:is(button,
|
|
88
|
+
:is(button,a,input,select,[tabindex]):focus-visible {
|
|
126
89
|
outline: 3px solid #2563eb;
|
|
127
90
|
outline-offset: 3px;
|
|
128
91
|
}
|
|
129
92
|
```
|
|
130
93
|
|
|
131
|
-
|
|
94
|
+
Keep build, logic assertions and server startup in separate commands. Check tooling/permissions first; use an explicit Vite port with `--strictPort`. Environment failures are not application failures; HTTP alone does not verify interactions.
|
|
95
|
+
|
|
96
|
+
## Additional APIs only
|
|
132
97
|
|
|
98
|
+
Use the map for the missing API, then read its focused contract. Button is `components/btn.md` (`button.md` redirects). With configured MCP: `jsswift_read({id:"component:table",section:"props",keys:["pagination","hideFooter"]})`; sections include props/events/methods/slots and resolve inherited props. No MCP setup is required for local documentation.
|
|
133
99
|
|
|
134
100
|
## Catalogue surfaces and field styling
|
|
135
101
|
|
|
136
|
-
|
|
102
|
+
`_.Kpi({title:'Disponibili',value:9,note:'Con quantità maggiore di zero'})` supplies dashboard metrics. Update KPI when inventory changes, not on each filter; see CRUD. Read the Kpi contract only for additional options.
|
|
137
103
|
|
|
138
|
-
Table
|
|
104
|
+
Table returns a Card; use a structural container. Card props include `shadow:'none'`, `radius:'12px'`, and `padding`. For a fixed light surface: `style:{background:'#fff'}`, `tableStyle:{background:'#fff','--set-color-1':'#fff'}`. This does not provide automatic dark theming.
|
|
139
105
|
|
|
140
|
-
|
|
106
|
+
For external pagination use `pagination:false`, `hideFooter:true`, `hideStatus:true`, `emptyText:'Nessun prodotto trovato'`, `minTableWidth:600`. Set each data column's `sortable:false`: built-in sorting sees only the supplied page. Sort the full filtered dataset before slicing if needed.
|
|
141
107
|
|
|
142
|
-
|
|
108
|
+
Input's native input is transparent; preserve that background to keep the outer curved border visible. Set height on an application container:
|
|
143
109
|
|
|
144
110
|
```css
|
|
145
111
|
.catalogue-filters {
|
|
146
112
|
--jss-input-height-default: 40px;
|
|
147
113
|
}
|
|
148
114
|
```
|
|
149
|
-
|
|
150
|
-
Check the border, floating label, error message and keyboard focus at desktop and mobile sizes. A passing CRUD test does not verify these visual states. Keep failed server startup, retry incidents, cached input and uncached input separate when comparing repeated benchmark runs.
|
|
115
|
+
Use the field's `radius` prop if it should not inherit a surface radius. Avoid clipping controls/focus indicators with overflow:hidden. Check borders, labels, errors and focus at desktop/mobile sizes.
|
|
151
116
|
|
|
152
117
|
## Theme and lifecycle essentials
|
|
153
118
|
|
package/README.md
CHANGED
|
@@ -5,3 +5,5 @@
|
|
|
5
5
|
For an API absent from that guide, use [knowledge-map.json](dist/documentions/knowledge-map.json) to locate the exact document. Read only the relevant contract; avoid concatenating the component catalog or searching runtime bundles for documentation.
|
|
6
6
|
|
|
7
7
|
Import `jsswift` and `jsswift/css/ui-light.css`; add `jsswift/css/responsive.css` when using breakpoint props. `ui.css` remains the complete alternative. Component CSS is included; fonts and Tabler are optional. The read-only `@jsswift/knowledge-mcp` is an optional discovery interface when configured in your AI client.
|
|
8
|
+
|
|
9
|
+
For configured builds, see [configured-runtime.md](dist/documentions/configured-runtime.md). Import `jssRuntime` from `jsswift/vite` in Vite config and `jss:runtime` in the application entry. Or use `npx jss-build` to generate standalone assets. Set `ui.all: true` for the complete UI, or list components/services for a selective build. Tooling requires Node.js 20.19.x or >=22.12.0; Vite is optional for standalone builds.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface RuntimeConfig {
|
|
2
|
+
ui?: {
|
|
3
|
+
all?: boolean;
|
|
4
|
+
responsive?: boolean;
|
|
5
|
+
components?: string[];
|
|
6
|
+
services?: Array<'Dialog' | 'Notify' | 'Loading' | 'Form'>;
|
|
7
|
+
};
|
|
8
|
+
extras?: Array<'material-symbols-outlined' | 'nunito-font' | 'tabler'>;
|
|
9
|
+
}
|
|
10
|
+
export interface NormalizedRuntimeConfig {
|
|
11
|
+
ui: { all: boolean; responsive: boolean; components: string[]; services: string[] };
|
|
12
|
+
extras: string[];
|
|
13
|
+
}
|
|
14
|
+
export interface RuntimeBuildReport {
|
|
15
|
+
config: NormalizedRuntimeConfig;
|
|
16
|
+
javascript: string[];
|
|
17
|
+
css: string[];
|
|
18
|
+
extras: string[];
|
|
19
|
+
selectiveComponents: boolean;
|
|
20
|
+
components: string[] | 'all';
|
|
21
|
+
services: string[] | 'all';
|
|
22
|
+
reasons: Record<string, string>;
|
|
23
|
+
removedSelectors: number;
|
|
24
|
+
runtimeBytes: number;
|
|
25
|
+
cssBytes: number;
|
|
26
|
+
}
|
|
27
|
+
export declare const extras: Record<string, string | null>;
|
|
28
|
+
export declare function validateConfig(config: RuntimeConfig): NormalizedRuntimeConfig;
|
|
29
|
+
export declare function buildConfiguredRuntime(config: RuntimeConfig, output: string, sourceRoot?: string): Promise<RuntimeBuildReport>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
5
|
+
import { transform } from 'esbuild';
|
|
6
|
+
import { selectUI, selectComponentCSS } from './select-ui.mjs';
|
|
7
|
+
import { runtimeLayout } from './runtime-layout.mjs';
|
|
8
|
+
|
|
9
|
+
const root = fileURLToPath(new URL('../', import.meta.url));
|
|
10
|
+
export const extras = {
|
|
11
|
+
'material-symbols-outlined': 'fonts-material.css',
|
|
12
|
+
'nunito-font': 'fonts-nunito.css',
|
|
13
|
+
tabler: null,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function validateConfig(config) {
|
|
17
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) throw new Error('Expected a configuration object');
|
|
18
|
+
for (const key of Object.keys(config)) if (!['ui', 'extras'].includes(key)) throw new Error(`Unknown config option: ${key}`);
|
|
19
|
+
const ui = config.ui ?? { all: true };
|
|
20
|
+
if (!ui || typeof ui !== 'object' || Array.isArray(ui)) throw new Error('ui must be an object');
|
|
21
|
+
for (const key of Object.keys(ui)) if (!['all', 'responsive', 'components', 'services'].includes(key)) throw new Error(`Unknown ui option: ${key}`);
|
|
22
|
+
if (ui.all !== undefined && typeof ui.all !== 'boolean') throw new Error('ui.all must be boolean');
|
|
23
|
+
if (ui.responsive !== undefined && typeof ui.responsive !== 'boolean') throw new Error('ui.responsive must be boolean');
|
|
24
|
+
for (const key of ['components', 'services']) {
|
|
25
|
+
if (ui[key] !== undefined && (!Array.isArray(ui[key]) || ui[key].some(name => typeof name !== 'string' || !name))) throw new Error(`ui.${key} must be an array of names`);
|
|
26
|
+
}
|
|
27
|
+
const all = ui.all ?? (ui.components === undefined && ui.services === undefined);
|
|
28
|
+
if (all && (ui.components?.length || ui.services?.length)) throw new Error('Use ui.all: true alone or selective components/services');
|
|
29
|
+
const selected = config.extras ?? [];
|
|
30
|
+
if (!Array.isArray(selected)) throw new Error('extras must be an array');
|
|
31
|
+
for (const name of selected) if (!Object.hasOwn(extras, name)) throw new Error(`Unknown extra: ${name}`);
|
|
32
|
+
return { ui: { all, responsive: ui.responsive ?? true, components: [...new Set(ui.components ?? [])], services: [...new Set(ui.services ?? [])] }, extras: [...new Set(selected)] };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function buildConfiguredRuntime(config, output, sourceRoot = root) {
|
|
36
|
+
const normalized = validateConfig(config);
|
|
37
|
+
const layout = runtimeLayout(sourceRoot);
|
|
38
|
+
const selective = !normalized.ui.all && (normalized.ui.components.length || normalized.ui.services.length);
|
|
39
|
+
const selection = selective ? await selectUI(sourceRoot, normalized.ui.components, normalized.ui.services) : null;
|
|
40
|
+
// A dedicated empty directory prevents stale opt-in assets and accidental overwrite.
|
|
41
|
+
await fs.mkdir(output, { recursive: true });
|
|
42
|
+
if ((await fs.readdir(output)).length) throw new Error('Output directory must be empty; choose a new application output directory');
|
|
43
|
+
const inputs = [path.relative(sourceRoot, layout.core)];
|
|
44
|
+
if (normalized.ui.all) inputs.push(path.relative(sourceRoot, layout.ui));
|
|
45
|
+
const chunks = await Promise.all(inputs.map(file => fs.readFile(path.join(sourceRoot, file), 'utf8')));
|
|
46
|
+
if (selection) { chunks.push(selection.code); inputs.push(...selection.sources); }
|
|
47
|
+
if (normalized.extras.includes('tabler')) {
|
|
48
|
+
chunks.push('JSswift.config ||= {}; JSswift.config.iconSpriteUrl = new URL("./img/svg/tabler-icons-sprite.svg", import.meta.url).href;');
|
|
49
|
+
await fs.mkdir(path.join(output, 'img/svg'), { recursive: true });
|
|
50
|
+
await fs.copyFile(path.join(layout.assets, 'img/svg/tabler-icons-sprite.svg'), path.join(output, 'img/svg/tabler-icons-sprite.svg'));
|
|
51
|
+
}
|
|
52
|
+
const runtime = await transform(chunks.join('\n'), { minify: true, target: 'es2018', format: 'esm' });
|
|
53
|
+
await fs.writeFile(path.join(output, 'runtime.js'), runtime.code);
|
|
54
|
+
const css = [];
|
|
55
|
+
if (normalized.ui.all) css.push(normalized.ui.responsive ? 'ui.css' : 'ui-light.css');
|
|
56
|
+
else if (selection) css.push('base.css', 'animation.css', 'ui-components.css', normalized.ui.responsive ? 'responsive.css' : 'responsive-base.css');
|
|
57
|
+
else css.push('base.css');
|
|
58
|
+
for (const name of normalized.extras) if (extras[name]) css.push(extras[name]);
|
|
59
|
+
await fs.mkdir(path.join(output, 'css'), { recursive: true });
|
|
60
|
+
let removedSelectors = 0;
|
|
61
|
+
const cssChunks = await Promise.all(css.map(async file => {
|
|
62
|
+
const content = await fs.readFile(path.join(layout.assets, 'css', file), 'utf8');
|
|
63
|
+
if (selection && file === 'ui-components.css') {
|
|
64
|
+
const result = selectComponentCSS(content, selection.components, selection.services);
|
|
65
|
+
removedSelectors = result.removedSelectors;
|
|
66
|
+
return result.css;
|
|
67
|
+
}
|
|
68
|
+
return content;
|
|
69
|
+
}));
|
|
70
|
+
const stylesheet = cssChunks.join('\n');
|
|
71
|
+
await fs.writeFile(path.join(output, 'css/runtime.css'), stylesheet);
|
|
72
|
+
if (normalized.extras.includes('nunito-font')) await fs.cp(path.join(layout.assets, 'fonts'), path.join(output, 'fonts'), { recursive: true });
|
|
73
|
+
const report = { config: normalized, javascript: inputs, css, extras: normalized.extras, selectiveComponents: !!selection, components: selection?.components ?? (normalized.ui.all ? 'all' : []), services: selection?.services ?? (normalized.ui.all ? 'all' : []), reasons: selection?.reasons ?? {}, removedSelectors, runtimeBytes: Buffer.byteLength(runtime.code), cssBytes: Buffer.byteLength(stylesheet) };
|
|
74
|
+
await fs.writeFile(path.join(output, 'build-report.json'), JSON.stringify(report, null, 2) + '\n');
|
|
75
|
+
return report;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// npm binaries are symlinks; compare the real entrypoint rather than .bin path.
|
|
79
|
+
const invokedPath = process.argv[1] ? await fs.realpath(process.argv[1]).catch(() => null) : null;
|
|
80
|
+
if (invokedPath === fileURLToPath(import.meta.url)) {
|
|
81
|
+
try {
|
|
82
|
+
if (process.argv[2] === '--help' || process.argv[2] === '-h') {
|
|
83
|
+
console.log('Usage: jss-build [config-path] [output-directory]\nDefaults: jss.config.js and dist-runtime (must be empty)');
|
|
84
|
+
} else {
|
|
85
|
+
const configPath = path.resolve(process.argv[2] ?? 'jss.config.js');
|
|
86
|
+
const output = path.resolve(process.argv[3] ?? 'dist-runtime');
|
|
87
|
+
const config = (await import(pathToFileURL(configPath).href)).default;
|
|
88
|
+
const report = await buildConfiguredRuntime(config, output);
|
|
89
|
+
console.log(`Configured runtime: ${output} (${report.runtimeBytes} JS bytes); report: build-report.json`);
|
|
90
|
+
}
|
|
91
|
+
} catch (error) { console.error(error.message); process.exitCode = 1; }
|
|
92
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
// The same tooling runs from the checkout and from jsswift/dist/build.
|
|
5
|
+
export function runtimeLayout(root) {
|
|
6
|
+
const checkout = existsSync(path.join(root, 'packages/ui/src/modules.json'));
|
|
7
|
+
return {
|
|
8
|
+
core: path.join(root, checkout ? 'packages/core/dist/cms.js' : 'cms.js'),
|
|
9
|
+
ui: path.join(root, checkout ? 'packages/ui/dist/ui.js' : 'ui.js'),
|
|
10
|
+
assets: path.join(root, checkout ? 'packages/ui/dist' : '.'),
|
|
11
|
+
source: path.join(root, checkout ? 'packages/ui/src' : 'ui-src'),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parseAst } from 'rolldown/parseAst';
|
|
4
|
+
import postcss from 'postcss';
|
|
5
|
+
import { runtimeLayout } from './runtime-layout.mjs';
|
|
6
|
+
|
|
7
|
+
const services = {
|
|
8
|
+
Notify: ['UI.Notify', 'JSswift.services.notify', 'JSswift.notify'],
|
|
9
|
+
Dialog: ['UI.Dialog', 'JSswift.dialog'],
|
|
10
|
+
Loading: ['JSswift.loading'],
|
|
11
|
+
Form: ['JSswift.useForm'],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
function member(node) {
|
|
15
|
+
if (node?.type === 'ChainExpression') return member(node.expression);
|
|
16
|
+
if (node?.type === 'Identifier') return node.name;
|
|
17
|
+
if (node?.type !== 'MemberExpression') return null;
|
|
18
|
+
const object = member(node.object);
|
|
19
|
+
const property = node.computed ? (node.property.type === 'Literal' ? node.property.value : null) : node.property.name;
|
|
20
|
+
if (!object || typeof property !== 'string') return null;
|
|
21
|
+
return `${object}.${property}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function walk(node, visit, parent = null, key = null) {
|
|
25
|
+
if (!node || typeof node !== 'object') return;
|
|
26
|
+
if (node.type) visit(node, parent, key);
|
|
27
|
+
for (const [name, value] of Object.entries(node)) {
|
|
28
|
+
if (['start', 'end', 'loc', 'range'].includes(name)) continue;
|
|
29
|
+
if (Array.isArray(value)) for (const child of value) walk(child, visit, node, name);
|
|
30
|
+
else if (value && typeof value === 'object') walk(value, visit, node, name);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function canonical(name, components) {
|
|
35
|
+
name = name.replace(/^app\./, 'JSswift.');
|
|
36
|
+
// UI aliases exist on both public namespaces. Slot helpers are separate.
|
|
37
|
+
const match = /^(?:UI|JSswift\.ui|_)\.([^.]+)(.*)$/.exec(name);
|
|
38
|
+
if (match && components.has(match[1])) return `UI.${match[1] === 'Button' ? 'Btn' : match[1]}${match[2]}`;
|
|
39
|
+
return name;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function selectUI(root, requested, requestedServices) {
|
|
43
|
+
const directory = runtimeLayout(root).source;
|
|
44
|
+
const manifest = JSON.parse(await fs.readFile(path.join(directory, 'modules.json'), 'utf8'));
|
|
45
|
+
const files = await Promise.all(manifest.buildOrder.map(async file => ({ file, text: await fs.readFile(path.join(directory, file), 'utf8') })));
|
|
46
|
+
let offset = 0;
|
|
47
|
+
for (const file of files) { file.start = offset; offset += file.text.length + 1; file.end = offset; }
|
|
48
|
+
const source = files.map(file => file.text).join('\n');
|
|
49
|
+
const program = parseAst(source);
|
|
50
|
+
const call = program.body[0]?.expression;
|
|
51
|
+
if (program.body.length !== 1 || call?.type !== 'CallExpression' || !call.callee.body?.body) throw new Error('Unexpected UI module structure; update dependency extraction');
|
|
52
|
+
const nodes = call.callee.body.body;
|
|
53
|
+
const components = new Set();
|
|
54
|
+
for (const node of nodes) {
|
|
55
|
+
const lhs = node.expression?.type === 'AssignmentExpression' ? member(node.expression.left) : null;
|
|
56
|
+
if (lhs && /^UI\.[^.]+$/.test(lhs)) components.add(lhs.slice(3));
|
|
57
|
+
}
|
|
58
|
+
for (const name of requested) if (!components.has(name)) throw new Error(`Unknown UI component: ${name}`);
|
|
59
|
+
for (const name of requestedServices) if (!Object.hasOwn(services, name)) throw new Error(`Unknown UI service: ${name}`);
|
|
60
|
+
const definitions = new Map();
|
|
61
|
+
const records = nodes.map((node, index) => {
|
|
62
|
+
const file = files.find(file => node.start >= file.start && node.start < file.end).file;
|
|
63
|
+
const defined = [];
|
|
64
|
+
if (node.type === 'VariableDeclaration') for (const declaration of node.declarations) {
|
|
65
|
+
walk(declaration.id, child => { if (child.type === 'Identifier') defined.push(child.name); });
|
|
66
|
+
}
|
|
67
|
+
if (node.type === 'FunctionDeclaration') defined.push(node.id.name);
|
|
68
|
+
if (node.expression?.type === 'AssignmentExpression') {
|
|
69
|
+
const lhs = member(node.expression.left);
|
|
70
|
+
if (!lhs) throw new Error(`Unsupported UI assignment in ${file}`);
|
|
71
|
+
defined.push(canonical(lhs, components));
|
|
72
|
+
}
|
|
73
|
+
for (const name of defined) {
|
|
74
|
+
if (!definitions.has(name)) definitions.set(name, []);
|
|
75
|
+
definitions.get(name).push(index);
|
|
76
|
+
}
|
|
77
|
+
const references = new Set();
|
|
78
|
+
walk(node, (child, parent, key) => {
|
|
79
|
+
if (child.type === 'MemberExpression') {
|
|
80
|
+
const name = member(child);
|
|
81
|
+
if (name) references.add(canonical(name, components));
|
|
82
|
+
}
|
|
83
|
+
if (child.type === 'Identifier') {
|
|
84
|
+
if (parent?.type === 'MemberExpression' && key === 'property' && !parent.computed) return;
|
|
85
|
+
if ((parent?.type === 'Property' || parent?.type === 'MethodDefinition') && key === 'key' && !parent.computed) return;
|
|
86
|
+
references.add(child.name);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
return { index, node, file, defined, references };
|
|
90
|
+
});
|
|
91
|
+
const included = new Set();
|
|
92
|
+
const reasons = {};
|
|
93
|
+
const include = (index, reason) => {
|
|
94
|
+
if (included.has(index)) return;
|
|
95
|
+
included.add(index);
|
|
96
|
+
const record = records[index];
|
|
97
|
+
for (const name of record.defined) reasons[name] ??= reason;
|
|
98
|
+
for (const reference of record.references) {
|
|
99
|
+
for (const dependency of definitions.get(reference) ?? []) include(dependency, record.defined[0] ?? `initialization:${record.file}`);
|
|
100
|
+
}
|
|
101
|
+
// A selected component/service includes its public methods and aliases.
|
|
102
|
+
for (const name of record.defined) {
|
|
103
|
+
if (/^UI\.[^.]+$/.test(name) || /^JSswift\.(dialog|loading|notify)$/.test(name) || name === 'JSswift.services.notify') {
|
|
104
|
+
for (const [child, indices] of definitions) if (child.startsWith(name + '.')) for (const dependency of indices) include(dependency, name);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
// Preserve initialization order and rod-path hooks. Helpers otherwise follow references.
|
|
109
|
+
for (const record of records) if ((record.file === '00-bootstrap.js' && !record.defined.includes('JSswift.services.notify')) || record.defined.includes('JSswift.omit')) include(record.index, 'shared UI initialization');
|
|
110
|
+
const roots = [...requested.map(name => `UI.${name}`), ...requestedServices.flatMap(name => services[name])];
|
|
111
|
+
for (const root of roots) for (const index of definitions.get(canonical(root, components)) ?? []) include(index, 'requested');
|
|
112
|
+
const selectedRecords = records.filter(record => included.has(record.index));
|
|
113
|
+
const selectedComponents = [...components].filter(name => definitions.get(canonical(`UI.${name}`, components))?.some(index => included.has(index)));
|
|
114
|
+
const selectedServices = Object.keys(services).filter(name => services[name].every(root => definitions.get(canonical(root, components))?.some(index => included.has(index))));
|
|
115
|
+
return {
|
|
116
|
+
code: `(function initJSswiftUI(app) {\n${selectedRecords.map(record => source.slice(record.node.start, record.node.end)).join('\n')}\n})(JSswift);`,
|
|
117
|
+
components: selectedComponents,
|
|
118
|
+
services: selectedServices,
|
|
119
|
+
sources: [...new Set(selectedRecords.map(record => path.relative(root, path.join(directory, record.file))))],
|
|
120
|
+
reasons,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Only rules with explicit component ownership are removed. Shared tokens,
|
|
125
|
+
// utilities, native styles and unknown selectors are retained conservatively.
|
|
126
|
+
const cssOwners = {
|
|
127
|
+
btn: ['Btn', 'Button'], card: ['Card', 'cardHeader', 'cardBody', 'cardFooter'],
|
|
128
|
+
input: ['Input', 'InputRaw', 'Textarea', 'FormField'], field: ['FormField'],
|
|
129
|
+
textarea: ['Textarea'], select: ['Select'], search: ['Search'], upload: ['Upload'],
|
|
130
|
+
icon: ['Icon'], badge: ['Badge'], avatar: ['Avatar'], chip: ['Chip'],
|
|
131
|
+
list: ['List', 'Item'], item: ['Item'], separator: ['Separator'], tooltip: ['Tooltip'],
|
|
132
|
+
checkbox: ['Checkbox'], radio: ['Radio'], toggle: ['Toggle'], slider: ['Slider'], rating: ['Rating'],
|
|
133
|
+
date: ['Date', 'Calendar', 'Datepicker'], calendar: ['Date', 'Calendar'], time: ['Time'],
|
|
134
|
+
tabs: ['Tabs', 'RouteTab'], tab: ['Tabs', 'RouteTab', 'TabPanel'], breadcrumbs: ['Breadcrumbs'], breadcrumb: ['Breadcrumbs'],
|
|
135
|
+
pagination: ['Pagination'], spinner: ['Spinner'], progress: ['Progress'], 'loading-bar': ['LoadingBar'],
|
|
136
|
+
banner: ['Banner'], alert: ['Alert'], table: ['Table'], menu: ['Menu'], popover: ['Popover'],
|
|
137
|
+
dialog: ['Dialog'], notify: ['Notify'], toast: ['Notify'], loading: ['$Loading'],
|
|
138
|
+
header: ['Header', 'Layout'], drawer: ['Drawer', 'Layout'], footer: ['Footer', 'Layout'],
|
|
139
|
+
page: ['Page', 'Layout'], 'app-shell': ['AppShell'], parallax: ['Parallax'],
|
|
140
|
+
toolbar: ['Toolbar'], grid: ['Grid', 'GridCol'], container: ['Container'], form: ['Form'], kpi: ['Kpi'],
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export function selectComponentCSS(css, components, selectedServices = []) {
|
|
144
|
+
const selected = new Set([...components, ...selectedServices.map(name => `$${name}`)]);
|
|
145
|
+
const prefixes = Object.keys(cssOwners).sort((a, b) => b.length - a.length);
|
|
146
|
+
const ast = postcss.parse(css);
|
|
147
|
+
let removedSelectors = 0;
|
|
148
|
+
ast.walkRules(rule => {
|
|
149
|
+
if (rule.parent.type === 'atrule' && /keyframes$/i.test(rule.parent.name)) return;
|
|
150
|
+
const selectors = postcss.list.comma(rule.selector).filter(selector => {
|
|
151
|
+
// Functional selector lists can mix owners; preserve them rather than
|
|
152
|
+
// risk changing :is/:not/:has semantics without a selector AST.
|
|
153
|
+
if (/:(?:is|where|not|has)\(/.test(selector)) return true;
|
|
154
|
+
const classes = [...selector.matchAll(/\.jss-([a-zA-Z0-9_-]+)/g)].map(match => match[1]);
|
|
155
|
+
const omitted = classes.some(name => {
|
|
156
|
+
const prefix = prefixes.find(prefix => name === prefix || name.startsWith(prefix + '-'));
|
|
157
|
+
return prefix && !cssOwners[prefix].some(owner => selected.has(owner));
|
|
158
|
+
});
|
|
159
|
+
if (omitted) removedSelectors++;
|
|
160
|
+
return !omitted;
|
|
161
|
+
});
|
|
162
|
+
if (selectors.length) rule.selector = selectors.join(',\n');
|
|
163
|
+
else rule.remove();
|
|
164
|
+
});
|
|
165
|
+
ast.walkAtRules(rule => { if (rule.nodes && !rule.nodes.length) rule.remove(); });
|
|
166
|
+
return { css: ast.toString(), removedSelectors };
|
|
167
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
4
|
+
import { validateConfig, extras } from './build-configured-runtime.mjs';
|
|
5
|
+
import { selectUI, selectComponentCSS } from './select-ui.mjs';
|
|
6
|
+
import { runtimeLayout } from './runtime-layout.mjs';
|
|
7
|
+
|
|
8
|
+
const frameworkRoot = fileURLToPath(new URL('../', import.meta.url));
|
|
9
|
+
const runtimeId = '\0jss:runtime';
|
|
10
|
+
const cssId = '\0jss:selected.css';
|
|
11
|
+
|
|
12
|
+
export function jssRuntime(options = {}) {
|
|
13
|
+
const configPath = path.resolve(options.config ?? 'jss.config.js');
|
|
14
|
+
const sourceRoot = options.sourceRoot ?? frameworkRoot;
|
|
15
|
+
const layout = runtimeLayout(sourceRoot);
|
|
16
|
+
let cached;
|
|
17
|
+
let used = false;
|
|
18
|
+
const prepare = () => cached ??= (async () => {
|
|
19
|
+
const config = validateConfig((await import(pathToFileURL(configPath).href + `?mtime=${(await fs.stat(configPath)).mtimeMs}`)).default);
|
|
20
|
+
const selective = !config.ui.all && (config.ui.components.length || config.ui.services.length);
|
|
21
|
+
const selection = selective ? await selectUI(sourceRoot, config.ui.components, config.ui.services) : null;
|
|
22
|
+
return { config, selection };
|
|
23
|
+
})();
|
|
24
|
+
return {
|
|
25
|
+
name: 'jss-configured-runtime',
|
|
26
|
+
resolveId(id) {
|
|
27
|
+
if (id === 'jss:runtime') return runtimeId;
|
|
28
|
+
if (id === 'jss:selected.css') return cssId;
|
|
29
|
+
},
|
|
30
|
+
async load(id) {
|
|
31
|
+
if (![runtimeId, cssId].includes(id)) return;
|
|
32
|
+
this.addWatchFile(configPath);
|
|
33
|
+
const { config, selection } = await prepare();
|
|
34
|
+
const asset = file => path.join(layout.assets, file);
|
|
35
|
+
if (id === cssId) {
|
|
36
|
+
const filename = asset('css/ui-components.css');
|
|
37
|
+
this.addWatchFile(filename);
|
|
38
|
+
return selectComponentCSS(await fs.readFile(filename, 'utf8'), selection.components, selection.services).css;
|
|
39
|
+
}
|
|
40
|
+
used = true;
|
|
41
|
+
const imports = [`import ${JSON.stringify(layout.core)};`];
|
|
42
|
+
const css = config.ui.all ? [config.ui.responsive ? 'ui.css' : 'ui-light.css'] : selection ? ['base.css', 'animation.css'] : ['base.css'];
|
|
43
|
+
for (const file of css) imports.push(`import ${JSON.stringify(asset('css/' + file))};`);
|
|
44
|
+
if (selection) {
|
|
45
|
+
imports.push('import "jss:selected.css";');
|
|
46
|
+
imports.push(`import ${JSON.stringify(asset('css/' + (config.ui.responsive ? 'responsive.css' : 'responsive-base.css')))};`);
|
|
47
|
+
for (const file of selection.sources) this.addWatchFile(path.join(sourceRoot, file));
|
|
48
|
+
} else if (config.ui.all) imports.push(`import ${JSON.stringify(asset('ui.js'))};`);
|
|
49
|
+
for (const extra of config.extras) {
|
|
50
|
+
if (extras[extra]) imports.push(`import ${JSON.stringify(asset('css/' + extras[extra]))};`);
|
|
51
|
+
else if (extra === 'tabler') imports.push(`import spriteUrl from ${JSON.stringify(asset('img/svg/tabler-icons-sprite.svg') + '?url')};`);
|
|
52
|
+
}
|
|
53
|
+
return imports.join('\n') + '\n' + (selection?.code ?? '') + (config.extras.includes('tabler') ? '\nJSswift.config ||= {}; JSswift.config.iconSpriteUrl = spriteUrl;' : '');
|
|
54
|
+
},
|
|
55
|
+
configureServer(server) {
|
|
56
|
+
server.watcher.add(configPath);
|
|
57
|
+
const onChange = file => {
|
|
58
|
+
if (file === configPath || file.startsWith(layout.source + path.sep)) {
|
|
59
|
+
cached = undefined;
|
|
60
|
+
void server.restart();
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
server.watcher.on('change', onChange);
|
|
64
|
+
server.httpServer?.once('close', () => server.watcher.off('change', onChange));
|
|
65
|
+
},
|
|
66
|
+
watchChange() { cached = undefined; },
|
|
67
|
+
async generateBundle() {
|
|
68
|
+
if (!used) return;
|
|
69
|
+
const { config, selection } = await prepare();
|
|
70
|
+
this.emitFile({ type: 'asset', fileName: 'jss-build-report.json', source: JSON.stringify({ config, components: selection?.components ?? (config.ui.all ? 'all' : []), services: selection?.services ?? (config.ui.all ? 'all' : []), reasons: selection?.reasons ?? {} }, null, 2) });
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|