diff --git a/packages/pv-stylemark/tasks/lsg/buildLsgExamples.js b/packages/pv-stylemark/tasks/lsg/buildLsgExamples.js index f57a526..49c53f9 100644 --- a/packages/pv-stylemark/tasks/lsg/buildLsgExamples.js +++ b/packages/pv-stylemark/tasks/lsg/buildLsgExamples.js @@ -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 => ``).join("\n"); + const scripts = exampleData.exampleScripts.map(script => ``).join("\n"); + markup = componentMarkup + .replace("", `${styles}\n`) + .replace("", `${scripts}\n`); + } 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); diff --git a/packages/pv-stylemark/tasks/lsg/getLsgData.js b/packages/pv-stylemark/tasks/lsg/getLsgData.js index 4719a0e..6e5ad46 100644 --- a/packages/pv-stylemark/tasks/lsg/getLsgData.js +++ b/packages/pv-stylemark/tasks/lsg/getLsgData.js @@ -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 ``; -}; +/** + * @typedef {{ + * componentName: string; + * componentPath: string; + * srcPath: 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 legacyRegexExecutableCodeBlocks = /``` *(?[\w\-]+)(:(?(\.?\.\/)*[\w\-/]+))?\.(?html|css|js)(?( .*)?) *\n+(?[^```]*)```/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 = /``` *(?html|css|js) (?[\w\-]+)( +(?(\.?\.\/)*[\w\-/]+\.[\w\-/]+))?(?\?.+?)?(?#.+?)?(?( .*))? *\n+(?[^```]*)```/g const exampleParser = { name: "exampleParser", @@ -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, @@ -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 += `` + } + 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 += ` +
+ ${groups.language} + \n\`\`\`${groups.language}\n${groups.content}\n\`\`\`\n +
`; + } + + return replacement; + } + + return markdownContent + .replace(legacyRegexExecutableCodeBlocks, replacer) + .replace(regexExecutableCodeBlocks, replacer); +} + module.exports = { getLsgData, }; diff --git a/packages/pv-stylemark/tasks/templates/lsg-component.hbs b/packages/pv-stylemark/tasks/templates/lsg-component.hbs index 9f82ad4..08a7636 100644 --- a/packages/pv-stylemark/tasks/templates/lsg-component.hbs +++ b/packages/pv-stylemark/tasks/templates/lsg-component.hbs @@ -1,5 +1,9 @@

{{options.name}}

+

+ Source: + {{srcPath}} +

{{{description}}}
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-component/dds-component.scss b/packages/pv-stylemark/ui/components/dds-component/dds-component.scss index ea8573b..0c85a57 100644 --- a/packages/pv-stylemark/ui/components/dds-component/dds-component.scss +++ b/packages/pv-stylemark/ui/components/dds-component/dds-component.scss @@ -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; 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() {