diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index f2073632..007fbcbd 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -74,7 +74,16 @@ jobs: if: ${{ steps.release.outputs.releases_created == 'true' }} run: pnpm build:cdn - - name: Build CLI (requires site build) + # The site build emits per-framework markdown to site/dist/docs/framework/{html,react}/. + # The @videojs/html and @videojs/react packages' prepack scripts (fired by `pnpm publish` + # below) copy that subtree into each package's docs/ directory so it ships in the tarball. + # build:cli also depends on the site build transitively; turbo caches it, so this step + # adds no rebuild cost — it just makes the dependency visible in CI. + - name: Build site (required by html/react prepack and CLI) + if: ${{ steps.release.outputs.releases_created == 'true' }} + run: pnpm build:site + + - name: Build CLI if: ${{ steps.release.outputs.releases_created == 'true' }} run: pnpm build:cli diff --git a/.gitignore b/.gitignore index ff05ce71..aadd8a25 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,8 @@ site/src/content/generated-feature-reference/ site/src/content/generated-preset-reference/ site/src/content/ejected-skins.json packages/cli/docs/ +packages/html/docs/ +packages/react/docs/ # ------------------------- # Environment diff --git a/build/scripts/check-workspace.mjs b/build/scripts/check-workspace.mjs index 34c59469..bfba6d6a 100644 --- a/build/scripts/check-workspace.mjs +++ b/build/scripts/check-workspace.mjs @@ -240,7 +240,33 @@ function checkReleasePleaseConfig() { return { ok: warnings.length === 0, warnings }; } -// ── Check 6: Define imports ────────────────────────────────────────────────── +// ── Check 6: Bundled docs publishing ───────────────────────────────────────── + +/** + * `@videojs/html` and `@videojs/react` ship the per-framework markdown docs + * subtree inside their tarballs (see `site/scripts/copy-package-docs.js`). + * Both wires (the `files[]` entry and the `prepack` script) must stay in sync + * — without one, publishing silently drops the docs. + */ +function checkBundledDocs() { + const warnings = []; + + for (const dir of ['html', 'react']) { + const pkg = readPackageJson(dir); + if (!pkg.files?.includes('docs')) { + warnings.push(`${pkg.name}: missing "docs" entry in "files" — bundled docs would not ship`); + } + const prepack = pkg.scripts?.prepack; + const expected = `node --import tsx ../../site/scripts/copy-package-docs.ts ${dir}`; + if (prepack !== expected) { + warnings.push(`${pkg.name}: prepack script should be \`${expected}\` (got: ${prepack ?? 'missing'})`); + } + } + + return { ok: warnings.length === 0, warnings }; +} + +// ── Check 7: Define imports ────────────────────────────────────────────────── /** * Bare side-effect imports from relative paths in the define directory cause @@ -306,6 +332,7 @@ const checks = [ { name: 'Root tsconfig references', fn: checkTsconfigReferences }, { name: 'Package metadata', fn: checkPackageMetadata }, { name: 'Release-please config', fn: checkReleasePleaseConfig }, + { name: 'Bundled docs publishing', fn: checkBundledDocs }, { name: 'Define imports', fn: checkDefineImports }, ]; diff --git a/packages/html/README.md b/packages/html/README.md index 593cb1d4..30859a4c 100644 --- a/packages/html/README.md +++ b/packages/html/README.md @@ -10,6 +10,12 @@ Web Components. It provides a complete set of Custom Elements, state management, controllers, and utilities for creating feature-rich, accessible video and audio players. +## Documentation + +Read the docs at [videojs.org](https://videojs.org/docs/framework/html), or after installing, +browse the bundled markdown at `node_modules/@videojs/html/docs/` (start with `llms.txt` for the +structured index). + ## Community If you need help with anything related to Video.js 10, or if you'd like to casually chat with other diff --git a/packages/html/package.json b/packages/html/package.json index d073a30d..64882eb9 100644 --- a/packages/html/package.json +++ b/packages/html/package.json @@ -20,7 +20,8 @@ ], "files": [ "dist", - "cdn" + "cdn", + "docs" ], "exports": { ".": { @@ -131,7 +132,8 @@ "build:watch": "tsdown --watch ./src --no-clean", "dev": "pnpm run build:watch", "test": "vitest run", - "clean": "rimraf --glob dist cdn types '*.tsbuildinfo'" + "clean": "rimraf --glob dist cdn docs types '*.tsbuildinfo'", + "prepack": "node --import tsx ../../site/scripts/copy-package-docs.ts html" }, "dependencies": { "@videojs/core": "workspace:*", diff --git a/packages/react/README.md b/packages/react/README.md index 4228fea0..fedb15c0 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -10,6 +10,12 @@ provides a complete set of components, hooks, and utilities for creating feature-rich, accessible video and audio players with React. +## Documentation + +Read the docs at [videojs.org](https://videojs.org/docs/framework/react), or after installing, +browse the bundled markdown at `node_modules/@videojs/react/docs/` (start with `llms.txt` for the +structured index). + ## Community If you need help with anything related to Video.js 10, or if you'd like to casually chat with other diff --git a/packages/react/package.json b/packages/react/package.json index 03fdfb23..b158c9e4 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -14,7 +14,8 @@ "types": "dist/dev/index.d.ts", "sideEffects": false, "files": [ - "dist" + "dist", + "docs" ], "exports": { ".": { @@ -100,7 +101,8 @@ "dev": "pnpm build:watch", "test": "vitest run", "test:watch": "vitest", - "clean": "rimraf --glob dist types '*.tsbuildinfo'" + "clean": "rimraf --glob dist docs types '*.tsbuildinfo'", + "prepack": "node --import tsx ../../site/scripts/copy-package-docs.ts react" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", diff --git a/site/scripts/copy-package-docs.ts b/site/scripts/copy-package-docs.ts new file mode 100644 index 00000000..f412c5ef --- /dev/null +++ b/site/scripts/copy-package-docs.ts @@ -0,0 +1,192 @@ +/** + * Copies the per-framework markdown documentation subtree emitted by the site + * build into a target package's `docs/` directory, ready to be shipped in the + * package tarball. + * + * Invoked from each package's `prepack` lifecycle script: + * "prepack": "node --import tsx ../../site/scripts/copy-package-docs.ts html" + * + * Reads `site/dist/docs/framework//` (produced by the llms-markdown + * integration), rewrites absolute site URLs to local relative paths, strips + * the breadcrumb footer from each .md file, synthesizes a short cold-start + * `docs/README.md`, and writes the result to `packages//docs/`. + * + * Hard-errors if `site/dist` is missing — run `pnpm build:site` first. + */ +import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join, posix, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); +const SITE_DIR = resolve(__dirname, '..'); +const WORKSPACE_ROOT = resolve(SITE_DIR, '..'); + +const PACKAGE_NAMES = { + html: '@videojs/html', + react: '@videojs/react', +} as const; + +export type Framework = keyof typeof PACKAGE_NAMES; + +const DOCS_SITE_BASE = 'https://videojs.org'; + +function isFramework(value: string): value is Framework { + return value in PACKAGE_NAMES; +} + +// ────────────────────────────────────────────────────────────────────────── +// Pure transforms (exported for unit tests) +// ────────────────────────────────────────────────────────────────────────── + +/** + * Removes the trailing breadcrumb block the site appends to every .md page + * and to llms.txt (looks like `---\n\n documentation: ...`). + * Mirrors the regex the CLI uses in `packages/cli/src/utils/docs.ts`. + */ +export function stripFooter(content: string): string { + return content.replace(/\n+---\n\n(\w+ documentation: https:\/\/.*\n)?All documentation: https:\/\/.*\n*$/, ''); +} + +/** + * Rewrites URLs that point at this framework's docs subtree into paths + * relative to the source file's location, so an agent reading from + * node_modules can follow links locally instead of via WebFetch. + * + * - sourceSlug is the path of the file the content belongs to, relative to + * the framework root and without an extension: e.g. `concepts/overview` + * for `concepts/overview.md`, or `llms` for the index `llms.txt`. + * - URLs outside the framework's docs subtree (e.g. the root /llms.txt, + * blog posts, the other framework) are left untouched. + */ +export function rewriteLinks(content: string, sourceSlug: string, framework: Framework): string { + const frameworkPath = `/docs/framework/${framework}/`; + // Match URLs in markdown link target position only: `](URL)`. Anchoring to + // `](` keeps the regex from chewing through URL-shaped strings inside link + // text (e.g. inside code spans like `[\`videojs.org/.../llms.txt\`](...)`) + // where the surrounding characters aren't safe to overrun. + // + // Capture the trailing extension (`.md`, `.txt`, or `/`) so it can be + // preserved — links to the framework's `llms.txt` must stay `.txt`, not be + // rewritten to `.md`. URLs with no extension and trailing-slash URLs both + // map to the `.md` file the site emits for that slug. + const pattern = new RegExp( + `(\\]\\()(?:https?://[^\\s)]+)?${escapeForRegex(frameworkPath)}([^\\s)#]*?)(\\.md|\\.txt|/)?(?=[)#])`, + 'g' + ); + const sourceDir = posix.dirname(sourceSlug); + return content.replace(pattern, (match, prefix: string, slug: string, ext: string | undefined) => { + // Bare framework-root URLs (empty slug) don't map to a single file — + // leave them alone rather than synthesizing a nonsense `./.md`. + if (!slug) return match; + const targetExt = ext === '.txt' ? '.txt' : '.md'; + return prefix + toRelativePath(sourceDir, `${slug}${targetExt}`); + }); +} + +/** + * Body for the synthesized `docs/README.md` cold-start file. Short on purpose: + * agents reflexively read README, this gets them to the structured index. + */ +export function synthesizeReadme({ + framework, + version, +}: { + framework: Framework; + version: string | undefined; +}): string { + const packageName = PACKAGE_NAMES[framework]; + if (!packageName) throw new Error(`Unknown framework: ${framework}`); + const versionSuffix = version ? ` v${version}` : ''; + return [ + `# ${packageName} documentation`, + '', + `Bundled markdown documentation for \`${packageName}\`${versionSuffix}.`, + '', + `Start at [\`./llms.txt\`](./llms.txt) — it's the structured index of every page in this directory.`, + '', + `Canonical online version: ${DOCS_SITE_BASE}/docs/framework/${framework}`, + '', + ].join('\n'); +} + +// ────────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────────── + +function escapeForRegex(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function toRelativePath(sourceDir: string, targetFile: string): string { + const fromDir = sourceDir === '.' || sourceDir === '' ? '.' : sourceDir; + const rel = posix.relative(fromDir, targetFile); + return rel.startsWith('.') ? rel : `./${rel}`; +} + +function isDocFile(path: string): boolean { + return path.endsWith('.md') || path.endsWith('.txt'); +} + +function walk(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + out.push(...walk(full)); + } else if (entry.isFile() && isDocFile(full)) { + out.push(full); + } + } + return out; +} + +function slugFor(relPath: string): string { + // 'concepts/overview.md' -> 'concepts/overview' + // 'llms.txt' -> 'llms' + return relPath.replace(/\.(md|txt)$/, ''); +} + +// ────────────────────────────────────────────────────────────────────────── +// Main IO +// ────────────────────────────────────────────────────────────────────────── + +function main(): void { + const framework = process.argv[2]; + if (!framework || !isFramework(framework)) { + console.error(`Usage: node --import tsx copy-package-docs.ts `); + process.exit(1); + } + + const sourceDir = join(SITE_DIR, 'dist', 'docs', 'framework', framework); + if (!existsSync(sourceDir)) { + console.error(`✗ ${sourceDir} not found — run \`pnpm build:site\` first.`); + process.exit(1); + } + + const targetDir = join(WORKSPACE_ROOT, 'packages', framework, 'docs'); + const version = process.env.npm_package_version; + + rmSync(targetDir, { recursive: true, force: true }); + mkdirSync(targetDir, { recursive: true }); + + const files = walk(sourceDir); + for (const sourcePath of files) { + const relPath = posix.relative(sourceDir.split(/[\\/]/).join('/'), sourcePath.split(/[\\/]/).join('/')); + const slug = slugFor(relPath); + const raw = readFileSync(sourcePath, 'utf-8'); + const transformed = rewriteLinks(stripFooter(raw), slug, framework); + const destPath = join(targetDir, relPath); + mkdirSync(dirname(destPath), { recursive: true }); + writeFileSync(destPath, transformed, 'utf-8'); + } + + writeFileSync(join(targetDir, 'README.md'), synthesizeReadme({ framework, version }), 'utf-8'); + + console.log(`✓ Copied ${files.length} doc files to packages/${framework}/docs/`); +} + +const isEntrypoint = process.argv[1] && resolve(process.argv[1]) === resolve(__filename); +if (isEntrypoint) { + main(); +} diff --git a/site/scripts/tests/copy-package-docs.test.ts b/site/scripts/tests/copy-package-docs.test.ts new file mode 100644 index 00000000..6ebebd5c --- /dev/null +++ b/site/scripts/tests/copy-package-docs.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; +import { type Framework, rewriteLinks, stripFooter, synthesizeReadme } from '../copy-package-docs.ts'; + +describe('stripFooter', () => { + it('removes a page-style breadcrumb footer', () => { + const input = [ + '# Installation', + '', + 'Body content.', + '', + '---', + '', + 'React documentation: https://videojs.org/docs/framework/react/llms.txt', + 'All documentation: https://videojs.org/llms.txt', + '', + ].join('\n'); + + expect(stripFooter(input)).toBe(['# Installation', '', 'Body content.'].join('\n')); + }); + + it('removes an index-style breadcrumb footer (no framework line)', () => { + const input = ['# Index', '- entry', '', '---', '', 'All documentation: https://videojs.org/llms.txt', ''].join( + '\n' + ); + + expect(stripFooter(input)).toBe(['# Index', '- entry'].join('\n')); + }); + + it('leaves content without a footer unchanged', () => { + const input = '# Heading\n\nBody.'; + expect(stripFooter(input)).toBe(input); + }); +}); + +describe('rewriteLinks', () => { + it('rewrites an absolute same-framework .md link to a relative path', () => { + const input = '- [Installation](https://videojs.org/docs/framework/react/how-to/installation.md): desc'; + expect(rewriteLinks(input, 'llms', 'react')).toBe('- [Installation](./how-to/installation.md): desc'); + }); + + it('rewrites a root-relative same-framework link with a trailing slash', () => { + const input = 'See [Play Button](/docs/framework/react/reference/play-button/) for details.'; + expect(rewriteLinks(input, 'concepts/overview', 'react')).toBe( + 'See [Play Button](../reference/play-button.md) for details.' + ); + }); + + it('rewrites a sibling-page link from inside a subdirectory', () => { + const input = '[Skins](https://videojs.org/docs/framework/html/concepts/skins.md)'; + expect(rewriteLinks(input, 'concepts/overview', 'html')).toBe('[Skins](./skins.md)'); + }); + + it('preserves a fragment when rewriting', () => { + const input = '[Section](https://videojs.org/docs/framework/react/reference/play-button.md#props)'; + expect(rewriteLinks(input, 'llms', 'react')).toBe('[Section](./reference/play-button.md#props)'); + }); + + it('does not touch links to a different framework', () => { + const input = '[HTML docs](https://videojs.org/docs/framework/html/how-to/installation.md)'; + expect(rewriteLinks(input, 'llms', 'react')).toBe(input); + }); + + it('does not touch links to non-docs site pages', () => { + const input = 'See the [blog](https://videojs.org/blog/post.md) for context.'; + expect(rewriteLinks(input, 'llms', 'react')).toBe(input); + }); + + it('preserves the .txt extension when rewriting a link to llms.txt', () => { + const input = '[index](https://videojs.org/docs/framework/react/llms.txt)'; + expect(rewriteLinks(input, 'how-to/build-with-ai', 'react')).toBe('[index](../llms.txt)'); + }); + + it('leaves bare framework-root URLs alone (empty slug)', () => { + const input = '[Docs](https://videojs.org/docs/framework/react/)'; + expect(rewriteLinks(input, 'how-to/build-with-ai', 'react')).toBe(input); + }); + + it('does not rewrite URLs that appear inside link text (e.g. code spans)', () => { + // The link text contains a URL-shaped string inside backticks; only the + // target inside `(...)` should be rewritten. + const input = '[`videojs.org/docs/framework/react/llms.txt`](https://videojs.org/docs/framework/react/llms.txt)'; + expect(rewriteLinks(input, 'how-to/build-with-ai', 'react')).toBe( + '[`videojs.org/docs/framework/react/llms.txt`](../llms.txt)' + ); + }); +}); + +describe('synthesizeReadme', () => { + it('renders the react cold-start file with a version', () => { + const out = synthesizeReadme({ framework: 'react', version: '10.0.0-beta.23' }); + expect(out).toContain('# @videojs/react documentation'); + expect(out).toContain('Bundled markdown documentation for `@videojs/react` v10.0.0-beta.23.'); + expect(out).toContain('Start at [`./llms.txt`](./llms.txt)'); + expect(out).toContain('Canonical online version: https://videojs.org/docs/framework/react'); + }); + + it('renders the html cold-start file', () => { + const out = synthesizeReadme({ framework: 'html', version: '10.0.0-beta.23' }); + expect(out).toContain('# @videojs/html documentation'); + expect(out).toContain('Canonical online version: https://videojs.org/docs/framework/html'); + }); + + it('omits the version suffix when version is unknown', () => { + const out = synthesizeReadme({ framework: 'react', version: undefined }); + expect(out).toContain('Bundled markdown documentation for `@videojs/react`.'); + expect(out).not.toContain('undefined'); + }); + + it('throws on an unsupported framework', () => { + expect(() => + synthesizeReadme({ + framework: 'svelte' as unknown as Framework, + version: '1.0.0', + }) + ).toThrow(); + }); +});