Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(pv-stylemark): fix regex extracting css code block #223

Merged
merged 6 commits into from
Apr 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 32 additions & 8 deletions packages/pv-stylemark/tasks/lsg/buildLsgExamples.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,43 @@ const loadTemplate = async hbsInst => {
return hbsInst.compile(templateContent);
};

/**
* @param {Object} config
* @param {import("./getLsgData.js").StyleMarkLSGData} lsgData
* @param {import("./getLsgData.js").StyleMarkExampleData} exampleData
* @param {Function} template
*/
const buildComponentExample = async (config, lsgData, exampleData, template) => {
const { destPath } = getAppConfig();
const componentPath = resolveApp(join(destPath, "components", lsgData.componentPath, exampleData.examplePath));
try {
let componentMarkup = await readFile(componentPath, { encoding: "utf-8" });
let componentMarkup = "";
if (exampleData.exampleMarkup.examplePath) {
const componentPath = resolveApp(join(destPath, "components", lsgData.componentPath, exampleData.exampleMarkup.examplePath));
componentMarkup = await readFile(componentPath, { encoding: "utf-8" });
} else {
componentMarkup = exampleData.exampleMarkup.content;
}
const configBodyHtml = config.examples?.bodyHtml ?? "{html}";
componentMarkup = configBodyHtml.replace(/{html}/g, componentMarkup);
const markup = template({
lsgData,
componentMarkup,
exampleStyles: exampleData.exampleStyles,
lsgConfig: config,
});
// when the `raw` parameter is set in stylemark config, or the markdowns frontmatter or via the parameters of the code block in the markdown,
// the markup will be used as it is and not wrapped by stylemark generated markup
const useMarkupRaw = Object.assign({}, config.examples, lsgData.options, exampleData.exampleMarkup.params).raw;
let markup = "";
if (useMarkupRaw) {
const styles = exampleData.exampleStyles.map(style => `<style>${style.content}</style>`).join("\n");
const scripts = exampleData.exampleScripts.map(script => `<script>${script.content}</script>`).join("\n");
markup = componentMarkup
.replace("</head>", `${styles}\n</head>`)
.replace("</body>", `${scripts}\n</body>`);
} else {
markup = template({
lsgData,
componentMarkup,
exampleStyles: exampleData.exampleStyles,
exampleScripts: exampleData.exampleScripts,
lsgConfig: config,
});
}
await writeFile(destPath, "styleguide", `${lsgData.componentName}-${exampleData.exampleName}`, markup);
} catch (error) {
console.warn(error);
Expand Down
215 changes: 173 additions & 42 deletions packages/pv-stylemark/tasks/lsg/getLsgData.js
Original file line number Diff line number Diff line change
@@ -1,30 +1,82 @@
const { readFile } = require("fs-extra");
const { resolve, parse: pathParse, normalize, relative: relPath, dirname } = require("path");
const { resolve, parse: pathParse, normalize, relative: relPath, join } = require("path");
const { marked } = require("marked");
const frontmatter = require("front-matter");
const { glob } = require("glob");

const { resolveApp, getAppConfig } = require("../../helper/paths");

const getStylesData = stylesMatch => {
const exampleKeys = stylesMatch
.match(/^\s*[a-zA-Z\-\d_]+.css/g)
.map(match => match.replace(/\s/g, "").replace(/.css$/g, ""));
if (exampleKeys.length === 0) return null;
/**
* Information extracted from the executable code blocks according to the stylemark spec (@see https://github.com/mpetrovich/stylemark/blob/main/README-SPEC.md)
* @typedef {Object} StyleMarkCodeBlock
* @property {string} exampleName - will be used to identify the html page rendered as an iframe
* @property {string} [examplePath] - optional, will be a relative path to the html file (relative from target/components/path/to/markdown)
* @property {string} [search] - optional, the query params coming after the path in the code block. example: `?foo=bar` (? is part of the value)
* @property {string} [hash] - optional, hash value coming after the path in the code block e.g. `#anchor` (# is part of the value)
* @property {"html"|"css"|"js"} language - `html` will create a new html page, `js` and `css` will be added in the html file
* @property {string} content - the content of the code block
* @property {Object} params
* @property {boolean} [params.hidden] - Indicates whether the code block should also be shown in the styleguide description of the component
* @property {boolean} [params.raw] - Indicates whether the html needs to be wrapped by stylemark or rendered as it comes, raw.
* @example
* ```exampleName:examplePath.language hidden
* content
* ```
* // new pattern
* ```language exampleName examplePath[search][hash] hidden raw=false
* content
* ```
*/

const styleContent = stylesMatch.replace(/^\s*[a-zA-Z\-\d_]+.css\s+(hidden)?\s+/g, "").trim();
return {
exampleKey: exampleKeys[0],
styleContent,
};
};
/**
* @typedef {{
* exampleName: string;
* exampleMarkup: StyleMarkCodeBlock;
* exampleStyles: StyleMarkCodeBlock[];
* exampleScripts: StyleMarkCodeBlock[];
* }} StyleMarkExampleData
*/

const getExampleMarkup = (matchingString, name, componentPath) => {
matchingString = matchingString.replace(/```/g, "").replace(/\s/g, "");
const [exampleName, examplePath] = matchingString.split(":");
const markupUrl = `../components/${componentPath}/${examplePath}`;
return `<dds-example name="${exampleName}" path="${name}-${exampleName}.html" markup-url="${markupUrl}"></dds-example>`;
};
/**
* @typedef {{
* componentName: string;
* componentPath: string;
* srcPath: string;
* options: Object;
* description: string;
* examples: Array<StyleMarkExampleData>;
* }} StyleMarkLSGData
*/

// example code blocks:
// ```example:/path/to/page.html
// ```
//
// ```example.js
// console.log('Example 1: ' + data);
// ```
//
// ```example.css hidden
// button {
// display: none;
// }
// ```
const legacyRegexExecutableCodeBlocks = /``` *(?<exampleName>[\w\-]+)(:(?<examplePath>(\.?\.\/)*[\w\-/]+))?\.(?<language>html|css|js)(?<params>( .*)?) *\n+(?<content>[^```]*)```/g;

// example code blocks:
// ```html example ./path/to/page.html
// ```
//
// ```js example
// console.log('Example 1: ' + data);
// ```
//
// ```css example hidden
// button {
// display: none;
// }
// ```
const regexExecutableCodeBlocks = /``` *(?<language>html|css|js) (?<exampleName>[\w\-]+)( +(?<examplePath>(\.?\.\/)*[\w\-/]+\.[\w\-/]+))?(?<search>\?.+?)?(?<hash>#.+?)?(?<params>( .*))? *\n+(?<content>[^```]*)```/g

const exampleParser = {
name: "exampleParser",
Expand All @@ -51,39 +103,41 @@ const exampleParser = {
},
};

const getLsgDataForPath = async (path, componentsSrc) => {
const fileContent = await readFile(path, { encoding: "utf-8" });
/**
* read markdown, extract code blocks for the individual examples
* @param {string} markdownPath
* @returns {StyleMarkLSGData}
*/
const getLsgDataForPath = async (markdownPath) => {
const fileContent = await readFile(markdownPath, { encoding: "utf-8" });

const { name } = pathParse(path);
const componentPath = dirname(relPath(resolveApp(componentsSrc), path));
const { name, dir } = pathParse(markdownPath);
const componentsSrc = resolveApp(getAppConfig().componentsSrc);
const componentPath = relPath(componentsSrc, dir);
const srcPath = relPath(componentsSrc, markdownPath);

const { attributes: frontmatterData, body: fileContentBody } = frontmatter(fileContent);

const stylesRegex = new RegExp(/```\s*([a-zA-Z\-\d_]+\.css (hidden)?)\s*[^```]+```/g);
const codeBlocks = await getExecutableCodeBlocks(fileContentBody);

const stylesMatches = fileContentBody.match(stylesRegex) || [];

const styles = stylesMatches.map(match => match.replace(/```/g, ""));
const stylesList = styles.map(getStylesData);

const exampleRegex = new RegExp(/```\s*[a-zA-Z\-\d_]+:(\.\.\/)*[a-zA-Z\-\d_/]+\.[a-z]+\s*```/g);

const exampleMatches = fileContentBody.match(exampleRegex) || [];
const examples = exampleMatches.map(match => match.replace(/```/g, "").replace(/\s/g, ""));
const exampleData = examples.map(match => {
const [exampleName, examplePath] = match.split(":");
const exampleStyles = stylesList.filter(style => style.exampleKey === exampleName);
return { exampleName, examplePath, exampleStyles };
});
const exampleNames = codeBlocks.filter(({language}) => language === "html").map(({ exampleName }) => exampleName);
const exampleData = exampleNames.map(name => ({
exampleName: name,
// assuming only one html (external file or as the content of the fenced code block) is allowed per example
exampleMarkup: codeBlocks.find(({ exampleName, language }) => exampleName === name && language === "html"),
// multiple css/js code blocks are allowed per example
exampleStyles: codeBlocks.filter(({ exampleName, language }) => exampleName === name && language === "css"),
exampleScripts: codeBlocks.filter(({ exampleName, language }) => exampleName === name && language === "js"),
}));

const cleanContent = fileContentBody
.replace(exampleRegex, match => getExampleMarkup(match, name, componentPath))
.replace(stylesRegex, "");
const cleanContent = cleanMarkdownFromExecutableCodeBlocks(fileContentBody, name, componentPath);
marked.use({ extensions: [exampleParser] });
const description = marked.parse(cleanContent);

return {
componentName: name,
componentPath,
srcPath,
options: frontmatterData,
description,
examples: exampleData,
Expand Down Expand Up @@ -120,16 +174,93 @@ const getDataSortedByCategory = (lsgData, config) => {
};

const getLsgData = async (curGlob, config) => {
const { componentsSrc } = getAppConfig();
const paths = await glob(curGlob, {
windowsPathsNoEscape: true,
});
const normalizedPaths = paths.map(filePath => normalize(resolve(process.cwd(), filePath)));

const data = await Promise.all(normalizedPaths.map(curPath => getLsgDataForPath(curPath, componentsSrc)));
const data = await Promise.all(normalizedPaths.map(curPath => getLsgDataForPath(curPath)));
return getDataSortedByCategory(data, config);
};

/**
* extracts the fenced code blocks from the markdown that are meant to be used in the example pages according to the stylemark spec (@link https://github.com/mpetrovich/stylemark/blob/main/README-SPEC.md)
*
* @param {string} markdownContent
* @returns {StyleMarkCodeBlock[]}
*/
function getExecutableCodeBlocks(markdownContent) {
return [
...markdownContent.matchAll(legacyRegexExecutableCodeBlocks),
...markdownContent.matchAll(regexExecutableCodeBlocks),
].map(match => normalizeRegexGroups(match.groups));
}

/**
* the `groups` object of the regex for the executable code blocks, will be modified to have the object exactly how it is needed and not what is possible using only regex.
* this includes nested objects and boolean casting
* @param {object} groups
* @param {string} [groups.examplePath]
* @param {string} [groups.params]
* @param {string} groups.exampleName
* @param {string} groups.language
* @param {string} [groups.content]
* @returns {StyleMarkCodeBlock}
*/
function normalizeRegexGroups(groups) {
// "type=module hidden" --> `{ type: "module", hidden: true }`
groups.params = Object.fromEntries((groups.params ?? "").trim().split(" ").map(part => part.trim()).filter(part => part !== "").map(part => {
let [key, value] = part.split("=");
// for boolean, cast
if (value === "true") value = true;
if (value === "false") value = false;
return [key, value ?? true];
}));

if (groups.examplePath) {
// in the new pattern, the extension is part of examplePath. in the old one the extension is used for the `language` instead.
groups.examplePath = groups.examplePath.match(/\.[\w\-]+$/) ? groups.examplePath : `${groups.examplePath}.${groups.language}`;
}

return groups;
}

/**
* removes all the fenced code blocks that stylemark will use to render the examples,
* but only for the ones referencing an external file or having the `hidden` attribute in the info string
*
* @param {string} markdownContent
* @returns {string}
*/
function cleanMarkdownFromExecutableCodeBlocks(markdownContent, name, componentPath) {
function replacer(...args) {
let replacement = "";
/** @type {StyleMarkCodeBlock} */
const groups = normalizeRegexGroups(args.at(-1));

if (groups.language === "html") {
// html file will be generated for html code blocks without a referenced file
const examplePath = groups.examplePath ? groups.examplePath : `${groups.exampleName}.html`;
const markupUrl = join("../components", componentPath, examplePath);
replacement += `<dds-example name="${groups.exampleName}" path="${name}-${groups.exampleName}.html${groups.search ?? ""}${groups.hash ?? ""}" ${groups.examplePath && !groups.params.hidden ? `markup-url="${markupUrl}"`: ""}></dds-example>`
}
if (groups.content && !groups.params.hidden) {
// add the css/js code blocks for the example. make sure it is indented the way `marked` can handle it
replacement += `
<details>
<summary class="dds-example__code-box-toggle">${groups.language}</summary>
\n\`\`\`${groups.language}\n${groups.content}\n\`\`\`\n
</details>`;
}

return replacement;
}

return markdownContent
.replace(legacyRegexExecutableCodeBlocks, replacer)
.replace(regexExecutableCodeBlocks, replacer);
}

module.exports = {
getLsgData,
};
4 changes: 4 additions & 0 deletions packages/pv-stylemark/tasks/templates/lsg-component.hbs
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
<section class="dds-component" id="{{componentName}}">
<h2 class="dds-component__name">{{options.name}}</h2>
<h2 class="dds-component__source theme-content-element-source">
<span class="dds-component__source-label theme-content-element-source-label">Source:</span>
<span class="dds-component__source-path theme-content-element-source-path">{{srcPath}}</span>
</h3>
<div class="dds-component__description">
{{{description}}}
</div>
Expand Down
9 changes: 7 additions & 2 deletions packages/pv-stylemark/tasks/templates/lsg-example.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@
{{{lsgConfig.examples.headHtml}}}
{{#each exampleStyles}}
<style>
{{this.styleContent}}
{{content}}
</style>
{{/each}}
</head>
<body>
{{{componentMarkup}}}
{{#each exampleScripts}}
<script>
{{content}}
</script>
{{/each}}
</body>
</html>
</html>
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,23 @@
margin: 0 0 24px;
}

&__source {
margin-bottom: 24px;
font-size: 14px;
font-weight: normal;
color: $dds-color__black-040;
}

&__source-label {
margin-right: 4px;
font-weight: 700;
text-transform: uppercase;
}

&__source-path {
font-family: Courier, monospace;
}

&__description {
h1 {
@extend %dds-typo__headline-1;
Expand Down
Loading
Loading