diff --git a/.changeset/sparkly-cows-show.md b/.changeset/sparkly-cows-show.md new file mode 100644 index 0000000..ccda95f --- /dev/null +++ b/.changeset/sparkly-cows-show.md @@ -0,0 +1,6 @@ +--- +"@oxa/conformance": patch +"@oxa/core": patch +--- + +Add @oxa/conformance package for conformance testing diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ec1a01d..1e9384d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -55,15 +55,16 @@ pnpm typecheck ### Code Generation ```bash -# Run all code generation (validate, ts, py, rs, docs) +# Run all code generation (validate, ts, py, rs, docs, conformance) pnpm codegen all # Or run individual steps: -pnpm codegen validate # Validate schema files -pnpm codegen ts # Generate TypeScript types -pnpm codegen py # Generate Python Pydantic models -pnpm codegen rs # Generate Rust types -pnpm codegen docs # Generate MyST documentation +pnpm codegen validate # Validate schema files +pnpm codegen ts # Generate TypeScript types +pnpm codegen py # Generate Python Pydantic models +pnpm codegen rs # Generate Rust types +pnpm codegen conformance # Generate conformance suite manifest +pnpm codegen docs # Generate MyST documentation ``` ### Building @@ -111,6 +112,53 @@ Commit the generated changeset file with your PR. - `oxa-types` (TypeScript), `oxa-types` (Python), and `oxa-types` (Rust) stay in sync - `@oxa/core` and `oxa` can version independently +## Conformance Suite + +The `@oxa/conformance` package provides test cases for validating OXA format conversion implementations. If you're building a tool that converts between OXA and other formats, you can use these test cases to validate your implementation. + +To add new test cases to the conformance suite: + +1. Create a new JSON file in the appropriate directory: + - `packages/@oxa/conformance/cases/inline/` for inline node tests + - `packages/@oxa/conformance/cases/block/` for block node tests + +2. Follow the test case format: + +```json +{ + "$schema": "../../schemas/test-case.schema.json", + "title": "Short descriptive title", + "description": "What this test validates", + "category": "inline", + "formats": { + "oxa": { ... }, + "myst": { ... }, + "pandoc": { ... }, + "stencila": { ... }, + "markdown": "...", + "html": "...", + "jats": "..." + }, + "notes": { + "markdown": "Optional format-specific notes" + } +} +``` + +3. Formats should be in canonical order: `oxa`, `myst`, `pandoc`, `stencila`, `markdown`, `html`, `jats`. The `oxa` format is required; others are optional. + +4. Regenerate the manifest: + +```bash +pnpm codegen conformance +``` + +5. Run tests to validate your test case: + +```bash +pnpm test +``` + ## Documentation The documentation uses [MyST](https://mystmd.org) and requires the automated codegen tool to run. To start the documentation use `npm install -g mystmd` and run `myst start` in the `docs/` folder. The online documentation is hosted using Curvenote under https://oxa.dev diff --git a/docs/schema/emphasis.md b/docs/schema/emphasis.md index 581683d..e5a24af 100644 --- a/docs/schema/emphasis.md +++ b/docs/schema/emphasis.md @@ -26,3 +26,78 @@ __children__: __array__ ("Inline") : The inline content to emphasize. : See @oxa:inline + +### Example + +`````{tab-set} +````{tab-item} OXA +:sync: oxa +```json +{"type":"Emphasis","children":[{"type":"Text","value":"emphasized text"}]} +``` +```` + +````{tab-item} MyST AST +:sync: myst-ast +```json +{"type":"emphasis","children":[{"type":"text","value":"emphasized text"}]} +``` +```` + +````{tab-item} Pandoc Types +:sync: pandoc-types +```json +{"t":"Emph","c":[{"t":"Str","c":"emphasized text"}]} +``` +```` + +````{tab-item} Stencila Schema +:sync: stencila-schema +```json +{"type":"Emphasis","content":[{"type":"Text","value":"emphasized text"}]} +``` +```` + +````{tab-item} Markdown +:sync: markdown +```markdown +*emphasized text* +``` +```` + +````{tab-item} MyST Markdown +:sync: myst-markdown +```markdown +*emphasized text* +``` +```` + +````{tab-item} Stencila Markdown +:sync: stencila-markdown +```markdown +*emphasized text* +``` +```` + +````{tab-item} Quarto Markdown +:sync: quarto-markdown +```markdown +*emphasized text* +``` +```` + +````{tab-item} HTML +:sync: html +```html +emphasized text +``` +```` + +````{tab-item} JATS +:sync: jats +```xml +emphasized text +``` +```` + +````` \ No newline at end of file diff --git a/docs/schema/heading.md b/docs/schema/heading.md index cd6a33d..4d5808e 100644 --- a/docs/schema/heading.md +++ b/docs/schema/heading.md @@ -30,3 +30,78 @@ __children__: __array__ ("Inline") : The inline content of the heading. : See @oxa:inline + +### Example + +`````{tab-set} +````{tab-item} OXA +:sync: oxa +```json +{"type":"Heading","level":1,"children":[{"type":"Text","value":"Introduction"}]} +``` +```` + +````{tab-item} MyST AST +:sync: myst-ast +```json +{"type":"heading","depth":1,"children":[{"type":"text","value":"Introduction"}]} +``` +```` + +````{tab-item} Pandoc Types +:sync: pandoc-types +```json +{"t":"Header","c":[1,["",[],[]],[{"t":"Str","c":"Introduction"}]]} +``` +```` + +````{tab-item} Stencila Schema +:sync: stencila-schema +```json +{"type":"Heading","depth":1,"content":[{"type":"Text","value":"Introduction"}]} +``` +```` + +````{tab-item} Markdown +:sync: markdown +```markdown +# Introduction +``` +```` + +````{tab-item} MyST Markdown +:sync: myst-markdown +```markdown +# Introduction +``` +```` + +````{tab-item} Stencila Markdown +:sync: stencila-markdown +```markdown +# Introduction +``` +```` + +````{tab-item} Quarto Markdown +:sync: quarto-markdown +```markdown +# Introduction +``` +```` + +````{tab-item} HTML +:sync: html +```html +

Introduction

+``` +```` + +````{tab-item} JATS +:sync: jats +```xml +Introduction +``` +```` + +````` \ No newline at end of file diff --git a/docs/schema/paragraph.md b/docs/schema/paragraph.md index 7e11cd7..c9c26ce 100644 --- a/docs/schema/paragraph.md +++ b/docs/schema/paragraph.md @@ -26,3 +26,78 @@ __children__: __array__ ("Inline") : The inline content of the paragraph. : See @oxa:inline + +### Example + +`````{tab-set} +````{tab-item} OXA +:sync: oxa +```json +{"type":"Paragraph","children":[{"type":"Text","value":"This is a paragraph."}]} +``` +```` + +````{tab-item} MyST AST +:sync: myst-ast +```json +{"type":"paragraph","children":[{"type":"text","value":"This is a paragraph."}]} +``` +```` + +````{tab-item} Pandoc Types +:sync: pandoc-types +```json +{"t":"Para","c":[{"t":"Str","c":"This is a paragraph."}]} +``` +```` + +````{tab-item} Stencila Schema +:sync: stencila-schema +```json +{"type":"Paragraph","content":[{"type":"Text","value":"This is a paragraph."}]} +``` +```` + +````{tab-item} Markdown +:sync: markdown +```markdown +This is a paragraph. +``` +```` + +````{tab-item} MyST Markdown +:sync: myst-markdown +```markdown +This is a paragraph. +``` +```` + +````{tab-item} Stencila Markdown +:sync: stencila-markdown +```markdown +This is a paragraph. +``` +```` + +````{tab-item} Quarto Markdown +:sync: quarto-markdown +```markdown +This is a paragraph. +``` +```` + +````{tab-item} HTML +:sync: html +```html +

This is a paragraph.

+``` +```` + +````{tab-item} JATS +:sync: jats +```xml +

This is a paragraph.

+``` +```` + +````` \ No newline at end of file diff --git a/docs/schema/strong.md b/docs/schema/strong.md index 8aa14de..fab4254 100644 --- a/docs/schema/strong.md +++ b/docs/schema/strong.md @@ -26,3 +26,78 @@ __children__: __array__ ("Inline") : The inline content to emphasize. : See @oxa:inline + +### Example + +`````{tab-set} +````{tab-item} OXA +:sync: oxa +```json +{"type":"Strong","children":[{"type":"Text","value":"strong text"}]} +``` +```` + +````{tab-item} MyST AST +:sync: myst-ast +```json +{"type":"strong","children":[{"type":"text","value":"strong text"}]} +``` +```` + +````{tab-item} Pandoc Types +:sync: pandoc-types +```json +{"t":"Strong","c":[{"t":"Str","c":"strong text"}]} +``` +```` + +````{tab-item} Stencila Schema +:sync: stencila-schema +```json +{"type":"Strong","content":[{"type":"Text","value":"strong text"}]} +``` +```` + +````{tab-item} Markdown +:sync: markdown +```markdown +**strong text** +``` +```` + +````{tab-item} MyST Markdown +:sync: myst-markdown +```markdown +**strong text** +``` +```` + +````{tab-item} Stencila Markdown +:sync: stencila-markdown +```markdown +**strong text** +``` +```` + +````{tab-item} Quarto Markdown +:sync: quarto-markdown +```markdown +**strong text** +``` +```` + +````{tab-item} HTML +:sync: html +```html +strong text +``` +```` + +````{tab-item} JATS +:sync: jats +```xml +strong text +``` +```` + +````` \ No newline at end of file diff --git a/docs/schema/text.md b/docs/schema/text.md index 4da7a92..5be534a 100644 --- a/docs/schema/text.md +++ b/docs/schema/text.md @@ -25,3 +25,78 @@ __data__: __object__ __value__: __string__ : The text content. + +### Example + +`````{tab-set} +````{tab-item} OXA +:sync: oxa +```json +{"type":"Text","value":"Hello, world!"} +``` +```` + +````{tab-item} MyST AST +:sync: myst-ast +```json +{"type":"text","value":"Hello, world!"} +``` +```` + +````{tab-item} Pandoc Types +:sync: pandoc-types +```json +{"t":"Str","c":"Hello, world!"} +``` +```` + +````{tab-item} Stencila Schema +:sync: stencila-schema +```json +{"type":"Text","value":"Hello, world!"} +``` +```` + +````{tab-item} Markdown +:sync: markdown +```markdown +Hello, world! +``` +```` + +````{tab-item} MyST Markdown +:sync: myst-markdown +```markdown +Hello, world! +``` +```` + +````{tab-item} Stencila Markdown +:sync: stencila-markdown +```markdown +Hello, world! +``` +```` + +````{tab-item} Quarto Markdown +:sync: quarto-markdown +```markdown +Hello, world! +``` +```` + +````{tab-item} HTML +:sync: html +```html +Hello, world! +``` +```` + +````{tab-item} JATS +:sync: jats +```xml +Hello, world! +``` +```` + +````` \ No newline at end of file diff --git a/package.json b/package.json index f52d004..236d602 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@changesets/cli": "^2.29.8", "@eslint/js": "^9.39.2", "eslint": "^9.39.2", + "json-schema-to-typescript": "^15.0.4", "prettier": "^3.7.4", "tsx": "^4.21.0", "turbo": "^2.0.0", diff --git a/packages/oxa-conformance/README.md b/packages/oxa-conformance/README.md new file mode 100644 index 0000000..9f53786 --- /dev/null +++ b/packages/oxa-conformance/README.md @@ -0,0 +1,186 @@ +# OXA Conformance Suite + +A collection of test cases for validating OXA format conversion implementations. + +## Overview + +The OXA Conformance Suite provides test cases containing OXA JSON alongside equivalent representations in other formats. Tool developers building converters between OXA and other formats can use these test cases to validate their implementations. + +## Installation + +```bash +npm install @oxa/conformance +``` + +Or include it as a dev dependency: + +```bash +npm install -D @oxa/conformance +``` + +## Usage + +### Loading Test Cases + +The package exports a manifest listing all available test cases: + +```javascript +// ESM (recommended) +import manifest from "@oxa/conformance"; + +// CommonJS +const manifest = require("@oxa/conformance"); + +console.log(manifest.cases); // Array of test case metadata +``` + +Load individual test cases by reading the JSON files directly: + +```javascript +import { readFileSync } from "fs"; +import { dirname, resolve } from "path"; +import { fileURLToPath } from "url"; + +// Get the path to the conformance package +const conformancePath = dirname(fileURLToPath(import.meta.resolve("@oxa/conformance"))); + +// Load a specific test case +const textBasic = JSON.parse( + readFileSync(resolve(conformancePath, "cases/inline/text-basic.json"), "utf-8") +); + +console.log(textBasic.formats.oxa); // OXA representation +console.log(textBasic.formats.markdown); // Markdown representation +``` + +**Note:** If using TypeScript with `resolveJsonModule` enabled, you can also import JSON files directly. + +### Running Tests + +Here's an example of using the conformance suite with a testing framework: + +```javascript +import { describe, it, expect } from "vitest"; +import manifest from "@oxa/conformance"; +import { readFileSync } from "fs"; +import { dirname, resolve } from "path"; +import { fileURLToPath } from "url"; + +// Your converter functions +import { oxaToMarkdown, markdownToOxa } from "./your-converter"; + +// Get path to conformance package +const conformancePath = dirname(fileURLToPath(import.meta.resolve("@oxa/conformance"))); + +describe("OXA Conformance", () => { + for (const testCase of manifest.cases) { + const casePath = resolve(conformancePath, testCase.path); + const caseData = JSON.parse(readFileSync(casePath, "utf-8")); + + if (caseData.formats.markdown) { + it(`${testCase.id}: OXA → Markdown`, () => { + const result = oxaToMarkdown(caseData.formats.oxa); + expect(result).toBe(caseData.formats.markdown); + }); + + it(`${testCase.id}: Markdown → OXA`, () => { + const result = markdownToOxa(caseData.formats.markdown); + expect(result).toEqual(caseData.formats.oxa); + }); + } + } +}); +``` + +## Test Case Format + +Each test case is a JSON file with the following structure: + +```json +{ + "$schema": "../../schemas/test-case.schema.json", + "title": "Basic emphasis", + "description": "Emphasized text (typically rendered as italic)", + "category": "inline", + "formats": { + "oxa": { ... }, + "myst": { ... }, + "pandoc": { ... }, + "stencila": { ... }, + "markdown": "...", + "html": "...", + "jats": "..." + }, + "notes": { + "markdown": "Additional format-specific notes" + } +} +``` + +### Fields + +- **title**: Short descriptive title +- **description**: Detailed description of what the test validates +- **category**: One of `inline`, `block`, `document`, or `edge-case` +- **formats**: Object containing equivalent representations in each format +- **notes**: Optional format-specific notes and considerations + +### Supported Formats + +1. **oxa** - OXA JSON (always present, authoritative) +2. **myst** - MyST Markdown AST +3. **pandoc** - Pandoc AST JSON format +4. **stencila** - Stencila Schema JSON +5. **markdown** - CommonMark/GFM Markdown +6. **html** - Semantic HTML5 +7. **jats** - JATS Publishing XML + +AST-based formats (oxa, myst, pandoc, stencila) are grouped first, followed by string-based interchange formats (markdown, html, jats). This ordering makes it easier to visually scan test cases for correctness across related formats. + +Not every test case includes all formats. The `oxa` format is always present and serves as the authoritative representation. + +## Manifest Structure + +The `manifest.json` file provides an index of all test cases: + +```json +{ + "$schema": "./schemas/manifest.schema.json", + "version": "0.1.0", + "formats": ["oxa", "myst", "pandoc", "stencila", "markdown", "html", "jats"], + "cases": [ + { + "id": "text-basic", + "path": "cases/inline/text-basic.json", + "category": "inline", + "nodeTypes": ["Text"] + } + ] +} +``` + +## Directory Structure + +``` +@oxa/conformance/ +├── manifest.json # Index of all test cases +├── cases/ +│ ├── inline/ # Inline node test cases +│ │ ├── text-basic.json +│ │ ├── emphasis-basic.json +│ │ └── strong-basic.json +│ └── block/ # Block node test cases +│ ├── paragraph-basic.json +│ └── heading-basic.json +└── schemas/ + ├── test-case.schema.json + └── manifest.schema.json +``` + +## Contributing New Test Cases + +See the [CONTRIBUTING.md](../../CONTRIBUTING.md) guide for instructions on adding new test cases to the conformance suite. + +## License + +MIT diff --git a/packages/oxa-conformance/cases/block/heading-basic.json b/packages/oxa-conformance/cases/block/heading-basic.json new file mode 100644 index 0000000..1963537 --- /dev/null +++ b/packages/oxa-conformance/cases/block/heading-basic.json @@ -0,0 +1,51 @@ +{ + "$schema": "../../schemas/test-case.schema.json", + "title": "Basic heading", + "description": "A level 1 heading containing text", + "category": "block", + "formats": { + "oxa": { + "type": "Heading", + "level": 1, + "children": [ + { "type": "Text", "value": "Introduction" } + ] + }, + "myst-ast": { + "type": "heading", + "depth": 1, + "children": [ + { "type": "text", "value": "Introduction" } + ] + }, + "pandoc-types": { + "t": "Header", + "c": [ + 1, + ["", [], []], + [{ "t": "Str", "c": "Introduction" }] + ] + }, + "stencila-schema": { + "type": "Heading", + "depth": 1, + "content": [ + { "type": "Text", "value": "Introduction" } + ] + }, + "markdown": "# Introduction", + "myst-markdown": "# Introduction", + "stencila-markdown": "# Introduction", + "quarto-markdown": "# Introduction", + "html": "

Introduction

", + "jats": "Introduction" + }, + "notes": { + "myst-ast": "MyST uses 'depth' instead of 'level'", + "stencila-schema": "Stencila uses 'depth' instead of 'level'", + "myst-markdown": "Equivalent to markdown for this node type", + "stencila-markdown": "Equivalent to markdown for this node type", + "quarto-markdown": "Equivalent to markdown for this node type", + "jats": "JATS uses for section headings within <sec> elements" + } +} diff --git a/packages/oxa-conformance/cases/block/paragraph-basic.json b/packages/oxa-conformance/cases/block/paragraph-basic.json new file mode 100644 index 0000000..447e2a3 --- /dev/null +++ b/packages/oxa-conformance/cases/block/paragraph-basic.json @@ -0,0 +1,43 @@ +{ + "$schema": "../../schemas/test-case.schema.json", + "title": "Basic paragraph", + "description": "A simple paragraph containing text", + "category": "block", + "formats": { + "oxa": { + "type": "Paragraph", + "children": [ + { "type": "Text", "value": "This is a paragraph." } + ] + }, + "myst-ast": { + "type": "paragraph", + "children": [ + { "type": "text", "value": "This is a paragraph." } + ] + }, + "pandoc-types": { + "t": "Para", + "c": [ + { "t": "Str", "c": "This is a paragraph." } + ] + }, + "stencila-schema": { + "type": "Paragraph", + "content": [ + { "type": "Text", "value": "This is a paragraph." } + ] + }, + "markdown": "This is a paragraph.", + "myst-markdown": "This is a paragraph.", + "stencila-markdown": "This is a paragraph.", + "quarto-markdown": "This is a paragraph.", + "html": "<p>This is a paragraph.</p>", + "jats": "<p>This is a paragraph.</p>" + }, + "notes": { + "myst-markdown": "Equivalent to markdown for this node type", + "stencila-markdown": "Equivalent to markdown for this node type", + "quarto-markdown": "Equivalent to markdown for this node type" + } +} diff --git a/packages/oxa-conformance/cases/inline/emphasis-basic.json b/packages/oxa-conformance/cases/inline/emphasis-basic.json new file mode 100644 index 0000000..4af956c --- /dev/null +++ b/packages/oxa-conformance/cases/inline/emphasis-basic.json @@ -0,0 +1,44 @@ +{ + "$schema": "../../schemas/test-case.schema.json", + "title": "Basic emphasis", + "description": "Emphasized text (typically rendered as italic)", + "category": "inline", + "formats": { + "oxa": { + "type": "Emphasis", + "children": [ + { "type": "Text", "value": "emphasized text" } + ] + }, + "myst-ast": { + "type": "emphasis", + "children": [ + { "type": "text", "value": "emphasized text" } + ] + }, + "pandoc-types": { + "t": "Emph", + "c": [ + { "t": "Str", "c": "emphasized text" } + ] + }, + "stencila-schema": { + "type": "Emphasis", + "content": [ + { "type": "Text", "value": "emphasized text" } + ] + }, + "markdown": "*emphasized text*", + "myst-markdown": "*emphasized text*", + "stencila-markdown": "*emphasized text*", + "quarto-markdown": "*emphasized text*", + "html": "<em>emphasized text</em>", + "jats": "<italic>emphasized text</italic>" + }, + "notes": { + "markdown": "Also accepts _emphasized text_ as input", + "myst-markdown": "Equivalent to markdown for this node type", + "stencila-markdown": "Equivalent to markdown for this node type", + "quarto-markdown": "Equivalent to markdown for this node type" + } +} diff --git a/packages/oxa-conformance/cases/inline/strong-basic.json b/packages/oxa-conformance/cases/inline/strong-basic.json new file mode 100644 index 0000000..7eff2f3 --- /dev/null +++ b/packages/oxa-conformance/cases/inline/strong-basic.json @@ -0,0 +1,44 @@ +{ + "$schema": "../../schemas/test-case.schema.json", + "title": "Basic strong emphasis", + "description": "Strongly emphasized text (typically rendered as bold)", + "category": "inline", + "formats": { + "oxa": { + "type": "Strong", + "children": [ + { "type": "Text", "value": "strong text" } + ] + }, + "myst-ast": { + "type": "strong", + "children": [ + { "type": "text", "value": "strong text" } + ] + }, + "pandoc-types": { + "t": "Strong", + "c": [ + { "t": "Str", "c": "strong text" } + ] + }, + "stencila-schema": { + "type": "Strong", + "content": [ + { "type": "Text", "value": "strong text" } + ] + }, + "markdown": "**strong text**", + "myst-markdown": "**strong text**", + "stencila-markdown": "**strong text**", + "quarto-markdown": "**strong text**", + "html": "<strong>strong text</strong>", + "jats": "<bold>strong text</bold>" + }, + "notes": { + "markdown": "Also accepts __strong text__ as input", + "myst-markdown": "Equivalent to markdown for this node type", + "stencila-markdown": "Equivalent to markdown for this node type", + "quarto-markdown": "Equivalent to markdown for this node type" + } +} diff --git a/packages/oxa-conformance/cases/inline/text-basic.json b/packages/oxa-conformance/cases/inline/text-basic.json new file mode 100644 index 0000000..5343084 --- /dev/null +++ b/packages/oxa-conformance/cases/inline/text-basic.json @@ -0,0 +1,35 @@ +{ + "$schema": "../../schemas/test-case.schema.json", + "title": "Basic text node", + "description": "A simple text node containing a string value", + "category": "inline", + "formats": { + "oxa": { + "type": "Text", + "value": "Hello, world!" + }, + "myst-ast": { + "type": "text", + "value": "Hello, world!" + }, + "pandoc-types": { + "t": "Str", + "c": "Hello, world!" + }, + "stencila-schema": { + "type": "Text", + "value": "Hello, world!" + }, + "markdown": "Hello, world!", + "myst-markdown": "Hello, world!", + "stencila-markdown": "Hello, world!", + "quarto-markdown": "Hello, world!", + "html": "Hello, world!", + "jats": "Hello, world!" + }, + "notes": { + "myst-markdown": "Equivalent to markdown for this node type", + "stencila-markdown": "Equivalent to markdown for this node type", + "quarto-markdown": "Equivalent to markdown for this node type" + } +} diff --git a/packages/oxa-conformance/index.cjs b/packages/oxa-conformance/index.cjs new file mode 100644 index 0000000..457cacd --- /dev/null +++ b/packages/oxa-conformance/index.cjs @@ -0,0 +1,15 @@ +/** + * OXA Conformance Suite CommonJS entrypoint + */ + +const manifest = require("./manifest.json"); + +/** + * All test cases keyed by id + */ +const cases = Object.fromEntries( + manifest.cases.map((c) => [c.id, require(`./${c.path}`)]) +); + +module.exports.manifest = manifest; +module.exports.cases = cases; diff --git a/packages/oxa-conformance/index.d.ts b/packages/oxa-conformance/index.d.ts new file mode 100644 index 0000000..7c7876c --- /dev/null +++ b/packages/oxa-conformance/index.d.ts @@ -0,0 +1,146 @@ +/** + * Type declarations for @oxa/conformance + * + * AUTO-GENERATED from JSON schemas - do not edit directly. + * Run `pnpm codegen conformance` to regenerate. + */ + +/** + * A test case for validating OXA format conversions + */ +export interface OXAConformanceTestCase { + /** + * Reference to this schema + */ + $schema?: string; + /** + * Short title describing the test case + */ + title: string; + /** + * Detailed description of what the test case validates + */ + description?: string; + /** + * Category of the test case + */ + category: "inline" | "block" | "document"; + /** + * Format representations of the same content. OXA is always required and should be first. + */ + formats: { + /** + * OXA JSON representation (required, authoritative) + */ + oxa: { + [k: string]: unknown; + }; + /** + * MyST Markdown AST representation + */ + "myst-ast"?: { + [k: string]: unknown; + }; + /** + * Pandoc AST JSON representation + */ + "pandoc-types"?: { + [k: string]: unknown; + }; + /** + * Stencila Schema JSON representation + */ + "stencila-schema"?: { + [k: string]: unknown; + }; + /** + * CommonMark/GFM Markdown representation + */ + markdown?: string; + /** + * MyST Markdown representation + */ + "myst-markdown"?: string; + /** + * Stencila Markdown representation + */ + "stencila-markdown"?: string; + /** + * Quarto Markdown representation + */ + "quarto-markdown"?: string; + /** + * Semantic HTML5 representation + */ + html?: string; + /** + * JATS Publishing XML representation + */ + jats?: string; + }; + /** + * Format-specific notes and considerations + */ + notes?: { + [k: string]: string; + }; +} + +/** + * Index of all test cases in the OXA Conformance Suite + */ +export interface OXAConformanceSuiteManifest { + /** + * Reference to this schema + */ + $schema?: string; + /** + * Version of the conformance suite + */ + version: string; + /** + * List of supported formats in canonical order + */ + formats: ( + | "oxa" + | "myst-ast" + | "pandoc-types" + | "stencila-schema" + | "markdown" + | "myst-markdown" + | "stencila-markdown" + | "quarto-markdown" + | "html" + | "jats" + )[]; + /** + * List of all test cases + */ + cases: { + /** + * Unique identifier for the test case (derived from filename) + */ + id: string; + /** + * Relative path to the test case file + */ + path: string; + /** + * Category of the test case + */ + category: "inline" | "block" | "document" | "edge-case"; + /** + * OXA node types covered by this test case + */ + nodeTypes: string[]; + }[]; +} + +// Friendly type aliases +export type TestCase = OXAConformanceTestCase; +export type Manifest = OXAConformanceSuiteManifest; +export type ManifestCase = Manifest["cases"][number]; + +export declare const manifest: Manifest; + +export declare const cases: Record<string, TestCase>; diff --git a/packages/oxa-conformance/index.js b/packages/oxa-conformance/index.js new file mode 100644 index 0000000..831c0ab --- /dev/null +++ b/packages/oxa-conformance/index.js @@ -0,0 +1,15 @@ +/** + * OXA Conformance Suite entrypoint + * + * Re-exports manifest.json for Node ESM compatibility. + * This avoids the need for import assertions when consuming the package. + */ + +import { createRequire } from "module"; +const require = createRequire(import.meta.url); + +export const manifest = require("./manifest.json"); + +export const cases = Object.fromEntries( + manifest.cases.map((c) => [c.id, require(`./${c.path}`)]) +); diff --git a/packages/oxa-conformance/manifest.json b/packages/oxa-conformance/manifest.json new file mode 100644 index 0000000..8b59f80 --- /dev/null +++ b/packages/oxa-conformance/manifest.json @@ -0,0 +1,48 @@ +{ + "$schema": "./schemas/manifest.schema.json", + "version": "0.1.0", + "formats": [ + "oxa", + "myst-ast", + "pandoc-types", + "stencila-schema", + "markdown", + "myst-markdown", + "stencila-markdown", + "quarto-markdown", + "html", + "jats" + ], + "cases": [ + { + "id": "emphasis-basic", + "path": "cases/inline/emphasis-basic.json", + "category": "inline", + "nodeTypes": ["Emphasis", "Text"] + }, + { + "id": "strong-basic", + "path": "cases/inline/strong-basic.json", + "category": "inline", + "nodeTypes": ["Strong", "Text"] + }, + { + "id": "text-basic", + "path": "cases/inline/text-basic.json", + "category": "inline", + "nodeTypes": ["Text"] + }, + { + "id": "heading-basic", + "path": "cases/block/heading-basic.json", + "category": "block", + "nodeTypes": ["Heading", "Text"] + }, + { + "id": "paragraph-basic", + "path": "cases/block/paragraph-basic.json", + "category": "block", + "nodeTypes": ["Paragraph", "Text"] + } + ] +} diff --git a/packages/oxa-conformance/package.json b/packages/oxa-conformance/package.json new file mode 100644 index 0000000..11e0718 --- /dev/null +++ b/packages/oxa-conformance/package.json @@ -0,0 +1,43 @@ +{ + "name": "@oxa/conformance", + "version": "0.1.0", + "description": "Conformance test suite for OXA schema implementations", + "type": "module", + "main": "./index.js", + "types": "./index.d.ts", + "exports": { + ".": { + "types": "./index.d.ts", + "import": "./index.js", + "require": "./index.cjs" + }, + "./manifest.json": "./manifest.json", + "./cases/*": "./cases/*", + "./schemas/*": "./schemas/*" + }, + "files": [ + "index.js", + "index.cjs", + "index.d.ts", + "manifest.json", + "cases", + "schemas", + "README.md" + ], + "keywords": [ + "oxa", + "conformance", + "test-suite", + "schema", + "validation" + ], + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/oxa-dev/oxa.git", + "directory": "packages/oxa-conformance" + } +} diff --git a/packages/oxa-conformance/schemas/manifest.schema.json b/packages/oxa-conformance/schemas/manifest.schema.json new file mode 100644 index 0000000..41badbd --- /dev/null +++ b/packages/oxa-conformance/schemas/manifest.schema.json @@ -0,0 +1,59 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://oxa.dev/schemas/conformance/manifest.schema.json", + "title": "OXA Conformance Suite Manifest", + "description": "Index of all test cases in the OXA Conformance Suite", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "Reference to this schema" + }, + "version": { + "type": "string", + "description": "Version of the conformance suite", + "pattern": "^\\d+\\.\\d+\\.\\d+$" + }, + "formats": { + "type": "array", + "description": "List of supported formats in canonical order", + "items": { + "type": "string", + "enum": ["oxa", "myst-ast", "pandoc-types", "stencila-schema", "markdown", "myst-markdown", "stencila-markdown", "quarto-markdown", "html", "jats"] + } + }, + "cases": { + "type": "array", + "description": "List of all test cases", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique identifier for the test case (derived from filename)" + }, + "path": { + "type": "string", + "description": "Relative path to the test case file" + }, + "category": { + "type": "string", + "enum": ["inline", "block", "document", "edge-case"], + "description": "Category of the test case" + }, + "nodeTypes": { + "type": "array", + "description": "OXA node types covered by this test case", + "items": { + "type": "string" + } + } + }, + "required": ["id", "path", "category", "nodeTypes"], + "additionalProperties": false + } + } + }, + "required": ["version", "formats", "cases"], + "additionalProperties": false +} diff --git a/packages/oxa-conformance/schemas/test-case.schema.json b/packages/oxa-conformance/schemas/test-case.schema.json new file mode 100644 index 0000000..05c7182 --- /dev/null +++ b/packages/oxa-conformance/schemas/test-case.schema.json @@ -0,0 +1,87 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://oxa.dev/schemas/conformance/test-case.schema.json", + "title": "OXA Conformance Test Case", + "description": "A test case for validating OXA format conversions", + "type": "object", + "properties": { + "$schema": { + "type": "string", + "description": "Reference to this schema" + }, + "title": { + "type": "string", + "description": "Short title describing the test case" + }, + "description": { + "type": "string", + "description": "Detailed description of what the test case validates" + }, + "category": { + "type": "string", + "enum": ["inline", "block", "document"], + "description": "Category of the test case" + }, + "formats": { + "type": "object", + "description": "Format representations of the same content. OXA is always required and should be first.", + "properties": { + "oxa": { + "type": "object", + "description": "OXA JSON representation (required, authoritative)", + "additionalProperties": true + }, + "myst-ast": { + "type": "object", + "description": "MyST Markdown AST representation", + "additionalProperties": true + }, + "pandoc-types": { + "type": "object", + "description": "Pandoc AST JSON representation", + "additionalProperties": true + }, + "stencila-schema": { + "type": "object", + "description": "Stencila Schema JSON representation", + "additionalProperties": true + }, + "markdown": { + "type": "string", + "description": "CommonMark/GFM Markdown representation" + }, + "myst-markdown": { + "type": "string", + "description": "MyST Markdown representation" + }, + "stencila-markdown": { + "type": "string", + "description": "Stencila Markdown representation" + }, + "quarto-markdown": { + "type": "string", + "description": "Quarto Markdown representation" + }, + "html": { + "type": "string", + "description": "Semantic HTML5 representation" + }, + "jats": { + "type": "string", + "description": "JATS Publishing XML representation" + } + }, + "required": ["oxa"], + "additionalProperties": false + }, + "notes": { + "type": "object", + "description": "Format-specific notes and considerations", + "additionalProperties": { + "type": "string" + } + } + }, + "required": ["title", "category", "formats"], + "additionalProperties": false +} diff --git a/packages/oxa-core/package.json b/packages/oxa-core/package.json index 89ca201..28804a5 100644 --- a/packages/oxa-core/package.json +++ b/packages/oxa-core/package.json @@ -36,6 +36,7 @@ "oxa-types": "workspace:*" }, "devDependencies": { + "@oxa/conformance": "workspace:*", "@types/js-yaml": "^4.0.9", "@types/node": "^22.0.0", "esbuild": "^0.25.0", diff --git a/packages/oxa-core/src/conformance.test.ts b/packages/oxa-core/src/conformance.test.ts new file mode 100644 index 0000000..3665f17 --- /dev/null +++ b/packages/oxa-core/src/conformance.test.ts @@ -0,0 +1,111 @@ +/** + * Tests that all OXA conformance test cases validate against the OXA schema. + * + * This ensures that: + * 1. All test cases in the conformance suite are valid OXA documents + * 2. The test cases stay in sync with schema changes + */ + +import { describe, it, expect } from "vitest"; +import { manifest, cases } from "@oxa/conformance"; +import { validate } from "./validate.js"; + +describe("OXA Conformance Suite", () => { + describe("manifest", () => { + it("has valid version", () => { + expect(manifest.version).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it("has expected formats in canonical order", () => { + expect(manifest.formats).toEqual([ + "oxa", + "myst-ast", + "pandoc-types", + "stencila-schema", + "markdown", + "myst-markdown", + "stencila-markdown", + "quarto-markdown", + "html", + "jats", + ]); + }); + + it("has at least one test case", () => { + expect(manifest.cases.length).toBeGreaterThan(0); + }); + }); + + describe("test cases", () => { + for (const testCaseMeta of manifest.cases) { + describe(`${testCaseMeta.category}/${testCaseMeta.id}`, () => { + const testCase = cases[testCaseMeta.id]; + + it("has required fields", () => { + expect(testCase.title).toBeDefined(); + expect(testCase.category).toBeDefined(); + expect(testCase.formats).toBeDefined(); + expect(testCase.formats.oxa).toBeDefined(); + }); + + it("category matches manifest", () => { + expect(testCase.category).toBe(testCaseMeta.category); + }); + + it("OXA format validates against schema", () => { + // Determine the root type from the OXA content + const oxa = testCase.formats.oxa; + const rootType = oxa.type as string; + + const result = validate(oxa, { type: rootType }); + + if (!result.valid) { + // Provide helpful error message + const errors = result.errors.map((e) => e.message).join("\n "); + expect.fail( + `OXA content failed validation:\n ${errors}\n\nContent: ${JSON.stringify(oxa, null, 2)}`, + ); + } + + expect(result.valid).toBe(true); + }); + + it("nodeTypes in manifest match actual types in OXA", () => { + // Compare as sorted arrays to avoid Set iteration order issues + const actualTypes = Array.from( + extractTypes(testCase.formats.oxa), + ).sort(); + const manifestTypes = [...testCaseMeta.nodeTypes].sort(); + + expect(actualTypes).toEqual(manifestTypes); + }); + }); + } + }); +}); + +/** + * Extract all type values from an OXA node tree. + */ +function extractTypes(node: unknown): Set<string> { + const types = new Set<string>(); + + function walk(n: unknown): void { + if (n && typeof n === "object" && !Array.isArray(n)) { + const obj = n as Record<string, unknown>; + if (typeof obj.type === "string") { + types.add(obj.type); + } + for (const value of Object.values(obj)) { + walk(value); + } + } else if (Array.isArray(n)) { + for (const item of n) { + walk(item); + } + } + } + + walk(node); + return types; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f3d8695..358e81d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: eslint: specifier: ^9.39.2 version: 9.39.2 + json-schema-to-typescript: + specifier: ^15.0.4 + version: 15.0.4 prettier: specifier: ^3.7.4 version: 3.7.4 @@ -45,6 +48,8 @@ importers: specifier: workspace:* version: link:../oxa-core + packages/oxa-conformance: {} + packages/oxa-core: dependencies: ajv: @@ -66,6 +71,9 @@ importers: specifier: workspace:* version: link:../oxa-types-ts devDependencies: + '@oxa/conformance': + specifier: workspace:* + version: link:../oxa-conformance '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 @@ -83,6 +91,9 @@ importers: scripts: dependencies: + '@oxa/conformance': + specifier: workspace:* + version: link:../packages/oxa-conformance ajv: specifier: ^8.17.1 version: 8.17.1 @@ -105,6 +116,10 @@ importers: packages: + '@apidevtools/json-schema-ref-parser@11.9.3': + resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==} + engines: {node: '>= 16'} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -560,6 +575,9 @@ packages: '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@jsdevtools/ono@7.1.3': + resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} + '@manypkg/find-root@1.1.0': resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} @@ -713,6 +731,9 @@ packages: '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/lodash@4.17.23': + resolution: {integrity: sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA==} + '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} @@ -1204,6 +1225,11 @@ packages: json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-schema-to-typescript@15.0.4: + resolution: {integrity: sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==} + engines: {node: '>=16.0.0'} + hasBin: true + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -1245,6 +1271,9 @@ packages: lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1263,6 +1292,9 @@ packages: resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -1708,6 +1740,12 @@ packages: snapshots: + '@apidevtools/json-schema-ref-parser@11.9.3': + dependencies: + '@jsdevtools/ono': 7.1.3 + '@types/json-schema': 7.0.15 + js-yaml: 4.1.1 + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -2101,6 +2139,8 @@ snapshots: '@jridgewell/sourcemap-codec@1.5.5': {} + '@jsdevtools/ono@7.1.3': {} + '@manypkg/find-root@1.1.0': dependencies: '@babel/runtime': 7.28.4 @@ -2214,6 +2254,8 @@ snapshots: '@types/json-schema@7.0.15': {} + '@types/lodash@4.17.23': {} + '@types/node@12.20.55': {} '@types/node@22.19.3': @@ -2781,6 +2823,18 @@ snapshots: json-buffer@3.0.1: {} + json-schema-to-typescript@15.0.4: + dependencies: + '@apidevtools/json-schema-ref-parser': 11.9.3 + '@types/json-schema': 7.0.15 + '@types/lodash': 4.17.23 + is-glob: 4.0.3 + js-yaml: 4.1.1 + lodash: 4.17.23 + minimist: 1.2.8 + prettier: 3.7.4 + tinyglobby: 0.2.15 + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -2816,6 +2870,8 @@ snapshots: lodash.startcase@4.4.0: {} + lodash@4.17.23: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2835,6 +2891,8 @@ snapshots: dependencies: brace-expansion: 2.0.2 + minimist@1.2.8: {} + mri@1.2.0: {} ms@2.1.3: {} diff --git a/scripts/codegen.ts b/scripts/codegen.ts index 4228d70..9b84534 100644 --- a/scripts/codegen.ts +++ b/scripts/codegen.ts @@ -6,6 +6,7 @@ */ import { program } from "commander"; +import { generateConformance } from "./lib/generate-conformance.js"; import { generateDocs } from "./lib/generate-docs.js"; import { generateJson } from "./lib/generate-json.js"; import { generatePy } from "./lib/generate-py.js"; @@ -24,6 +25,11 @@ const generators: Generator[] = [ { name: "py", label: "Python Pydantic models", fn: generatePy }, { name: "rs", label: "Rust types", fn: generateRs }, { name: "ts", label: "TypeScript types", fn: generateTs }, + { + name: "conformance", + label: "Conformance suite manifest", + fn: generateConformance, + }, { name: "docs", label: "Schema documentation", fn: generateDocs }, ]; diff --git a/scripts/lib/generate-conformance.ts b/scripts/lib/generate-conformance.ts new file mode 100644 index 0000000..81acda8 --- /dev/null +++ b/scripts/lib/generate-conformance.ts @@ -0,0 +1,233 @@ +/** + * Generate conformance suite manifest and TypeScript types. + * + * - Generates manifest.json from test case files + * - Generates index.d.ts from JSON schemas + */ + +import { readdirSync, readFileSync, writeFileSync } from "fs"; +import { basename, join } from "path"; +import { compile } from "json-schema-to-typescript"; +import prettier from "prettier"; + +const CONFORMANCE_PATH = join( + import.meta.dirname, + "../../packages/oxa-conformance", +); +const CASES_PATH = join(CONFORMANCE_PATH, "cases"); +const SCHEMAS_PATH = join(CONFORMANCE_PATH, "schemas"); +const MANIFEST_OUTPUT_PATH = join(CONFORMANCE_PATH, "manifest.json"); +const TYPES_OUTPUT_PATH = join(CONFORMANCE_PATH, "index.d.ts"); + +interface TestCase { + title: string; + category: string; + formats: { + oxa: Record<string, unknown>; + }; +} + +interface ManifestCase { + id: string; + path: string; + category: string; + nodeTypes: string[]; +} + +interface Manifest { + $schema: string; + version: string; + formats: string[]; + cases: ManifestCase[]; +} + +/** + * Extract OXA node types from a test case's oxa format. + */ +function extractNodeTypes(oxa: Record<string, unknown>): string[] { + const types = new Set<string>(); + + function walk(node: unknown): void { + if (node && typeof node === "object" && !Array.isArray(node)) { + const obj = node as Record<string, unknown>; + if (typeof obj.type === "string") { + types.add(obj.type); + } + for (const value of Object.values(obj)) { + walk(value); + } + } else if (Array.isArray(node)) { + for (const item of node) { + walk(item); + } + } + } + + walk(oxa); + return Array.from(types).sort(); +} + +/** + * Scan a category directory for test case files. + * Validates that each test case's declared category matches its folder location. + */ +function scanCategory(category: string): ManifestCase[] { + const categoryPath = join(CASES_PATH, category); + const cases: ManifestCase[] = []; + + let files: string[]; + try { + files = readdirSync(categoryPath).filter((f) => f.endsWith(".json")); + } catch { + // Directory doesn't exist yet + return cases; + } + + for (const file of files) { + const filePath = join(categoryPath, file); + const content = readFileSync(filePath, "utf-8"); + const testCase = JSON.parse(content) as TestCase; + + // Validate category matches folder location + if (testCase.category !== category) { + throw new Error( + `Category mismatch in ${filePath}: ` + + `file declares "${testCase.category}" but is located in "${category}/" folder. ` + + `Move the file or update the category field.`, + ); + } + + const id = basename(file, ".json"); + const nodeTypes = extractNodeTypes(testCase.formats.oxa); + + cases.push({ + id, + path: `cases/${category}/${file}`, + category, + nodeTypes, + }); + } + + return cases; +} + +/** + * Generate TypeScript types from JSON schemas. + */ +async function generateTypes(): Promise<void> { + const testCaseSchema = JSON.parse( + readFileSync(join(SCHEMAS_PATH, "test-case.schema.json"), "utf-8"), + ); + const manifestSchema = JSON.parse( + readFileSync(join(SCHEMAS_PATH, "manifest.schema.json"), "utf-8"), + ); + + // Generate types from schemas + const testCaseTypes = await compile(testCaseSchema, "TestCase", { + bannerComment: "", + additionalProperties: false, + }); + + const manifestTypes = await compile(manifestSchema, "Manifest", { + bannerComment: "", + additionalProperties: false, + }); + + // Extract the generated interface names from the output + // json-schema-to-typescript uses the schema title for the interface name + const testCaseMatch = testCaseTypes.match(/export interface (\w+)/); + const manifestMatch = manifestTypes.match(/export interface (\w+)/); + const testCaseInterfaceName = testCaseMatch?.[1] ?? "TestCase"; + const manifestInterfaceName = manifestMatch?.[1] ?? "Manifest"; + + // Combine into a single declaration file with friendly type aliases + const output = `/** + * Type declarations for @oxa/conformance + * + * AUTO-GENERATED from JSON schemas - do not edit directly. + * Run \`pnpm codegen conformance\` to regenerate. + */ + +${testCaseTypes} + +${manifestTypes} + +// Friendly type aliases +export type TestCase = ${testCaseInterfaceName}; +export type Manifest = ${manifestInterfaceName}; +export type ManifestCase = Manifest["cases"][number]; + +export declare const manifest: Manifest; + +export declare const cases: Record<string, TestCase>; +`; + + const formatted = await prettier.format(output, { + parser: "typescript", + filepath: TYPES_OUTPUT_PATH, + }); + + writeFileSync(TYPES_OUTPUT_PATH, formatted); + console.log(`Generated ${TYPES_OUTPUT_PATH}`); +} + +/** + * Generate manifest.json from test case files. + */ +async function generateManifest(): Promise<void> { + // Get version from oxa-conformance package.json + const pkgPath = join(CONFORMANCE_PATH, "package.json"); + const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); + const version = pkg.version; + + // Scan all category directories + const categories = ["inline", "block", "document"]; + const allCases: ManifestCase[] = []; + + for (const category of categories) { + const cases = scanCategory(category); + allCases.push(...cases); + } + + // Sort cases by category then id for deterministic output + allCases.sort((a, b) => { + if (a.category !== b.category) { + return categories.indexOf(a.category) - categories.indexOf(b.category); + } + return a.id.localeCompare(b.id); + }); + + const manifest: Manifest = { + $schema: "./schemas/manifest.schema.json", + version, + formats: [ + "oxa", + "myst-ast", + "pandoc-types", + "stencila-schema", + "markdown", + "myst-markdown", + "stencila-markdown", + "quarto-markdown", + "html", + "jats", + ], + cases: allCases, + }; + + const json = JSON.stringify(manifest, null, 2); + const formatted = await prettier.format(json, { + parser: "json", + filepath: MANIFEST_OUTPUT_PATH, + }); + + writeFileSync(MANIFEST_OUTPUT_PATH, formatted); + console.log( + `Generated ${MANIFEST_OUTPUT_PATH} with ${allCases.length} test cases`, + ); +} + +export async function generateConformance(): Promise<void> { + await generateTypes(); + await generateManifest(); +} diff --git a/scripts/lib/generate-docs.ts b/scripts/lib/generate-docs.ts index 9300aa6..8601c4e 100644 --- a/scripts/lib/generate-docs.ts +++ b/scripts/lib/generate-docs.ts @@ -8,6 +8,8 @@ import { mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from "fs"; import { join } from "path"; +import { manifest, cases, type TestCase } from "@oxa/conformance"; + import { loadMergedSchema } from "./schema.js"; const OUTPUT_DIR = join(import.meta.dirname, "../../docs/schema"); @@ -54,11 +56,12 @@ export async function generateDocs(): Promise<void> { const schema = loadMergedSchema(); const definitions = schema.definitions as Record<string, SchemaDefinition>; + const testCases = loadTestCases(); // Generate documentation for object types (non-union types) for (const [name, def] of Object.entries(definitions)) { if (!def.anyOf && def.type === "object") { - const content = generateDocContent(name, def); + const content = generateDocContent(name, def, testCases); const filePath = join(OUTPUT_DIR, `${name.toLowerCase()}.md`); writeFileSync(filePath, content); console.log(`Generated ${filePath}`); @@ -76,7 +79,11 @@ export async function generateDocs(): Promise<void> { } } -function generateDocContent(name: string, def: SchemaDefinition): string { +function generateDocContent( + name: string, + def: SchemaDefinition, + testCases: Map<string, TestCase>, +): string { const lines: string[] = []; // Frontmatter @@ -127,6 +134,12 @@ function generateDocContent(name: string, def: SchemaDefinition): string { lines.push(""); } + // Add test case example if available + const testCase = testCases.get(name.toLowerCase()); + if (testCase) { + lines.push(generateTestCaseSection(testCase)); + } + return lines.join("\n"); } @@ -199,3 +212,76 @@ function getArrayItemType(items: { $ref?: string; type?: string }): string { } return "unknown"; } + +function loadTestCases(): Map<string, TestCase> { + const testCases = new Map<string, TestCase>(); + + // Filter for *-basic test cases + for (const caseInfo of manifest.cases) { + if (!caseInfo.id.endsWith("-basic")) continue; + + // Primary node type is first in nodeTypes array + const primaryType = caseInfo.nodeTypes[0]; + if (!primaryType) continue; + + const testCase = cases[caseInfo.id]; + testCases.set(primaryType.toLowerCase(), testCase); + } + + return testCases; +} + +const FORMAT_LABELS: Record<string, string> = { + oxa: "OXA", + "myst-ast": "MyST AST", + "pandoc-types": "Pandoc Types", + "stencila-schema": "Stencila Schema", + markdown: "Markdown", + "myst-markdown": "MyST Markdown", + "stencila-markdown": "Stencila Markdown", + "quarto-markdown": "Quarto Markdown", + html: "HTML", + jats: "JATS", +}; + +const FORMAT_LANGUAGES: Record<string, string> = { + oxa: "json", + "myst-ast": "json", + "pandoc-types": "json", + "stencila-schema": "json", + markdown: "markdown", + "myst-markdown": "markdown", + "stencila-markdown": "markdown", + "quarto-markdown": "markdown", + html: "html", + jats: "xml", +}; + +function generateTestCaseSection(testCase: TestCase): string { + const lines: string[] = []; + + lines.push("### Example"); + lines.push(""); + lines.push("`````{tab-set}"); + + for (const format of manifest.formats) { + const value = testCase.formats[format as keyof typeof testCase.formats]; + if (value === undefined) continue; + + const label = FORMAT_LABELS[format]; + const lang = FORMAT_LANGUAGES[format]; + const content = typeof value === "string" ? value : JSON.stringify(value); + + lines.push(`\`\`\`\`{tab-item} ${label}`); + lines.push(`:sync: ${format}`); + lines.push(`\`\`\`${lang}`); + lines.push(content); + lines.push("```"); + lines.push("````"); + lines.push(""); + } + + lines.push("`````"); + + return lines.join("\n"); +} diff --git a/scripts/package.json b/scripts/package.json index 73c1d2a..de97d69 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -11,6 +11,7 @@ "lint:fix": "eslint --fix ." }, "dependencies": { + "@oxa/conformance": "workspace:*", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "commander": "^14.0.2",