From 501b49c7df971c15501827c6c7e8f2f7afded1c5 Mon Sep 17 00:00:00 2001 From: Peter Nham Date: Tue, 31 Mar 2026 16:57:45 +1100 Subject: [PATCH] feat(adding a new generate option): add single file mode add single file mode which enables generating onyl a single markdown file per schema rather than having all individual fields as separate files too. fix #531. Addresses part of #296 --- lib/index.js | 22 +++++++++- lib/markdownBuilder.js | 84 +++++++++++++++++++++++------------- test/api.test.js | 13 ++++++ test/markdownBuilder.test.js | 23 ++++++++++ 4 files changed, 110 insertions(+), 32 deletions(-) diff --git a/lib/index.js b/lib/index.js index c58f852e..df5718e0 100644 --- a/lib/index.js +++ b/lib/index.js @@ -24,6 +24,7 @@ import build from './markdownBuilder.js'; import { writereadme, writemarkdown } from './writeMarkdown.js'; import readme from './readmeBuilder.js'; import loader from './schemaProxy.js'; +import s from './symbols.js'; import writeSchema from './writeSchema.js'; const __filename = fileURLToPath(import.meta.url); @@ -74,6 +75,8 @@ const { debug, info, error } = console; * in markdown. * @param {string[]} [options.skipProperties=[]] - (optional) Name of a default property to * skip in markdown. + * @param {boolean} [options.singleFile=false] - (optional) Generate a single Markdown file + * per schema, inlining all property documentation instead of creating separate files. * @returns {GeneratedOutput} List of raw markdown that were generated from input schema. */ export function jsonschema2md(schema, options) { @@ -90,6 +93,7 @@ export function jsonschema2md(schema, options) { includeProperties, header, skipProperties, + singleFile, } = options; if (!schema || typeof schema !== 'object') { throw Error('Input is not valid. Provide JSON schema either as Object or Array.'); @@ -123,7 +127,7 @@ export function jsonschema2md(schema, options) { const schemaLoader = loader(); // collect data about the schemas and turn everything into a big object - const schemas = pipe( + let schemas = pipe( normalized, // Checking if data contains the file path or its contents (JSON schema) map(({ fileName, fullPath, content }) => { @@ -135,6 +139,10 @@ export function jsonschema2md(schema, options) { traverse, ); + if (singleFile) { + schemas = schemas.filter((item) => !item[s.parent]); + } + /** * @type {GeneratedOutput} */ @@ -175,6 +183,7 @@ export function jsonschema2md(schema, options) { includeProperties, exampleFormat, skipProperties, + singleFile, rewritelinks: (origin) => { const mddir = out; if (!mddir) { @@ -283,7 +292,14 @@ export async function main(args) { .alias('s', 'skip') .array('s') .describe('s', 'name of a default property to skip in markdown (may be used multiple times), e.g. -s typefact -s proptable') - .default('s', []); + .default('s', []) + + .option('S', { + alias: 'single-file', + type: 'boolean', + describe: 'generate a single Markdown file per schema, inlining all property documentation', + default: false, + }); const links = pipe( iter(argv), @@ -303,6 +319,7 @@ export async function main(args) { const includeProperties = argv.p; const header = argv.h; const skipProperties = argv.s; + const singleFile = argv.S; const schemaExtension = argv.e; @@ -335,6 +352,7 @@ export async function main(args) { includeProperties, header, skipProperties, + singleFile, }); return 1; diff --git a/lib/markdownBuilder.js b/lib/markdownBuilder.js index fcf381b4..c308f315 100644 --- a/lib/markdownBuilder.js +++ b/lib/markdownBuilder.js @@ -43,8 +43,20 @@ export default function build({ includeProperties = [], rewritelinks = (x) => x, exampleFormat = 'json', - skipProperties = [], + skipProperties: rawSkipProperties = [], + singleFile = false, } = {}) { + const skipProperties = singleFile + ? [...new Set([...rawSkipProperties, 'definedinfact'])] + : rawSkipProperties; + + function schemaLink(url, title, children) { + if (singleFile) { + return children; + } + return link(url, title, children); + } + const formats = { 'date-time': { label: i18n`date time`, @@ -322,17 +334,22 @@ export default function build({ * @param {*} param0 */ function makepropheader(required = [], ispattern = false, slugger) { - return ([name, definition]) => tableRow([ - tableCell(ispattern ? inlineCode(name) : link(`#${slugger.slug(name)}`, '', text(name))), // Property - tableCell(type(definition)), - tableCell(text(required.indexOf(name) > -1 ? i18n`Required` : i18n`Optional`)), - tableCell(nullable(definition)), - tableCell(link( - `${definition[s.slug]}.md`, - `${definition[s.id]}#${definition[s.pointer]}`, - text(definition[s.titles] && definition[s.titles][0] ? definition[s.titles][0] : i18n`Untitled schema`), - )), - ]); + return ([name, definition]) => { + const cells = [ + tableCell(ispattern ? inlineCode(name) : link(`#${slugger.slug(name)}`, '', text(name))), // Property + tableCell(type(definition)), + tableCell(text(required.indexOf(name) > -1 ? i18n`Required` : i18n`Optional`)), + tableCell(nullable(definition)), + ]; + if (!singleFile) { + cells.push(tableCell(schemaLink( + `${definition[s.slug]}.md`, + `${definition[s.id]}#${definition[s.pointer]}`, + text(definition[s.titles] && definition[s.titles][0] ? definition[s.titles][0] : i18n`Untitled schema`), + ))); + } + return tableRow(cells); + }; } /** @@ -353,27 +370,33 @@ export default function build({ const additionalproprows = (() => { if (additionalProps) { const any = additionalProps === true; - return [tableRow([ + const cells = [ tableCell(text(i18n`Additional Properties`)), tableCell(any ? text('Any') : type(additionalProps)), tableCell(text(i18n`Optional`)), tableCell(any ? text('can be null') : nullable(additionalProps)), - tableCell(any ? text('') : link(`${additionalProps[s.slug]}.md`, `${additionalProps[s.id]}#${additionalProps[s.pointer]}`, text(additionalProps[s.titles][0] || i18n`Untitled schema`))), - ])]; + ]; + if (!singleFile) { + cells.push(tableCell(any ? text('') : schemaLink(`${additionalProps[s.slug]}.md`, `${additionalProps[s.id]}#${additionalProps[s.pointer]}`, text(additionalProps[s.titles][0] || i18n`Untitled schema`)))); + } + return [tableRow(cells)]; } return []; })(); // const proprows = flist(map(iter(props || {}), makepropheader(required))); + const headerCells = [ + tableCell(text(i18n`Property`)), + tableCell(text(i18n`Type`)), + tableCell(text(i18n`Required`)), + tableCell(text(i18n`Nullable`)), + ]; + if (!singleFile) { + headerCells.push(tableCell(text(i18n`Defined by`))); + } return table('left', [ - tableRow([ - tableCell(text(i18n`Property`)), - tableCell(text(i18n`Type`)), - tableCell(text(i18n`Required`)), - tableCell(text(i18n`Nullable`)), - tableCell(text(i18n`Defined by`)), - ]), + tableRow(headerCells), ...proprows, ...patternproprows, ...additionalproprows, @@ -388,7 +411,7 @@ export default function build({ paragraph([text(i18n`Type: `), text(i18n`an array where each item follows the corresponding schema in the following list:`)]), list( 'ordered', - [...items.map((schema) => listItem(paragraph(link( + [...items.map((schema) => listItem(paragraph(schemaLink( `${schema[s.slug]}.md`, i18n`check type definition`, text(gentitle(schema[s.titles], schema[keyword`type`])), @@ -398,7 +421,7 @@ export default function build({ return [listItem(paragraph(text(i18n`and all following items may follow any schema`)))]; } else if (typeof additional === 'object') { return [listItem(paragraph([text(i18n`and all following items must follow the schema: `), - link( + schemaLink( `${additional[s.slug]}.md`, i18n`check type definition`, text(gentitle(additional[s.titles], additional[keyword`type`])), @@ -448,8 +471,9 @@ export default function build({ const typelink = (() => { if (definition[keyword`title`] && typeof definition[keyword`title`] === 'string') { // if the type has a title, always create a link to the schema - return [text(' ('), link(`${definition[s.slug]}.md`, '', text(definition[keyword`title`])), text(')')]; + return [text(' ('), schemaLink(`${definition[s.slug]}.md`, '', text(definition[keyword`title`])), text(')')]; } else if (!singletype || firsttype === keyword`object` || merged) { + if (singleFile) return []; return [text(' ('), link(`${definition[s.slug]}.md`, '', text(i18n`Details`)), text(')')]; } return []; @@ -473,7 +497,7 @@ export default function build({ function makedefinedinfact(definition) { return listItem(paragraph([ text(i18n`defined in: `), - link(`${definition[s.slug]}.md`, `${definition[s.id]}#${definition[s.pointer]}`, text(definition[s.titles] && definition[s.titles][0] ? definition[s.titles][0] : i18n`Untitled schema`)), + schemaLink(`${definition[s.slug]}.md`, `${definition[s.id]}#${definition[s.pointer]}`, text(definition[s.titles] && definition[s.titles][0] ? definition[s.titles][0] : i18n`Untitled schema`)), ])); } @@ -544,7 +568,7 @@ export default function build({ ]; } else if (depth > 0) { return [ - link(`${schema[s.slug]}.md`, i18n`check type definition`, text(gentitle(schema[s.titles], schema[keyword`type`]))), + schemaLink(`${schema[s.slug]}.md`, i18n`check type definition`, text(gentitle(schema[s.titles], schema[keyword`type`]))), ]; } else { return []; @@ -645,7 +669,7 @@ export default function build({ constraints.push(paragraph([ strong(text(i18n`schema`)), text(': '), text(i18n`the contents of this string should follow this schema: `), - link(`${schema[keyword`contentSchema`][s.slug]}.md`, i18n`check type definition`, text(gentitle(schema[keyword`contentSchema`][s.titles], schema[keyword`contentSchema`][keyword`type`])))])); + schemaLink(`${schema[keyword`contentSchema`][s.slug]}.md`, i18n`check type definition`, text(gentitle(schema[keyword`contentSchema`][s.titles], schema[keyword`contentSchema`][keyword`type`])))])); } // https://json-schema.org/draft/2019-09/json-schema-validation.html#rfc.section.6.4 @@ -664,12 +688,12 @@ export default function build({ if (schema[keyword`minContains`] !== undefined && schema[keyword`contains`]) { // console.log('minContains!', schema[s.filename], schema[s.pointer]); constraints.push(paragraph([strong(text(i18n`minimum number of contained items`)), text(': '), text(`${i18n`this array may not contain fewer than ${String(schema[keyword`minContains`])} items that validate against the schema:`} `), - link(`${schema[keyword`contains`][s.slug]}.md`, i18n`check type definition`, text(gentitle(schema[keyword`contains`][s.titles], schema[keyword`contains`][keyword`type`])))])); + schemaLink(`${schema[keyword`contains`][s.slug]}.md`, i18n`check type definition`, text(gentitle(schema[keyword`contains`][s.titles], schema[keyword`contains`][keyword`type`])))])); } if (schema[keyword`maxContains`] !== undefined && schema[keyword`contains`]) { // console.log('maxContains!', schema[s.filename], schema[s.pointer]); constraints.push(paragraph([strong(text(i18n`maximum number of contained items`)), text(': '), text(`${i18n`this array may not contain more than ${String(schema[keyword`maxContains`])} items that validate against the schema:`} `), - link(`${schema[keyword`contains`][s.slug]}.md`, i18n`check type definition`, text(gentitle(schema[keyword`contains`][s.titles], schema[keyword`contains`][keyword`type`])))])); + schemaLink(`${schema[keyword`contains`][s.slug]}.md`, i18n`check type definition`, text(gentitle(schema[keyword`contains`][s.titles], schema[keyword`contains`][keyword`type`])))])); } // https://json-schema.org/draft/2019-09/json-schema-validation.html#rfc.section.6.5 diff --git a/test/api.test.js b/test/api.test.js index 803e5107..39f27f55 100644 --- a/test/api.test.js +++ b/test/api.test.js @@ -140,6 +140,19 @@ The schemas linked above follow the JSON Schema Spec version: \`http://json-sche .contains('## examples'); }); + it('Public API with singleFile generates one markdown per top-level schema', async () => { + const result = jsonschema2md(example, { + includeReadme: false, + singleFile: true, + }); + assert.strictEqual(result.markdown.length, 1); + assertMarkdown(result.markdown[0].markdownAst) + .contains('## foo') + .contains('## bar') + .doesNotContain('defined in:') + .doesNotContain('Defined by'); + }); + it('Public API with invalid schema', async () => { try { jsonschema2md('test', { diff --git a/test/markdownBuilder.test.js b/test/markdownBuilder.test.js index 8e7db114..d1ac16df 100644 --- a/test/markdownBuilder.test.js +++ b/test/markdownBuilder.test.js @@ -440,6 +440,29 @@ describe('Testing Markdown Builder: Skip properties', () => { }); }); +describe('Testing Markdown Builder: singleFile', () => { + it('Single-file mode omits Defined by column and defined in fact', async () => { + const schemas = await traverseSchemas('type'); + const builder = build({ header: false, singleFile: true }); + const results = builder(schemas); + + assertMarkdown(results.type) + .doesNotContain('Defined by') + .doesNotContain('defined in:') + .doesNotContain('.md'); + }); + + it('Single-file mode strips file links from type references', async () => { + const schemas = await traverseSchemas('type'); + const builder = build({ header: false, singleFile: true }); + const results = builder(schemas); + + assertMarkdown(results.button) + .contains('Properties') + .doesNotContain('button-properties-properties.md'); + }); +}); + describe('Testing Markdown Builder: boolean defaults', () => { let results;