@xoong/cli 0.0.0-stage → 0.0.34
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 +26 -2
- package/package.json +47 -4
- package/src/body-schema.js +161 -0
- package/src/build.js +211 -0
- package/src/check.js +347 -0
- package/src/cli.js +101 -0
- package/src/commands.js +55 -0
- package/src/dev.js +48 -0
- package/src/html-data.js +85 -0
- package/src/i18n-extract.js +78 -0
- package/src/index.js +2 -0
- package/src/openapi.js +474 -0
- package/src/scaffold.js +159 -0
- package/src/swagger2.js +88 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Duong Tran Xuan
|
|
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
CHANGED
|
@@ -1,3 +1,27 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @xoong/cli
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The `xoong` command: turns an OpenAPI document into typed operations, lints your screens, runs the dev server and builds static output that runs under a strict Content-Security-Policy.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @xoong/cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Use
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx xoong sync openapi.json # writes .xoong/operations.json and api.d.ts
|
|
15
|
+
npx xoong check index.html # lint screens against the API
|
|
16
|
+
npx xoong dev # static server with an API proxy
|
|
17
|
+
npx xoong build --out dist # strict-CSP static output
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## More
|
|
21
|
+
|
|
22
|
+
* Documentation and demos: https://github.com/fpt-duongtx/xoong#readme
|
|
23
|
+
* Source: https://github.com/fpt-duongtx/xoong/tree/main/packages/cli
|
|
24
|
+
* All packages share one version number and are released together: https://github.com/fpt-duongtx/xoong/releases
|
|
25
|
+
* xoong is a working prototype (0.0.x): the HTML syntax is still Draft.
|
|
26
|
+
|
|
27
|
+
MIT licence.
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xoong/cli",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.0.34",
|
|
4
|
+
"description": "The xoong command line: sync (OpenAPI to typed operations), doctor, check (lint screens), dev, build (strict-CSP static output), render and i18n.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"xoong": "src/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./src/index.js",
|
|
11
|
+
"./check": "./src/check.js",
|
|
12
|
+
"./cli": "./src/cli.js",
|
|
13
|
+
"./build": "./src/build.js",
|
|
14
|
+
"./html-data": "./src/html-data.js"
|
|
15
|
+
},
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"parse5": "^8.1.0",
|
|
19
|
+
"yaml": "^2.8.0",
|
|
20
|
+
"@xoong/server": "0.0.34",
|
|
21
|
+
"@xoong/core": "0.0.34",
|
|
22
|
+
"@xoong/i18n": "0.0.34"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"src"
|
|
26
|
+
],
|
|
27
|
+
"keywords": [
|
|
28
|
+
"xoong",
|
|
29
|
+
"web-framework",
|
|
30
|
+
"openapi",
|
|
31
|
+
"custom-elements"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=22.12"
|
|
35
|
+
},
|
|
36
|
+
"author": "Duong Tran Xuan <duongtx1505@icloud.com>",
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://github.com/fpt-duongtx/xoong.git",
|
|
40
|
+
"directory": "packages/cli"
|
|
41
|
+
},
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/fpt-duongtx/xoong/issues"
|
|
44
|
+
},
|
|
45
|
+
"homepage": "https://github.com/fpt-duongtx/xoong#readme",
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* OpenAPI request schema -> the compact node tree that forms use at runtime (`bodySchema` in operations.json; see
|
|
4
|
+
* packages/data/src/schema.js for the node shapes and docs/decisions/0009-forms-from-the-api-schema.md).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const refName = (r) => r.slice(r.lastIndexOf('/') + 1);
|
|
8
|
+
const PRIMITIVE = new Set(['string', 'integer', 'number', 'boolean']);
|
|
9
|
+
const MAX_DEPTH = 10;
|
|
10
|
+
/** Where a union of objects could not be told apart, collected by `bodySchemaOf(..., lost)`. */
|
|
11
|
+
let lostUnions;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {any} schema the request body schema (maybe a $ref)
|
|
15
|
+
* @param {Record<string, any>} schemas components.schemas
|
|
16
|
+
* @param {{ fields?: Record<string, { label?: string, hint?: string, placeholder?: string, widget?: string, order?: number, readOnly?: boolean }> }} [hints]
|
|
17
|
+
* `fields`: presentation hints keyed "SchemaName.property" for specs that cannot be edited
|
|
18
|
+
* @returns {any | undefined} an object node, or undefined when the body is not an object
|
|
19
|
+
*/
|
|
20
|
+
export function bodySchemaOf(schema, schemas, hints, lost) {
|
|
21
|
+
lostUnions = lost;
|
|
22
|
+
const n = convert(schema, schemas, hints, 0, undefined);
|
|
23
|
+
lostUnions = undefined;
|
|
24
|
+
return n && n.t === 'object' ? n : undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Follow $ref chains; returns the schema and the name of the last named schema met. */
|
|
28
|
+
function deref(s, schemas, owner) {
|
|
29
|
+
let name = owner;
|
|
30
|
+
for (let i = 0; s?.$ref && i < 20; i++) { name = refName(s.$ref); s = schemas[name]; }
|
|
31
|
+
return { s, name };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** allOf: the union of properties and required lists, other keywords from the parts and the schema itself. */
|
|
35
|
+
function flatten(schema, schemas, owner, depth) {
|
|
36
|
+
const { s, name } = deref(schema, schemas, owner);
|
|
37
|
+
if (!s?.allOf) return { s, name };
|
|
38
|
+
const merged = { ...s, properties: { ...(s.properties ?? {}) }, required: [...(s.required ?? [])] };
|
|
39
|
+
delete merged.allOf;
|
|
40
|
+
for (const part of s.allOf) {
|
|
41
|
+
const f = depth < MAX_DEPTH ? flatten(part, schemas, name, depth + 1).s : undefined;
|
|
42
|
+
if (!f) continue;
|
|
43
|
+
for (const [k, v] of Object.entries(f)) if (k !== 'properties' && k !== 'required' && merged[k] === undefined) merged[k] = v;
|
|
44
|
+
merged.properties = { ...(f.properties ?? {}), ...merged.properties };
|
|
45
|
+
merged.required = [...new Set([...merged.required, ...(f.required ?? [])])];
|
|
46
|
+
}
|
|
47
|
+
return { s: merged, name };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function convert(schema, schemas, hints, depth, owner) {
|
|
51
|
+
if (depth > MAX_DEPTH) return { t: 'any' };
|
|
52
|
+
let { s, name } = flatten(schema, schemas, owner, depth);
|
|
53
|
+
if (!s || typeof s !== 'object') return { t: 'any' };
|
|
54
|
+
|
|
55
|
+
// oneOf / anyOf: [X, null] is a nullable X; several objects need a tag to be told apart
|
|
56
|
+
const alternatives = s.oneOf ?? s.anyOf;
|
|
57
|
+
if (alternatives) {
|
|
58
|
+
const real = alternatives.filter((a) => deref(a, schemas).s?.type !== 'null');
|
|
59
|
+
const nullable = real.length !== alternatives.length;
|
|
60
|
+
if (real.length === 1) return decorate({ ...convert(real[0], schemas, hints, depth + 1, name), ...(nullable ? { nullable: true } : {}) }, s);
|
|
61
|
+
const union = unionOf(s, real, schemas, hints, depth, name);
|
|
62
|
+
if (!union && real.some((m) => deref(m, schemas).s?.type === 'object' || deref(m, schemas).s?.properties)) lostUnions?.push(name ?? owner ?? 'the request body');
|
|
63
|
+
return union ? decorate({ ...union, ...(nullable ? { nullable: true } : {}) }, s) : { t: 'any' };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let type = s.type;
|
|
67
|
+
let nullable = s.nullable === true;
|
|
68
|
+
if (Array.isArray(type)) { nullable = type.includes('null'); type = type.find((t) => t !== 'null'); }
|
|
69
|
+
if (!type && s.const !== undefined) { type = typeof s.const === 'number' ? 'number' : typeof s.const === 'boolean' ? 'boolean' : 'string'; s = { ...s, enum: [s.const] }; }
|
|
70
|
+
if (!type) type = s.properties ? 'object' : s.items ? 'array' : s.enum ? typeof s.enum[0] === 'number' ? 'number' : 'string' : undefined;
|
|
71
|
+
|
|
72
|
+
/** @type {any} */ let node;
|
|
73
|
+
if (type === 'object') {
|
|
74
|
+
const props = {};
|
|
75
|
+
for (const [prop, child] of Object.entries(s.properties ?? {})) {
|
|
76
|
+
const n = convert(child, schemas, hints, depth + 1, name);
|
|
77
|
+
props[prop] = applyHints(n, name ? hints?.fields?.[`${name}.${prop}`] : undefined);
|
|
78
|
+
}
|
|
79
|
+
if (!Object.keys(props).length) return { t: 'any', ...(nullable ? { nullable: true } : {}) }; // a free-form object (additionalProperties only)
|
|
80
|
+
node = { t: 'object', props, ...(s.required?.length ? { required: [...s.required] } : {}) };
|
|
81
|
+
if (name && hints?.schemas?.[name]) { // hints for a whole schema: pruneEmpty, emptyAs, and anything else a widget of that schema wants
|
|
82
|
+
const { id: _id, entity: _entity, ...rest } = hints.schemas[name];
|
|
83
|
+
node = applyHints(node, rest);
|
|
84
|
+
}
|
|
85
|
+
} else if (type === 'array') {
|
|
86
|
+
node = { t: 'array', items: convert(s.items ?? {}, schemas, hints, depth + 1, name),
|
|
87
|
+
...(s.minItems != null ? { minItems: s.minItems } : {}), ...(s.maxItems != null ? { maxItems: s.maxItems } : {}), ...(s.uniqueItems ? { uniqueItems: true } : {}) };
|
|
88
|
+
} else if (PRIMITIVE.has(type)) {
|
|
89
|
+
node = { t: type };
|
|
90
|
+
for (const k of ['format', 'enum', 'minLength', 'maxLength', 'pattern', 'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf']) if (s[k] !== undefined) node[k] = s[k];
|
|
91
|
+
if (type === 'string' && s.format === 'binary') node.format = 'binary';
|
|
92
|
+
// OpenAPI 3.0 writes exclusiveMinimum as a boolean next to minimum
|
|
93
|
+
if (s.exclusiveMinimum === true) { node.exclusiveMinimum = s.minimum; delete node.minimum; }
|
|
94
|
+
if (s.exclusiveMaximum === true) { node.exclusiveMaximum = s.maximum; delete node.maximum; }
|
|
95
|
+
if (node.exclusiveMinimum === false) delete node.exclusiveMinimum;
|
|
96
|
+
if (node.exclusiveMaximum === false) delete node.exclusiveMaximum;
|
|
97
|
+
} else {
|
|
98
|
+
return { t: 'any' };
|
|
99
|
+
}
|
|
100
|
+
if (nullable) node.nullable = true;
|
|
101
|
+
return decorate(node, s);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Presentation and server-owned flags that any node may carry. */
|
|
105
|
+
function decorate(node, s) {
|
|
106
|
+
if (s.readOnly) node.readOnly = true;
|
|
107
|
+
if (s.default !== undefined) node.default = s.default;
|
|
108
|
+
if (s.title) node.title = s.title;
|
|
109
|
+
if (s.description) node.description = s.description;
|
|
110
|
+
if (s['x-xoong-widget']) node.widget = s['x-xoong-widget'];
|
|
111
|
+
if (s['x-xoong-prune-empty'] !== undefined) node.pruneEmpty = !!s['x-xoong-prune-empty']; // the same as the pruneEmpty hint, written in the spec
|
|
112
|
+
if (s['x-xoong-empty-as']) node.emptyAs = s['x-xoong-empty-as'];
|
|
113
|
+
for (const [k, v] of Object.entries(s)) { // any other x-* extension reaches custom widgets untouched, in `schema.hints`
|
|
114
|
+
if (k.startsWith('x-') && !k.startsWith('x-xoong-') && !['x-nullable', 'x-xoong-id', 'x-xoong-entity'].includes(k)) (node.hints ??= {})[k] = v;
|
|
115
|
+
}
|
|
116
|
+
return node;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const KNOWN_HINTS = new Set(['label', 'hint', 'placeholder', 'widget', 'order', 'readOnly', 'hidden', 'group', 'emptyAs', 'pruneEmpty']);
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Hints from xoong.hints.json. `label` and `hint` become the node's title and description; the other keys xoong acts on keep their names;
|
|
123
|
+
* every other key is passed through untouched in `node.hints`, for custom widgets (an open registry, ADR-0009).
|
|
124
|
+
*/
|
|
125
|
+
function applyHints(node, h) {
|
|
126
|
+
if (!h) return node;
|
|
127
|
+
const out = { ...node };
|
|
128
|
+
if (h.label !== undefined) out.title = h.label;
|
|
129
|
+
if (h.hint !== undefined) out.description = h.hint;
|
|
130
|
+
for (const k of ['placeholder', 'widget', 'order', 'readOnly', 'hidden', 'group', 'emptyAs', 'pruneEmpty']) if (h[k] !== undefined) out[k] = h[k];
|
|
131
|
+
const extra = Object.fromEntries(Object.entries(h).filter(([k]) => !KNOWN_HINTS.has(k)));
|
|
132
|
+
if (Object.keys(extra).length) out.hints = { ...(out.hints ?? {}), ...extra };
|
|
133
|
+
return out;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** A union told apart by the discriminator, or by a property every member fixes to one value. */
|
|
137
|
+
function unionOf(s, members, schemas, hints, depth, owner) {
|
|
138
|
+
const d = s.discriminator;
|
|
139
|
+
const resolved = members.map((m) => flatten(m, schemas, owner, depth + 1));
|
|
140
|
+
let key = d?.propertyName;
|
|
141
|
+
/** @type {(string|number|boolean)[] | undefined} */ let values;
|
|
142
|
+
if (key) {
|
|
143
|
+
const single = (p) => (p && 'const' in p ? p.const : p?.enum?.length === 1 ? p.enum[0] : undefined);
|
|
144
|
+
values = members.map((m, i) => {
|
|
145
|
+
if (!m.$ref) return single(deref(resolved[i].s?.properties?.[key], schemas).s); // an inline member names itself by the fixed value of the tag
|
|
146
|
+
const mapped = Object.entries(d.mapping ?? {}).find(([, target]) => target === m.$ref || String(target).endsWith('/' + refName(m.$ref)));
|
|
147
|
+
return mapped ? mapped[0] : refName(m.$ref);
|
|
148
|
+
});
|
|
149
|
+
} else {
|
|
150
|
+
const single = (p) => (p && 'const' in p ? p.const : p?.enum?.length === 1 ? p.enum[0] : undefined);
|
|
151
|
+
const keys = Object.keys(resolved[0]?.s?.properties ?? {});
|
|
152
|
+
for (const k of keys) {
|
|
153
|
+
const vs = resolved.map((r) => single(deref(r.s?.properties?.[k], schemas).s));
|
|
154
|
+
if (vs.every((v) => v !== undefined) && new Set(vs).size === vs.length) { key = k; values = vs; break; }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
if (!key || !values || values.some((v) => v === undefined)) return undefined;
|
|
158
|
+
const map = {};
|
|
159
|
+
members.forEach((m, i) => { map[String(values[i])] = convert(m, schemas, hints, depth + 1, owner); });
|
|
160
|
+
return { t: 'union', key, map };
|
|
161
|
+
}
|
package/src/build.js
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* `xoong build`: static output that runs under a strict Content-Security-Policy (no import map, no inline script, no inline style).
|
|
4
|
+
*
|
|
5
|
+
* xoong build [--root .] [--out dist] [--base /admin]
|
|
6
|
+
*
|
|
7
|
+
* Starts from every .html page under the root and follows what it references (scripts, stylesheets, images) and what the scripts import
|
|
8
|
+
* (relative modules, JSON, and bare packages, which are found in node_modules and copied to the same URLs the dev server uses). Then:
|
|
9
|
+
* - bare imports ("@xoong/data") are rewritten to URLs, so the page needs no import map;
|
|
10
|
+
* - TypeScript is stripped (erasable syntax only) and .ts files are written as .js;
|
|
11
|
+
* - `--base` prefixes root-absolute URLs for an app served under a path;
|
|
12
|
+
* - nothing is minified or bundled; every file is the one you wrote (readable in the browser's tools).
|
|
13
|
+
* What cannot be seen (a fetch() of a data file, an image named in JavaScript) is added with `--include <file-or-folder>`.
|
|
14
|
+
*/
|
|
15
|
+
import { readFile, writeFile, mkdir, readdir, stat, copyFile, realpath } from 'node:fs/promises';
|
|
16
|
+
import { basename, dirname, join, relative, resolve, extname, posix, sep } from 'node:path';
|
|
17
|
+
import { gzipSync } from 'node:zlib';
|
|
18
|
+
import { parse } from 'parse5';
|
|
19
|
+
import { findPackages, packagePath, stripTypes } from '@xoong/server/node';
|
|
20
|
+
import { loadConfig } from './dev.js';
|
|
21
|
+
import { buildImportMap } from '@xoong/server';
|
|
22
|
+
|
|
23
|
+
const SCRIPT_EXT = new Set(['.js', '.mjs', '.ts', '.mts']);
|
|
24
|
+
const toJs = (p) => p.replace(/\.m?ts$/, (m) => (m === '.mts' ? '.mjs' : '.js'));
|
|
25
|
+
const isLocal = (u) => !!u && !/^([a-z][a-z0-9+.-]*:|\/\/|#)/i.test(u);
|
|
26
|
+
|
|
27
|
+
/** The policy this output is meant to run under (and is tested with). */
|
|
28
|
+
export const CSP = "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self' data:; font-src 'self'; base-uri 'none'; form-action 'self'";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {{ root?: string, out?: string, base?: string, include?: string[] }} [options]
|
|
32
|
+
* @returns {Promise<{ out: string, files: { path: string, bytes: number, gzip: number }[], warnings: string[], csp: string }>}
|
|
33
|
+
*/
|
|
34
|
+
export async function build({ root = '.', out = 'dist', base = '', include = [], packages: extra } = {}) {
|
|
35
|
+
const rootDir = await realpath(resolve(root)); // real path: packages are found by theirs, so an app inside a symlinked package must compare equal
|
|
36
|
+
// the output folder may not exist yet: resolve the part that does
|
|
37
|
+
const realOut = async (p) => { try { return await realpath(p); } catch { const up = dirname(p); return up === p ? p : join(await realOut(up), basename(p)); } };
|
|
38
|
+
const outDir = await realOut(resolve(out));
|
|
39
|
+
const basePath = base ? `/${base.replace(/^\/+|\/+$/g, '')}` : '';
|
|
40
|
+
const packages = await findPackages(rootDir, undefined, extra ?? (await loadConfig(rootDir)).packages ?? []);
|
|
41
|
+
const importMap = buildImportMap(packages).imports;
|
|
42
|
+
/** @type {string[]} */ const warnings = [];
|
|
43
|
+
/** @type {Map<string, string>} output path (relative to out, with a leading slash) -> source file */
|
|
44
|
+
const sources = new Map();
|
|
45
|
+
/** @type {Map<string, Buffer | string>} */ const written = new Map();
|
|
46
|
+
const queue = [];
|
|
47
|
+
|
|
48
|
+
/** Source file for a root-absolute URL path: the app root, or an installed package under its prefix. */
|
|
49
|
+
function fileFor(urlPath) {
|
|
50
|
+
for (const p of Object.values(packages)) {
|
|
51
|
+
if (urlPath === p.prefix || urlPath.startsWith(`${p.prefix}/`)) return join(p.dir, urlPath.slice(p.prefix.length));
|
|
52
|
+
}
|
|
53
|
+
return join(rootDir, urlPath);
|
|
54
|
+
}
|
|
55
|
+
/** An exports subpath used as a URL ("/@rainlet/theme.css") becomes the real file's URL, so relative references inside it keep working. */
|
|
56
|
+
const canon = (url) => {
|
|
57
|
+
for (const p of Object.values(packages)) if (url.startsWith(`${p.prefix}/`)) return p.prefix + packagePath(p.exports, url.slice(p.prefix.length));
|
|
58
|
+
return url;
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* The URL of a file. The most specific owner wins: an app that lives inside the package it consumes (`"rainlet": "file:.."`, a workspace)
|
|
62
|
+
* keeps its own pages at the site root, while the package's files are still served under its prefix.
|
|
63
|
+
*/
|
|
64
|
+
const urlOf = (file) => {
|
|
65
|
+
const inside = (dir) => file === dir || file.startsWith(dir + sep);
|
|
66
|
+
const owner = Object.values(packages).filter((p) => inside(p.dir)).sort((a, b) => b.dir.length - a.dir.length)[0];
|
|
67
|
+
if (owner && !(inside(rootDir) && rootDir.length > owner.dir.length)) return `${owner.prefix}${file.slice(owner.dir.length).split(sep).join('/')}`;
|
|
68
|
+
return `/${relative(rootDir, file).split(sep).join('/')}`;
|
|
69
|
+
};
|
|
70
|
+
const inOut = (f) => f === outDir || f.startsWith(outDir + sep); // the output folder is never an input, even when it sits inside the root
|
|
71
|
+
const exists = async (f) => !inOut(f) && stat(f).then((s) => s.isFile(), () => false);
|
|
72
|
+
|
|
73
|
+
/** Rewrite one specifier or URL found in `fromUrl` (a root-absolute URL path). Returns the new text, and queues the target file. */
|
|
74
|
+
async function rewrite(spec, fromUrl) {
|
|
75
|
+
let target;
|
|
76
|
+
if (importMap[spec]) target = importMap[spec]; // a bare import of an installed package
|
|
77
|
+
else if (/^[a-z@][^:]*$/i.test(spec) && !spec.startsWith('.') && !spec.startsWith('/')) {
|
|
78
|
+
const prefix = Object.keys(importMap).filter((k) => k.endsWith('/') && spec.startsWith(k)).sort((a, b) => b.length - a.length)[0];
|
|
79
|
+
if (!prefix) { warnings.push(`${fromUrl}: cannot resolve "${spec}" (not an installed xoong package); left as it is`); return spec; }
|
|
80
|
+
target = importMap[prefix] + spec.slice(prefix.length);
|
|
81
|
+
} else if (spec.startsWith('/')) target = spec.split(/[?#]/)[0];
|
|
82
|
+
else if (spec.startsWith('.')) target = posix.normalize(posix.join(posix.dirname(fromUrl), spec.split(/[?#]/)[0]));
|
|
83
|
+
else return spec; // an absolute URL: not ours
|
|
84
|
+
target = canon(target);
|
|
85
|
+
const suffix = (spec.match(/[?#].*$/) ?? [''])[0];
|
|
86
|
+
queue.push(target);
|
|
87
|
+
return `${basePath}${toJs(target)}${suffix}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function transformScript(urlPath, source) {
|
|
91
|
+
let code = source;
|
|
92
|
+
if (/\.m?ts$/.test(urlPath)) code = await stripTypes(code, urlPath);
|
|
93
|
+
const re = /(\bimport\s*(?:[\w*${}\s,]*?\bfrom\s*)?|\bexport\s+(?:\*(?:\s+as\s+\w+)?|\{[^}]*\})\s*from\s*|\bimport\s*\(\s*)(['"])([^'"\n]+)\2/g;
|
|
94
|
+
const parts = [];
|
|
95
|
+
let last = 0;
|
|
96
|
+
for (const m of code.matchAll(re)) {
|
|
97
|
+
const specStart = (m.index ?? 0) + m[0].length - m[3].length - 1;
|
|
98
|
+
parts.push(code.slice(last, specStart), await rewrite(m[3], urlPath));
|
|
99
|
+
last = specStart + m[3].length;
|
|
100
|
+
}
|
|
101
|
+
parts.push(code.slice(last));
|
|
102
|
+
return parts.join('');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function transformCss(urlPath, source) {
|
|
106
|
+
const parts = [];
|
|
107
|
+
let last = 0;
|
|
108
|
+
for (const m of source.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)|@import\s+(['"])([^'"]+)\3/g)) {
|
|
109
|
+
const spec = m[2] ?? m[4];
|
|
110
|
+
if (!isLocal(spec) || spec.startsWith('data:')) continue;
|
|
111
|
+
const at = (m.index ?? 0) + m[0].indexOf(spec);
|
|
112
|
+
parts.push(source.slice(last, at), await rewrite(relativeUrl(spec), urlPath));
|
|
113
|
+
last = at + spec.length;
|
|
114
|
+
}
|
|
115
|
+
parts.push(source.slice(last));
|
|
116
|
+
return parts.join('');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** HTML: rewrite references, drop an import map (the imports are rewritten), warn about what a strict policy blocks. */
|
|
120
|
+
async function transformHtml(urlPath, source) {
|
|
121
|
+
const doc = parse(source, { sourceCodeLocationInfo: true });
|
|
122
|
+
const edits = [];
|
|
123
|
+
const visit = async (node) => {
|
|
124
|
+
for (const child of node.childNodes ?? []) await visit(child);
|
|
125
|
+
if (node.nodeName === 'template') for (const child of node.content?.childNodes ?? []) await visit(child);
|
|
126
|
+
if (!node.tagName) return;
|
|
127
|
+
const attr = (n) => node.attrs.find((a) => a.name === n);
|
|
128
|
+
if (node.tagName === 'script') {
|
|
129
|
+
if (attr('type')?.value === 'importmap') { const l = node.sourceCodeLocation; edits.push([l.startOffset, l.endOffset, '']); return; }
|
|
130
|
+
if (!attr('src') && (node.childNodes ?? []).some((c) => c.value?.trim())) warnings.push(`${urlPath}:${node.sourceCodeLocation.startLine}: an inline <script> is blocked by script-src 'self'; move it to a file`);
|
|
131
|
+
}
|
|
132
|
+
if (attr('style')) warnings.push(`${urlPath}:${node.sourceCodeLocation.startLine}: a style="" attribute is blocked by style-src 'self'; use a class`);
|
|
133
|
+
if (node.tagName === 'style') warnings.push(`${urlPath}:${node.sourceCodeLocation.startLine}: an inline <style> is blocked by style-src 'self'; move it to a stylesheet`);
|
|
134
|
+
for (const name of ['src', 'href', 'poster', 'srcset']) {
|
|
135
|
+
const a = attr(name);
|
|
136
|
+
if (!a || !isLocal(a.value) || node.tagName === 'a' || (node.tagName === 'link' && attr('rel')?.value && !/stylesheet|icon|preload|modulepreload|manifest/.test(attr('rel')?.value ?? ''))) continue;
|
|
137
|
+
if (name === 'srcset' || a.value.startsWith('data:')) continue;
|
|
138
|
+
const loc = node.sourceCodeLocation.attrs?.[name];
|
|
139
|
+
if (!loc) continue;
|
|
140
|
+
const raw = source.slice(loc.startOffset, loc.endOffset);
|
|
141
|
+
edits.push([loc.startOffset, loc.endOffset, raw.replace(a.value, await rewriteHtmlUrl(a.value, urlPath))]);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
await visit(doc);
|
|
145
|
+
edits.sort((x, y) => y[0] - x[0]);
|
|
146
|
+
let result = source;
|
|
147
|
+
for (const [s, e, text] of edits) result = result.slice(0, s) + text + result.slice(e);
|
|
148
|
+
return result;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** In HTML and CSS a URL without a leading slash or dot is relative to the file (only JavaScript imports have bare package names). */
|
|
152
|
+
const relativeUrl = (u) => (/^[./?#]/.test(u) ? u : `./${u}`);
|
|
153
|
+
|
|
154
|
+
/** Links inside the page to other pages are left; files the page loads are queued and given the base. */
|
|
155
|
+
async function rewriteHtmlUrl(rawUrl, fromUrl) {
|
|
156
|
+
const url = relativeUrl(rawUrl); // href="src/app.css" is relative to the page, not a package name
|
|
157
|
+
const target = url.startsWith('/') ? url.split(/[?#]/)[0] : posix.normalize(posix.join(posix.dirname(fromUrl), url.split(/[?#]/)[0]));
|
|
158
|
+
if (!(await exists(fileFor(canon(target))))) return url; // not a file we can serve (a route): keep as written
|
|
159
|
+
return rewrite(url, fromUrl);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function emit(urlPath) {
|
|
163
|
+
const outPath = toJs(urlPath);
|
|
164
|
+
if (sources.has(outPath)) return;
|
|
165
|
+
const file = fileFor(urlPath);
|
|
166
|
+
if (inOut(file)) { warnings.push(`${urlPath}: points into the output folder, which is not an input`); sources.set(outPath, ''); return; }
|
|
167
|
+
if (!(await exists(file))) { warnings.push(`${urlPath}: referenced but not found`); sources.set(outPath, ''); return; }
|
|
168
|
+
sources.set(outPath, file);
|
|
169
|
+
const ext = extname(file).toLowerCase();
|
|
170
|
+
if (SCRIPT_EXT.has(ext)) written.set(outPath, await transformScript(urlPath, await readFile(file, 'utf8')));
|
|
171
|
+
else if (ext === '.css') written.set(outPath, await transformCss(urlPath, await readFile(file, 'utf8')));
|
|
172
|
+
else if (ext === '.html' || ext === '.htm') written.set(outPath, await transformHtml(urlPath, await readFile(file, 'utf8')));
|
|
173
|
+
else written.set(outPath, await readFile(file));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// pages first, then whatever they pull in
|
|
177
|
+
const pages = [];
|
|
178
|
+
const walk = async (dir) => {
|
|
179
|
+
for (const name of await readdir(dir)) {
|
|
180
|
+
if (name === 'node_modules' || name.startsWith('.') || resolve(dir, name) === outDir) continue;
|
|
181
|
+
const p = join(dir, name);
|
|
182
|
+
if ((await stat(p)).isDirectory()) await walk(p); else if (/\.html?$/i.test(name)) pages.push(urlOf(p));
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
await walk(rootDir);
|
|
186
|
+
if (!pages.length) throw new Error(`xoong build: no .html page found under ${rootDir}`);
|
|
187
|
+
for (const item of include) {
|
|
188
|
+
const p = resolve(rootDir, item);
|
|
189
|
+
const s = await stat(p).catch(() => null);
|
|
190
|
+
if (!s) { warnings.push(`--include ${item}: not found`); continue; }
|
|
191
|
+
const files = [];
|
|
192
|
+
const collect = async (d) => { for (const n of await readdir(d)) { const f = join(d, n); if (inOut(f)) continue; if ((await stat(f)).isDirectory()) await collect(f); else files.push(f); } };
|
|
193
|
+
if (s.isDirectory()) await collect(p); else files.push(p);
|
|
194
|
+
for (const f of files) queue.push(urlOf(f));
|
|
195
|
+
}
|
|
196
|
+
queue.push(...pages);
|
|
197
|
+
while (queue.length) await emit(/** @type {string} */ (queue.shift()));
|
|
198
|
+
|
|
199
|
+
const report = [];
|
|
200
|
+
for (const [outPath, content] of written) {
|
|
201
|
+
const dest = join(outDir, outPath);
|
|
202
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
203
|
+
await writeFile(dest, content);
|
|
204
|
+
const buf = typeof content === 'string' ? Buffer.from(content) : content;
|
|
205
|
+
report.push({ path: outPath, bytes: buf.length, gzip: gzipSync(buf).length });
|
|
206
|
+
}
|
|
207
|
+
report.sort((a, b) => a.path.localeCompare(b.path));
|
|
208
|
+
await writeFile(join(outDir, 'build-report.json'), JSON.stringify({ csp: CSP, files: report, warnings }, null, 2) + '\n');
|
|
209
|
+
void copyFile;
|
|
210
|
+
return { out: outDir, files: report, warnings, csp: CSP };
|
|
211
|
+
}
|