Skip to content

Commit

Permalink
add docs codemod command for scripts directory
Browse files Browse the repository at this point in the history
  • Loading branch information
yannbf committed Jan 20, 2025
1 parent f75b007 commit 7d38d73
Show file tree
Hide file tree
Showing 4 changed files with 292 additions and 1 deletion.
2 changes: 2 additions & 0 deletions scripts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"check-package": "jiti ./check-package.ts",
"docs:prettier:check": "cd ../docs && prettier --check ./_snippets || echo 'Please run \"docs:prettier:write\" in the \"scripts\" directory to fix the issues'",
"docs:prettier:write": "cd ../docs && prettier --write ./_snippets",
"docs:codemod": "jiti ./snippets/codemod.ts",
"generate-sandboxes": "jiti ./sandbox/generate.ts",
"get-report-message": "jiti ./get-report-message.ts",
"get-template": "jiti ./get-template.ts",
Expand Down Expand Up @@ -131,6 +132,7 @@
"fs-extra": "^11.2.0",
"github-release-from-changelog": "^2.1.1",
"glob": "^10.4.5",
"globby": "^14.0.1",
"http-server": "^14.1.1",
"husky": "^4.3.7",
"jiti": "^1.21.6",
Expand Down
252 changes: 252 additions & 0 deletions scripts/snippets/codemod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
/* eslint-disable @typescript-eslint/default-param-last */
import os from 'node:os';
import { join } from 'node:path';

import { program } from 'commander';
import { promises as fs } from 'fs';
import pLimit from 'p-limit';
import picocolors from 'picocolors';
import slash from 'slash';

import { configToCsfFactory } from '../../code/lib/cli-storybook/src/codemod/helpers/config-to-csf-factory';
import { storyToCsfFactory } from '../../code/lib/cli-storybook/src/codemod/helpers/story-to-csf-factory';
import { SNIPPETS_DIRECTORY } from '../utils/constants';

const logger = console;

export const maxConcurrentTasks = Math.max(1, os.cpus().length - 1);

type SnippetInfo = {
path: string;
source: string;
attributes: {
filename?: string;
language?: string;
renderer?: string;
tabTitle?: string;
highlightSyntax?: string;
[key: string]: string;
};
};

type Codemod = {
check: (snippetInfo: SnippetInfo) => boolean;
transform: (snippetInfo: SnippetInfo) => string | Promise<string>;
};

export async function runSnippetCodemod({
glob,
check,
transform,
dryRun = false,
}: {
glob: string;
check: Codemod['check'];
transform: Codemod['transform'];
dryRun?: boolean;
}) {
let modifiedCount = 0;
let unmodifiedCount = 0;
let errorCount = 0;
let skippedCount = 0;

try {
// Dynamically import these packages because they are pure ESM modules
// eslint-disable-next-line depend/ban-dependencies
const { globby } = await import('globby');

const files = await globby(slash(glob), {
followSymbolicLinks: true,
ignore: ['node_modules/**', 'dist/**', 'storybook-static/**', 'build/**'],
});

if (!files.length) {
logger.error(`No files found for pattern ${glob}`);
return;
}

const limit = pLimit(10);

await Promise.all(
files.map((file) =>
limit(async () => {
try {
const source = await fs.readFile(file, 'utf-8');
const snippets = extractSnippets(source);
if (snippets.length === 0) {
unmodifiedCount++;
return;
}

const targetSnippet = snippets.find(check);
if (!targetSnippet) {
skippedCount++;
logger.log('Skipping file', file);
return;
}

const counterpartSnippets = snippets.filter((snippet) => {
return (
snippet !== targetSnippet &&
snippet.attributes.renderer === targetSnippet.attributes.renderer &&
snippet.attributes.language !== targetSnippet.attributes.language
);
});

const getSource = (snippet: SnippetInfo) =>
`\n\`\`\`${formatAttributes(snippet.attributes)}\n${snippet.source}\n\`\`\`\n`;

try {
let appendedContent = '';
if (counterpartSnippets.length > 0) {
appendedContent +=
'\n<!-- js & ts-4-9 (when applicable) still needed while providing both CSF 3 & 4 -->\n';
}

for (const snippet of [targetSnippet, ...counterpartSnippets]) {
const newSnippet = { ...snippet };
newSnippet.attributes.tabTitle = 'CSF 4 (experimental)';
appendedContent += getSource({
...newSnippet,
attributes: {
...newSnippet.attributes,
renderer: 'react',
tabTitle: 'CSF 4 (experimental)',
},
source: await transform(newSnippet),
});
}

const updatedSource = source + appendedContent;

if (!dryRun) {
await fs.writeFile(file, updatedSource, 'utf-8');
} else {
logger.log(
`Dry run: would have modified ${picocolors.yellow(file)} with \n` +
picocolors.green(appendedContent)
);
}

modifiedCount++;
} catch (transformError) {
logger.error(`Error transforming snippet in file ${file}:`, transformError);
errorCount++;
}
} catch (fileError) {
logger.error(`Error processing file ${file}:`, fileError);
errorCount++;
}
})
)
);
} catch (error) {
logger.error('Error applying snippet transform:', error);
errorCount++;
}

logger.log(
`Summary: ${picocolors.green(`${modifiedCount} files modified`)}, ${picocolors.yellow(`${unmodifiedCount} files unmodified`)}, ${picocolors.gray(`${skippedCount} skipped`)}, ${picocolors.red(`${errorCount} errors`)}`
);
}

export function extractSnippets(source: string): SnippetInfo[] {
const snippetRegex =
/```(?<highlightSyntax>[a-zA-Z0-9]+)?(?<attributes>[^\n]*)\n(?<content>[\s\S]*?)```/g;
const snippets: SnippetInfo[] = [];
let match;

while ((match = snippetRegex.exec(source)) !== null) {
const { highlightSyntax, attributes, content } = match.groups || {};
const snippetAttributes = parseAttributes(attributes || '');
if (highlightSyntax) {
snippetAttributes.highlightSyntax = highlightSyntax.trim();
}

snippets.push({
path: snippetAttributes.filename || '',
source: content.trim(),
attributes: snippetAttributes,
});
}

return snippets;
}

export function parseAttributes(attributes: string): Record<string, string> {
const attributeRegex = /([a-zA-Z0-9.-]+)="([^"]+)"/g;
const result: Record<string, string> = {};
let match;

while ((match = attributeRegex.exec(attributes)) !== null) {
result[match[1]] = match[2];
}

return result;
}

function formatAttributes(attributes: Record<string, string>): string {
const formatted = Object.entries(attributes)
.filter(([key]) => key !== 'highlightSyntax')
.map(([key, value]) => `${key}="${value}"`)
.join(' ');
return `${attributes.highlightSyntax || 'js'} ${formatted}`;
}

const codemods: Record<string, Codemod> = {
'csf-factory-story': {
check: (snippetInfo: SnippetInfo) => {
return (
snippetInfo.path.includes('.stories') &&
snippetInfo.attributes.tabTitle !== 'CSF 4 (experimental)' &&
snippetInfo.attributes.language === 'ts' &&
(snippetInfo.attributes.renderer === 'react' ||
snippetInfo.attributes.renderer === 'common')
);
},
transform: storyToCsfFactory,
},
'csf-factory-config': {
check: (snippetInfo: SnippetInfo) => {
return (
snippetInfo.attributes.tabTitle !== 'CSF 4 (experimental)' &&
(snippetInfo.path.includes('preview') || snippetInfo.path.includes('main'))
);
},
transform: (snippetInfo: SnippetInfo) => {
const configType = snippetInfo.path.includes('preview') ? 'preview' : 'main';
return configToCsfFactory(snippetInfo, {
configType,
frameworkPackage: '@storybook/your-framework',
});
},
},
};

program
.name('command')
.description('A minimal CLI for demonstration')
.argument('<id>', 'ID to process')
.requiredOption('--glob <pattern>', 'Glob pattern to match')
.option('--dry-run', 'Run without making actual changes', false)
.action(async (id, { glob, dryRun }) => {
const codemod = codemods[id as keyof typeof codemods];
if (!codemod) {
logger.error(`Unknown codemod "${id}"`);
logger.log(
`\n\nAvailable codemods: ${Object.keys(codemods)
.map((c) => `\n- ${c}`)
.join('')}`
);
process.exit(1);
}

await runSnippetCodemod({
glob: join(SNIPPETS_DIRECTORY, glob),
dryRun,
...codemod,
});
});

// Parse and validate arguments
program.parse(process.argv);
1 change: 1 addition & 0 deletions scripts/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const BEFORE_DIR_NAME = 'before-storybook';

export const ROOT_DIRECTORY = join(__dirname, '..', '..');
export const CODE_DIRECTORY = join(ROOT_DIRECTORY, 'code');
export const SNIPPETS_DIRECTORY = join(ROOT_DIRECTORY, 'docs', '_snippets');
export const PACKS_DIRECTORY = join(ROOT_DIRECTORY, 'packs');
export const REPROS_DIRECTORY = join(ROOT_DIRECTORY, 'repros');
export const SANDBOX_DIRECTORY = join(ROOT_DIRECTORY, 'sandbox');
Expand Down
38 changes: 37 additions & 1 deletion scripts/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1538,6 +1538,13 @@ __metadata:
languageName: node
linkType: hard

"@sindresorhus/merge-streams@npm:^2.1.0":
version: 2.3.0
resolution: "@sindresorhus/merge-streams@npm:2.3.0"
checksum: 10c0/69ee906f3125fb2c6bb6ec5cdd84e8827d93b49b3892bce8b62267116cc7e197b5cccf20c160a1d32c26014ecd14470a72a5e3ee37a58f1d6dadc0db1ccf3894
languageName: node
linkType: hard

"@snyk/github-codeowners@npm:1.1.0":
version: 1.1.0
resolution: "@snyk/github-codeowners@npm:1.1.0"
Expand Down Expand Up @@ -1673,6 +1680,7 @@ __metadata:
fs-extra: "npm:^11.2.0"
github-release-from-changelog: "npm:^2.1.1"
glob: "npm:^10.4.5"
globby: "npm:^14.0.1"
http-server: "npm:^14.1.1"
husky: "npm:^4.3.7"
jiti: "npm:^1.21.6"
Expand Down Expand Up @@ -7121,6 +7129,20 @@ __metadata:
languageName: node
linkType: hard

"globby@npm:^14.0.1":
version: 14.0.2
resolution: "globby@npm:14.0.2"
dependencies:
"@sindresorhus/merge-streams": "npm:^2.1.0"
fast-glob: "npm:^3.3.2"
ignore: "npm:^5.2.4"
path-type: "npm:^5.0.0"
slash: "npm:^5.1.0"
unicorn-magic: "npm:^0.1.0"
checksum: 10c0/3f771cd683b8794db1e7ebc8b6b888d43496d93a82aad4e9d974620f578581210b6c5a6e75ea29573ed16a1345222fab6e9b877a8d1ed56eeb147e09f69c6f78
languageName: node
linkType: hard

"globby@npm:^7.1.1":
version: 7.1.1
resolution: "globby@npm:7.1.1"
Expand Down Expand Up @@ -11055,6 +11077,13 @@ __metadata:
languageName: node
linkType: hard

"path-type@npm:^5.0.0":
version: 5.0.0
resolution: "path-type@npm:5.0.0"
checksum: 10c0/e8f4b15111bf483900c75609e5e74e3fcb79f2ddb73e41470028fcd3e4b5162ec65da9907be077ee5012c18801ff7fffb35f9f37a077f3f81d85a0b7d6578efd
languageName: node
linkType: hard

"pathe@npm:^2.0.0":
version: 2.0.1
resolution: "pathe@npm:2.0.1"
Expand Down Expand Up @@ -13262,7 +13291,7 @@ __metadata:
languageName: node
linkType: hard

"slash@npm:^5.0.0":
"slash@npm:^5.0.0, slash@npm:^5.1.0":
version: 5.1.0
resolution: "slash@npm:5.1.0"
checksum: 10c0/eb48b815caf0bdc390d0519d41b9e0556a14380f6799c72ba35caf03544d501d18befdeeef074bc9c052acf69654bc9e0d79d7f1de0866284137a40805299eb3
Expand Down Expand Up @@ -14504,6 +14533,13 @@ __metadata:
languageName: node
linkType: hard

"unicorn-magic@npm:^0.1.0":
version: 0.1.0
resolution: "unicorn-magic@npm:0.1.0"
checksum: 10c0/e4ed0de05b0a05e735c7d8a2930881e5efcfc3ec897204d5d33e7e6247f4c31eac92e383a15d9a6bccb7319b4271ee4bea946e211bf14951fec6ff2cbbb66a92
languageName: node
linkType: hard

"unified-args@npm:^11.0.0":
version: 11.0.1
resolution: "unified-args@npm:11.0.1"
Expand Down

0 comments on commit 7d38d73

Please sign in to comment.