Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions apps/docsite/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,40 @@ This means:
- No hand-maintained arrays of component names; use `componentRegistry`
- No `if (pkg === '@astryxdesign/core')` switches; let the pipeline classify packages

## Versioned Content: latest vs canary

The docsite ships one codebase deployed from `main`, but the data pipeline can
read package docs from two different sources depending on the build **target**.
The two targets mirror the two npm dist-tags the packages publish under:

| Target | npm dist-tag | Reads package docs from | Deploys |
| -------- | ------------ | ---------------------------------- | -------------------------------------- |
| `latest` | `@latest` | the last **published** npm release | production (`astryx.atmeta.com`) |
| `canary` | `@canary` | the live monorepo (`main`, WIP) | the canary site + **every PR preview** |

The target is derived from Vercel's `VERCEL_ENV`: the production deploy is
`latest`; preview deploys (the `main` canary site and all PR previews) and local
dev are `canary`. `scripts/resolve-content-root.mjs` maps the target to the
filesystem root the pipeline reads from — for `latest` it downloads the
published package tarballs from npm (their `src/` ships the `.doc.mjs` the
pipeline needs); for `canary` it reads the live workspace. Only the documented
**data** is version-pinned — CLI template demos are live-rendered React and
always resolve `@astryxdesign/core` from the bundled workspace version.

> **⚠️ Contributor gotcha: library changes don't show on the production site
> until they're released.** Production (`astryx.atmeta.com`) documents the last
> **published** release. If you add a component, change a prop, or update docs
> and merge to `main`, those changes will **not** appear in production until the
> next release publishes to npm. To see your merged change in a deployed site,
> use the **canary** site or any **PR preview** (both read `main`) — or the
> "Canary docs" link in the footer. The canary banner links back to
> production.

**Local dev is unaffected.** `pnpm dev` has no `VERCEL_ENV`, so it defaults to
`canary` and reads the live workspace — your local changes show up as expected.
To preview the `latest` view locally, run the pipeline with
`DOCSITE_TARGET=latest` (needs network to fetch the published tarballs).

## Adding a New Theme

1. Create the theme package under `packages/themes/<name>/`
Expand Down
39 changes: 28 additions & 11 deletions apps/docsite/scripts/generate-data.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,29 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import {fileURLToPath, pathToFileURL} from 'node:url';
import {resolveContentRoot} from './resolve-content-root.mjs';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DOCSITE_ROOT = path.resolve(__dirname, '..');
const REPO_ROOT = path.resolve(DOCSITE_ROOT, '..', '..');
const OUT_DIR = path.join(DOCSITE_ROOT, 'src', 'generated');
const CLI_ROOT = path.join(REPO_ROOT, 'packages', 'cli');

// Which version of the packages supplies the documented DATA (component
// .doc.mjs, package.json versions, READMEs). `canary` (and every PR preview)
// reads the live workspace; `latest` (production) reads the published release.
// CLI_ROOT is deliberately NOT pinned — template demos are live-rendered React
// against the bundled core, so they always come from the workspace.
const {
target: DOCSITE_TARGET,
contentRoot: CONTENT_ROOT,
cliRoot: CLI_ROOT,
} = resolveContentRoot();

console.log(
`Docsite content target: ${DOCSITE_TARGET} (reading package docs from ${
path.relative(REPO_ROOT, CONTENT_ROOT) || '.'
})`,
);

fs.mkdirSync(OUT_DIR, {recursive: true});

Expand Down Expand Up @@ -191,7 +208,7 @@ function findDocFilesRecursive(dir) {
* Falls back to the package name when no explicit export is found.
*/
function resolveImportPathForPkg(pkgDir, directory) {
const pkgJsonPath = path.join(REPO_ROOT, pkgDir, 'package.json');
const pkgJsonPath = path.join(CONTENT_ROOT, pkgDir, 'package.json');
if (!fs.existsSync(pkgJsonPath)) return null;
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
if (pkg.exports && pkg.exports[`./${directory}`]) {
Expand All @@ -209,7 +226,7 @@ function resolveImportPathForPkg(pkgDir, directory) {
* Skips apps/* and internal/* — only surfaces packages/*.
*/
function discoverPackageDirs() {
const rootPkg = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf-8'));
const rootPkg = JSON.parse(fs.readFileSync(path.join(CONTENT_ROOT, 'package.json'), 'utf-8'));
const workspaces = rootPkg.workspaces || [];
const dirs = [];

Expand All @@ -219,7 +236,7 @@ function discoverPackageDirs() {

// Expand glob: packages/* or packages/themes/*
const base = pattern.replace('/*', '');
const baseDir = path.join(REPO_ROOT, base);
const baseDir = path.join(CONTENT_ROOT, base);
if (!fs.existsSync(baseDir)) continue;

for (const entry of fs.readdirSync(baseDir, {withFileTypes: true})) {
Expand All @@ -245,7 +262,7 @@ function generatePackageRegistry() {

const packages = packageDirs
.map(dir => {
const pkgPath = path.join(REPO_ROOT, dir, 'package.json');
const pkgPath = path.join(CONTENT_ROOT, dir, 'package.json');
const raw = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));

// Skip private packages
Expand All @@ -254,13 +271,13 @@ function generatePackageRegistry() {
// Skip packages not installed in the docsite
if (docsiteDeps[raw.name] == null) return null;

const hasReadme = fs.existsSync(path.join(REPO_ROOT, dir, 'README.md'));
const hasChangelog = fs.existsSync(path.join(REPO_ROOT, dir, 'CHANGELOG.md'));
const hasReadme = fs.existsSync(path.join(CONTENT_ROOT, dir, 'README.md'));
const hasChangelog = fs.existsSync(path.join(CONTENT_ROOT, dir, 'CHANGELOG.md'));
const readme = hasReadme
? fs.readFileSync(path.join(REPO_ROOT, dir, 'README.md'), 'utf-8')
? fs.readFileSync(path.join(CONTENT_ROOT, dir, 'README.md'), 'utf-8')
: null;
const changelog = hasChangelog
? fs.readFileSync(path.join(REPO_ROOT, dir, 'CHANGELOG.md'), 'utf-8')
? fs.readFileSync(path.join(CONTENT_ROOT, dir, 'CHANGELOG.md'), 'utf-8')
: null;
return {
name: raw.name,
Expand Down Expand Up @@ -321,9 +338,9 @@ async function generateComponentRegistry() {
const componentPackages = [];

for (const dir of packageDirs) {
const srcDir = path.join(REPO_ROOT, dir, 'src');
const srcDir = path.join(CONTENT_ROOT, dir, 'src');
if (!fs.existsSync(srcDir)) continue;
const pkgJson = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, dir, 'package.json'), 'utf-8'));
const pkgJson = JSON.parse(fs.readFileSync(path.join(CONTENT_ROOT, dir, 'package.json'), 'utf-8'));
const allDocFiles = findDocFilesRecursive(srcDir);
if (allDocFiles.length > 0) {
componentPackages.push({name: pkgJson.name, srcDir, dir});
Expand Down
253 changes: 253 additions & 0 deletions apps/docsite/scripts/resolve-content-root.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
#!/usr/bin/env node
// Copyright (c) Meta Platforms, Inc. and affiliates.

/**
* @file resolve-content-root.mjs
*
* Resolves the CONTENT ROOT the docsite data pipeline reads package
* documentation from (component .doc.mjs prop tables, package.json versions,
* READMEs, CHANGELOGs).
*
* The docsite CODE is always the current checkout. What varies per deploy is
* *which version of the @astryxdesign/* packages* supplies that documentation:
*
* canary → the live monorepo workspace (REPO_ROOT). Documents main / WIP.
* Used for the canary deploy AND every PR preview.
* latest → the last PUBLISHED release on npm. Documents exactly what
* `npm install` gives a consumer today. Used for production.
*
* Target selection (no manual config in the common case):
* - Explicit override: DOCSITE_TARGET=latest|canary always wins.
* - Otherwise derived from Vercel's VERCEL_ENV: `production` → latest,
* everything else (preview, development, unset) → canary.
*
* How `latest` sources are materialized: the version is read live from npm
* (`npm view @astryxdesign/core version`) so it always tracks the current
* published release with nothing to hand-maintain. Each docsite @astryxdesign/*
* dependency's published tarball is downloaded and unpacked into a cache dir
* laid out like the monorepo, so generate-data.mjs's workspace discovery works
* unchanged. The published tarballs ship `src/` (see each package's
* package.json "files"), so the .doc.mjs docs the pipeline reads are present.
*
* This module is the SINGLE place that maps a target to a filesystem root.
* generate-data.mjs consumes CONTENT_ROOT from here; every generated registry
* keeps its exact shape, so no consuming page code changes.
*/

import * as fs from 'node:fs';
import * as path from 'node:path';
import {execFileSync} from 'node:child_process';
import * as os from 'node:os';
import {fileURLToPath} from 'node:url';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DOCSITE_ROOT = path.resolve(__dirname, '..');
const REPO_ROOT = path.resolve(DOCSITE_ROOT, '..', '..');

/** The npm dist-tag whose version `latest` documents. */
const PUBLISHED_DIST_TAG = 'latest';
/** Package whose published version defines the release the docsite pins to. */
const VERSION_SOURCE_PKG = '@astryxdesign/core';

function run(cmd, args, opts = {}) {
return execFileSync(cmd, args, {encoding: 'utf-8', ...opts});
}

/**
* Resolve the build target. Explicit DOCSITE_TARGET wins; otherwise derive from
* VERCEL_ENV (production → latest, else canary).
*/
export function getTarget() {
const explicit = (process.env.DOCSITE_TARGET || '').trim();
if (explicit) {
if (explicit !== 'canary' && explicit !== 'latest') {
throw new Error(
`Invalid DOCSITE_TARGET="${explicit}". Expected "canary" or "latest".`,
);
}
return explicit;
}
return process.env.VERCEL_ENV === 'production' ? 'latest' : 'canary';
}

/**
* The docsite's @astryxdesign/* dependencies that are documented on the
* `latest` (production) site: the ones actually published to the stable npm
* `latest` dist-tag. Mapped to the monorepo-relative dir each occupies (so the
* materialized cache mirrors REPO_ROOT's layout — themes under
* packages/themes/<name>, everything else under packages/<name>).
*
* Packages that never reach the stable tag are EXCLUDED from `latest`:
* - `private` packages, and
* - `astryx.canaryOnly` packages (e.g. @astryxdesign/charts, @astryxdesign/lab)
* — these publish only as canaries, so no stable version exists to document.
* This mirrors the publishable predicate in .github/workflows/release.yml's
* stable-publish step, so production documents exactly the stable release set.
* (On canary these still appear, sourced from the workspace like everything else.)
*/
function latestPublishablePackages() {
const pkg = JSON.parse(
fs.readFileSync(path.join(DOCSITE_ROOT, 'package.json'), 'utf-8'),
);
const deps = {...pkg.dependencies, ...pkg.devDependencies};
return Object.keys(deps)
.filter(name => name.startsWith('@astryxdesign/'))
.map(name => {
const short = name.slice('@astryxdesign/'.length);
const dir = short.startsWith('theme-')
? path.join('packages', 'themes', short.slice('theme-'.length))
: path.join('packages', short);
return {name, dir};
})
.filter(({dir}) => {
// Read the package's own workspace manifest for its publish flags. Same
// rule release.yml uses for the stable `latest` tag.
const manifestPath = path.join(REPO_ROOT, dir, 'package.json');
if (!fs.existsSync(manifestPath)) {
return false;
}
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
return manifest.private !== true && manifest.astryx?.canaryOnly !== true;
});
}

/** Latest published version of the version-source package, read live from npm. */
function latestPublishedVersion() {
const out = run(
'npm',
[
'view',
`${VERSION_SOURCE_PKG}@${PUBLISHED_DIST_TAG}`,
'version',
'--json',
],
{maxBuffer: 8 * 1024 * 1024},
).trim();
// `npm view … version` prints a bare quoted string for a single match.
const version = JSON.parse(out);
if (typeof version !== 'string' || !version) {
throw new Error(
`Could not resolve a published version for ${VERSION_SOURCE_PKG}@${PUBLISHED_DIST_TAG}.`,
);
}
return version;
}

/**
* Materialize the published package sources for `latest` into a cache dir by
* downloading each package's PUBLISHED TARBALL from npm.
*
* Layout produced (mirrors a real monorepo release so generate-data.mjs's
* workspace discovery works unchanged):
* <cache>/package.json — synthetic root (workspaces globs)
* <cache>/packages/core/… — @astryxdesign/core tarball
* <cache>/packages/themes/<name>/… — @astryxdesign/theme-<name> tarballs
*/
function materializeFromNpm(version, packages) {
const cacheRoot = path.join(DOCSITE_ROOT, '.content-cache', `npm-${version}`);
const stamp = path.join(cacheRoot, '.stamp');
const stampValue = `npm-${version}:${packages
.map(p => p.name)
.sort()
.join(',')}`;
if (
fs.existsSync(stamp) &&
fs.readFileSync(stamp, 'utf-8').trim() === stampValue
) {
return cacheRoot;
}
fs.rmSync(cacheRoot, {recursive: true, force: true});
fs.mkdirSync(cacheRoot, {recursive: true});

// Synthetic root package.json so discoverPackageDirs() expands the same
// workspace globs it would in the real monorepo.
fs.writeFileSync(
path.join(cacheRoot, 'package.json'),
JSON.stringify(
{
name: 'astryx-pinned-content',
private: true,
version,
workspaces: ['packages/*', 'packages/themes/*'],
},
null,
2,
),
);

const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'astryx-pin-'));
try {
for (const {name, dir} of packages) {
const spec = `${name}@${version}`;
// `npm pack` downloads the exact published tarball and prints its
// filename; --pack-destination keeps it out of the cwd.
//
// Run it from the tmp dir (OUTSIDE the repo), NOT from the workspace: the
// root package.json declares `devEngines.packageManager: pnpm`, which
// makes npm hard-error EBADDEVENGINES if it discovers that manifest by
// walking up from the cwd. The tmp dir has no package.json, so npm skips
// the engine guard. (COREPACK_ENABLE_STRICT=0 as belt-and-suspenders.)
const out = run(
'npm',
['pack', spec, '--pack-destination', tmp, '--json'],
{
cwd: tmp,
maxBuffer: 256 * 1024 * 1024,
env: {...process.env, COREPACK_ENABLE_STRICT: '0'},
},
);
const tgz = path.join(tmp, JSON.parse(out)[0].filename);
const dest = path.join(cacheRoot, dir);
fs.mkdirSync(dest, {recursive: true});
// npm tarballs wrap everything under a top-level "package/" dir.
run('tar', ['-x', '-z', '-f', tgz, '--strip-components=1', '-C', dest]);
}
} finally {
fs.rmSync(tmp, {recursive: true, force: true});
}

fs.writeFileSync(stamp, stampValue);
return cacheRoot;
}

/**
* Returns {target, contentRoot, cliRoot, version} for the current target.
* contentRoot mirrors REPO_ROOT layout (has packages/* and package.json).
*/
export function resolveContentRoot() {
const target = getTarget();

if (target === 'canary') {
return {
target,
contentRoot: REPO_ROOT,
cliRoot: path.join(REPO_ROOT, 'packages', 'cli'),
version: null,
};
}

// latest
const version = latestPublishedVersion();
const packages = latestPublishablePackages();
const contentRoot = materializeFromNpm(version, packages);
return {
target,
// DATA (component .doc.mjs prop tables, package.json versions, READMEs) is
// read from the published release — that's the point of `latest`.
contentRoot,
// EXECUTABLE content (CLI templates: showcases, examples, blocks, pages,
// long-form docs) is LIVE-RENDERED as React and always resolves
// @astryxdesign/core from node_modules — i.e. the version the docsite
// bundles. Pinning it would make a stale release's demo use API the bundled
// core no longer exposes, breaking render AND typecheck. So it always comes
// from the real workspace; only the documented DATA is pinned.
cliRoot: path.join(REPO_ROOT, 'packages', 'cli'),
version,
};
}

// CLI usage: `node resolve-content-root.mjs` prints the resolution as JSON.
if (import.meta.url === `file://${process.argv[1]}`) {
const r = resolveContentRoot();
process.stdout.write(JSON.stringify(r, null, 2) + '\n');
}
Loading
Loading