Browse Source

feat(markdown): add support for mermain in markdown docs and inlining CSS

Craig Fletcher 1 tháng trước cách đây
mục cha
commit
21172381cb

+ 31 - 3
README.md

@@ -21,8 +21,9 @@ extension. These file paths are made available to the template renderer, for inc
 
 Markdown is compiled using `marked` with the `marked-code-preview` extension enabled, then passed to the template
 renderer in the `content` property. Images referenced in markdown are automatically processed and converted to responsive
-srcsets if matching images are found in the images task. Raw HTML in markdown is stripped by default; set
-`opts.markdown.allowHtml` to `true` to allow it.
+srcsets if matching images are found in the images task. Mermaid fences are rendered to `.mermaid` blocks, and
+`renderMarkdownWithTemplate` injects the Mermaid runtime automatically when a page contains a Mermaid diagram. Raw HTML
+in markdown is stripped by default; set `opts.markdown.allowHtml` to `true` to allow it.
 
 Once the markdown content and template have been rendered out, the resulting `.html` file is minified and output to `dist/`, 
 with the matching path (e.g. `markdown/recipes/soup.md` would be rendered to `dist/recipes/soup.html`).
@@ -90,7 +91,13 @@ opts: {
     styles: [{ pattern: '~/.rhedyn/styles/*.scss' }] 
   },
   markdown: {
-    allowHtml: false
+    allowHtml: false,
+    mermaid: {
+      scriptSrc: "https://cdn.jsdelivr.net/npm/mermaid@11.13.0/dist/mermaid.min.js",
+      initialize: {
+        theme: "default"
+      }
+    }
   },
   site: {
     name: "My Website",
@@ -164,6 +171,7 @@ When imported stylesheets emit relative `url(...)` references, `compileSass` cop
 
 ```javascript
 actionConfig: {
+  includeCssInDetail: true,
   assetOutputDirsByExtension: {
     ".woff2": "static/fonts/",
     ".woff": "static/fonts/",
@@ -172,6 +180,8 @@ actionConfig: {
 }
 ```
 
+If you need a downstream template to inline styles, set `actionConfig.includeCssInDetail: true`. That adds the compiled CSS to `detail.css` for that stylesheet resource only. The default is `false` so CSS text is not carried in action state or cache files unless you explicitly opt in.
+
 **Task Properties:**
 - `key`: Task identifier used in state/resources (required)
 - `name`: Human-readable task label used in logs (optional, defaults to `key`)
@@ -333,6 +343,24 @@ This renders a block containing the route preview image and a link to download t
 
 Route blocks render semantic HTML with stable `.route-block*` classes, but no injected CSS or inline styling. The consuming app is responsible for presentation.
 
+## Mermaid In Markdown
+
+Mermaid diagrams can be embedded with fenced code blocks:
+
+~~~markdown
+```mermaid
+flowchart LR
+A[Start] --> B[Finish]
+```
+~~~
+
+The shared markdown renderer converts these blocks to `.mermaid` elements. When using `renderMarkdownWithTemplate`,
+Rhedyn injects the Mermaid runtime automatically only on pages that contain Mermaid diagrams.
+
+By default the runtime is loaded from jsDelivr. To self-host it or pin a different build, override
+`opts.markdown.mermaid.scriptSrc`. Any `opts.markdown.mermaid.initialize` values are passed through to
+`mermaid.initialize(...)`.
+
 ## Logging
 
 Rhedyn includes comprehensive logging with configurable levels:

+ 1 - 1
package.json

@@ -6,7 +6,7 @@
   "scripts": {
     "generate": "node src/index.js",
     "test": "node --test",
-    "build": "nexe -i src/index.js -o dist/rhedyn --build --python=$(which python3)",
+    "build": "nexe -i src/index.js -o dist/rhedyn --build --python python3 --temp .nexe --patch $PWD/src/build/nexe-python-compat.cjs",
     "lint-fix": "eslint --fix src/**/*.js"
   },
   "bin": {

+ 87 - 3
src/actions/_shared/markdown.js

@@ -1,7 +1,72 @@
-import { marked } from "marked";
+import { Marked } from "marked";
 import markedCodePreview from "marked-code-preview";
 import { escapeHtmlAttr, slugifyString } from "../../util/index.js";
 
+const DEFAULT_MERMAID_SCRIPT_SRC =
+  "https://cdn.jsdelivr.net/npm/mermaid@11.13.0/dist/mermaid.min.js";
+
+function normaliseCodeLanguage(lang) {
+  if (typeof lang !== "string") {
+    return "";
+  }
+  return lang.trim().split(/\s+/)[0]?.toLowerCase() || "";
+}
+
+function isMermaidCodeBlock(lang) {
+  return normaliseCodeLanguage(lang) === "mermaid";
+}
+
+function getMermaidOptions(meta) {
+  const mermaidOptions = meta?.opts?.markdown?.mermaid;
+  if (mermaidOptions === false || mermaidOptions?.enabled === false) {
+    return null;
+  }
+  return {
+    initialize:
+      mermaidOptions && typeof mermaidOptions.initialize === "object"
+        ? mermaidOptions.initialize
+        : {},
+    scriptSrc:
+      typeof mermaidOptions?.scriptSrc === "string" &&
+      mermaidOptions.scriptSrc.trim()
+        ? mermaidOptions.scriptSrc.trim()
+        : DEFAULT_MERMAID_SCRIPT_SRC,
+  };
+}
+
+function renderMermaidBlock(text) {
+  return `<pre class="mermaid">${escapeHtmlAttr(text)}</pre>`;
+}
+
+function stringifyInlineScriptValue(value) {
+  return JSON.stringify(value).replace(/<\/script/gi, "<\\/script");
+}
+
+export function buildMermaidRuntimeHtml(meta) {
+  const mermaidOptions = getMermaidOptions(meta);
+  if (!mermaidOptions) {
+    return "";
+  }
+
+  const initialConfig = stringifyInlineScriptValue({
+    ...mermaidOptions.initialize,
+    startOnLoad: false,
+  });
+
+  return [
+    `<script defer src="${escapeHtmlAttr(mermaidOptions.scriptSrc)}"></script>`,
+    "<script>",
+    'document.addEventListener("DOMContentLoaded",function(){',
+    "if(!window.mermaid){return}",
+    `window.mermaid.initialize(${initialConfig});`,
+    'window.mermaid.run({querySelector:".mermaid"}).catch(function(err){',
+    'console.error("Failed to render Mermaid diagrams",err);',
+    "});",
+    "});",
+    "</script>",
+  ].join("");
+}
+
 function normaliseRouteRef(routeName) {
   if (typeof routeName !== "string") {
     return "";
@@ -107,19 +172,29 @@ function createRouteExtension(meta) {
 
 export function createMarkdownRenderer(meta) {
   const allowHtml = meta?.opts?.markdown?.allowHtml === true;
-  return marked
+  let lastRenderState = { hasMermaid: false };
+
+  const marked = new Marked();
+  marked
     .use({ gfm: true })
     .use(markedCodePreview)
     .use(createRouteExtension(meta))
     .use({
       renderer: {
+        code({ text, lang }) {
+          if (!isMermaidCodeBlock(lang)) {
+            return false;
+          }
+          lastRenderState = { hasMermaid: true };
+          return renderMermaidBlock(text);
+        },
         html(html) {
           return allowHtml ? html : "";
         },
         image({ href, title, text }) {
           const attrs = [`alt="${escapeHtmlAttr(text)}"`];
 
-          const foundSrcSet = meta.resources.images?.[slugifyString(href)];
+          const foundSrcSet = meta?.resources?.images?.[slugifyString(href)];
 
           if (foundSrcSet && foundSrcSet.detail.srcSet?.length > 0) {
             const srcSetString = foundSrcSet.detail.srcSet
@@ -146,4 +221,13 @@ export function createMarkdownRenderer(meta) {
         }
       }
     });
+
+  const renderMarkdown = markdown => {
+    lastRenderState = { hasMermaid: false };
+    return marked.parse(markdown);
+  };
+
+  renderMarkdown.getLastRenderState = () => ({ ...lastRenderState });
+
+  return renderMarkdown;
 }

+ 3 - 0
src/actions/compileSass/README.md

@@ -6,6 +6,7 @@ Compiles SCSS to compressed CSS, including official Sass `pkg:` package imports.
 - `filePath` (string, required): Input SCSS file path (computed from `jobConfig`).
 - `fileOutputPath` (string, required): Output CSS file path (computed from `jobConfig`).
 - `assetOutputDirsByExtension` (object, optional): Maps copied asset extensions to output dirs relative to `opts.outDir`.
+- `includeCssInDetail` (boolean, optional): When `true`, includes the compiled CSS text in `detail.css` for downstream template inlining. Defaults to `false`.
   Defaults to:
   - fonts (`.woff2`, `.woff`, `.ttf`, `.otf`, `.eot`) -> `static/fonts/`
   - `default` -> `static/assets/`
@@ -19,6 +20,7 @@ jobConfig: {
   outputFileExtension: ".css"
 },
 actionConfig: {
+  includeCssInDetail: true,
   assetOutputDirsByExtension: {
     ".woff2": "static/fonts/",
     ".woff": "static/fonts/",
@@ -31,3 +33,4 @@ actionConfig: {
 - `compileSass` enables Sass `NodePackageImporter` automatically, so `@use "pkg:@fontsource/overlock" as overlock;` works out of the box.
 - Relative `url(...)` assets that resolve against loaded Sass or CSS files are copied into the configured output dirs and rewritten to site-rooted URLs such as `/static/fonts/example.woff2`.
 - Dependency paths include loaded Sass/CSS files and any copied asset source files for cache invalidation.
+- `includeCssInDetail` is off by default so action state and caches do not carry CSS blobs unless a downstream template explicitly needs inline styles.

+ 5 - 3
src/actions/compileSass/index.js

@@ -260,12 +260,14 @@ export async function compileSass({ config: actionConfig, meta }) {
   })
   await writeFile(fileOutputPath, css)
   const loadedFilePaths = getLoadedFilePaths(result.loadedUrls)
+  const detail = {
+    href: toPublicHref(fileOutputPath, meta.opts.outDir),
+    ...(actionConfig.includeCssInDetail === true ? { css } : {}),
+  }
   return {
     paths: [fileOutputPath, ...assetOutputPaths],
     ref: slugifyString(fileOutputPath),
-    detail: {
-      href: toPublicHref(fileOutputPath, meta.opts.outDir),
-    },
+    detail,
     deps: {
       paths: [...new Set([...loadedFilePaths, ...assetSourcePaths])],
     },

+ 2 - 1
src/actions/renderMarkdownToHtml/README.md

@@ -17,8 +17,9 @@ actionConfig: {}
 ```
 
 ## Notes
-- Emits `detail` containing frontmatter, `href`, `content` (HTML), and `fileOutputPath`.
+- Emits `detail` containing frontmatter, `href`, `content` (HTML), `fileOutputPath`, and `hasMermaid`.
 - Uses `meta.resources.images` to attach responsive image srcsets when available.
+- Mermaid fences render as `.mermaid` blocks. This action reports `detail.hasMermaid` so downstream templates can decide whether to include Mermaid runtime scripts.
 - Supports custom route blocks in markdown using `::route <gpx-name>`, sourced from `resources.routes`.
 - Route blocks include OpenStreetMap attribution when provided by route assets.
 - Route blocks render with stable `.route-block*` classes but without injected CSS or inline styling; the consuming app is responsible for presentation.

+ 2 - 1
src/actions/renderMarkdownToHtml/index.js

@@ -12,7 +12,8 @@ export async function renderMarkdownToHtml({ config: actionConfig, meta }) {
 
   const renderer = createMarkdownRenderer(meta)
   const html = renderer(markdown)
-  const detail = { ...data, href, content: html, fileOutputPath }
+  const { hasMermaid } = renderer.getLastRenderState()
+  const detail = { ...data, href, content: html, fileOutputPath, hasMermaid }
 
   return {
     detail,

+ 2 - 0
src/actions/renderMarkdownWithTemplate/README.md

@@ -27,6 +27,8 @@ actionConfig: {
 - Uses frontmatter `template` when present, otherwise `defaultTemplate`.
 - Writes minified HTML to `fileOutputPath` and records the template as a dependency.
 - Respects `opts.markdown.allowHtml` to enable or strip raw HTML in markdown.
+- Mermaid code fences render as `.mermaid` blocks, and Mermaid runtime scripts are auto-injected only on pages that use them.
+- Set `opts.markdown.mermaid.scriptSrc` to self-host or pin a Mermaid bundle; `opts.markdown.mermaid.initialize` is passed to `mermaid.initialize(...)`.
 - Supports custom route blocks in markdown using `::route <gpx-name>` (for example: `::route mountain-loop`).
 - Route blocks render an image preview + GPX download link from `resources.routes`.
 - Route blocks include OpenStreetMap attribution when provided by route assets.

+ 24 - 3
src/actions/renderMarkdownWithTemplate/index.js

@@ -2,7 +2,10 @@ import fs from "fs/promises"
 import { minify } from "html-minifier-terser"
 import matter from "gray-matter"
 import { getHref, slugifyString, writeFile } from "../../util/index.js"
-import { createMarkdownRenderer } from "../_shared/markdown.js"
+import {
+  buildMermaidRuntimeHtml,
+  createMarkdownRenderer,
+} from "../_shared/markdown.js"
 import { getTemplateByName, loadPartials } from "../_shared/template-cache.js"
 
 function normaliseDateValue(value) {
@@ -161,6 +164,19 @@ function injectJsonLdScript(html, jsonLdString) {
   return `${html}\n${script}`
 }
 
+function injectHtmlBeforeBody(html, injectedHtml) {
+  if (!injectedHtml) {
+    return html
+  }
+  if (html.includes("</body>")) {
+    return html.replace("</body>", `  ${injectedHtml}\n</body>`)
+  }
+  if (html.includes("</head>")) {
+    return html.replace("</head>", `  ${injectedHtml}\n</head>`)
+  }
+  return `${html}\n${injectedHtml}`
+}
+
 export async function renderMarkdownWithTemplate({ config: actionConfig, meta }) {
   const filePath = actionConfig.filePath
   const fileOutputPath = actionConfig.fileOutputPath
@@ -181,6 +197,8 @@ export async function renderMarkdownWithTemplate({ config: actionConfig, meta })
   const partialPaths = await loadPartials(actionConfig.partialDirs)
   const template = await getTemplateByName(actionConfig.templateDirs, templateName)
   const renderer = createMarkdownRenderer(meta)
+  const renderedContent = renderer(markdown)
+  const { hasMermaid } = renderer.getLastRenderState()
   const renderedHtml = template.renderer({
     ...data,
     ...meta,
@@ -189,9 +207,12 @@ export async function renderMarkdownWithTemplate({ config: actionConfig, meta })
       article: articleJsonLd,
       articleJsonLd: articleJsonLdString,
     },
-    content: renderer(markdown),
+    content: renderedContent,
   })
-  const html = injectJsonLdScript(renderedHtml, articleJsonLdString)
+  const htmlWithJsonLd = injectJsonLdScript(renderedHtml, articleJsonLdString)
+  const html = hasMermaid
+    ? injectHtmlBeforeBody(htmlWithJsonLd, buildMermaidRuntimeHtml(meta))
+    : htmlWithJsonLd
   const minifiedHtml = await minify(html, {
     collapseWhitespace: true,
     removeComments: true,

+ 35 - 0
src/build/nexe-python-compat.cjs

@@ -0,0 +1,35 @@
+exports.default = async function nexePythonCompat(compiler, next) {
+  const fileName = "./tools/configure.d/nodedownload.py";
+  const file = await compiler.readFileAsync(fileName);
+
+  if (!file.contents.includes("FancyURLopener")) {
+    return next();
+  }
+
+  const updatedContents = file.contents
+    .replace(
+      /try:\n    from urllib\.request import FancyURLopener, URLopener\nexcept ImportError:\n    from urllib import FancyURLopener, URLopener/,
+      [
+        "try:",
+        "    from urllib.request import urlretrieve",
+        "except ImportError:",
+        "    from urllib import urlretrieve",
+      ].join("\n"),
+    )
+    .replace(
+      /class ConfigOpener\(FancyURLopener\):\n    """fancy opener used by retrievefile\. Set a UA"""\n    # append to existing version \(UA\)\n    version = '%s node\.js\/configure' % URLopener\.version/,
+      [
+        "class ConfigOpener(object):",
+        '    """compat opener used by retrievefile."""',
+        "",
+        "    def retrieve(self, url, targetfile, reporthook=None):",
+        "        return urlretrieve(url, targetfile, reporthook=reporthook)",
+      ].join("\n"),
+    );
+
+  if (updatedContents !== file.contents) {
+    await compiler.setFileContentsAsync(fileName, updatedContents);
+  }
+
+  return next();
+};

+ 66 - 0
test/compileSass.test.js

@@ -0,0 +1,66 @@
+import assert from "node:assert/strict";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import test from "node:test";
+import { compileSass } from "../src/actions/compileSass/index.js";
+
+async function setupStylesheetFixture(t) {
+  const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "rhedyn-sass-"));
+  const stylesDir = path.join(tempDir, "styles");
+  const outDir = path.join(tempDir, "dist");
+  const filePath = path.join(stylesDir, "main.scss");
+  const fileOutputPath = path.join(outDir, "static/styles/main.css");
+
+  t.after(async () => {
+    await fs.rm(tempDir, { force: true, recursive: true });
+  });
+
+  await fs.mkdir(stylesDir, { recursive: true });
+  await fs.mkdir(path.dirname(fileOutputPath), { recursive: true });
+  await fs.writeFile(filePath, "body { color: red; }", "utf8");
+
+  return { tempDir, outDir, filePath, fileOutputPath };
+}
+
+test("compileSass does not include css in detail by default", async t => {
+  const { tempDir, outDir, filePath, fileOutputPath } = await setupStylesheetFixture(t);
+
+  const result = await compileSass({
+    config: {
+      filePath,
+      fileOutputPath,
+    },
+    meta: {
+      opts: {
+        outDir,
+        runDir: tempDir,
+      },
+    },
+  });
+
+  assert.equal(result.detail.href, "/static/styles/main.css");
+  assert.equal("css" in result.detail, false);
+});
+
+test("compileSass includes css in detail when configured", async t => {
+  const { tempDir, outDir, filePath, fileOutputPath } = await setupStylesheetFixture(t);
+
+  const result = await compileSass({
+    config: {
+      filePath,
+      fileOutputPath,
+      includeCssInDetail: true,
+    },
+    meta: {
+      opts: {
+        outDir,
+        runDir: tempDir,
+      },
+    },
+  });
+
+  assert.equal(result.detail.href, "/static/styles/main.css");
+  assert.equal(typeof result.detail.css, "string");
+  assert.equal(result.detail.css.includes("color:red"), true);
+});

+ 126 - 0
test/markdownMermaid.test.js

@@ -0,0 +1,126 @@
+import assert from "node:assert/strict";
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import test from "node:test";
+import {
+  buildMermaidRuntimeHtml,
+  createMarkdownRenderer,
+} from "../src/actions/_shared/markdown.js";
+import { clearTemplateCache } from "../src/actions/_shared/template-cache.js";
+import { renderMarkdownWithTemplate } from "../src/actions/renderMarkdownWithTemplate/index.js";
+
+test("mermaid fences render as mermaid blocks and track render state", () => {
+  const renderer = createMarkdownRenderer({ resources: {} });
+
+  const html = renderer("```mermaid\nflowchart LR\nA-->B\n```");
+
+  assert.equal(
+    html,
+    '<pre class="mermaid">flowchart LR\nA--&gt;B</pre>',
+  );
+  assert.deepEqual(renderer.getLastRenderState(), { hasMermaid: true });
+
+  renderer("Regular paragraph");
+  assert.deepEqual(renderer.getLastRenderState(), { hasMermaid: false });
+});
+
+test("mermaid runtime html uses defaults and respects overrides", () => {
+  const defaultRuntime = buildMermaidRuntimeHtml({ opts: { markdown: {} } });
+  assert.equal(
+    defaultRuntime.includes(
+      'src="https://cdn.jsdelivr.net/npm/mermaid@11.13.0/dist/mermaid.min.js"',
+    ),
+    true,
+  );
+  assert.equal(defaultRuntime.includes('"startOnLoad":false'), true);
+
+  const customRuntime = buildMermaidRuntimeHtml({
+    opts: {
+      markdown: {
+        mermaid: {
+          initialize: { theme: "forest" },
+          scriptSrc: "/static/vendor/mermaid.min.js",
+        },
+      },
+    },
+  });
+
+  assert.equal(
+    customRuntime.includes('src="/static/vendor/mermaid.min.js"'),
+    true,
+  );
+  assert.equal(customRuntime.includes('"theme":"forest"'), true);
+});
+
+test("renderMarkdownWithTemplate injects mermaid runtime only when needed", async t => {
+  const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "rhedyn-mermaid-"));
+  const templateDir = path.join(tempDir, "templates");
+  const outDir = path.join(tempDir, "dist");
+  const partialDir = path.join(tempDir, "partials");
+  const mermaidMarkdownPath = path.join(tempDir, "diagram.md");
+  const plainMarkdownPath = path.join(tempDir, "plain.md");
+  const mermaidOutputPath = path.join(outDir, "diagram.html");
+  const plainOutputPath = path.join(outDir, "plain.html");
+
+  t.after(async () => {
+    await fs.rm(tempDir, { force: true, recursive: true });
+  });
+
+  await fs.mkdir(templateDir, { recursive: true });
+  await fs.mkdir(partialDir, { recursive: true });
+  await fs.writeFile(
+    path.join(templateDir, "page.hbs"),
+    "<html><body>{{{content}}}</body></html>",
+    "utf8",
+  );
+  await fs.writeFile(
+    mermaidMarkdownPath,
+    "```mermaid\nflowchart LR\nA-->B\n```",
+    "utf8",
+  );
+  await fs.writeFile(plainMarkdownPath, "Plain paragraph.", "utf8");
+
+  clearTemplateCache();
+
+  const meta = {
+    opts: {
+      markdown: {},
+      outDir: outDir,
+      runDir: tempDir,
+    },
+    resources: {},
+  };
+
+  await renderMarkdownWithTemplate({
+    config: {
+      defaultTemplate: "page",
+      fileOutputPath: mermaidOutputPath,
+      filePath: mermaidMarkdownPath,
+      partialDirs: [partialDir],
+      templateDirs: [templateDir],
+    },
+    meta,
+  });
+
+  await renderMarkdownWithTemplate({
+    config: {
+      defaultTemplate: "page",
+      fileOutputPath: plainOutputPath,
+      filePath: plainMarkdownPath,
+      partialDirs: [partialDir],
+      templateDirs: [templateDir],
+    },
+    meta,
+  });
+
+  const mermaidHtml = await fs.readFile(mermaidOutputPath, "utf8");
+  const plainHtml = await fs.readFile(plainOutputPath, "utf8");
+
+  assert.equal(/class=(?:"mermaid"|mermaid)/.test(mermaidHtml), true);
+  assert.equal(mermaidHtml.includes("mermaid.initialize"), true);
+  assert.equal(mermaidHtml.includes("cdn.jsdelivr.net/npm/mermaid@11.13.0"), true);
+
+  assert.equal(plainHtml.includes("mermaid.initialize"), false);
+  assert.equal(plainHtml.includes("cdn.jsdelivr.net/npm/mermaid@11.13.0"), false);
+});