diff --git a/packages/pv-stylemark/tasks/lsg/buildLsgExamples.js b/packages/pv-stylemark/tasks/lsg/buildLsgExamples.js index f57a526..23306b8 100644 --- a/packages/pv-stylemark/tasks/lsg/buildLsgExamples.js +++ b/packages/pv-stylemark/tasks/lsg/buildLsgExamples.js @@ -12,11 +12,22 @@ 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 + ".html")); + 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({ diff --git a/packages/pv-stylemark/tasks/lsg/getLsgData.js b/packages/pv-stylemark/tasks/lsg/getLsgData.js index ab58bb5..98b4d36 100644 --- a/packages/pv-stylemark/tasks/lsg/getLsgData.js +++ b/packages/pv-stylemark/tasks/lsg/getLsgData.js @@ -1,30 +1,58 @@ 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(/^ *[\w\-]+\.css/) - .map(match => match.replace(/ /g, "").replace(/\.css$/g, "")); - if (exampleKeys.length === 0) return null; - - const styleContent = stylesMatch.replace(/^ *[\w\-]+\.css( +hidden)?\s+/g, "").trim(); - return { - exampleKey: exampleKeys[0], - styleContent, - }; -}; - -const getExampleMarkup = (matchingString, name, componentPath) => { - matchingString = matchingString.replace(/```/g, "").replace(/\s/g, ""); - const [exampleName, examplePath] = matchingString.split(":"); - const markupUrl = `../components/${componentPath}/${examplePath}`; - return ``; -}; +/** + * 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 {"html"|"css"|"js"} language - `html` will create a new html page, `js` and `css` will be added in the html file + * @property {"" | " hidden"} hidden - Indicates whether the code block should also be shown in the styleguide description of the component + * @property {string} content - the content of the code block + * @example + * ```exampleName:examplePath.lang hidden + * content + * ``` + */ + +/** + * @typedef {{ + * exampleName: string; + * exampleMarkup: StyleMarkCodeBlock; + * exampleStyles: StyleMarkCodeBlock[]; + * exampleScripts: StyleMarkCodeBlock[]; + * }} StyleMarkExampleData + */ + +/** + * @typedef {{ + * componentName: string; + * componentPath: string; + * options: Object; + * description: string; + * examples: Array; + * }} StyleMarkLSGData + */ + +// example code blocks +// ```example:/path/to/page.html +// ``` +// +// ```example.js +// console.log('Example 1: ' + data); +// ``` +// +// ```example.css hidden +// button { +// display: none; +// } +// ``` +const regexExecutableCodeBlocks = /``` *(?[\w\-]+)(:(?(\.?\.\/)*[\w\-/]+))?\.(?html|css|js)(?( hidden)?) *\n+(?[^```]*)```/g; const exampleParser = { name: "exampleParser", @@ -51,36 +79,36 @@ 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 { attributes: frontmatterData, body: fileContentBody } = frontmatter(fileContent); - const stylesRegex = new RegExp(/``` *[\w\-]+\.css( +hidden)? *\n+[^```]+```/g); - - const stylesMatches = fileContentBody.match(stylesRegex) || []; - - const styles = stylesMatches.map(match => match.replace(/```/g, "")); - const stylesList = styles.map(getStylesData); - - const exampleRegex = new RegExp(/``` *[\w\-]+:(\.?\.\/)*[\w\-/]+\.[a-z]+\s*\n```/g); + const codeBlocks = await getExecutableCodeBlocks(fileContentBody); - 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, @@ -120,16 +148,58 @@ 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 {Array} + */ +async function getExecutableCodeBlocks(markdownContent) { + return Array.from(markdownContent.matchAll(regexExecutableCodeBlocks)) + .map(match => match.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) { + return markdownContent.replace(regexExecutableCodeBlocks, (...args) => { + let replacement = ""; + /** @type {StyleMarkCodeBlock} */ + const groups = 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}.html` : `${groups.exampleName}.html`; + const markupUrl = join("../components", componentPath, examplePath); + replacement += `` + } + if (groups.content && !groups.hidden) { + // add the css/js code blocks for the example. make sure it is indented the way `marked` can handle it + replacement += ` +
+ ${groups.language} + \n\`\`\`${groups.language}\n${groups.content}\n\`\`\`\n +
`; + } + + return replacement; + }); +} + module.exports = { getLsgData, }; diff --git a/packages/pv-stylemark/tasks/templates/lsg-example.hbs b/packages/pv-stylemark/tasks/templates/lsg-example.hbs index 02239c7..e00e7c0 100644 --- a/packages/pv-stylemark/tasks/templates/lsg-example.hbs +++ b/packages/pv-stylemark/tasks/templates/lsg-example.hbs @@ -5,11 +5,16 @@ {{{lsgConfig.examples.headHtml}}} {{#each exampleStyles}} {{/each}} {{{componentMarkup}}} + {{#each exampleScripts}} + + {{/each}} - \ No newline at end of file + diff --git a/packages/pv-stylemark/ui/components/dds-example/dds-example.scss b/packages/pv-stylemark/ui/components/dds-example/dds-example.scss index 7c85ced..4fa0d57 100644 --- a/packages/pv-stylemark/ui/components/dds-example/dds-example.scss +++ b/packages/pv-stylemark/ui/components/dds-example/dds-example.scss @@ -43,7 +43,8 @@ dds-example { } } - &__html-box-toggle { + &__html-box-toggle, + &__code-box-toggle { display: flex; gap: 8px; align-items: center; @@ -76,12 +77,23 @@ dds-example { border-bottom: 5px solid transparent; border-left: 6px solid $dds-color__black-040; - .dds-state--open & { + .dds-state--open &, + [open] & { transform: rotate(90deg); } } } + &__code-box-toggle { + margin-top: 16px; + + + pre { + margin: 0; + padding: 24px; + background: $dds-color__black-010; + } + } + &__html-box-content { display: none; padding: 24px; diff --git a/packages/pv-stylemark/ui/components/dds-example/dds-example.ts b/packages/pv-stylemark/ui/components/dds-example/dds-example.ts index 0ba55f0..fdbdaf2 100644 --- a/packages/pv-stylemark/ui/components/dds-example/dds-example.ts +++ b/packages/pv-stylemark/ui/components/dds-example/dds-example.ts @@ -54,7 +54,7 @@ class DSExample extends HTMLElement { this.renderComponent(); window.addEventListener('click', () => this.handleWindowClick()); this.viewportObserver = new IntersectionObserver( - (entries) => this.handleViewportChange(entries), + (entries) => this.handleViewportChange(entries), { threshold: 0 } @@ -65,7 +65,7 @@ class DSExample extends HTMLElement { private renderComponent() { this.renderExampleLink(); this.renderExampleBox(); - this.renderHtmlBox(); + if (this.markupUrl) this.renderHtmlBox(); } private renderExampleLink() {