init project
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

This commit is contained in:
how2ice
2025-12-12 14:26:25 +09:00
commit 005cf56baf
43188 changed files with 1079531 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
# Changelog
## 1.0.0
First release as an npm package.

View File

@@ -0,0 +1,11 @@
# @mui-internal/markdown
MUI markdown parser and webpack loader.
This is an internal package not meant for general use.
## Release
There is no build step.
Sources are meant to be used directly.
To publish the build artifacts to npm run `pnpm release:publish`.

View File

@@ -0,0 +1,12 @@
// @ts-check
const importModuleRegexp =
/^\s*import (?:["'\s]*(?:[\w*{}\n, ]+)from\s*)?["'\s]*([^"'{}$\s]+)["'\s].*/gm;
/**
* @param {string} code
* @returns {string[]}
*/
export default function extractImports(code) {
return (code.match(importModuleRegexp) || []).map((x) => x.replace(importModuleRegexp, '$1'));
}

View File

@@ -0,0 +1,39 @@
import { expect } from 'chai';
import extractImports from './extractImports.mjs';
describe('extractImports', () => {
it('finds all imports', () => {
const imports = extractImports(`
import {
Component
} from '@angular2/core';
import defaultMember from "module-1";
import * as name from "module-2 ";
import { member } from " module-3";
import { member as alias } from "module-4";
import { member1 ,
member2 } from "module-5";
import { member1 , member2 as alias2 , member3 as alias3 } from "module-6";
import defaultMember, { member, member } from "module-7";
import defaultMember, * as name from "module-8";
import "module-9";
import "module-10";
import * from './smdn';
import \${importName} from 'module11/\${importName}';
`);
expect(imports[0]).to.equal('@angular2/core');
expect(imports[1]).to.equal('module-1');
expect(imports[2]).to.equal('module-2');
expect(imports[3]).to.equal('module-3');
expect(imports[4]).to.equal('module-4');
expect(imports[5]).to.equal('module-5');
expect(imports[6]).to.equal('module-6');
expect(imports[7]).to.equal('module-7');
expect(imports[8]).to.equal('module-8');
expect(imports[9]).to.equal('module-9');
expect(imports[10]).to.equal('module-10');
expect(imports[11]).to.equal('./smdn');
expect(imports[12]).to.equal(undefined); // It's not a valid import
});
});

View File

@@ -0,0 +1,38 @@
interface TableOfContentsEntry {
children: TableOfContentsEntry[];
hash: string;
level: number;
text: string;
}
export function createRender(context: {
headingHashes?: Record<string, string>;
toc?: TableOfContentsEntry[];
userLanguage?: string;
ignoreLanguagePages?: (path: string) => boolean;
options: object;
}): (markdown: string) => string;
export interface MarkdownHeaders {
packageName?: string;
productId: string;
githubLabel?: string;
waiAria?: string;
materialDesign?: string;
components: string[];
hooks?: string[];
slug?: string;
title?: string;
description?: string;
image?: string;
tags?: string[];
authors?: string[];
date?: string;
githubSource?: string;
}
export function getHeaders(markdown: string): MarkdownHeaders;
export function getTitle(markdown: string): string;
export function renderMarkdown(markdown: string): string;

View File

@@ -0,0 +1 @@
export { createRender, getHeaders, getTitle, renderMarkdown } from './parseMarkdown.mjs';

View File

@@ -0,0 +1,719 @@
// @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;
}

View File

@@ -0,0 +1,40 @@
{
"name": "@mui/internal-markdown",
"version": "2.0.13",
"author": "MUI Team",
"description": "MUI markdown parser. This is an internal package not meant for general use.",
"main": "./index.mjs",
"browser": "./index.mjs",
"types": "./index.d.mts",
"exports": {
".": "./index.mjs",
"./loader": "./loader.mjs",
"./prism": {
"types": "./prism.d.mts",
"require": null,
"import": "./prism.mjs"
}
},
"repository": {
"type": "git",
"url": "git+https://github.com/mui/material-ui.git",
"directory": "packages/markdown"
},
"scripts": {
"release:publish": "pnpm publish --tag latest",
"release:publish:dry-run": "pnpm publish --tag latest --registry=\"http://localhost:4873/\""
},
"dependencies": {
"@babel/runtime": "^7.28.4",
"es-toolkit": "^1.42.0",
"marked": "^17.0.1",
"prismjs": "^1.30.0"
},
"devDependencies": {
"@types/chai": "^5.2.3",
"chai": "^6.0.1"
},
"publishConfig": {
"access": "public"
}
}

View File

@@ -0,0 +1,498 @@
import { marked } from 'marked';
import textToHash from './textToHash.mjs';
import prism from './prism.mjs';
/**
* Option used by `marked` the library parsing markdown.
*/
const markedOptions = {
gfm: true,
breaks: false,
pedantic: false,
};
const headerRegExp = /---[\r\n]([\s\S]*)[\r\n]---/;
const titleRegExp = /# (.*)[\r\n]/;
const descriptionRegExp = /<p class="description">(.*?)<\/p>/s;
const headerKeyValueRegExp = /(.*?):[\r\n]?\s+(\[[^\]]+\]|.*)/g;
const emptyRegExp = /^\s*$/;
/**
* Same as https://github.com/markedjs/marked/blob/master/src/helpers.js
* Need to duplicate because `marked` does not export `escape` function
*/
const escapeTest = /[&<>"']/;
const escapeReplace = /[&<>"']/g;
const escapeTestNoEncode = /[<>"']|&(?!#?\w+;)/;
const escapeReplaceNoEncode = /[<>"']|&(?!#?\w+;)/g;
const escapeReplacements = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
};
const getEscapeReplacement = (ch) => escapeReplacements[ch];
function escape(html, encode) {
if (encode) {
if (escapeTest.test(html)) {
return html.replace(escapeReplace, getEscapeReplacement);
}
} else if (escapeTestNoEncode.test(html)) {
return html.replace(escapeReplaceNoEncode, getEscapeReplacement);
}
return html;
}
function checkUrlHealth(href, linkText, context) {
const url = new URL(href, 'https://mui.com/');
if (/\/{2,}$/.test(url.pathname)) {
throw new Error(
[
'docs-infra: Duplicated trailing slashes. The following link:',
`[${linkText}](${href}) in ${context.location} has duplicated trailing slashes, please only add one.`,
'',
'See https://ahrefs.com/blog/trailing-slash/ for more details.',
'',
].join('\n'),
);
}
// External links to MUI, ignore
if (url.host !== 'mui.com') {
return;
}
/**
* Break for links like:
* /material-ui/customization/theming
*
* It needs to be:
* /material-ui/customization/theming/
*/
if (!url.pathname.endsWith('/')) {
throw new Error(
[
'docs-infra: Missing trailing slash. The following link:',
`[${linkText}](${href}) in ${context.location} is missing a trailing slash, please add it.`,
'',
'See https://ahrefs.com/blog/trailing-slash/ for more details.',
'',
].join('\n'),
);
}
// Relative links
if (href[0] !== '#' && !(href.startsWith('https://') || href.startsWith('http://'))) {
/**
* Break for links like:
* material-ui/customization/theming/
*
* It needs to be:
* /material-ui/customization/theming/
*/
if (href[0] !== '/') {
throw new Error(
[
'docs-infra: Missing leading slash. The following link:',
`[${linkText}](${href}) in ${context.location} is missing a leading slash, please add it.`,
'',
].join('\n'),
);
}
}
}
/**
* Extract information from the top of the markdown.
* For instance, the following input:
*
* ---
* title: Backdrop React Component
* components: Backdrop
* ---
*
* # Backdrop
*
* should output:
* { title: 'Backdrop React Component', components: ['Backdrop'] }
*/
function getHeaders(markdown) {
let header = markdown.match(headerRegExp);
if (!header) {
return {
components: [],
};
}
header = header[1];
try {
let regexMatches;
const headers = {};
// eslint-disable-next-line no-cond-assign
while ((regexMatches = headerKeyValueRegExp.exec(header)) !== null) {
const key = regexMatches[1];
let value = regexMatches[2].replace(/(.*)/, '$1');
if (value.startsWith('[')) {
// Need double quotes to JSON parse.
value = value.replace(/'/g, '"');
// Remove the comma after the last value e.g. ["foo", "bar",] -> ["foo", "bar"].
value = value.replace(/,\s+\]$/g, ']');
headers[key] = JSON.parse(value);
} else {
// Remove quote YAML escaping.
headers[key] = value.replace(/^"|"$|^'|'$/g, '');
}
}
if (headers.components) {
headers.components = headers.components
.split(',')
.map((x) => x.trim())
.sort();
} else {
headers.components = [];
}
if (headers.hooks) {
headers.hooks = headers.hooks
.split(',')
.map((x) => x.trim())
.sort();
} else {
headers.hooks = [];
}
return headers;
} catch (err) {
throw new Error(
`docs-infra: ${err.message} in getHeader(markdown) with markdown: \n\n${header}\n`,
);
}
}
function getContents(markdown) {
const rep = markdown
.replace(headerRegExp, '') // Remove header information
.split(/^{{("(?:demo|component)":.*)}}$/gm) // Split markdown into an array, separating demos
.flatMap((text) => text.split(/^(<codeblock.*?<\/codeblock>)$/gmsu))
.flatMap((text) => text.split(/^(<featureList.*?<\/featureList>)$/gmsu))
.filter((content) => !emptyRegExp.test(content)); // Remove empty lines
return rep;
}
function getTitle(markdown) {
const matches = markdown.match(titleRegExp);
if (matches === null) {
return '';
}
return matches[1].replace(/`/g, '');
}
function getDescription(markdown) {
const matches = markdown.match(descriptionRegExp);
if (matches === null) {
return undefined;
}
return matches[1].trim().replace(/`/g, '');
}
function getCodeblock(content) {
if (!content.startsWith('<codeblock')) {
return undefined;
}
// The regexes below have a negative lookahead to prevent ReDoS
// See https://github.com/mui/material-ui/issues/44078
const storageKey = content.match(
/^<codeblock (?!<codeblock )[^>]*storageKey=["|'](?!storageKey=["|'])(\S*)["|'].*>/m,
)?.[1];
const blocks = [...content.matchAll(/^```(\S*) (\S*)\n(.*?)\n```/gmsu)].map(
([, language, tab, code]) => ({ language, tab, code }),
);
const blocksData = blocks.filter(
(block) => block.tab !== undefined && !emptyRegExp.test(block.code),
);
return {
type: 'codeblock',
data: blocksData,
storageKey,
};
}
function getFeatureList(content) {
if (!content.startsWith('<featureList')) {
return undefined;
}
const lines = content
.split('\n')
.filter((line) => line.startsWith('- '))
.map((line) => line.slice(2));
return ['<ul class="feature-list">', ...lines.map((line) => `<li>${line}</li>`), '</ul>'].join(
'',
);
}
/**
* @param {string} markdown
*/
function renderMarkdown(markdown) {
// Check if the markdown contains an inline list. Unordered lists are block elements and cannot be parsed inline.
if (/[-*+] `([A-Za-z]+)`/g.test(markdown)) {
return marked.parse(markdown, markedOptions);
}
// Two new lines result in a newline in the table.
// All other new lines must be eliminated to prevent markdown mayhem.
return marked
.parseInline(markdown, markedOptions)
.replace(/(\r?\n){2}/g, '<br>')
.replace(/\r?\n/g, ' ');
}
// Help rank mui.com on component searches first.
const noSEOadvantage = [
'https://m2.material.io/',
'https://m3.material.io/',
'https://material.io/',
'https://getbootstrap.com/',
'https://icons.getbootstrap.com/',
'https://pictogrammers.com/',
'https://www.w3.org/',
'https://tailwindcss.com/',
'https://heroicons.com/',
'https://react-icons.github.io/',
'https://fontawesome.com/',
'https://react-spectrum.adobe.com/',
'https://headlessui.com/',
'https://refine.dev/',
'https://scaffoldhub.io/',
'https://marmelab.com/',
'https://framesxdesign.com/',
];
/**
* Creates a function that MUST be used to render non-inline markdown.
* It keeps track of a table of contents and hashes of its items.
* This is important to create anchors that are invariant between languages.
*
* @typedef {object} TableOfContentsEntry
* @property {TableOfContentsEntry[]} children
* @property {string} hash
* @property {number} level
* @property {string} text
* @param {object} context
* @param {Record<string, string>} [context.headingHashes] - WILL BE MUTATED
* @param {TableOfContentsEntry[]} [context.toc] - WILL BE MUTATED
* @param {string} [context.userLanguage]
* @param {object} context.options
*/
function createRender(context) {
const { headingHashes = {}, toc = [], userLanguage = 'en', options } = context;
const headingHashesFallbackTranslated = {};
let headingIndex = -1;
/**
* @param {string} markdown
*/
function render(markdown) {
const renderer = new marked.Renderer();
renderer.heading = function heading({ tokens, depth: level }) {
// Main title, no need for an anchor.
// It adds noises to the URL.
//
// Small title, no need for an anchor.
// It reduces the risk of duplicated id and it's fewer elements in the DOM.
const headingHtml = this.parser.parseInline(tokens);
if (level === 1 || level >= 4) {
return `<h${level}>${headingHtml}</h${level}>`;
}
// Remove links to avoid nested links in the TOCs
let headingText = headingHtml.replace(/<a\b[^>]*>/gi, '').replace(/<\/a>/gi, '');
// Remove `code` tags
headingText = headingText.replace(/<code\b[^>]*>/gi, '').replace(/<\/code>/gi, '');
// Standardizes the hash from the default location (en) to different locations
// Need english.md file parsed first
let hash;
if (userLanguage === 'en') {
hash = textToHash(headingText, headingHashes);
} else {
headingIndex += 1;
hash = Object.keys(headingHashes)[headingIndex];
if (!hash) {
hash = textToHash(headingText, headingHashesFallbackTranslated);
}
}
// enable splitting of long words from function name + first arg name
// Closing parens are less interesting since this would only allow breaking one character earlier.
// Applying the same mechanism would also allow breaking of non-function signatures like "Community help (free)".
// To detect that we enabled breaking of open/closing parens we'd need a context-sensitive parser.
const displayText = headingText.replace(/([^\s]\()/g, '$1&#8203;');
// create a nested structure with 2 levels starting with level 2 e.g.
// [{...level2, children: [level3, level3, level3]}, level2]
if (level === 2) {
toc.push({
text: displayText,
level,
hash,
children: [],
});
} else if (level === 3) {
if (!toc[toc.length - 1]) {
throw new Error(`docs-infra: Missing parent level for: ${headingText}\n`);
}
toc[toc.length - 1].children.push({
text: displayText,
level,
hash,
});
}
return [
headingHtml.includes('<a ')
? [
// Avoid breaking the anchor link button
`<h${level} id="${hash}">${headingHtml}`,
`<a href="#${hash}" class="title-link-to-anchor" aria-labelledby="${hash}"><span class="anchor-icon"><svg><use xlink:href="#anchor-link-icon" /></svg></span></a>`,
].join('')
: `<h${level} id="${hash}"><a href="#${hash}" class="title-link-to-anchor">${headingHtml}<span class="anchor-icon"><svg><use xlink:href="#anchor-link-icon" /></svg></span></a>`,
`<button title="Post a comment" class="comment-link" data-feedback-hash="${hash}">`,
'<svg><use xlink:href="#comment-link-icon" /></svg>',
`</button>`,
`</h${level}>`,
].join('');
};
renderer.link = function link({ href, title, tokens }) {
const linkText = this.parser.parseInline(tokens);
let more = '';
if (title) {
more += ` title="${title}"`;
}
if (noSEOadvantage.some((domain) => href.includes(domain))) {
more = ' target="_blank" rel="noopener nofollow"';
}
let finalHref = href;
checkUrlHealth(href, linkText, context);
if (userLanguage !== 'en' && href.startsWith('/') && !options.ignoreLanguagePages(href)) {
finalHref = `/${userLanguage}${href}`;
}
// This logic turns link like:
// https://github.com/mui/material-ui/blob/-/packages/mui-joy/src/styles/components.d.ts
// into a permalink:
// https://github.com/mui/material-ui/blob/v5.11.15/packages/mui-joy/src/styles/components.d.ts
if (finalHref.startsWith(`${options.env.SOURCE_CODE_REPO}/blob/-/`)) {
finalHref = finalHref.replace(
`${options.env.SOURCE_CODE_REPO}/blob/-/`,
`${options.env.SOURCE_CODE_REPO}/blob/v${options.env.LIB_VERSION}/`,
);
}
return `<a href="${finalHref}"${more}>${linkText}</a>`;
};
renderer.code = ({ lang, text, escaped }) => {
// https://github.com/markedjs/marked/blob/30e90e5175700890e6feb1836c57b9404c854466/src/Renderer.js#L15
const langString = (lang || '').match(/\S*/)[0];
const title = (lang || '').match(/title="([^"]*)"/)?.[1];
const out = prism(text, langString);
if (out != null && out !== text) {
escaped = true;
text = out;
}
const code = `${text.replace(/\n$/, '')}\n`;
if (!lang) {
return `<pre><code>${escaped ? code : escape(code, true)}</code></pre>\n`;
}
return `<div class="MuiCode-root">${title ? `<div class="MuiCode-title">${title}</div>` : ''}<pre><code class="language-${escape(lang, true)}">${
escaped ? code : escape(code, true)
}</code></pre>${[
'<button data-ga-event-category="code" data-ga-event-action="copy-click" aria-label="Copy the code" class="MuiCode-copy">',
'<span class="MuiCode-copy-label">Copy</span>',
'<span class="MuiCode-copied-label">Copied</span>',
'<span class="MuiCode-copyKeypress"><span>(or</span> $keyC<span>)</span></span></button></div>',
].join('')}\n`;
};
marked.use({
extensions: [
{
name: 'callout',
level: 'block',
start(src) {
const match = src.match(/:::/);
return match ? match.index : undefined;
},
tokenizer(src) {
const rule =
/^ {0,3}(:{3,}(?=[^:\n]*\n)|~{3,})([^\n]*)\n(?:|([\s\S]*?)\n)(?: {0,3}\1[~:]* *(?=\n|$)|$)/;
const match = rule.exec(src);
if (match) {
const token = {
type: 'callout',
raw: match[0],
text: match[3].trim(),
severity: match[2],
tokens: [],
};
this.lexer.blockTokens(token.text, token.tokens);
return token;
}
return undefined;
},
renderer(token) {
if (!['info', 'success', 'warning', 'error'].includes(token.severity)) {
throw new Error(`docs-infra: Callout :::${token.severity} is not supported`);
}
return `<aside class="MuiCallout-root MuiCallout-${token.severity}">${[
'<div class="MuiCallout-icon-container">',
'<svg focusable="false" aria-hidden="true" viewBox="0 0 24 24">',
`<use class="MuiCode-copied-icon" xlink:href="#${token.severity}-icon" />`,
'</svg>',
'</div>',
].join(
'\n',
)}<div class="MuiCallout-content">${this.parser.parse(token.tokens)}</div></aside>`;
},
},
],
});
return marked(markdown, { ...markedOptions, renderer });
}
return render;
}
export {
createRender,
getContents,
getDescription,
getCodeblock,
getFeatureList,
getHeaders,
getTitle,
renderMarkdown,
};

View File

@@ -0,0 +1,329 @@
import { expect } from 'chai';
import {
getContents,
getDescription,
getTitle,
getHeaders,
getCodeblock,
renderMarkdown,
createRender,
} from './parseMarkdown.mjs';
describe('parseMarkdown', () => {
describe('getTitle', () => {
it('remove backticks', () => {
expect(
getTitle(`
# \`@mui/styled-engine\`
<p class="description">Configuring your preferred styling library.</p>
`),
).to.equal('@mui/styled-engine');
});
});
describe('getDescription', () => {
it('trims the description', () => {
expect(
getDescription(`
<p class="description">
Some description
</p>
`),
).to.equal('Some description');
});
it('remove backticks', () => {
expect(
getDescription(`
<p class="description">
Some \`description\`
</p>
`),
).to.equal('Some description');
});
it('should not be greedy', () => {
expect(
getDescription(`
<p class="description">
Some description
</p>
## Foo
<p>bar</p>
`),
).to.equal('Some description');
});
});
describe('getHeaders', () => {
it('should return a correct result', () => {
expect(
getHeaders(`
---
title: React Alert component
components: Alert, AlertTitle
hooks: useAlert
githubLabel: 'scope: alert'
packageName: '@mui/lab'
waiAria: https://www.w3.org/TR/wai-aria-practices/#alert
authors: ['foo', 'bar']
---
`),
).to.deep.equal({
components: ['Alert', 'AlertTitle'],
hooks: ['useAlert'],
githubLabel: 'scope: alert',
packageName: '@mui/lab',
title: 'React Alert component',
waiAria: 'https://www.w3.org/TR/wai-aria-practices/#alert',
authors: ['foo', 'bar'],
});
});
it('should work with authors broken in two lines', () => {
expect(
getHeaders(`
---
title: React Alert component
components: Alert, AlertTitle
authors:
['foo', 'bar']
---
`),
).to.deep.equal({
components: ['Alert', 'AlertTitle'],
hooks: [],
title: 'React Alert component',
authors: ['foo', 'bar'],
});
});
it('should work with one author per line', () => {
expect(
getHeaders(`
---
title: React Alert component
components: Alert, AlertTitle
authors:
[
'foo',
'bar',
]
---
`),
).to.deep.equal({
components: ['Alert', 'AlertTitle'],
hooks: [],
title: 'React Alert component',
authors: ['foo', 'bar'],
});
});
it('should work with quotes', () => {
expect(
getHeaders(`
---
title: "Our docs just got a major upgrade—here's what that means for you"
---
`),
).to.deep.equal({
title: "Our docs just got a major upgrade—here's what that means for you",
components: [],
hooks: [],
});
});
});
describe('getContents', () => {
describe('Split markdown into an array, separating demos', () => {
it('returns a single entry without a demo', () => {
expect(getContents('# SomeGuide\nwhich has no demo')).to.deep.equal([
'# SomeGuide\nwhich has no demo',
]);
});
it('uses a `{{"demo"` marker to split', () => {
expect(
getContents('# SomeGuide\n{{"demo": "GuideDemo.js" }}\n## NextHeading'),
).to.deep.equal(['# SomeGuide\n', '"demo": "GuideDemo.js" ', '\n## NextHeading']);
});
it('ignores possible code', () => {
expect(getContents('# SomeGuide\n```jsx\n<Button props={{\nfoo: 1\n}}')).to.deep.equal([
'# SomeGuide\n```jsx\n<Button props={{\nfoo: 1\n}}',
]);
});
});
describe('Split markdown into an array, separating codeblocks', () => {
it('uses a `<codeblock>` tag to split', () => {
expect(
getContents(
[
'## Tabs',
'',
'<codeblock storageKey="package-manager">',
'',
'```bash npm',
'npm install @mui/material @emotion/react @emotion/styled',
'```',
'',
'```bash yarn',
'yarn add @mui/material @emotion/react @emotion/styled',
'```',
'',
'</codeblock>',
].join('\n'),
),
).to.deep.equal([
'## Tabs\n\n',
[
'<codeblock storageKey="package-manager">',
'',
'```bash npm',
'npm install @mui/material @emotion/react @emotion/styled',
'```',
'',
'```bash yarn',
'yarn add @mui/material @emotion/react @emotion/styled',
'```',
'',
'</codeblock>',
].join('\n'),
]);
});
});
});
describe('getCodeblock', () => {
it('should return undefined if no codeblock found', () => {
const codeblock = getCodeblock('## Tabs');
expect(codeblock).to.equal(undefined);
});
it('should return the codeblock', () => {
const codeblock = getCodeblock(
[
'<codeblock storageKey="package-manager">',
'',
'```bash npm',
'npm install @mui/material @emotion/react @emotion/styled',
'# `@emotion/react` and `@emotion/styled` are peer dependencies',
'```',
'',
'```sh yarn',
'yarn add @mui/material @emotion/react @emotion/styled',
'# `@emotion/react` and `@emotion/styled` are peer dependencies',
'```',
'',
'</codeblock>',
].join('\n'),
);
expect(codeblock).to.deep.equal({
type: 'codeblock',
storageKey: 'package-manager',
data: [
{
language: 'bash',
tab: 'npm',
code: [
'npm install @mui/material @emotion/react @emotion/styled',
'# `@emotion/react` and `@emotion/styled` are peer dependencies',
].join('\n'),
},
{
language: 'sh',
tab: 'yarn',
code: [
'yarn add @mui/material @emotion/react @emotion/styled',
'# `@emotion/react` and `@emotion/styled` are peer dependencies',
].join('\n'),
},
],
});
});
});
describe('renderMarkdown', () => {
it('should render markdown lists correctly', () => {
expect(
renderMarkdown(
[
'The track presentation:',
'- `normal` the track will render a bar representing the slider value.',
'- `inverted` the track will render a bar representing the remaining slider value.',
'- `false` the track will render without a bar.',
].join('\n'),
),
).to.equal(
[
'<p>The track presentation:</p>',
'<ul>',
'<li><code>normal</code> the track will render a bar representing the slider value.</li>',
'<li><code>inverted</code> the track will render a bar representing the remaining slider value.</li>',
'<li><code>false</code> the track will render without a bar.</li>',
'</ul>',
'',
].join('\n'),
);
});
it('should render inline descriptions correctly', () => {
expect(
renderMarkdown(
'Allows to control whether the dropdown is open. This is a controlled counterpart of `defaultOpen`.',
),
).to.equal(
'Allows to control whether the dropdown is open. This is a controlled counterpart of <code>defaultOpen</code>.',
);
});
});
describe('createRender', () => {
it('should collect headers correctly', () => {
const context = { toc: [], headingHashes: {} };
// eslint-disable-next-line testing-library/render-result-naming-convention
const render = createRender(context);
expect(
render(
[
'# Accordion',
'## Basic features 🧪',
'## Using `slots` and `slotProps`',
'### Specific example',
].join('\n'),
),
).to.equal(
[
`<h1>Accordion</h1>`,
`<h2 id="basic-features"><a href="#basic-features" class="title-link-to-anchor">Basic features 🧪<span class="anchor-icon"><svg><use xlink:href="#anchor-link-icon" /></svg></span></a><button title="Post a comment" class="comment-link" data-feedback-hash="basic-features"><svg><use xlink:href="#comment-link-icon" /></svg></button></h2>`,
`<h2 id="using-slots-and-slotprops"><a href="#using-slots-and-slotprops" class="title-link-to-anchor">Using <code>slots</code> and <code>slotProps</code><span class="anchor-icon"><svg><use xlink:href="#anchor-link-icon" /></svg></span></a><button title="Post a comment" class="comment-link" data-feedback-hash="using-slots-and-slotprops"><svg><use xlink:href="#comment-link-icon" /></svg></button></h2>`,
`<h3 id="specific-example"><a href="#specific-example" class="title-link-to-anchor">Specific example<span class="anchor-icon"><svg><use xlink:href="#anchor-link-icon" /></svg></span></a><button title="Post a comment" class="comment-link" data-feedback-hash="specific-example"><svg><use xlink:href="#comment-link-icon" /></svg></button></h3>`,
].join(''),
);
expect(context.toc).to.deep.equal([
{
children: [],
hash: 'basic-features',
level: 2,
text: 'Basic features 🧪',
},
{
children: [
{
hash: 'specific-example',
level: 3,
text: 'Specific example',
},
],
hash: 'using-slots-and-slotprops',
level: 2,
text: 'Using slots and slotProps',
},
]);
});
});
});

View File

@@ -0,0 +1,276 @@
import fs from 'fs';
import path from 'path';
import { kebabCase } from 'es-toolkit/string';
import {
createRender,
getContents,
getDescription,
getCodeblock,
getFeatureList,
getHeaders,
getTitle,
} from './parseMarkdown.mjs';
/**
* @type {string | string[]}
*/
const BaseUIReexportedComponents = [];
/**
* @param {string} productId
* @example 'material'
* @param {string} componentPkg
* @example 'mui-base'
* @param {string} component
* @example 'Button'
* @returns {string}
*/
function resolveComponentApiUrl(productId, componentPkg, component) {
if (!productId) {
return `/api/${kebabCase(component)}/`;
}
if (productId === 'x-date-pickers') {
return `/x/api/date-pickers/${kebabCase(component)}/`;
}
if (productId === 'x-charts') {
return `/x/api/charts/${kebabCase(component)}/`;
}
if (productId === 'x-tree-view') {
return `/x/api/tree-view/${kebabCase(component)}/`;
}
if (productId === 'x-data-grid') {
return `/x/api/data-grid/${kebabCase(component)}/`;
}
if (componentPkg === 'mui-base' || BaseUIReexportedComponents.includes(component)) {
return `/base-ui/react-${kebabCase(component)}/components-api/#${kebabCase(component)}`;
}
if (productId === 'toolpad-core') {
return `/toolpad/core/api/${kebabCase(component)}/`;
}
return `/${productId}/api/${kebabCase(component)}/`;
}
/**
* @typedef {{ component: string, demo?: undefined }} ComponentEntry
* @typedef {{ component?: undefined, demo: string, hideToolbar?: boolean }} DemoEntry
*/
/**
* @typedef {{ rendered: Array<string | ComponentEntry | DemoEntry> }} TranslatedDoc
*/
/**
* @param {object} config
* @param {Array<{ markdown: string, filename: string, userLanguage: string }>} config.translations - Mapping of locale to its markdown
* @param {string} config.fileRelativeContext - posix filename relative to repository root directory
* @param {object} config.options - provided to the webpack loader
* @param {string} config.options.workspaceRoot - The absolute path of the repository root directory
* @param {object} [config.componentPackageMapping] - Mapping of productId to mapping of component name to package name
* @example { 'material': { 'Button': 'mui-material' } }
* @returns {{ docs: Record<string, TranslatedDoc> }} - Mapping of locale to its prepared markdown
*/
function prepareMarkdown(config) {
const { fileRelativeContext, translations, componentPackageMapping = {}, options } = config;
/**
* @type {Record<string, TranslatedDoc>}
*/
const docs = {};
const headingHashes = {};
translations
// Process the English markdown before the other locales.
// English ToC anchor links are used in all languages
.sort((a) => (a.userLanguage === 'en' ? -1 : 1))
.forEach((translation) => {
const { filename, markdown, userLanguage } = translation;
const headers = getHeaders(markdown);
const location = headers.filename || `/${fileRelativeContext}/${filename}`;
const markdownH1 = getTitle(markdown);
const title = headers.title || markdownH1;
const description = headers.description || getDescription(markdown);
if (title == null || title === '') {
throw new Error(`docs-infra: Missing title in the page: ${location}\n`);
}
if (title.length > 70) {
throw new Error(
[
`docs-infra: The title "${title}" is too long (${title.length} characters).`,
'It needs to have fewer than 70 characters—ideally less than 60. For more details, see:',
'https://developers.google.com/search/docs/advanced/appearance/title-link',
'',
].join('\n'),
);
}
if (description == null || description === '') {
throw new Error(`docs-infra: Missing description in the page: ${location}\n`);
}
if (description.length > 160) {
throw new Error(
[
`docs-infra: The description "${description}" is too long (${description.length} characters).`,
'It needs to have fewer than 170 characters—ideally less than 160. For more details, see:',
'https://ahrefs.com/blog/meta-description/#4-be-concise',
'',
].join('\n'),
);
}
if (description.slice(-1) !== '.' && description.slice(-1) !== '!') {
throw new Error(
`docs-infra: The description "${description}" should end with a "." or "!", those are sentences.`,
);
}
const contents = getContents(markdown);
if (headers.components.length > 0 && headers.productId !== 'base-ui') {
contents.push(`
## API
See the documentation below for a complete reference to all of the props and classes available to the components mentioned here.
${headers.components
.map((component) => {
const componentPkgMap = componentPackageMapping[headers.productId];
const componentPkg = componentPkgMap ? componentPkgMap[component] : null;
return `- [\`<${component} />\`](${resolveComponentApiUrl(
headers.productId,
componentPkg,
component,
)})`;
})
.join('\n')}
${headers.hooks
.map((hook) => {
const componentPkgMap = componentPackageMapping[headers.productId];
const componentPkg = componentPkgMap ? componentPkgMap[hook] : null;
return `- [\`${hook}\`](${resolveComponentApiUrl(headers.productId, componentPkg, hook)})`;
})
.join('\n')}
`);
}
const toc = [];
const render = createRender({
headingHashes,
toc,
userLanguage,
location,
options,
});
const rendered = contents.map((content) => {
if (/^"(demo|component)": "(.*)"/.test(content)) {
try {
return JSON.parse(`{${content}}`);
} catch (err) {
console.error('JSON.parse fails with: ', `{${content}}`);
console.error(err);
return null;
}
}
const codeblock = getCodeblock(content);
if (codeblock) {
return codeblock;
}
const featureList = getFeatureList(content);
if (featureList) {
return featureList;
}
return render(content);
});
// fragment link symbol
rendered.unshift(`<svg style="display: none;" xmlns="http://www.w3.org/2000/svg">
<symbol id="comment-link-icon" viewBox="0 0 24 24">
<path d="M22.8481 4C22.8481 2.9 21.9481 2 20.8481 2H4.84814C3.74814 2 2.84814 2.9 2.84814 4V16C2.84814 17.1 3.74814 18 4.84814 18H18.8481L22.8481 22V4ZM16.8481 11H13.8481V14C13.8481 14.55 13.3981 15 12.8481 15C12.2981 15 11.8481 14.55 11.8481 14V11H8.84814C8.29814 11 7.84814 10.55 7.84814 10C7.84814 9.45 8.29814 9 8.84814 9H11.8481V6C11.8481 5.45 12.2981 5 12.8481 5C13.3981 5 13.8481 5.45 13.8481 6V9H16.8481C17.3981 9 17.8481 9.45 17.8481 10C17.8481 10.55 17.3981 11 16.8481 11Z" />
</symbol>
</svg>`);
rendered.unshift(`<svg style="display: none;" xmlns="http://www.w3.org/2000/svg">
<symbol id="anchor-link-icon" viewBox="0 0 12 6">
<path d="M8.9176 0.083252H7.1676C6.84677 0.083252 6.58427 0.345752 6.58427 0.666585C6.58427 0.987419 6.84677 1.24992 7.1676 1.24992H8.9176C9.8801 1.24992 10.6676 2.03742 10.6676 2.99992C10.6676 3.96242 9.8801 4.74992 8.9176 4.74992H7.1676C6.84677 4.74992 6.58427 5.01242 6.58427 5.33325C6.58427 5.65409 6.84677 5.91659 7.1676 5.91659H8.9176C10.5276 5.91659 11.8343 4.60992 11.8343 2.99992C11.8343 1.38992 10.5276 0.083252 8.9176 0.083252ZM3.6676 2.99992C3.6676 3.32075 3.9301 3.58325 4.25094 3.58325H7.75094C8.07177 3.58325 8.33427 3.32075 8.33427 2.99992C8.33427 2.67909 8.07177 2.41659 7.75094 2.41659H4.25094C3.9301 2.41659 3.6676 2.67909 3.6676 2.99992ZM4.83427 4.74992H3.08427C2.12177 4.74992 1.33427 3.96242 1.33427 2.99992C1.33427 2.03742 2.12177 1.24992 3.08427 1.24992H4.83427C5.1551 1.24992 5.4176 0.987419 5.4176 0.666585C5.4176 0.345752 5.1551 0.083252 4.83427 0.083252H3.08427C1.47427 0.083252 0.167603 1.38992 0.167603 2.99992C0.167603 4.60992 1.47427 5.91659 3.08427 5.91659H4.83427C5.1551 5.91659 5.4176 5.65409 5.4176 5.33325C5.4176 5.01242 5.1551 4.74992 4.83427 4.74992Z" />
</symbol>
</svg>`);
rendered.unshift(`<svg style="display: none;" xmlns="http://www.w3.org/2000/svg">
<symbol id="copy-icon" viewBox="0 0 24 24">
<path d="M15 20H5V7c0-.55-.45-1-1-1s-1 .45-1 1v13c0 1.1.9 2 2 2h10c.55 0 1-.45 1-1s-.45-1-1-1zm5-4V4c0-1.1-.9-2-2-2H9c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h9c1.1 0 2-.9 2-2zm-2 0H9V4h9v12z" />
+</symbol>
</svg>`);
rendered.unshift(`<svg style="display: none;" xmlns="http://www.w3.org/2000/svg">
<symbol id="copied-icon" viewBox="0 0 24 24">
<path d="M20 2H8c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-8.24 11.28L9.69 11.2c-.38-.39-.38-1.01 0-1.4.39-.39 1.02-.39 1.41 0l1.36 1.37 4.42-4.46c.39-.39 1.02-.39 1.41 0 .38.39.38 1.01 0 1.4l-5.13 5.17c-.37.4-1.01.4-1.4 0zM3 6c-.55 0-1 .45-1 1v13c0 1.1.9 2 2 2h13c.55 0 1-.45 1-1s-.45-1-1-1H5c-.55 0-1-.45-1-1V7c0-.55-.45-1-1-1z" />
</symbol>
</svg>`);
// icons for callout (info, success, warning, error)
rendered.unshift(
`<svg style="display: none;" xmlns="http://www.w3.org/2000/svg">
<symbol id="info-icon" viewBox="0 0 20 20">
<path d="M9.996 14c.21 0 .39-.072.535-.216a.72.72 0 0 0 .219-.534v-3.5a.728.728 0 0 0-.214-.534.72.72 0 0 0-.532-.216.734.734 0 0 0-.535.216.72.72 0 0 0-.219.534v3.5c0 .213.071.39.214.534a.72.72 0 0 0 .532.216Zm0-6.5c.21 0 .39-.071.535-.214a.714.714 0 0 0 .219-.532.736.736 0 0 0-.214-.535.714.714 0 0 0-.532-.219.736.736 0 0 0-.535.214.714.714 0 0 0-.219.532c0 .21.071.39.214.535.143.146.32.219.532.219Zm.01 10.5a7.81 7.81 0 0 1-3.11-.625 8.065 8.065 0 0 1-2.552-1.719 8.066 8.066 0 0 1-1.719-2.551A7.818 7.818 0 0 1 2 9.99c0-1.104.208-2.14.625-3.105a8.066 8.066 0 0 1 4.27-4.26A7.818 7.818 0 0 1 10.009 2a7.75 7.75 0 0 1 3.106.625 8.083 8.083 0 0 1 4.26 4.265A7.77 7.77 0 0 1 18 9.994a7.81 7.81 0 0 1-.625 3.11 8.066 8.066 0 0 1-1.719 2.552 8.083 8.083 0 0 1-2.546 1.719 7.77 7.77 0 0 1-3.104.625Z"/>
</symbol>
</svg>`,
);
rendered.unshift(
`<svg style="display: none;" xmlns="http://www.w3.org/2000/svg">
<symbol id="success-icon" viewBox="0 0 20 20">
<path d="m8.938 10.875-1.25-1.23a.718.718 0 0 0-.521-.228.718.718 0 0 0-.521.229.73.73 0 0 0 0 1.062l1.77 1.771c.153.153.327.23.521.23a.718.718 0 0 0 .521-.23l3.896-3.896a.73.73 0 0 0 0-1.062.718.718 0 0 0-.52-.23.718.718 0 0 0-.521.23l-3.376 3.354ZM10 18a7.796 7.796 0 0 1-3.104-.625 8.065 8.065 0 0 1-2.552-1.719 8.064 8.064 0 0 1-1.719-2.552A7.797 7.797 0 0 1 2 10c0-1.111.208-2.15.625-3.115a8.064 8.064 0 0 1 4.27-4.26A7.797 7.797 0 0 1 10 2c1.111 0 2.15.208 3.115.625a8.096 8.096 0 0 1 4.26 4.26C17.792 7.851 18 8.89 18 10a7.797 7.797 0 0 1-.625 3.104 8.066 8.066 0 0 1-4.26 4.271A7.774 7.774 0 0 1 10 18Z"/>
</symbol>
</svg>`,
);
rendered.unshift(
`<svg style="display: none;" xmlns="http://www.w3.org/2000/svg">
<symbol id="warning-icon" viewBox="0 0 20 20">
<path d="M2.33 17a.735.735 0 0 1-.665-.375.631.631 0 0 1-.094-.375.898.898 0 0 1 .115-.396L9.353 3.062a.621.621 0 0 1 .281-.27.85.85 0 0 1 .729 0 .622.622 0 0 1 .281.27l7.667 12.792c.07.125.108.257.114.396a.63.63 0 0 1-.093.375.842.842 0 0 1-.271.27.728.728 0 0 1-.394.105H2.33Zm7.664-2.5c.211 0 .39-.072.536-.214a.714.714 0 0 0 .218-.532.736.736 0 0 0-.214-.535.714.714 0 0 0-.531-.22.736.736 0 0 0-.536.215.714.714 0 0 0-.219.531c0 .212.072.39.215.536.143.146.32.219.531.219Zm0-2.5c.211 0 .39-.072.536-.216a.72.72 0 0 0 .218-.534v-2.5a.728.728 0 0 0-.214-.534.72.72 0 0 0-.531-.216.734.734 0 0 0-.536.216.72.72 0 0 0-.219.534v2.5c0 .212.072.39.215.534a.72.72 0 0 0 .531.216Z"/>
</symbol>
</svg>`,
);
rendered.unshift(
`<svg style="display: none;" xmlns="http://www.w3.org/2000/svg">
<symbol id="error-icon" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M2 7.4v5.2a2 2 0 0 0 .586 1.414l3.4 3.4A2 2 0 0 0 7.4 18h5.2a2 2 0 0 0 1.414-.586l3.4-3.4A2 2 0 0 0 18 12.6V7.4a2 2 0 0 0-.586-1.414l-3.4-3.4A2 2 0 0 0 12.6 2H7.4a2 2 0 0 0-1.414.586l-3.4 3.4A2 2 0 0 0 2 7.4Zm11.03-.43a.75.75 0 0 1 0 1.06L11.06 10l1.97 1.97a.75.75 0 1 1-1.06 1.06L10 11.06l-1.97 1.97a.75.75 0 0 1-1.06-1.06L8.94 10 6.97 8.03a.75.75 0 0 1 1.06-1.06L10 8.94l1.97-1.97a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd"/>
</symbol>
</svg>`,
);
docs[userLanguage] = {
description,
location,
rendered,
toc,
title,
headers,
};
});
if (docs.en.headers.card === 'true') {
const slug = docs.en.location.replace(/(.*)\/(.*)\.md/, '$2');
const exists = fs.existsSync(
path.resolve(config.options.workspaceRoot, `docs/public/static/blog/${slug}/card.png`),
);
if (!exists) {
throw new Error(
[
`MUI: the card image for the blog post "${slug}" is missing.`,
`Add a docs/public/static/blog/${slug}/card.png file and then restart Next.js or else remove card: true from the headers.`,
].join('\n'),
);
}
}
return { docs };
}
export default prepareMarkdown;

View File

@@ -0,0 +1,417 @@
import { expect } from 'chai';
import prepareMarkdown from './prepareMarkdown.mjs';
describe('prepareMarkdown', () => {
const defaultParams = {
fileRelativeContext: 'test/bar',
options: {
env: {},
},
};
it('returns the table of contents with html and emojis preserved and <a> tags stripped', () => {
const markdown = `
# Support
<p class="description">Foo.</p>
## Community help (free)
### GitHub <img src="/static/images/logos/github.svg" width="24" height="24" alt="GitHub logo" loading="lazy" />
### Unofficial 👍
### Warning ⚠️
### Header with Pro plan <a title="Pro plan" href="/x/introduction/licensing/#plan-pro"><span class="plan-pro"></span></a>
### Header with \`code\`
`;
const {
docs: {
en: { toc },
},
} = prepareMarkdown({
...defaultParams,
translations: [{ filename: 'index.md', markdown, userLanguage: 'en' }],
});
expect(toc).to.have.deep.ordered.members([
{
children: [
{
hash: 'github',
level: 3,
text: 'GitHub <img src="/static/images/logos/github.svg" width="24" height="24" alt="GitHub logo" loading="lazy" />',
},
{ hash: 'unofficial', level: 3, text: 'Unofficial 👍' },
{ hash: 'warning', level: 3, text: 'Warning ⚠️' },
{
hash: 'header-with-pro-plan',
level: 3,
text: 'Header with Pro plan <span class="plan-pro"></span>',
},
{
hash: 'header-with-code',
level: 3,
text: 'Header with code',
},
],
hash: 'community-help-free',
level: 2,
text: 'Community help (free)',
},
]);
});
it('enables word-break for function signatures', () => {
const markdown = `
# Theming
<p class="description">Foo.</p>
## API
### responsiveFontSizes(theme, options) => theme
### createTheme(options, ...args) => theme
`;
const {
docs: {
en: { toc },
},
} = prepareMarkdown({
...defaultParams,
translations: [{ filename: 'index.md', markdown, userLanguage: 'en' }],
});
expect(toc).to.have.deep.ordered.members([
{
children: [
{
hash: 'responsivefontsizes-theme-options-theme',
level: 3,
text: 'responsiveFontSizes(&#8203;theme, options) =&gt; theme',
},
{
hash: 'createtheme-options-args-theme',
level: 3,
text: 'createTheme(&#8203;options, ...args) =&gt; theme',
},
],
hash: 'api',
level: 2,
text: 'API',
},
]);
});
it('use english hash for different locales', () => {
const markdownEn = `
# Localization
<p class="description">Foo.</p>
## Locales
### Example
### Use same hash
`;
const markdownPt = `
# Localização
<p class="description">Foo.</p>
## Idiomas
### Exemplo
### Usar o mesmo hash
`;
const markdownZh = `
# 所在位置
<p class="description">Foo.</p>
## 语言环境
### 例
### 使用相同的哈希
`;
const {
docs: {
en: { toc: tocEn },
pt: { toc: tocPt },
zh: { toc: tocZh },
},
} = prepareMarkdown({
pageFilename: '/same-hash-test',
translations: [
{ filename: 'localization.md', markdown: markdownEn, userLanguage: 'en' },
{ filename: 'localization-pt.md', markdown: markdownPt, userLanguage: 'pt' },
{ filename: 'localization-zh.md', markdown: markdownZh, userLanguage: 'zh' },
],
});
expect(tocZh).to.have.deep.ordered.members([
{
children: [
{
hash: 'example',
level: 3,
text: '例',
},
{
hash: 'use-same-hash',
level: 3,
text: '使用相同的哈希',
},
],
hash: 'locales',
level: 2,
text: '语言环境',
},
]);
expect(tocPt).to.have.deep.ordered.members([
{
children: [
{
hash: 'example',
level: 3,
text: 'Exemplo',
},
{
hash: 'use-same-hash',
level: 3,
text: 'Usar o mesmo hash',
},
],
hash: 'locales',
level: 2,
text: 'Idiomas',
},
]);
expect(tocEn).to.have.deep.ordered.members([
{
children: [
{
hash: 'example',
level: 3,
text: 'Example',
},
{
hash: 'use-same-hash',
level: 3,
text: 'Use same hash',
},
],
hash: 'locales',
level: 2,
text: 'Locales',
},
]);
});
it('use translated hash for translations are not synced', () => {
const markdownEn = `
# Localization
<p class="description">Foo.</p>
## Locales
### Example
### Use same hash
`;
const markdownPt = `
# Localização
<p class="description">Foo.</p>
## Idiomas
### Exemplo
### Usar o mesmo hash
### Usar traduzido
`;
const {
docs: {
en: { toc: tocEn },
pt: { toc: tocPt },
},
} = prepareMarkdown({
pageFilename: '/same-hash-test',
translations: [
{ filename: 'localization.md', markdown: markdownEn, userLanguage: 'en' },
{ filename: 'localization-pt.md', markdown: markdownPt, userLanguage: 'pt' },
],
});
expect(tocPt).to.have.deep.ordered.members([
{
children: [
{
hash: 'example',
level: 3,
text: 'Exemplo',
},
{
hash: 'use-same-hash',
level: 3,
text: 'Usar o mesmo hash',
},
{
hash: 'usar-traduzido',
level: 3,
text: 'Usar traduzido',
},
],
hash: 'locales',
level: 2,
text: 'Idiomas',
},
]);
expect(tocEn).to.have.deep.ordered.members([
{
children: [
{
hash: 'example',
level: 3,
text: 'Example',
},
{
hash: 'use-same-hash',
level: 3,
text: 'Use same hash',
},
],
hash: 'locales',
level: 2,
text: 'Locales',
},
]);
});
it('should report missing trailing splashes', () => {
const markdown = `
# Localization
<p class="description">Foo.</p>
[bar](/bar/)
[foo](/foo)
`;
expect(() => {
prepareMarkdown({
...defaultParams,
translations: [{ filename: 'index.md', markdown, userLanguage: 'en' }],
});
}).to.throw(`docs-infra: Missing trailing slash. The following link:
[foo](/foo) in /test/bar/index.md is missing a trailing slash, please add it.
See https://ahrefs.com/blog/trailing-slash/ for more details.
`);
});
it('should report missing leading splashes', () => {
const markdown = `
# Localization
<p class="description">Foo.</p>
[bar](/bar/)
[foo](foo/)
`;
expect(() => {
prepareMarkdown({
...defaultParams,
translations: [{ filename: 'index.md', markdown, userLanguage: 'en' }],
});
}).to.throw(`docs-infra: Missing leading slash. The following link:
[foo](foo/) in /test/bar/index.md is missing a leading slash, please add it.
`);
});
it('should report title too long', () => {
const markdown = `
# Foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo
<p class="description">Foo.</p>
`;
expect(() => {
prepareMarkdown({
...defaultParams,
translations: [{ filename: 'index.md', markdown, userLanguage: 'en' }],
});
}).to
.throw(`docs-infra: The title "Foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo" is too long (117 characters).
It needs to have fewer than 70 characters—ideally less than 60. For more details, see:
https://developers.google.com/search/docs/advanced/appearance/title-link
`);
});
it('should report description too long', () => {
const markdown = `
# Foo
<p class="description">Foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo.</p>
`;
expect(() => {
prepareMarkdown({
...defaultParams,
translations: [{ filename: 'index.md', markdown, userLanguage: 'en' }],
});
}).to
.throw(`docs-infra: The description "Foooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooo." is too long (188 characters).
It needs to have fewer than 170 characters—ideally less than 160. For more details, see:
https://ahrefs.com/blog/meta-description/#4-be-concise
`);
});
it('should not accept sh', () => {
const markdown = `
# Foo
<p class="description">Fo.</p>
\`\`\`sh
npm install @mui/material
\`\`\`
`;
expect(() => {
prepareMarkdown({
...defaultParams,
translations: [{ filename: 'index.md', markdown, userLanguage: 'en' }],
});
}).to.throw(`docs-infra: Unsupported language: "sh" in:
\`\`\`sh
npm install @mui/material
\`\`\`
Use "bash" instead.
`);
});
it('should report duplicated trailing splashes', () => {
const markdown = `
# Localization
<p class="description">Foo.</p>
[foo](/foo/)
[bar](/bar//#foo)
`;
expect(() => {
prepareMarkdown({
...defaultParams,
translations: [{ filename: 'index.md', markdown, userLanguage: 'en' }],
});
}).to.throw(`docs-infra: Duplicated trailing slashes.`);
});
});

View File

@@ -0,0 +1 @@
export default function highlight(code: string, language: string): string;

View File

@@ -0,0 +1,61 @@
import prism from 'prismjs';
import 'prismjs/components/prism-css.js';
import 'prismjs/components/prism-bash.js';
import 'prismjs/components/prism-diff.js';
import 'prismjs/components/prism-javascript.js';
import 'prismjs/components/prism-json.js';
import 'prismjs/components/prism-jsx.js';
import 'prismjs/components/prism-markup.js';
import 'prismjs/components/prism-yaml.js';
import 'prismjs/components/prism-tsx.js';
function highlight(code, language) {
let prismLanguage;
switch (language) {
case 'ts':
prismLanguage = prism.languages.tsx;
break;
case 'js':
prismLanguage = prism.languages.jsx;
break;
case 'sh':
throw new Error(
[
`docs-infra: Unsupported language: "sh" in:`,
'',
'```sh',
code,
'```',
'',
'Use "bash" instead.',
'',
].join('\n'),
);
case 'diff':
prismLanguage = { ...prism.languages.diff };
// original `/^[-<].*$/m` matches lines starting with `<` which matches
// <SomeComponent />
// we will only use `-` as the deleted marker
prismLanguage.deleted = /^[-].*$/m;
break;
default:
prismLanguage = prism.languages[language];
break;
}
if (!prismLanguage) {
if (language) {
throw new Error(`unsupported language: "${language}", "${code}"`);
} else {
prismLanguage = prism.languages.jsx;
}
}
return prism.highlight(code, prismLanguage);
}
export default highlight;

View File

@@ -0,0 +1,34 @@
function makeUnique(hash, unique, i = 1) {
const uniqueHash = i === 1 ? hash : `${hash}-${i}`;
if (!unique[uniqueHash]) {
unique[uniqueHash] = true;
return uniqueHash;
}
return makeUnique(hash, unique, i + 1);
}
/**
* @param {string} text - HTML from e.g. parseMarkdown#render
* @param {Record<string, boolean>} [unique] - Ensures that each output is unique in `unique`
* @returns {string} that is safe to use in fragment links
*/
export default function textToHash(text, unique = {}) {
return makeUnique(
encodeURI(
text
.toLowerCase()
.replace(/<\/?[^>]+(>|$)/g, '') // remove HTML
.replace(/=&gt;|&lt;| \/&gt;|<code>|<\/code>|&#39;/g, '')
.replace(/[!@#$%^&*()=_+[\]{}`~;:'"|,.<>/?\s]+/g, '-')
.replace(
/([\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])\uFE0F?/g,
'',
) // remove emojis
.replace(/-+/g, '-')
.replace(/^-|-$/g, ''),
),
unique,
);
}

View File

@@ -0,0 +1,33 @@
import { expect } from 'chai';
import { parseInline as renderInlineMarkdown } from 'marked';
import textToHash from './textToHash.mjs';
describe('textToHash', () => {
it('should hash as expected', () => {
const table = [
['createTheme(options) => theme', 'createtheme-options-theme'],
['Typography - Font family', 'typography-font-family'],
["barre d'application", 'barre-dapplication'],
[
'createGenerateClassName([options]) => class name generator',
'creategenerateclassname-options-class-name-generator',
],
['@mui/material/styles vs @mui/styles', 'mui-material-styles-vs-mui-styles'],
['Blog 📝', 'blog'],
];
table.forEach((entry, index) => {
const [markdown, expected] = entry;
// eslint-disable-next-line testing-library/render-result-naming-convention
const text = renderInlineMarkdown(markdown, { mangle: false, headerIds: false });
const actual = textToHash(text);
expect(actual).to.equal(expected, `snapshot #${index} matches`);
});
});
it('should generate a unique hash', () => {
const unique = {};
expect(textToHash('Styling solution', unique)).to.equal('styling-solution');
expect(textToHash('Styling solution', unique)).to.equal('styling-solution-2');
});
});

View File

@@ -0,0 +1,4 @@
// eslint-disable-next-line import/no-relative-packages
import sharedConfig from '../../vitest.shared.mts';
export default sharedConfig(import.meta.url);