From 6e20b49e80b83573118824372cac05461ed3cde7 Mon Sep 17 00:00:00 2001 From: David de Boer Date: Tue, 28 Apr 2026 20:04:35 +0200 Subject: [PATCH] feat(docgen): deep-merge user-supplied JSON-LD frame with built-in default Closes #313 A user-supplied frame is now merged on top of the built-in default frame, so consumers only need to specify their additions (e.g. extra @context entries) instead of duplicating all of docgen's defaults. Plain objects merge key-by-key; arrays and primitives in the user frame replace the default. --- .../dataset-registry-client/tsconfig.lib.json | 3 - packages/docgen/README.md | 20 ++++-- packages/docgen/src/cli.ts | 10 +-- packages/docgen/src/frame.ts | 58 ++++++++++++++--- packages/docgen/src/index.ts | 11 +++- .../docgen/test/fixtures/partial.frame.jsonld | 6 ++ packages/docgen/test/frame.test.ts | 63 +++++++++++++++++++ packages/docgen/test/index.test.ts | 9 +-- packages/pipeline/tsconfig.lib.json | 3 - 9 files changed, 149 insertions(+), 34 deletions(-) create mode 100644 packages/docgen/test/fixtures/partial.frame.jsonld create mode 100644 packages/docgen/test/frame.test.ts diff --git a/packages/dataset-registry-client/tsconfig.lib.json b/packages/dataset-registry-client/tsconfig.lib.json index c41688c2..b3d02635 100644 --- a/packages/dataset-registry-client/tsconfig.lib.json +++ b/packages/dataset-registry-client/tsconfig.lib.json @@ -10,9 +10,6 @@ }, "include": ["src/**/*.ts"], "references": [ - { - "path": "../local-sparql-endpoint/tsconfig.lib.json" - }, { "path": "../dataset/tsconfig.lib.json" } diff --git a/packages/docgen/README.md b/packages/docgen/README.md index 81963caa..55fa14c9 100644 --- a/packages/docgen/README.md +++ b/packages/docgen/README.md @@ -32,9 +32,9 @@ npx @lde/docgen@latest from-shacl [options] ### Options -| Option | Description | Default | -| -------------------- | ---------------------------- | ------------------------------------ | -| `-f, --frame ` | Path to a JSON-LD Frame file | Built-in `frames/shacl.frame.jsonld` | +| Option | Description | Default | +| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | +| `-f, --frame ` | Path to a JSON-LD Frame file. Deep-merged on top of the built-in default frame, so it only needs to contain your additions (e.g. extra `@context` entries). | Built-in `frames/shacl.frame.jsonld` | ### Example @@ -108,8 +108,20 @@ Property shapes with the same `sh:path` are common in SHACL (e.g. one for cardin ## Custom frames -The default frame selects all `sh:NodeShape` resources. To customise which shapes are selected or how they are nested, pass a custom [JSON-LD Frame](https://www.w3.org/TR/json-ld11-framing/): +The default frame selects all `sh:NodeShape` resources and provides type coercions for common SHACL terms (`targetClass`, `path`, `severity`, etc.). To extend it, pass a partial [JSON-LD Frame](https://www.w3.org/TR/json-ld11-framing/) – it is **deep-merged** on top of the default, so you only need to specify your additions: + +```json +{ + "@context": { + "nde": "https://def.nde.nl#", + "nde:futureChange": {}, + "nde:version": {} + } +} +``` ```sh npx @lde/docgen@latest from-shacl shapes.ttl template.liquid -f my-frame.jsonld ``` + +Plain objects are merged key-by-key, with user values winning; arrays and primitives in your frame replace the default. To override a built-in coercion (e.g. change `severity` from `@vocab` to `@id`), redefine the same key in your `@context`. diff --git a/packages/docgen/src/cli.ts b/packages/docgen/src/cli.ts index ed3431a7..bcdbca37 100644 --- a/packages/docgen/src/cli.ts +++ b/packages/docgen/src/cli.ts @@ -3,10 +3,6 @@ import { Command } from 'commander'; import { generateDocumentation } from './index.js'; import packageJson from '../package.json' with { type: 'json' }; -import { dirname } from 'path'; -import { fileURLToPath } from 'url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); const program = new Command(); @@ -27,9 +23,8 @@ program 'Path to Liquid template file' ) .option( -'-f --frame ', - 'Path to a JSON-LD Frame file', - __dirname + '/../frames/shacl.frame.jsonld' + '-f --frame ', + 'Path to a JSON-LD Frame file. Deep-merged on top of the built-in default frame, so it only needs to contain your additions.' ) .addHelpText( 'after', @@ -49,4 +44,3 @@ Example: }); program.parse(); - diff --git a/packages/docgen/src/frame.ts b/packages/docgen/src/frame.ts index d7211355..12bf542b 100644 --- a/packages/docgen/src/frame.ts +++ b/packages/docgen/src/frame.ts @@ -1,14 +1,56 @@ -import type { JsonLdArray } from 'jsonld/jsonld-spec.js'; +import type { Frame, JsonLdArray } from 'jsonld/jsonld-spec.js'; +import type { NodeObject } from 'jsonld'; import jsonld from 'jsonld'; import { readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; -export async function frame(document: JsonLdArray, frame: string) { - return await jsonld.frame( - document, - JSON.parse(await readFile(frame, 'utf8')), - { - omitGraph: false, - embed: '@always', +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const defaultFramePath = join(__dirname, '../frames/shacl.frame.jsonld'); + +export async function frame( + document: JsonLdArray, + userFramePath?: string +): Promise { + const defaultFrame = await readFrame(defaultFramePath); + const mergedFrame = userFramePath + ? deepMerge(defaultFrame, await readFrame(userFramePath)) + : defaultFrame; + + return await jsonld.frame(document, mergedFrame, { + omitGraph: false, + embed: '@always', + }); +} + +async function readFrame(path: string): Promise { + return JSON.parse(await readFile(path, 'utf8')) as Frame; +} + +/** + * Recursively merges `source` into `target`, returning a new object. Plain + * objects are merged key-by-key; arrays and primitives in `source` replace + * those in `target`. Used to compose a user-supplied JSON-LD frame on top of + * docgen’s built-in default so consumers only need to specify their additions. + */ +function deepMerge(target: Frame, source: Frame): Frame { + const result = { ...(target as Record) }; + for (const [key, sourceValue] of Object.entries( + source as Record + )) { + const targetValue = result[key]; + if (isPlainObject(targetValue) && isPlainObject(sourceValue)) { + result[key] = deepMerge(targetValue as Frame, sourceValue as Frame); + } else { + result[key] = sourceValue; } + } + return result as Frame; +} + +function isPlainObject(value: unknown): value is Record { + return ( + typeof value === 'object' && value !== null && !Array.isArray(value) ); } diff --git a/packages/docgen/src/index.ts b/packages/docgen/src/index.ts index ed4f1ac7..bb3b9f39 100644 --- a/packages/docgen/src/index.ts +++ b/packages/docgen/src/index.ts @@ -2,10 +2,19 @@ import { parseRdfToJsonLd } from './parse.js'; import { frame } from './frame.js'; import { render } from './render.js'; +/** + * Generate documentation from a SHACL shapes file using a Liquid template. + * + * @param rdfPath Path to a SHACL shapes file in any RDF serialization. + * @param templatePath Path to a Liquid template. + * @param framePath Optional path to a JSON-LD frame. When provided, it is + * deep-merged on top of docgen’s built-in default frame, so consumers only + * need to specify their additions (e.g. extra `@context` entries). + */ export async function generateDocumentation( rdfPath: string, templatePath: string, - framePath: string + framePath?: string ): Promise { const jsonld = await parseRdfToJsonLd(rdfPath); const framed = await frame(jsonld, framePath); diff --git a/packages/docgen/test/fixtures/partial.frame.jsonld b/packages/docgen/test/fixtures/partial.frame.jsonld new file mode 100644 index 00000000..9d5477c7 --- /dev/null +++ b/packages/docgen/test/fixtures/partial.frame.jsonld @@ -0,0 +1,6 @@ +{ + "@context": { + "schema": "https://schema.org/", + "schema:version": {} + } +} diff --git a/packages/docgen/test/frame.test.ts b/packages/docgen/test/frame.test.ts new file mode 100644 index 00000000..c9b9b57a --- /dev/null +++ b/packages/docgen/test/frame.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { frame } from '../src/frame.js'; +import { parseRdfToJsonLd } from '../src/parse.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SHACL_PATH = join(__dirname, 'fixtures', 'shacl.ttl'); +const PARTIAL_FRAME_PATH = join(__dirname, 'fixtures', 'partial.frame.jsonld'); + +describe('frame', () => { + it('uses the built-in default frame when no user frame is given', async () => { + const document = await parseRdfToJsonLd(SHACL_PATH); + const framed = (await frame(document)) as { '@graph': unknown[] }; + + expect(framed['@graph']).toBeDefined(); + expect(Array.isArray(framed['@graph'])).toBe(true); + expect(framed['@graph'].length).toBeGreaterThan(0); + }); + + it('deep-merges a user-supplied frame with the default', async () => { + const document = await parseRdfToJsonLd(SHACL_PATH); + const framed = (await frame(document, PARTIAL_FRAME_PATH)) as { + '@context': Record; + '@graph': unknown[]; + }; + + // User addition is present. + expect(framed['@context'].schema).toBe('https://schema.org/'); + + // Default coercions are still applied (path is rendered as IRI string, + // not a `{ "@id": "..." }` object), proving the default frame’s + // `"path": { "@type": "@id" }` was preserved. + const firstShape = framed['@graph'][0] as { property: { path: unknown }[] }; + expect(typeof firstShape.property[0].path).toBe('string'); + }); + + it('lets a user frame override a default coercion', async () => { + const overridePath = join(tmpdir(), `docgen-frame-override-${Date.now()}.jsonld`); + await writeFile( + overridePath, + JSON.stringify({ + '@context': { + severity: { '@type': '@id' }, + }, + }) + ); + + const document = await parseRdfToJsonLd(SHACL_PATH); + const framed = (await frame(document, overridePath)) as { + '@graph': { property: { severity?: unknown }[] }[]; + }; + + const properties = framed['@graph'].flatMap((shape) => shape.property); + const withSeverity = properties.find((p) => p.severity !== undefined); + + // Default coerces severity to @vocab → "Info"; the override coerces to + // @id → full IRI "http://www.w3.org/ns/shacl#Info". + expect(withSeverity?.severity).toBe('http://www.w3.org/ns/shacl#Info'); + }); +}); diff --git a/packages/docgen/test/index.test.ts b/packages/docgen/test/index.test.ts index c3802c6c..cff7c5be 100644 --- a/packages/docgen/test/index.test.ts +++ b/packages/docgen/test/index.test.ts @@ -6,15 +6,10 @@ import { generateDocumentation } from '../src/index.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const SHACL_PATH = join(__dirname, 'fixtures', 'shacl.ttl'); const TEMPLATE_PATH = join(__dirname, 'fixtures', 'template.liquid'); -const FRAME_PATH = join(__dirname, '../frames', 'shacl.frame.jsonld'); describe('Integration tests', () => { - it('should render template', async () => { - const output = await generateDocumentation( - SHACL_PATH, - TEMPLATE_PATH, - FRAME_PATH - ); + it('should render template using the built-in default frame', async () => { + const output = await generateDocumentation(SHACL_PATH, TEMPLATE_PATH); expect(output.trim().replace(/ +$/gm, '')) .toBe(`targetClass: http://www.w3.org/ns/dcat#Dataset diff --git a/packages/pipeline/tsconfig.lib.json b/packages/pipeline/tsconfig.lib.json index a08155cc..50075f5a 100644 --- a/packages/pipeline/tsconfig.lib.json +++ b/packages/pipeline/tsconfig.lib.json @@ -10,9 +10,6 @@ }, "include": ["src/**/*.ts"], "references": [ - { - "path": "../local-sparql-endpoint/tsconfig.lib.json" - }, { "path": "../sparql-server/tsconfig.lib.json" },