mirror of
https://github.com/zoriya/v10.git
synced 2026-08-05 05:37:21 +00:00
feat(packages): ship bundled markdown docs in html and react tarballs (#1560)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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/<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/<framework>/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<framework> 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 <html|react>`);
|
||||
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();
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user