Files
react-test/packages/markdown/loader.mjs
how2ice 005cf56baf
Some checks failed
No response / noResponse (push) Has been cancelled
CI / Continuous releases (push) Has been cancelled
CI / test-dev (macos-latest) (push) Has been cancelled
CI / test-dev (ubuntu-latest) (push) Has been cancelled
CI / test-dev (windows-latest) (push) Has been cancelled
Maintenance / main (push) Has been cancelled
Scorecards supply-chain security / Scorecards analysis (push) Has been cancelled
CodeQL / Analyze (push) Has been cancelled
init project
2025-12-12 14:26:25 +09:00

720 lines
24 KiB
JavaScript

// @ts-check
import { promises as fs, readdirSync, statSync } from 'fs';
import path from 'path';
import prepareMarkdown from './prepareMarkdown.mjs';
import extractImports from './extractImports.mjs';
const notEnglishMarkdownRegExp = /-([a-z]{2})\.md$/;
/**
* @param {string} string
*/
function upperCaseFirst(string) {
return `${string[0].toUpperCase()}${string.slice(1)}`;
}
/**
* @param {string} moduleID
* @example moduleIDToJSIdentifier('./Box.js') === '$$IndexJs'
* @example moduleIDToJSIdentifier('./Box-new.js') === '$$BoxNewJs'
* @example moduleIDToJSIdentifier('../Box-new.js') === '$$$BoxNewJs'
*/
function moduleIDToJSIdentifier(moduleID) {
const delimiter = /(@|\.|-|\/|:)/;
return moduleID
.split(delimiter)
.filter((part) => !delimiter.test(part))
.map((part) => (part.length === 0 ? '$' : part))
.map(upperCaseFirst)
.join('');
}
/**
* @typedef {Record<string, Record<string, string>> } ComponentPackageMapping
*/
/** @type {ComponentPackageMapping | null} */
let componentPackageMapping = null;
/**
* @typedef {Object} Package
* @property {string[]} paths
* @property {string} productId
*/
/**
* @param {Package[]} packages
*/
function findComponents(packages) {
/** @type {ComponentPackageMapping} */
const mapping = {};
packages.forEach((pkg) => {
pkg.paths.forEach((pkgPath) => {
const match = pkgPath.match(/packages(?:\\|\/)([^/\\]+)(?:\\|\/)src/);
const packageName = match ? match[1] : null;
if (!packageName) {
throw new Error(`cannot find package name from path: ${pkgPath}`);
}
const filePaths = readdirSync(pkgPath);
filePaths.forEach((folder) => {
if (folder.match(/^[A-Z]/)) {
if (!mapping[pkg.productId]) {
mapping[pkg.productId] = {};
}
// filename starts with Uppercase = component
mapping[pkg.productId][folder] = packageName;
}
});
});
});
return mapping;
}
/**
* @typedef {Object} LoaderOptions
* @property {Package[]} packages
* @property {string[]} languagesInProgress
* @property {string} workspaceRoot
*/
/**
* @typedef {Object} ModuleData
* @property {string} module
* @property {string} raw
*/
/**
* @typedef {Object} Translation
* @property {string} filename
* @property {string} userLanguage
* @property {string} [markdown]
*/
/**
* @typedef {Object} Demo
* @property {string} module
* @property {string} [moduleTS]
* @property {string} [moduleTailwind]
* @property {string} [moduleTSTailwind]
* @property {string} [moduleCSS]
* @property {string} [moduleTSCSS]
* @property {string} raw
* @property {string} [rawTS]
* @property {string} [rawTailwind]
* @property {string} [rawTailwindTS]
* @property {string} [rawCSS]
* @property {string} [rawCSSTS]
* @property {string} [jsxPreview]
* @property {string} [tailwindJsxPreview]
* @property {string} [cssJsxPreview]
* @property {Object.<string, ModuleData[]>} [relativeModules]
*/
/**
* @type {import('webpack').LoaderDefinitionFunction<LoaderOptions>}
* @this {import('webpack').LoaderContext<LoaderOptions>}
*/
export default async function demoLoader() {
const englishFilepath = this.resourcePath;
const options = this.getOptions();
if (componentPackageMapping === null) {
componentPackageMapping = findComponents(options.packages ?? []);
}
const englishFilename = path.basename(englishFilepath, '.md');
const files = await fs.readdir(path.dirname(englishFilepath));
const translations = await Promise.all(
/** @type {Translation[]} */ (
files
.map((filename) => {
if (filename === `${englishFilename}.md`) {
return {
filename,
userLanguage: 'en',
};
}
const matchNotEnglishMarkdown = filename.match(notEnglishMarkdownRegExp);
if (
filename.startsWith(englishFilename) &&
matchNotEnglishMarkdown !== null &&
options.languagesInProgress.includes(matchNotEnglishMarkdown[1])
) {
return {
filename,
userLanguage: matchNotEnglishMarkdown[1],
};
}
return null;
})
.filter((translation) => translation)
).map(async (translation) => {
const filepath = path.join(path.dirname(englishFilepath), translation.filename);
this.addDependency(filepath);
const markdown = await fs.readFile(filepath, { encoding: 'utf8' });
return {
...translation,
markdown,
};
}),
);
// Use .. as the docs runs from the /docs folder
const fileRelativeContext = path
.relative(options.workspaceRoot, this.context)
// win32 to posix
.replace(/\\/g, '/');
const { docs } = prepareMarkdown({
fileRelativeContext,
translations,
componentPackageMapping,
options,
});
/** @type {Record<string, Demo>} */
const demos = {};
/** @type {Set<string>} */
const importedModuleIDs = new Set();
/** @type {Record<string, string>} */
const components = {};
/** @type {Set<string>} */
const demoModuleIDs = new Set();
/** @type {Set<string>} */
const componentModuleIDs = new Set();
/** @type {Set<string>} */
const nonEditableDemos = new Set();
/** @type {Map<string, Map<string, string[]>>} */
const relativeModules = new Map();
/** @type {string[]} */
const demoNames = Array.from(
new Set(
/** @type {import('./prepareMarkdown.mjs').DemoEntry[]} */ (
docs.en.rendered.filter((markdownOrComponentConfig) => {
return typeof markdownOrComponentConfig !== 'string' && markdownOrComponentConfig.demo;
})
).map((demoConfig) => {
if (demoConfig.hideToolbar) {
nonEditableDemos.add(demoConfig.demo);
}
return demoConfig.demo;
}),
),
);
/**
* @param {*} demoName
* @param {*} moduleFilepath
* @param {*} variant
* @param {*} importModuleID
* @returns {string} The name of the imported module along with a resolved extension if not provided
* @example detectRelativeImports('ComboBox.js', '', JS', './top100Films') => relativeModules.set('ComboBox.js', new Map([['./top100Films.js', ['JS']]]))
*/
function detectRelativeImports(demoName, moduleFilepath, variant, importModuleID) {
let relativeModuleFilename = importModuleID;
if (importModuleID.startsWith('.')) {
const demoMap = relativeModules.get(demoName);
// If the moduleID does not end with an extension, or ends with an unsupported extension (e.g. ".styling") we need to resolve it
// Fastest way to get a file extension, see: https://stackoverflow.com/a/12900504/
const importType = importModuleID.slice(
(Math.max(0, importModuleID.lastIndexOf('.')) || Infinity) + 1,
);
const supportedTypes = ['js', 'jsx', 'ts', 'tsx', 'css', 'json'];
if (!importType || !supportedTypes.includes(importType)) {
// If the demo is a JS demo, we can assume that the relative import is either
// a `.js` or a `.jsx` file, with `.js` taking precedence over `.jsx`
// likewise for TS demos, with `.ts` taking precedence over `.tsx`
const extensions =
variant === 'JS' ? ['.js', '.jsx', '.ts', '.tsx'] : ['.ts', '.tsx', '.js', '.jsx'];
const extension = extensions.find((ext) => {
try {
return statSync(path.join(moduleFilepath, '..', `${importModuleID}${ext}`));
} catch (error) {
// If the file does not exist, we return false and continue to the next extension
return false;
}
});
if (!extension) {
throw new Error(
[
`You are trying to import a module "${importModuleID}" in the demo "${demoName}" that could not be resolved.`,
`Please make sure that one of the following file exists:`,
...extensions.map((ext) => `- ${importModuleID}${ext}`),
].join('\n'),
);
} else {
relativeModuleFilename = `${importModuleID}${extension}`;
}
}
if (!demoMap) {
relativeModules.set(demoName, new Map([[relativeModuleFilename, [variant]]]));
} else {
const variantArray = demoMap.get(relativeModuleFilename);
if (variantArray) {
variantArray.push(variant);
} else {
demoMap.set(relativeModuleFilename, [variant]);
}
}
}
return relativeModuleFilename;
}
/**
* Inserts the moduleData into the relativeModules object
* @param {string} demoName
* @param {ModuleData} moduleData
* @param {string} variant
*/
function updateRelativeModules(demoName, moduleData, variant) {
const variantModule = /** @type {Object.<string, ModuleData[]>} */ (
demos[demoName].relativeModules
);
if (variantModule[variant]) {
// Avoid duplicates
if (!variantModule[variant].some((elem) => elem.module === moduleData.module)) {
variantModule[variant].push(moduleData);
}
} else {
variantModule[variant] = [moduleData];
}
}
await Promise.all(
demoNames.map(async (demoName) => {
const multipleDemoVersionsUsed = !demoName.endsWith('.js');
// TODO: const moduleID = demoName;
// The import paths currently use a completely different format.
// They should just use relative imports.
let moduleID = `./${demoName.replace(
`pages/${fileRelativeContext.replace(/^docs\/src\/pages\//, '')}/`,
'',
)}`;
if (multipleDemoVersionsUsed) {
moduleID = `${moduleID}/system/index.js`;
}
const moduleFilepath = path.join(
path.dirname(this.resourcePath),
moduleID.replace(/\//g, path.sep),
);
this.addDependency(moduleFilepath);
demos[demoName] = {
module: moduleID,
raw: await fs.readFile(moduleFilepath, { encoding: 'utf8' }),
};
demoModuleIDs.add(moduleID);
// Skip non-editable demos
if (!nonEditableDemos.has(demoName)) {
extractImports(demos[demoName].raw).forEach((importModuleID) => {
// detect relative import
detectRelativeImports(demoName, moduleFilepath, 'JS', importModuleID);
importedModuleIDs.add(importModuleID);
});
}
if (multipleDemoVersionsUsed) {
// Add Tailwind demo data
const tailwindModuleID = moduleID.replace('/system/index.js', '/tailwind/index.js');
try {
// Add JS demo data
const tailwindModuleFilepath = path.join(
path.dirname(this.resourcePath),
tailwindModuleID.replace(/\//g, path.sep),
);
demos[demoName].moduleTailwind = tailwindModuleID;
demos[demoName].rawTailwind = await fs.readFile(tailwindModuleFilepath, {
encoding: 'utf8',
});
this.addDependency(tailwindModuleFilepath);
demoModuleIDs.add(tailwindModuleID);
extractImports(demos[demoName].rawTailwind).forEach((importModuleID) =>
importedModuleIDs.add(importModuleID),
);
demoModuleIDs.add(demos[demoName].moduleTailwind);
} catch (error) {
// tailwind js demo doesn't exists
}
try {
// Add TS demo data
const tailwindTSModuleID = tailwindModuleID.replace('.js', '.tsx');
const tailwindTSModuleFilepath = path.join(
path.dirname(this.resourcePath),
tailwindTSModuleID.replace(/\//g, path.sep),
);
demos[demoName].moduleTSTailwind = tailwindTSModuleID;
demos[demoName].rawTailwindTS = await fs.readFile(tailwindTSModuleFilepath, {
encoding: 'utf8',
});
this.addDependency(tailwindTSModuleFilepath);
demoModuleIDs.add(tailwindTSModuleID);
extractImports(demos[demoName].rawTailwindTS).forEach((importModuleID) =>
importedModuleIDs.add(importModuleID),
);
demoModuleIDs.add(demos[demoName].moduleTSTailwind);
} catch (error) {
// tailwind TS demo doesn't exists
}
// Add plain CSS demo data
const cssModuleID = moduleID.replace('/system/index.js', '/css/index.js');
try {
// Add JS demo data
const cssModuleFilepath = path.join(
path.dirname(this.resourcePath),
cssModuleID.replace(/\//g, path.sep),
);
demos[demoName].moduleCSS = cssModuleID;
demos[demoName].rawCSS = await fs.readFile(cssModuleFilepath, {
encoding: 'utf8',
});
this.addDependency(cssModuleFilepath);
demoModuleIDs.add(cssModuleID);
extractImports(demos[demoName].rawCSS).forEach((importModuleID) =>
importedModuleIDs.add(importModuleID),
);
demoModuleIDs.add(demos[demoName].moduleCSS);
} catch (error) {
// plain css js demo doesn't exists
}
try {
// Add TS demo data
const cssTSModuleID = cssModuleID.replace('.js', '.tsx');
const cssTSModuleFilepath = path.join(
path.dirname(this.resourcePath),
cssTSModuleID.replace(/\//g, path.sep),
);
demos[demoName].moduleTSCSS = cssTSModuleID;
demos[demoName].rawCSSTS = await fs.readFile(cssTSModuleFilepath, {
encoding: 'utf8',
});
this.addDependency(cssTSModuleFilepath);
demoModuleIDs.add(cssTSModuleID);
extractImports(demos[demoName].rawCSSTS).forEach((importModuleID) =>
importedModuleIDs.add(importModuleID),
);
demoModuleIDs.add(demos[demoName].moduleTSCSS);
} catch (error) {
// plain css demo doesn't exists
}
// Tailwind preview
try {
const tailwindPreviewFilepath = moduleFilepath.replace(
`${path.sep}system${path.sep}index.js`,
`${path.sep}tailwind${path.sep}index.tsx.preview`,
);
const tailwindJsxPreview = await fs.readFile(tailwindPreviewFilepath, {
encoding: 'utf8',
});
this.addDependency(tailwindPreviewFilepath);
demos[demoName].tailwindJsxPreview = tailwindJsxPreview;
} catch (error) {
// No preview exists. This is fine.
}
// CSS preview
try {
const cssPreviewFilepath = moduleFilepath.replace(
`${path.sep}system${path.sep}index.js`,
`${path.sep}css${path.sep}index.tsx.preview`,
);
const cssJsxPreview = await fs.readFile(cssPreviewFilepath, {
encoding: 'utf8',
});
this.addDependency(cssPreviewFilepath);
demos[demoName].cssJsxPreview = cssJsxPreview;
} catch (error) {
// No preview exists. This is fine.
}
}
try {
const previewFilepath = moduleFilepath.replace(/\.js$/, '.tsx.preview');
const jsxPreview = await fs.readFile(previewFilepath, { encoding: 'utf8' });
this.addDependency(previewFilepath);
demos[demoName].jsxPreview = jsxPreview;
} catch (error) {
// No preview exists. This is fine.
}
try {
const moduleTS = moduleID.replace(/\.js$/, '.tsx');
const moduleTSFilepath = path.join(
path.dirname(this.resourcePath),
moduleTS.replace(/\//g, path.sep),
);
this.addDependency(moduleTSFilepath);
const rawTS = await fs.readFile(moduleTSFilepath, { encoding: 'utf8' });
// In development devs can choose whether they want to work on the TS or JS version.
// But this leads to building both demo version i.e. more build time.
demos[demoName].moduleTS = this.mode === 'production' ? moduleID : moduleTS;
demos[demoName].rawTS = rawTS;
// Extract relative imports from the TypeScript version
// of demos which have relative imports in the JS version
if (relativeModules.has(demoName)) {
extractImports(demos[demoName].rawTS).forEach((importModuleID) => {
detectRelativeImports(demoName, moduleTSFilepath, 'TS', importModuleID);
importedModuleIDs.add(importModuleID);
});
}
demoModuleIDs.add(demos[demoName].moduleTS);
} catch (error) {
// TS version of the demo doesn't exist. This is fine.
}
/* Map over relative import module IDs and resolve them
* while grouping by demo variant
* From:
* relativeModules: { 'ComboBox.js' =>
* { './top100Films.js' => ['JS', 'TS'] }
* }
* To:
* demos["ComboBox.js"].relativeModules = {
* JS: [{ module: './top100Films.js', raw: '...' }],
* TS: [{ module: './top100Films.js', raw: '...' }]
* }
* }
*/
if (relativeModules.has(demoName)) {
if (!demos[demoName].relativeModules) {
demos[demoName].relativeModules = {};
}
/** @type {Record<string, Set<string>>} */
const addedModulesRelativeToModulePathPerVariant = {};
const demoRelativeModules = /** @type {Map<string, string[]>} */ (
relativeModules.get(demoName)
);
await Promise.all(
Array.from(demoRelativeModules).map(async ([relativeModuleID, variants]) => {
for (const variant of variants) {
addedModulesRelativeToModulePathPerVariant[variant] ??= new Set();
const addedModulesRelativeToModulePath =
addedModulesRelativeToModulePathPerVariant[variant];
let raw = '';
const relativeModuleFilePath = path.join(
path.dirname(moduleFilepath),
relativeModuleID,
);
// the file has already been processed
if (addedModulesRelativeToModulePath.has(relativeModuleFilePath)) {
continue;
}
try {
// We are only iterating through an array that looks
// like this: ['JS', 'TS'], so it is safe to await
// eslint-disable-next-line no-await-in-loop
raw = await fs.readFile(relativeModuleFilePath, {
encoding: 'utf8',
});
const importedProcessedModuleIDs = new Set();
const importedProcessedModulesIDsParents = new Map();
// Find the relative paths in the relative module
extractImports(raw).forEach((importModuleID) => {
// detect relative import
const importModuleIdWithExtension = detectRelativeImports(
relativeModuleID,
relativeModuleFilePath,
variant,
importModuleID,
);
if (importModuleID.startsWith('.')) {
importedProcessedModuleIDs.add(importModuleIdWithExtension);
importedProcessedModulesIDsParents.set(
importModuleIdWithExtension,
relativeModuleFilePath,
);
}
});
updateRelativeModules(demoName, { module: relativeModuleID, raw }, variant);
addedModulesRelativeToModulePath.add(relativeModuleFilePath);
// iterate recursively over the relative imports
while (importedProcessedModuleIDs.size > 0) {
for (const entry of importedProcessedModuleIDs) {
if (entry.startsWith('.')) {
const entryModuleFilePath = path.join(
path.dirname(importedProcessedModulesIDsParents.get(entry)),
entry,
);
// We are only iterating through an array that looks
// like this: ['JS', 'TS'], so it is safe to await
// eslint-disable-next-line no-await-in-loop
const rawEntry = await fs.readFile(entryModuleFilePath, {
encoding: 'utf8',
});
extractImports(rawEntry).forEach((importModuleID) => {
// detect relative import
const importModuleIdWithExtension = detectRelativeImports(
relativeModuleID,
entryModuleFilePath,
variant,
importModuleID,
);
if (importModuleID.startsWith('.')) {
importedProcessedModuleIDs.add(importModuleIdWithExtension);
importedProcessedModulesIDsParents.set(
importModuleIdWithExtension,
entryModuleFilePath,
);
}
});
if (!addedModulesRelativeToModulePath.has(entryModuleFilePath)) {
const modulePathDirectory = moduleFilepath
.split('/')
.slice(0, -1)
.join('/');
const moduleData = {
module: `.${entryModuleFilePath.replace(modulePathDirectory, '')}`,
raw: rawEntry,
};
updateRelativeModules(demoName, moduleData, variant);
addedModulesRelativeToModulePath.add(entryModuleFilePath);
}
}
importedProcessedModuleIDs.delete(entry);
}
}
} catch {
throw new Error(
`Could not find a module for the relative import "${relativeModuleID}" in the demo "${demoName}"`,
);
}
}
}),
);
}
}),
);
/** @type {string[]} */
const componentNames = Array.from(
new Set(
/** @type {import('./prepareMarkdown.mjs').ComponentEntry[]} */ (
docs.en.rendered.filter((markdownOrComponentConfig) => {
return (
typeof markdownOrComponentConfig !== 'string' && markdownOrComponentConfig.component
);
})
).map((componentConfig) => {
return componentConfig.component;
}),
),
);
componentNames.forEach((componentName) => {
const moduleID = componentName.startsWith('@mui/docs/')
? componentName
: path.join(this.rootContext, 'src', componentName).replace(/\\/g, '/');
components[moduleID] = componentName;
componentModuleIDs.add(moduleID);
});
const transformed = `
${Array.from(importedModuleIDs)
.map((moduleID) => {
return `import * as ${moduleIDToJSIdentifier(
moduleID.replace('@', '$'),
)} from '${moduleID}';`;
})
.join('\n')}
${Array.from(demoModuleIDs)
.map((moduleID) => {
return `import ${moduleIDToJSIdentifier(moduleID)} from '${moduleID}';`;
})
.join('\n')}
${Array.from(componentModuleIDs)
.map((moduleID) => {
return `import ${moduleIDToJSIdentifier(moduleID)} from '${moduleID}';`;
})
.join('\n')}
export const docs = ${JSON.stringify(docs, null, 2)};
export const demos = ${JSON.stringify(demos, null, 2)};
demos.scope = {
process: {},
import: {
${Array.from(importedModuleIDs)
.map((moduleID) => ` "${moduleID}": ${moduleIDToJSIdentifier(moduleID.replace('@', '$'))},`)
.join('\n')}
},
};
export const demoComponents = {
${Array.from(demoModuleIDs)
.map((moduleID) => {
return ` "${moduleID}": ${moduleIDToJSIdentifier(moduleID)},`;
})
.join('\n')}
};
export const srcComponents = {
${Array.from(componentModuleIDs)
.map((moduleID) => {
return ` "${components[moduleID]}": ${moduleIDToJSIdentifier(moduleID)},`;
})
.join('\n')}
};
`;
return transformed;
}