From 8a0ec21a05f2813f7d6407bde35fb7b91193e5b4 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 | 81 +++++ packages/__docs__/buildScripts/DataTypes.mts | 16 +- packages/__docs__/buildScripts/build-docs.mts | 253 ++++++++++---- .../generate-component-overrides.mts | 327 ++++++++++++++++++ .../__docs__/buildScripts/processFile.mts | 19 +- .../buildScripts/utils/buildVersionMap.mts | 188 ++++++++++ .../__docs__/buildScripts/watch-markdown.mjs | 78 ++++- packages/__docs__/component-overrides.ts | 194 +++++++++++ packages/__docs__/globals.ts | 17 + packages/__docs__/package.json | 1 + packages/__docs__/src/App/index.tsx | 181 ++++++++-- 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 | 91 ++++- packages/__docs__/src/Header/props.ts | 14 +- packages/__docs__/src/versionData.ts | 53 ++- packages/__docs__/tsconfig.build.json | 1 + 18 files changed, 1397 insertions(+), 138 deletions(-) create mode 100644 docs/contributor-docs/docs-versioning.md create mode 100644 packages/__docs__/buildScripts/generate-component-overrides.mts 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..c9acc023b2 --- /dev/null +++ b/docs/contributor-docs/docs-versioning.md @@ -0,0 +1,81 @@ +--- +title: Docs Versioning +category: Contributor Guides +order: 7 +--- + +# 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) + +The multi-version switcher is gated behind a `?beta` URL parameter. Without it, the app loads docs from the root path and displays the full version (e.g. "v10.3.2") in the navbar — the same as the pre-multi-version behavior. + +When `?beta` is present: + +1. On load, the App fetches `docs-versions.json` to discover available minor versions. +2. The Header renders a minor version dropdown if multiple versions exist, showing only the major version (e.g. "v10") since the dropdown handles the rest. +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 **auto-generated** — do not edit it manually. It must be regenerated with `pnpm run generate:component-overrides` after changing package exports (e.g. adding a new versioned subpath). The generated file is checked into git so that the docs build uses a known good state. + +It works by: + +1. Importing `DefaultComponents` from `./components` (baseline, matching the default `.` package export). +2. Importing only the components that **differ** in a given version from their versioned subpath (e.g. `@instructure/ui-avatar/v11_7`). +3. Building an override map per version. +4. `getComponentsForVersion(version)` spreads overrides on top of defaults. + +Versions whose export letters match the default need no overrides and return the baseline as-is. + +The generator (`buildScripts/generate-component-overrides.mts`) scans each package's `package.json` exports, compares the versioned export letter against the default, and when they differ, reads the export file to discover which component names are exported. + +To regenerate it: + +```bash +cd packages/__docs__ +pnpm run generate:component-overrides +``` + +## Adding a new library version (e.g. v11_23) + +1. **Ensure packages export the new subpath.** Each package that has version-specific code must have a `/v11_23` export in its `package.json` `exports` field. + +2. **Regenerate `component-overrides.ts`** by running `pnpm run generate:component-overrides` from `packages/__docs__/`. Commit the updated file. +3. **Run `pnpm run dev` or `pnpm run build:docs`** — the version map is rebuilt automatically during the docs build. + +## Introducing a breaking change to a component + +If a component (e.g. `Checkbox`) needs a breaking change for v11_23: + +1. Add the new implementation under the package's versioned directory (e.g. `packages/ui-checkbox/src/Checkbox/v2/`, or `v3/` if `v2/` already exists). +2. Update the package's `exports` so `/v11_23` re-exports the new implementation. +3. Regenerate `component-overrides.ts`: run `pnpm run generate:component-overrides` from `packages/__docs__/` and commit the result. +4. Run `pnpm run dev` to verify the docs build picks up the new version. + +## Key files + +| File | Purpose | +|------|---------| +| `packages/__docs__/components.ts` | Default component exports (baseline, matches the default `.` package export) | +| `packages/__docs__/component-overrides.ts` | **Auto-generated** — version-specific overrides + `getComponentsForVersion()` | +| `packages/__docs__/buildScripts/generate-component-overrides.mts` | Generator script for `component-overrides.ts` | +| `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 + shared filtering utilities (`isDocIncludedInVersion`, `getPackageShortName`) | 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..e80700e19d 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, getPackageShortName, isDocIncludedInVersion } 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,12 @@ 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/*/*.md', // non-versioned component READMEs + '**/src/*/*.{ts,tsx}', // non-versioned component src files + '**/src/*/*/*.{ts,tsx}', // non-versioned child component src files + '**/src/*/v*/*.md', // versioned component READMEs + '**/src/*/v*/*.{ts,tsx}', // versioned component src files + '**/src/*/v*/*/*.{ts,tsx}' // versioned child component src files ] const pathsToIgnore = [ @@ -100,6 +104,11 @@ const pathsToIgnore = [ // ignore index files that just re-export '**/src/index.ts', + // version export mapping files (e.g. src/exports/a.ts, b.ts) + '**/src/exports/**', + // shared theme token files + '**/src/sharedThemeTokens/**', + // packages to ignore: '**/canvas-theme/**', '**/canvas-high-contrast-theme/**', @@ -122,7 +131,26 @@ if (import.meta.url === pathToFileURL(process.argv[1]).href) { buildDocs() } -function buildDocs() { +/** + * Filters processed docs for a specific library version using the version map. + * Delegates per-doc inclusion logic to the shared `isDocIncludedInVersion`. + */ +function filterDocsForVersion( + allDocs: ProcessedFile[], + libVersion: string, + versionMap: VersionMap +): ProcessedFile[] { + return allDocs.filter((doc) => + isDocIncludedInVersion( + versionMap, + libVersion, + doc.componentVersion, + getPackageShortName(doc.relativePath) + ) + ) +} + +async function buildDocs() { // eslint-disable-next-line no-console console.log('Start building application data') console.time('docs build time') @@ -130,87 +158,156 @@ 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 default version's per-doc JSONs to root docs/ for non-beta mode, + // which fetches from docs/{docId}.json without a version prefix. + const defaultVersionDocs = filterDocsForVersion(allDocs, defaultVersion, versionMap) + for (const doc of defaultVersionDocs) { + fs.writeFileSync( + buildDir + 'docs/' + doc.id + '.json', + JSON.stringify(doc) ) + } - // 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' - ) + // 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('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` + // Generate AI accessible documentation from default version + const defaultVersionDocsDir = buildDir + 'docs/' + defaultVersion + '/' + generateAIAccessibleMarkdowns(defaultVersionDocsDir, buildDir + 'markdowns/') + + 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 instanceof Error ? error : new Error(String(error)) + throw new Error( + `Error when generating documentation data: ${err.message}\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 +327,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 +342,15 @@ function processSingleFile(fullPath: string) { // documentation .md files, utils ts and tsx files docObject = processFile(fullPath, projectRoot, library) } + return docObject +} + +/** + * 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 +360,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 +379,11 @@ function parseThemes() { return parsed } -export { pathsToProcess, pathsToIgnore, processSingleFile, buildDocs } +export { + pathsToProcess, + pathsToIgnore, + parseSingleFile, + processSingleFile, + buildDocs, + filterDocsForVersion +} diff --git a/packages/__docs__/buildScripts/generate-component-overrides.mts b/packages/__docs__/buildScripts/generate-component-overrides.mts new file mode 100644 index 0000000000..141a927041 --- /dev/null +++ b/packages/__docs__/buildScripts/generate-component-overrides.mts @@ -0,0 +1,327 @@ +/* + * 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. + */ + +/** + * Generates `component-overrides.ts` automatically by scanning package exports. + * + * For each library version (e.g. v11_6, v11_7), it compares each package's + * versioned export letter against the default (`.`) export letter. When they + * differ, the versioned subpath exports different components — those become + * overrides. + * + * Run: pnpm run generate:component-overrides + */ + +import fs from 'fs' +import path from 'path' +import { globby } from 'globby' +import { fileURLToPath } from 'url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const projectRoot = path.resolve(__dirname, '../../../') +const outputPath = path.resolve(__dirname, '../component-overrides.ts') + +const LICENSE_HEADER = `/* + * 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. + */` + +type PackageInfo = { + /** e.g. '@instructure/ui-avatar' */ + name: string + /** e.g. 'ui-avatar' */ + shortName: string + /** Export letter for the default "." export, e.g. 'a' */ + defaultLetter: string + /** Map of version key (e.g. 'v11_7') to its export letter */ + versionLetters: Record + /** Absolute path to the package directory */ + dir: string +} + +/** + * Reads an export file (e.g. src/exports/b.ts) and extracts the exported + * component names. Returns names like ['Avatar'] or ['Table', 'TableContext']. + */ +function getExportedNames(exportFilePath: string): string[] { + if (!fs.existsSync(exportFilePath)) { + return [] + } + const content = fs.readFileSync(exportFilePath, 'utf-8') + const names: string[] = [] + + // Match: export { Foo, Bar } from '...' + // Also handles: export { Foo as Bar } — we take the exported name (Bar) + const exportBlockRegex = /export\s*\{([^}]+)\}\s*from/g + let match: RegExpExecArray | null + while ((match = exportBlockRegex.exec(content)) !== null) { + const block = match[1] + for (const item of block.split(',')) { + const trimmed = item.trim() + if (!trimmed) continue + // Skip type-only exports + if (trimmed.startsWith('type ')) continue + // Handle "Foo as Bar" — take Bar + const asParts = trimmed.split(/\s+as\s+/) + const exportedName = (asParts.length > 1 ? asParts[1] : asParts[0]).trim() + if (exportedName) { + names.push(exportedName) + } + } + } + + return names +} + +async function main() { + const packagesDir = path.join(projectRoot, 'packages') + const pkgJsonPaths = await globby('ui-*/package.json', { + cwd: packagesDir, + absolute: true + }) + + // Gather package info + const packages: PackageInfo[] = [] + + for (const pkgJsonPath of pkgJsonPaths) { + const pkgDir = path.dirname(pkgJsonPath) + const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8')) + const exports = pkgJson.exports + + if (!exports || typeof exports !== 'object') continue + if (!exports['.']) continue + + const defaultImportPath = exports['.'].import || exports['.'].default + if (!defaultImportPath) continue + const defaultLetter = path.parse(defaultImportPath).name + + const versionLetters: Record = {} + for (const [key, val] of Object.entries(exports)) { + const vMatch = key.match(/^\.\/v(\d+_\d+)$/) + if (!vMatch) continue + const versionKey = `v${vMatch[1]}` + const vExport = val as Record + const vImportPath = vExport.import || vExport.default + if (vImportPath) { + versionLetters[versionKey] = path.parse(vImportPath).name + } + } + + // Only include if this package has versioned exports + if (Object.keys(versionLetters).length === 0) continue + + packages.push({ + name: pkgJson.name, + shortName: path.basename(pkgDir), + defaultLetter, + versionLetters, + dir: pkgDir + }) + } + + // Sort packages for stable output + packages.sort((a, b) => a.name.localeCompare(b.name)) + + // Collect all library versions + const allVersions = new Set() + for (const pkg of packages) { + for (const v of Object.keys(pkg.versionLetters)) { + allVersions.add(v) + } + } + const sortedVersions = Array.from(allVersions).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 + }) + + // For each version, determine which packages need overrides + // (their export letter differs from the default) + type OverrideInfo = { + pkg: PackageInfo + exportLetter: string + componentNames: string[] + } + const overridesByVersion: Record = {} + + for (const version of sortedVersions) { + const overrides: OverrideInfo[] = [] + for (const pkg of packages) { + const letter = pkg.versionLetters[version] + if (!letter || letter === pkg.defaultLetter) continue + + const exportFile = path.join(pkg.dir, 'src', 'exports', `${letter}.ts`) + const componentNames = getExportedNames(exportFile) + if (componentNames.length === 0) { + console.warn( + `[generate-component-overrides] No exported names found in ${exportFile} for ${pkg.name}` + ) + continue + } + overrides.push({ pkg, exportLetter: letter, componentNames }) + } + if (overrides.length > 0) { + overridesByVersion[version] = overrides + } + } + + // Generate the file + const lines: string[] = [] + + lines.push( + '// AUTO-GENERATED FILE — do not edit manually.', + '// Run: pnpm run generate:component-overrides', + '//', + '' + ) + lines.push(LICENSE_HEADER) + lines.push('') + lines.push("import * as DefaultComponents from './components'") + lines.push('') + + // Generate import statements per version + for (const version of sortedVersions) { + const overrides = overridesByVersion[version] + if (!overrides) continue + + lines.push(`// === ${version} overrides ===`) + for (const { pkg, componentNames } of overrides) { + const aliasedNames = componentNames + .map((n) => `${n} as ${n}_${version}`) + .join(', ') + lines.push( + `import { ${aliasedNames} } from '${pkg.name}/${version}'` + ) + } + lines.push('') + } + + // Generate override objects per version + for (const version of sortedVersions) { + const overrides = overridesByVersion[version] + if (!overrides) continue + + lines.push(`const ${version}_overrides: Record = {`) + const allNames = overrides.flatMap((o) => o.componentNames) + for (const name of allNames) { + lines.push(` ${name}: ${name}_${version},`) + } + lines.push('}') + lines.push('') + } + + // Generate the registry + const versionsWithOverrides = sortedVersions.filter( + (v) => overridesByVersion[v] + ) + lines.push( + '// Registry: version → its overrides (self-contained, no stacking)' + ) + lines.push('const overridesByVersion: Record> = {') + for (const version of versionsWithOverrides) { + lines.push(` ${version}: ${version}_overrides,`) + } + lines.push('}') + lines.push('') + + // Cache + getComponentsForVersion function + lines.push( + '// Memoized results — versions and component maps are immutable at runtime' + ) + lines.push('const versionCache = new Map>()') + lines.push('') + lines.push('/**') + lines.push( + ' * Returns the full component map for a given library version.' + ) + lines.push( + ' * Starts with default components and applies version-specific overrides.' + ) + lines.push( + ' * If no version is given or the version has no overrides, returns defaults.' + ) + lines.push(' */') + lines.push('export function getComponentsForVersion(') + lines.push(' version?: string') + lines.push('): Record {') + lines.push(' const base = DefaultComponents as Record') + lines.push(' if (!version) return base') + lines.push('') + lines.push(' const cached = versionCache.get(version)') + lines.push(' if (cached) return cached') + lines.push('') + lines.push(' const overrides = overridesByVersion[version]') + lines.push(' if (!overrides) return base') + lines.push('') + lines.push(' const merged = { ...base, ...overrides }') + lines.push(' versionCache.set(version, merged)') + lines.push(' return merged') + lines.push('}') + lines.push('') + + const content = lines.join('\n') + fs.writeFileSync(outputPath, content) + console.log(`[generate-component-overrides] Wrote ${outputPath}`) + + // Summary + for (const version of sortedVersions) { + const overrides = overridesByVersion[version] + if (overrides) { + const count = overrides.reduce( + (sum, o) => sum + o.componentNames.length, + 0 + ) + console.log( + ` ${version}: ${count} component overrides across ${overrides.length} packages` + ) + } else { + console.log(` ${version}: no overrides (same as default)`) + } + } +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/packages/__docs__/buildScripts/processFile.mts b/packages/__docs__/buildScripts/processFile.mts index ce46074e03..3986944b20 100644 --- a/packages/__docs__/buildScripts/processFile.mts +++ b/packages/__docs__/buildScripts/processFile.mts @@ -52,7 +52,14 @@ 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! + const fallbackId = path.basename(path.dirname(fullPath)) + if (!docData.displayName && /^v\d+$/.test(fallbackId)) { + // eslint-disable-next-line no-console + console.warn( + `[processFile] Suspicious docId "${fallbackId}" derived from path: ${fullPath}` + ) + } + docId = docData.displayName ?? fallbackId } else if (lowerPath.includes('readme.md')) { const folder = path.basename(dirName) docId = docData.describes ? folder + '__README' : folder @@ -63,5 +70,15 @@ 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 srcIndex = pathSegments.indexOf('src') + const segmentsAfterSrc = srcIndex >= 0 ? pathSegments.slice(srcIndex + 1) : pathSegments + const versionSegment = segmentsAfterSrc.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..53edcd491d --- /dev/null +++ b/packages/__docs__/buildScripts/utils/buildVersionMap.mts @@ -0,0 +1,188 @@ +/* + * 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 } +} + +/** + * Extracts the package short name (e.g. 'ui-avatar') from a file path. + * Returns undefined for files that are not inside a ui-* package. + */ +export function getPackageShortName(filePath: string): string | undefined { + const match = filePath.match(/packages\/(ui-[^/]+)\//) + return match ? match[1] : undefined +} + +/** + * Returns true if a doc with the given componentVersion and package should + * be included in the specified library version. + * + * Rules: + * - No componentVersion (general docs, utils, CHANGELOG) → included in all versions + * - No pkgShortName (not a ui-* package file) → included in all versions + * - Package not in version map (no multi-version exports) → included only if v1 + * - Otherwise → included only if componentVersion matches the version map entry + */ +export function isDocIncludedInVersion( + versionMap: VersionMap, + libVersion: string, + componentVersion: string | undefined, + pkgShortName: string | undefined +): boolean { + if (!componentVersion || !pkgShortName) { + return true + } + + const entry = versionMap.mapping[libVersion]?.[pkgShortName] + if (!entry) { + // Package has no multi-version exports; include only the default (v1) + return componentVersion === 'v1' + } + + return componentVersion === entry.componentVersion +} + +/** + * 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] + } + + throw new Error( + `[buildVersionMap] Could not resolve component version from ${exportFilePath} (${pkgShortName}). ` + + `Ensure the file has an import like: from '../ComponentName/v1'` + ) +} diff --git a/packages/__docs__/buildScripts/watch-markdown.mjs b/packages/__docs__/buildScripts/watch-markdown.mjs index 3019c2d797..ba2d979f18 100644 --- a/packages/__docs__/buildScripts/watch-markdown.mjs +++ b/packages/__docs__/buildScripts/watch-markdown.mjs @@ -27,15 +27,51 @@ 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 build functions and shared version utilities const { processSingleFile } = await import('../lib/build-docs.mjs') +const { buildVersionMap, getPackageShortName, isDocIncludedInVersion } = 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 [] + } + + const pkgShortName = getPackageShortName(filePath) + return versionMap.libraryVersions.filter((libVersion) => + isDocIncludedInVersion( + versionMap, + libVersion, + docObject.componentVersion, + pkgShortName + ) + ) +} + // Find all markdown files to watch const patterns = ['packages/**/*.md', 'docs/**/*.md'] const ignore = [ @@ -58,33 +94,43 @@ 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()) { - if (now - timestamp > DEBOUNCE_MS) { - processedFiles.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..9019b3936d --- /dev/null +++ b/packages/__docs__/component-overrides.ts @@ -0,0 +1,194 @@ +// AUTO-GENERATED FILE — do not edit manually. +// Run: pnpm run generate:component-overrides +// + +/* + * 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_7 overrides === +import { Alert as Alert_v11_7 } from '@instructure/ui-alerts/v11_7' +import { Avatar as Avatar_v11_7 } from '@instructure/ui-avatar/v11_7' +import { Badge as Badge_v11_7 } from '@instructure/ui-badge/v11_7' +import { Billboard as Billboard_v11_7 } from '@instructure/ui-billboard/v11_7' +import { Breadcrumb as Breadcrumb_v11_7, BreadcrumbLink as BreadcrumbLink_v11_7 } from '@instructure/ui-breadcrumb/v11_7' +import { Calendar as Calendar_v11_7, CalendarDay as CalendarDay_v11_7 } from '@instructure/ui-calendar/v11_7' +import { Checkbox as Checkbox_v11_7, CheckboxFacade as CheckboxFacade_v11_7, ToggleFacade as ToggleFacade_v11_7, CheckboxGroup as CheckboxGroup_v11_7 } from '@instructure/ui-checkbox/v11_7' +import { DateInput as DateInput_v11_7, DateInput2 as DateInput2_v11_7 } from '@instructure/ui-date-input/v11_7' +import { DrawerLayout as DrawerLayout_v11_7, DrawerContent as DrawerContent_v11_7, DrawerTray as DrawerTray_v11_7 } from '@instructure/ui-drawer-layout/v11_7' +import { FileDrop as FileDrop_v11_7 } from '@instructure/ui-file-drop/v11_7' +import { Flex as Flex_v11_7, FlexItem as FlexItem_v11_7 } from '@instructure/ui-flex/v11_7' +import { FormField as FormField_v11_7, FormFieldLabel as FormFieldLabel_v11_7, FormFieldMessage as FormFieldMessage_v11_7, FormFieldMessages as FormFieldMessages_v11_7, FormFieldLayout as FormFieldLayout_v11_7, FormFieldGroup as FormFieldGroup_v11_7 } from '@instructure/ui-form-field/v11_7' +import { Grid as Grid_v11_7, GridRow as GridRow_v11_7, GridCol as GridCol_v11_7 } from '@instructure/ui-grid/v11_7' +import { Heading as Heading_v11_7 } from '@instructure/ui-heading/v11_7' +import { Link as Link_v11_7 } from '@instructure/ui-link/v11_7' +import { InlineList as InlineList_v11_7, List as List_v11_7, ListItem as ListItem_v11_7, InlineListItem as InlineListItem_v11_7 } from '@instructure/ui-list/v11_7' +import { Menu as Menu_v11_7, MenuItem as MenuItem_v11_7, MenuItemGroup as MenuItemGroup_v11_7, MenuItemSeparator as MenuItemSeparator_v11_7 } from '@instructure/ui-menu/v11_7' +import { Metric as Metric_v11_7, MetricGroup as MetricGroup_v11_7 } from '@instructure/ui-metric/v11_7' +import { Modal as Modal_v11_7, ModalBody as ModalBody_v11_7, ModalFooter as ModalFooter_v11_7, ModalHeader as ModalHeader_v11_7 } from '@instructure/ui-modal/v11_7' +import { NumberInput as NumberInput_v11_7 } from '@instructure/ui-number-input/v11_7' +import { Options as Options_v11_7, OptionItem as OptionItem_v11_7, OptionSeparator as OptionSeparator_v11_7, optionsThemeGenerator as optionsThemeGenerator_v11_7, optionsItemThemeGenerator as optionsItemThemeGenerator_v11_7, optionsSeparatorThemeGenerator as optionsSeparatorThemeGenerator_v11_7 } from '@instructure/ui-options/v11_7' +import { Pagination as Pagination_v11_7, PaginationButton as PaginationButton_v11_7 } from '@instructure/ui-pagination/v11_7' +import { Pill as Pill_v11_7 } from '@instructure/ui-pill/v11_7' +import { Popover as Popover_v11_7 } from '@instructure/ui-popover/v11_7' +import { ProgressBar as ProgressBar_v11_7, ProgressCircle as ProgressCircle_v11_7 } from '@instructure/ui-progress/v11_7' +import { RadioInput as RadioInput_v11_7, RadioInputGroup as RadioInputGroup_v11_7 } from '@instructure/ui-radio-input/v11_7' +import { RangeInput as RangeInput_v11_7 } from '@instructure/ui-range-input/v11_7' +import { SideNavBar as SideNavBar_v11_7, SideNavBarItem as SideNavBarItem_v11_7 } from '@instructure/ui-side-nav-bar/v11_7' +import { SourceCodeEditor as SourceCodeEditor_v11_7 } from '@instructure/ui-source-code-editor/v11_7' +import { Spinner as Spinner_v11_7 } from '@instructure/ui-spinner/v11_7' +import { Table as Table_v11_7, TableContext as TableContext_v11_7, TableBody as TableBody_v11_7, TableCell as TableCell_v11_7, TableColHeader as TableColHeader_v11_7, TableHead as TableHead_v11_7, TableRow as TableRow_v11_7, TableRowHeader as TableRowHeader_v11_7 } from '@instructure/ui-table/v11_7' +import { Tabs as Tabs_v11_7, TabsPanel as TabsPanel_v11_7, TabsTab as TabsTab_v11_7 } from '@instructure/ui-tabs/v11_7' +import { Tag as Tag_v11_7 } from '@instructure/ui-tag/v11_7' +import { Text as Text_v11_7 } from '@instructure/ui-text/v11_7' +import { TextArea as TextArea_v11_7 } from '@instructure/ui-text-area/v11_7' +import { TextInput as TextInput_v11_7 } from '@instructure/ui-text-input/v11_7' +import { Tooltip as Tooltip_v11_7 } from '@instructure/ui-tooltip/v11_7' +import { Tray as Tray_v11_7 } from '@instructure/ui-tray/v11_7' +import { TreeBrowser as TreeBrowser_v11_7, TreeButton as TreeButton_v11_7, TreeCollection as TreeCollection_v11_7, TreeNode as TreeNode_v11_7 } from '@instructure/ui-tree-browser/v11_7' +import { TruncateText as TruncateText_v11_7 } from '@instructure/ui-truncate-text/v11_7' +import { ContextView as ContextView_v11_7, View as View_v11_7 } from '@instructure/ui-view/v11_7' + +const v11_7_overrides: Record = { + Alert: Alert_v11_7, + Avatar: Avatar_v11_7, + Badge: Badge_v11_7, + Billboard: Billboard_v11_7, + Breadcrumb: Breadcrumb_v11_7, + BreadcrumbLink: BreadcrumbLink_v11_7, + Calendar: Calendar_v11_7, + CalendarDay: CalendarDay_v11_7, + Checkbox: Checkbox_v11_7, + CheckboxFacade: CheckboxFacade_v11_7, + ToggleFacade: ToggleFacade_v11_7, + CheckboxGroup: CheckboxGroup_v11_7, + DateInput: DateInput_v11_7, + DateInput2: DateInput2_v11_7, + DrawerLayout: DrawerLayout_v11_7, + DrawerContent: DrawerContent_v11_7, + DrawerTray: DrawerTray_v11_7, + FileDrop: FileDrop_v11_7, + Flex: Flex_v11_7, + FlexItem: FlexItem_v11_7, + FormField: FormField_v11_7, + FormFieldLabel: FormFieldLabel_v11_7, + FormFieldMessage: FormFieldMessage_v11_7, + FormFieldMessages: FormFieldMessages_v11_7, + FormFieldLayout: FormFieldLayout_v11_7, + FormFieldGroup: FormFieldGroup_v11_7, + Grid: Grid_v11_7, + GridRow: GridRow_v11_7, + GridCol: GridCol_v11_7, + Heading: Heading_v11_7, + Link: Link_v11_7, + InlineList: InlineList_v11_7, + List: List_v11_7, + ListItem: ListItem_v11_7, + InlineListItem: InlineListItem_v11_7, + Menu: Menu_v11_7, + MenuItem: MenuItem_v11_7, + MenuItemGroup: MenuItemGroup_v11_7, + MenuItemSeparator: MenuItemSeparator_v11_7, + Metric: Metric_v11_7, + MetricGroup: MetricGroup_v11_7, + Modal: Modal_v11_7, + ModalBody: ModalBody_v11_7, + ModalFooter: ModalFooter_v11_7, + ModalHeader: ModalHeader_v11_7, + NumberInput: NumberInput_v11_7, + Options: Options_v11_7, + OptionItem: OptionItem_v11_7, + OptionSeparator: OptionSeparator_v11_7, + optionsThemeGenerator: optionsThemeGenerator_v11_7, + optionsItemThemeGenerator: optionsItemThemeGenerator_v11_7, + optionsSeparatorThemeGenerator: optionsSeparatorThemeGenerator_v11_7, + Pagination: Pagination_v11_7, + PaginationButton: PaginationButton_v11_7, + Pill: Pill_v11_7, + Popover: Popover_v11_7, + ProgressBar: ProgressBar_v11_7, + ProgressCircle: ProgressCircle_v11_7, + RadioInput: RadioInput_v11_7, + RadioInputGroup: RadioInputGroup_v11_7, + RangeInput: RangeInput_v11_7, + SideNavBar: SideNavBar_v11_7, + SideNavBarItem: SideNavBarItem_v11_7, + SourceCodeEditor: SourceCodeEditor_v11_7, + Spinner: Spinner_v11_7, + Table: Table_v11_7, + TableContext: TableContext_v11_7, + TableBody: TableBody_v11_7, + TableCell: TableCell_v11_7, + TableColHeader: TableColHeader_v11_7, + TableHead: TableHead_v11_7, + TableRow: TableRow_v11_7, + TableRowHeader: TableRowHeader_v11_7, + Tabs: Tabs_v11_7, + TabsPanel: TabsPanel_v11_7, + TabsTab: TabsTab_v11_7, + Tag: Tag_v11_7, + Text: Text_v11_7, + TextArea: TextArea_v11_7, + TextInput: TextInput_v11_7, + Tooltip: Tooltip_v11_7, + Tray: Tray_v11_7, + TreeBrowser: TreeBrowser_v11_7, + TreeButton: TreeButton_v11_7, + TreeCollection: TreeCollection_v11_7, + TreeNode: TreeNode_v11_7, + TruncateText: TruncateText_v11_7, + ContextView: ContextView_v11_7, + View: View_v11_7, +} + +// Registry: version → its overrides (self-contained, no stacking) +const overridesByVersion: Record> = { + 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..7c006066a8 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,20 @@ 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 defaults = Components as Record + Object.keys(defaults).forEach((key) => { + ;(global as any)[key] = defaults[key] + }) + 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__/package.json b/packages/__docs__/package.json index 4abd2bcb5e..b4285c085a 100644 --- a/packages/__docs__/package.json +++ b/packages/__docs__/package.json @@ -20,6 +20,7 @@ "lint": "ui-scripts lint", "lint:fix": "ui-scripts lint --fix", "build:scripts:ts": "tsc -b tsconfig.node.build.json", + "generate:component-overrides": "pnpm run build:scripts:ts && node lib/generate-component-overrides.mjs", "ts:check": "tsc -p tsconfig.build.json --noEmit --emitDeclarationOnly false", "clean": "ui-scripts clean", "createStatsFile": "mkdirp ./__build__ && webpack --profile --json > __build__/bundleStats.json", diff --git a/packages/__docs__/src/App/index.tsx b/packages/__docs__/src/App/index.tsx index f6b5ebdb72..1650804f04 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,57 @@ 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 + + const errorHandler = (error: Error) => { + if (error.name !== 'AbortError') { + logError(false, error.message) + } + } + + // Update globals BEFORE fetching new data — ensures any render + // triggered by the new docs uses the correct component references + updateGlobalsForVersion(newVersion) + + // Clear current data to show loading screen, update selected version + this.setState({ + docsData: null, + currentDocData: undefined, + changelogData: undefined, + selectedMinorVersion: newVersion + }) + + 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 +280,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,15 +295,38 @@ 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] + + const isBetaMode = new URLSearchParams(window.location.search).has('beta') + + if (isBetaMode) { + // 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 + // Update globals before fetching docs so renders use correct components + updateGlobalsForVersion(selectedMinorVersion) + this.setState({ minorVersionsData, 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) + .catch(errorHandler) + } else { + this.fetchMainDocsData('markdown-and-sources-data.json', signal).catch( + errorHandler + ) + } const [page] = this.getPathInfo() const isHomepage = page === 'index' || typeof page === 'undefined' @@ -535,21 +632,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 +695,7 @@ class App extends Component { themeVariables={themeVariables} repository={repository} layout={layout} + selectedMinorVersion={this.state.selectedMinorVersion} /> @@ -635,9 +741,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 +908,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} />