Compare commits

..

5 Commits

Author SHA1 Message Date
Jim Liu 宝玉 e55f91b0ea chore: release v1.74.0 2026-03-20 23:17:14 -05:00
Jim Liu 宝玉 fe3b3d9125 feat(baoyu-markdown-to-html): pass through all rendering options from CLI
- CLI now supports --color, --font-family, --font-size, --code-theme, --mac-code-block, --line-number, --count, --legend
- convertMarkdown accepts full CliOptions instead of limited subset
- Dynamic help text showing available theme/color/font options
2026-03-20 23:17:10 -05:00
Jim Liu 宝玉 105339cf3f fix(baoyu-md): fix CSS custom property regex for quoted values and add theme layering
- Remove quotes from CSS custom property regex character class so values containing quotes are fully stripped
- grace/simple themes now layer default CSS before their own rules
- Add tests for quoted property stripping and theme layering
2026-03-20 23:17:04 -05:00
Jim Liu 宝玉 dcfd9033ae chore: release v1.73.3 2026-03-20 18:46:01 -05:00
Jim Liu 宝玉 eb416d174c fix(baoyu-post-to-wechat): prevent placeholder regex from matching longer numbered variants 2026-03-20 18:45:05 -05:00
24 changed files with 426 additions and 69 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
}, },
"metadata": { "metadata": {
"description": "Skills shared by Baoyu for improving daily work efficiency", "description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "1.73.2" "version": "1.74.0"
}, },
"plugins": [ "plugins": [
{ {
+13
View File
@@ -2,6 +2,19 @@
English | [中文](./CHANGELOG.zh.md) English | [中文](./CHANGELOG.zh.md)
## 1.74.0 - 2026-03-20
### Features
- `baoyu-markdown-to-html`: CLI now supports all rendering options — color, font-family, font-size, code-theme, mac-code-block, line-number, count, legend
### Fixes
- `baoyu-markdown-to-html`: fix CSS custom property regex to handle quoted values; grace/simple themes now layer default CSS
## 1.73.3 - 2026-03-20
### Fixes
- `baoyu-post-to-wechat`: fix placeholder replacement to avoid shorter placeholders matching longer numbered variants
## 1.73.2 - 2026-03-20 ## 1.73.2 - 2026-03-20
### Fixes ### Fixes
+13
View File
@@ -2,6 +2,19 @@
[English](./CHANGELOG.md) | 中文 [English](./CHANGELOG.md) | 中文
## 1.74.0 - 2026-03-20
### 新功能
- `baoyu-markdown-to-html`:CLI 支持全部渲染选项 — color、font-family、font-size、code-theme、mac-code-block、line-number、count、legend
### 修复
- `baoyu-markdown-to-html`:修复 CSS 自定义属性正则无法处理带引号值的问题;grace/simple 主题现在会叠加 default 主题 CSS
## 1.73.3 - 2026-03-20
### 修复
- `baoyu-post-to-wechat`:修复占位符替换时短占位符错误匹配更长编号变体的问题
## 1.73.2 - 2026-03-20 ## 1.73.2 - 2026-03-20
### 修复 ### 修复
+1 -1
View File
@@ -1,6 +1,6 @@
# CLAUDE.md # CLAUDE.md
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.73.2**. Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.74.0**.
## Architecture ## Architecture
+34
View File
@@ -9,12 +9,26 @@ import { COLOR_PRESETS, FONT_FAMILY_MAP } from "./constants.ts";
import { import {
buildMarkdownDocumentMeta, buildMarkdownDocumentMeta,
formatTimestamp, formatTimestamp,
renderMarkdownDocument,
resolveColorToken, resolveColorToken,
resolveFontFamilyToken, resolveFontFamilyToken,
resolveMarkdownStyle, resolveMarkdownStyle,
resolveRenderOptions, resolveRenderOptions,
} from "./document.ts"; } from "./document.ts";
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, `\\$&`);
}
function findInlineStyle(html: string, tagName: string, text: string): string {
const pattern = new RegExp(
`<${tagName}[^>]*style="([^"]*)"[^>]*>${escapeRegExp(text)}</${tagName}>`,
);
const match = html.match(pattern);
assert.ok(match, `Expected inline style for <${tagName}>${text}</${tagName}>`);
return match![1]!;
}
function useCwd(t: TestContext, cwd: string): void { function useCwd(t: TestContext, cwd: string): void {
const previous = process.cwd(); const previous = process.cwd();
process.chdir(cwd); process.chdir(cwd);
@@ -138,3 +152,23 @@ keep_title: true
assert.equal(explicit.fontSize, "18px"); assert.equal(explicit.fontSize, "18px");
assert.equal(explicit.keepTitle, false); assert.equal(explicit.keepTitle, false);
}); });
test("renderMarkdownDocument layers default rules into grace theme before CSS inlining", async () => {
const { html } = await renderMarkdownDocument(
`## Section\n\nParagraph with **bold** text.`,
{ keepTitle: true, theme: "grace" },
);
const h2Style = findInlineStyle(html, "h2", "Section");
assert.match(h2Style, /background: #92617E/);
assert.match(h2Style, /box-shadow: 0 4px 6px rgba\(0, 0, 0, 0\.1\)/);
const pMatch = html.match(/<p[^>]*style="([^"]*)"[^>]*>/);
assert.ok(pMatch, "Expected inline style on <p> tag");
assert.match(pMatch![1]!, /color:/);
const strongPattern = /<strong[^>]*style="([^"]*)"[^>]*>bold<\/strong>/;
const strongMatch = html.match(strongPattern);
assert.ok(strongMatch, "Expected inline style for <strong>bold</strong>");
assert.match(strongMatch![1]!, /font-weight:/);
});
@@ -59,6 +59,17 @@ test("normalizeCssText and normalizeInlineCss replace variables and strip declar
assert.doesNotMatch(normalizedHtml, /var\(--md-primary-color\)/); assert.doesNotMatch(normalizedHtml, /var\(--md-primary-color\)/);
}); });
test("normalizeInlineCss removes quoted custom property values without leaving fragments behind", () => {
const normalizedHtml = normalizeInlineCss(
`<html style="--md-font-family: Menlo, Monaco, 'Courier New', monospace; color: var(--md-primary-color)"></html>`,
DEFAULT_STYLE,
);
assert.match(normalizedHtml, /style=" color: #0F4C81"/);
assert.doesNotMatch(normalizedHtml, /Courier New/);
assert.doesNotMatch(normalizedHtml, /--md-font-family/);
});
test("HTML structure helpers hoist nested lists and remove the first heading", () => { test("HTML structure helpers hoist nested lists and remove the first heading", () => {
const nestedList = `<ul><li>Parent<ul><li>Child</li></ul></li></ul>`; const nestedList = `<ul><li>Parent<ul><li>Child</li></ul></li></ul>`;
assert.equal( assert.equal(
+7 -7
View File
@@ -100,13 +100,13 @@ export function normalizeCssText(cssText: string, style: StyleConfig = DEFAULT_S
.replace(/var\(--md-accent-color\)/g, style.accentColor) .replace(/var\(--md-accent-color\)/g, style.accentColor)
.replace(/var\(--md-container-bg\)/g, style.containerBg) .replace(/var\(--md-container-bg\)/g, style.containerBg)
.replace(/hsl\(var\(--foreground\)\)/g, "#3f3f3f") .replace(/hsl\(var\(--foreground\)\)/g, "#3f3f3f")
.replace(/--md-primary-color:\s*[^;"']+;?/g, "") .replace(/--md-primary-color:\s*[^;]+;?/g, "")
.replace(/--md-font-family:\s*[^;"']+;?/g, "") .replace(/--md-font-family:\s*[^;]+;?/g, "")
.replace(/--md-font-size:\s*[^;"']+;?/g, "") .replace(/--md-font-size:\s*[^;]+;?/g, "")
.replace(/--blockquote-background:\s*[^;"']+;?/g, "") .replace(/--blockquote-background:\s*[^;]+;?/g, "")
.replace(/--md-accent-color:\s*[^;"']+;?/g, "") .replace(/--md-accent-color:\s*[^;]+;?/g, "")
.replace(/--md-container-bg:\s*[^;"']+;?/g, "") .replace(/--md-container-bg:\s*[^;]+;?/g, "")
.replace(/--foreground:\s*[^;"']+;?/g, ""); .replace(/--foreground:\s*[^;]+;?/g, "");
} }
export function normalizeInlineCss(html: string, style: StyleConfig = DEFAULT_STYLE): string { export function normalizeInlineCss(html: string, style: StyleConfig = DEFAULT_STYLE): string {
+12 -1
View File
@@ -6,6 +6,7 @@ import type { ThemeName } from "./types.js";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
export const THEME_DIR = path.resolve(SCRIPT_DIR, "themes"); export const THEME_DIR = path.resolve(SCRIPT_DIR, "themes");
const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"]; const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"];
const THEMES_EXTENDING_DEFAULT = new Set<ThemeName>(["grace", "simple"]);
function stripOutputScope(cssContent: string): string { function stripOutputScope(cssContent: string): string {
let css = cssContent; let css = cssContent;
@@ -41,6 +42,7 @@ export function loadThemeCss(theme: ThemeName): {
themeCss: string; themeCss: string;
} { } {
const basePath = path.join(THEME_DIR, "base.css"); const basePath = path.join(THEME_DIR, "base.css");
const defaultThemePath = path.join(THEME_DIR, "default.css");
const themePath = path.join(THEME_DIR, `${theme}.css`); const themePath = path.join(THEME_DIR, `${theme}.css`);
if (!fs.existsSync(basePath)) { if (!fs.existsSync(basePath)) {
@@ -51,9 +53,18 @@ export function loadThemeCss(theme: ThemeName): {
throw new Error(`Missing theme CSS for "${theme}": ${themePath}`); throw new Error(`Missing theme CSS for "${theme}": ${themePath}`);
} }
const layeredThemeCss: string[] = [];
if (theme !== "default" && THEMES_EXTENDING_DEFAULT.has(theme)) {
if (!fs.existsSync(defaultThemePath)) {
throw new Error(`Missing default theme CSS: ${defaultThemePath}`);
}
layeredThemeCss.push(fs.readFileSync(defaultThemePath, "utf-8"));
}
layeredThemeCss.push(fs.readFileSync(themePath, "utf-8"));
return { return {
baseCss: fs.readFileSync(basePath, "utf-8"), baseCss: fs.readFileSync(basePath, "utf-8"),
themeCss: fs.readFileSync(themePath, "utf-8"), themeCss: layeredThemeCss.join("\n"),
}; };
} }
@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
const SCRIPT_PATH = path.join(SCRIPT_DIR, "main.ts");
async function makeTempDir(prefix: string): Promise<string> {
return fs.mkdtemp(path.join(os.tmpdir(), prefix));
}
test("CLI forwards wrapper title and vendor render options", async () => {
const root = await makeTempDir("baoyu-markdown-to-html-cli-");
const markdownPath = path.join(root, "article.md");
await fs.writeFile(markdownPath, "## Section\n\nParagraph with **bold** text.\n", "utf-8");
const { stdout } = await execFileAsync(
"bun",
[
SCRIPT_PATH,
markdownPath,
"--theme", "grace",
"--color", "red",
"--font-family", "mono",
"--font-size", "18",
"--keep-title",
"--title", "Overridden",
],
{ cwd: SCRIPT_DIR },
);
const result = JSON.parse(stdout.trim()) as {
htmlPath: string;
title: string;
};
assert.equal(result.title, "Overridden");
const html = await fs.readFile(result.htmlPath, "utf-8");
assert.match(html, /<title>Overridden<\/title>/);
assert.match(html, /<h2[^>]*style="[^"]*background: #A93226/);
assert.match(html, /<strong[^>]*style="[^"]*color: #A93226/);
assert.match(
html,
/<body[^>]*style="[^"]*font-family: Menlo, Monaco, 'Courier New', monospace;[^"]*font-size: 18px/,
);
});
+83 -29
View File
@@ -4,16 +4,22 @@ import path from "node:path";
import process from "node:process"; import process from "node:process";
import { import {
COLOR_PRESETS,
FONT_FAMILY_MAP,
FONT_SIZE_OPTIONS,
THEME_NAMES,
extractSummaryFromBody, extractSummaryFromBody,
extractTitleFromMarkdown, extractTitleFromMarkdown,
formatTimestamp, formatTimestamp,
parseArgs,
parseFrontmatter, parseFrontmatter,
renderMarkdownDocument, renderMarkdownDocument,
replaceMarkdownImagesWithPlaceholders, replaceMarkdownImagesWithPlaceholders,
resolveContentImages, resolveContentImages,
serializeFrontmatter, serializeFrontmatter,
stripWrappingQuotes, stripWrappingQuotes,
} from "baoyu-md"; } from "./vendor/baoyu-md/src/index.ts";
import type { CliOptions } from "./vendor/baoyu-md/src/types.ts";
interface ImageInfo { interface ImageInfo {
placeholder: string; placeholder: string;
@@ -30,9 +36,13 @@ interface ParsedResult {
contentImages: ImageInfo[]; contentImages: ImageInfo[];
} }
type ConvertMarkdownOptions = Partial<Omit<CliOptions, "inputPath">> & {
title?: string;
};
export async function convertMarkdown( export async function convertMarkdown(
markdownPath: string, markdownPath: string,
options?: { title?: string; theme?: string; keepTitle?: boolean; citeStatus?: boolean }, options?: ConvertMarkdownOptions,
): Promise<ParsedResult> { ): Promise<ParsedResult> {
const baseDir = path.dirname(markdownPath); const baseDir = path.dirname(markdownPath);
const content = fs.readFileSync(markdownPath, "utf-8"); const content = fs.readFileSync(markdownPath, "utf-8");
@@ -56,20 +66,32 @@ export async function convertMarkdown(
summary = extractSummaryFromBody(body, 120); summary = extractSummaryFromBody(body, 120);
} }
const effectiveFrontmatter = options?.title
? { ...frontmatter, title }
: frontmatter;
const { images, markdown: rewrittenBody } = replaceMarkdownImagesWithPlaceholders( const { images, markdown: rewrittenBody } = replaceMarkdownImagesWithPlaceholders(
body, body,
"MDTOHTMLIMGPH_", "MDTOHTMLIMGPH_",
); );
const rewrittenMarkdown = `${serializeFrontmatter(frontmatter)}${rewrittenBody}`; const rewrittenMarkdown = `${serializeFrontmatter(effectiveFrontmatter)}${rewrittenBody}`;
console.error( console.error(
`[markdown-to-html] Rendering with theme: ${theme ?? "default"}, keepTitle: ${keepTitle}, citeStatus: ${citeStatus}`, `[markdown-to-html] Rendering with theme: ${theme ?? "default"}, keepTitle: ${keepTitle}, citeStatus: ${citeStatus}`,
); );
const { html } = await renderMarkdownDocument(rewrittenMarkdown, { const { html } = await renderMarkdownDocument(rewrittenMarkdown, {
codeTheme: options?.codeTheme,
countStatus: options?.countStatus,
citeStatus, citeStatus,
defaultTitle: title, defaultTitle: title,
fontFamily: options?.fontFamily,
fontSize: options?.fontSize,
isMacCodeBlock: options?.isMacCodeBlock,
isShowLineNumber: options?.isShowLineNumber,
keepTitle, keepTitle,
legend: options?.legend,
primaryColor: options?.primaryColor,
theme, theme,
}); });
@@ -111,7 +133,10 @@ export async function convertMarkdown(
}; };
} }
function printUsage(): never { function printUsage(exitCode = 0): never {
const colorNames = Object.keys(COLOR_PRESETS).join(", ");
const fontFamilyNames = Object.keys(FONT_FAMILY_MAP).join(", ");
console.log(`Convert Markdown to styled HTML console.log(`Convert Markdown to styled HTML
Usage: Usage:
@@ -119,8 +144,17 @@ Usage:
Options: Options:
--title <title> Override title --title <title> Override title
--theme <name> Theme name (default, grace, simple). Default: default --theme <name> Theme name (${THEME_NAMES.join(", ")}). Default: default
--color <name|hex> Primary color: ${colorNames}
--font-family <name> Font: ${fontFamilyNames}, or CSS value
--font-size <N> Font size: ${FONT_SIZE_OPTIONS.join(", ")} (default: 16px)
--code-theme <name> Code highlight theme (default: github)
--mac-code-block Show Mac-style code block header
--no-mac-code-block Hide Mac-style code block header
--line-number Show line numbers in code blocks
--cite Convert ordinary external links to bottom citations. Default: off --cite Convert ordinary external links to bottom citations. Default: off
--count Show reading time / word count
--legend <value> Image caption: title-alt, alt-title, title, alt, none
--keep-title Keep the first heading in content. Default: false (removed) --keep-title Keep the first heading in content. Default: false (removed)
--help Show this help --help Show this help
@@ -142,40 +176,60 @@ Output JSON format:
Example: Example:
npx -y bun main.ts article.md npx -y bun main.ts article.md
npx -y bun main.ts article.md --theme grace npx -y bun main.ts article.md --theme grace
npx -y bun main.ts article.md --theme modern --color red
npx -y bun main.ts article.md --cite npx -y bun main.ts article.md --cite
`); `);
process.exit(0); process.exit(exitCode);
}
function parseArgValue(argv: string[], i: number, flag: string): string | null {
const arg = argv[i]!;
if (arg.includes("=")) {
return arg.slice(flag.length + 1);
}
const next = argv[i + 1];
return next ?? null;
}
function extractTitleArg(argv: string[]): { renderArgs: string[]; title?: string } {
let title: string | undefined;
const renderArgs: string[] = [];
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i]!;
if (arg === "--title" || arg.startsWith("--title=")) {
const value = parseArgValue(argv, i, "--title");
if (!value) {
console.error("Missing value for --title");
printUsage(1);
}
title = value;
if (!arg.includes("=")) {
i += 1;
}
continue;
}
renderArgs.push(arg);
}
return { renderArgs, title };
} }
async function main(): Promise<void> { async function main(): Promise<void> {
const args = process.argv.slice(2); const args = process.argv.slice(2);
if (args.length === 0 || args.includes("--help") || args.includes("-h")) { if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
printUsage(); printUsage(0);
} }
let markdownPath: string | undefined; const { renderArgs, title } = extractTitleArg(args);
let title: string | undefined; const options = parseArgs(renderArgs);
let theme: string | undefined; if (!options) {
let citeStatus = false; printUsage(1);
let keepTitle = false;
for (let i = 0; i < args.length; i++) {
const arg = args[i]!;
if (arg === "--title" && args[i + 1]) {
title = args[++i];
} else if (arg === "--theme" && args[i + 1]) {
theme = args[++i];
} else if (arg === "--cite") {
citeStatus = true;
} else if (arg === "--keep-title") {
keepTitle = true;
} else if (!arg.startsWith("-")) {
markdownPath = arg;
}
} }
if (!markdownPath) { const markdownPath = path.resolve(process.cwd(), options.inputPath);
console.error("Error: Markdown file path is required"); if (!markdownPath.toLowerCase().endsWith(".md")) {
console.error("Input file must end with .md");
process.exit(1); process.exit(1);
} }
@@ -184,7 +238,7 @@ async function main(): Promise<void> {
process.exit(1); process.exit(1);
} }
const result = await convertMarkdown(markdownPath, { title, theme, keepTitle, citeStatus }); const result = await convertMarkdown(markdownPath, { ...options, title });
console.log(JSON.stringify(result, null, 2)); console.log(JSON.stringify(result, null, 2));
} }
@@ -9,12 +9,26 @@ import { COLOR_PRESETS, FONT_FAMILY_MAP } from "./constants.ts";
import { import {
buildMarkdownDocumentMeta, buildMarkdownDocumentMeta,
formatTimestamp, formatTimestamp,
renderMarkdownDocument,
resolveColorToken, resolveColorToken,
resolveFontFamilyToken, resolveFontFamilyToken,
resolveMarkdownStyle, resolveMarkdownStyle,
resolveRenderOptions, resolveRenderOptions,
} from "./document.ts"; } from "./document.ts";
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, `\\$&`);
}
function findInlineStyle(html: string, tagName: string, text: string): string {
const pattern = new RegExp(
`<${tagName}[^>]*style="([^"]*)"[^>]*>${escapeRegExp(text)}</${tagName}>`,
);
const match = html.match(pattern);
assert.ok(match, `Expected inline style for <${tagName}>${text}</${tagName}>`);
return match![1]!;
}
function useCwd(t: TestContext, cwd: string): void { function useCwd(t: TestContext, cwd: string): void {
const previous = process.cwd(); const previous = process.cwd();
process.chdir(cwd); process.chdir(cwd);
@@ -138,3 +152,23 @@ keep_title: true
assert.equal(explicit.fontSize, "18px"); assert.equal(explicit.fontSize, "18px");
assert.equal(explicit.keepTitle, false); assert.equal(explicit.keepTitle, false);
}); });
test("renderMarkdownDocument layers default rules into grace theme before CSS inlining", async () => {
const { html } = await renderMarkdownDocument(
`## Section\n\nParagraph with **bold** text.`,
{ keepTitle: true, theme: "grace" },
);
const h2Style = findInlineStyle(html, "h2", "Section");
assert.match(h2Style, /background: #92617E/);
assert.match(h2Style, /box-shadow: 0 4px 6px rgba\(0, 0, 0, 0\.1\)/);
const pMatch = html.match(/<p[^>]*style="([^"]*)"[^>]*>/);
assert.ok(pMatch, "Expected inline style on <p> tag");
assert.match(pMatch![1]!, /color:/);
const strongPattern = /<strong[^>]*style="([^"]*)"[^>]*>bold<\/strong>/;
const strongMatch = html.match(strongPattern);
assert.ok(strongMatch, "Expected inline style for <strong>bold</strong>");
assert.match(strongMatch![1]!, /font-weight:/);
});
@@ -59,6 +59,17 @@ test("normalizeCssText and normalizeInlineCss replace variables and strip declar
assert.doesNotMatch(normalizedHtml, /var\(--md-primary-color\)/); assert.doesNotMatch(normalizedHtml, /var\(--md-primary-color\)/);
}); });
test("normalizeInlineCss removes quoted custom property values without leaving fragments behind", () => {
const normalizedHtml = normalizeInlineCss(
`<html style="--md-font-family: Menlo, Monaco, 'Courier New', monospace; color: var(--md-primary-color)"></html>`,
DEFAULT_STYLE,
);
assert.match(normalizedHtml, /style=" color: #0F4C81"/);
assert.doesNotMatch(normalizedHtml, /Courier New/);
assert.doesNotMatch(normalizedHtml, /--md-font-family/);
});
test("HTML structure helpers hoist nested lists and remove the first heading", () => { test("HTML structure helpers hoist nested lists and remove the first heading", () => {
const nestedList = `<ul><li>Parent<ul><li>Child</li></ul></li></ul>`; const nestedList = `<ul><li>Parent<ul><li>Child</li></ul></li></ul>`;
assert.equal( assert.equal(
@@ -100,13 +100,13 @@ export function normalizeCssText(cssText: string, style: StyleConfig = DEFAULT_S
.replace(/var\(--md-accent-color\)/g, style.accentColor) .replace(/var\(--md-accent-color\)/g, style.accentColor)
.replace(/var\(--md-container-bg\)/g, style.containerBg) .replace(/var\(--md-container-bg\)/g, style.containerBg)
.replace(/hsl\(var\(--foreground\)\)/g, "#3f3f3f") .replace(/hsl\(var\(--foreground\)\)/g, "#3f3f3f")
.replace(/--md-primary-color:\s*[^;"']+;?/g, "") .replace(/--md-primary-color:\s*[^;]+;?/g, "")
.replace(/--md-font-family:\s*[^;"']+;?/g, "") .replace(/--md-font-family:\s*[^;]+;?/g, "")
.replace(/--md-font-size:\s*[^;"']+;?/g, "") .replace(/--md-font-size:\s*[^;]+;?/g, "")
.replace(/--blockquote-background:\s*[^;"']+;?/g, "") .replace(/--blockquote-background:\s*[^;]+;?/g, "")
.replace(/--md-accent-color:\s*[^;"']+;?/g, "") .replace(/--md-accent-color:\s*[^;]+;?/g, "")
.replace(/--md-container-bg:\s*[^;"']+;?/g, "") .replace(/--md-container-bg:\s*[^;]+;?/g, "")
.replace(/--foreground:\s*[^;"']+;?/g, ""); .replace(/--foreground:\s*[^;]+;?/g, "");
} }
export function normalizeInlineCss(html: string, style: StyleConfig = DEFAULT_STYLE): string { export function normalizeInlineCss(html: string, style: StyleConfig = DEFAULT_STYLE): string {
@@ -6,6 +6,7 @@ import type { ThemeName } from "./types.js";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
export const THEME_DIR = path.resolve(SCRIPT_DIR, "themes"); export const THEME_DIR = path.resolve(SCRIPT_DIR, "themes");
const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"]; const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"];
const THEMES_EXTENDING_DEFAULT = new Set<ThemeName>(["grace", "simple"]);
function stripOutputScope(cssContent: string): string { function stripOutputScope(cssContent: string): string {
let css = cssContent; let css = cssContent;
@@ -41,6 +42,7 @@ export function loadThemeCss(theme: ThemeName): {
themeCss: string; themeCss: string;
} { } {
const basePath = path.join(THEME_DIR, "base.css"); const basePath = path.join(THEME_DIR, "base.css");
const defaultThemePath = path.join(THEME_DIR, "default.css");
const themePath = path.join(THEME_DIR, `${theme}.css`); const themePath = path.join(THEME_DIR, `${theme}.css`);
if (!fs.existsSync(basePath)) { if (!fs.existsSync(basePath)) {
@@ -51,9 +53,18 @@ export function loadThemeCss(theme: ThemeName): {
throw new Error(`Missing theme CSS for "${theme}": ${themePath}`); throw new Error(`Missing theme CSS for "${theme}": ${themePath}`);
} }
const layeredThemeCss: string[] = [];
if (theme !== "default" && THEMES_EXTENDING_DEFAULT.has(theme)) {
if (!fs.existsSync(defaultThemePath)) {
throw new Error(`Missing default theme CSS: ${defaultThemePath}`);
}
layeredThemeCss.push(fs.readFileSync(defaultThemePath, "utf-8"));
}
layeredThemeCss.push(fs.readFileSync(themePath, "utf-8"));
return { return {
baseCss: fs.readFileSync(basePath, "utf-8"), baseCss: fs.readFileSync(basePath, "utf-8"),
themeCss: fs.readFileSync(themePath, "utf-8"), themeCss: layeredThemeCss.join("\n"),
}; };
} }
@@ -9,12 +9,26 @@ import { COLOR_PRESETS, FONT_FAMILY_MAP } from "./constants.ts";
import { import {
buildMarkdownDocumentMeta, buildMarkdownDocumentMeta,
formatTimestamp, formatTimestamp,
renderMarkdownDocument,
resolveColorToken, resolveColorToken,
resolveFontFamilyToken, resolveFontFamilyToken,
resolveMarkdownStyle, resolveMarkdownStyle,
resolveRenderOptions, resolveRenderOptions,
} from "./document.ts"; } from "./document.ts";
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, `\\$&`);
}
function findInlineStyle(html: string, tagName: string, text: string): string {
const pattern = new RegExp(
`<${tagName}[^>]*style="([^"]*)"[^>]*>${escapeRegExp(text)}</${tagName}>`,
);
const match = html.match(pattern);
assert.ok(match, `Expected inline style for <${tagName}>${text}</${tagName}>`);
return match![1]!;
}
function useCwd(t: TestContext, cwd: string): void { function useCwd(t: TestContext, cwd: string): void {
const previous = process.cwd(); const previous = process.cwd();
process.chdir(cwd); process.chdir(cwd);
@@ -138,3 +152,23 @@ keep_title: true
assert.equal(explicit.fontSize, "18px"); assert.equal(explicit.fontSize, "18px");
assert.equal(explicit.keepTitle, false); assert.equal(explicit.keepTitle, false);
}); });
test("renderMarkdownDocument layers default rules into grace theme before CSS inlining", async () => {
const { html } = await renderMarkdownDocument(
`## Section\n\nParagraph with **bold** text.`,
{ keepTitle: true, theme: "grace" },
);
const h2Style = findInlineStyle(html, "h2", "Section");
assert.match(h2Style, /background: #92617E/);
assert.match(h2Style, /box-shadow: 0 4px 6px rgba\(0, 0, 0, 0\.1\)/);
const pMatch = html.match(/<p[^>]*style="([^"]*)"[^>]*>/);
assert.ok(pMatch, "Expected inline style on <p> tag");
assert.match(pMatch![1]!, /color:/);
const strongPattern = /<strong[^>]*style="([^"]*)"[^>]*>bold<\/strong>/;
const strongMatch = html.match(strongPattern);
assert.ok(strongMatch, "Expected inline style for <strong>bold</strong>");
assert.match(strongMatch![1]!, /font-weight:/);
});
@@ -59,6 +59,17 @@ test("normalizeCssText and normalizeInlineCss replace variables and strip declar
assert.doesNotMatch(normalizedHtml, /var\(--md-primary-color\)/); assert.doesNotMatch(normalizedHtml, /var\(--md-primary-color\)/);
}); });
test("normalizeInlineCss removes quoted custom property values without leaving fragments behind", () => {
const normalizedHtml = normalizeInlineCss(
`<html style="--md-font-family: Menlo, Monaco, 'Courier New', monospace; color: var(--md-primary-color)"></html>`,
DEFAULT_STYLE,
);
assert.match(normalizedHtml, /style=" color: #0F4C81"/);
assert.doesNotMatch(normalizedHtml, /Courier New/);
assert.doesNotMatch(normalizedHtml, /--md-font-family/);
});
test("HTML structure helpers hoist nested lists and remove the first heading", () => { test("HTML structure helpers hoist nested lists and remove the first heading", () => {
const nestedList = `<ul><li>Parent<ul><li>Child</li></ul></li></ul>`; const nestedList = `<ul><li>Parent<ul><li>Child</li></ul></li></ul>`;
assert.equal( assert.equal(
@@ -100,13 +100,13 @@ export function normalizeCssText(cssText: string, style: StyleConfig = DEFAULT_S
.replace(/var\(--md-accent-color\)/g, style.accentColor) .replace(/var\(--md-accent-color\)/g, style.accentColor)
.replace(/var\(--md-container-bg\)/g, style.containerBg) .replace(/var\(--md-container-bg\)/g, style.containerBg)
.replace(/hsl\(var\(--foreground\)\)/g, "#3f3f3f") .replace(/hsl\(var\(--foreground\)\)/g, "#3f3f3f")
.replace(/--md-primary-color:\s*[^;"']+;?/g, "") .replace(/--md-primary-color:\s*[^;]+;?/g, "")
.replace(/--md-font-family:\s*[^;"']+;?/g, "") .replace(/--md-font-family:\s*[^;]+;?/g, "")
.replace(/--md-font-size:\s*[^;"']+;?/g, "") .replace(/--md-font-size:\s*[^;]+;?/g, "")
.replace(/--blockquote-background:\s*[^;"']+;?/g, "") .replace(/--blockquote-background:\s*[^;]+;?/g, "")
.replace(/--md-accent-color:\s*[^;"']+;?/g, "") .replace(/--md-accent-color:\s*[^;]+;?/g, "")
.replace(/--md-container-bg:\s*[^;"']+;?/g, "") .replace(/--md-container-bg:\s*[^;]+;?/g, "")
.replace(/--foreground:\s*[^;"']+;?/g, ""); .replace(/--foreground:\s*[^;]+;?/g, "");
} }
export function normalizeInlineCss(html: string, style: StyleConfig = DEFAULT_STYLE): string { export function normalizeInlineCss(html: string, style: StyleConfig = DEFAULT_STYLE): string {
@@ -6,6 +6,7 @@ import type { ThemeName } from "./types.js";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
export const THEME_DIR = path.resolve(SCRIPT_DIR, "themes"); export const THEME_DIR = path.resolve(SCRIPT_DIR, "themes");
const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"]; const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"];
const THEMES_EXTENDING_DEFAULT = new Set<ThemeName>(["grace", "simple"]);
function stripOutputScope(cssContent: string): string { function stripOutputScope(cssContent: string): string {
let css = cssContent; let css = cssContent;
@@ -41,6 +42,7 @@ export function loadThemeCss(theme: ThemeName): {
themeCss: string; themeCss: string;
} { } {
const basePath = path.join(THEME_DIR, "base.css"); const basePath = path.join(THEME_DIR, "base.css");
const defaultThemePath = path.join(THEME_DIR, "default.css");
const themePath = path.join(THEME_DIR, `${theme}.css`); const themePath = path.join(THEME_DIR, `${theme}.css`);
if (!fs.existsSync(basePath)) { if (!fs.existsSync(basePath)) {
@@ -51,9 +53,18 @@ export function loadThemeCss(theme: ThemeName): {
throw new Error(`Missing theme CSS for "${theme}": ${themePath}`); throw new Error(`Missing theme CSS for "${theme}": ${themePath}`);
} }
const layeredThemeCss: string[] = [];
if (theme !== "default" && THEMES_EXTENDING_DEFAULT.has(theme)) {
if (!fs.existsSync(defaultThemePath)) {
throw new Error(`Missing default theme CSS: ${defaultThemePath}`);
}
layeredThemeCss.push(fs.readFileSync(defaultThemePath, "utf-8"));
}
layeredThemeCss.push(fs.readFileSync(themePath, "utf-8"));
return { return {
baseCss: fs.readFileSync(basePath, "utf-8"), baseCss: fs.readFileSync(basePath, "utf-8"),
themeCss: fs.readFileSync(themePath, "utf-8"), themeCss: layeredThemeCss.join("\n"),
}; };
} }
@@ -450,7 +450,7 @@ function renderMarkdownWithPlaceholders(
function replaceAllPlaceholders(html: string, placeholder: string, replacement: string): string { function replaceAllPlaceholders(html: string, placeholder: string, replacement: string): string {
const escapedPlaceholder = placeholder.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const escapedPlaceholder = placeholder.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return html.replace(new RegExp(escapedPlaceholder, "g"), replacement); return html.replace(new RegExp(escapedPlaceholder + "(?!\\d)", "g"), replacement);
} }
function extractHtmlContent(htmlPath: string): string { function extractHtmlContent(htmlPath: string): string {
@@ -9,12 +9,26 @@ import { COLOR_PRESETS, FONT_FAMILY_MAP } from "./constants.ts";
import { import {
buildMarkdownDocumentMeta, buildMarkdownDocumentMeta,
formatTimestamp, formatTimestamp,
renderMarkdownDocument,
resolveColorToken, resolveColorToken,
resolveFontFamilyToken, resolveFontFamilyToken,
resolveMarkdownStyle, resolveMarkdownStyle,
resolveRenderOptions, resolveRenderOptions,
} from "./document.ts"; } from "./document.ts";
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, `\\$&`);
}
function findInlineStyle(html: string, tagName: string, text: string): string {
const pattern = new RegExp(
`<${tagName}[^>]*style="([^"]*)"[^>]*>${escapeRegExp(text)}</${tagName}>`,
);
const match = html.match(pattern);
assert.ok(match, `Expected inline style for <${tagName}>${text}</${tagName}>`);
return match![1]!;
}
function useCwd(t: TestContext, cwd: string): void { function useCwd(t: TestContext, cwd: string): void {
const previous = process.cwd(); const previous = process.cwd();
process.chdir(cwd); process.chdir(cwd);
@@ -138,3 +152,23 @@ keep_title: true
assert.equal(explicit.fontSize, "18px"); assert.equal(explicit.fontSize, "18px");
assert.equal(explicit.keepTitle, false); assert.equal(explicit.keepTitle, false);
}); });
test("renderMarkdownDocument layers default rules into grace theme before CSS inlining", async () => {
const { html } = await renderMarkdownDocument(
`## Section\n\nParagraph with **bold** text.`,
{ keepTitle: true, theme: "grace" },
);
const h2Style = findInlineStyle(html, "h2", "Section");
assert.match(h2Style, /background: #92617E/);
assert.match(h2Style, /box-shadow: 0 4px 6px rgba\(0, 0, 0, 0\.1\)/);
const pMatch = html.match(/<p[^>]*style="([^"]*)"[^>]*>/);
assert.ok(pMatch, "Expected inline style on <p> tag");
assert.match(pMatch![1]!, /color:/);
const strongPattern = /<strong[^>]*style="([^"]*)"[^>]*>bold<\/strong>/;
const strongMatch = html.match(strongPattern);
assert.ok(strongMatch, "Expected inline style for <strong>bold</strong>");
assert.match(strongMatch![1]!, /font-weight:/);
});
@@ -59,6 +59,17 @@ test("normalizeCssText and normalizeInlineCss replace variables and strip declar
assert.doesNotMatch(normalizedHtml, /var\(--md-primary-color\)/); assert.doesNotMatch(normalizedHtml, /var\(--md-primary-color\)/);
}); });
test("normalizeInlineCss removes quoted custom property values without leaving fragments behind", () => {
const normalizedHtml = normalizeInlineCss(
`<html style="--md-font-family: Menlo, Monaco, 'Courier New', monospace; color: var(--md-primary-color)"></html>`,
DEFAULT_STYLE,
);
assert.match(normalizedHtml, /style=" color: #0F4C81"/);
assert.doesNotMatch(normalizedHtml, /Courier New/);
assert.doesNotMatch(normalizedHtml, /--md-font-family/);
});
test("HTML structure helpers hoist nested lists and remove the first heading", () => { test("HTML structure helpers hoist nested lists and remove the first heading", () => {
const nestedList = `<ul><li>Parent<ul><li>Child</li></ul></li></ul>`; const nestedList = `<ul><li>Parent<ul><li>Child</li></ul></li></ul>`;
assert.equal( assert.equal(
@@ -100,13 +100,13 @@ export function normalizeCssText(cssText: string, style: StyleConfig = DEFAULT_S
.replace(/var\(--md-accent-color\)/g, style.accentColor) .replace(/var\(--md-accent-color\)/g, style.accentColor)
.replace(/var\(--md-container-bg\)/g, style.containerBg) .replace(/var\(--md-container-bg\)/g, style.containerBg)
.replace(/hsl\(var\(--foreground\)\)/g, "#3f3f3f") .replace(/hsl\(var\(--foreground\)\)/g, "#3f3f3f")
.replace(/--md-primary-color:\s*[^;"']+;?/g, "") .replace(/--md-primary-color:\s*[^;]+;?/g, "")
.replace(/--md-font-family:\s*[^;"']+;?/g, "") .replace(/--md-font-family:\s*[^;]+;?/g, "")
.replace(/--md-font-size:\s*[^;"']+;?/g, "") .replace(/--md-font-size:\s*[^;]+;?/g, "")
.replace(/--blockquote-background:\s*[^;"']+;?/g, "") .replace(/--blockquote-background:\s*[^;]+;?/g, "")
.replace(/--md-accent-color:\s*[^;"']+;?/g, "") .replace(/--md-accent-color:\s*[^;]+;?/g, "")
.replace(/--md-container-bg:\s*[^;"']+;?/g, "") .replace(/--md-container-bg:\s*[^;]+;?/g, "")
.replace(/--foreground:\s*[^;"']+;?/g, ""); .replace(/--foreground:\s*[^;]+;?/g, "");
} }
export function normalizeInlineCss(html: string, style: StyleConfig = DEFAULT_STYLE): string { export function normalizeInlineCss(html: string, style: StyleConfig = DEFAULT_STYLE): string {
@@ -6,6 +6,7 @@ import type { ThemeName } from "./types.js";
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
export const THEME_DIR = path.resolve(SCRIPT_DIR, "themes"); export const THEME_DIR = path.resolve(SCRIPT_DIR, "themes");
const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"]; const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"];
const THEMES_EXTENDING_DEFAULT = new Set<ThemeName>(["grace", "simple"]);
function stripOutputScope(cssContent: string): string { function stripOutputScope(cssContent: string): string {
let css = cssContent; let css = cssContent;
@@ -41,6 +42,7 @@ export function loadThemeCss(theme: ThemeName): {
themeCss: string; themeCss: string;
} { } {
const basePath = path.join(THEME_DIR, "base.css"); const basePath = path.join(THEME_DIR, "base.css");
const defaultThemePath = path.join(THEME_DIR, "default.css");
const themePath = path.join(THEME_DIR, `${theme}.css`); const themePath = path.join(THEME_DIR, `${theme}.css`);
if (!fs.existsSync(basePath)) { if (!fs.existsSync(basePath)) {
@@ -51,9 +53,18 @@ export function loadThemeCss(theme: ThemeName): {
throw new Error(`Missing theme CSS for "${theme}": ${themePath}`); throw new Error(`Missing theme CSS for "${theme}": ${themePath}`);
} }
const layeredThemeCss: string[] = [];
if (theme !== "default" && THEMES_EXTENDING_DEFAULT.has(theme)) {
if (!fs.existsSync(defaultThemePath)) {
throw new Error(`Missing default theme CSS: ${defaultThemePath}`);
}
layeredThemeCss.push(fs.readFileSync(defaultThemePath, "utf-8"));
}
layeredThemeCss.push(fs.readFileSync(themePath, "utf-8"));
return { return {
baseCss: fs.readFileSync(basePath, "utf-8"), baseCss: fs.readFileSync(basePath, "utf-8"),
themeCss: fs.readFileSync(themePath, "utf-8"), themeCss: layeredThemeCss.join("\n"),
}; };
} }