From 4fae5c790675bc4f537eeed58c4f0ffea151eacf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20S=C3=A1ros?= Date: Fri, 27 Feb 2026 14:24:36 +0100 Subject: [PATCH] docs: add multi-version support for docs --- docs/contributor-docs/docs-versioning.md | 62 ++++ packages/__docs__/buildScripts/DataTypes.mts | 16 +- packages/__docs__/buildScripts/build-docs.mts | 264 +++++++++++++----- .../__docs__/buildScripts/processFile.mts | 10 +- .../buildScripts/utils/buildVersionMap.mts | 151 ++++++++++ .../__docs__/buildScripts/watch-markdown.mjs | 92 +++++- packages/__docs__/component-overrides.ts | 161 +++++++++++ packages/__docs__/globals.ts | 13 + packages/__docs__/src/App/index.tsx | 173 +++++++++--- packages/__docs__/src/App/props.ts | 4 + packages/__docs__/src/Document/index.tsx | 15 +- packages/__docs__/src/Document/props.ts | 2 + packages/__docs__/src/Header/index.tsx | 77 ++++- packages/__docs__/src/Header/props.ts | 14 +- packages/__docs__/src/versionData.ts | 53 +++- packages/__docs__/tsconfig.build.json | 1 + 16 files changed, 980 insertions(+), 128 deletions(-) create mode 100644 docs/contributor-docs/docs-versioning.md create mode 100644 packages/__docs__/buildScripts/utils/buildVersionMap.mts create mode 100644 packages/__docs__/component-overrides.ts diff --git a/docs/contributor-docs/docs-versioning.md b/docs/contributor-docs/docs-versioning.md new file mode 100644 index 0000000000..01391b85c6 --- /dev/null +++ b/docs/contributor-docs/docs-versioning.md @@ -0,0 +1,62 @@ +# Multi-Version Documentation + +The docs site supports showing documentation for multiple library minor versions (e.g. v11.5, v11.6). This allows users to switch between versions in the UI and see the correct component implementations and documentation for each. + +## How it works + +### Build pipeline + +1. `buildScripts/utils/buildVersionMap.mts` scans package `exports` fields to discover which library versions exist (e.g. `v11_5`, `v11_6`). +2. `buildScripts/build-docs.mts` processes all source/markdown files once, then filters them per library version using the version map. Each version gets its own output directory (`__build__/docs/v11_5/`, `__build__/docs/v11_6/`). +3. A `docs-versions.json` manifest is written with `libraryVersions` and `defaultVersion`. + +### Runtime (client) + +1. On load, the App fetches `docs-versions.json` to discover available minor versions. +2. The Header renders a version dropdown if multiple versions exist. +3. When the user switches versions: + - `updateGlobalsForVersion(version)` in `globals.ts` re-populates the global scope with the correct component implementations (so interactive code examples use the right version). + - The App re-fetches `docs/{version}/markdown-and-sources-data.json` for the documentation data. +4. `getComponentsForVersion(version)` in `component-overrides.ts` returns the full component map: default components merged with any version-specific overrides. + +### Component overrides + +`packages/__docs__/component-overrides.ts` is the single source of truth for version-specific component differences. It works by: + +1. Importing `DefaultComponents` from `./components` (these are the v11_5 / baseline components). +2. Importing only the components that **differ** in a given version from their versioned subpath (e.g. `@instructure/ui-avatar/v11_6`). +3. Building an override map per version. +4. `getComponentsForVersion(version)` spreads overrides on top of defaults. + +Versions with no overrides (like `v11_5`) simply return the defaults. + +## Adding a new library version (e.g. v11_7) + +1. **Ensure packages export the new subpath.** Each package that has version-specific code must have a `/v11_7` export in its `package.json` `exports` field. + +2. **Update `component-overrides.ts`:** + - Add import statements for the components that differ in v11_7 (use `_v11_7` suffix aliases). + - Create a `v11_7_overrides` object mapping component names to the new imports. + - Add `v11_7: v11_7_overrides` to the `overridesByVersion` registry. + +3. **Rebuild docs:** `pnpm run build:docs` — the build will automatically discover the new version via the version map and generate output for it. + +## Migrating a component to v2 within an existing version + +If a component (e.g. `Checkbox`) is refactored to a functional component for v11_6: + +1. Add the v2 implementation under the package's versioned directory (e.g. `packages/ui-checkbox/src/Checkbox/v2/`). +2. Update the package's `exports` so `/v11_6` re-exports the v2 implementation. +3. Add the import and override entry in `component-overrides.ts` under the `v11_6` section. + +## Key files + +| File | Purpose | +|------|---------| +| `packages/__docs__/components.ts` | Default component exports (baseline / v11_5) | +| `packages/__docs__/component-overrides.ts` | Version-specific overrides + `getComponentsForVersion()` | +| `packages/__docs__/globals.ts` | Populates global scope for interactive examples | +| `packages/__docs__/src/App/index.tsx` | Docs app — handles version switching | +| `packages/__docs__/src/versionData.ts` | Fetches version manifest at runtime | +| `packages/__docs__/buildScripts/build-docs.mts` | Build pipeline — generates per-version JSON | +| `packages/__docs__/buildScripts/utils/buildVersionMap.mts` | Discovers versions from package exports | diff --git a/packages/__docs__/buildScripts/DataTypes.mts b/packages/__docs__/buildScripts/DataTypes.mts index 9eddf07f87..a5fdeeb7ab 100644 --- a/packages/__docs__/buildScripts/DataTypes.mts +++ b/packages/__docs__/buildScripts/DataTypes.mts @@ -31,7 +31,7 @@ type ProcessedFile = YamlMetaInfo & JsDocResult & PackagePathData & - { title: string, id:string } + { title: string, id:string, componentVersion?: string } type PackagePathData = { extension: string @@ -146,6 +146,16 @@ type MainDocsData = { library: LibraryOptions } & ParsedDoc +type VersionMapEntry = { + exportLetter: string + componentVersion: string +} + +type VersionMap = { + libraryVersions: string[] + mapping: Record> +} + export type { ProcessedFile, PackagePathData, @@ -156,5 +166,7 @@ export type { MainDocsData, MainIconsData, JsDocResult, - Section + Section, + VersionMapEntry, + VersionMap } diff --git a/packages/__docs__/buildScripts/build-docs.mts b/packages/__docs__/buildScripts/build-docs.mts index e6a276969c..c7c5e5d400 100644 --- a/packages/__docs__/buildScripts/build-docs.mts +++ b/packages/__docs__/buildScripts/build-docs.mts @@ -36,9 +36,11 @@ import { import type { LibraryOptions, MainDocsData, - ProcessedFile + ProcessedFile, + VersionMap } from './DataTypes.mjs' import { getFrontMatter } from './utils/getFrontMatter.mjs' +import { buildVersionMap } from './utils/buildVersionMap.mjs' import { createRequire } from 'module' import { fileURLToPath, pathToFileURL } from 'url' import { generateAIAccessibleMarkdowns } from './ai-accessible-documentation/generate-ai-accessible-markdowns.mjs' @@ -74,10 +76,9 @@ const pathsToProcess = [ 'LICENSE.md', '**/docs/**/*.md', // general docs '**/src/*.{ts,tsx}', // util src files - // TODO expand this to support new components - '**/src/*/v1/*.md', // package READMEs - '**/src/*/v1/*.{ts,tsx}', // component src files - '**/src/*/v1/*/*.{ts,tsx}' // child component src files + '**/src/*/v*/*.md', // package READMEs (all versions) + '**/src/*/v*/*.{ts,tsx}', // component src files (all versions) + '**/src/*/v*/*/*.{ts,tsx}' // child component src files (all versions) ] const pathsToIgnore = [ @@ -122,7 +123,52 @@ if (import.meta.url === pathToFileURL(process.argv[1]).href) { buildDocs() } -function buildDocs() { +/** + * Extracts the package short name (e.g. 'ui-avatar') from a doc's relativePath. + * Returns undefined for files that are not inside a ui-* package. + */ +function getPackageShortName(relativePath: string): string | undefined { + const match = relativePath.match(/packages\/(ui-[^/]+)\//) + return match ? match[1] : undefined +} + +/** + * Filters processed docs for a specific library version using the version map. + * - Docs with no componentVersion (general docs, utils, CHANGELOG) → included in all versions + * - Versioned docs → only included if their componentVersion matches the expected + * version for this library version in the version map + */ +function filterDocsForVersion( + allDocs: ProcessedFile[], + libVersion: string, + versionMap: VersionMap +): ProcessedFile[] { + const versionMapping = versionMap.mapping[libVersion] || {} + + return allDocs.filter((doc) => { + // Non-versioned docs go into all versions + if (!doc.componentVersion) { + return true + } + + const pkgShortName = getPackageShortName(doc.relativePath) + if (!pkgShortName) { + // Not a package file, include it + return true + } + + const entry = versionMapping[pkgShortName] + if (!entry) { + // Package not in the version map (e.g. no multi-version exports). + // Include the doc if it's v1 (the default/only version) + return doc.componentVersion === 'v1' + } + + return doc.componentVersion === entry.componentVersion + }) +} + +async function buildDocs() { // eslint-disable-next-line no-console console.log('Start building application data') console.time('docs build time') @@ -130,87 +176,146 @@ function buildDocs() { const { COPY_VERSIONS_JSON = '1' } = process.env const shouldDoTheVersionCopy = Boolean(parseInt(COPY_VERSIONS_JSON)) - // globby needs the posix format - const files = pathsToProcess.map((file) => path.posix.join(packagesDir, file)) - const ignore = pathsToIgnore.map((file) => path.posix.join(packagesDir, file)) - globby(files, { ignore }) - .then((matches) => { - fs.mkdirSync(buildDir + 'docs/', { recursive: true }) + try { + // Build the version map first + // eslint-disable-next-line no-console + console.log('Building version map...') + const versionMap = await buildVersionMap(projectRoot) + // eslint-disable-next-line no-console + console.log( + `Found library versions: ${versionMap.libraryVersions.join(', ')}` + ) + + // globby needs the posix format + const files = pathsToProcess.map((file) => + path.posix.join(packagesDir, file) + ) + const ignore = pathsToIgnore.map((file) => + path.posix.join(packagesDir, file) + ) + const matches = await globby(files, { ignore }) + + fs.mkdirSync(buildDir + 'docs/', { recursive: true }) + // eslint-disable-next-line no-console + console.log( + 'Parsing markdown and source files... (' + matches.length + ' files)' + ) + const allDocs = matches + .map((relativePath) => parseSingleFile(path.resolve(relativePath))) + .filter(Boolean) as ProcessedFile[] + + const themes = parseThemes() + const defaultVersion = + versionMap.libraryVersions[versionMap.libraryVersions.length - 1] + + // Build per-version output, caching the default version result + let defaultMainDocsData: MainDocsData | undefined + for (const libVersion of versionMap.libraryVersions) { + const versionBuildDir = buildDir + 'docs/' + libVersion + '/' + fs.mkdirSync(versionBuildDir, { recursive: true }) + + const versionDocs = filterDocsForVersion(allDocs, libVersion, versionMap) // eslint-disable-next-line no-console console.log( - 'Parsing markdown and source files... (' + matches.length + ' files)' + `Building docs for ${libVersion}: ${versionDocs.length} docs` ) - let docs = matches.map((relativePath) => { - // loop through every source and Readme file - return processSingleFile(path.resolve(relativePath)) - }) - docs = docs.filter(Boolean) // filter out undefined - - const themes = parseThemes() - const clientProps = getClientProps(docs as ProcessedFile[], library) + + const clientProps = getClientProps(versionDocs, library) const mainDocsData: MainDocsData = { ...clientProps, - themes: themes, + themes, library } - const markdownsAndSources = JSON.stringify(mainDocsData) + + if (libVersion === defaultVersion) { + defaultMainDocsData = mainDocsData + } + + // Write markdown-and-sources-data.json for this version fs.writeFileSync( - buildDir + 'markdown-and-sources-data.json', - markdownsAndSources + versionBuildDir + 'markdown-and-sources-data.json', + JSON.stringify(mainDocsData) ) - generateAIAccessibleMarkdowns( - buildDir + 'docs/', - buildDir + 'markdowns/' - ) + // Write individual doc JSONs for this version + for (const doc of versionDocs) { + fs.writeFileSync( + versionBuildDir + doc.id + '.json', + JSON.stringify(doc) + ) + } + } - const parentOfDocs = path.dirname(buildDir + 'docs/') + // Backward-compatible root output (uses default/highest version) + fs.writeFileSync( + buildDir + 'markdown-and-sources-data.json', + JSON.stringify(defaultMainDocsData) + ) - generateAIAccessibleLlmsFile( - buildDir + 'markdown-and-sources-data.json', - { - outputFilePath: path.join(parentOfDocs, 'llms.txt'), - baseUrl: 'https://instructure.design/markdowns/', - summariesFilePath: path.join(__dirname, '../buildScripts/ai-accessible-documentation/summaries-for-llms-file.json') - } - ) + // Write version manifest (client only needs versions + default, not the full map) + const docsVersionsManifest = { + libraryVersions: versionMap.libraryVersions, + defaultVersion + } + fs.writeFileSync( + buildDir + 'docs-versions.json', + JSON.stringify(docsVersionsManifest) + ) + // eslint-disable-next-line no-console + console.log('Wrote docs-versions.json') - // eslint-disable-next-line no-console - console.log('Copying icons data...') - fs.copyFileSync( - projectRoot + '/packages/ui-icons/src/__build__/icons-data.json', - buildDir + 'icons-data.json' - ) + // Generate AI accessible documentation from default version + const defaultVersionDocsDir = buildDir + 'docs/' + defaultVersion + '/' + generateAIAccessibleMarkdowns(defaultVersionDocsDir, buildDir + 'markdowns/') - // eslint-disable-next-line no-console - console.log('Finished building documentation data') - }) - .then(() => { - console.timeEnd('docs build time') - if (shouldDoTheVersionCopy) { - // eslint-disable-next-line no-console - console.log('Copying versions.json into __build__ folder') - const versionFilePath = path.resolve(__dirname, '..', 'versions.json') - const buildDirPath = path.resolve(__dirname, '..', '__build__') - - return fs.promises.copyFile( - versionFilePath, - `${buildDirPath}/versions.json` + generateAIAccessibleLlmsFile( + buildDir + 'markdown-and-sources-data.json', + { + outputFilePath: path.join(buildDir, 'llms.txt'), + baseUrl: 'https://instructure.design/markdowns/', + summariesFilePath: path.join( + __dirname, + '../buildScripts/ai-accessible-documentation/summaries-for-llms-file.json' ) } - return undefined - }) - .catch((error: Error) => { - throw Error( - `Error when generating documentation data: ${error}\n${error.stack}` + ) + + // eslint-disable-next-line no-console + console.log('Copying icons data...') + fs.copyFileSync( + projectRoot + '/packages/ui-icons/src/__build__/icons-data.json', + buildDir + 'icons-data.json' + ) + + // eslint-disable-next-line no-console + console.log('Finished building documentation data') + + console.timeEnd('docs build time') + + if (shouldDoTheVersionCopy) { + // eslint-disable-next-line no-console + console.log('Copying versions.json into __build__ folder') + const versionFilePath = path.resolve(__dirname, '..', 'versions.json') + const buildDirPath = path.resolve(__dirname, '..', '__build__') + + await fs.promises.copyFile( + versionFilePath, + `${buildDirPath}/versions.json` ) - }) + } + } catch (error: unknown) { + const err = error as Error + throw Error( + `Error when generating documentation data: ${err}\n${err.stack}` + ) + } } -// This function is also called by Webpack if a file changes -// TODO this parses some files twice, its needed for the Webpack watcher but not -// for the full build. -function processSingleFile(fullPath: string) { +/** + * Parses a single file and returns a ProcessedFile, or undefined if it + * should be skipped. Pure parsing — no file writes. + */ +function parseSingleFile(fullPath: string) { let docObject const dirName = path.dirname(fullPath) const fileName = path.parse(fullPath).name @@ -230,7 +335,7 @@ function processSingleFile(fullPath: string) { let componentIndexFile: string | undefined if (fs.existsSync(path.join(dirName, 'index.tsx'))) { componentIndexFile = path.join(dirName, 'index.tsx') - } else if (fs.existsSync(dirName + 'index.ts')) { + } else if (fs.existsSync(path.join(dirName, 'index.ts'))) { componentIndexFile = path.join(dirName, 'index.ts') } if (componentIndexFile) { @@ -245,6 +350,15 @@ function processSingleFile(fullPath: string) { // documentation .md files, utils ts and tsx files docObject = processFile(fullPath, projectRoot, library) } + return docObject || undefined +} + +/** + * Parses a file and writes its JSON to the root build dir. + * Used by the Webpack watcher for incremental rebuilds. + */ +function processSingleFile(fullPath: string) { + const docObject = parseSingleFile(fullPath) if (!docObject) { return } @@ -254,7 +368,7 @@ function processSingleFile(fullPath: string) { } function tryParseReadme(dirName: string) { - const readme = path.join(dirName + '/README.md') + const readme = path.join(dirName, 'README.md') if (fs.existsSync(readme)) { const data = fs.readFileSync(readme) const frontMatter = getFrontMatter(data) @@ -273,4 +387,12 @@ function parseThemes() { return parsed } -export { pathsToProcess, pathsToIgnore, processSingleFile, buildDocs } +export { + pathsToProcess, + pathsToIgnore, + parseSingleFile, + processSingleFile, + buildDocs, + filterDocsForVersion, + getPackageShortName +} diff --git a/packages/__docs__/buildScripts/processFile.mts b/packages/__docs__/buildScripts/processFile.mts index ce46074e03..18cd87ebe2 100644 --- a/packages/__docs__/buildScripts/processFile.mts +++ b/packages/__docs__/buildScripts/processFile.mts @@ -52,7 +52,7 @@ export function processFile( // exist if it was in the YAML description at the top docId = docData.id } else if (lowerPath.includes(path.sep + 'index.tsx')) { - docId = docData.displayName! + docId = docData.displayName ?? path.basename(path.dirname(fullPath)) } else if (lowerPath.includes('readme.md')) { const folder = path.basename(dirName) docId = docData.describes ? folder + '__README' : folder @@ -63,5 +63,13 @@ export function processFile( if (!docData.title) { docData.title = docData.id } + + // Extract component version from the file path (e.g. /v1/ or /v2/) + const pathSegments = fullPath.split(path.sep) + const versionSegment = pathSegments.find((seg) => /^v\d+$/.test(seg)) + if (versionSegment) { + docData.componentVersion = versionSegment + } + return docData } diff --git a/packages/__docs__/buildScripts/utils/buildVersionMap.mts b/packages/__docs__/buildScripts/utils/buildVersionMap.mts new file mode 100644 index 0000000000..cd27db7bdf --- /dev/null +++ b/packages/__docs__/buildScripts/utils/buildVersionMap.mts @@ -0,0 +1,151 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import fs from 'fs' +import path from 'path' +import { globby } from 'globby' +import type { VersionMap } from '../DataTypes.mjs' + +/** + * Scans all packages/ui-* /package.json files to build a version map that + * describes which library version (e.g. v11_5, v11_6) maps to which + * export letter (a, b) and component version directory (v1, v2) per package. + */ +export async function buildVersionMap( + projectRoot: string +): Promise { + const packagesDir = path.join(projectRoot, 'packages') + const packageJsonPaths = await globby('ui-*/package.json', { + cwd: packagesDir, + absolute: true + }) + + const libraryVersionsSet = new Set() + const mapping: VersionMap['mapping'] = {} + + for (const pkgJsonPath of packageJsonPaths) { + const pkgDir = path.dirname(pkgJsonPath) + const pkgShortName = path.basename(pkgDir) // e.g. 'ui-avatar' + const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')) + const exports = pkgJson.exports + + if (!exports || typeof exports !== 'object') { + continue + } + + for (const exportKey of Object.keys(exports)) { + // Match keys like ./v11_5, ./v11_6 + const versionMatch = exportKey.match(/^\.\/v(\d+_\d+)$/) + if (!versionMatch) { + continue + } + + const libVersion = `v${versionMatch[1]}` // e.g. 'v11_5' + libraryVersionsSet.add(libVersion) + + const exportValue = exports[exportKey] + if (!exportValue || typeof exportValue !== 'object') { + continue + } + + // Extract export letter from the import path + // e.g. "./es/exports/a.js" -> "a" + const importPath = exportValue.import || exportValue.default + if (!importPath) { + continue + } + + const exportLetter = path.parse(importPath).name + if (!exportLetter) { + continue + } + + // Resolve the component version from the source export file + const componentVersion = resolveComponentVersion( + pkgDir, + exportLetter, + pkgShortName + ) + + if (!mapping[libVersion]) { + mapping[libVersion] = {} + } + mapping[libVersion][pkgShortName] = { + exportLetter, + componentVersion + } + } + } + + const libraryVersions = Array.from(libraryVersionsSet).sort((a, b) => { + const [aMaj, aMin] = a.replace('v', '').split('_').map(Number) + const [bMaj, bMin] = b.replace('v', '').split('_').map(Number) + return aMaj - bMaj || aMin - bMin + }) + + return { libraryVersions, mapping } +} + +/** + * Reads the source export file (e.g. src/exports/a.ts) and parses imports + * to determine which component version directory it maps to (v1 or v2). + */ +function resolveComponentVersion( + pkgDir: string, + exportLetter: string, + pkgShortName: string +): string { + const exportFilePath = path.join( + pkgDir, + 'src', + 'exports', + `${exportLetter}.ts` + ) + + if (!fs.existsSync(exportFilePath)) { + // eslint-disable-next-line no-console + console.warn( + `[buildVersionMap] Export file not found: ${exportFilePath} (${pkgShortName}), defaulting to v1` + ) + return 'v1' + } + + const content = fs.readFileSync(exportFilePath, 'utf-8') + + // Match patterns like: + // from '../ComponentName/v2' + // from '../ComponentName/v1/SubComponent' + const versionMatch = content.match( + /from\s+['"]\.\.\/[^/]+\/(v\d+)(?:\/|['"])/ + ) + if (versionMatch) { + return versionMatch[1] + } + + // eslint-disable-next-line no-console + console.warn( + `[buildVersionMap] Could not resolve component version from ${exportFilePath} (${pkgShortName}), defaulting to v1` + ) + return 'v1' +} diff --git a/packages/__docs__/buildScripts/watch-markdown.mjs b/packages/__docs__/buildScripts/watch-markdown.mjs index 3019c2d797..679f3da25b 100644 --- a/packages/__docs__/buildScripts/watch-markdown.mjs +++ b/packages/__docs__/buildScripts/watch-markdown.mjs @@ -27,15 +27,72 @@ import { globby } from 'globby' import { resolve as resolvePath } from 'path' import { fileURLToPath } from 'url' import { dirname } from 'path' +import fs from 'fs' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) -// Dynamically import the processSingleFile function +// Dynamically import the processSingleFile function and version map builder const { processSingleFile } = await import('../lib/build-docs.mjs') +const { buildVersionMap } = await import('../lib/utils/buildVersionMap.mjs') + +const projectRoot = resolvePath(__dirname, '../../../') +const buildDir = resolvePath(__dirname, '../__build__/') console.log('[MARKDOWN WATCHER] Starting markdown file watcher...') +// Load the version map once at startup +let versionMap = null +try { + versionMap = await buildVersionMap(projectRoot) + console.log( + `[MARKDOWN WATCHER] Loaded version map with versions: ${versionMap.libraryVersions.join(', ')}` + ) +} catch (error) { + console.warn('[MARKDOWN WATCHER] Could not load version map, falling back to root-only writes:', error.message) +} + +/** + * Given a file path and a docObject, determines which version subdirectories + * the doc JSON should be written to. + */ +function getTargetVersionDirs(filePath, docObject) { + if (!versionMap || versionMap.libraryVersions.length === 0) { + return [] // No version subdirs to write to + } + + // Non-versioned file: write to ALL version subdirs + if (!docObject.componentVersion) { + return versionMap.libraryVersions + } + + // Versioned file: write only to version subdirs where this component version is expected + const pkgMatch = filePath.match(/packages\/(ui-[^/]+)\//) + if (!pkgMatch) { + return versionMap.libraryVersions + } + + const pkgShortName = pkgMatch[1] + const targetVersions = [] + + for (const libVersion of versionMap.libraryVersions) { + const mapping = versionMap.mapping[libVersion] + if (!mapping) continue + + const entry = mapping[pkgShortName] + if (!entry) { + // Package not in version map, include v1 files + if (docObject.componentVersion === 'v1') { + targetVersions.push(libVersion) + } + } else if (entry.componentVersion === docObject.componentVersion) { + targetVersions.push(libVersion) + } + } + + return targetVersions +} + // Find all markdown files to watch const patterns = ['packages/**/*.md', 'docs/**/*.md'] const ignore = [ @@ -58,33 +115,54 @@ const paths = await globby(patterns, { cwd, absolute: true, ignore }) console.log(`[MARKDOWN WATCHER] Found ${paths.length} markdown files to watch`) // Debounce file changes to avoid processing the same file multiple times -const processedFiles = new Map() +const processedFilesMap = new Map() const DEBOUNCE_MS = 300 // Cleanup old entries from the Map every 5 minutes to prevent memory leak const CLEANUP_INTERVAL_MS = 5 * 60 * 1000 // 5 minutes setInterval(() => { const now = Date.now() - for (const [filePath, timestamp] of processedFiles.entries()) { + for (const [filePath, timestamp] of processedFilesMap.entries()) { if (now - timestamp > DEBOUNCE_MS) { - processedFiles.delete(filePath) + processedFilesMap.delete(filePath) } } }, CLEANUP_INTERVAL_MS) function debouncedProcess(filePath) { const now = Date.now() - const lastProcessed = processedFiles.get(filePath) + const lastProcessed = processedFilesMap.get(filePath) if (lastProcessed && now - lastProcessed < DEBOUNCE_MS) { return // Skip if file was processed recently } - processedFiles.set(filePath, now) + processedFilesMap.set(filePath, now) try { console.log(`[MARKDOWN WATCHER] File changed: ${filePath}`) - processSingleFile(filePath) + const docObject = processSingleFile(filePath) + if (!docObject) { + console.log(`[MARKDOWN WATCHER] No doc output for: ${filePath}`) + return + } + + // Write to version subdirectories + const targetVersionDirs = getTargetVersionDirs(filePath, docObject) + const docJSON = JSON.stringify(docObject) + + for (const libVersion of targetVersionDirs) { + const versionDocsDir = `${buildDir}/docs/${libVersion}/` + fs.mkdirSync(versionDocsDir, { recursive: true }) + fs.writeFileSync(versionDocsDir + docObject.id + '.json', docJSON) + } + + if (targetVersionDirs.length > 0) { + console.log( + `[MARKDOWN WATCHER] Wrote to version dirs: ${targetVersionDirs.join(', ')}` + ) + } + console.log(`[MARKDOWN WATCHER] Successfully processed: ${filePath}`) } catch (error) { console.error(`[MARKDOWN WATCHER] Error processing file: ${filePath}`, error) diff --git a/packages/__docs__/component-overrides.ts b/packages/__docs__/component-overrides.ts new file mode 100644 index 0000000000..14d2079e04 --- /dev/null +++ b/packages/__docs__/component-overrides.ts @@ -0,0 +1,161 @@ +/* + * The MIT License (MIT) + * + * Copyright (c) 2015 - present Instructure, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as DefaultComponents from './components' + +// === v11_6 overrides === +// Only the components that differ from the default (v11_5) exports. +// These packages export a v2 (functional) component in v11_6. +import { Avatar as Avatar_v11_6 } from '@instructure/ui-avatar/v11_6' +import { Badge as Badge_v11_6 } from '@instructure/ui-badge/v11_6' +import { Billboard as Billboard_v11_6 } from '@instructure/ui-billboard/v11_6' +import { Breadcrumb as Breadcrumb_v11_6 } from '@instructure/ui-breadcrumb/v11_6' +import { Calendar as Calendar_v11_6 } from '@instructure/ui-calendar/v11_6' +import { + DateInput as DateInput_v11_6, + DateInput2 as DateInput2_v11_6 +} from '@instructure/ui-date-input/v11_6' +import { FileDrop as FileDrop_v11_6 } from '@instructure/ui-file-drop/v11_6' +import { Flex as Flex_v11_6 } from '@instructure/ui-flex/v11_6' +import { + FormField as FormField_v11_6, + FormFieldLabel as FormFieldLabel_v11_6, + FormFieldMessage as FormFieldMessage_v11_6, + FormFieldMessages as FormFieldMessages_v11_6, + FormFieldLayout as FormFieldLayout_v11_6, + FormFieldGroup as FormFieldGroup_v11_6 +} from '@instructure/ui-form-field/v11_6' +import { Grid as Grid_v11_6 } from '@instructure/ui-grid/v11_6' +import { Heading as Heading_v11_6 } from '@instructure/ui-heading/v11_6' +import { Link as Link_v11_6 } from '@instructure/ui-link/v11_6' +import { + List as List_v11_6, + InlineList as InlineList_v11_6 +} from '@instructure/ui-list/v11_6' +import { + MetricGroup as MetricGroup_v11_6, + Metric as Metric_v11_6 +} from '@instructure/ui-metric/v11_6' +import { Pill as Pill_v11_6 } from '@instructure/ui-pill/v11_6' +import { Popover as Popover_v11_6 } from '@instructure/ui-popover/v11_6' +import { + ProgressBar as ProgressBar_v11_6, + ProgressCircle as ProgressCircle_v11_6 +} from '@instructure/ui-progress/v11_6' +import { RangeInput as RangeInput_v11_6 } from '@instructure/ui-range-input/v11_6' +import { SourceCodeEditor as SourceCodeEditor_v11_6 } from '@instructure/ui-source-code-editor/v11_6' +import { Spinner as Spinner_v11_6 } from '@instructure/ui-spinner/v11_6' +import { + Table as Table_v11_6, + TableContext as TableContext_v11_6 +} from '@instructure/ui-table/v11_6' +import { Tabs as Tabs_v11_6 } from '@instructure/ui-tabs/v11_6' +import { Tag as Tag_v11_6 } from '@instructure/ui-tag/v11_6' +import { Text as Text_v11_6 } from '@instructure/ui-text/v11_6' +import { TextArea as TextArea_v11_6 } from '@instructure/ui-text-area/v11_6' +import { TextInput as TextInput_v11_6 } from '@instructure/ui-text-input/v11_6' +import { Tooltip as Tooltip_v11_6 } from '@instructure/ui-tooltip/v11_6' +import { TopNavBar as TopNavBar_v11_6 } from '@instructure/ui-top-nav-bar/v11_6' +import { Tray as Tray_v11_6 } from '@instructure/ui-tray/v11_6' +import { TreeBrowser as TreeBrowser_v11_6 } from '@instructure/ui-tree-browser/v11_6' +import { + View as View_v11_6, + ContextView as ContextView_v11_6 +} from '@instructure/ui-view/v11_6' + +const v11_6_overrides: Record = { + Avatar: Avatar_v11_6, + Badge: Badge_v11_6, + Billboard: Billboard_v11_6, + Breadcrumb: Breadcrumb_v11_6, + Calendar: Calendar_v11_6, + DateInput: DateInput_v11_6, + DateInput2: DateInput2_v11_6, + FileDrop: FileDrop_v11_6, + Flex: Flex_v11_6, + FormField: FormField_v11_6, + FormFieldLabel: FormFieldLabel_v11_6, + FormFieldMessage: FormFieldMessage_v11_6, + FormFieldMessages: FormFieldMessages_v11_6, + FormFieldLayout: FormFieldLayout_v11_6, + FormFieldGroup: FormFieldGroup_v11_6, + Grid: Grid_v11_6, + Heading: Heading_v11_6, + Link: Link_v11_6, + List: List_v11_6, + InlineList: InlineList_v11_6, + MetricGroup: MetricGroup_v11_6, + Metric: Metric_v11_6, + Pill: Pill_v11_6, + Popover: Popover_v11_6, + ProgressBar: ProgressBar_v11_6, + ProgressCircle: ProgressCircle_v11_6, + RangeInput: RangeInput_v11_6, + SourceCodeEditor: SourceCodeEditor_v11_6, + Spinner: Spinner_v11_6, + Table: Table_v11_6, + TableContext: TableContext_v11_6, + Tabs: Tabs_v11_6, + Tag: Tag_v11_6, + Text: Text_v11_6, + TextArea: TextArea_v11_6, + TextInput: TextInput_v11_6, + Tooltip: Tooltip_v11_6, + TopNavBar: TopNavBar_v11_6, + Tray: Tray_v11_6, + TreeBrowser: TreeBrowser_v11_6, + View: View_v11_6, + ContextView: ContextView_v11_6 +} + +// Registry: version → its overrides (self-contained, no stacking) +const overridesByVersion: Record> = { + v11_6: v11_6_overrides + // Future: v11_7: v11_7_overrides +} + +// Memoized results — versions and component maps are immutable at runtime +const versionCache = new Map>() + +/** + * Returns the full component map for a given library version. + * Starts with default components and applies version-specific overrides. + * If no version is given or the version has no overrides, returns defaults. + */ +export function getComponentsForVersion( + version?: string +): Record { + const base = DefaultComponents as Record + if (!version) return base + + const cached = versionCache.get(version) + if (cached) return cached + + const overrides = overridesByVersion[version] + if (!overrides) return base + + const merged = { ...base, ...overrides } + versionCache.set(version, merged) + return merged +} diff --git a/packages/__docs__/globals.ts b/packages/__docs__/globals.ts index 821bc64fed..db3efa7107 100644 --- a/packages/__docs__/globals.ts +++ b/packages/__docs__/globals.ts @@ -40,6 +40,7 @@ import { mirrorHorizontalPlacement } from '@instructure/ui-position' // eslint-plugin-import doesn't like 'import * as Components' here const Components = require('./components') +import { getComponentsForVersion } from './component-overrides' import { rebrandDark, rebrandLight } from '@instructure/ui-themes' import { debounce } from '@instructure/debounce' @@ -109,4 +110,16 @@ Object.keys(globals).forEach((key) => { ;(global as any)[key] = globals[key] }) +/** + * Re-populates global component references with version-specific components. + * Called when the user switches minor versions in the docs UI. + */ +function updateGlobalsForVersion(version: string) { + const versionComponents = getComponentsForVersion(version) + Object.keys(versionComponents).forEach((key) => { + ;(global as any)[key] = versionComponents[key] + }) +} + export default globals +export { updateGlobalsForVersion } diff --git a/packages/__docs__/src/App/index.tsx b/packages/__docs__/src/App/index.tsx index f6b5ebdb72..06a5d302ee 100644 --- a/packages/__docs__/src/App/index.tsx +++ b/packages/__docs__/src/App/index.tsx @@ -62,12 +62,17 @@ import { Section } from '../Section' import IconsPage from '../Icons' import { compileMarkdown } from '../compileMarkdown' -import { fetchVersionData, versionInPath } from '../versionData' +import { + fetchVersionData, + fetchMinorVersionData, + versionInPath +} from '../versionData' import generateStyle from './styles' import generateComponentTheme from './theme' import { LoadingScreen } from '../LoadingScreen' -import * as EveryComponent from '../../components' +import { getComponentsForVersion } from '../../component-overrides' +import { updateGlobalsForVersion } from '../../globals' import type { AppProps, AppState, DocData, LayoutSize } from './props' import { allowedProps } from './props' import type { @@ -140,18 +145,34 @@ class App extends Component { this._navRef = createRef() } + getDocsBasePath = () => { + const { selectedMinorVersion } = this.state + if (selectedMinorVersion) { + return `docs/${selectedMinorVersion}/` + } + return 'docs/' + } + + getComponentsForCurrentVersion = (): Record => { + const { selectedMinorVersion } = this.state + return getComponentsForVersion(selectedMinorVersion) + } + fetchDocumentData = async (docId: string) => { - const result = await fetch('docs/' + docId + '.json', { + const basePath = this.getDocsBasePath() + const result = await fetch(basePath + docId + '.json', { signal: this._controller?.signal }) + if (!result.ok) { + throw new Error(`Failed to fetch ${docId}: ${result.status}`) + } const docData: DocData = await result.json() + const everyComp = this.getComponentsForCurrentVersion() if (docId.includes('.')) { // e.g. 'Calendar.Day', first get 'Calendar' then 'Day' const components = docId.split('.') - const everyComp = EveryComponent as Record docData.componentInstance = everyComp[components[0]][components[1]] } else { - const everyComp = EveryComponent as Record docData.componentInstance = everyComp[docId] } return docData @@ -162,6 +183,59 @@ class App extends Component { return this.setState({ versionsData }) } + fetchMainDocsData = (url: string, signal: AbortSignal) => { + return fetch(url, { signal }) + .then((response) => response.json()) + .then((docsData) => { + this.setState({ + docsData, + themeKey: Object.keys(docsData.themes)[0] + }) + }) + } + + handleMinorVersionChange = (newVersion: string) => { + // Abort current fetches + this._controller?.abort() + this._controller = new AbortController() + const signal = this._controller.signal + + // Clear current data to show loading screen, update selected version + this.setState( + { + docsData: null, + currentDocData: undefined, + changelogData: undefined, + selectedMinorVersion: newVersion + }, + () => { + // Update global component references after state is committed + // so globals and state.selectedMinorVersion agree + updateGlobalsForVersion(newVersion) + + const errorHandler = (error: Error) => { + if (error.name !== 'AbortError') { + logError(false, error.message) + } + } + this.fetchMainDocsData( + `docs/${newVersion}/markdown-and-sources-data.json`, + signal + ).catch(errorHandler) + + // Icons are not version-specific; only re-fetch if not already loaded + if (!this.state.iconsData) { + fetch('icons-data.json', { signal }) + .then((response) => response.json()) + .then((iconsData) => { + this.setState({ iconsData }) + }) + .catch(errorHandler) + } + } + ) + } + mainContentRef = (el: Element | null) => { this._mainContentRef = el as HTMLElement } @@ -208,11 +282,13 @@ class App extends Component { this._controller = new AbortController() const signal = this._controller.signal - this.fetchVersionData(signal) - const errorHandler = (error: Error) => { - logError(error.name === 'AbortError', error.message) + if (error.name !== 'AbortError') { + logError(false, error.message) + } } + + this.fetchVersionData(signal).catch(errorHandler) document.addEventListener('keydown', this.handleTabKey) fetch('icons-data.json', { signal }) @@ -221,13 +297,28 @@ class App extends Component { this.setState({ iconsData: iconsData }) }) .catch(errorHandler) - fetch('markdown-and-sources-data.json', { signal }) - .then((response) => response.json()) - .then((docsData) => { - this.setState({ - docsData, - themeKey: Object.keys(docsData.themes)[0] - }) + + // Fetch minor version data, then load docs for the appropriate version + fetchMinorVersionData(signal) + .then((minorVersionsData) => { + if (minorVersionsData && minorVersionsData.libraryVersions.length > 0) { + const selectedMinorVersion = minorVersionsData.defaultVersion + this.setState( + { minorVersionsData, selectedMinorVersion }, + () => { + updateGlobalsForVersion(selectedMinorVersion) + } + ) + return this.fetchMainDocsData( + `docs/${selectedMinorVersion}/markdown-and-sources-data.json`, + signal + ) + } + // No minor versions available, fetch from root path + return this.fetchMainDocsData( + 'markdown-and-sources-data.json', + signal + ) }) .catch(errorHandler) @@ -535,21 +626,29 @@ class App extends Component { const currentData = this.state.currentDocData if (!currentData || currentData.id !== docId) { // load all children and the main doc - this.fetchDocumentData(docId).then(async (data) => { - if (parents[docId]) { - for (const childId of parents[docId].children) { - children.push(await this.fetchDocumentData(childId)) + this.fetchDocumentData(docId) + .then(async (data) => { + if (parents[docId]) { + for (const childId of parents[docId].children) { + children.push(await this.fetchDocumentData(childId)) + } } - } - // eslint-disable-next-line no-param-reassign - data.children = children - this.setState( - { - currentDocData: data - }, - this.scrollToElement - ) - }) + // Guard: check if we are still on the same page + if (this.state.key !== docId) return + // eslint-disable-next-line no-param-reassign + data.children = children + this.setState( + { + currentDocData: data + }, + this.scrollToElement + ) + }) + .catch((error: Error) => { + if (error.name !== 'AbortError') { + logError(false, `Failed to fetch document ${docId}: ${error.message}`) + } + }) return ( @@ -590,6 +689,7 @@ class App extends Component { themeVariables={themeVariables} repository={repository} layout={layout} + selectedMinorVersion={this.state.selectedMinorVersion} /> @@ -635,9 +735,15 @@ class App extends Component { renderChangeLog() { if (!this.state.changelogData) { - this.fetchDocumentData('CHANGELOG').then((data) => { - this.setState({ changelogData: data }) - }) + this.fetchDocumentData('CHANGELOG') + .then((data) => { + this.setState({ changelogData: data }) + }) + .catch((error: Error) => { + if (error.name !== 'AbortError') { + logError(false, `Failed to fetch CHANGELOG: ${error.message}`) + } + }) return ( @@ -796,6 +902,9 @@ class App extends Component { name={name === 'instructure-ui' ? 'v' : name} version={version} versionsData={versionsData} + minorVersionsData={this.state.minorVersionsData} + selectedMinorVersion={this.state.selectedMinorVersion} + onMinorVersionChange={this.handleMinorVersionChange} />