Skip to content

Commit

Permalink
build(types): add script to generate Config types
Browse files Browse the repository at this point in the history
Add script `packages/mermaid/scripts/create-types-from-json-schema.mjs`
to automatically generate the TypeScript definition for `MermaidConfig`
from the `MermaidConfig` JSON Schema at
`packages/mermaid/src/schemas/config.schema.yaml`.

To do this, we are using this library
[`json-schema-to-typescript`][1], which is also used by Webpack to
generate their types from their JSON Schema.

In order to make sure that this isn't a breaking change, the script
makes all fields **optional**, as that is what the original typescript
file has.

Additionally, I've put in some custom logic into the script, so that
the exact same order is used for the TypeScript file, to make the
`git diff` easier to review. In the future, we can remove this custom
logic, once we no longer need to worry about `git merge` conflicts.

[1]: https://github.com/bcherny/json-schema-to-typescript
  • Loading branch information
aloisklink committed Feb 20, 2023
1 parent f9d3d53 commit a156446
Show file tree
Hide file tree
Showing 6 changed files with 619 additions and 1 deletion.
4 changes: 4 additions & 0 deletions .eslintrc.cjs
Expand Up @@ -38,6 +38,10 @@ module.exports = {
'lodash',
'unicorn',
],
ignorePatterns: [
// this file is automatically generated by `pnpm run --filter mermaid types:build-config`
'packages/mermaid/src/config.type.ts',
],
rules: {
curly: 'error',
'no-console': 'error',
Expand Down
4 changes: 3 additions & 1 deletion .prettierignore
Expand Up @@ -4,4 +4,6 @@ cypress/platform/xss3.html
coverage
# Autogenerated by PNPM
pnpm-lock.yaml
stats
stats
# Autogenerated by `pnpm run --filter mermaid types:build-config`
packages/mermaid/src/config.type.ts
1 change: 1 addition & 0 deletions packages/mermaid/.lintstagedrc.mjs
Expand Up @@ -4,4 +4,5 @@ export default {
'src/docs/**': ['pnpm --filter mermaid run docs:build --git'],
'src/docs.mts': ['pnpm --filter mermaid run docs:build --git'],
'src/(defaultConfig|config|mermaidAPI).ts': ['pnpm --filter mermaid run docs:build --git'],
'src/schemas/config.schema.yaml': ['pnpm --filter mermaid run types:verify-config'],
};
5 changes: 5 additions & 0 deletions packages/mermaid/package.json
Expand Up @@ -32,6 +32,8 @@
"docs:dev": "pnpm docs:pre:vitepress && concurrently \"vitepress dev src/vitepress\" \"ts-node-esm src/docs.mts --watch --vitepress\"",
"docs:serve": "pnpm docs:build:vitepress && vitepress serve src/vitepress",
"docs:spellcheck": "cspell --config ../../cSpell.json \"src/docs/**/*.md\"",
"types:build-config": "node scripts/create-types-from-json-schema.mjs",
"types:verify-config": "node scripts/create-types-from-json-schema.mjs --verify",
"release": "pnpm build",
"prepublishOnly": "cpy '../../README.*' ./ --cwd=. && pnpm -w run build"
},
Expand Down Expand Up @@ -70,6 +72,7 @@
"web-worker": "^1.2.0"
},
"devDependencies": {
"@adobe/jsonschema2md": "^7.1.4",
"@types/cytoscape": "^3.19.9",
"@types/d3": "^7.4.0",
"@types/dompurify": "^2.4.0",
Expand All @@ -81,6 +84,7 @@
"@types/uuid": "^9.0.0",
"@typescript-eslint/eslint-plugin": "^5.42.1",
"@typescript-eslint/parser": "^5.42.1",
"ajv": "^8.11.2",
"chokidar": "^3.5.3",
"concurrently": "^7.5.0",
"coveralls": "^3.1.1",
Expand All @@ -90,6 +94,7 @@
"jison": "^0.4.18",
"js-base64": "^3.7.2",
"jsdom": "^21.0.0",
"json-schema-to-typescript": "^11.0.3",
"micromatch": "^4.0.5",
"path-browserify": "^1.0.1",
"prettier": "^2.7.1",
Expand Down
234 changes: 234 additions & 0 deletions packages/mermaid/scripts/create-types-from-json-schema.mjs
@@ -0,0 +1,234 @@
/**
* Script to load Mermaid Config JSON Schema from YAML and to:
*
* - Validate JSON Schema
*
* Then to generate:
*
* - config.types.ts TypeScript file
*/

/* eslint-disable no-console */

import { readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import assert from 'node:assert';

import { load, JSON_SCHEMA } from 'js-yaml';
import { compile } from 'json-schema-to-typescript';

import Ajv2019 from 'ajv/dist/2019.js';

// options for running the main command
const verifyOnly = process.argv.includes('--verify');

/**
* @typedef {import("ajv/dist/2019.js").JSONSchemaType} JSONSchemaType
* @typedef {import("ajv/dist/2019.js").AnySchema} AnySchema
* @typedef {import("../src/config.type").MermaidConfig} MermaidConfig
* This type might be slightly wrong, since we're creating it in this script.
* @typedef {import("../src/config.type").BaseDiagramConfig} BaseDiagramConfig
*
*/

/**
* All of the keys in the mermaid config that have a mermaid diagram config.
*/
const MERMAID_CONFIG_DIAGRAM_KEYS = [
'flowchart',
'sequence',
'gantt',
'journey',
'class',
'state',
'er',
'pie',
'requirement',
'mindmap',
'timeline',
'gitGraph',
'c4',
];

/**
* Loads the MermaidConfig JSON schema YAML file.
*
* @returns {Promise<unknown>} - The loaded JSON Schema, use {@link validateSchema} to confirm
* it is a valid JSON Schema.
*/
async function loadJsonSchemaFromYaml() {
const configSchemaFile = join('src', 'schemas', 'config.schema.yaml');
const contentsYaml = await readFile(configSchemaFile, { encoding: 'utf8' });
const jsonSchema = load(contentsYaml, {
filename: configSchemaFile,
// only allow JSON types in our YAML doc (will probably be default in YAML 1.3)
// e.g. `true` will be parsed a boolean `true`, `True` will be parsed as string `"True"`.
schema: JSON_SCHEMA,
});
return jsonSchema;
}

/**
* Asserts that the given object is a valid JSON Schema object.
*
* @param {unknown} jsonSchema - The object to validate as JSON Schema 2019-09
* @throws {Error} if the given object is invalid.
* @returns {asserts jsonSchema is JSONSchemaType<MermaidConfig>}
*/
function validateSchema(jsonSchema) {
const ajv = new Ajv2019({
allErrors: true,
allowUnionTypes: true,
strict: true,
});

ajv.addKeyword({
keyword: 'meta:enum', // used by jsonschema2md (in docs.mts script)
errors: false,
});
ajv.addKeyword({
keyword: 'tsType', // used by json-schema-to-typescript
errors: false,
});

ajv.compile(jsonSchema);
}

/**
* Generate a typescript definition from a JSON Schema using json-schema-to-typescript.
*
* @param {JSONSchemaType<MermaidConfig>} mermaidConfigSchema - The input JSON Schema.
*/
async function generateTypescript(mermaidConfigSchema) {
/**
* Replace all usages of `allOf` with `extends`.
*
* `extends` is only valid JSON Schema in very old versions of JSON Schema.
* However, json-schema-to-typescript creates much nicer types when using
* `extends`, so we should use them instead when possible.
*
* @param {JSONSchemaType} schema - The input schema.
* @returns {JSONSchemaType} The schema with `allOf` replaced with `extends`.
*/
function replaceAllOfWithExtends(schema) {
if (schema['allOf']) {
const { allOf, ...schemaWithoutAllOf } = schema;
return {
...schemaWithoutAllOf,
extends: allOf,
};
}
return schema;
}

/**
* For backwards compatibility with older Mermaid Typescript defs,
* we need to make all value optional instead of required.
*
* This is because the `MermaidConfig` type is used as an input, and everything is optional,
* since all the required values have default values.s
*
* In the future, we should make make the input to Mermaid `Partial<MermaidConfig>`.
*
* @todo TODO: Remove this function when Mermaid releases a new breaking change.
* @param {JSONSchemaType} schema - The input schema.
* @returns {JSONSchemaType} The schema with all required values removed.
*/
function removeRequired(schema) {
return { ...schema, required: [] };
}

/**
* This is a temporary hack to control the order the types are generated in.
*
* By default, json-schema-to-typescript outputs the $defs in the order they
* are used, then any unused schemas at the end.
*
* **The only purpose of this function is to make the `git diff` simpler**
* **We should remove this later to simplify the code**
*
* @todo TODO: Remove this function in a future PR.
* @param {JSONSchemaType} schema - The input schema.
* @returns {JSONSchemaType} The schema with all `$ref`s removed.
*/
function unrefSubschemas(schema) {
return {
...schema,
properties: Object.fromEntries(
Object.entries(schema.properties).map(([key, propertySchema]) => {
if (MERMAID_CONFIG_DIAGRAM_KEYS.includes(key)) {
const { $ref, ...propertySchemaWithoutRef } = propertySchema;
const [
_root, // eslint-disable-line @typescript-eslint/no-unused-vars
_defs, // eslint-disable-line @typescript-eslint/no-unused-vars
defName,
] = $ref.split('/');
return [
key,
{
...propertySchemaWithoutRef,
tsType: defName,
},
];
}
return [key, propertySchema];
})
),
};
}

assert.ok(mermaidConfigSchema.$defs);
const modifiedSchema = {
...unrefSubschemas(removeRequired(mermaidConfigSchema)),

$defs: Object.fromEntries(
Object.entries(mermaidConfigSchema.$defs).map(([key, subSchema]) => {
return [key, removeRequired(replaceAllOfWithExtends(subSchema))];
})
),
};

const typescriptFile = await compile(
modifiedSchema, // json-schema-to-typescript only allows JSON Schema 4 as input type
'MermaidConfig',
{
additionalProperties: false, // in JSON Schema 2019-09, these are called `unevaluatedProperties`
unreachableDefinitions: true, // definition for FontConfig is unreachable
}
);

// TODO, should we somehow use the functions from `docs.mts` instead?
if (verifyOnly) {
const originalFile = await readFile('./src/config.type.ts', { encoding: 'utf-8' });
if (typescriptFile !== originalFile) {
console.error('❌ Error: ./src/config.type.ts will be changed.');
console.error("Please run 'pnpm run --filter mermaid types:build-config' to update this");
process.exitCode = 1;
} else {
console.log('✅ ./src/config.type.ts will be unchanged');
}
} else {
console.log('Writing typescript file to ./src/config.type.ts');
await writeFile('./src/config.type.ts', typescriptFile, { encoding: 'utf8' });
}
}

/** Main function */
async function main() {
if (verifyOnly) {
console.log(
'Verifying that ./src/config.type.ts is in sync with src/schemas/config.schema.yaml'
);
}

const configJsonSchema = await loadJsonSchemaFromYaml();
validateSchema(configJsonSchema);

// Generate types from JSON Schema
await generateTypescript(configJsonSchema);
}

main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

0 comments on commit a156446

Please sign in to comment.