mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-25 03:19:48 +08:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 603cabaef4 | |||
| 7d12526e90 | |||
| e7f9764a49 | |||
| e55f91b0ea | |||
| fe3b3d9125 | |||
| 105339cf3f | |||
| dcfd9033ae | |||
| eb416d174c | |||
| e43eec260a | |||
| 96ef6e2251 | |||
| efb7a1917a | |||
| 1af984a64f |
@@ -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.1"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,25 @@
|
|||||||
|
|
||||||
English | [中文](./CHANGELOG.zh.md)
|
English | [中文](./CHANGELOG.zh.md)
|
||||||
|
|
||||||
|
## 1.74.1 - 2026-03-21
|
||||||
|
|
||||||
|
### Fixes
|
||||||
|
- `baoyu-image-gen`: align OpenRouter image generation with current API, harden image support, and narrow Gemini aspect ratios (by @cwandev)
|
||||||
|
- `baoyu-image-gen`: broaden OpenRouter model detection and aspect ratio validation
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|||||||
@@ -2,6 +2,25 @@
|
|||||||
|
|
||||||
[English](./CHANGELOG.md) | 中文
|
[English](./CHANGELOG.md) | 中文
|
||||||
|
|
||||||
|
## 1.74.1 - 2026-03-21
|
||||||
|
|
||||||
|
### 修复
|
||||||
|
- `baoyu-image-gen`:对齐 OpenRouter 图像生成与当前 API,增强图像支持,收窄 Gemini 宽高比范围 (by @cwandev)
|
||||||
|
- `baoyu-image-gen`:扩展 OpenRouter 模型检测和宽高比验证
|
||||||
|
|
||||||
|
## 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,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
|
||||||
|
|
||||||
|
|||||||
@@ -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"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import type { CliArgs } from "../types.ts";
|
||||||
|
import {
|
||||||
|
buildContent,
|
||||||
|
buildRequestBody,
|
||||||
|
extractImageFromResponse,
|
||||||
|
getAspectRatio,
|
||||||
|
getImageSize,
|
||||||
|
validateArgs,
|
||||||
|
} from "./openrouter.ts";
|
||||||
|
|
||||||
|
const GEMINI_MODEL = "google/gemini-3.1-flash-image-preview";
|
||||||
|
const GEMINI_25_MODEL = "google/gemini-2.5-flash-image";
|
||||||
|
const GPT_5_IMAGE_MODEL = "openai/gpt-5-image";
|
||||||
|
const OPENROUTER_AUTO_MODEL = "openrouter/auto";
|
||||||
|
const FLUX_MODEL = "black-forest-labs/flux.2-pro";
|
||||||
|
|
||||||
|
function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||||
|
return {
|
||||||
|
prompt: null,
|
||||||
|
promptFiles: [],
|
||||||
|
imagePath: null,
|
||||||
|
provider: null,
|
||||||
|
model: null,
|
||||||
|
aspectRatio: null,
|
||||||
|
size: null,
|
||||||
|
quality: null,
|
||||||
|
imageSize: null,
|
||||||
|
referenceImages: [],
|
||||||
|
n: 1,
|
||||||
|
batchFile: null,
|
||||||
|
jobs: null,
|
||||||
|
json: false,
|
||||||
|
help: false,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test("OpenRouter request body uses image_config and string content for text-only prompts", () => {
|
||||||
|
const args = makeArgs({ aspectRatio: "16:9", quality: "2k" });
|
||||||
|
const body = buildRequestBody("hello", GEMINI_MODEL, args, []);
|
||||||
|
|
||||||
|
assert.deepEqual(body.image_config, {
|
||||||
|
image_size: "2K",
|
||||||
|
aspect_ratio: "16:9",
|
||||||
|
});
|
||||||
|
assert.deepEqual(body.provider, {
|
||||||
|
require_parameters: true,
|
||||||
|
});
|
||||||
|
assert.deepEqual(body.modalities, ["image", "text"]);
|
||||||
|
assert.equal(body.stream, false);
|
||||||
|
assert.equal(body.messages[0].content, "hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("OpenRouter request body keeps text+image modalities for current text+image models", () => {
|
||||||
|
for (const model of [GEMINI_MODEL, GEMINI_25_MODEL, GPT_5_IMAGE_MODEL, OPENROUTER_AUTO_MODEL]) {
|
||||||
|
const body = buildRequestBody("hello", model, makeArgs({ quality: "2k" }), []);
|
||||||
|
|
||||||
|
assert.deepEqual(body.image_config, {
|
||||||
|
image_size: "2K",
|
||||||
|
});
|
||||||
|
assert.deepEqual(body.provider, {
|
||||||
|
require_parameters: true,
|
||||||
|
});
|
||||||
|
assert.deepEqual(body.modalities, ["image", "text"]);
|
||||||
|
assert.equal(body.messages[0].content, "hello");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("OpenRouter request body uses image-only modalities for image-only models under CLI defaults", () => {
|
||||||
|
const body = buildRequestBody("hello", FLUX_MODEL, makeArgs({ quality: "2k" }), []);
|
||||||
|
|
||||||
|
assert.deepEqual(body.image_config, {
|
||||||
|
image_size: "2K",
|
||||||
|
});
|
||||||
|
assert.deepEqual(body.provider, {
|
||||||
|
require_parameters: true,
|
||||||
|
});
|
||||||
|
assert.deepEqual(body.modalities, ["image"]);
|
||||||
|
assert.equal(body.stream, false);
|
||||||
|
assert.equal(body.messages[0].content, "hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("OpenRouter helper omits image_config when no size or quality is passed", () => {
|
||||||
|
const body = buildRequestBody("hello", FLUX_MODEL, makeArgs(), []);
|
||||||
|
|
||||||
|
assert.equal(body.image_config, undefined);
|
||||||
|
assert.equal(body.provider, undefined);
|
||||||
|
assert.deepEqual(body.modalities, ["image"]);
|
||||||
|
assert.equal(body.stream, false);
|
||||||
|
assert.equal(body.messages[0].content, "hello");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("OpenRouter request body keeps multimodal array content when references are provided", () => {
|
||||||
|
const content = buildContent("hello", ["data:image/png;base64,abc"]);
|
||||||
|
assert.ok(Array.isArray(content));
|
||||||
|
assert.deepEqual(content[0], { type: "text", text: "hello" });
|
||||||
|
assert.deepEqual(content[1], {
|
||||||
|
type: "image_url",
|
||||||
|
image_url: { url: "data:image/png;base64,abc" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("OpenRouter size and aspect helpers infer supported values", () => {
|
||||||
|
assert.equal(getImageSize(makeArgs()), null);
|
||||||
|
assert.equal(getImageSize(makeArgs({ quality: "normal" })), "1K");
|
||||||
|
assert.equal(getImageSize(makeArgs({ size: "2048x1024" })), "2K");
|
||||||
|
assert.equal(getAspectRatio(GEMINI_MODEL, makeArgs({ size: "1600x900" })), "16:9");
|
||||||
|
assert.equal(getAspectRatio(GEMINI_MODEL, makeArgs({ size: "1024x4096" })), "1:4");
|
||||||
|
assert.equal(getAspectRatio(GEMINI_25_MODEL, makeArgs({ size: "1600x900" })), "16:9");
|
||||||
|
assert.equal(getAspectRatio(FLUX_MODEL, makeArgs({ size: "1024x4096" })), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("OpenRouter validates explicit aspect ratios and inferred size ratios against model support", () => {
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
validateArgs(GEMINI_MODEL, makeArgs({ aspectRatio: "1:4" })),
|
||||||
|
);
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
validateArgs(GEMINI_MODEL, makeArgs({ size: "1024x4096" })),
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => validateArgs(GEMINI_25_MODEL, makeArgs({ aspectRatio: "1:4" })),
|
||||||
|
/does not support aspect ratio 1:4/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => validateArgs(FLUX_MODEL, makeArgs({ aspectRatio: "1:4" })),
|
||||||
|
/does not support aspect ratio 1:4/,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => validateArgs(GEMINI_MODEL, makeArgs({ size: "2048x1024" })),
|
||||||
|
/does not support size 2048x1024 \(aspect ratio 2:1\)/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("OpenRouter response extraction supports inline image data and finish_reason errors", async () => {
|
||||||
|
const bytes = await extractImageFromResponse({
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
message: {
|
||||||
|
images: [
|
||||||
|
{
|
||||||
|
image_url: {
|
||||||
|
url: `data:image/png;base64,${Buffer.from("hello").toString("base64")}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.equal(Buffer.from(bytes).toString("utf8"), "hello");
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
extractImageFromResponse({
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
finish_reason: "error",
|
||||||
|
native_finish_reason: "MALFORMED_FUNCTION_CALL",
|
||||||
|
message: { content: null },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
/finish_reason=MALFORMED_FUNCTION_CALL/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -3,6 +3,19 @@ import { readFile } from "node:fs/promises";
|
|||||||
import type { CliArgs } from "../types";
|
import type { CliArgs } from "../types";
|
||||||
|
|
||||||
const DEFAULT_MODEL = "google/gemini-3.1-flash-image-preview";
|
const DEFAULT_MODEL = "google/gemini-3.1-flash-image-preview";
|
||||||
|
const COMMON_ASPECT_RATIOS = [
|
||||||
|
"1:1",
|
||||||
|
"2:3",
|
||||||
|
"3:2",
|
||||||
|
"3:4",
|
||||||
|
"4:3",
|
||||||
|
"4:5",
|
||||||
|
"5:4",
|
||||||
|
"9:16",
|
||||||
|
"16:9",
|
||||||
|
"21:9",
|
||||||
|
];
|
||||||
|
const GEMINI_EXTENDED_ASPECT_RATIOS = ["1:4", "4:1", "1:8", "8:1"];
|
||||||
|
|
||||||
type OpenRouterImageEntry = {
|
type OpenRouterImageEntry = {
|
||||||
image_url?: string | { url?: string | null } | null;
|
image_url?: string | { url?: string | null } | null;
|
||||||
@@ -18,9 +31,11 @@ type OpenRouterMessagePart = {
|
|||||||
|
|
||||||
type OpenRouterResponse = {
|
type OpenRouterResponse = {
|
||||||
choices?: Array<{
|
choices?: Array<{
|
||||||
|
finish_reason?: string | null;
|
||||||
|
native_finish_reason?: string | null;
|
||||||
message?: {
|
message?: {
|
||||||
images?: OpenRouterImageEntry[];
|
images?: OpenRouterImageEntry[];
|
||||||
content?: string | OpenRouterMessagePart[];
|
content?: string | OpenRouterMessagePart[] | null;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
@@ -29,6 +44,36 @@ export function getDefaultModel(): string {
|
|||||||
return process.env.OPENROUTER_IMAGE_MODEL || DEFAULT_MODEL;
|
return process.env.OPENROUTER_IMAGE_MODEL || DEFAULT_MODEL;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeModelId(model: string): string {
|
||||||
|
return model.trim().toLowerCase().split(":")[0]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTextAndImageModel(model: string): boolean {
|
||||||
|
const normalized = normalizeModelId(model);
|
||||||
|
if (normalized === "openrouter/auto") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.startsWith("google/gemini-") && normalized.includes("image")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.startsWith("openai/gpt-") && normalized.includes("image")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSupportedAspectRatios(model: string): Set<string> {
|
||||||
|
const normalized = normalizeModelId(model);
|
||||||
|
if (normalized !== "google/gemini-3.1-flash-image-preview") {
|
||||||
|
return new Set(COMMON_ASPECT_RATIOS);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Set([...COMMON_ASPECT_RATIOS, ...GEMINI_EXTENDED_ASPECT_RATIOS]);
|
||||||
|
}
|
||||||
|
|
||||||
function getApiKey(): string | null {
|
function getApiKey(): string | null {
|
||||||
return process.env.OPENROUTER_API_KEY || null;
|
return process.env.OPENROUTER_API_KEY || null;
|
||||||
}
|
}
|
||||||
@@ -103,17 +148,50 @@ function inferImageSize(size: string | null): "1K" | "2K" | "4K" | null {
|
|||||||
return "4K";
|
return "4K";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getImageSize(args: CliArgs): "1K" | "2K" | "4K" {
|
export function getImageSize(args: CliArgs): "1K" | "2K" | "4K" | null {
|
||||||
if (args.imageSize) return args.imageSize as "1K" | "2K" | "4K";
|
if (args.imageSize) return args.imageSize as "1K" | "2K" | "4K";
|
||||||
|
|
||||||
const inferredFromSize = inferImageSize(args.size);
|
const inferredFromSize = inferImageSize(args.size);
|
||||||
if (inferredFromSize) return inferredFromSize;
|
if (inferredFromSize) return inferredFromSize;
|
||||||
|
|
||||||
return args.quality === "normal" ? "1K" : "2K";
|
if (args.quality === "normal") return "1K";
|
||||||
|
if (args.quality === "2k") return "2K";
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAspectRatio(args: CliArgs): string | null {
|
export function getAspectRatio(model: string, args: CliArgs): string | null {
|
||||||
return args.aspectRatio || inferAspectRatio(args.size);
|
if (args.aspectRatio) return args.aspectRatio;
|
||||||
|
|
||||||
|
const inferred = inferAspectRatio(args.size);
|
||||||
|
if (!inferred || !getSupportedAspectRatios(model).has(inferred)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return inferred;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModalities(model: string): string[] {
|
||||||
|
return isTextAndImageModel(model) ? ["image", "text"] : ["image"];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateArgs(model: string, args: CliArgs): void {
|
||||||
|
const requestedAspectRatio = args.aspectRatio || inferAspectRatio(args.size);
|
||||||
|
if (!requestedAspectRatio) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const supported = getSupportedAspectRatios(model);
|
||||||
|
if (supported.has(requestedAspectRatio)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedValue = args.aspectRatio
|
||||||
|
? `aspect ratio ${requestedAspectRatio}`
|
||||||
|
: `size ${args.size} (aspect ratio ${requestedAspectRatio})`;
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`OpenRouter model ${model} does not support ${requestedValue}. Supported values: ${Array.from(supported).join(", ")}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMimeType(filename: string): string {
|
function getMimeType(filename: string): string {
|
||||||
@@ -129,7 +207,14 @@ async function readImageAsDataUrl(filePath: string): Promise<string> {
|
|||||||
return `data:${getMimeType(filePath)};base64,${bytes.toString("base64")}`;
|
return `data:${getMimeType(filePath)};base64,${bytes.toString("base64")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildContent(prompt: string, referenceImages: string[]): Array<Record<string, unknown>> {
|
export function buildContent(
|
||||||
|
prompt: string,
|
||||||
|
referenceImages: string[],
|
||||||
|
): string | Array<Record<string, unknown>> {
|
||||||
|
if (referenceImages.length === 0) {
|
||||||
|
return prompt;
|
||||||
|
}
|
||||||
|
|
||||||
const content: Array<Record<string, unknown>> = [{ type: "text", text: prompt }];
|
const content: Array<Record<string, unknown>> = [{ type: "text", text: prompt }];
|
||||||
|
|
||||||
for (const imageUrl of referenceImages) {
|
for (const imageUrl of referenceImages) {
|
||||||
@@ -171,8 +256,9 @@ async function downloadImage(value: string): Promise<Uint8Array> {
|
|||||||
return Uint8Array.from(Buffer.from(value, "base64"));
|
return Uint8Array.from(Buffer.from(value, "base64"));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function extractImageFromResponse(result: OpenRouterResponse): Promise<Uint8Array> {
|
export async function extractImageFromResponse(result: OpenRouterResponse): Promise<Uint8Array> {
|
||||||
const message = result.choices?.[0]?.message;
|
const choice = result.choices?.[0];
|
||||||
|
const message = choice?.message;
|
||||||
|
|
||||||
for (const image of message?.images ?? []) {
|
for (const image of message?.images ?? []) {
|
||||||
const imageUrl = extractImageUrl(image);
|
const imageUrl = extractImageUrl(image);
|
||||||
@@ -194,7 +280,52 @@ async function extractImageFromResponse(result: OpenRouterResponse): Promise<Uin
|
|||||||
if (inline) return inline;
|
if (inline) return inline;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error("No image in OpenRouter response");
|
const finishReason =
|
||||||
|
choice?.native_finish_reason || choice?.finish_reason || "unknown";
|
||||||
|
throw new Error(
|
||||||
|
`No image in OpenRouter response (finish_reason=${finishReason})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRequestBody(
|
||||||
|
prompt: string,
|
||||||
|
model: string,
|
||||||
|
args: CliArgs,
|
||||||
|
referenceImages: string[],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
validateArgs(model, args);
|
||||||
|
|
||||||
|
const imageConfig: Record<string, string> = {};
|
||||||
|
|
||||||
|
const imageSize = getImageSize(args);
|
||||||
|
if (imageSize) {
|
||||||
|
imageConfig.image_size = imageSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
const aspectRatio = getAspectRatio(model, args);
|
||||||
|
if (aspectRatio) {
|
||||||
|
imageConfig.aspect_ratio = aspectRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: buildContent(prompt, referenceImages),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
modalities: getModalities(model),
|
||||||
|
stream: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Object.keys(imageConfig).length > 0) {
|
||||||
|
body.image_config = imageConfig;
|
||||||
|
body.provider = {
|
||||||
|
require_parameters: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateImage(
|
export async function generateImage(
|
||||||
@@ -212,32 +343,15 @@ export async function generateImage(
|
|||||||
referenceImages.push(await readImageAsDataUrl(refPath));
|
referenceImages.push(await readImageAsDataUrl(refPath));
|
||||||
}
|
}
|
||||||
|
|
||||||
const imageGenerationOptions: Record<string, string> = {
|
|
||||||
size: getImageSize(args),
|
|
||||||
};
|
|
||||||
|
|
||||||
const aspectRatio = getAspectRatio(args);
|
|
||||||
if (aspectRatio) {
|
|
||||||
imageGenerationOptions.aspect_ratio = aspectRatio;
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = {
|
const body = {
|
||||||
model,
|
model,
|
||||||
messages: [
|
...buildRequestBody(prompt, model, args, referenceImages),
|
||||||
{
|
|
||||||
role: "user",
|
|
||||||
content: buildContent(prompt, referenceImages),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
modalities: ["image", "text"],
|
|
||||||
max_tokens: 256,
|
|
||||||
imageGenerationOptions,
|
|
||||||
providerPreferences: {
|
|
||||||
require_parameters: true,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(`Generating image with OpenRouter (${model})...`, imageGenerationOptions);
|
console.log(
|
||||||
|
`Generating image with OpenRouter (${model})...`,
|
||||||
|
(body.image_config as Record<string, string>),
|
||||||
|
);
|
||||||
|
|
||||||
const response = await fetch(`${getBaseUrl()}/chat/completions`, {
|
const response = await fetch(`${getBaseUrl()}/chat/completions`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -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 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:/);
|
||||||
|
});
|
||||||
|
|||||||
+11
@@ -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"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user