Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 40 additions & 18 deletions tools/src/main/js/bundler/src/bundle.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import {basename, dirname, relative, resolve} from 'node:path';
import {ok} from 'node:assert/strict';

import {JSON_SCHEMA_RE, getJsonfile, posixPath, findDuplicateValues} from './helpers/common.js';
import {
escapeJsonPointer, isJsonPointer,
posixPath,
JSON_SCHEMA_RE, getJsonfile,
objectGetOwnPath, PATH_NOT_FOUND,
findDuplicateValues
} from './helpers/common.js';
import {
escapeJsonPointer, isJsonPointer, jsonPointer2stack,
PREFIX as JP_PREFIX, ROOT_POINTER
} from './helpers/json-pointer-spec.js';
import {
Expand Down Expand Up @@ -86,16 +91,22 @@ class ModuleNameCollisionError extends Error {
* @param {Iterable<string>} includeFiles Absolute path to schema files that shall be bundled.
* @param {string} targetFile Absolute path to target schema file.
* @returns {Promise<BundleResult>}
* @throws {RangeError} on a local ref whose fragment is not a JSON Pointer (e.g. an anchor
*/
export const bundle = async (entryFile, includeFiles, targetFile) => {
const entryDir = dirname(entryFile);
const targetDir = dirname(targetFile);

/** @type {ReadonlyMap<string, string>} */
const fileModuleNames = Object.freeze(new Map(
[entryFile, ...includeFiles].map(f => [f, makeModuleName(basename(f))])
));
const _dupModuleNames = findDuplicateValues(fileModuleNames)
/** @type {ReadonlyMap<string, Readonly<{module:string, schema:Schema}>>} */
const fileModuleSchema = Object.freeze(new Map(await Promise.all(
[entryFile, ...includeFiles].map(async f => {
const module = makeModuleName(basename(f));
const schema = await getJsonfile(f);
ok(isSchemaObject(schema), `Not schema: ${f}`);
return [f, Object.freeze({module, schema})];
}))));
const _dupModuleNames = findDuplicateValues(new Map(
fileModuleSchema.entries().map(([f, {module}]) => [f, module])));
if (_dupModuleNames.size) {
throw new ModuleNameCollisionError(_dupModuleNames);
}
Expand All @@ -118,6 +129,8 @@ export const bundle = async (entryFile, includeFiles, targetFile) => {
*/
const rewiredMap = new Map();

/** @type {WeakSet<Schema>} */
const _rewireRefsSeen = new WeakSet();
/**
* Rewire refs in place.
*
Expand All @@ -128,6 +141,8 @@ export const bundle = async (entryFile, includeFiles, targetFile) => {
* @returns {void}
*/
const rewireRefs = (schema, sourceFile) => {
if (_rewireRefsSeen.has(schema)) return;
_rewireRefsSeen.add(schema);
const sourceDir = dirname(sourceFile);
for (const k of REF_KEYWORDS) {
const v = schema[k];
Expand All @@ -142,8 +157,8 @@ export const bundle = async (entryFile, includeFiles, targetFile) => {
const refFile = path
? resolve(sourceDir, ...path.split(URI_PATH_SEP))
: sourceFile;
const moduleName = fileModuleNames.get(refFile);
if (moduleName) {
const refMS = fileModuleSchema.get(refFile)
if (refMS) {
if (frag !== null && !isJsonPointer(frag)) {
// currently dont support anchors - only JSON pointer.
throw new RangeError(`Unsupported ref fragment: ${v}`);
Expand All @@ -152,7 +167,7 @@ export const bundle = async (entryFile, includeFiles, targetFile) => {
path: null,
frag: (refFile === entryFile && !frag?.startsWith(JP_DEFS_PREFIX)
? ROOT_POINTER
: `${JP_DEFS_PREFIX}${escapeJsonPointer(moduleName)}`
: `${JP_DEFS_PREFIX}${escapeJsonPointer(refMS.module)}`
) + (frag ?? '')
};
} else {
Expand All @@ -166,25 +181,33 @@ export const bundle = async (entryFile, includeFiles, targetFile) => {
rewiredMap.set(refJoin({path: refFile, frag}), rewired);
if (rewiredRS.path) {
externals.add(rewired);
} else if (refMS) {
const tSchema = objectGetOwnPath(refMS.schema, jsonPointer2stack(frag ?? ROOT_POINTER));
if (tSchema === PATH_NOT_FOUND) {
throw RangeError(`Missing target for ref: ${v}`);
}
if (isSchemaObject(tSchema)) {
rewireRefs(tSchema, refFile);
} else if (typeof tSchema !== 'boolean') {
throw new RangeError(`Ref target is not schema nor boolean: ${v}`)
}
}
}
subschemas(schema).forEach(([s]) => rewireRefs(s, sourceFile));
}
};
fileModuleSchema.forEach(({schema}, sourceFile) => rewireRefs(schema, sourceFile));

const schema = await getJsonfile(entryFile);
ok(isSchemaObject(schema), `Not schema: ${entryFile}`);
const {schema, module: entryModuleName} = fileModuleSchema.get(entryFile);
if (URL.canParse(schema[ID_KEYWORD])) {
schema[ID_KEYWORD] = new URL(
posixPath(relative(entryDir, targetFile)),
schema[ID_KEYWORD]
).toString();
}
rewireRefs(schema, entryFile);
const schemaDefsOrig = schema[DEFS_KEYWORD];
schema[DEFS_KEYWORD] = Object.fromEntries( // sort for deterministic order
[...fileModuleNames.values()].sort().map(m => [m, undefined]));
[...fileModuleSchema.values().map(({module}) => module)].sort().map(m => [m, undefined]));

const entryModuleName = fileModuleNames.get(entryFile);
if (schemaDefsOrig) {
schema[DEFS_KEYWORD][entryModuleName] = {
[DEFS_KEYWORD]: schemaDefsOrig,
Expand All @@ -198,9 +221,8 @@ export const bundle = async (entryFile, includeFiles, targetFile) => {
delete schema[DEFS_KEYWORD][entryModuleName];
}

for (const [includeFile, moduleName] of fileModuleNames) {
for (const [includeFile, {module: moduleName, schema: includeSchema}] of fileModuleSchema) {
if (includeFile === entryFile) continue;
const includeSchema = await getJsonfile(includeFile);
ok(isSchemaObject(includeSchema), `Not schema: ${includeFile}`);
delete includeSchema[SCHEMA_KEYWORD];
delete includeSchema[ID_KEYWORD];
Expand Down
6 changes: 6 additions & 0 deletions tools/src/main/js/bundler/test/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ The modules exercise the tree-shaker as follows:
items *after* the last reached one (`allOf/4`) may be truncated. The unreferenced `examples` of
`choice` are removed. `$defs/keptViaArray` is kept transitively via `allOf/1`; `$defs/droppedDef`
and the nested `oneOf/*/$defs/droppedDef` are removed.
- `used-custom-defs` — definitions live in non-standard containers (`custom-defs`,
`even-more-custom-defs`) instead of `$defs`. The entry refs `#/custom-defs/customKept`, which refs
`#/even-more-custom-defs/deepKept`. Both containers must be traversed; `customKept` and `deepKept`
are kept, `customDropped`/`deepDropped` and the module body are dropped, the containers are hollowed.
- `unused` — nothing reachable refers to it; the whole module is removed.
- `dynamic-anchor` — declares a `$dynamicAnchor`; the tree-shaker cannot follow anchors and keeps it as is.

Expand All @@ -66,6 +70,8 @@ The entry schema additionally covers:
*properties* of a module rather than its `$defs` (see `used-properties-only` above).
- refs into *array items* of a module (`properties.middle`, `properties.lastChoice`;
see `used-array-items` above).
- a ref into a *non-standard* definitions container of a module (`properties.customDef`;
see `used-custom-defs` above).
- refs to an empty `{}`, a `$comment`-only and an annotation-only schema
(`properties.anyValue`, `properties.todoValue`, `properties.annotatedValue`) — reached, so
they must survive and must *not* be hollowed.
Expand Down
6 changes: 6 additions & 0 deletions tools/src/main/js/bundler/test/fixtures/main.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,12 @@
"title": "Annotated value",
"description": "An annotation-only schema.",
"$ref": "modules/used-subdefs-only.schema.json#/$defs/keptAnnotationsOnly"
},
"customDef": {
"$comment": "Ref into a NON-STANDARD definitions container (`custom-defs`, not `$defs`) of a module, which itself refs into yet another non-standard container (`even-more-custom-defs`). Both must be followed; unreferenced siblings in both containers and the module body are dropped.",
"title": "Custom def",
"description": "A value defined in a non-standard definitions container of another module.",
"$ref": "modules/used-custom-defs.schema.json#/custom-defs/customKept"
}
},
"$defs": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "http://example.com/test/modules/used-custom-defs.schema.json",
"$comment": "FIXTURE: definitions live in NON-STANDARD containers (`custom-defs`, `even-more-custom-defs`) instead of `$defs`. The entry refs into `custom-defs`, which in turn refs into `even-more-custom-defs`. Both containers must be traversed by the ref-rewriter and the tree-shaker; the reached defs are kept, the unreferenced siblings are dropped, the containers are hollowed, and the body of this module is dropped.",
"title": "Used custom defs",
"description": "A module whose definitions live in non-standard containers.",
"type": "object",
"properties": {
"body": {
"$comment": "Lives in the dropped body -> this ref must NOT make `custom-defs/customDropped` reachable.",
"title": "Body",
"description": "The body of this module.",
"$ref": "#/custom-defs/customDropped"
}
},
"custom-defs": {
"customKept": {
"$comment": "Reached from main via a JSON pointer into a non-`$defs` container. Transitively keeps `even-more-custom-defs/deepKept`.",
"title": "Custom kept",
"description": "A definition in a non-standard container, reached from the root schema.",
"type": "object",
"properties": {
"deep": {
"$comment": "Internal ref into ANOTHER non-standard container -> must be rewired and keep `deepKept`.",
"title": "Deep",
"description": "A value defined in an even more custom container.",
"$ref": "#/even-more-custom-defs/deepKept"
}
}
},
"customDropped": {
"$comment": "Only referenced from the dropped body -> shaken away.",
"title": "Custom dropped",
"description": "A definition nothing reachable refers to.",
"type": "null"
}
},
"even-more-custom-defs": {
"deepKept": {
"$comment": "Only reachable via `custom-defs/customKept`.",
"title": "Deep kept",
"description": "A string value.",
"type": "string"
},
"deepDropped": {
"$comment": "Not reachable -> shaken away.",
"title": "Deep dropped",
"description": "A definition nothing reachable refers to.",
"type": "null"
}
}
}
Loading
Loading