mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-12 22:09:48 +08:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e55f91b0ea | |||
| fe3b3d9125 | |||
| 105339cf3f |
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Skills shared by Baoyu for improving daily work efficiency",
|
||||
"version": "1.73.3"
|
||||
"version": "1.74.0"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
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
|
||||
|
||||
@@ -2,6 +2,14 @@
|
||||
|
||||
[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
|
||||
|
||||
### 修复
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CLAUDE.md
|
||||
|
||||
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.73.3**.
|
||||
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.74.0**.
|
||||
|
||||
## Architecture
|
||||
|
||||
|
||||
@@ -9,12 +9,26 @@ import { COLOR_PRESETS, FONT_FAMILY_MAP } from "./constants.ts";
|
||||
import {
|
||||
buildMarkdownDocumentMeta,
|
||||
formatTimestamp,
|
||||
renderMarkdownDocument,
|
||||
resolveColorToken,
|
||||
resolveFontFamilyToken,
|
||||
resolveMarkdownStyle,
|
||||
resolveRenderOptions,
|
||||
} 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 {
|
||||
const previous = process.cwd();
|
||||
process.chdir(cwd);
|
||||
@@ -138,3 +152,23 @@ keep_title: true
|
||||
assert.equal(explicit.fontSize, "18px");
|
||||
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\)/);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const nestedList = `<ul><li>Parent<ul><li>Child</li></ul></li></ul>`;
|
||||
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-container-bg\)/g, style.containerBg)
|
||||
.replace(/hsl\(var\(--foreground\)\)/g, "#3f3f3f")
|
||||
.replace(/--md-primary-color:\s*[^;"']+;?/g, "")
|
||||
.replace(/--md-font-family:\s*[^;"']+;?/g, "")
|
||||
.replace(/--md-font-size:\s*[^;"']+;?/g, "")
|
||||
.replace(/--blockquote-background:\s*[^;"']+;?/g, "")
|
||||
.replace(/--md-accent-color:\s*[^;"']+;?/g, "")
|
||||
.replace(/--md-container-bg:\s*[^;"']+;?/g, "")
|
||||
.replace(/--foreground:\s*[^;"']+;?/g, "");
|
||||
.replace(/--md-primary-color:\s*[^;]+;?/g, "")
|
||||
.replace(/--md-font-family:\s*[^;]+;?/g, "")
|
||||
.replace(/--md-font-size:\s*[^;]+;?/g, "")
|
||||
.replace(/--blockquote-background:\s*[^;]+;?/g, "")
|
||||
.replace(/--md-accent-color:\s*[^;]+;?/g, "")
|
||||
.replace(/--md-container-bg:\s*[^;]+;?/g, "")
|
||||
.replace(/--foreground:\s*[^;]+;?/g, "");
|
||||
}
|
||||
|
||||
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));
|
||||
export const THEME_DIR = path.resolve(SCRIPT_DIR, "themes");
|
||||
const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"];
|
||||
const THEMES_EXTENDING_DEFAULT = new Set<ThemeName>(["grace", "simple"]);
|
||||
|
||||
function stripOutputScope(cssContent: string): string {
|
||||
let css = cssContent;
|
||||
@@ -41,6 +42,7 @@ export function loadThemeCss(theme: ThemeName): {
|
||||
themeCss: string;
|
||||
} {
|
||||
const basePath = path.join(THEME_DIR, "base.css");
|
||||
const defaultThemePath = path.join(THEME_DIR, "default.css");
|
||||
const themePath = path.join(THEME_DIR, `${theme}.css`);
|
||||
|
||||
if (!fs.existsSync(basePath)) {
|
||||
@@ -51,9 +53,18 @@ export function loadThemeCss(theme: ThemeName): {
|
||||
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 {
|
||||
baseCss: fs.readFileSync(basePath, "utf-8"),
|
||||
themeCss: fs.readFileSync(themePath, "utf-8"),
|
||||
themeCss: layeredThemeCss.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: baoyu-markdown-to-html
|
||||
description: Converts Markdown to styled HTML with WeChat-compatible themes. Supports code highlighting, math, PlantUML, footnotes, alerts, infographics, and optional bottom citations for external links. Use when user asks for "markdown to html", "convert md to html", "md转html", "微信外链转底部引用", or needs styled HTML output from markdown.
|
||||
description: Converts Markdown to styled HTML with WeChat-compatible themes. Supports code highlighting, math, PlantUML, footnotes, alerts, infographics, and optional bottom citations for external links. Use when user asks for "markdown to html", "convert md to html", "md 转 html", "微信外链转底部引用", or needs styled HTML output from markdown.
|
||||
version: 1.56.1
|
||||
metadata:
|
||||
openclaw:
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -4,16 +4,22 @@ import path from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
import {
|
||||
COLOR_PRESETS,
|
||||
FONT_FAMILY_MAP,
|
||||
FONT_SIZE_OPTIONS,
|
||||
THEME_NAMES,
|
||||
extractSummaryFromBody,
|
||||
extractTitleFromMarkdown,
|
||||
formatTimestamp,
|
||||
parseArgs,
|
||||
parseFrontmatter,
|
||||
renderMarkdownDocument,
|
||||
replaceMarkdownImagesWithPlaceholders,
|
||||
resolveContentImages,
|
||||
serializeFrontmatter,
|
||||
stripWrappingQuotes,
|
||||
} from "baoyu-md";
|
||||
} from "./vendor/baoyu-md/src/index.ts";
|
||||
import type { CliOptions } from "./vendor/baoyu-md/src/types.ts";
|
||||
|
||||
interface ImageInfo {
|
||||
placeholder: string;
|
||||
@@ -30,9 +36,13 @@ interface ParsedResult {
|
||||
contentImages: ImageInfo[];
|
||||
}
|
||||
|
||||
type ConvertMarkdownOptions = Partial<Omit<CliOptions, "inputPath">> & {
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export async function convertMarkdown(
|
||||
markdownPath: string,
|
||||
options?: { title?: string; theme?: string; keepTitle?: boolean; citeStatus?: boolean },
|
||||
options?: ConvertMarkdownOptions,
|
||||
): Promise<ParsedResult> {
|
||||
const baseDir = path.dirname(markdownPath);
|
||||
const content = fs.readFileSync(markdownPath, "utf-8");
|
||||
@@ -56,20 +66,32 @@ export async function convertMarkdown(
|
||||
summary = extractSummaryFromBody(body, 120);
|
||||
}
|
||||
|
||||
const effectiveFrontmatter = options?.title
|
||||
? { ...frontmatter, title }
|
||||
: frontmatter;
|
||||
|
||||
const { images, markdown: rewrittenBody } = replaceMarkdownImagesWithPlaceholders(
|
||||
body,
|
||||
"MDTOHTMLIMGPH_",
|
||||
);
|
||||
const rewrittenMarkdown = `${serializeFrontmatter(frontmatter)}${rewrittenBody}`;
|
||||
const rewrittenMarkdown = `${serializeFrontmatter(effectiveFrontmatter)}${rewrittenBody}`;
|
||||
|
||||
console.error(
|
||||
`[markdown-to-html] Rendering with theme: ${theme ?? "default"}, keepTitle: ${keepTitle}, citeStatus: ${citeStatus}`,
|
||||
);
|
||||
|
||||
const { html } = await renderMarkdownDocument(rewrittenMarkdown, {
|
||||
codeTheme: options?.codeTheme,
|
||||
countStatus: options?.countStatus,
|
||||
citeStatus,
|
||||
defaultTitle: title,
|
||||
fontFamily: options?.fontFamily,
|
||||
fontSize: options?.fontSize,
|
||||
isMacCodeBlock: options?.isMacCodeBlock,
|
||||
isShowLineNumber: options?.isShowLineNumber,
|
||||
keepTitle,
|
||||
legend: options?.legend,
|
||||
primaryColor: options?.primaryColor,
|
||||
theme,
|
||||
});
|
||||
|
||||
@@ -111,18 +133,30 @@ 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
|
||||
|
||||
Usage:
|
||||
npx -y bun main.ts <markdown_file> [options]
|
||||
|
||||
Options:
|
||||
--title <title> Override title
|
||||
--theme <name> Theme name (default, grace, simple). Default: default
|
||||
--cite Convert ordinary external links to bottom citations. Default: off
|
||||
--keep-title Keep the first heading in content. Default: false (removed)
|
||||
--help Show this help
|
||||
--title <title> Override title
|
||||
--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
|
||||
--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)
|
||||
--help Show this help
|
||||
|
||||
Output:
|
||||
HTML file saved to same directory as input markdown file.
|
||||
@@ -142,40 +176,60 @@ Output JSON format:
|
||||
Example:
|
||||
npx -y bun main.ts article.md
|
||||
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
|
||||
`);
|
||||
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> {
|
||||
const args = process.argv.slice(2);
|
||||
if (args.length === 0 || args.includes("--help") || args.includes("-h")) {
|
||||
printUsage();
|
||||
printUsage(0);
|
||||
}
|
||||
|
||||
let markdownPath: string | undefined;
|
||||
let title: string | undefined;
|
||||
let theme: string | undefined;
|
||||
let citeStatus = false;
|
||||
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;
|
||||
}
|
||||
const { renderArgs, title } = extractTitleArg(args);
|
||||
const options = parseArgs(renderArgs);
|
||||
if (!options) {
|
||||
printUsage(1);
|
||||
}
|
||||
|
||||
if (!markdownPath) {
|
||||
console.error("Error: Markdown file path is required");
|
||||
const markdownPath = path.resolve(process.cwd(), options.inputPath);
|
||||
if (!markdownPath.toLowerCase().endsWith(".md")) {
|
||||
console.error("Input file must end with .md");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -184,7 +238,7 @@ async function main(): Promise<void> {
|
||||
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));
|
||||
}
|
||||
|
||||
|
||||
@@ -9,12 +9,26 @@ import { COLOR_PRESETS, FONT_FAMILY_MAP } from "./constants.ts";
|
||||
import {
|
||||
buildMarkdownDocumentMeta,
|
||||
formatTimestamp,
|
||||
renderMarkdownDocument,
|
||||
resolveColorToken,
|
||||
resolveFontFamilyToken,
|
||||
resolveMarkdownStyle,
|
||||
resolveRenderOptions,
|
||||
} 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 {
|
||||
const previous = process.cwd();
|
||||
process.chdir(cwd);
|
||||
@@ -138,3 +152,23 @@ keep_title: true
|
||||
assert.equal(explicit.fontSize, "18px");
|
||||
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:/);
|
||||
});
|
||||
|
||||
+11
@@ -59,6 +59,17 @@ test("normalizeCssText and normalizeInlineCss replace variables and strip declar
|
||||
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", () => {
|
||||
const nestedList = `<ul><li>Parent<ul><li>Child</li></ul></li></ul>`;
|
||||
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-container-bg\)/g, style.containerBg)
|
||||
.replace(/hsl\(var\(--foreground\)\)/g, "#3f3f3f")
|
||||
.replace(/--md-primary-color:\s*[^;"']+;?/g, "")
|
||||
.replace(/--md-font-family:\s*[^;"']+;?/g, "")
|
||||
.replace(/--md-font-size:\s*[^;"']+;?/g, "")
|
||||
.replace(/--blockquote-background:\s*[^;"']+;?/g, "")
|
||||
.replace(/--md-accent-color:\s*[^;"']+;?/g, "")
|
||||
.replace(/--md-container-bg:\s*[^;"']+;?/g, "")
|
||||
.replace(/--foreground:\s*[^;"']+;?/g, "");
|
||||
.replace(/--md-primary-color:\s*[^;]+;?/g, "")
|
||||
.replace(/--md-font-family:\s*[^;]+;?/g, "")
|
||||
.replace(/--md-font-size:\s*[^;]+;?/g, "")
|
||||
.replace(/--blockquote-background:\s*[^;]+;?/g, "")
|
||||
.replace(/--md-accent-color:\s*[^;]+;?/g, "")
|
||||
.replace(/--md-container-bg:\s*[^;]+;?/g, "")
|
||||
.replace(/--foreground:\s*[^;]+;?/g, "");
|
||||
}
|
||||
|
||||
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));
|
||||
export const THEME_DIR = path.resolve(SCRIPT_DIR, "themes");
|
||||
const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"];
|
||||
const THEMES_EXTENDING_DEFAULT = new Set<ThemeName>(["grace", "simple"]);
|
||||
|
||||
function stripOutputScope(cssContent: string): string {
|
||||
let css = cssContent;
|
||||
@@ -41,6 +42,7 @@ export function loadThemeCss(theme: ThemeName): {
|
||||
themeCss: string;
|
||||
} {
|
||||
const basePath = path.join(THEME_DIR, "base.css");
|
||||
const defaultThemePath = path.join(THEME_DIR, "default.css");
|
||||
const themePath = path.join(THEME_DIR, `${theme}.css`);
|
||||
|
||||
if (!fs.existsSync(basePath)) {
|
||||
@@ -51,9 +53,18 @@ export function loadThemeCss(theme: ThemeName): {
|
||||
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 {
|
||||
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 {
|
||||
buildMarkdownDocumentMeta,
|
||||
formatTimestamp,
|
||||
renderMarkdownDocument,
|
||||
resolveColorToken,
|
||||
resolveFontFamilyToken,
|
||||
resolveMarkdownStyle,
|
||||
resolveRenderOptions,
|
||||
} 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 {
|
||||
const previous = process.cwd();
|
||||
process.chdir(cwd);
|
||||
@@ -138,3 +152,23 @@ keep_title: true
|
||||
assert.equal(explicit.fontSize, "18px");
|
||||
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\)/);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const nestedList = `<ul><li>Parent<ul><li>Child</li></ul></li></ul>`;
|
||||
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-container-bg\)/g, style.containerBg)
|
||||
.replace(/hsl\(var\(--foreground\)\)/g, "#3f3f3f")
|
||||
.replace(/--md-primary-color:\s*[^;"']+;?/g, "")
|
||||
.replace(/--md-font-family:\s*[^;"']+;?/g, "")
|
||||
.replace(/--md-font-size:\s*[^;"']+;?/g, "")
|
||||
.replace(/--blockquote-background:\s*[^;"']+;?/g, "")
|
||||
.replace(/--md-accent-color:\s*[^;"']+;?/g, "")
|
||||
.replace(/--md-container-bg:\s*[^;"']+;?/g, "")
|
||||
.replace(/--foreground:\s*[^;"']+;?/g, "");
|
||||
.replace(/--md-primary-color:\s*[^;]+;?/g, "")
|
||||
.replace(/--md-font-family:\s*[^;]+;?/g, "")
|
||||
.replace(/--md-font-size:\s*[^;]+;?/g, "")
|
||||
.replace(/--blockquote-background:\s*[^;]+;?/g, "")
|
||||
.replace(/--md-accent-color:\s*[^;]+;?/g, "")
|
||||
.replace(/--md-container-bg:\s*[^;]+;?/g, "")
|
||||
.replace(/--foreground:\s*[^;]+;?/g, "");
|
||||
}
|
||||
|
||||
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));
|
||||
export const THEME_DIR = path.resolve(SCRIPT_DIR, "themes");
|
||||
const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"];
|
||||
const THEMES_EXTENDING_DEFAULT = new Set<ThemeName>(["grace", "simple"]);
|
||||
|
||||
function stripOutputScope(cssContent: string): string {
|
||||
let css = cssContent;
|
||||
@@ -41,6 +42,7 @@ export function loadThemeCss(theme: ThemeName): {
|
||||
themeCss: string;
|
||||
} {
|
||||
const basePath = path.join(THEME_DIR, "base.css");
|
||||
const defaultThemePath = path.join(THEME_DIR, "default.css");
|
||||
const themePath = path.join(THEME_DIR, `${theme}.css`);
|
||||
|
||||
if (!fs.existsSync(basePath)) {
|
||||
@@ -51,9 +53,18 @@ export function loadThemeCss(theme: ThemeName): {
|
||||
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 {
|
||||
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 {
|
||||
buildMarkdownDocumentMeta,
|
||||
formatTimestamp,
|
||||
renderMarkdownDocument,
|
||||
resolveColorToken,
|
||||
resolveFontFamilyToken,
|
||||
resolveMarkdownStyle,
|
||||
resolveRenderOptions,
|
||||
} 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 {
|
||||
const previous = process.cwd();
|
||||
process.chdir(cwd);
|
||||
@@ -138,3 +152,23 @@ keep_title: true
|
||||
assert.equal(explicit.fontSize, "18px");
|
||||
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\)/);
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const nestedList = `<ul><li>Parent<ul><li>Child</li></ul></li></ul>`;
|
||||
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-container-bg\)/g, style.containerBg)
|
||||
.replace(/hsl\(var\(--foreground\)\)/g, "#3f3f3f")
|
||||
.replace(/--md-primary-color:\s*[^;"']+;?/g, "")
|
||||
.replace(/--md-font-family:\s*[^;"']+;?/g, "")
|
||||
.replace(/--md-font-size:\s*[^;"']+;?/g, "")
|
||||
.replace(/--blockquote-background:\s*[^;"']+;?/g, "")
|
||||
.replace(/--md-accent-color:\s*[^;"']+;?/g, "")
|
||||
.replace(/--md-container-bg:\s*[^;"']+;?/g, "")
|
||||
.replace(/--foreground:\s*[^;"']+;?/g, "");
|
||||
.replace(/--md-primary-color:\s*[^;]+;?/g, "")
|
||||
.replace(/--md-font-family:\s*[^;]+;?/g, "")
|
||||
.replace(/--md-font-size:\s*[^;]+;?/g, "")
|
||||
.replace(/--blockquote-background:\s*[^;]+;?/g, "")
|
||||
.replace(/--md-accent-color:\s*[^;]+;?/g, "")
|
||||
.replace(/--md-container-bg:\s*[^;]+;?/g, "")
|
||||
.replace(/--foreground:\s*[^;]+;?/g, "");
|
||||
}
|
||||
|
||||
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));
|
||||
export const THEME_DIR = path.resolve(SCRIPT_DIR, "themes");
|
||||
const FALLBACK_THEMES: ThemeName[] = ["default", "grace", "simple"];
|
||||
const THEMES_EXTENDING_DEFAULT = new Set<ThemeName>(["grace", "simple"]);
|
||||
|
||||
function stripOutputScope(cssContent: string): string {
|
||||
let css = cssContent;
|
||||
@@ -41,6 +42,7 @@ export function loadThemeCss(theme: ThemeName): {
|
||||
themeCss: string;
|
||||
} {
|
||||
const basePath = path.join(THEME_DIR, "base.css");
|
||||
const defaultThemePath = path.join(THEME_DIR, "default.css");
|
||||
const themePath = path.join(THEME_DIR, `${theme}.css`);
|
||||
|
||||
if (!fs.existsSync(basePath)) {
|
||||
@@ -51,9 +53,18 @@ export function loadThemeCss(theme: ThemeName): {
|
||||
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 {
|
||||
baseCss: fs.readFileSync(basePath, "utf-8"),
|
||||
themeCss: fs.readFileSync(themePath, "utf-8"),
|
||||
themeCss: layeredThemeCss.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user