diff --git a/packages/vocab/.npmignore b/packages/vocab/.npmignore new file mode 100644 index 0000000..db25063 --- /dev/null +++ b/packages/vocab/.npmignore @@ -0,0 +1,4 @@ +*_test.ts +*_bench.ts +_memory_test.ts +*.map diff --git a/packages/vocab/README.md b/packages/vocab/README.md new file mode 100644 index 0000000..52185f2 --- /dev/null +++ b/packages/vocab/README.md @@ -0,0 +1,69 @@ +# `@okikio/vocab` + +RDF ontology compiler and generated vocabulary runtime. + +## Generated vocabulary + +Generated vocabularies expose direct RDF terms, TypeScript types, and Standard Schema validators: + +```ts +import { + Product, + ProductSchema, + type ProductType, + name, +} from '@okikio/vocab/schema' +``` + +Generated terms are `@okikio/rdf` named nodes, so they work directly with datasets and SPARQL builders. + +```ts +import * as rdf from '@okikio/rdf' +import * as sparql from '@okikio/sparql' +import { Product, name } from '@okikio/vocab/schema' + +const query = sparql.select(['?product', '?name']).where( + sparql.triple('?product', rdf.namedNode(rdf.RDF.type), Product), + sparql.triple('?product', name, '?name'), +) +``` + +## Standard Schema + +Each generated `*Schema` implements runtime validation and Standard JSON Schema conversion on the same `~standard` object: + +```ts +const result = await ProductSchema['~standard'].validate({ + '@type': 'Product', + name: 'Widget', +}) + +const jsonSchema = ProductSchema['~standard'].jsonSchema.input({ + target: 'draft-2020-12', +}) +``` + +The runtime is open-world. Unknown vocabulary extensions are accepted, and RDFS/OWL domain statements are not converted into false required-property rules. + +See [`../../docs/standard-schema.md`](../../docs/standard-schema.md). + +## Compiler + +The old format-specific `ttl-to-ts` script is replaced by the reusable `compile()` library API: + +```ts +import * as turtle from '@okikio/rdf/turtle' +import { compile } from '@okikio/vocab/compile' + +const result = await compile([ + { id: 'example', quads: turtle.parse(source) }, +], { + vocabulary: 'example', + namespace: 'https://example.com/', + prefix: 'ex', +}) +``` + +The compiler consumes RDF quad sources through `@okikio/rdf/ontology`; it does not own one serialization parser. `.mise/tasks/vocab.ts` is only the repository file-I/O wrapper. + +See [`../../docs/vocabulary-generation.md`](../../docs/vocabulary-generation.md). diff --git a/packages/vocab/compile.ts b/packages/vocab/compile.ts new file mode 100644 index 0000000..8a02602 --- /dev/null +++ b/packages/vocab/compile.ts @@ -0,0 +1,27 @@ +/** Format-neutral RDF ontology to TypeScript vocabulary compiler. @module */ + +import type { OntologySourceType } from '@okikio/rdf/ontology' +import { emit, type EmitOptionsType, type EmitResultType } from './emit.ts' +import { read, type ReadOptions } from './read.ts' + +/** Options for one ontology compilation. */ +export interface CompileOptionsType extends EmitOptionsType { + /** Ontology-reading limits and vocabulary-specific relationship aliases. */ + readonly read?: ReadOptions +} + +/** + * Compiles RDF quad sources into one deterministic TypeScript vocabulary module. + * + * Parsing is deliberately outside this function. Turtle, TriG, N-Quads, + * JSON-LD, RDF/XML, a triplestore cursor, or any future source can participate + * as long as it exposes RDF quads. This is the reusable replacement for the old + * format-specific `ttl-to-ts` script. + */ +export async function compile( + sources: readonly OntologySourceType[], + options: CompileOptionsType, +): Promise { + const model = await read(sources, options.read) + return emit(model, options) +} diff --git a/packages/vocab/compile_bench.ts b/packages/vocab/compile_bench.ts new file mode 100644 index 0000000..bbcb26a --- /dev/null +++ b/packages/vocab/compile_bench.ts @@ -0,0 +1,41 @@ +/** Decision benchmark for the format-neutral ontology compiler pipeline. @module */ + +import { bench, do_not_optimize, run } from 'mitata' +import { namedNode, quad, type Quad } from '@okikio/rdf' +import { compile } from './compile.ts' + +const RDF_TYPE = namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type') +const RDFS_CLASS = namedNode('http://www.w3.org/2000/01/rdf-schema#Class') +const RDF_PROPERTY = namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#Property') +const RDFS_DOMAIN = namedNode('http://www.w3.org/2000/01/rdf-schema#domain') +const RDFS_RANGE = namedNode('http://www.w3.org/2000/01/rdf-schema#range') +const XSD_STRING = namedNode('http://www.w3.org/2001/XMLSchema#string') + +const CLASS_COUNT = 500 +const PROPERTY_COUNT = 250 +const quads: Quad[] = [] +for (let index = 0; index < CLASS_COUNT; index++) { + quads.push(quad(namedNode(`https://example.test/Class${index}`), RDF_TYPE, RDFS_CLASS)) +} +for (let index = 0; index < PROPERTY_COUNT; index++) { + const property = namedNode(`https://example.test/property${index}`) + quads.push(quad(property, RDF_TYPE, RDF_PROPERTY)) + quads.push(quad(property, RDFS_DOMAIN, namedNode(`https://example.test/Class${index % CLASS_COUNT}`))) + quads.push(quad(property, RDFS_RANGE, XSD_STRING)) +} + +const options = { + vocabulary: 'Benchmark', + namespace: 'https://example.test/', + prefix: 'bench', +} as const + +const compileFixture = () => compile([{ id: 'benchmark', quads }], options) +const oracle = await compileFixture() +if (!oracle.source.includes('export const Class499 =')) throw new Error('Vocabulary compiler benchmark oracle failed.') + +bench('vocab compile: 500 classes + 250 properties', async () => { + do_not_optimize((await compileFixture()).source.length) +}) + +await run() diff --git a/packages/vocab/compile_test.ts b/packages/vocab/compile_test.ts new file mode 100644 index 0000000..8f35c56 --- /dev/null +++ b/packages/vocab/compile_test.ts @@ -0,0 +1,38 @@ +import { describe, it } from 'node:test' +import { expect } from '@std/expect' +import { namedNode, quad } from '@okikio/rdf' +import { parse as parseTurtle } from '@okikio/rdf/turtle' +import { compile } from './compile.ts' + +const RDF_TYPE = namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type') +const RDFS_CLASS = namedNode('http://www.w3.org/2000/01/rdf-schema#Class') + +describe('@okikio/vocab compile', () => { + it('replaces format-specific ttl-to-ts generation with a quad-source compiler', async () => { + const product = namedNode('https://example.test/Product') + const direct = { id: 'direct', quads: [quad(product, RDF_TYPE, RDFS_CLASS)] } + const turtle = { + id: 'turtle', + quads: parseTurtle('@prefix rdfs: .\n a rdfs:Class .'), + } + const result = await compile([direct, turtle], { + vocabulary: 'Example', + namespace: 'https://example.test/', + prefix: 'example', + }) + expect(result.source.includes('export const Product =')).toBe(true) + expect(result.source.includes('export const Offer =')).toBe(true) + }) + + it('is deterministic for repeated semantic inputs', async () => { + const make = () => [{ + id: 'source', + quads: [quad(namedNode('https://example.test/Product'), RDF_TYPE, RDFS_CLASS)], + }] + const options = { vocabulary: 'Example', namespace: 'https://example.test/', prefix: 'example' } + const first = await compile(make(), options) + const second = await compile(make(), options) + expect(first.source).toBe(second.source) + expect(first.manifest).toEqual(second.manifest) + }) +}) diff --git a/packages/vocab/deno.json b/packages/vocab/deno.json new file mode 100644 index 0000000..9a97b5d --- /dev/null +++ b/packages/vocab/deno.json @@ -0,0 +1,12 @@ +{ + "name": "@okikio/vocab", + "version": "0.1.0", + "license": "MIT", + "exports": { + ".": "./mod.ts", + "./runtime": "./runtime.ts", + "./schema": "./schema/mod.ts", + "./compile": "./compile.ts", + "./standard": "./standard.ts" + } +} diff --git a/packages/vocab/emit.ts b/packages/vocab/emit.ts new file mode 100644 index 0000000..8c076c3 --- /dev/null +++ b/packages/vocab/emit.ts @@ -0,0 +1,293 @@ +/** TypeScript vocabulary source emitter. @module */ + +import type { ManifestType, PropertyType, VocabularyModelType } from './model.ts' +import { createManifest } from './manifest.ts' +import { plan, type NamePlanType } from './name.ts' +import type { RangeKind } from './runtime.ts' + +/** TypeScript vocabulary emission options. */ +export interface EmitOptionsType { + readonly vocabulary: string + readonly namespace: string + readonly prefix: string + readonly rdfImport?: string + readonly runtimeImport?: string +} + +/** Complete deterministic vocabulary generation result. */ +export interface EmitResultType { + readonly source: string + readonly manifest: ManifestType +} + +/** + * Emits a directly importable TypeScript vocabulary module. + * + * The generated source contains RDF term constants, JSON-LD property interfaces, + * class node types, Standard Schema validators, and a multi-type composition map. + * It intentionally keeps the ontology IR independent of TypeScript syntax. + */ +export function emit(model: VocabularyModelType, options: EmitOptionsType): EmitResultType { + const names = plan(model, { prefix: options.prefix }) + const writer = new Writer() + const rdfImport = options.rdfImport ?? '@okikio/rdf' + const runtimeImport = options.runtimeImport ?? '@okikio/vocab/runtime' + + writer.line('/**') + writer.line(` * Generated ${options.vocabulary} vocabulary terms, types, and schemas.`) + writer.line(' *') + writer.line(' * This file is generated. Edit the ontology source or generator instead.') + writer.line(' * @module') + writer.line(' */') + writer.line('') + writer.line(`import { namedNode } from ${quote(rdfImport)}`) + writer.line(`import { createSchema, type IdReferenceType, type NodeType, type ValueType } from ${quote(runtimeImport)}`) + writer.line('') + writer.line('/** Base IRI used by every generated vocabulary term in this module. */') + writer.line(`export const namespace = ${quote(options.namespace)}`) + writer.line('') + + emitTerms(writer, model, names) + emitProperties(writer, model, names) + emitClasses(writer, model, names) + emitTypeMap(writer, model, names) + + return { + source: `${writer.toString()}\n`, + manifest: createManifest(options.vocabulary, model, names), + } +} + +/** Emit terms deterministically to the caller-owned output. */ +function emitTerms(writer: Writer, model: VocabularyModelType, names: NamePlanType): void { + writer.line('/** RDF class terms. */') + for (const value of model.classes) { + const name = names.classes.get(value.iri)! + emitDoc(writer, value.comments, value.deprecated, `RDF class term for ${name}.`) + writer.line(`export const ${name} = namedNode(${quote(value.iri)})`) + } + writer.line('') + + writer.line('/** RDF datatype terms. */') + for (const iri of model.datatypes) { + const name = names.datatypes.get(iri)! + writer.line(`/** RDF datatype term for ${name}. */`) + writer.line(`export const ${name} = namedNode(${quote(iri)})`) + } + writer.line('') + + writer.line('/** RDF property terms. */') + for (const value of model.properties) { + const name = names.properties.get(value.iri)! + emitDoc(writer, value.comments, value.deprecated, `RDF property term for ${name}.`) + writer.line(`export const ${name} = namedNode(${quote(value.iri)})`) + } + writer.line('') +} + +/** Emit properties deterministically to the caller-owned output. */ +function emitProperties(writer: Writer, model: VocabularyModelType, names: NamePlanType): void { + const byDomain = propertiesByDomain(model) + for (const value of model.classes) { + const className = names.classes.get(value.iri)! + const supers = value.superClasses + .map((iri) => names.classes.get(iri)) + .filter((name): name is string => name !== undefined) + .map((name) => `${name}PropertiesType`) + const heritage = supers.length > 0 ? ` extends ${supers.join(', ')}` : '' + writer.line(`/** JSON-LD properties directly available to ${className}, including inherited interfaces. */`) + writer.line(`export interface ${className}PropertiesType${heritage} {`) + writer.indent(() => { + for (const property of byDomain.get(value.iri) ?? []) { + const propertyName = names.properties.get(property.iri)! + writer.line(`readonly ${propertyKey(propertyName)}?: ValueType<${propertyType(property, names)}>`) + } + }) + writer.line('}') + writer.line('') + } +} + +/** Emit classes deterministically to the caller-owned output. */ +function emitClasses(writer: Writer, model: VocabularyModelType, names: NamePlanType): void { + const directProperties = propertiesByDomain(model) + for (const value of model.classes) { + const name = names.classes.get(value.iri)! + const parents = value.superClasses + .map((iri) => names.classes.get(iri)) + .filter((parent): parent is string => parent !== undefined) + writer.line(`/** JSON-LD node typed as ${name}. */`) + writer.line(`export type ${name}Type = NodeType<${quote(name)}, ${name}PropertiesType>`) + writer.line(`/** Standard Schema validator and JSON Schema converter for ${name}. */`) + writer.line(`export const ${name}Schema = createSchema<${name}Type>({`) + writer.indent(() => { + writer.line(`types: [${quote(name)}],`) + if (parents.length > 0) writer.line(`parents: () => [${parents.map((parent) => `${parent}Schema`).join(', ')}],`) + const properties = directProperties.get(value.iri) ?? [] + if (properties.length > 0) { + writer.line('properties: {') + writer.indent(() => { + for (const property of properties) { + const propertyName = names.properties.get(property.iri)! + writer.line(`${propertyKey(propertyName)}: ${rangeLiteral(property)},`) + } + }) + writer.line('},') + } + }) + writer.line('})') + writer.line('') + } +} + +/** Emit type map deterministically to the caller-owned output. */ +function emitTypeMap(writer: Writer, model: VocabularyModelType, names: NamePlanType): void { + writer.line('/** Generated datatype value aliases. */') + for (const iri of model.datatypes) { + const name = names.datatypes.get(iri)! + writer.line(`/** JavaScript value type for the ${name} RDF datatype. */`) + writer.line(`export type ${name}Type = ${scalarType(iri) ?? 'unknown'}`) + } + writer.line('') + writer.line('/** Generated class-name to property-interface map used by multi-typed JSON-LD nodes. */') + writer.line('export interface TypeMapType {') + writer.indent(() => { + for (const value of model.classes) { + const name = names.classes.get(value.iri)! + writer.line(`readonly ${propertyKey(name)}: ${name}PropertiesType`) + } + }) + writer.line('}') + writer.line('') + writer.line('/** Every generated vocabulary class name accepted by multi-type nodes. */') + writer.line('export type ClassNameType = keyof TypeMapType') + writer.line('/** Resolves one generated class name to its property interface. */') + writer.line('type PropertiesForType = Type extends keyof TypeMapType ? TypeMapType[Type] : never') + writer.line('/** Converts the selected class-property union into one intersection for multi-typed nodes. */') + writer.line('type UnionToIntersection = (Value extends unknown ? (value: Value) => void : never) extends (value: infer Intersection) => void ? Intersection : never') + writer.line('') + writer.line('/** Intersects the properties contributed by every class on a multi-typed JSON-LD node. */') + writer.line('type MergedPropertiesType = UnionToIntersection> & object') + writer.line('/** JSON-LD node carrying all properties contributed by the selected generated class names. */') + writer.line('export type MultiTypeType = NodeType>') +} + +/** Indexes properties by directly declared domain without treating RDFS domain as requiredness. */ +function propertiesByDomain(model: VocabularyModelType): Map { + const result = new Map() + for (const property of model.properties) { + for (const domain of property.domains) { + const values = result.get(domain) ?? [] + values.push(property) + result.set(domain, values) + } + } + for (const values of result.values()) values.sort((a, b) => a.iri.localeCompare(b.iri)) + return result +} + + +/** Builds the generated TypeScript value type for one ontology property range. */ +function propertyType(property: PropertyType, names: NamePlanType): string { + if (property.ranges.length === 0) return 'unknown' + const types = new Set() + for (const range of property.ranges) { + const scalar = scalarType(range) + if (scalar) types.add(scalar) + else { + const className = names.classes.get(range) + types.add(className ? `${className}Type | IdReferenceType` : 'unknown') + } + } + return [...types].sort().join(' | ') || 'unknown' +} + +/** Maps known RDF datatype IRIs to JavaScript-native TypeScript scalar types. */ +function scalarType(iri: string): string | undefined { + if (iri === 'https://schema.org/Text' || iri === 'http://schema.org/Text') return 'string' + if (iri === 'https://schema.org/URL' || iri === 'http://schema.org/URL') return 'string' + if (iri === 'https://schema.org/Number' || iri === 'http://schema.org/Number') return 'number' + if (iri === 'https://schema.org/Integer' || iri === 'http://schema.org/Integer') return 'number' + if (iri === 'https://schema.org/Float' || iri === 'http://schema.org/Float') return 'number' + if (iri === 'https://schema.org/Boolean' || iri === 'http://schema.org/Boolean') return 'boolean' + if (iri === 'http://www.w3.org/2001/XMLSchema#string') return 'string' + if (iri === 'http://www.w3.org/2001/XMLSchema#boolean') return 'boolean' + if (/^http:\/\/www\.w3\.org\/2001\/XMLSchema#(?:decimal|double|float|integer|int|long|short|byte|nonNegativeInteger|nonPositiveInteger|positiveInteger|negativeInteger|unsignedLong|unsignedInt|unsignedShort|unsignedByte)$/.test(iri)) return 'number' + return undefined +} + +/** Builds the runtime range descriptor emitted into a generated Standard Schema. */ +function rangeLiteral(property: PropertyType): string { + const kinds = new Set() + if (property.ranges.length === 0) kinds.add('unknown') + for (const range of property.ranges) { + const scalar = scalarType(range) + if (scalar === 'string') kinds.add('string') + else if (scalar === 'number') kinds.add('number') + else if (scalar === 'boolean') kinds.add('boolean') + else kinds.add('node') + } + const values = [...kinds].sort() + return values.length === 1 ? quote(values[0]!) : `[${values.map(quote).join(', ')}]` +} + +/** Writes normalized ontology documentation and deprecation metadata into generated TSDoc. */ +function emitDoc( + writer: Writer, + comments: readonly { readonly value: string }[], + deprecated: boolean, + fallback: string, +): void { + writer.line('/**') + writer.line(` * ${comments[0] ? cleanDoc(comments[0].value) : fallback}`) + if (deprecated) writer.line(' * @deprecated The vocabulary marks this term as deprecated.') + writer.line(' */') +} + +/** Normalizes ontology prose so it is safe to embed in one generated TSDoc block. */ +function cleanDoc(value: string): string { + return value.replace(/\*\//g, '*\\/').replace(/\s+/g, ' ').trim() +} + +/** Serializes a generated property or class name as a valid TypeScript object key. */ +function propertyKey(value: string): string { + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value) ? value : quote(value) +} + +/** Serializes one deterministic JavaScript string literal for generated source. */ +function quote(value: string): string { + const escaped = value + .replace(/\\/g, '\\\\') + .replace(/'/g, "\\'") + .replace(/\r/g, '\\r') + .replace(/\n/g, '\\n') + .replace(/\u2028/g, '\\u2028') + .replace(/\u2029/g, '\\u2029') + return `'${escaped}'` +} + +/** Internal Writer implementation and its owned state. */ +class Writer { + #lines: string[] = [] + #depth = 0 + + /** Appends one source line at the current indentation depth. */ + line(value = ''): void { + this.#lines.push(`${' '.repeat(this.#depth)}${value}`) + } + + /** Runs one nested emission step and restores indentation even when it throws. */ + indent(write: () => void): void { + this.#depth++ + try { + write() + } finally { + this.#depth-- + } + } + + /** Joins emitted lines without adding a trailing newline. */ + toString(): string { + return this.#lines.join('\n') + } +} diff --git a/packages/vocab/emit_test.ts b/packages/vocab/emit_test.ts new file mode 100644 index 0000000..3a8458c --- /dev/null +++ b/packages/vocab/emit_test.ts @@ -0,0 +1,18 @@ +import { describe, it } from 'node:test' +import { expect } from '@std/expect' +import { namedNode, quad } from '@okikio/rdf' +import { emit, read } from './mod.ts' + +const RDF_TYPE = namedNode('http://www.w3.org/1999/02/22-rdf-syntax-ns#type') +const RDFS_CLASS = namedNode('http://www.w3.org/2000/01/rdf-schema#Class') + +describe('@okikio/vocab', () => { + it('emits deterministic direct class term/type/schema exports', async () => { + const product = namedNode('https://schema.org/Product') + const model = await read([{ id: 'schema', quads: [quad(product, RDF_TYPE, RDFS_CLASS)] }]) + const source = emit(model, { vocabulary: 'Schema.org', namespace: 'https://schema.org/', prefix: 'schema' }).source + expect(source.includes('export const Product =')).toBe(true) + expect(source.includes('export type ProductType =')).toBe(true) + expect(source.includes('export const ProductSchema =')).toBe(true) + }) +}) diff --git a/packages/vocab/manifest.ts b/packages/vocab/manifest.ts new file mode 100644 index 0000000..68e3818 --- /dev/null +++ b/packages/vocab/manifest.ts @@ -0,0 +1,21 @@ +/** Generated vocabulary manifest creation. @module */ + +import type { ManifestType, VocabularyModelType } from './model.ts' +import type { NamePlanType } from './name.ts' + +/** Creates the reproducible manifest stored beside generated vocabulary output. */ +export function createManifest( + vocabulary: string, + model: VocabularyModelType, + names: NamePlanType, + generator = '@okikio/vocab/0.1.0', +): ManifestType { + return { + version: 1, + generator, + vocabulary, + sources: model.sources, + symbols: names.symbols, + diagnostics: model.diagnostics, + } +} diff --git a/packages/vocab/mod.ts b/packages/vocab/mod.ts new file mode 100644 index 0000000..1755943 --- /dev/null +++ b/packages/vocab/mod.ts @@ -0,0 +1,10 @@ +/** RDF ontology compilation and generated vocabulary support. @module */ + +export * from './compile.ts' +export * from './emit.ts' +export * from './manifest.ts' +export * from './model.ts' +export * from './name.ts' +export * from './read.ts' +export * from './runtime.ts' +export * from './standard.ts' diff --git a/packages/vocab/model.ts b/packages/vocab/model.ts new file mode 100644 index 0000000..3863cdd --- /dev/null +++ b/packages/vocab/model.ts @@ -0,0 +1,58 @@ +/** Serializable vocabulary compiler intermediate representation. @module */ + +import type { + AssertionType as OntologyAssertionType, + ClassType as OntologyClassType, + PropertyType as OntologyPropertyType, + SourceType as OntologySourceType, + TextType as OntologyTextType, +} from '@okikio/rdf/ontology' + +/** Localized ontology text inherited from the generic RDF ontology model. */ +export type LabelType = OntologyTextType + +/** One ontology class with generator-facing symbol candidates. */ +export interface ClassType extends OntologyClassType { + readonly names: readonly string[] +} + +/** One ontology property with generator-facing symbol candidates. */ +export interface PropertyType extends Omit { + readonly names: readonly string[] + /** Convenience projection used by current emitters. */ + readonly functional: boolean + readonly characteristics: OntologyPropertyType['characteristics'] +} + +/** Assertion retained when the vocabulary generator does not interpret it. */ +export type AssertionType = OntologyAssertionType + +/** Source metadata supplied to the ontology compiler. */ +export type SourceType = OntologySourceType + +/** Stable language-neutral ontology model consumed by emitters. */ +export interface VocabularyModelType { + readonly sources: readonly SourceType[] + readonly classes: readonly ClassType[] + readonly properties: readonly PropertyType[] + readonly datatypes: readonly string[] + readonly assertions: readonly AssertionType[] + readonly diagnostics: readonly string[] +} + +/** Generated symbol mapping retained for collision review and provenance. */ +export interface SymbolType { + readonly iri: string + readonly kind: 'class' | 'property' | 'datatype' + readonly name: string +} + +/** Machine-readable output manifest. */ +export interface ManifestType { + readonly version: 1 + readonly generator: string + readonly vocabulary: string + readonly sources: readonly SourceType[] + readonly symbols: readonly SymbolType[] + readonly diagnostics: readonly string[] +} diff --git a/packages/vocab/name.ts b/packages/vocab/name.ts new file mode 100644 index 0000000..c234ec4 --- /dev/null +++ b/packages/vocab/name.ts @@ -0,0 +1,144 @@ +/** Deterministic vocabulary symbol planning. @module */ + +import type { SymbolType, VocabularyModelType } from './model.ts' + +/** Options that control generated identifier fallback names. */ +export interface NameOptionsType { + /** Short vocabulary prefix used only when a canonical name collides or is invalid. */ + readonly prefix: string +} + +/** Planned generated symbols for classes, properties, and datatypes. */ +export interface NamePlanType { + readonly classes: ReadonlyMap + readonly properties: ReadonlyMap + readonly datatypes: ReadonlyMap + readonly symbols: readonly SymbolType[] +} + +/** ECMAScript/TypeScript words that cannot be emitted unchanged as binding identifiers. */ +const RESERVED = new Set([ + 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', + 'delete', 'do', 'else', 'enum', 'export', 'extends', 'false', 'finally', 'for', 'function', + 'if', 'implements', 'import', 'in', 'instanceof', 'interface', 'let', 'new', 'null', + 'package', 'private', 'protected', 'public', 'return', 'static', 'super', 'switch', 'this', + 'throw', 'true', 'try', 'typeof', 'undefined', 'var', 'void', 'while', 'with', 'yield', +]) + +/** + * Creates a byte-stable identifier plan for one vocabulary model. + * + * Canonical local names win when they are valid and unique. Invalid or colliding + * names receive a deterministic vocabulary-qualified fallback. The fallback is + * intentionally exceptional; ordinary generated APIs retain vocabulary-native + * names such as `Product` and `name`. + */ +export function plan(model: VocabularyModelType, options: NameOptionsType): NamePlanType { + const used = new Map() + const classes = new Map() + const properties = new Map() + const datatypes = new Map() + const symbols: SymbolType[] = [] + + for (const value of [...model.classes].sort((left, right) => left.iri.localeCompare(right.iri))) { + const name = claim(preferred(value.names, value.iri), value.iri, options.prefix, used, 'Class') + classes.set(value.iri, name) + symbols.push({ iri: value.iri, kind: 'class', name }) + } + for (const value of [...model.properties].sort((left, right) => left.iri.localeCompare(right.iri))) { + const name = claim(preferred(value.names, value.iri), value.iri, options.prefix, used, 'Property') + properties.set(value.iri, name) + symbols.push({ iri: value.iri, kind: 'property', name }) + } + for (const iri of [...model.datatypes].sort((left, right) => left.localeCompare(right))) { + const name = claim(localName(iri), iri, options.prefix, used, 'Datatype') + datatypes.set(iri, name) + symbols.push({ iri, kind: 'datatype', name }) + } + + symbols.sort((a, b) => a.iri.localeCompare(b.iri) || a.kind.localeCompare(b.kind)) + return { classes, properties, datatypes, symbols } +} + +/** Selects the first valid non-reserved ontology name before falling back to the IRI local name. */ +function preferred(names: readonly string[], iri: string): string { + for (const name of names) if (isIdentifier(name) && !RESERVED.has(name)) return name + return names[0] ?? localName(iri) +} + +/** Claims one deterministic TypeScript symbol, qualifying collisions with vocabulary and kind information. */ +function claim( + candidate: string, + iri: string, + prefix: string, + used: Map, + kind: 'Class' | 'Property' | 'Datatype', +): string { + const base = isIdentifier(candidate) && !RESERVED.has(candidate) + ? candidate + : `${safePrefix(prefix)}${pascal(candidate || localName(iri))}` + const owner = used.get(base) + if (!owner || owner === iri) { + used.set(base, iri) + return base + } + + const qualified = `${safePrefix(prefix)}${pascal(base)}${kind}` + const qualifiedOwner = used.get(qualified) + if (!qualifiedOwner || qualifiedOwner === iri) { + used.set(qualified, iri) + return qualified + } + + // Stable short IRI digest avoids source-order-dependent numeric suffixes. + const fallback = `${qualified}${hash(iri)}` + used.set(fallback, iri) + return fallback +} + +/** Converts an arbitrary vocabulary prefix into a valid PascalCase TypeScript identifier prefix. */ +function safePrefix(value: string): string { + const clean = value.replace(/[^A-Za-z0-9_$]+/g, ' ').trim() + const name = pascal(clean || 'Vocab') + return /^[A-Za-z_$]/.test(name) ? name : `Vocab${name}` +} + +/** Converts punctuation-separated source text into a valid PascalCase identifier candidate. */ +function pascal(value: string): string { + const parts = value.split(/[^A-Za-z0-9_$]+/g).filter(Boolean) + const joined = parts.map((part) => part.length === 0 ? '' : `${part[0]!.toUpperCase()}${part.slice(1)}`).join('') + if (!joined) return 'Term' + return /^[A-Za-z_$]/.test(joined) ? joined : `Term${joined}` +} + +/** Returns whether the supplied value satisfies the identifier contract. */ +function isIdentifier(value: string): boolean { + return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(value) +} + +/** Derives a stable source-symbol candidate from an ontology IRI without changing the IRI itself. */ +function localName(iri: string): string { + const hashIndex = iri.lastIndexOf('#') + const slashIndex = iri.lastIndexOf('/') + const colonIndex = iri.lastIndexOf(':') + return decodeSafe(iri.slice(Math.max(hashIndex, slashIndex, colonIndex) + 1)) || 'Term' +} + +/** Decodes percent-escaped local names while preserving malformed source text verbatim. */ +function decodeSafe(value: string): string { + try { + return decodeURIComponent(value) + } catch { + return value + } +} + +/** Computes the stable short FNV-1a suffix used only when qualified symbol names still collide. */ +function hash(value: string): string { + let result = 2166136261 + for (let index = 0; index < value.length; index++) { + result ^= value.charCodeAt(index) + result = Math.imul(result, 16777619) + } + return (result >>> 0).toString(36).toUpperCase() +} diff --git a/packages/vocab/name_test.ts b/packages/vocab/name_test.ts new file mode 100644 index 0000000..f7a8870 --- /dev/null +++ b/packages/vocab/name_test.ts @@ -0,0 +1,43 @@ +import { describe, it } from 'node:test' +import { expect } from '@std/expect' +import type { ClassType, VocabularyModelType } from './model.ts' +import { plan } from './name.ts' + +/** Builds the minimum compiler model needed to test symbol planning. */ +function model(classes: readonly ClassType[]): VocabularyModelType { + return { sources: [], classes, properties: [], datatypes: [], assertions: [], diagnostics: [] } +} + +/** Builds one class with a caller-selected source symbol candidate. */ +function cls(iri: string, name: string): ClassType { + return { + iri, + names: [name], + labels: [], + comments: [], + superClasses: [], + equivalentClasses: [], + disjointClasses: [], + deprecated: false, + } +} + +describe('@okikio/vocab symbol planning', () => { + it('resolves collisions independently of ontology input order', () => { + const alpha = cls('https://a.example/Thing', 'Thing') + const beta = cls('https://b.example/Thing', 'Thing') + const forward = plan(model([alpha, beta]), { prefix: 'ex' }) + const reverse = plan(model([beta, alpha]), { prefix: 'ex' }) + expect([...forward.classes]).toEqual([...reverse.classes]) + expect(forward.symbols).toEqual(reverse.symbols) + }) + + it('qualifies reserved and invalid TypeScript identifiers', () => { + const result = plan(model([ + cls('urn:class', 'class'), + cls('urn:bad', 'not-valid!'), + ]), { prefix: 'demo' }) + expect(result.classes.get('urn:class') === 'class').toBe(false) + expect(result.classes.get('urn:bad')?.startsWith('Demo')).toBe(true) + }) +}) diff --git a/packages/vocab/package.json b/packages/vocab/package.json new file mode 100644 index 0000000..8467b8a --- /dev/null +++ b/packages/vocab/package.json @@ -0,0 +1,26 @@ +{ + "name": "@okikio/vocab", + "version": "0.1.0", + "type": "module", + "sideEffects": false, + "exports": { + ".": "./mod.ts", + "./runtime": "./runtime.ts", + "./schema": "./schema/mod.ts", + "./compile": "./compile.ts", + "./standard": "./standard.ts" + }, + "description": "RDF ontology compiler and generated TypeScript vocabulary runtime.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/okikio/sparql-client.git", + "directory": "packages/vocab" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@okikio/rdf": "0.1.0" + } +} diff --git a/packages/vocab/read.ts b/packages/vocab/read.ts new file mode 100644 index 0000000..91bfb62 --- /dev/null +++ b/packages/vocab/read.ts @@ -0,0 +1,88 @@ +/** + * RDF ontology model to vocabulary-generator IR adapter. + * + * Generic RDFS/OWL interpretation belongs to `@okikio/rdf/ontology`. This + * module adds vocabulary-generator policy: source symbol candidates and common + * Schema.org domain/range aliases. + * + * @module + */ + +import { + read as readOntology, + type OntologySourceType, + type ReadOptions as OntologyReadOptions, +} from '@okikio/rdf/ontology' +import type { ClassType, PropertyType, VocabularyModelType } from './model.ts' + +/** Schema.org HTTPS extension predicate used as an additional vocabulary-domain declaration. */ +const SCHEMA_DOMAIN = 'https://schema.org/domainIncludes' +/** Schema.org HTTPS extension predicate used as an additional vocabulary-range declaration. */ +const SCHEMA_RANGE = 'https://schema.org/rangeIncludes' +/** Legacy Schema.org HTTP domain predicate retained for older vocabulary releases. */ +const SCHEMA_HTTP_DOMAIN = 'http://schema.org/domainIncludes' +/** Legacy Schema.org HTTP range predicate retained for older vocabulary releases. */ +const SCHEMA_HTTP_RANGE = 'http://schema.org/rangeIncludes' + +export type { OntologySourceType } + +/** Additional vocabulary conventions accepted by the generator reader. */ +export interface ReadOptions extends Omit { + readonly domainPredicates?: readonly string[] + readonly rangePredicates?: readonly string[] +} + +/** + * Reads ontology sources into the deterministic vocabulary compiler model. + * + * Schema.org domain/range aliases are enabled because generated Schema.org is a + * first-class consumer. Callers can add equivalent vocabulary-specific aliases + * without teaching the generic RDF ontology package about those vocabularies. + */ +export async function read( + sources: readonly OntologySourceType[], + options: ReadOptions = {}, +): Promise { + const model = await readOntology(sources, { + domainPredicates: [SCHEMA_DOMAIN, SCHEMA_HTTP_DOMAIN, ...(options.domainPredicates ?? [])], + rangePredicates: [SCHEMA_RANGE, SCHEMA_HTTP_RANGE, ...(options.rangePredicates ?? [])], + ...(options.maxQuads === undefined ? {} : { maxQuads: options.maxQuads }), + ...(options.signal === undefined ? {} : { signal: options.signal }), + }) + + return { + sources: model.sources, + classes: model.classes.map(toClass), + properties: model.properties.map(toProperty), + datatypes: model.datatypes, + assertions: model.assertions, + diagnostics: model.diagnostics.map((value) => value.message), + } +} + +/** Projects a generic ontology class into the vocabulary compiler model. */ +function toClass(value: Parameters[0]): ClassType { + return classValue(value) +} + +/** Adds the deterministic source symbol candidate used by vocabulary naming. */ +function classValue(value: Awaited>['classes'][number]): ClassType { + return { ...value, names: [localName(value.iri)] } +} + +/** Projects a generic ontology property into the vocabulary compiler model and retains functional semantics. */ +function toProperty(value: Awaited>['properties'][number]): PropertyType { + return { + ...value, + names: [localName(value.iri)], + functional: value.characteristics.includes('functional'), + } +} + +/** Derives a stable source-symbol candidate from an ontology IRI without changing the IRI itself. */ +function localName(iri: string): string { + const hash = iri.lastIndexOf('#') + const slash = iri.lastIndexOf('/') + const colon = iri.lastIndexOf(':') + return iri.slice(Math.max(hash, slash, colon) + 1) || 'Term' +} diff --git a/packages/vocab/read_test.ts b/packages/vocab/read_test.ts new file mode 100644 index 0000000..33f4f75 --- /dev/null +++ b/packages/vocab/read_test.ts @@ -0,0 +1,35 @@ +import { describe, it } from 'node:test' +import { expect } from '@std/expect' +import { parse } from '@okikio/rdf/turtle' +import { read } from './read.ts' + +describe('@okikio/vocab ontology adapter', () => { + it('adds Schema.org domain/range aliases without changing the generic ontology reader', async () => { + const source = ` + @prefix rdf: . + @prefix rdfs: . + @prefix schema: . + @prefix ex: . + ex:Product a rdfs:Class . + ex:name a rdf:Property ; schema:domainIncludes ex:Product ; schema:rangeIncludes schema:Text . + ` + const model = await read([{ id: 'schema-style', quads: parse(source) }]) + expect(model.properties[0]?.domains).toEqual(['https://example.com/Product']) + expect(model.properties[0]?.ranges).toEqual(['https://schema.org/Text']) + expect(model.properties[0]?.names).toEqual(['name']) + }) + + it('accepts additional vocabulary-specific relationship aliases', async () => { + const source = ` + @prefix rdf: . + @prefix rdfs: . + @prefix ex: . + ex:Thing a rdfs:Class . + ex:value a rdf:Property ; ex:appliesTo ex:Thing . + ` + const model = await read([{ id: 'custom', quads: parse(source) }], { + domainPredicates: ['https://example.com/appliesTo'], + }) + expect(model.properties[0]?.domains).toEqual(['https://example.com/Thing']) + }) +}) diff --git a/packages/vocab/runtime.ts b/packages/vocab/runtime.ts new file mode 100644 index 0000000..f3c3724 --- /dev/null +++ b/packages/vocab/runtime.ts @@ -0,0 +1,232 @@ +/** + * Dependency-free Standard Schema runtime used by generated vocabularies. + * + * Standard Schema explicitly permits implementations to copy the interfaces, + * which keeps generated vocabulary validation interoperable without imposing a + * schema-library dependency on consumers. + * + * @module + */ + +import type { + JsonSchemaOptions, + JsonSchemaTarget, + StandardJSONSchemaV1Props, + StandardIssue, + StandardResult, + StandardSchemaV1Props, +} from './standard.ts' + +export type { + JsonSchemaOptions, + JsonSchemaTarget, + StandardFailureResult, + StandardInferInput, + StandardInferOutput, + StandardIssue, + StandardJSONSchemaConverter, + StandardJSONSchemaV1, + StandardPathSegment, + StandardResult, + StandardSchemaOptions, + StandardSchemaV1, + StandardSuccessResult, + StandardTypedTypes, + StandardTypedV1, +} from './standard.ts' + +/** Combined validator and JSON Schema converter exposed by generated classes. */ +export interface VocabularySchema { + readonly '~standard': StandardSchemaV1Props & StandardJSONSchemaV1Props +} + +/** JSON-LD reference to another node by IRI. */ +export interface IdReferenceType { + readonly '@id': string + readonly [key: string]: unknown +} + +/** Property values can appear once or as a JSON-LD value array. */ +export type ValueType = T | readonly T[] + +/** Open-world generated JSON-LD node. */ +export type NodeType = Readonly< + Properties & { + readonly '@type': Type + readonly '@id'?: string + readonly '@context'?: unknown + readonly [key: string]: unknown + } +> + +/** Runtime range classes that can be represented safely by the structural validator. */ +export type RangeKind = 'string' | 'number' | 'boolean' | 'node' | 'unknown' + +/** Generated runtime schema configuration. */ +export interface SchemaConfigType { + readonly types: readonly string[] + readonly properties?: Readonly> + /** Parent class schemas whose property ranges also apply to this class. */ + readonly parents?: () => readonly VocabularySchema[] +} + +/** Internal generated schema metadata retained without copying inherited properties. */ +interface SchemaStateType { + readonly properties: Readonly> + readonly parents: () => readonly VocabularySchema[] +} + +/** Generated schema metadata indexed by the public Standard Schema object. */ +const schemaState = new WeakMap() + +/** + * Creates an open-world structural vocabulary schema. + * + * Unknown extension properties are accepted. RDFS/OWL absence is never treated + * as requiredness; required/cardinality rules belong to a shape/profile layer. + */ +export function createSchema(config: SchemaConfigType): VocabularySchema { + const validate = (value: unknown): StandardResult => { + const issues: StandardIssue[] = [] + if (!isRecord(value)) return { issues: [{ message: 'Expected a JSON-LD object.' }] } + + if (!hasType(value['@type'], config.types)) { + issues.push({ message: `Expected @type to include ${config.types.join(', ')}.`, path: ['@type'] }) + } + + visitProperties(schema, (name, range) => { + const property = value[name] + if (property === undefined) return + const kinds = Array.isArray(range) ? range : [range] + const values = Array.isArray(property) ? property : [property] + for (let index = 0; index < values.length; index++) { + if (!matches(values[index], kinds)) { + issues.push({ message: `Property '${name}' does not match its generated vocabulary range.`, path: [name, index] }) + } + } + }) + + return issues.length === 0 ? { value: value as Output } : { issues } + } + + const jsonSchema = (options: JsonSchemaOptions): Record => { + const schemaUri = getSchemaUri(options.target) + const properties: Record = { + '@type': { + anyOf: [ + config.types.length === 1 ? { const: config.types[0] } : { enum: [...config.types] }, + { + type: 'array', + items: { type: 'string' }, + allOf: config.types.map((type) => ({ contains: { const: type } })), + }, + ], + }, + '@id': { type: 'string' }, + } + visitProperties(schema, (name, range) => { + const kinds = Array.isArray(range) ? range : [range] + const item = jsonRange(kinds) + properties[name] = { anyOf: [item, { type: 'array', items: item }] } + }) + return { + ...(schemaUri ? { $schema: schemaUri } : {}), + type: 'object', + required: ['@type'], + properties, + additionalProperties: true, + } + } + + const schema: VocabularySchema = { + '~standard': { + version: 1, + vendor: '@okikio/vocab', + validate, + jsonSchema: { input: jsonSchema, output: jsonSchema }, + }, + } + schemaState.set(schema, { + properties: config.properties ?? {}, + parents: config.parents ?? (() => []), + }) + return schema +} + +/** + * Visits each inherited property once without materializing a merged property map. + * + * Ontologies can contain redundant or cyclic superclass declarations. The schema + * object identity is therefore the cycle key. Child properties win when two + * ancestors declare the same JSON-LD key because the child is visited first. + */ +function visitProperties( + schema: VocabularySchema, + visit: (name: string, range: RangeKind | readonly RangeKind[]) => void, +): void { + const schemas = [schema] + const seenSchemas = new Set() + const seenProperties = new Set() + while (schemas.length > 0) { + const current = schemas.pop()! + if (seenSchemas.has(current)) continue + seenSchemas.add(current) + const state = schemaState.get(current) + if (!state) continue + for (const [name, range] of Object.entries(state.properties)) { + if (seenProperties.has(name)) continue + seenProperties.add(name) + visit(name, range) + } + const parents = state.parents() + for (let index = parents.length - 1; index >= 0; index--) schemas.push(parents[index]!) + } +} + +/** Returns whether a value is a non-array JSON object that can represent a JSON-LD node. */ +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Checks whether a JSON-LD @type value satisfies every generated class type required by the schema. */ +function hasType(value: unknown, required: readonly string[]): boolean { + if (typeof value === 'string') return required.length === 1 && value === required[0] + if (!Array.isArray(value) || !value.every((item) => typeof item === 'string')) return false + return required.every((type) => value.includes(type)) +} + +/** Checks one JSON-LD property value against the generated open-world range kinds. */ +function matches(value: unknown, kinds: readonly RangeKind[]): boolean { + if (kinds.includes('unknown')) return true + return kinds.some((kind) => { + switch (kind) { + case 'string': return typeof value === 'string' + case 'number': return typeof value === 'number' && Number.isFinite(value) + case 'boolean': return typeof value === 'boolean' + case 'node': return typeof value === 'string' || isRecord(value) + case 'unknown': return true + } + }) +} + +/** Converts generated vocabulary range kinds into their JSON Schema representation. */ +function jsonRange(kinds: readonly RangeKind[]): Record { + const schemas = kinds.map((kind): Record => { + switch (kind) { + case 'string': return { type: 'string' } + case 'number': return { type: 'number' } + case 'boolean': return { type: 'boolean' } + case 'node': return { anyOf: [{ type: 'string' }, { type: 'object' }] } + case 'unknown': return {} + } + }) + return schemas.length === 1 ? schemas[0]! : { anyOf: schemas } +} + +/** Resolves a supported Standard JSON Schema target to its canonical meta-schema URI. */ +function getSchemaUri(target: JsonSchemaTarget): string | undefined { + if (target === 'draft-2020-12') return 'https://json-schema.org/draft/2020-12/schema' + if (target === 'draft-07') return 'http://json-schema.org/draft-07/schema#' + if (target === 'openapi-3.0') throw new TypeError('OpenAPI 3.0 conversion is not implemented because it is not JSON Schema-equivalent.') + throw new TypeError(`Unsupported JSON Schema target '${target}'.`) +} diff --git a/packages/vocab/runtime_bench.ts b/packages/vocab/runtime_bench.ts new file mode 100644 index 0000000..b318312 --- /dev/null +++ b/packages/vocab/runtime_bench.ts @@ -0,0 +1,37 @@ +/** Decision benchmark for generated Standard Schema runtime validation. @module */ + +import { bench, do_not_optimize, group, run } from 'mitata' +import { ProductSchema, type ProductType } from './schema/mod.ts' + +const PRODUCTS = 10_000 +const valid: ProductType[] = Array.from({ length: PRODUCTS }, (_, index) => ({ + '@type': 'Product', + name: `Product ${index}`, + sku: `SKU-${index}`, +})) +const invalid = valid.map((value, index) => ({ ...value, name: index })) + +const validate = (values: readonly unknown[]): number => { + let issues = 0 + for (const value of values) { + const result = ProductSchema['~standard'].validate(value) + if (result instanceof Promise) throw new Error('Generated bootstrap schema unexpectedly became async.') + if ('issues' in result) issues += result.issues?.length ?? 0 + } + return issues +} + +if (validate(valid) !== 0) throw new Error('Vocabulary runtime benchmark valid-data oracle failed.') +if (validate(invalid) !== PRODUCTS) throw new Error('Vocabulary runtime benchmark invalid-data oracle failed.') + +group('vocab generated Standard Schema: 10k Product objects', () => { + bench('valid objects', () => { + do_not_optimize(validate(valid)) + }) + + bench('invalid inherited property', () => { + do_not_optimize(validate(invalid)) + }) +}) + +await run() diff --git a/packages/vocab/runtime_test.ts b/packages/vocab/runtime_test.ts new file mode 100644 index 0000000..4ed1e20 --- /dev/null +++ b/packages/vocab/runtime_test.ts @@ -0,0 +1,48 @@ +import { describe, it } from 'node:test' +import { expect } from '@std/expect' +import { createSchema } from './runtime.ts' + +describe('@okikio/vocab runtime', () => { + it('validates every required multi-type name and accepts extension fields', async () => { + const schema = createSchema({ types: ['Product', 'SoftwareApplication'] }) + expect(await schema['~standard'].validate({ '@type': ['Product', 'SoftwareApplication'], extension: true })).toEqual({ + value: { '@type': ['Product', 'SoftwareApplication'], extension: true }, + }) + const invalid = await schema['~standard'].validate({ '@type': ['Product'] }) + expect('issues' in invalid).toBe(true) + }) + + it('validates arrays and node references against generated range kinds', async () => { + const schema = createSchema({ + types: ['Product'], + properties: { + price: 'number', + brand: 'node', + code: ['string', 'number'], + }, + }) + expect(await schema['~standard'].validate({ + '@type': 'Product', + price: [10, 20], + brand: { '@id': 'urn:brand:1' }, + code: ['A', 2], + })).toEqual({ + value: { + '@type': 'Product', + price: [10, 20], + brand: { '@id': 'urn:brand:1' }, + code: ['A', 2], + }, + }) + }) + + it('handles cyclic parent schemas without duplicate traversal or recursion', async () => { + let left = createSchema({ types: ['Left'] }) + let right = createSchema({ types: ['Right'] }) + left = createSchema({ types: ['Left'], properties: { left: 'string' }, parents: () => [right] }) + right = createSchema({ types: ['Right'], properties: { right: 'number' }, parents: () => [left] }) + + const invalid = await left['~standard'].validate({ '@type': 'Left', left: 'ok', right: 'bad' }) + expect('issues' in invalid).toBe(true) + }) +}) diff --git a/packages/vocab/schema/manifest.json b/packages/vocab/schema/manifest.json new file mode 100644 index 0000000..a66ce09 --- /dev/null +++ b/packages/vocab/schema/manifest.json @@ -0,0 +1,87 @@ +{ + "version": 1, + "generator": "@okikio/vocab/0.1.0", + "vocabulary": "Schema.org bootstrap", + "sources": [ + { + "id": "schema.org-bootstrap", + "iri": "https://schema.org/", + "version": "bootstrap" + } + ], + "symbols": [ + { + "iri": "https://schema.org/Boolean", + "kind": "datatype", + "name": "Boolean" + }, + { + "iri": "https://schema.org/description", + "kind": "property", + "name": "description" + }, + { + "iri": "https://schema.org/Intangible", + "kind": "class", + "name": "Intangible" + }, + { + "iri": "https://schema.org/name", + "kind": "property", + "name": "name" + }, + { + "iri": "https://schema.org/Number", + "kind": "datatype", + "name": "Number" + }, + { + "iri": "https://schema.org/Offer", + "kind": "class", + "name": "Offer" + }, + { + "iri": "https://schema.org/offers", + "kind": "property", + "name": "offers" + }, + { + "iri": "https://schema.org/price", + "kind": "property", + "name": "price" + }, + { + "iri": "https://schema.org/priceCurrency", + "kind": "property", + "name": "priceCurrency" + }, + { + "iri": "https://schema.org/Product", + "kind": "class", + "name": "Product" + }, + { + "iri": "https://schema.org/sku", + "kind": "property", + "name": "sku" + }, + { + "iri": "https://schema.org/Text", + "kind": "datatype", + "name": "Text" + }, + { + "iri": "https://schema.org/Thing", + "kind": "class", + "name": "Thing" + }, + { + "iri": "https://schema.org/URL", + "kind": "datatype", + "name": "URL" + } + ], + "diagnostics": [ + "This checked-in module is a bootstrap slice for generator/runtime validation, not the complete Schema.org release." + ] +} diff --git a/packages/vocab/schema/mod.ts b/packages/vocab/schema/mod.ts new file mode 100644 index 0000000..9f8a293 --- /dev/null +++ b/packages/vocab/schema/mod.ts @@ -0,0 +1,161 @@ +/** + * Generated Schema.org bootstrap vocabulary terms, types, and schemas. + * + * This file is generated. Edit the ontology source or generator instead. + * @module + */ + +import { namedNode } from '@okikio/rdf' +import { createSchema, type IdReferenceType, type NodeType, type ValueType } from '../runtime.ts' + +/** Base IRI used by every generated vocabulary term in this module. */ +export const namespace = 'https://schema.org/' + +/** RDF class terms. */ +/** + * A compact bootstrap class used to validate the generator and direct-import API. + */ +export const Offer = namedNode('https://schema.org/Offer') +/** + * A compact bootstrap class used to validate the generator and direct-import API. + */ +export const Product = namedNode('https://schema.org/Product') +/** + * The most generic type of item in this bootstrap vocabulary slice. + */ +export const Thing = namedNode('https://schema.org/Thing') +/** + * RDF class term for Intangible. + */ +export const Intangible = namedNode('https://schema.org/Intangible') + +/** RDF datatype terms. */ +/** RDF datatype term for Boolean. */ +export const Boolean = namedNode('https://schema.org/Boolean') +/** RDF datatype term for Number. */ +export const Number = namedNode('https://schema.org/Number') +/** RDF datatype term for Text. */ +export const Text = namedNode('https://schema.org/Text') +/** RDF datatype term for URL. */ +export const URL = namedNode('https://schema.org/URL') + +/** RDF property terms. */ +/** + * RDF property term for description. + */ +export const description = namedNode('https://schema.org/description') +/** + * RDF property term for name. + */ +export const name = namedNode('https://schema.org/name') +/** + * RDF property term for offers. + */ +export const offers = namedNode('https://schema.org/offers') +/** + * RDF property term for price. + */ +export const price = namedNode('https://schema.org/price') +/** + * RDF property term for priceCurrency. + */ +export const priceCurrency = namedNode('https://schema.org/priceCurrency') +/** + * RDF property term for sku. + */ +export const sku = namedNode('https://schema.org/sku') + +/** JSON-LD properties directly available to Offer, including inherited interfaces. */ +export interface OfferPropertiesType extends IntangiblePropertiesType { + readonly price?: ValueType + readonly priceCurrency?: ValueType +} + +/** JSON-LD properties directly available to Product, including inherited interfaces. */ +export interface ProductPropertiesType extends ThingPropertiesType { + readonly offers?: ValueType + readonly sku?: ValueType +} + +/** JSON-LD properties directly available to Thing, including inherited interfaces. */ +export interface ThingPropertiesType { + readonly description?: ValueType + readonly name?: ValueType +} + +/** JSON-LD properties directly available to Intangible, including inherited interfaces. */ +export interface IntangiblePropertiesType extends ThingPropertiesType { +} + +/** JSON-LD node typed as Offer. */ +export type OfferType = NodeType<'Offer', OfferPropertiesType> +/** Standard Schema validator and JSON Schema converter for Offer. */ +export const OfferSchema = createSchema({ + types: ['Offer'], + parents: () => [IntangibleSchema], + properties: { + price: ['number', 'string'], + priceCurrency: 'string', + }, +}) + +/** JSON-LD node typed as Product. */ +export type ProductType = NodeType<'Product', ProductPropertiesType> +/** Standard Schema validator and JSON Schema converter for Product. */ +export const ProductSchema = createSchema({ + types: ['Product'], + parents: () => [ThingSchema], + properties: { + offers: 'node', + sku: 'string', + }, +}) + +/** JSON-LD node typed as Thing. */ +export type ThingType = NodeType<'Thing', ThingPropertiesType> +/** Standard Schema validator and JSON Schema converter for Thing. */ +export const ThingSchema = createSchema({ + types: ['Thing'], + properties: { + description: 'string', + name: 'string', + }, +}) + +/** JSON-LD node typed as Intangible. */ +export type IntangibleType = NodeType<'Intangible', IntangiblePropertiesType> +/** Standard Schema validator and JSON Schema converter for Intangible. */ +export const IntangibleSchema = createSchema({ + types: ['Intangible'], + parents: () => [ThingSchema], +}) + +/** Generated datatype value aliases. */ +/** JavaScript value type for the Boolean RDF datatype. */ +export type BooleanType = boolean +/** JavaScript value type for the Number RDF datatype. */ +export type NumberType = number +/** JavaScript value type for the Text RDF datatype. */ +export type TextType = string +/** JavaScript value type for the URL RDF datatype. */ +export type URLType = string + +/** Generated class-name to property-interface map used by multi-typed JSON-LD nodes. */ +export interface TypeMapType { + readonly Offer: OfferPropertiesType + readonly Product: ProductPropertiesType + readonly Thing: ThingPropertiesType + readonly Intangible: IntangiblePropertiesType +} + +/** Every generated vocabulary class name accepted by multi-type nodes. */ +export type ClassNameType = keyof TypeMapType +/** Resolves one generated class name to its property interface. */ +type PropertiesForType = Type extends keyof TypeMapType ? TypeMapType[Type] : never +/** Converts the selected class-property union into one intersection for multi-typed nodes. */ +type UnionToIntersection = (Value extends unknown ? (value: Value) => void : never) extends (value: infer Intersection) => void ? Intersection : never + +/** Intersects the properties contributed by every class on a multi-typed JSON-LD node. */ +type MergedPropertiesType = UnionToIntersection> & object +/** JSON-LD node carrying all properties contributed by the selected generated class names. */ +export type MultiTypeType = NodeType> diff --git a/packages/vocab/standard.ts b/packages/vocab/standard.ts new file mode 100644 index 0000000..d1fa56a --- /dev/null +++ b/packages/vocab/standard.ts @@ -0,0 +1,111 @@ +/** + * Dependency-free Standard Schema contracts used by generated vocabularies. + * + * The Standard Schema project explicitly permits implementers to copy these + * structural interfaces. Keeping the contract here avoids adding a runtime + * dependency only to satisfy TypeScript shape compatibility. + * + * This module tracks the v1 Standard Typed, Standard Schema, and Standard JSON + * Schema contracts. Generated vocabulary schemas implement validation and JSON + * Schema conversion on the same `~standard` object. + * + * @module + */ + +/** Base Standard Typed v1 contract shared by the other Standard Schema traits. */ +export interface StandardTypedV1 { + readonly '~standard': StandardTypedV1Props +} + +/** Inferred input/output pair carried by Standard Typed metadata. */ +export interface StandardTypedTypes { + readonly input: Input + readonly output: Output +} + +/** Base metadata and optional inference types carried by a Standard v1 object. */ +export interface StandardTypedV1Props { + readonly version: 1 + readonly vendor: string + readonly types?: StandardTypedTypes | undefined +} + +/** Infers the declared input type from any Standard Typed-compatible object. */ +export type StandardInferInput = NonNullable['input'] + +/** Infers the declared output type from any Standard Typed-compatible object. */ +export type StandardInferOutput = NonNullable['output'] + +/** Standard Schema v1 validation contract. */ +export interface StandardSchemaV1 { + readonly '~standard': StandardSchemaV1Props +} + +/** One structured segment in a Standard Schema issue path. */ +export interface StandardPathSegment { + readonly key: PropertyKey +} + +/** One Standard Schema validation issue. */ +export interface StandardIssue { + readonly message: string + readonly path?: ReadonlyArray | undefined +} + +/** Successful Standard Schema validation result. */ +export interface StandardSuccessResult { + readonly value: Output + readonly issues?: undefined +} + +/** Failed Standard Schema validation result. */ +export interface StandardFailureResult { + readonly issues: ReadonlyArray +} + +/** Success or failure returned by a Standard Schema validator. */ +export type StandardResult = StandardSuccessResult | StandardFailureResult + +/** Optional vendor-specific Standard Schema validation options. */ +export interface StandardSchemaOptions { + readonly libraryOptions?: Record | undefined +} + +/** Standard Schema v1 validation properties. */ +export interface StandardSchemaV1Props + extends StandardTypedV1Props { + readonly validate: ( + value: unknown, + options?: StandardSchemaOptions | undefined, + ) => StandardResult | Promise> +} + +/** Standard JSON Schema v1 conversion contract. */ +export interface StandardJSONSchemaV1 { + readonly '~standard': StandardJSONSchemaV1Props +} + +/** JSON Schema dialect or integration target requested by a consumer. */ +export type JsonSchemaTarget = + | 'draft-2020-12' + | 'draft-07' + | 'openapi-3.0' + | ({} & string) + +/** Options passed by a Standard JSON Schema consumer. */ +export interface JsonSchemaOptions { + readonly target: JsonSchemaTarget + readonly libraryOptions?: Record | undefined +} + +/** Input/output converter carried by Standard JSON Schema metadata. */ +export interface StandardJSONSchemaConverter { + readonly input: (options: JsonSchemaOptions) => Record + readonly output: (options: JsonSchemaOptions) => Record +} + +/** Standard JSON Schema v1 conversion properties. */ +export interface StandardJSONSchemaV1Props + extends StandardTypedV1Props { + readonly jsonSchema: StandardJSONSchemaConverter +} diff --git a/packages/vocab/standard_test.ts b/packages/vocab/standard_test.ts new file mode 100644 index 0000000..5858cd6 --- /dev/null +++ b/packages/vocab/standard_test.ts @@ -0,0 +1,55 @@ +import { describe, it } from 'node:test' +import { expect } from '@std/expect' +import type { StandardJSONSchemaV1 as OfficialJSONSchemaV1, StandardSchemaV1 as OfficialSchemaV1 } from '@standard-schema/spec' +import { ProductSchema, type ProductType } from './schema/mod.ts' +import type { StandardInferInput, StandardInferOutput, StandardJSONSchemaV1, StandardSchemaV1 } from './standard.ts' + +/** Compile-time assertion that generated schemas satisfy both local and official contracts. */ + +/** Compile-time proof that local Standard Typed inference preserves the generated schema output type. */ +function acceptInference(_input: StandardInferInput, output: StandardInferOutput): ProductType { + return output +} + +function acceptSchema( + schema: StandardSchemaV1 & + StandardJSONSchemaV1 & + OfficialSchemaV1 & + OfficialJSONSchemaV1, +): void { + void schema +} + +describe('@okikio/vocab Standard Schema', () => { + it('is structurally assignable to the pinned Standard Schema v1 contracts', () => { + acceptSchema(ProductSchema) + const value: ProductType = { '@type': 'Product' } + expect(acceptInference(value, value)).toEqual(value) + }) + + it('validates generated output while preserving issue paths', async () => { + const value: ProductType = { '@type': 'Product', name: 'Widget', custom: true } + expect(await ProductSchema['~standard'].validate(value)).toEqual({ value }) + + const invalid = await ProductSchema['~standard'].validate({ '@type': 'Product', name: 42 }) + expect('issues' in invalid).toBe(true) + if ('issues' in invalid && invalid.issues) expect(invalid.issues[0]?.path).toEqual(['name', 0]) + }) + + it('emits the two recommended JSON Schema drafts and stays open-world', () => { + const current = ProductSchema['~standard'].jsonSchema.output({ target: 'draft-2020-12' }) + const draft7 = ProductSchema['~standard'].jsonSchema.input({ target: 'draft-07' }) + expect(current['$schema']).toBe('https://json-schema.org/draft/2020-12/schema') + expect(draft7['$schema']).toBe('http://json-schema.org/draft-07/schema#') + expect(current['additionalProperties']).toBe(true) + }) + + it('validates inherited properties without duplicating parent descriptors', async () => { + const result = await ProductSchema['~standard'].validate({ '@type': 'Product', name: 42 }) + expect('issues' in result).toBe(true) + }) + + it('rejects conversion targets the runtime cannot represent soundly', () => { + expect(() => ProductSchema['~standard'].jsonSchema.output({ target: 'openapi-3.0' })).toThrow() + }) +})