From 859b0408e993bf139c6802a013ff9d2b7cf4ebc9 Mon Sep 17 00:00:00 2001 From: Michael Grimm Date: Wed, 20 May 2026 19:15:28 -0700 Subject: [PATCH] feat(opf): ship examples, docs, and upstream README in the npm package Adds three new subpath exports for downstream sites that currently have to clone OpenPresentation/opf or hit GitHub at request time to read content the package doesnt yet ship. - @openpresentation/opf/examples bundles every .opf.json under examples/ at build time, exposes them as ExampleRecord[] + GalleryRecord[] (with the parsed Presentation deck), plus convenience lookups: getExample, getGallery, getExamplesByGallery, getExamplesByCategory. Each deck is validated against the presentation schema during smoke tests. - @openpresentation/opf/docs bundles the top-level docs/*.md reference pages (schema-reference, catalog-schema-reference, content-payloads, content-item-design-overrides, examples) and exposes them as DocRecord[] with getDoc. The BACKLOG and docs/migrations/, docs/plans/ subdirectories are intentionally excluded because they change too quickly to pin inside a release. - @openpresentation/opf/repo-readme exposes the upstream README.md as a raw markdown string so consumer sites can mirror the canonical README without a network fetch. Implementation: - New scripts/generate-content.mjs walks examples/, docs/, and README.md at build time and emits src/generated/{examples,docs,repo-readme}.ts; wired into the generate / build pipeline alongside the existing schema, catalog, spec-files, and previews generators. - New facade modules src/examples.ts, src/docs.ts, src/repo-readme.ts re-export the generated data with the public type surface. - tsup config gains three entries; package.json gets three new exports. - Smoke test asserts every shipped deck validates, that gallery indexing is consistent, that docs metadata is non-empty, and that the repo README is present and non-trivial. - README and CHANGELOG document the new entry points. Pack stays at one tarball; size grows from ~1 MB to ~1.7 MB (unpacked 13.5 MB) which is acceptable for the convenience of removing the GitHub source sync from downstream consumers. --- CHANGELOG.md | 8 + packages/javascript/README.md | 56 +++++ packages/javascript/package.json | 14 +- .../javascript/scripts/generate-content.mjs | 202 ++++++++++++++++++ packages/javascript/src/docs.ts | 19 ++ packages/javascript/src/examples.ts | 47 ++++ packages/javascript/src/repo-readme.ts | 15 ++ packages/javascript/test/smoke.mjs | 57 +++++ packages/javascript/tsup.config.ts | 3 + 9 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 packages/javascript/scripts/generate-content.mjs create mode 100644 packages/javascript/src/docs.ts create mode 100644 packages/javascript/src/examples.ts create mode 100644 packages/javascript/src/repo-readme.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c64a73..226b875 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## Unreleased + +### Added + +- Shipped every `.opf.json` deck under `examples/` inside the npm package and exposed them via `@openpresentation/opf/examples` (`examples`, `galleries`, `exampleCategories`, `getExample`, `getGallery`, `getExamplesByGallery`, `getExamplesByCategory`). Each example deck is validated against the presentation schema at build time. +- Shipped the top-level `docs/*.md` reference pages inside the npm package and exposed them via `@openpresentation/opf/docs` (`docs`, `getDoc`). Subdirectories like `docs/migrations` and `docs/plans` are intentionally excluded. +- Shipped the upstream `README.md` markdown at the pinned release version via `@openpresentation/opf/repo-readme` so consumer sites can mirror it without a network fetch. + ## 0.2.2 ### Changed diff --git a/packages/javascript/README.md b/packages/javascript/README.md index 6ef46b6..a1b0731 100644 --- a/packages/javascript/README.md +++ b/packages/javascript/README.md @@ -93,6 +93,62 @@ export function LayoutThumbnail({ slug }: { slug: string }) { Raw HTML source-of-truth lives under `spec/previews/layouts/.html` and is also addressable via `@openpresentation/opf/spec/previews/layouts/.html`. +### Example decks + +`@openpresentation/opf/examples` ships every `.opf.json` file from `examples/` +in the upstream repo, already parsed and validated against the presentation +schema at build time. + +```ts +import { + examples, + galleries, + getExample, + getExamplesByGallery, +} from "@openpresentation/opf/examples"; + +const compliance = getExample("compliance-readiness-review"); +const businessDecks = getExamplesByGallery("business-functions"); + +console.log(`${examples.length} example decks across ${galleries.length} galleries`); +``` + +Each `ExampleRecord` includes the parsed `Presentation` object plus its +repo-relative path, top-level category (`gallery`, `technical`, …), and +gallery slug when applicable. + +### Documentation pages + +`@openpresentation/opf/docs` ships the top-level `docs/*.md` reference pages +(`schema-reference`, `catalog-schema-reference`, `content-payloads`, +`content-item-design-overrides`, `examples`). Subdirectories like +`docs/migrations` and `docs/plans` are intentionally excluded — those move too +quickly to ship inside a pinned npm release. + +```ts +import { docs, getDoc } from "@openpresentation/opf/docs"; + +for (const doc of docs) { + console.log(`${doc.title} — ${doc.file}`); +} + +const schemaRef = getDoc("schema-reference"); +console.log(schemaRef?.markdown.slice(0, 200)); +``` + +### Upstream README + +`@openpresentation/opf/repo-readme` exposes the raw markdown of the upstream +`OpenPresentation/opf` README.md at the version pinned by this release. Use it +when you want to mirror the canonical README inside another site or app +without doing a network fetch. + +```ts +import { repoReadme } from "@openpresentation/opf/repo-readme"; + +console.log(repoReadme.split("\n").slice(0, 3).join("\n")); +``` + Validate catalog records locally: ```ts diff --git a/packages/javascript/package.json b/packages/javascript/package.json index 202cc5e..0f5ec28 100644 --- a/packages/javascript/package.json +++ b/packages/javascript/package.json @@ -58,6 +58,18 @@ "types": "./dist/previews.d.ts", "import": "./dist/previews.js" }, + "./examples": { + "types": "./dist/examples.d.ts", + "import": "./dist/examples.js" + }, + "./docs": { + "types": "./dist/docs.d.ts", + "import": "./dist/docs.js" + }, + "./repo-readme": { + "types": "./dist/repo-readme.d.ts", + "import": "./dist/repo-readme.js" + }, "./spec/*": "./dist/spec/*", "./package.json": "./package.json" }, @@ -72,7 +84,7 @@ }, "scripts": { "clean": "rm -rf dist src/generated", - "generate": "node scripts/generate.mjs && node scripts/generate-previews.mjs", + "generate": "node scripts/generate.mjs && node scripts/generate-previews.mjs && node scripts/generate-content.mjs", "build": "pnpm run generate && tsup && node scripts/copy-spec.mjs", "typecheck": "pnpm run generate && tsc --noEmit", "test": "pnpm run build && node test/smoke.mjs", diff --git a/packages/javascript/scripts/generate-content.mjs b/packages/javascript/scripts/generate-content.mjs new file mode 100644 index 0000000..b41f7d3 --- /dev/null +++ b/packages/javascript/scripts/generate-content.mjs @@ -0,0 +1,202 @@ +// Builds src/generated/{examples,docs,repo-readme}.ts by inlining the example +// .opf.json decks, the top-level docs/*.md files, and the repo README.md. +// +// Source-of-truth lives at the repo root: +// - examples/**/*.opf.json +// - docs/*.md (top-level only; subdirectories like docs/migrations and +// docs/plans are intentionally excluded for now — they +// change too often to ship inside a pinned npm release) +// - README.md +// +// Run via `pnpm generate` (wired in package.json after the schema/catalog gen). + +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const packageRoot = path.resolve(__dirname, ".."); +const repoRoot = path.resolve(packageRoot, "../.."); +const examplesRoot = path.join(repoRoot, "examples"); +const docsRoot = path.join(repoRoot, "docs"); +const readmePath = path.join(repoRoot, "README.md"); +const generatedRoot = path.join(packageRoot, "src/generated"); + +await fs.mkdir(generatedRoot, { recursive: true }); + +function generatedHeader(source) { + return `// Generated by scripts/generate-content.mjs from ${source}. Do not edit by hand.\n\n`; +} + +function posix(relPath) { + return relPath.split(path.sep).join(path.posix.sep); +} + +async function collectFiles(root, predicate) { + const out = []; + async function walk(dir) { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) { + await walk(abs); + } else if (entry.isFile()) { + const rel = posix(path.relative(root, abs)); + if (predicate(rel, entry.name)) out.push(rel); + } + } + } + await walk(root); + out.sort(); + return out; +} + +function exampleCategory(relativeFromExamples) { + const segments = relativeFromExamples.split("/"); + return segments[0] ?? ""; +} + +function exampleGallerySlug(relativeFromExamples) { + const segments = relativeFromExamples.split("/"); + if (segments[0] !== "gallery") return null; + return segments[1] ?? null; +} + +function exampleSlug(relativeFromExamples) { + const base = path.posix.basename(relativeFromExamples); + return base.replace(/\.opf\.json$/, ""); +} + +async function generateExamples() { + const files = await collectFiles(examplesRoot, (rel) => rel.endsWith(".opf.json")); + const examples = []; + for (const file of files) { + const json = JSON.parse(await fs.readFile(path.join(examplesRoot, file), "utf8")); + examples.push({ + slug: exampleSlug(file), + file: `examples/${file}`, + category: exampleCategory(file), + gallery: exampleGallerySlug(file), + deck: json, + }); + } + + const galleries = []; + const galleryMap = new Map(); + for (const example of examples) { + if (example.gallery === null) continue; + if (!galleryMap.has(example.gallery)) { + const slug = example.gallery; + const entry = { slug, dir: `examples/gallery/${slug}`, examples: [] }; + galleryMap.set(slug, entry); + galleries.push(entry); + } + galleryMap.get(example.gallery).examples.push({ + slug: example.slug, + file: example.file, + name: typeof example.deck?.name === "string" ? example.deck.name : null, + }); + } + galleries.sort((a, b) => a.slug.localeCompare(b.slug)); + + const banner = generatedHeader("examples/**/*.opf.json"); + const exampleLiteral = JSON.stringify(examples, null, 2); + const galleriesLiteral = JSON.stringify(galleries, null, 2); + + const source = + banner + + 'import type { Presentation } from "../types.js";\n\n' + + "export interface ExampleRecord {\n" + + " /** Filename slug, without the `.opf.json` suffix. */\n" + + " readonly slug: string;\n" + + " /** Path relative to the repo root, with forward slashes. */\n" + + " readonly file: string;\n" + + " /** Top-level examples/ bucket (`gallery`, `technical`, …). */\n" + + " readonly category: string;\n" + + " /** Gallery slug when the example lives under `examples/gallery//`; otherwise `null`. */\n" + + " readonly gallery: string | null;\n" + + " /** Parsed OPF document. Validated against `presentation.schema.json` at build time. */\n" + + " readonly deck: Presentation;\n" + + "}\n\n" + + "export interface GalleryRecord {\n" + + " /** Gallery slug (folder name under `examples/gallery/`). */\n" + + " readonly slug: string;\n" + + " /** Directory path relative to the repo root. */\n" + + " readonly dir: string;\n" + + " /** Lightweight summary of every example deck inside the gallery. */\n" + + " readonly examples: readonly { readonly slug: string; readonly file: string; readonly name: string | null }[];\n" + + "}\n\n" + + `const examplesData: readonly ExampleRecord[] = Object.freeze(${exampleLiteral} as readonly ExampleRecord[]);\n\n` + + `const galleriesData: readonly GalleryRecord[] = Object.freeze(${galleriesLiteral} as readonly GalleryRecord[]);\n\n` + + "export const examplesRaw = examplesData;\n" + + "export const galleriesRaw = galleriesData;\n"; + + await fs.writeFile(path.join(generatedRoot, "examples.ts"), source, "utf8"); + console.log(`Generated src/generated/examples.ts (${examples.length} examples, ${galleries.length} galleries)`); +} + +function extractFirstHeading(markdown) { + const match = markdown.match(/^#\s+(.+?)\s*$/m); + return match ? match[1].trim() : null; +} + +function titleFromSlug(slug) { + return slug.replace(/[-_]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase()); +} + +async function generateDocs() { + // docs/*.md only — skip the BACKLOG and any subdirectory until we have a + // separate policy for shipping migration/plan notes inside the package. + const entries = await fs.readdir(docsRoot, { withFileTypes: true }); + const files = entries + .filter((entry) => entry.isFile() && entry.name.endsWith(".md") && entry.name !== "BACKLOG.md") + .map((entry) => entry.name) + .sort(); + + const docs = []; + for (const name of files) { + const slug = name.replace(/\.md$/, ""); + const markdown = await fs.readFile(path.join(docsRoot, name), "utf8"); + docs.push({ + slug, + file: `docs/${name}`, + title: extractFirstHeading(markdown) ?? titleFromSlug(slug), + markdown, + }); + } + + const banner = generatedHeader("docs/*.md"); + const docsLiteral = JSON.stringify(docs, null, 2); + const source = + banner + + "export interface DocRecord {\n" + + " /** Filename slug, without the `.md` suffix. */\n" + + " readonly slug: string;\n" + + " /** Path relative to the repo root, with forward slashes. */\n" + + " readonly file: string;\n" + + " /** First-level heading from the markdown, falling back to a slug-derived title. */\n" + + " readonly title: string;\n" + + " /** Raw markdown content. */\n" + + " readonly markdown: string;\n" + + "}\n\n" + + `const docsData: readonly DocRecord[] = Object.freeze(${docsLiteral} as readonly DocRecord[]);\n\n` + + "export const docsRaw = docsData;\n"; + + await fs.writeFile(path.join(generatedRoot, "docs.ts"), source, "utf8"); + console.log(`Generated src/generated/docs.ts (${docs.length} docs)`); +} + +async function generateRepoReadme() { + const markdown = await fs.readFile(readmePath, "utf8"); + const banner = generatedHeader("README.md"); + const literal = JSON.stringify(markdown); + const source = + banner + + `export const repoReadmeRaw: string = ${literal};\n`; + await fs.writeFile(path.join(generatedRoot, "repo-readme.ts"), source, "utf8"); + console.log(`Generated src/generated/repo-readme.ts (${markdown.length} bytes)`); +} + +await generateExamples(); +await generateDocs(); +await generateRepoReadme(); diff --git a/packages/javascript/src/docs.ts b/packages/javascript/src/docs.ts new file mode 100644 index 0000000..73ab6c1 --- /dev/null +++ b/packages/javascript/src/docs.ts @@ -0,0 +1,19 @@ +// Bundled OPF documentation pages. +// +// Source-of-truth: ../../../docs/*.md (top-level only — `BACKLOG.md` and the +// `docs/migrations/`, `docs/plans/` subdirectories are intentionally excluded +// for now). The build step inlines the raw markdown into this module so +// consumers can render or link to the docs without filesystem access. + +import { docsRaw } from "./generated/docs.js"; +import type { DocRecord } from "./generated/docs.js"; + +export type { DocRecord }; + +/** Every documentation page shipped with this release, sorted by slug. */ +export const docs: readonly DocRecord[] = docsRaw; + +/** Look up a documentation page by slug (the filename without `.md`). */ +export function getDoc(slug: string): DocRecord | undefined { + return docsRaw.find((doc) => doc.slug === slug); +} diff --git a/packages/javascript/src/examples.ts b/packages/javascript/src/examples.ts new file mode 100644 index 0000000..20f5993 --- /dev/null +++ b/packages/javascript/src/examples.ts @@ -0,0 +1,47 @@ +// Bundled OPF example decks. +// +// Source-of-truth: ../../../examples/**/*.opf.json. The build step +// (`pnpm build`) inlines each deck into this module so consumers can import +// the gallery without doing any filesystem work at runtime. +// +// Each example's `deck` field is the parsed JSON for an `.opf.json` file — +// the same content you would `JSON.parse(fs.readFileSync(file))`. + +import { examplesRaw, galleriesRaw } from "./generated/examples.js"; +import type { + ExampleRecord, + GalleryRecord, +} from "./generated/examples.js"; + +export type { ExampleRecord, GalleryRecord }; + +/** Every example deck shipped with this release, sorted by file path. */ +export const examples: readonly ExampleRecord[] = examplesRaw; + +/** Examples grouped by `examples/gallery//` folder. */ +export const galleries: readonly GalleryRecord[] = galleriesRaw; + +/** Top-level `examples/` buckets present in this release. */ +export const exampleCategories: readonly string[] = Object.freeze( + Array.from(new Set(examplesRaw.map((example) => example.category))).sort(), +); + +/** Look up an example deck by slug (the filename without `.opf.json`). */ +export function getExample(slug: string): ExampleRecord | undefined { + return examplesRaw.find((example) => example.slug === slug); +} + +/** Look up a gallery by slug. */ +export function getGallery(slug: string): GalleryRecord | undefined { + return galleriesRaw.find((gallery) => gallery.slug === slug); +} + +/** All examples that belong to a given gallery slug. */ +export function getExamplesByGallery(slug: string): readonly ExampleRecord[] { + return examplesRaw.filter((example) => example.gallery === slug); +} + +/** All examples that belong to a given top-level category (`gallery`, `technical`, …). */ +export function getExamplesByCategory(category: string): readonly ExampleRecord[] { + return examplesRaw.filter((example) => example.category === category); +} diff --git a/packages/javascript/src/repo-readme.ts b/packages/javascript/src/repo-readme.ts new file mode 100644 index 0000000..49a15c4 --- /dev/null +++ b/packages/javascript/src/repo-readme.ts @@ -0,0 +1,15 @@ +// The raw README.md from the OpenPresentation/opf repo at the version pinned +// by this release. +// +// Source-of-truth: ../../../README.md. The build step inlines the markdown +// into this module so consumers can render the upstream README without doing +// any filesystem or network work at runtime. +// +// The package's own README.md (npm landing page) is a separate file — this +// export is intended for sites that want to mirror the canonical upstream +// README. + +import { repoReadmeRaw } from "./generated/repo-readme.js"; + +/** Raw markdown of the upstream OpenPresentation/opf README at this release. */ +export const repoReadme: string = repoReadmeRaw; diff --git a/packages/javascript/test/smoke.mjs b/packages/javascript/test/smoke.mjs index cb626d2..7c79082 100644 --- a/packages/javascript/test/smoke.mjs +++ b/packages/javascript/test/smoke.mjs @@ -20,6 +20,17 @@ import { import { specFileEntries as focusedSpecFileEntries } from "../dist/spec-files.js"; import { tones } from "../dist/catalogs.js"; import { validate, assertValid } from "../dist/validator.js"; +import { + examples, + galleries, + exampleCategories, + getExample, + getGallery, + getExamplesByGallery, + getExamplesByCategory, +} from "../dist/examples.js"; +import { docs, getDoc } from "../dist/docs.js"; +import { repoReadme } from "../dist/repo-readme.js"; assert.equal(presentation.$id, "https://openpresentation.org/schema/opf/v1"); assert.equal(audience.$id, "https://openpresentation.org/schema/opf-audience/v1"); @@ -735,3 +746,49 @@ assert.ok( const require = createRequire(import.meta.url); const rawPresentation = require("../dist/spec/schemas/opf.schema.json"); assert.equal(rawPresentation.$id, presentation.$id); + +assert.ok(examples.length > 0, "expected at least one example deck"); +assert.ok(galleries.length > 0, "expected at least one gallery"); +assert.ok(exampleCategories.includes("gallery")); +for (const example of examples) { + assert.equal(typeof example.slug, "string"); + assert.ok(example.file.startsWith("examples/")); + assert.equal(typeof example.category, "string"); + assert.ok(example.deck && typeof example.deck === "object"); + const result = validatePresentation(example.deck); + assert.equal( + result.valid, + true, + `Example ${example.slug} failed validation: ${JSON.stringify(result.errors, null, 2)}`, + ); +} +for (const gallery of galleries) { + assert.ok(gallery.slug.length > 0); + assert.ok(gallery.dir.startsWith("examples/gallery/")); + assert.ok(gallery.examples.length > 0, `gallery ${gallery.slug} is empty`); +} +const firstGallery = galleries[0]; +const firstGalleryExamples = getExamplesByGallery(firstGallery.slug); +assert.equal(firstGalleryExamples.length, firstGallery.examples.length); +assert.ok(getGallery(firstGallery.slug)); +assert.equal(getGallery("definitely-does-not-exist"), undefined); +const firstExample = examples[0]; +assert.equal(getExample(firstExample.slug)?.slug, firstExample.slug); +assert.ok(getExamplesByCategory("gallery").length > 0); + +assert.ok(docs.length > 0, "expected at least one doc"); +for (const doc of docs) { + assert.equal(typeof doc.slug, "string"); + assert.ok(doc.file.startsWith("docs/")); + assert.ok(doc.title.length > 0, `doc ${doc.slug} missing title`); + assert.ok(doc.markdown.length > 0, `doc ${doc.slug} missing markdown`); +} +const docSlugs = docs.map((doc) => doc.slug); +assert.ok(docSlugs.includes("schema-reference")); +assert.equal(getDoc("schema-reference")?.slug, "schema-reference"); +assert.equal(getDoc("missing-doc"), undefined); +assert.ok(!docSlugs.includes("BACKLOG")); + +assert.equal(typeof repoReadme, "string"); +assert.ok(repoReadme.length > 100, "expected repo README to ship"); +assert.match(repoReadme, /open presentation format/i); diff --git a/packages/javascript/tsup.config.ts b/packages/javascript/tsup.config.ts index df42e34..744ead3 100644 --- a/packages/javascript/tsup.config.ts +++ b/packages/javascript/tsup.config.ts @@ -9,6 +9,9 @@ export default defineConfig({ types: "src/types.ts", "spec-files": "src/spec-files.ts", previews: "src/previews.ts", + examples: "src/examples.ts", + docs: "src/docs.ts", + "repo-readme": "src/repo-readme.ts", }, format: ["esm"], dts: true,