diff --git a/.github/workflows/auto-import-components.yml b/.github/workflows/auto-import-components.yml new file mode 100644 index 000000000000..c983b3b37d35 --- /dev/null +++ b/.github/workflows/auto-import-components.yml @@ -0,0 +1,89 @@ +name: Auto-import new components + +on: + workflow_dispatch: + schedule: + - cron: '0 6 * * *' + +permissions: + contents: write + pull-requests: write + +jobs: + auto-import-components: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: actions/setup-node@v3 + with: + node-version: 24 + + - name: Run auto-import script + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: npm run auto-import-components + + - name: Check for changes + id: diff + run: | + CHANGED=$(git diff --name-only) + if [ -z "$CHANGED" ]; then + echo "has_changes=false" >> $GITHUB_OUTPUT + else + echo "has_changes=true" >> $GITHUB_OUTPUT + fi + + - name: Exit if no changes + if: steps.diff.outputs.has_changes == 'false' + run: | + echo "No new components found — nothing to commit." + exit 0 + + - name: Parse added component names from diff + if: steps.diff.outputs.has_changes == 'true' + id: components + run: | + # Extract component names added to the auto-generated arrays (lines starting with + ") + NAMES=$(git diff -- 'src/content/components/*/auto-generated.ts' \ + | grep '^+ "' \ + | sed 's/^+ "//;s/",\?$//' \ + | sort -u \ + | tr '\n' ',' \ + | sed 's/,$//') + echo "names=$NAMES" >> $GITHUB_OUTPUT + + - name: Commit and push + if: steps.diff.outputs.has_changes == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + git config user.email "" + git config user.name "Auto-import components action" + git checkout -b auto-import-components + git add src/content/components/uikit/auto-generated.ts \ + src/content/components/date-components/auto-generated.ts \ + src/content/components/navigation/auto-generated.ts + git commit -m "feat: auto-import new components" + git push --set-upstream origin auto-import-components --force + + - name: Create or update pull request + if: steps.diff.outputs.has_changes == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + NAMES="${{ steps.components.outputs.names }}" + if [ -n "$NAMES" ]; then + BODY="## New components\n\n$(echo "$NAMES" | tr ',' '\n' | sed 's/^/- /')" + else + BODY="Auto-generated component configs updated." + fi + + gh pr create \ + --title "feat: auto-import new components" \ + --body "$(printf '%b' "$BODY")" \ + --base main \ + --head auto-import-components \ + 2>/dev/null || true diff --git a/package.json b/package.json index 4875ca30fd31..1ba3b0e59017 100644 --- a/package.json +++ b/package.json @@ -119,6 +119,7 @@ "build:analyze": "ANALYZE_BUNDLE=true next build", "start": "next start", "postbuild": "next-sitemap", + "auto-import-components": "node ./scripts/auto-import-components.mjs", "get-packages-versions": "node ./scripts/get-packages-versions.mjs", "prepare-metadata": "npm run get-packages-versions", "update-og-images": "node ./scripts/update-og-images.mjs", diff --git a/scripts/auto-import-components.mjs b/scripts/auto-import-components.mjs new file mode 100644 index 000000000000..de46220bc41a --- /dev/null +++ b/scripts/auto-import-components.mjs @@ -0,0 +1,123 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const ROOT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const LIBS = [ + { + githubRepo: 'gravity-ui/uikit', + reposValue: 'Uikit', + contentDir: path.join(ROOT_DIR, 'src/content/components/uikit'), + }, + { + githubRepo: 'gravity-ui/date-components', + reposValue: 'DateComponents', + contentDir: path.join(ROOT_DIR, 'src/content/components/date-components'), + }, + { + githubRepo: 'gravity-ui/navigation', + reposValue: 'Navigation', + contentDir: path.join(ROOT_DIR, 'src/content/components/navigation'), + }, +]; + +const PASCAL_CASE_RE = /^[A-Z]/; + +/** + * Fetch GitHub directory listing for src/components in the given repo. + * Returns array of PascalCase directory names. + */ +async function fetchGithubComponents(githubRepo) { + const url = `https://api.github.com/repos/${githubRepo}/contents/src/components`; + const headers = {'Accept': 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28'}; + + const token = process.env.GH_TOKEN; + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + + const response = await fetch(url, {headers}); + + if (!response.ok) { + const text = await response.text(); + throw new Error(`GitHub API error for ${githubRepo}: ${response.status} ${text}`); + } + + const items = await response.json(); + + return items + .filter((item) => item.type === 'dir' && PASCAL_CASE_RE.test(item.name)) + .map((item) => item.name); +} + +/** + * Read local component directories to build the manual (hand-crafted) set. + * We look at every subdirectory whose name starts with an uppercase letter, + * excluding the special "auto-generated" file. + */ +function getManualComponents(contentDir) { + const entries = fs.readdirSync(contentDir, {withFileTypes: true}); + return new Set( + entries + .filter((e) => e.isDirectory() && PASCAL_CASE_RE.test(e.name)) + .map((e) => e.name), + ); +} + +/** + * Build the content of the auto-generated.ts file. + */ +function buildFileContent(autoComponents, reposValue) { + const namesLiteral = + autoComponents.length === 0 + ? '[]' + : `[\n ${autoComponents.map((n) => JSON.stringify(n)).join(',\n ')},\n]`; + + return `// This file is auto-generated. Do not edit manually. +// Run 'npm run auto-import-components' to update. + +import {Repos} from '../../../types/common'; +import {createComponentConfig} from '../utils'; +import {Component} from '../types'; + +export const autoGeneratedComponents: Component[] = (${namesLiteral} as string[]).map((name) => + createComponentConfig(name, Repos.${reposValue}), +); +`; +} + +async function run() { + let anyError = false; + + for (const {githubRepo, reposValue, contentDir} of LIBS) { + console.log(`\nProcessing ${githubRepo}…`); + + try { + const githubComponents = await fetchGithubComponents(githubRepo); + const manualSet = getManualComponents(contentDir); + + const autoComponents = githubComponents.filter((name) => !manualSet.has(name)); + + console.log(` GitHub components : ${githubComponents.length}`); + console.log(` Manual components : ${manualSet.size}`); + console.log(` Auto-generated : ${autoComponents.length}`); + if (autoComponents.length > 0) { + console.log(` Components : ${autoComponents.join(', ')}`); + } + + const outPath = path.join(contentDir, 'auto-generated.ts'); + fs.writeFileSync(outPath, buildFileContent(autoComponents, reposValue), 'utf8'); + console.log(` Written → ${outPath}`); + } catch (err) { + console.error(` ERROR: ${err.message}`); + anyError = true; + } + } + + if (anyError) { + process.exit(1); + } +} + +run(); diff --git a/src/content/components/date-components/auto-generated.ts b/src/content/components/date-components/auto-generated.ts new file mode 100644 index 000000000000..92c79f5969eb --- /dev/null +++ b/src/content/components/date-components/auto-generated.ts @@ -0,0 +1,10 @@ +// This file is auto-generated. Do not edit manually. +// Run 'npm run auto-import-components' to update. + +import {Repos} from '../../../types/common'; +import {Component} from '../types'; +import {createComponentConfig} from '../utils'; + +export const autoGeneratedComponents: Component[] = ([] as string[]).map((name) => + createComponentConfig(name, Repos.DateComponents), +); diff --git a/src/content/components/date-components/index.ts b/src/content/components/date-components/index.ts index 3c196e322a53..c53977102b87 100644 --- a/src/content/components/date-components/index.ts +++ b/src/content/components/date-components/index.ts @@ -9,10 +9,12 @@ import {datePickerConfig} from './DatePicker'; import {rangeCalendarConfig} from './RangeCalendar'; import {relativeDateFieldConfig} from './RelativeDateField'; import {relativeDatePickerConfig} from './RelativeDatePicker'; +import {autoGeneratedComponents} from './auto-generated'; const config = getLibConfigById('date-components'); const components: Component[] = [ + ...autoGeneratedComponents, calendarConfig, dateFieldConfig, datePickerConfig, diff --git a/src/content/components/navigation/auto-generated.ts b/src/content/components/navigation/auto-generated.ts new file mode 100644 index 000000000000..f239809a64dc --- /dev/null +++ b/src/content/components/navigation/auto-generated.ts @@ -0,0 +1,10 @@ +// This file is auto-generated. Do not edit manually. +// Run 'npm run auto-import-components' to update. + +import {Repos} from '../../../types/common'; +import {Component} from '../types'; +import {createComponentConfig} from '../utils'; + +export const autoGeneratedComponents: Component[] = ([] as string[]).map((name) => + createComponentConfig(name, Repos.Navigation), +); diff --git a/src/content/components/navigation/index.ts b/src/content/components/navigation/index.ts index 6ea74705c18f..436b7be54375 100644 --- a/src/content/components/navigation/index.ts +++ b/src/content/components/navigation/index.ts @@ -11,10 +11,12 @@ import {footerConfig} from './Footer'; import {hotkeysPanelConfig} from './HotkeysPanel'; import {mobileHeaderConfig} from './MobileHeader'; import {settingsConfig} from './Settings'; +import {autoGeneratedComponents} from './auto-generated'; const config = getLibConfigById('navigation'); const components: Component[] = [ + ...autoGeneratedComponents, actionBarConfig, allPagesPanelConfig, asideHeaderConfig, diff --git a/src/content/components/uikit/auto-generated.ts b/src/content/components/uikit/auto-generated.ts new file mode 100644 index 000000000000..a4a298a5bcb2 --- /dev/null +++ b/src/content/components/uikit/auto-generated.ts @@ -0,0 +1,10 @@ +// This file is auto-generated. Do not edit manually. +// Run 'npm run auto-import-components' to update. + +import {Repos} from '../../../types/common'; +import {Component} from '../types'; +import {createComponentConfig} from '../utils'; + +export const autoGeneratedComponents: Component[] = ([] as string[]).map((name) => + createComponentConfig(name, Repos.Uikit), +); diff --git a/src/content/components/uikit/index.ts b/src/content/components/uikit/index.ts index 702f7b085b67..e35bfe7b9405 100644 --- a/src/content/components/uikit/index.ts +++ b/src/content/components/uikit/index.ts @@ -60,10 +60,12 @@ import {tocConfig} from './Toc'; import {tooltipConfig} from './Tooltip'; import {userConfig} from './User'; import {userLabelConfig} from './UserLabel'; +import {autoGeneratedComponents} from './auto-generated'; const config = getLibConfigById('uikit'); const uikitComponents: Component[] = [ + ...autoGeneratedComponents, accordionConfig, alertConfig, arrowToggleConfig, diff --git a/src/content/components/utils.ts b/src/content/components/utils.ts index 3f1cd7feacfa..6ea97d90a54f 100644 --- a/src/content/components/utils.ts +++ b/src/content/components/utils.ts @@ -2,6 +2,8 @@ import {TARGET_PROFILE} from '../../constants'; import packagesVersions from '../../data/packages-versions.json'; import {Repos} from '../../types/common'; +import {Component} from './types'; + const githubTargetProfile = process.env.GITHUB_PROFILE || TARGET_PROFILE; const TARGET_REPOS_VERSIONS = { @@ -39,3 +41,21 @@ export const mappingOptions = (arr: string[]) => value: item, content: item, })); + +export const toKebabCase = (name: string): string => + name.replace( + /([A-Z])/g, + (_, letter, offset) => (offset === 0 ? '' : '-') + letter.toLowerCase(), + ); + +export const toTitleCase = (name: string): string => + name.replace(/([A-Z])/g, (_, letter, offset) => (offset === 0 ? '' : ' ') + letter); + +export const createComponentConfig = (componentName: string, repoName: Repos): Component => ({ + id: toKebabCase(componentName), + title: toTitleCase(componentName), + githubUrl: getGithubUrl({componentName, repoName}), + content: { + readmeUrl: getReadmeUrl({componentName, repoName}), + }, +});