@dialecte/create 0.0.1
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/README.md +88 -0
- package/dist/cli/index.js +281 -0
- package/package.json +43 -0
- package/python/generate/__init__.py +1 -0
- package/python/generate/__main__.py +103 -0
- package/python/generate/collector.py +128 -0
- package/python/generate/deriver.py +117 -0
- package/python/generate/emitters/__init__.py +1 -0
- package/python/generate/emitters/constants.py +69 -0
- package/python/generate/emitters/definition.py +49 -0
- package/python/generate/emitters/ts_helpers.py +283 -0
- package/python/generate/emitters/types.py +67 -0
- package/python/generate/extractors/__init__.py +1 -0
- package/python/generate/extractors/attributes.py +83 -0
- package/python/generate/extractors/children.py +175 -0
- package/python/generate/extractors/constraints.py +154 -0
- package/python/generate/extractors/docs.py +32 -0
- package/python/generate/extractors/facets.py +168 -0
- package/python/generate/extractors/namespace.py +59 -0
- package/python/generate/globals.py +99 -0
- package/python/generate/helpers.py +143 -0
- package/python/generate/ir.py +81 -0
- package/python/generate/orphans.py +69 -0
- package/python/generate/xpath_parser.py +167 -0
- package/python/generate/xsi_type.py +150 -0
- package/python/pyproject.toml +15 -0
- package/templates/dialecte/README.md +39 -0
- package/templates/dialecte/_gitignore +4 -0
- package/templates/dialecte/docs/.vitepress/config.ts +15 -0
- package/templates/dialecte/docs/index.md +18 -0
- package/templates/dialecte/env.d.ts +1 -0
- package/templates/dialecte/package.json +45 -0
- package/templates/dialecte/src/__version__/config/dialecte.config.ts +48 -0
- package/templates/dialecte/src/__version__/config/hydrated.types.ts +78 -0
- package/templates/dialecte/src/__version__/config/index.ts +2 -0
- package/templates/dialecte/src/__version__/config/namespaces.ts +6 -0
- package/templates/dialecte/src/__version__/definition/.gitkeep +2 -0
- package/templates/dialecte/src/__version__/definition/index.ts +4 -0
- package/templates/dialecte/src/__version__/dialecte.ts +30 -0
- package/templates/dialecte/src/__version__/extensions/index.ts +3 -0
- package/templates/dialecte/src/__version__/index.ts +2 -0
- package/templates/dialecte/src/__version__/test/hydrated-test.ts +53 -0
- package/templates/dialecte/src/__version__/test/index.ts +1 -0
- package/templates/dialecte/src/index.ts +1 -0
- package/templates/dialecte/tsconfig.build.json +24 -0
- package/templates/dialecte/tsconfig.json +8 -0
- package/templates/dialecte/tsconfig.node.json +13 -0
- package/templates/dialecte/tsconfig.vitest.json +10 -0
- package/templates/dialecte/vite.config.ts +36 -0
- package/templates/dialecte/vitest.config.ts +35 -0
- package/vendor/elementpath-5.1.1-py3-none-any.whl +0 -0
- package/vendor/xmlschema-4.3.1-py3-none-any.whl +0 -0
package/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# @dialecte/create
|
|
2
|
+
|
|
3
|
+
Scaffold and generate [Dialecte](https://github.com/dialecte) SDKs from an XSD schema.
|
|
4
|
+
|
|
5
|
+
The XSD-to-TypeScript generator is written in Python but runs inside Node via
|
|
6
|
+
[Pyodide](https://pyodide.org/) (WebAssembly), so **end users do not need Python
|
|
7
|
+
installed**. Pure-Python dependencies are vendored as wheels and installed
|
|
8
|
+
offline at runtime.
|
|
9
|
+
|
|
10
|
+
## Quick start
|
|
11
|
+
|
|
12
|
+
Scaffold a brand-new dialecte package from a schema:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
npm create @dialecte -- ./my-schema.xsd --name @acme/widget
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
> `npm create @dialecte` resolves to the `@dialecte/create` package. The `--`
|
|
19
|
+
> separator is required so npm forwards the schema path and flags to the CLI
|
|
20
|
+
> instead of consuming them itself.
|
|
21
|
+
|
|
22
|
+
Or just regenerate definition files into an existing package:
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
npx @dialecte/create generate --entry ./my-schema.xsd --out-dir ./src/v1/definition
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Commands
|
|
29
|
+
|
|
30
|
+
### `create <schema.xsd>`
|
|
31
|
+
|
|
32
|
+
Scaffolds a new dialecte package (built on `@dialecte/core`) and generates its
|
|
33
|
+
element definitions from the schema in one step.
|
|
34
|
+
|
|
35
|
+
| Option | Default | Description |
|
|
36
|
+
| ---------------------- | ----------------------------- | ------------------------------ |
|
|
37
|
+
| `--name <pkg>` | `@dialecte/<schema basename>` | npm package name |
|
|
38
|
+
| `--out <dir>` | `./<dialecte id>` | target directory |
|
|
39
|
+
| `--version <vN>` | `v1` | version folder name |
|
|
40
|
+
| `--namespace <uri>` | `urn:dialecte:<id>` | default XML namespace URI |
|
|
41
|
+
| `--core-version <ver>` | `^0.2.19` | `@dialecte/core` version range |
|
|
42
|
+
|
|
43
|
+
The generated package includes:
|
|
44
|
+
|
|
45
|
+
- Hydrated type aliases bound to your config (`Dialecte.Project`, `Dialecte.Query`, ...)
|
|
46
|
+
- A project factory (`create<Name>Project`)
|
|
47
|
+
- Test hydration utilities wired to `@dialecte/core/test`
|
|
48
|
+
- VitePress documentation scaffolding
|
|
49
|
+
|
|
50
|
+
### `generate`
|
|
51
|
+
|
|
52
|
+
Generates only the three definition files
|
|
53
|
+
(`definition.generated.ts`, `constants.generated.ts`, `types.generated.ts`).
|
|
54
|
+
|
|
55
|
+
| Option | Description |
|
|
56
|
+
| ---------------------- | --------------------------- |
|
|
57
|
+
| `--entry <schema.xsd>` | entry XSD file (required) |
|
|
58
|
+
| `--out-dir <dir>` | output directory (required) |
|
|
59
|
+
|
|
60
|
+
## Bring your own XSD
|
|
61
|
+
|
|
62
|
+
This package does **not** bundle IEC or other proprietary schemas. Point it at
|
|
63
|
+
your own `.xsd` file. Optional sidecar files next to the entry XSD are honored:
|
|
64
|
+
|
|
65
|
+
- `parent-mapping.json` - declares parents for orphan (wildcard) elements
|
|
66
|
+
- `attribute-mapping.json` - injects extension-namespace attributes
|
|
67
|
+
|
|
68
|
+
## Development
|
|
69
|
+
|
|
70
|
+
The generator engine is a Python package under `python/`. Develop and test it
|
|
71
|
+
with native Python (fast loop), then ship it to users via WebAssembly.
|
|
72
|
+
|
|
73
|
+
```sh
|
|
74
|
+
# Engine (Python) tests
|
|
75
|
+
cd python && python -m pytest
|
|
76
|
+
|
|
77
|
+
# Vendor the runtime wheels (network required; run once / on dep bump)
|
|
78
|
+
npm run vendor
|
|
79
|
+
|
|
80
|
+
# Build the Node CLI
|
|
81
|
+
npm run build
|
|
82
|
+
|
|
83
|
+
# Try it
|
|
84
|
+
node dist/cli/index.js generate --entry ./xsd/SCL/IEC61850-6-100.xsd --out-dir .tmp/out
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`xsd/` and `local/` are git-ignored: they hold local-only schemas and the
|
|
88
|
+
maintainer's batch generation script.
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli/index.ts
|
|
4
|
+
import { existsSync as existsSync2 } from "fs";
|
|
5
|
+
import { basename as basename2, resolve as resolve4 } from "path";
|
|
6
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
7
|
+
|
|
8
|
+
// src/cli/pyodide-runner.ts
|
|
9
|
+
import { readdir, mkdir } from "fs/promises";
|
|
10
|
+
import { dirname as dirname2, basename, resolve as resolve2 } from "path";
|
|
11
|
+
|
|
12
|
+
// src/cli/paths.ts
|
|
13
|
+
import { dirname, resolve } from "path";
|
|
14
|
+
import { fileURLToPath } from "url";
|
|
15
|
+
var here = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
var PACKAGE_ROOT = resolve(here, "..", "..");
|
|
17
|
+
var PYTHON_ENGINE_DIR = resolve(PACKAGE_ROOT, "python");
|
|
18
|
+
var VENDOR_DIR = resolve(PACKAGE_ROOT, "vendor");
|
|
19
|
+
var TEMPLATES_DIR = resolve(PACKAGE_ROOT, "templates");
|
|
20
|
+
|
|
21
|
+
// src/cli/pyodide-runner.ts
|
|
22
|
+
import { loadPyodide } from "pyodide";
|
|
23
|
+
var pyodidePromise;
|
|
24
|
+
function fs(py) {
|
|
25
|
+
return py.FS;
|
|
26
|
+
}
|
|
27
|
+
async function getPyodide(quiet) {
|
|
28
|
+
if (!pyodidePromise) {
|
|
29
|
+
pyodidePromise = loadPyodide({
|
|
30
|
+
stdout: quiet ? () => {
|
|
31
|
+
} : (msg) => console.log(msg),
|
|
32
|
+
stderr: (msg) => console.error(msg)
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return pyodidePromise;
|
|
36
|
+
}
|
|
37
|
+
async function mountReadOnly(py, mountPoint, hostRoot) {
|
|
38
|
+
const f = fs(py);
|
|
39
|
+
try {
|
|
40
|
+
f.mkdir(mountPoint);
|
|
41
|
+
} catch {
|
|
42
|
+
}
|
|
43
|
+
f.mount(f.filesystems.NODEFS, { root: hostRoot }, mountPoint);
|
|
44
|
+
}
|
|
45
|
+
async function listWheels() {
|
|
46
|
+
const entries = await readdir(VENDOR_DIR);
|
|
47
|
+
return entries.filter((f) => f.endsWith(".whl"));
|
|
48
|
+
}
|
|
49
|
+
async function runGenerator(options) {
|
|
50
|
+
const { entry, outDir, quiet = false } = options;
|
|
51
|
+
const entryAbs = resolve2(entry);
|
|
52
|
+
const outAbs = resolve2(outDir);
|
|
53
|
+
const entryDir = dirname2(entryAbs);
|
|
54
|
+
const entryName = basename(entryAbs);
|
|
55
|
+
await mkdir(outAbs, { recursive: true });
|
|
56
|
+
const py = await getPyodide(quiet);
|
|
57
|
+
await mountReadOnly(py, "/engine", PYTHON_ENGINE_DIR);
|
|
58
|
+
await mountReadOnly(py, "/vendor", VENDOR_DIR);
|
|
59
|
+
const wheels = await listWheels();
|
|
60
|
+
if (wheels.length === 0) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`No vendored wheels found in ${VENDOR_DIR}. Run "npm run vendor" to download them.`
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
const wheelPaths = JSON.stringify(wheels.map((w) => `/vendor/${w}`));
|
|
66
|
+
await mountReadOnly(py, "/in", entryDir);
|
|
67
|
+
await mountReadOnly(py, "/out", outAbs);
|
|
68
|
+
const argv = JSON.stringify(["--entry", `/in/${entryName}`, "--out-dir", "/out"]);
|
|
69
|
+
await py.runPythonAsync(`
|
|
70
|
+
import sys, os, zipfile
|
|
71
|
+
|
|
72
|
+
site_dir = '/site-packages'
|
|
73
|
+
if not os.path.isdir(site_dir):
|
|
74
|
+
os.makedirs(site_dir, exist_ok=True)
|
|
75
|
+
for wheel in ${wheelPaths}:
|
|
76
|
+
with zipfile.ZipFile(wheel) as zf:
|
|
77
|
+
zf.extractall(site_dir)
|
|
78
|
+
|
|
79
|
+
if site_dir not in sys.path:
|
|
80
|
+
sys.path.insert(0, site_dir)
|
|
81
|
+
if '/engine' not in sys.path:
|
|
82
|
+
sys.path.insert(0, '/engine')
|
|
83
|
+
|
|
84
|
+
from generate.__main__ import main
|
|
85
|
+
main(${argv})
|
|
86
|
+
`);
|
|
87
|
+
const f = fs(py);
|
|
88
|
+
f.unmount("/in");
|
|
89
|
+
f.unmount("/out");
|
|
90
|
+
f.rmdir("/in");
|
|
91
|
+
f.rmdir("/out");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// src/cli/scaffold.ts
|
|
95
|
+
import { existsSync } from "fs";
|
|
96
|
+
import { readdir as readdir2, mkdir as mkdir2, readFile, writeFile, rm } from "fs/promises";
|
|
97
|
+
import { join, resolve as resolve3 } from "path";
|
|
98
|
+
function dialecteIdFromPackageName(packageName) {
|
|
99
|
+
const last = packageName.split("/").pop() ?? packageName;
|
|
100
|
+
return last.replace(/[^a-zA-Z0-9]+/g, "").toLowerCase();
|
|
101
|
+
}
|
|
102
|
+
function buildReplacements(options) {
|
|
103
|
+
const dialecteId = dialecteIdFromPackageName(options.packageName);
|
|
104
|
+
const DialecteName = dialecteId.charAt(0).toUpperCase() + dialecteId.slice(1);
|
|
105
|
+
const DIALECTE_NAME = dialecteId.toUpperCase();
|
|
106
|
+
return {
|
|
107
|
+
packageName: options.packageName,
|
|
108
|
+
dialecteId,
|
|
109
|
+
DialecteName,
|
|
110
|
+
DIALECTE_NAME,
|
|
111
|
+
version: options.version,
|
|
112
|
+
namespaceUri: options.namespaceUri,
|
|
113
|
+
coreVersion: options.coreVersion
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
function applyReplacements(text, r) {
|
|
117
|
+
return text.replaceAll("__packageName__", r.packageName).replaceAll("__DIALECTE_NAME__", r.DIALECTE_NAME).replaceAll("__DialecteName__", r.DialecteName).replaceAll("__dialecteId__", r.dialecteId).replaceAll("__version__", r.version).replaceAll("__namespaceUri__", r.namespaceUri).replaceAll("__coreVersion__", r.coreVersion);
|
|
118
|
+
}
|
|
119
|
+
function applyPathReplacements(path, r) {
|
|
120
|
+
const replaced = path.replaceAll("__version__", r.version);
|
|
121
|
+
return replaced.replace(/(^|\/)_gitignore$/, "$1.gitignore");
|
|
122
|
+
}
|
|
123
|
+
async function copyTemplateTree(srcDir, destDir, r) {
|
|
124
|
+
const entries = await readdir2(srcDir, { withFileTypes: true });
|
|
125
|
+
await mkdir2(destDir, { recursive: true });
|
|
126
|
+
for (const entry of entries) {
|
|
127
|
+
const srcPath = join(srcDir, entry.name);
|
|
128
|
+
const destName = applyPathReplacements(entry.name, r);
|
|
129
|
+
const destPath = join(destDir, destName);
|
|
130
|
+
if (entry.isDirectory()) {
|
|
131
|
+
await copyTemplateTree(srcPath, destPath, r);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (entry.name === ".gitkeep") continue;
|
|
135
|
+
const raw = await readFile(srcPath, "utf8");
|
|
136
|
+
await writeFile(destPath, applyReplacements(raw, r), "utf8");
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
async function scaffoldDialecte(options) {
|
|
140
|
+
const targetDir = resolve3(options.targetDir);
|
|
141
|
+
const templateRoot = join(TEMPLATES_DIR, "dialecte");
|
|
142
|
+
if (existsSync(targetDir)) {
|
|
143
|
+
const remaining = await readdir2(targetDir);
|
|
144
|
+
if (remaining.length > 0) {
|
|
145
|
+
throw new Error(`Target directory is not empty: ${targetDir}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const replacements = buildReplacements(options);
|
|
149
|
+
console.log(`Scaffolding ${options.packageName} -> ${targetDir}`);
|
|
150
|
+
await copyTemplateTree(templateRoot, targetDir, replacements);
|
|
151
|
+
const definitionDir = join(targetDir, "src", options.version, "definition");
|
|
152
|
+
console.log(`Generating definitions from ${options.entry}`);
|
|
153
|
+
await runGenerator({ entry: options.entry, outDir: definitionDir });
|
|
154
|
+
await rm(join(definitionDir, ".gitkeep"), { force: true });
|
|
155
|
+
console.log("");
|
|
156
|
+
console.log("Done. Next steps:");
|
|
157
|
+
console.log(` cd ${options.targetDir}`);
|
|
158
|
+
console.log(" npm install");
|
|
159
|
+
console.log(" npm run build");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// src/cli/index.ts
|
|
163
|
+
var DEFAULT_CORE_VERSION = "^0.2.19";
|
|
164
|
+
var DEFAULT_VERSION = "v1";
|
|
165
|
+
function parseArgs(argv) {
|
|
166
|
+
const _ = [];
|
|
167
|
+
const flags = {};
|
|
168
|
+
for (let i = 0; i < argv.length; i++) {
|
|
169
|
+
const arg = argv[i];
|
|
170
|
+
if (arg.startsWith("--")) {
|
|
171
|
+
const key = arg.slice(2);
|
|
172
|
+
const next = argv[i + 1];
|
|
173
|
+
if (next === void 0 || next.startsWith("--")) {
|
|
174
|
+
flags[key] = true;
|
|
175
|
+
} else {
|
|
176
|
+
flags[key] = next;
|
|
177
|
+
i++;
|
|
178
|
+
}
|
|
179
|
+
} else {
|
|
180
|
+
_.push(arg);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return { _, flags };
|
|
184
|
+
}
|
|
185
|
+
function printHelp() {
|
|
186
|
+
console.log(`@dialecte/create - scaffold and generate Dialecte SDKs from XSD
|
|
187
|
+
|
|
188
|
+
Usage:
|
|
189
|
+
create-dialecte create <schema.xsd> [options] Scaffold a new dialecte package
|
|
190
|
+
create-dialecte generate [options] Generate definition files only
|
|
191
|
+
create-dialecte <schema.xsd> Shorthand for "create"
|
|
192
|
+
|
|
193
|
+
create options:
|
|
194
|
+
--name <pkg> npm package name (default: @dialecte/<schema basename>)
|
|
195
|
+
--out <dir> target directory (default: ./<dialecte id>)
|
|
196
|
+
--version <vN> version folder name (default: ${DEFAULT_VERSION})
|
|
197
|
+
--namespace <uri> default XML namespace URI (default: derived placeholder)
|
|
198
|
+
--core-version <ver> @dialecte/core version range (default: ${DEFAULT_CORE_VERSION})
|
|
199
|
+
|
|
200
|
+
generate options:
|
|
201
|
+
--entry <schema.xsd> entry XSD file (required)
|
|
202
|
+
--out-dir <dir> output directory for generated .ts files (required)
|
|
203
|
+
|
|
204
|
+
-h, --help show this help
|
|
205
|
+
`);
|
|
206
|
+
}
|
|
207
|
+
async function runGenerateCommand(flags) {
|
|
208
|
+
const entry = typeof flags.entry === "string" ? flags.entry : void 0;
|
|
209
|
+
const outDir = typeof flags["out-dir"] === "string" ? flags["out-dir"] : void 0;
|
|
210
|
+
if (!entry || !outDir) {
|
|
211
|
+
throw new Error("generate requires --entry <schema.xsd> and --out-dir <dir>");
|
|
212
|
+
}
|
|
213
|
+
if (!existsSync2(resolve4(entry))) {
|
|
214
|
+
throw new Error(`XSD file not found: ${entry}`);
|
|
215
|
+
}
|
|
216
|
+
await runGenerator({ entry, outDir });
|
|
217
|
+
}
|
|
218
|
+
async function runCreateCommand(positionals, flags) {
|
|
219
|
+
const entry = positionals[0];
|
|
220
|
+
if (!entry) {
|
|
221
|
+
throw new Error("create requires a path to an XSD schema: create-dialecte create <schema.xsd>");
|
|
222
|
+
}
|
|
223
|
+
if (!existsSync2(resolve4(entry))) {
|
|
224
|
+
throw new Error(`XSD file not found: ${entry}`);
|
|
225
|
+
}
|
|
226
|
+
const schemaBase = basename2(entry).replace(/\.xsd$/i, "");
|
|
227
|
+
const packageName = typeof flags.name === "string" ? flags.name : `@dialecte/${schemaBase.toLowerCase()}`;
|
|
228
|
+
const dialecteId = dialecteIdFromPackageName(packageName);
|
|
229
|
+
const version = typeof flags.version === "string" ? flags.version : DEFAULT_VERSION;
|
|
230
|
+
const namespaceUri = typeof flags.namespace === "string" ? flags.namespace : `urn:dialecte:${dialecteId}`;
|
|
231
|
+
const coreVersion = typeof flags["core-version"] === "string" ? flags["core-version"] : DEFAULT_CORE_VERSION;
|
|
232
|
+
const targetDir = typeof flags.out === "string" ? flags.out : positionals[1] ?? `./${dialecteId}`;
|
|
233
|
+
await scaffoldDialecte({
|
|
234
|
+
entry,
|
|
235
|
+
targetDir,
|
|
236
|
+
packageName,
|
|
237
|
+
version,
|
|
238
|
+
namespaceUri,
|
|
239
|
+
coreVersion
|
|
240
|
+
});
|
|
241
|
+
if (typeof flags.namespace !== "string") {
|
|
242
|
+
console.log("");
|
|
243
|
+
console.log(
|
|
244
|
+
`Note: default namespace set to "${namespaceUri}". Update src/${version}/config/namespaces.ts if needed.`
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
async function main(argv = process.argv.slice(2)) {
|
|
249
|
+
const { _, flags } = parseArgs(argv);
|
|
250
|
+
if (flags.help || flags.h || _.length === 0 && Object.keys(flags).length === 0) {
|
|
251
|
+
printHelp();
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
const command = _[0];
|
|
255
|
+
if (command === "generate") {
|
|
256
|
+
await runGenerateCommand(flags);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
if (command === "create") {
|
|
260
|
+
await runCreateCommand(_.slice(1), flags);
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (command && command.toLowerCase().endsWith(".xsd")) {
|
|
264
|
+
await runCreateCommand(_, flags);
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
printHelp();
|
|
268
|
+
throw new Error(`Unknown command: ${command ?? "(none)"}`);
|
|
269
|
+
}
|
|
270
|
+
var invokedDirectly = process.argv[1] !== void 0 && resolve4(process.argv[1]) === fileURLToPath2(import.meta.url);
|
|
271
|
+
if (invokedDirectly) {
|
|
272
|
+
main().catch((err) => {
|
|
273
|
+
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
|
274
|
+
process.exit(1);
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
export {
|
|
278
|
+
main,
|
|
279
|
+
runGenerator,
|
|
280
|
+
scaffoldDialecte
|
|
281
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dialecte/create",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Scaffold and generate Dialecte SDKs from an XSD schema. Runs the Python generator in WebAssembly - no Python required.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/dialecte/create.git"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"bin": {
|
|
12
|
+
"create-dialecte": "./dist/cli/index.js"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"templates",
|
|
17
|
+
"vendor",
|
|
18
|
+
"python/generate",
|
|
19
|
+
"python/pyproject.toml",
|
|
20
|
+
"README.md"
|
|
21
|
+
],
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=20"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsup",
|
|
27
|
+
"dev": "tsup --watch",
|
|
28
|
+
"start": "node dist/cli/index.js",
|
|
29
|
+
"vendor": "node scripts/vendor-wheels.mjs",
|
|
30
|
+
"test:py": "cd python && python -m pytest",
|
|
31
|
+
"test:cli": "node scripts/smoke.mjs",
|
|
32
|
+
"prepack": "find python -name __pycache__ -type d -prune -exec rm -rf {} + 2>/dev/null || true",
|
|
33
|
+
"prepublishOnly": "npm run vendor && npm run build"
|
|
34
|
+
},
|
|
35
|
+
"dependencies": {
|
|
36
|
+
"pyodide": "^0.27.2"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^22.0.0",
|
|
40
|
+
"tsup": "^8.5.1",
|
|
41
|
+
"typescript": "^5.6.0"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# XSD to TypeScript generation package
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""CLI entry point: python -m generate --entry <xsd> --out-dir <dir>"""
|
|
2
|
+
import argparse
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import xmlschema
|
|
7
|
+
|
|
8
|
+
from generate.collector import collect
|
|
9
|
+
from generate.deriver import derive_graph, derive_identity_fields, derive_root_element, derive_singleton_elements
|
|
10
|
+
from generate.emitters.constants import emit_constants
|
|
11
|
+
from generate.emitters.definition import emit_definition
|
|
12
|
+
from generate.emitters.types import emit_types
|
|
13
|
+
from generate.globals import inject_mapped_attributes, load_attr_mapping
|
|
14
|
+
from generate.orphans import detect_orphans, inject_orphan_parents, load_parent_mapping
|
|
15
|
+
from generate.xsi_type import XsiTypeExpander
|
|
16
|
+
def main(argv: list[str] | None = None) -> None:
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
description='Generate TypeScript definition files from XSD schemas.',
|
|
19
|
+
)
|
|
20
|
+
parser.add_argument(
|
|
21
|
+
'--entry',
|
|
22
|
+
type=Path,
|
|
23
|
+
required=True,
|
|
24
|
+
help='Path to the entry XSD file (e.g. IEC61850-6-100.xsd)',
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
'--out-dir',
|
|
28
|
+
type=Path,
|
|
29
|
+
required=True,
|
|
30
|
+
help='Output directory for generated .ts files',
|
|
31
|
+
)
|
|
32
|
+
args = parser.parse_args(argv)
|
|
33
|
+
|
|
34
|
+
entry: Path = args.entry
|
|
35
|
+
out_dir: Path = args.out_dir
|
|
36
|
+
|
|
37
|
+
if not entry.exists():
|
|
38
|
+
print(f'Error: XSD file not found: {entry}', file=sys.stderr)
|
|
39
|
+
sys.exit(1)
|
|
40
|
+
|
|
41
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
|
|
43
|
+
# Phase 1: Parse
|
|
44
|
+
print(f'Loading schema: {entry}')
|
|
45
|
+
schema = xmlschema.XMLSchema(str(entry))
|
|
46
|
+
|
|
47
|
+
# Phase 2: Collect
|
|
48
|
+
print('Collecting elements...')
|
|
49
|
+
expander = XsiTypeExpander(schema)
|
|
50
|
+
elements = collect(schema, expander=expander)
|
|
51
|
+
print(f' Found {len(elements)} elements')
|
|
52
|
+
|
|
53
|
+
# Phase 2b: Orphan injection
|
|
54
|
+
mapping = load_parent_mapping(entry)
|
|
55
|
+
# Determine root before injection so orphans don't compete
|
|
56
|
+
roots = sorted(n for n, e in elements.items() if not e.parents)
|
|
57
|
+
root_candidate = max(roots, key=lambda n: len(elements[n].children)) if roots else None
|
|
58
|
+
|
|
59
|
+
orphans_injected = 0
|
|
60
|
+
unmapped_count = 0
|
|
61
|
+
if mapping:
|
|
62
|
+
orphans = detect_orphans(elements, root_candidate or '')
|
|
63
|
+
if orphans:
|
|
64
|
+
unmapped = inject_orphan_parents(elements, mapping, root_name=root_candidate or '')
|
|
65
|
+
orphans_injected = len(orphans) - len(unmapped)
|
|
66
|
+
unmapped_count = len(unmapped)
|
|
67
|
+
for name in unmapped:
|
|
68
|
+
print(f' WARNING: unmapped orphan element: {name}', file=sys.stderr)
|
|
69
|
+
|
|
70
|
+
# Phase 2c: Mapped attribute injection (attribute-mapping.json sidecar)
|
|
71
|
+
attr_mapping = load_attr_mapping(entry)
|
|
72
|
+
mapped_attrs_injected = inject_mapped_attributes(schema, elements, attr_mapping)
|
|
73
|
+
if mapped_attrs_injected:
|
|
74
|
+
print(f' Mapped attributes injected: {mapped_attrs_injected}')
|
|
75
|
+
|
|
76
|
+
# Phase 3: Derive
|
|
77
|
+
print('Deriving graphs...')
|
|
78
|
+
descendants, ancestors = derive_graph(elements)
|
|
79
|
+
root_element = derive_root_element(elements, override=root_candidate)
|
|
80
|
+
singleton_elements = derive_singleton_elements(elements, root_element)
|
|
81
|
+
identity_fields = derive_identity_fields(elements)
|
|
82
|
+
for name, fields in identity_fields.items():
|
|
83
|
+
elements[name].identity_fields = fields
|
|
84
|
+
print(f' Root: {root_element}')
|
|
85
|
+
print(f' Singletons: {len(singleton_elements)}')
|
|
86
|
+
|
|
87
|
+
# Phase 4: Emit
|
|
88
|
+
def_path = out_dir / 'definition.generated.ts'
|
|
89
|
+
const_path = out_dir / 'constants.generated.ts'
|
|
90
|
+
types_path = out_dir / 'types.generated.ts'
|
|
91
|
+
|
|
92
|
+
print('Emitting files...')
|
|
93
|
+
emit_definition(elements, def_path)
|
|
94
|
+
emit_constants(elements, descendants, ancestors, root_element, singleton_elements, const_path)
|
|
95
|
+
emit_types(elements, types_path)
|
|
96
|
+
|
|
97
|
+
print(f' {def_path}')
|
|
98
|
+
print(f' {const_path}')
|
|
99
|
+
print(f' {types_path}')
|
|
100
|
+
warnings = f', {unmapped_count} unmapped warnings' if unmapped_count else ', 0 unmapped warnings'
|
|
101
|
+
print(f'Done. {len(elements)} elements, {orphans_injected} orphans injected{warnings}, ROOT={root_element}')
|
|
102
|
+
if __name__ == '__main__':
|
|
103
|
+
main()
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Collector — recursive walk of XSD schema into ElementDef IR.
|
|
2
|
+
|
|
3
|
+
Phase 2 of the pipeline: Parse → **Collect** → Derive → Emit.
|
|
4
|
+
"""
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from generate.extractors.attributes import extract_attributes
|
|
8
|
+
from generate.extractors.children import extract_children, extract_choices, extract_text_content, iter_child_elements
|
|
9
|
+
from generate.extractors.constraints import extract_constraints
|
|
10
|
+
from generate.extractors.docs import extract_docs
|
|
11
|
+
from generate.extractors.namespace import extract_namespace
|
|
12
|
+
from generate.ir import ElementDef
|
|
13
|
+
from generate.xsi_type import XsiTypeExpander
|
|
14
|
+
def collect(schema: Any, expander: XsiTypeExpander | None = None) -> dict[str, ElementDef]:
|
|
15
|
+
"""Walk all elements across all schemas (root + imported/included) into a flat IR dict.
|
|
16
|
+
|
|
17
|
+
When *expander* is provided, abstract-typed element slots are additionally enriched
|
|
18
|
+
with their ``xsi:type`` substitution variants (children/attributes unioned in, and
|
|
19
|
+
variant-only children walked for recursion). When omitted, behaviour is unchanged.
|
|
20
|
+
|
|
21
|
+
xmlschema API:
|
|
22
|
+
XMLSchemaBase.elements: NamespaceView — global elements of this schema
|
|
23
|
+
XMLSchemaBase.includes: dict[str, XMLSchemaBase] — included schemas
|
|
24
|
+
XMLSchemaBase.imports: dict[str, XMLSchemaBase | None] — imported schemas
|
|
25
|
+
"""
|
|
26
|
+
elements: dict[str, ElementDef] = {}
|
|
27
|
+
|
|
28
|
+
def walk(xsd_elem: Any, parent_name: str | None = None, visited: set[int] | None = None) -> None:
|
|
29
|
+
if visited is None:
|
|
30
|
+
visited = set()
|
|
31
|
+
|
|
32
|
+
name = getattr(xsd_elem, 'local_name', None)
|
|
33
|
+
if not name:
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
# Already seen this element by tag name — always add parent link first,
|
|
37
|
+
# then id-guard the recursion to prevent infinite loops.
|
|
38
|
+
if name in elements:
|
|
39
|
+
existing = elements[name]
|
|
40
|
+
if parent_name and parent_name not in existing.parents:
|
|
41
|
+
existing.parents.append(parent_name)
|
|
42
|
+
if expander is not None:
|
|
43
|
+
expander.expand(
|
|
44
|
+
xsd_elem,
|
|
45
|
+
existing.attr_sequence,
|
|
46
|
+
existing.attributes,
|
|
47
|
+
existing.child_sequence,
|
|
48
|
+
existing.children,
|
|
49
|
+
)
|
|
50
|
+
elem_id = id(xsd_elem)
|
|
51
|
+
if elem_id in visited:
|
|
52
|
+
return
|
|
53
|
+
visited.add(elem_id)
|
|
54
|
+
# Still recurse into children to discover deeper elements
|
|
55
|
+
for child_xsd in iter_child_elements(xsd_elem):
|
|
56
|
+
walk(child_xsd, parent_name=name, visited=visited)
|
|
57
|
+
if expander is not None:
|
|
58
|
+
for child_xsd in expander.iter_variant_child_elements(xsd_elem):
|
|
59
|
+
walk(child_xsd, parent_name=name, visited=visited)
|
|
60
|
+
return
|
|
61
|
+
|
|
62
|
+
elem_id = id(xsd_elem)
|
|
63
|
+
if elem_id in visited:
|
|
64
|
+
return
|
|
65
|
+
visited.add(elem_id)
|
|
66
|
+
|
|
67
|
+
attr_seq, attr_any, attrs = extract_attributes(xsd_elem)
|
|
68
|
+
child_seq, child_any, children = extract_children(xsd_elem)
|
|
69
|
+
|
|
70
|
+
if expander is not None:
|
|
71
|
+
expander.expand(xsd_elem, attr_seq, attrs, child_seq, children)
|
|
72
|
+
|
|
73
|
+
elements[name] = ElementDef(
|
|
74
|
+
tag=name,
|
|
75
|
+
namespace=extract_namespace(xsd_elem),
|
|
76
|
+
documentation=extract_docs(xsd_elem),
|
|
77
|
+
parents=[parent_name] if parent_name else [],
|
|
78
|
+
attr_sequence=attr_seq,
|
|
79
|
+
attr_any=attr_any,
|
|
80
|
+
attributes=attrs,
|
|
81
|
+
child_sequence=child_seq,
|
|
82
|
+
child_any=child_any,
|
|
83
|
+
children=children,
|
|
84
|
+
choices=extract_choices(xsd_elem),
|
|
85
|
+
constraints=extract_constraints(xsd_elem),
|
|
86
|
+
text_content=extract_text_content(xsd_elem),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
for child_xsd in iter_child_elements(xsd_elem):
|
|
90
|
+
walk(child_xsd, parent_name=name, visited=visited)
|
|
91
|
+
if expander is not None:
|
|
92
|
+
for child_xsd in expander.iter_variant_child_elements(xsd_elem):
|
|
93
|
+
walk(child_xsd, parent_name=name, visited=visited)
|
|
94
|
+
|
|
95
|
+
# Walk imported/included schemas FIRST so standard elements (e.g. scl:LNode)
|
|
96
|
+
# are registered with their canonical namespace before the root extension
|
|
97
|
+
# schema re-declares them as local elements (e.g. eIEC61850-6-100:LNode).
|
|
98
|
+
for sub_schema in _iter_sub_schemas(schema):
|
|
99
|
+
for root_elem in sub_schema.elements.values():
|
|
100
|
+
walk(root_elem)
|
|
101
|
+
|
|
102
|
+
# Walk root schema's global elements (adds extension elements + parent links)
|
|
103
|
+
for root_elem in schema.elements.values():
|
|
104
|
+
walk(root_elem)
|
|
105
|
+
|
|
106
|
+
return elements
|
|
107
|
+
def _iter_sub_schemas(schema: Any):
|
|
108
|
+
"""Yield all imported and included schemas.
|
|
109
|
+
|
|
110
|
+
xmlschema API:
|
|
111
|
+
XMLSchemaBase.includes: dict[str, XMLSchemaBase]
|
|
112
|
+
XMLSchemaBase.imports: dict[str, XMLSchemaBase | None]
|
|
113
|
+
"""
|
|
114
|
+
seen: set[int] = set()
|
|
115
|
+
|
|
116
|
+
includes = getattr(schema, 'includes', None) or {}
|
|
117
|
+
if hasattr(includes, 'values'):
|
|
118
|
+
for included in includes.values():
|
|
119
|
+
if included is not None and id(included) not in seen:
|
|
120
|
+
seen.add(id(included))
|
|
121
|
+
yield included
|
|
122
|
+
|
|
123
|
+
imports = getattr(schema, 'imports', None) or {}
|
|
124
|
+
if hasattr(imports, 'values'):
|
|
125
|
+
for imported in imports.values():
|
|
126
|
+
if imported is not None and id(imported) not in seen:
|
|
127
|
+
seen.add(id(imported))
|
|
128
|
+
yield imported
|