diff --git a/packages/oxa-core/.gitignore b/packages/oxa-core/.gitignore index 7271f1a..e364d71 100644 --- a/packages/oxa-core/.gitignore +++ b/packages/oxa-core/.gitignore @@ -1 +1,2 @@ -src/version.ts +dist/ +src/schema.json diff --git a/packages/oxa-core/package.json b/packages/oxa-core/package.json index c8c9378..13124b9 100644 --- a/packages/oxa-core/package.json +++ b/packages/oxa-core/package.json @@ -9,16 +9,13 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" - }, - "./schema.json": "./dist/schema.json" + } }, "files": [ "dist" ], "scripts": { - "copy:version": "echo \"const version = '\"$npm_package_version\"';\nexport default version;\" > src/version.ts", - "build": "pnpm run copy:version && tsc && cp ../../schema/schema.json dist/ && pnpm run build:bundle", - "build:bundle": "esbuild src/cli.ts --bundle --platform=node --target=node22 --format=cjs --outfile=dist/cli.bundle.cjs --banner:js='#!/usr/bin/env node' --log-override:empty-import-meta=silent", + "build": "cp ../../schema/schema.json src/schema.json && tsc", "test": "vitest run", "typecheck": "tsc --noEmit", "clean": "rm -rf dist", @@ -31,16 +28,12 @@ "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "better-ajv-errors": "^2.0.2", - "commander": "^14.0.0", "js-yaml": "^4.1.0", "oxa-types": "workspace:*" }, "devDependencies": { "@oxa/conformance": "workspace:*", - "@types/js-yaml": "^4.0.9", - "@types/node": "^22.0.0", - "esbuild": "^0.25.0", - "execa": "^9.5.0" + "@types/js-yaml": "^4.0.9" }, "keywords": [ "oxa", @@ -58,7 +51,6 @@ "url": "git+https://github.com/oxa-dev/oxa.git", "directory": "packages/oxa-core" }, - "engines": { - "node": ">=22" - } + "engines": {} + } diff --git a/packages/oxa-core/src/convert.test.ts b/packages/oxa-core/src/convert.test.ts index a1916d7..131a5cd 100644 --- a/packages/oxa-core/src/convert.test.ts +++ b/packages/oxa-core/src/convert.test.ts @@ -1,11 +1,25 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect } from "vitest"; import { readFileSync } from "fs"; import { dirname, relative, resolve } from "path"; import { fileURLToPath } from "url"; +import type { Session } from "./types.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = resolve(__dirname, "../../.."); +function createTestSession(): Session & { messages: string[] } { + const messages: string[] = []; + return { + messages, + log: { + debug: (...args: unknown[]) => messages.push(String(args.join(" "))), + info: (...args: unknown[]) => messages.push(String(args.join(" "))), + warn: (...args: unknown[]) => messages.push(String(args.join(" "))), + error: (...args: unknown[]) => messages.push(String(args.join(" "))), + }, + }; +} + const lexiconFiles = { facet: { id: "pub.oxa.richtext.facet", @@ -154,7 +168,7 @@ function documentNode( } satisfies TestDocumentNode; } -async function flatten(inlines: unknown[]) { +async function flatten(inlines: unknown[], session?: Session) { const convertModule = await import("./convert.js").catch((error) => { if (error instanceof Error && error.message.includes("/src/convert.js")) { return undefined; @@ -170,10 +184,10 @@ async function flatten(inlines: unknown[]) { "Expected packages/oxa-core/src/convert.ts to export flattenInlines", ).toBeTypeOf("function"); - return flattenInlines!(inlines as never); + return flattenInlines!(session ?? createTestSession(), inlines as never); } -async function map(block: unknown) { +async function map(block: unknown, session?: Session) { const convertModule = await import("./convert.js"); const mapBlock = convertModule.mapBlock; @@ -182,10 +196,14 @@ async function map(block: unknown) { "Expected packages/oxa-core/src/convert.ts to export mapBlock", ).toBeTypeOf("function"); - return mapBlock!(block as never); + return mapBlock!(session ?? createTestSession(), block as never); } -async function convertDocument(document: unknown, options?: unknown) { +async function convertDocument( + document: unknown, + options?: unknown, + session?: Session, +) { const convertModule = await import("./convert.js"); const oxaToAtproto = convertModule.oxaToAtproto; @@ -194,7 +212,11 @@ async function convertDocument(document: unknown, options?: unknown) { "Expected packages/oxa-core/src/convert.ts to export oxaToAtproto", ).toBeTypeOf("function"); - return oxaToAtproto!(document as never, options as never); + return oxaToAtproto!( + session ?? createTestSession(), + document as never, + options as never, + ); } function readLexicon(filePath: string): LexiconDoc { @@ -208,12 +230,6 @@ function readLexicon(filePath: string): LexiconDoc { } } -function stderrOutput(writeSpy: ReturnType) { - return writeSpy.mock.calls - .map(([chunk]: [unknown, ...unknown[]]) => String(chunk)) - .join(""); -} - // eslint-disable-next-line @typescript-eslint/no-explicit-any function getLexiconDefs(filePath: string): Record { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -402,29 +418,25 @@ describe("mapBlock", () => { }); it("warns and omits unknown block types instead of coercing them into known ATProto blocks", async () => { - const writeSpy = vi - .spyOn(process.stderr, "write") - .mockImplementation(() => true); + const session = createTestSession(); - try { - await expect( - map({ - type: "Callout", - children: [text("Should be dropped")], - id: "callout-1", - classes: ["note"], - data: { severity: "warning" }, - }), - ).resolves.toBeUndefined(); + const result = await map( + { + type: "Callout", + children: [text("Should be dropped")], + id: "callout-1", + classes: ["note"], + data: { severity: "warning" }, + }, + session, + ); - expect(writeSpy).toHaveBeenCalled(); + expect(result).toBeUndefined(); + expect(session.messages.length).toBeGreaterThan(0); - const warning = stderrOutput(writeSpy); - expect(warning).toContain("unknown block type"); - expect(warning).toContain("Callout"); - } finally { - writeSpy.mockRestore(); - } + const warning = session.messages.join("\n"); + expect(warning).toContain("unknown block type"); + expect(warning).toContain("Callout"); }); }); @@ -507,50 +519,45 @@ describe("oxaToAtproto", () => { tags: ["oxa", "atproto"], }, }; - const writeSpy = vi - .spyOn(process.stderr, "write") - .mockImplementation(() => true); - - try { - const converted = await convertDocument( - { - type: "Document", - metadata, - children: [ - paragraph([text("Keep this paragraph")]), - { - type: "Callout", - children: [text("Drop this block")], - data: { severity: "warning" }, - }, - ], - }, - { createdAt }, - ); + const session = createTestSession(); - expect(converted).toEqual({ - $type: "pub.oxa.document.document", + const converted = await convertDocument( + { + type: "Document", metadata, children: [ + paragraph([text("Keep this paragraph")]), { - $type: "pub.oxa.document.defs#paragraph", - text: "Keep this paragraph", - facets: [], + type: "Callout", + children: [text("Drop this block")], + data: { severity: "warning" }, }, ], - createdAt, - }); - expect(converted.metadata).toBe(metadata); - expect("title" in converted).toBe(false); + }, + { createdAt }, + session, + ); + + expect(converted).toEqual({ + $type: "pub.oxa.document.document", + metadata, + children: [ + { + $type: "pub.oxa.document.defs#paragraph", + text: "Keep this paragraph", + facets: [], + }, + ], + createdAt, + }); + expect(converted.metadata).toBe(metadata); + expect("title" in converted).toBe(false); - expect(writeSpy).toHaveBeenCalled(); + expect(session.messages.length).toBeGreaterThan(0); - const warning = stderrOutput(writeSpy); - expect(warning).toContain("unknown block type"); - expect(warning).toContain("Callout"); - } finally { - writeSpy.mockRestore(); - } + const warning = session.messages.join("\n"); + expect(warning).toContain("unknown block type"); + expect(warning).toContain("Callout"); }); }); @@ -769,39 +776,36 @@ describe("flattenInlines", () => { ); }); - it("warns to stderr and drops inline id, classes, and data properties on formatting nodes", async () => { - const writeSpy = vi - .spyOn(process.stderr, "write") - .mockImplementation(() => true); + it("warns and drops inline id, classes, and data properties on formatting nodes", async () => { + const session = createTestSession(); - try { - const richText = await flatten([ + const richText = await flatten( + [ strong([text("styled")], { id: "inline-id", classes: ["callout", "accent"], data: { note: "keep warning only" }, }), - ]); + ], + session, + ); - expect(richText).toEqual({ - text: "styled", - facets: [ - { - index: { byteStart: 0, byteEnd: 6 }, - features: [{ $type: "pub.oxa.richtext.facet#strong" }], - }, - ], - }); + expect(richText).toEqual({ + text: "styled", + facets: [ + { + index: { byteStart: 0, byteEnd: 6 }, + features: [{ $type: "pub.oxa.richtext.facet#strong" }], + }, + ], + }); - expect(writeSpy).toHaveBeenCalled(); + expect(session.messages.length).toBeGreaterThan(0); - const warning = stderrOutput(writeSpy); - expect(warning).toContain("Strong"); - expect(warning).toContain("id"); - expect(warning).toContain("classes"); - expect(warning).toContain("data"); - } finally { - writeSpy.mockRestore(); - } + const warning = session.messages.join("\n"); + expect(warning).toContain("Strong"); + expect(warning).toContain("id"); + expect(warning).toContain("classes"); + expect(warning).toContain("data"); }); }); diff --git a/packages/oxa-core/src/convert.ts b/packages/oxa-core/src/convert.ts index 5f6eec8..e303807 100644 --- a/packages/oxa-core/src/convert.ts +++ b/packages/oxa-core/src/convert.ts @@ -1,7 +1,11 @@ /** * Minimal inline conversion helpers for OXA rich text. + * + * All functions take a `Session` object as their first argument. */ +import type { Session } from "./types.js"; + type TextNode = { type: "Text"; value: string; @@ -212,10 +216,6 @@ function createFacet( }; } -function warn(message: string): void { - process.stderr.write(`Warning: ${message}\n`); -} - function getDroppedFormattingProperties( node: FormattingNode, ): FormattingPropertyName[] { @@ -241,26 +241,30 @@ function copyDefinedProperties( return props; } -function warnDroppedProperties(node: FormattingNode): void { +function warnDroppedProperties(session: Session, node: FormattingNode): void { const dropped = getDroppedFormattingProperties(node); if (dropped.length === 0) { return; } - warn( + session.log.warn( `dropping unsupported inline properties on ${node.type}: ${dropped.join(", ")}`, ); } -function flattenNode(node: InlineNode, richText: RichText): void { +function flattenNode( + session: Session, + node: InlineNode, + richText: RichText, +): void { if (node.type === "Text") { richText.text += node.value; return; } if (node.type === "InlineCode") { - warnDroppedProperties(node as unknown as FormattingNode); + warnDroppedProperties(session, node as unknown as FormattingNode); const byteStart = getCurrentByteOffset(richText); richText.text += node.value; const byteEnd = getCurrentByteOffset(richText); @@ -268,12 +272,12 @@ function flattenNode(node: InlineNode, richText: RichText): void { return; } - warnDroppedProperties(node); + warnDroppedProperties(session, node); const byteStart = getCurrentByteOffset(richText); for (const child of node.children) { - flattenNode(child, richText); + flattenNode(session, child, richText); } const byteEnd = getCurrentByteOffset(richText); @@ -281,14 +285,17 @@ function flattenNode(node: InlineNode, richText: RichText): void { richText.facets.push(createFacet(node, byteStart, byteEnd)); } -export function flattenInlines(inlines: InlineNode[]): RichText { +export function flattenInlines( + session: Session, + inlines: InlineNode[], +): RichText { const richText: RichText = { text: "", facets: [], }; for (const inline of inlines) { - flattenNode(inline, richText); + flattenNode(session, inline, richText); } return richText; @@ -303,19 +310,23 @@ function copyBlockProps(block: BlockNodeBase): BlockNodeBase { type RichTextBlockNode = ParagraphNode | HeadingNode; -function mapBlockRichText(block: RichTextBlockNode): BlockNodeBase & RichText { +function mapBlockRichText( + session: Session, + block: RichTextBlockNode, +): BlockNodeBase & RichText { return { ...copyBlockProps(block), - ...flattenInlines(block.children), + ...flattenInlines(session, block.children), }; } function getOptionalDocumentFields( + session: Session, document: DocumentNode, ): Partial { return { ...(document.title !== undefined - ? { title: flattenInlines(document.title) } + ? { title: flattenInlines(session, document.title) } : {}), ...(document.metadata !== undefined ? { metadata: document.metadata } : {}), }; @@ -330,8 +341,8 @@ function isKnownBlock(block: BlockNode): block is KnownBlockNode { ); } -function warnUnknownBlockType(block: BlockNode): void { - warn(`unknown block type: ${block.type}`); +function warnUnknownBlockType(session: Session, block: BlockNode): void { + session.log.warn(`unknown block type: ${block.type}`); } function mapCodeBlock(block: CodeNode): AtprotoCode { @@ -353,7 +364,7 @@ function mapThematicBreak(block: ThematicBreakNode): AtprotoThematicBreak { }; } -function mapKnownBlock(block: KnownBlockNode): AtprotoBlock { +function mapKnownBlock(session: Session, block: KnownBlockNode): AtprotoBlock { if (block.type === "Code") { return mapCodeBlock(block); } @@ -362,7 +373,7 @@ function mapKnownBlock(block: KnownBlockNode): AtprotoBlock { return mapThematicBreak(block); } - const richTextBlock = mapBlockRichText(block); + const richTextBlock = mapBlockRichText(session, block); if (block.type === "Paragraph") { return { @@ -378,30 +389,34 @@ function mapKnownBlock(block: KnownBlockNode): AtprotoBlock { }; } -function mapKnownBlocks(blocks: BlockNode[]): AtprotoBlock[] { +function mapKnownBlocks(session: Session, blocks: BlockNode[]): AtprotoBlock[] { return blocks.flatMap((block) => { - const mapped = mapBlock(block); + const mapped = mapBlock(session, block); return mapped === undefined ? [] : [mapped]; }); } -export function mapBlock(block: BlockNode): AtprotoBlock | undefined { +export function mapBlock( + session: Session, + block: BlockNode, +): AtprotoBlock | undefined { if (!isKnownBlock(block)) { - warnUnknownBlockType(block); + warnUnknownBlockType(session, block); return undefined; } - return mapKnownBlock(block); + return mapKnownBlock(session, block); } export function oxaToAtproto( + session: Session, document: DocumentNode, options: OxaToAtprotoOptions = {}, ): AtprotoDocument { return { $type: "pub.oxa.document.document", - ...getOptionalDocumentFields(document), - children: mapKnownBlocks(document.children), + ...getOptionalDocumentFields(session, document), + children: mapKnownBlocks(session, document.children), createdAt: options.createdAt ?? new Date().toISOString(), }; } diff --git a/packages/oxa-core/src/index.test.ts b/packages/oxa-core/src/index.test.ts deleted file mode 100644 index 9d72f27..0000000 --- a/packages/oxa-core/src/index.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, it, expect } from "vitest"; - -describe("package exports", () => { - it("re-exports the ATProto conversion API from the package entrypoint", async () => { - const pkg = await import("./index.js"); - - expect(pkg.compatibleFeatures).toBeTypeOf("object"); - expect(pkg.flattenInlines).toBeTypeOf("function"); - expect(pkg.mapBlock).toBeTypeOf("function"); - expect(pkg.oxaToAtproto).toBeTypeOf("function"); - }); -}); diff --git a/packages/oxa-core/src/index.ts b/packages/oxa-core/src/index.ts index dec5458..8786575 100644 --- a/packages/oxa-core/src/index.ts +++ b/packages/oxa-core/src/index.ts @@ -20,6 +20,8 @@ export type * from "oxa-types"; // Export validation functions and types +export { type Session, type Logger } from "./types.js"; + export { compatibleFeatures, flattenInlines, @@ -31,7 +33,6 @@ export { validate, validateJson, validateYaml, - validateFile, getSchema, getTypeNames, type ValidationResult, diff --git a/packages/oxa-core/src/types.ts b/packages/oxa-core/src/types.ts new file mode 100644 index 0000000..79bd5c9 --- /dev/null +++ b/packages/oxa-core/src/types.ts @@ -0,0 +1,5 @@ +export type Logger = Pick; + +export interface Session { + log: Logger; +} diff --git a/packages/oxa-core/src/validate.test.ts b/packages/oxa-core/src/validate.test.ts index 8813d91..9e7260b 100644 --- a/packages/oxa-core/src/validate.test.ts +++ b/packages/oxa-core/src/validate.test.ts @@ -1,12 +1,8 @@ -import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { writeFileSync, unlinkSync, mkdtempSync } from "fs"; -import { join } from "path"; -import { tmpdir } from "os"; +import { describe, it, expect } from "vitest"; import { validate, validateJson, validateYaml, - validateFile, getSchema, getTypeNames, } from "./validate.js"; @@ -131,56 +127,6 @@ children: [] }); }); -describe("validateFile", () => { - let tempDir: string; - let jsonFile: string; - let yamlFile: string; - let invalidFile: string; - - beforeAll(() => { - tempDir = mkdtempSync(join(tmpdir(), "oxa-test-")); - jsonFile = join(tempDir, "valid.json"); - yamlFile = join(tempDir, "valid.yaml"); - invalidFile = join(tempDir, "invalid.json"); - - writeFileSync(jsonFile, JSON.stringify(validDocument)); - writeFileSync( - yamlFile, - `type: Document -children: [] -`, - ); - writeFileSync(invalidFile, '{"type": "Document"}'); - }); - - afterAll(() => { - unlinkSync(jsonFile); - unlinkSync(yamlFile); - unlinkSync(invalidFile); - }); - - it("validates JSON file", () => { - const result = validateFile(jsonFile); - expect(result.valid).toBe(true); - }); - - it("validates YAML file", () => { - const result = validateFile(yamlFile); - expect(result.valid).toBe(true); - }); - - it("returns errors for invalid file content", () => { - const result = validateFile(invalidFile); - expect(result.valid).toBe(false); - }); - - it("returns error for non-existent file", () => { - const result = validateFile("/nonexistent/file.json"); - expect(result.valid).toBe(false); - expect(result.errors[0].message).toContain("Failed to read file"); - }); -}); - describe("getSchema", () => { it("returns the OXA schema", () => { const schema = getSchema(); diff --git a/packages/oxa-core/src/validate.ts b/packages/oxa-core/src/validate.ts index 285bb90..c8e8615 100644 --- a/packages/oxa-core/src/validate.ts +++ b/packages/oxa-core/src/validate.ts @@ -1,5 +1,8 @@ /** * Validation functions for OXA documents. + * + * This module is browser-safe — it does not import Node.js modules. + * The schema is bundled directly via JSON import. */ import AjvDefault from "ajv"; @@ -7,10 +10,8 @@ import addFormatsDefault from "ajv-formats"; import type { ValidateFunction, ErrorObject } from "ajv"; // @ts-expect-error - better-ajv-errors has broken TypeScript exports import betterAjvErrors from "better-ajv-errors"; -import { existsSync, readFileSync } from "fs"; -import { dirname, join } from "path"; -import { fileURLToPath } from "url"; import yaml from "js-yaml"; +import schemaData from "./schema.json" with { type: "json" }; // Handle CJS/ESM interop - these packages don't have proper ESM types // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -18,50 +19,7 @@ const Ajv = AjvDefault as any; // eslint-disable-next-line @typescript-eslint/no-explicit-any const addFormats = addFormatsDefault as any; -// Load schema from bundled location (dist/) or source location (schema/) -// Handle both ESM (import.meta.url) and CJS (__dirname global) for bundled CLI -const currentDir = - typeof import.meta !== "undefined" && import.meta.url - ? dirname(fileURLToPath(import.meta.url)) - : typeof __dirname !== "undefined" - ? __dirname - : process.cwd(); - -function findSchemaPath(): string { - // When running from dist/, schema is bundled alongside - const bundledPath = join(currentDir, "schema.json"); - if (existsSync(bundledPath)) { - return bundledPath; - } - // When running from src/ (e.g., during tests), use source schema - const sourcePath = join( - currentDir, - "..", - "..", - "..", - "schema", - "schema.json", - ); - if (existsSync(sourcePath)) { - return sourcePath; - } - throw new Error( - `Schema not found at ${bundledPath} or ${sourcePath}. Run 'pnpm build' first.`, - ); -} - -let schema: Record | null = null; - -function loadSchema(): Record { - if (!schema) { - const schemaPath = findSchemaPath(); - schema = JSON.parse(readFileSync(schemaPath, "utf-8")) as Record< - string, - unknown - >; - } - return schema!; -} +const schema: Record = schemaData as Record; /** * Result of validating a document. @@ -112,8 +70,7 @@ const validatorCache = new Map(); * Get available type names from the schema definitions. */ export function getTypeNames(): string[] { - const schemaData = loadSchema(); - const definitions = schemaData.definitions as Record; + const definitions = schema.definitions as Record; return Object.keys(definitions); } @@ -128,11 +85,9 @@ function getValidator(type?: string): ValidateFunction | null { return validatorCache.get(cacheKey)!; } - const schemaData = loadSchema(); - // Validate that the type exists in definitions if (type) { - const definitions = schemaData.definitions as Record; + const definitions = schema.definitions as Record; if (!definitions || !(type in definitions)) { return null; } @@ -214,8 +169,6 @@ export function validate( options: ValidateOptions = {}, ): ValidationResult { const validator = getValidator(options.type); - const schema = loadSchema(); - // Unknown type specified if (!validator) { const availableTypes = getTypeNames().join(", "); @@ -250,12 +203,15 @@ export function validate( * Validate a JSON string against the OXA schema. */ export function validateJson( - json: string, + json: string | Record, options: ValidateOptions = {}, ): ValidationResult { try { - const data = JSON.parse(json); - return validate(data, { ...options, json }); + const data = typeof json === "string" ? JSON.parse(json) : json; + return validate(data, { + ...options, + json: typeof json === "string" ? json : undefined, + }); } catch (error) { return { valid: false, @@ -293,36 +249,8 @@ export function validateYaml( } /** - * Validate a file (JSON or YAML based on extension). - */ -export function validateFile( - filePath: string, - options: ValidateOptions = {}, -): ValidationResult { - try { - const content = readFileSync(filePath, "utf-8"); - - if (filePath.endsWith(".yaml") || filePath.endsWith(".yml")) { - return validateYaml(content, options); - } else { - return validateJson(content, options); - } - } catch (error) { - return { - valid: false, - errors: [ - { - path: "/", - message: `Failed to read file: ${error instanceof Error ? error.message : String(error)}`, - }, - ], - }; - } -} - -/** - * Get the bundled OXA schema. + * Get the loaded OXA schema. */ export function getSchema(): Record { - return structuredClone(loadSchema()); + return structuredClone(schema); } diff --git a/packages/oxa-core/tsconfig.json b/packages/oxa-core/tsconfig.json index b052a1c..40d9377 100644 --- a/packages/oxa-core/tsconfig.json +++ b/packages/oxa-core/tsconfig.json @@ -2,10 +2,13 @@ "extends": "../../tsconfig.json", "compilerOptions": { "declaration": true, + "declarationMap": true, + "sourceMap": true, + "resolveJsonModule": true, "outDir": "./dist", "rootDir": "./src", "noEmit": false }, - "include": ["src/**/*.ts"], + "include": ["src/**/*.ts", "src/schema.json"], "exclude": ["dist", "src/**/*.test.ts"] } diff --git a/packages/oxa/.gitignore b/packages/oxa/.gitignore new file mode 100644 index 0000000..7271f1a --- /dev/null +++ b/packages/oxa/.gitignore @@ -0,0 +1 @@ +src/version.ts diff --git a/packages/oxa/package.json b/packages/oxa/package.json index bde6ba0..081e30d 100644 --- a/packages/oxa/package.json +++ b/packages/oxa/package.json @@ -10,8 +10,16 @@ "dist" ], "scripts": { - "build": "rm -rf dist && mkdir -p dist && cp ../oxa-core/dist/cli.bundle.cjs dist/cli.cjs && cp ../oxa-core/dist/schema.json dist/", - "clean": "rm -rf dist" + "copy:version": "echo \"const version = '\"$npm_package_version\"';\nexport default version;\" > src/version.ts", + "build": "pnpm run copy:version && pnpm run build:bundle", + "build:bundle": "esbuild src/cli.ts --bundle --platform=node --target=node22 --format=cjs --outfile=dist/cli.cjs --banner:js='#!/usr/bin/env node' --log-override:empty-import-meta=silent", + "test": "vitest run", + "typecheck": "tsc --noEmit", + "clean": "rm -rf dist", + "format": "prettier --write \"src/**/*.ts\"", + "format:check": "prettier --check \"src/**/*.ts\"", + "lint": "eslint .", + "lint:fix": "eslint --fix ." }, "keywords": [ "oxa", @@ -25,8 +33,20 @@ "url": "git+https://github.com/oxa-dev/oxa.git", "directory": "packages/oxa" }, + "dependencies": {}, "devDependencies": { - "@oxa/core": "workspace:*" + "@oxa/conformance": "workspace:*", + "@oxa/core": "workspace:*", + "@types/js-yaml": "^4.0.9", + "@types/node": "^22.0.0", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "better-ajv-errors": "^2.0.2", + "commander": "^14.0.0", + "esbuild": "^0.25.0", + "execa": "^9.5.0", + "js-yaml": "^4.1.0", + "oxa-types": "workspace:*" }, "engines": { "node": ">=22" diff --git a/packages/oxa-core/src/cli.test.ts b/packages/oxa/src/cli.test.ts similarity index 99% rename from packages/oxa-core/src/cli.test.ts rename to packages/oxa/src/cli.test.ts index b1b5f58..d440f08 100644 --- a/packages/oxa-core/src/cli.test.ts +++ b/packages/oxa/src/cli.test.ts @@ -6,7 +6,7 @@ import { tmpdir } from "os"; import { fileURLToPath } from "url"; const __dirname = dirname(fileURLToPath(import.meta.url)); -const CLI_PATH = join(__dirname, "..", "dist", "cli.js"); +const CLI_PATH = join(__dirname, "..", "dist", "cli.cjs"); // Valid minimal document (only required fields) const validDocument = { diff --git a/packages/oxa-core/src/cli.ts b/packages/oxa/src/cli.ts similarity index 83% rename from packages/oxa-core/src/cli.ts rename to packages/oxa/src/cli.ts index 7d336ac..e5e26b2 100644 --- a/packages/oxa-core/src/cli.ts +++ b/packages/oxa/src/cli.ts @@ -1,22 +1,36 @@ /** * OXA CLI * - * Command-line interface for validating OXA documents. + * Command-line interface for validating and converting OXA documents. */ import { program } from "commander"; -import { readFileSync } from "fs"; -import yaml from "js-yaml"; import version from "./version.js"; -import { oxaToAtproto, type DocumentNode } from "./convert.js"; import { - validateFile, - validateJson, - validateYaml, + oxaToAtproto, + type Document, + type Session, type ValidationResult, -} from "./validate.js"; +} from "@oxa/core"; +import { + validateFile, + validateContent, + parseFile, + parseDocumentText, +} from "./validate-file.js"; + +// ── Session for CLI ───────────────────────────────────────────────────── -const yamlFileExtensions = [".yaml", ".yml"] as const; +const session: Session = { + log: { + debug: (...args) => process.stderr.write(`[debug] ${args.join(" ")}\n`), + info: (...args) => process.stderr.write(`${args.join(" ")}\n`), + warn: (...args) => process.stderr.write(`Warning: ${args.join(" ")}\n`), + error: (...args) => process.stderr.write(`Error: ${args.join(" ")}\n`), + }, +}; + +// ── CLI helpers ───────────────────────────────────────────────────────── // Exit codes const EXIT_SUCCESS = 0; @@ -85,29 +99,6 @@ async function readStdin(): Promise { return Buffer.concat(chunks).toString("utf-8"); } -function parseDocumentText(content: string, isYaml: boolean): unknown { - return isYaml ? yaml.load(content) : JSON.parse(content); -} - -function isYamlFilePath(filePath: string): boolean { - return yamlFileExtensions.some((extension) => filePath.endsWith(extension)); -} - -function parseFile(filePath: string): unknown { - const content = readFileSync(filePath, "utf-8"); - return parseDocumentText(content, isYamlFilePath(filePath)); -} - -function validateContent( - content: string, - options: { type?: string; yaml?: boolean }, - format: "cli" | "js", -): ValidationResult { - return options.yaml - ? validateYaml(content, { type: options.type, format }) - : validateJson(content, { type: options.type, format }); -} - function isStdinInput(file: string | undefined): file is undefined | "-" { return file === undefined || file === "-"; } @@ -123,6 +114,8 @@ async function readDocument( return parseFile(file); } +// ── Commands ──────────────────────────────────────────────────────────── + program .name("oxa") .description("CLI for validating OXA documents") @@ -243,7 +236,7 @@ program console.log( JSON.stringify( - oxaToAtproto(document as DocumentNode, { + oxaToAtproto(session, document as Document, { createdAt: options.createdAt, }), ), diff --git a/packages/oxa-core/src/conformance.test.ts b/packages/oxa/src/conformance.test.ts similarity index 98% rename from packages/oxa-core/src/conformance.test.ts rename to packages/oxa/src/conformance.test.ts index 3665f17..e876435 100644 --- a/packages/oxa-core/src/conformance.test.ts +++ b/packages/oxa/src/conformance.test.ts @@ -8,7 +8,7 @@ import { describe, it, expect } from "vitest"; import { manifest, cases } from "@oxa/conformance"; -import { validate } from "./validate.js"; +import { validate } from "@oxa/core"; describe("OXA Conformance Suite", () => { describe("manifest", () => { diff --git a/packages/oxa/src/validate-file.test.ts b/packages/oxa/src/validate-file.test.ts new file mode 100644 index 0000000..2e269fd --- /dev/null +++ b/packages/oxa/src/validate-file.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { writeFileSync, unlinkSync, mkdtempSync } from "fs"; +import { join } from "path"; +import { tmpdir } from "os"; +import { + validateFile, + validateContent, + parseFile, + parseDocumentText, + isYamlFilePath, +} from "./validate-file.js"; + +const validDocument = { + type: "Document", + children: [], +}; + +describe("validateFile", () => { + let tempDir: string; + let jsonFile: string; + let yamlFile: string; + let invalidFile: string; + + beforeAll(() => { + tempDir = mkdtempSync(join(tmpdir(), "oxa-test-")); + jsonFile = join(tempDir, "valid.json"); + yamlFile = join(tempDir, "valid.yaml"); + invalidFile = join(tempDir, "invalid.json"); + + writeFileSync(jsonFile, JSON.stringify(validDocument)); + writeFileSync( + yamlFile, + `type: Document +children: [] +`, + ); + writeFileSync(invalidFile, '{"type": "Document"}'); + }); + + afterAll(() => { + unlinkSync(jsonFile); + unlinkSync(yamlFile); + unlinkSync(invalidFile); + }); + + it("validates JSON file", () => { + const result = validateFile(jsonFile); + expect(result.valid).toBe(true); + }); + + it("validates YAML file", () => { + const result = validateFile(yamlFile); + expect(result.valid).toBe(true); + }); + + it("returns errors for invalid file content", () => { + const result = validateFile(invalidFile); + expect(result.valid).toBe(false); + }); + + it("returns error for non-existent file", () => { + const result = validateFile("/nonexistent/file.json"); + expect(result.valid).toBe(false); + expect(result.errors[0].message).toContain("Failed to read file"); + }); +}); + +describe("validateContent", () => { + it("validates JSON content", () => { + const result = validateContent(JSON.stringify(validDocument), {}, "js"); + expect(result.valid).toBe(true); + }); + + it("validates YAML content", () => { + const result = validateContent( + "type: Document\nchildren: []\n", + { yaml: true }, + "js", + ); + expect(result.valid).toBe(true); + }); + + it("returns errors for invalid content", () => { + const result = validateContent('{"type": "Document"}', {}, "js"); + expect(result.valid).toBe(false); + }); +}); + +describe("parseFile", () => { + let tempDir: string; + let jsonFile: string; + let yamlFile: string; + + beforeAll(() => { + tempDir = mkdtempSync(join(tmpdir(), "oxa-parse-test-")); + jsonFile = join(tempDir, "doc.json"); + yamlFile = join(tempDir, "doc.yaml"); + + writeFileSync(jsonFile, JSON.stringify(validDocument)); + writeFileSync(yamlFile, "type: Document\nchildren: []\n"); + }); + + afterAll(() => { + unlinkSync(jsonFile); + unlinkSync(yamlFile); + }); + + it("parses a JSON file", () => { + const result = parseFile(jsonFile); + expect(result).toEqual(validDocument); + }); + + it("parses a YAML file", () => { + const result = parseFile(yamlFile); + expect(result).toEqual(validDocument); + }); +}); + +describe("parseDocumentText", () => { + it("parses JSON text", () => { + const result = parseDocumentText('{"type": "Document"}', false); + expect(result).toEqual({ type: "Document" }); + }); + + it("parses YAML text", () => { + const result = parseDocumentText("type: Document\n", true); + expect(result).toEqual({ type: "Document" }); + }); +}); + +describe("isYamlFilePath", () => { + it("returns true for .yaml", () => { + expect(isYamlFilePath("doc.yaml")).toBe(true); + }); + + it("returns true for .yml", () => { + expect(isYamlFilePath("doc.yml")).toBe(true); + }); + + it("returns false for .json", () => { + expect(isYamlFilePath("doc.json")).toBe(false); + }); +}); diff --git a/packages/oxa/src/validate-file.ts b/packages/oxa/src/validate-file.ts new file mode 100644 index 0000000..0eb5e1c --- /dev/null +++ b/packages/oxa/src/validate-file.ts @@ -0,0 +1,62 @@ +/** + * File-based validation and parsing utilities (Node.js only). + */ + +import { readFileSync } from "fs"; +import yaml from "js-yaml"; +import { + validateJson, + validateYaml, + type ValidationResult, + type ValidateOptions, +} from "@oxa/core"; + +const yamlFileExtensions = [".yaml", ".yml"] as const; + +export function isYamlFilePath(filePath: string): boolean { + return yamlFileExtensions.some((extension) => filePath.endsWith(extension)); +} + +export function parseDocumentText(content: string, isYaml: boolean): unknown { + return isYaml ? yaml.load(content) : JSON.parse(content); +} + +export function parseFile(filePath: string): unknown { + const content = readFileSync(filePath, "utf-8"); + return parseDocumentText(content, isYamlFilePath(filePath)); +} + +export function validateFile( + filePath: string, + options: ValidateOptions = {}, +): ValidationResult { + try { + const content = readFileSync(filePath, "utf-8"); + + if (isYamlFilePath(filePath)) { + return validateYaml(content, options); + } else { + return validateJson(content, options); + } + } catch (error) { + return { + valid: false, + errors: [ + { + path: "/", + message: `Failed to read file: ${error instanceof Error ? error.message : String(error)}`, + }, + ], + }; + } +} + +export function validateContent( + content: string, + options: { type?: string; yaml?: boolean }, + format: "cli" | "js", +): ValidationResult { + return options.yaml + ? validateYaml(content, { type: options.type, format }) + : validateJson(content, { type: options.type, format }); +} diff --git a/packages/oxa/tsconfig.json b/packages/oxa/tsconfig.json new file mode 100644 index 0000000..b052a1c --- /dev/null +++ b/packages/oxa/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "declaration": true, + "outDir": "./dist", + "rootDir": "./src", + "noEmit": false + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "src/**/*.test.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41a1efa..4f0d9d1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,9 +44,42 @@ importers: packages/oxa: devDependencies: + '@oxa/conformance': + specifier: workspace:* + version: link:../oxa-conformance '@oxa/core': specifier: workspace:* version: link:../oxa-core + '@types/js-yaml': + specifier: ^4.0.9 + version: 4.0.9 + '@types/node': + specifier: ^22.0.0 + version: 22.19.3 + ajv: + specifier: ^8.17.1 + version: 8.17.1 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.17.1) + better-ajv-errors: + specifier: ^2.0.2 + version: 2.0.2(ajv@8.17.1) + commander: + specifier: ^14.0.0 + version: 14.0.2 + esbuild: + specifier: ^0.25.0 + version: 0.25.12 + execa: + specifier: ^9.5.0 + version: 9.6.1 + js-yaml: + specifier: ^4.1.0 + version: 4.1.1 + oxa-types: + specifier: workspace:* + version: link:../oxa-types-ts packages/oxa-conformance: {} @@ -61,9 +94,6 @@ importers: better-ajv-errors: specifier: ^2.0.2 version: 2.0.2(ajv@8.17.1) - commander: - specifier: ^14.0.0 - version: 14.0.2 js-yaml: specifier: ^4.1.0 version: 4.1.1 @@ -77,15 +107,6 @@ importers: '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 - '@types/node': - specifier: ^22.0.0 - version: 22.19.3 - esbuild: - specifier: ^0.25.0 - version: 0.25.12 - execa: - specifier: ^9.5.0 - version: 9.6.1 packages/oxa-types-ts: {}