Skill: mutour/kip2-markdown-to-html (version 0.5.0)

--- name: markdown-to-html description: Convert a Markdown file or raw Markdown string into a polished HTML document. Supports custom Pandoc HTML templates, custom CSS, and includes built-in HTML template themes. Use this skill when the user asks to export Markdown to HTML, generate a beautiful web page from Markdown, or render Markdown into a branded standalone HTML file. --- # Markdown To HTML Use this skill when the user wants a Markdown file or Markdown string converted into a polished HTML page. ## What this skill provides - Deterministic conversion through `pandoc` - Input from either a Markdown file or a raw Markdown string - Custom HTML template support through `--template /path/to/template.html` - Custom CSS support through `--css /path/to/file.css` - Built-in HTML themes in `assets/templates/` and `assets/styles/` - Optional table of contents, numbered headings, metadata, and embedded assets ## Quick workflow 1. Choose the input source. File input: pass `--input-file /abs/path/file.md` Raw string input: pass `--markdown '...'` 2. Choose a layout. Use `--template` for a fully custom Pandoc HTML template. Otherwise use `--builtin-template`. 3. Optionally add one or more CSS files with `--css`. 4. Set output with `--output /abs/path/output.html`. 5. Optional layout flags: Use `--toc` to render a table of contents panel. Use `--no-body-background` to remove the article card background, border, and shadow. ## Built-in templates - `docs-slate`: technical docs layout with restrained slate tones - `magazine-amber`: editorial landing-page feel with warm contrast - `product-midnight`: dark product narrative with stronger gradients - `serif-paper`: print-inspired article layout List them at runtime with: ```bash python3 scripts/markdown_to_html.py --list-templates ``` ## Main command ```bash python3 scripts/markdown_to_html.py \ --input-file /abs/path/source.md \ --output /abs/path/output.html \ --builtin-template docs-slate \ --toc \ --number-sections \ --embed-assets ``` ## Raw string example ```bash python3 scripts/markdown_to_html.py \ --markdown '# Release Notes\n\n- Added HTML export\n- Added built-in themes' \ --output /abs/path/release.html \ --builtin-template magazine-amber \ --title 'Release Notes' ``` ## Template rules - `--template` overrides `--builtin-template` - Built-in templates automatically include their matching built-in CSS - Extra `--css` files are appended after the built-in CSS, so local overrides win - `--number-sections` enables Pandoc heading numbering in the generated HTML - When `--number-sections` is enabled, avoid manually writing heading prefixes like `1.`, `2.1`, or `3.2` inside the Markdown heading text, or the output will show duplicated numbering such as `1.2.1 2.1 Title` - When `--toc` is omitted, the built-in templates render only the article body and expand it to the full content width - `--no-body-background` removes the article panel background, border, and shadow while keeping the overall page theme ## Bundled files - Converter: `scripts/markdown_to_html.py` - Built-in templates: `assets/templates/*.html` - Built-in CSS themes: `assets/styles/*.css` ## Notes - This skill expects `pandoc` to be installed and available on `PATH` - Custom templates should be valid Pandoc HTML templates and include `$body$` - If you want a table of contents without visible heading numbers in the HTML body, use `--toc` without `--number-sections`

path: assets/examples/sample.md
content: # Markdown to HTML Example

This is a sample document to verify the `markdown-to-html` skill.

## Supported Capabilities

- Markdown file input
- Markdown string input
- Custom Pandoc HTML templates
- Custom CSS overrides
- Built-in four style templates

## Example Table

| Template | Visual Direction | Use Case |
| --- | --- | --- |
| docs-slate | Documentation | Internal docs, Tech guides |
| magazine-amber | Editorial | Columns, Long forms, Content pages |
| product-midnight | Release | Release notes, Product updates |
| serif-paper | Paper | Articles, Essays, Whitepapers |

## Quote

> Templates handle the skeleton, CSS handles the visuals. Custom templates take priority over built-in ones.

## Code

```bash
python3 scripts/markdown_to_html.py \
  --input-file sample.md \
  --output sample.html \
  --builtin-template docs-slate \
  --toc \
  --embed-assets
```


path: assets/styles/docs-slate.css
content: :root {
  --bg: #f3f5f8;
  --panel: #ffffff;
  --panel-alt: #eef2f7;
  --text: #17212b;
  --muted: #5c6b7a;
  --line: #d7dee7;
  --accent: #1f4f78;
  --accent-soft: #dce8f3;
  --code-bg: #0f1720;
  --code-text: #e8edf2;
  --quote: #f7fbff;
}

* {
  box-sizing: border-box;
}

html {
  scroll-behavior: smooth;
}

body {
  margin: 0;
  font-family: "Inter", "PingFang SC", "Noto Sans CJK SC", sans-serif;
  background:
    radial-gradient(circle at top left, rgba(31, 79, 120, 0.08), transparent 28rem),
    linear-gradient(180deg, #f8fafc 0%, var(--bg) 100%);
  color: var(--text);
  line-height: 1.75;
}

.page-shell {
  max-width: 1240px;
  margin: 0 auto;
  padding: 56px 24px 80px;
}

.page-header {
  padding: 28px 8px 32px;
}

.page-header h1 {
  margin: 0;
  font-size: clamp(2.4rem, 6vw, 4.5rem);
  line-height: 1.02;
  letter-spacing: -0.04em;
}

.eyebrow,
.toc-label {
  margin: 0 0 12px;
  color: var(--accent);
  font-size: 0.82rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  font-weight: 700;
}

.meta-row {
  margin-top: 14px;
  color: var(--muted);
  display: flex;
  flex-wrap: wrap;
  gap: 14px;
  font-size: 0.96rem;
}

.page-main {
  display: grid;
  grid-template-columns: minmax(0, 280px) minmax(0, 1fr);
  gap: 28px;
  align-items: start;
}

.page-main.no-toc {
  grid-template-columns: minmax(0, 1fr);
}

.toc-panel,
.content-panel {
  background: rgba(255, 255, 255, 0.82);
  backdrop-filter: blur(18px);
  border: 1px solid rgba(215, 222, 231, 0.9);
  border-radius: 28px;
  box-shadow: 0 24px 70px rgba(10, 18, 30, 0.08);
}

.toc-panel {
  position: sticky;
  top: 24px;
  padding: 22px 20px;
}

.content-panel {
  padding: 34px clamp(20px, 4vw, 48px) 48px;
}

.content-panel.plain {
  background: transparent;
  backdrop-filter: none;
  border: 0;
  box-shadow: none;
  border-radius: 0;
  padding-left: 8px;
  padding-right: 8px;
}

#TOC ul,
.toc-panel ul {
  list-style: none;
  padding-left: 0;
}

#TOC li,
.toc-panel li {
  margin: 0.4rem 0;
}

a {
  color: var(--accent);
}

h1,
h2,
h3,
h4,
h5,
h6 {
  color: #122b42;
  line-height: 1.15;
  letter-spacing: -0.03em;
  margin-top: 2.2rem;
  margin-bottom: 0.9rem;
}

h2 {
  font-size: clamp(1.8rem, 3vw, 2.4rem);
  padding-top: 0.6rem;
  border-top: 1px solid var(--line);
}

blockquote {
  margin: 1.6rem 0;
  padding: 1rem 1.2rem;
  border-left: 4px solid var(--accent);
  background: var(--quote);
  color: #243647;
  border-radius: 0 18px 18px 0;
}

code,
pre {
  font-family: "JetBrains Mono", "SFMono-Regular", monospace;
}

code {
  background: var(--accent-soft);
  padding: 0.16rem 0.38rem;
  border-radius: 0.45rem;
  font-size: 0.92em;
}

pre {
  background: var(--code-bg);
  color: var(--code-text);
  padding: 1rem 1.1rem;
  border-radius: 18px;
  overflow-x: auto;
}

pre code {
  background: transparent;
  padding: 0;
  color: inherit;
}

table {
  width: 100%;
  border-collapse: collapse;
  margin: 1.6rem 0;
  background: var(--panel);
  border-radius: 16px;
  overflow: hidden;
}

th,
td {
  padding: 0.85rem 0.95rem;
  border-bottom: 1px solid var(--line);
  text-align: left;
}

th {
  background: var(--panel-alt);
}

img {
  max-width: 100%;
  border-radius: 18px;
  box-shadow: 0 20px 60px rgba(15, 23, 32, 0.12);
}

@media (max-width: 920px) {
  .page-main {
    grid-template-columns: 1fr;
  }

  .toc-panel {
    position: static;
  }
}


path: assets/styles/magazine-amber.css
content: :root {
  --bg: #fff8f0;
  --paper: rgba(255, 252, 246, 0.92);
  --card: #fffdf8;
  --text: #2d241b;
  --muted: #7a5c3c;
  --line: #eadcc9;
  --accent: #a64b00;
  --accent-2: #e07a1f;
  --quote: #fff2df;
  --code-bg: #2f2419;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  color: var(--text);
  font-family: "Avenir Next", "PingFang SC", "Noto Sans CJK SC", sans-serif;
  background:
    linear-gradient(180deg, rgba(230, 122, 31, 0.12), transparent 26rem),
    linear-gradient(135deg, #fff9f2 0%, #fff3df 42%, #fffaf6 100%);
  line-height: 1.78;
}

.hero-band {
  height: 22vh;
  min-height: 180px;
  background:
    radial-gradient(circle at 20% 30%, rgba(230, 122, 31, 0.4), transparent 18rem),
    radial-gradient(circle at 80% 10%, rgba(166, 75, 0, 0.25), transparent 18rem),
    linear-gradient(135deg, #2d241b 0%, #a64b00 100%);
}

.magazine-page {
  max-width: 980px;
  margin: -90px auto 0;
  padding: 0 22px 80px;
}

.hero-card,
.magazine-toc,
.story-card {
  background: var(--paper);
  backdrop-filter: blur(16px);
  border: 1px solid rgba(234, 220, 201, 0.8);
  box-shadow: 0 28px 90px rgba(77, 46, 17, 0.12);
}

.hero-card {
  border-radius: 34px;
  padding: 30px 30px 34px;
}

.hero-card h1 {
  margin: 0;
  font-size: clamp(2.8rem, 7vw, 5.3rem);
  line-height: 0.95;
  letter-spacing: -0.05em;
}

.kicker,
.toc-label {
  margin: 0 0 12px;
  text-transform: uppercase;
  letter-spacing: 0.22em;
  color: var(--accent);
  font-size: 0.78rem;
  font-weight: 800;
}

.meta-row {
  margin-top: 14px;
  color: var(--muted);
  display: flex;
  gap: 14px;
  flex-wrap: wrap;
}

.magazine-toc,
.story-card {
  margin-top: 20px;
  border-radius: 28px;
  padding: 24px clamp(18px, 4vw, 40px);
}

.story-card.plain {
  background: transparent;
  backdrop-filter: none;
  border: 0;
  box-shadow: none;
  padding-left: 0;
  padding-right: 0;
}

#TOC ul {
  list-style: none;
  padding-left: 0;
}

a {
  color: var(--accent);
}

h1,
h2,
h3,
h4,
h5,
h6 {
  font-family: "Canela", "Iowan Old Style", "Songti SC", serif;
  letter-spacing: -0.035em;
  line-height: 1.08;
  color: #2f2419;
}

h2 {
  font-size: clamp(2rem, 4vw, 2.8rem);
  margin-top: 2.3rem;
}

blockquote {
  margin: 1.8rem 0;
  padding: 1rem 1.2rem;
  background: var(--quote);
  border-left: 4px solid var(--accent-2);
  border-radius: 0 16px 16px 0;
}

code,
pre {
  font-family: "JetBrains Mono", "SFMono-Regular", monospace;
}

code {
  background: #f8e4cb;
  color: #6f3200;
  padding: 0.16rem 0.38rem;
  border-radius: 0.45rem;
}

pre {
  background: var(--code-bg);
  color: #fff7ef;
  padding: 1rem 1.1rem;
  border-radius: 20px;
  overflow-x: auto;
}

pre code {
  background: transparent;
  color: inherit;
  padding: 0;
}

table {
  width: 100%;
  border-collapse: collapse;
  margin: 1.6rem 0;
}

th,
td {
  padding: 0.9rem 0.95rem;
  border-bottom: 1px solid var(--line);
  text-align: left;
}

th {
  color: var(--accent);
}

img {
  max-width: 100%;
  border-radius: 22px;
}


path: assets/styles/product-midnight.css
content: :root {
  --bg: #08111f;
  --bg-2: #101b30;
  --panel: rgba(11, 18, 33, 0.72);
  --panel-2: rgba(17, 27, 48, 0.78);
  --text: #e6edf7;
  --muted: #92a4c4;
  --line: rgba(144, 164, 196, 0.18);
  --accent: #6ee7f2;
  --accent-2: #7c3aed;
  --quote: rgba(110, 231, 242, 0.08);
  --code-bg: #050a14;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: "Space Grotesk", "PingFang SC", "Noto Sans CJK SC", sans-serif;
  color: var(--text);
  background:
    radial-gradient(circle at 10% 10%, rgba(110, 231, 242, 0.14), transparent 22rem),
    radial-gradient(circle at 90% 20%, rgba(124, 58, 237, 0.18), transparent 24rem),
    linear-gradient(180deg, #050a14 0%, #09111f 45%, #0b1628 100%);
  line-height: 1.78;
}

.ambient {
  position: fixed;
  inset: auto;
  width: 40vw;
  height: 40vw;
  border-radius: 999px;
  filter: blur(80px);
  opacity: 0.25;
  pointer-events: none;
}

.ambient-a {
  top: -8vw;
  right: -10vw;
  background: #7c3aed;
}

.ambient-b {
  left: -12vw;
  top: 24vh;
  background: #0891b2;
}

.midnight-page {
  position: relative;
  z-index: 1;
  max-width: 1220px;
  margin: 0 auto;
  padding: 58px 24px 80px;
}

.launch-header h1 {
  margin: 0;
  font-size: clamp(2.9rem, 7vw, 5.8rem);
  line-height: 0.94;
  letter-spacing: -0.06em;
  max-width: 12ch;
}

.eyebrow,
.toc-label {
  margin: 0 0 12px;
  color: var(--accent);
  font-size: 0.82rem;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  font-weight: 800;
}

.meta-row {
  margin-top: 14px;
  color: var(--muted);
  display: flex;
  gap: 14px;
  flex-wrap: wrap;
}

.midnight-layout {
  display: grid;
  grid-template-columns: minmax(0, 270px) minmax(0, 1fr);
  gap: 24px;
  align-items: start;
  margin-top: 28px;
}

.midnight-layout.no-toc {
  grid-template-columns: minmax(0, 1fr);
}

.toc-panel,
.content-panel {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 30px;
  box-shadow: 0 30px 90px rgba(0, 0, 0, 0.28);
  backdrop-filter: blur(22px);
}

.toc-panel {
  position: sticky;
  top: 24px;
  padding: 20px;
}

.content-panel {
  padding: 34px clamp(20px, 4vw, 46px) 48px;
}

.content-panel.plain {
  background: transparent;
  border: 0;
  box-shadow: none;
  backdrop-filter: none;
  border-radius: 0;
  padding-left: 0;
  padding-right: 0;
}

#TOC ul,
.toc-panel ul {
  list-style: none;
  padding-left: 0;
}

a {
  color: var(--accent);
}

h1,
h2,
h3,
h4,
h5,
h6 {
  color: #f3f7ff;
  line-height: 1.1;
  letter-spacing: -0.03em;
}

h2 {
  margin-top: 2.3rem;
  font-size: clamp(1.9rem, 4vw, 2.8rem);
}

h3 {
  color: #b9c8e4;
}

blockquote {
  margin: 1.7rem 0;
  padding: 1rem 1.2rem;
  background: var(--quote);
  border-left: 4px solid var(--accent);
  border-radius: 0 18px 18px 0;
}

code,
pre {
  font-family: "JetBrains Mono", "SFMono-Regular", monospace;
}

code {
  background: rgba(110, 231, 242, 0.12);
  color: #aef8ff;
  padding: 0.14rem 0.38rem;
  border-radius: 0.42rem;
}

pre {
  background: var(--code-bg);
  color: #d9e3f0;
  padding: 1rem 1.1rem;
  border-radius: 20px;
  overflow-x: auto;
  border: 1px solid rgba(146, 164, 196, 0.14);
}

pre code {
  background: transparent;
  color: inherit;
  padding: 0;
}

table {
  width: 100%;
  border-collapse: collapse;
  margin: 1.6rem 0;
}

th,
td {
  padding: 0.9rem 0.95rem;
  border-bottom: 1px solid var(--line);
  text-align: left;
}

th {
  color: #b8f4fa;
}

img {
  max-width: 100%;
  border-radius: 22px;
  border: 1px solid var(--line);
}

@media (max-width: 920px) {
  .midnight-layout {
    grid-template-columns: 1fr;
  }

  .toc-panel {
    position: static;
  }
}


path: assets/styles/serif-paper.css
content: :root {
  --bg: #ebe7df;
  --paper: #fffdf8;
  --text: #2b231d;
  --muted: #78695a;
  --line: #ded3c5;
  --accent: #6d4c2e;
  --accent-2: #b17a4e;
  --quote: #f8f0e6;
  --code-bg: #1f1d1a;
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: "Source Serif 4", "Songti SC", "STSong", serif;
  color: var(--text);
  background:
    linear-gradient(180deg, rgba(255, 255, 255, 0.4), transparent 18rem),
    linear-gradient(180deg, #efebe4 0%, var(--bg) 100%);
  line-height: 1.84;
}

.paper-wrap {
  max-width: 900px;
  margin: 0 auto;
  padding: 52px 22px 84px;
}

.paper-header,
.paper-toc,
.paper-body {
  background: rgba(255, 253, 248, 0.92);
  border: 1px solid rgba(222, 211, 197, 0.9);
  box-shadow: 0 22px 70px rgba(58, 43, 30, 0.08);
}

.paper-header {
  border-radius: 22px 22px 0 0;
  padding: 28px 28px 20px;
}

.paper-toc {
  padding: 16px 28px 12px;
  border-top: 0;
}

.paper-body {
  padding: 18px 28px 42px;
  border-top: 0;
  border-radius: 0 0 22px 22px;
}

.paper-body.plain {
  background: transparent;
  border: 0;
  box-shadow: none;
  border-radius: 0;
  padding-left: 0;
  padding-right: 0;
}

.paper-header h1 {
  margin: 0;
  font-size: clamp(2.6rem, 6vw, 4.8rem);
  line-height: 1;
  letter-spacing: -0.045em;
}

.eyebrow,
.toc-label {
  margin: 0 0 10px;
  color: var(--accent);
  font-size: 0.82rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  font-weight: 700;
  font-family: "Inter", "PingFang SC", sans-serif;
}

.meta-row {
  margin-top: 16px;
  color: var(--muted);
  display: flex;
  gap: 14px;
  flex-wrap: wrap;
  font-family: "Inter", "PingFang SC", sans-serif;
  font-size: 0.95rem;
}

#TOC ul {
  list-style: none;
  padding-left: 0;
}

a {
  color: var(--accent);
}

h1,
h2,
h3,
h4,
h5,
h6 {
  color: #201914;
  line-height: 1.14;
}

h2 {
  margin-top: 2.4rem;
  padding-bottom: 0.4rem;
  border-bottom: 1px solid var(--line);
}

blockquote {
  margin: 1.7rem 0;
  padding: 1rem 1.2rem;
  background: var(--quote);
  border-left: 4px solid var(--accent-2);
}

code,
pre {
  font-family: "JetBrains Mono", "SFMono-Regular", monospace;
}

code {
  background: #efe2d2;
  color: #7e4d1d;
  padding: 0.14rem 0.34rem;
  border-radius: 0.4rem;
}

pre {
  background: var(--code-bg);
  color: #f8f3ed;
  padding: 1rem 1.1rem;
  border-radius: 12px;
  overflow-x: auto;
}

pre code {
  background: transparent;
  color: inherit;
  padding: 0;
}

table {
  width: 100%;
  border-collapse: collapse;
  margin: 1.6rem 0;
}

th,
td {
  padding: 0.85rem 0.9rem;
  border-bottom: 1px solid var(--line);
  text-align: left;
}

th {
  color: var(--accent);
  font-family: "Inter", "PingFang SC", sans-serif;
}

img {
  max-width: 100%;
  border-radius: 14px;
}


path: assets/templates/docs-slate.html
content: $if(lang)$<!DOCTYPE html>
<html lang="$lang$">
$else$<!DOCTYPE html>
<html lang="zh-CN">
$endif$
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>$if(title-prefix)$$title-prefix$ - $endif$$pagetitle$</title>
  $for(author-meta)$<meta name="author" content="$author-meta$" />$endfor$
  $if(date-meta)$<meta name="date" content="$date-meta$" />$endif$
  $if(description-meta)$<meta name="description" content="$description-meta$" />$endif$
  $for(css)$<link rel="stylesheet" href="$css$" />$endfor$
</head>
<body class="theme-docs-slate">
  <div class="page-shell">
    <header class="page-header">
      $if(title)$<p class="eyebrow">Markdown to HTML</p><h1>$title$</h1>$endif$
      <div class="meta-row">
        $if(author)$<span>$for(author)$$author$$sep$ · $endfor$</span>$endif$
        $if(date)$<span>$date$</span>$endif$
      </div>
    </header>
    <main class="page-main$if(toc)$ has-toc$else$ no-toc$endif$">
      $if(toc)$
      <aside class="toc-panel">
        <p class="toc-label">目录</p>
        $table-of-contents$
      </aside>
      $endif$
      <article class="content-panel$if(plain_content)$ plain$endif$">
        $body$
      </article>
    </main>
  </div>
</body>
</html>


path: assets/templates/magazine-amber.html
content: $if(lang)$<!DOCTYPE html>
<html lang="$lang$">
$else$<!DOCTYPE html>
<html lang="zh-CN">
$endif$
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>$if(title-prefix)$$title-prefix$ - $endif$$pagetitle$</title>
  $for(author-meta)$<meta name="author" content="$author-meta$" />$endfor$
  $if(date-meta)$<meta name="date" content="$date-meta$" />$endif$
  $if(description-meta)$<meta name="description" content="$description-meta$" />$endif$
  $for(css)$<link rel="stylesheet" href="$css$" />$endfor$
</head>
<body class="theme-magazine-amber">
  <div class="hero-band"></div>
  <main class="magazine-page">
    <header class="hero-card">
      $if(title)$<p class="kicker">Feature</p><h1>$title$</h1>$endif$
      <div class="meta-row">
        $if(author)$<span>$for(author)$$author$$sep$ · $endfor$</span>$endif$
        $if(date)$<span>$date$</span>$endif$
      </div>
    </header>
    $if(toc)$
    <section class="magazine-toc">
      <p class="toc-label">On This Page</p>
      $table-of-contents$
    </section>
    $endif$
    <article class="story-card$if(plain_content)$ plain$endif$">
      $body$
    </article>
  </main>
</body>
</html>


path: assets/templates/product-midnight.html
content: $if(lang)$<!DOCTYPE html>
<html lang="$lang$">
$else$<!DOCTYPE html>
<html lang="zh-CN">
$endif$
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>$if(title-prefix)$$title-prefix$ - $endif$$pagetitle$</title>
  $for(author-meta)$<meta name="author" content="$author-meta$" />$endfor$
  $if(date-meta)$<meta name="date" content="$date-meta$" />$endif$
  $if(description-meta)$<meta name="description" content="$description-meta$" />$endif$
  $for(css)$<link rel="stylesheet" href="$css$" />$endfor$
</head>
<body class="theme-product-midnight">
  <div class="ambient ambient-a"></div>
  <div class="ambient ambient-b"></div>
  <main class="midnight-page">
    <header class="launch-header">
      $if(title)$<p class="eyebrow">Launch Notes</p><h1>$title$</h1>$endif$
      <div class="meta-row">
        $if(author)$<span>$for(author)$$author$$sep$ · $endfor$</span>$endif$
        $if(date)$<span>$date$</span>$endif$
      </div>
    </header>
    <section class="midnight-layout$if(toc)$ has-toc$else$ no-toc$endif$">
      $if(toc)$
      <aside class="toc-panel">
        <p class="toc-label">Navigate</p>
        $table-of-contents$
      </aside>
      $endif$
      <article class="content-panel$if(plain_content)$ plain$endif$">
        $body$
      </article>
    </section>
  </main>
</body>
</html>


path: assets/templates/serif-paper.html
content: $if(lang)$<!DOCTYPE html>
<html lang="$lang$">
$else$<!DOCTYPE html>
<html lang="zh-CN">
$endif$
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>$if(title-prefix)$$title-prefix$ - $endif$$pagetitle$</title>
  $for(author-meta)$<meta name="author" content="$author-meta$" />$endfor$
  $if(date-meta)$<meta name="date" content="$date-meta$" />$endif$
  $if(description-meta)$<meta name="description" content="$description-meta$" />$endif$
  $for(css)$<link rel="stylesheet" href="$css$" />$endfor$
</head>
<body class="theme-serif-paper">
  <main class="paper-wrap">
    <header class="paper-header">
      $if(title)$<p class="eyebrow">Essay</p><h1>$title$</h1>$endif$
      <div class="meta-row">
        $if(author)$<span>$for(author)$$author$$sep$ · $endfor$</span>$endif$
        $if(date)$<span>$date$</span>$endif$
      </div>
    </header>
    $if(toc)$
    <section class="paper-toc">
      <p class="toc-label">Contents</p>
      $table-of-contents$
    </section>
    $endif$
    <article class="paper-body$if(plain_content)$ plain$endif$">
      $body$
    </article>
  </main>
</body>
</html>


path: scripts/markdown_to_html.py
content: #!/usr/bin/env python3
from __future__ import annotations

import argparse
import json
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path


ROOT = Path(__file__).resolve().parent.parent
TEMPLATE_DIR = ROOT / "assets" / "templates"
STYLE_DIR = ROOT / "assets" / "styles"

BUILTIN_TEMPLATES = {
    "docs-slate": {
        "template": TEMPLATE_DIR / "docs-slate.html",
        "css": [STYLE_DIR / "docs-slate.css"],
        "description": "Technical documentation layout with clean spacing and slate tones.",
    },
    "magazine-amber": {
        "template": TEMPLATE_DIR / "magazine-amber.html",
        "css": [STYLE_DIR / "magazine-amber.css"],
        "description": "Editorial layout with warm highlights and magazine rhythm.",
    },
    "product-midnight": {
        "template": TEMPLATE_DIR / "product-midnight.html",
        "css": [STYLE_DIR / "product-midnight.css"],
        "description": "Dark narrative layout for launch notes and product storytelling.",
    },
    "serif-paper": {
        "template": TEMPLATE_DIR / "serif-paper.html",
        "css": [STYLE_DIR / "serif-paper.css"],
        "description": "Print-inspired article page with serif typography.",
    },
}


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Convert Markdown files or Markdown strings into polished HTML documents."
    )
    parser.add_argument("--input-file", type=Path, help="Absolute or relative path to a Markdown file.")
    parser.add_argument("--markdown", help="Raw Markdown string to convert.")
    parser.add_argument("--output", type=Path, help="Path to the output .html file.")
    parser.add_argument(
        "--template",
        type=Path,
        help="Custom Pandoc HTML template. Overrides --builtin-template when provided.",
    )
    parser.add_argument(
        "--builtin-template",
        choices=sorted(BUILTIN_TEMPLATES.keys()),
        default="docs-slate",
        help="Built-in HTML theme to use when no custom --template is provided.",
    )
    parser.add_argument(
        "--css",
        action="append",
        type=Path,
        default=[],
        help="Additional CSS file. May be passed more than once.",
    )
    parser.add_argument("--metadata-file", type=Path, help="Pandoc metadata file, usually YAML.")
    parser.add_argument("--resource-path", help="Extra pandoc resource path for linked assets.")
    parser.add_argument("--title")
    parser.add_argument("--author")
    parser.add_argument("--date")
    parser.add_argument("--toc", action="store_true", help="Insert a table of contents.")
    parser.add_argument(
        "--number-sections",
        action="store_true",
        help="Number document headings in the output.",
    )
    parser.add_argument(
        "--no-body-background",
        action="store_true",
        help="Remove the background, border, and shadow behind the rendered article body.",
    )
    parser.add_argument(
        "--embed-assets",
        action="store_true",
        help="Embed CSS, images, and other resources into the final HTML when possible.",
    )
    parser.add_argument(
        "--self-contained",
        action="store_true",
        help="Compatibility alias for --embed-assets.",
    )
    parser.add_argument(
        "--highlight-style",
        default="tango",
        help="Pandoc syntax highlighting style. Default: tango.",
    )
    parser.add_argument(
        "--list-templates",
        action="store_true",
        help="Print built-in template names and exit.",
    )
    parser.add_argument(
        "--print-command",
        action="store_true",
        help="Print the pandoc command before running it.",
    )
    return parser


def ensure_pandoc() -> str:
    pandoc = shutil.which("pandoc")
    if not pandoc:
        raise SystemExit("pandoc is required but was not found on PATH.")
    return pandoc


def list_templates() -> None:
    payload = []
    for name, config in BUILTIN_TEMPLATES.items():
        payload.append(
            {
                "name": name,
                "template": str(config["template"]),
                "css": [str(path) for path in config["css"]],
                "description": config["description"],
                "exists": config["template"].exists() and all(path.exists() for path in config["css"]),
            }
        )
    print(json.dumps(payload, ensure_ascii=False, indent=2))


def validate_args(args: argparse.Namespace) -> None:
    if args.list_templates:
        return
    if bool(args.input_file) == bool(args.markdown):
        raise SystemExit("Provide exactly one of --input-file or --markdown.")
    if args.input_file and not args.input_file.exists():
        raise SystemExit(f"Input Markdown file not found: {args.input_file}")
    if args.template and not args.template.exists():
        raise SystemExit(f"Template file not found: {args.template}")
    if args.metadata_file and not args.metadata_file.exists():
        raise SystemExit(f"Metadata file not found: {args.metadata_file}")
    for css_path in args.css:
        if not css_path.exists():
            raise SystemExit(f"CSS file not found: {css_path}")


def resolve_output(args: argparse.Namespace) -> Path:
    if args.output:
        return args.output.expanduser().resolve()
    if args.input_file:
        return args.input_file.expanduser().resolve().with_suffix(".html")
    raise SystemExit("--output is required when using --markdown.")


def resolve_template(args: argparse.Namespace) -> Path:
    if args.template:
        return args.template.expanduser().resolve()
    template_path = BUILTIN_TEMPLATES[args.builtin_template]["template"]
    if not template_path.exists():
        raise SystemExit(f"Built-in template is missing: {template_path}")
    return template_path


def resolve_css(args: argparse.Namespace) -> list[Path]:
    builtin_css: list[Path] = []
    if not args.template:
        builtin_css = [path.resolve() for path in BUILTIN_TEMPLATES[args.builtin_template]["css"]]
    custom_css = [path.expanduser().resolve() for path in args.css]
    return builtin_css + custom_css


def base_resource_path(args: argparse.Namespace) -> str | None:
    if args.resource_path:
        return args.resource_path
    if args.input_file:
        return str(args.input_file.expanduser().resolve().parent)
    return None


def build_command(
    pandoc: str,
    source_path: Path,
    output_path: Path,
    template_path: Path,
    css_paths: list[Path],
    args: argparse.Namespace,
) -> list[str]:
    command = [
        pandoc,
        str(source_path),
        "--from",
        "markdown+yaml_metadata_block+emoji+footnotes+pipe_tables+grid_tables+task_lists+fenced_divs+fenced_code_attributes+raw_attribute+tex_math_dollars",
        "--to",
        "html5",
        "--standalone",
        "--template",
        str(template_path),
        "--syntax-highlighting",
        args.highlight_style,
        "--output",
        str(output_path),
    ]
    if args.metadata_file:
        command.extend(["--metadata-file", str(args.metadata_file.expanduser().resolve())])
    if args.title:
        command.extend(["--metadata", f"title={args.title}"])
    if args.author:
        command.extend(["--metadata", f"author={args.author}"])
    if args.date:
        command.extend(["--metadata", f"date={args.date}"])
    if args.no_body_background:
        command.extend(["--metadata", "plain_content=true"])
    resource_path = base_resource_path(args)
    if resource_path:
        command.extend(["--resource-path", resource_path])
    if args.toc:
        command.append("--toc")
    if args.number_sections:
        command.append("--number-sections")
    if args.embed_assets or args.self_contained:
        command.append("--embed-resources")
    for css_path in css_paths:
        command.extend(["--css", str(css_path)])
    return command


def run_conversion(args: argparse.Namespace) -> int:
    pandoc = ensure_pandoc()
    output_path = resolve_output(args)
    template_path = resolve_template(args)
    css_paths = resolve_css(args)
    output_path.parent.mkdir(parents=True, exist_ok=True)

    temp_source: tempfile.NamedTemporaryFile[str] | None = None
    try:
        if args.input_file:
            source_path = args.input_file.expanduser().resolve()
        else:
            # 使用上下文管理器确保临时文件总是被清理
            with tempfile.NamedTemporaryFile(
                mode="w",
                suffix=".md",
                prefix="markdown-to-html-",
                encoding="utf-8",
                delete=False,
            ) as f:
                f.write(args.markdown)
                f.flush()
                source_path = Path(f.name)
                temp_source = f

        command = build_command(pandoc, source_path, output_path, template_path, css_paths, args)
        if args.print_command:
            print(" ".join(command), file=sys.stderr)
        completed = subprocess.run(command, check=False)
        if completed.returncode != 0:
            return completed.returncode
        print(
            json.dumps(
                {
                    "output": str(output_path),
                    "template": str(template_path),
                    "css": [str(path) for path in css_paths],
                    "builtin_template": None if args.template else args.builtin_template,
                    "source": str(source_path),
                },
                ensure_ascii=False,
                indent=2,
            )
        )
        return 0
    finally:
        if temp_source is not None:
            try:
                Path(temp_source.name).unlink(missing_ok=True)
            except OSError:
                pass


def main() -> int:
    parser = build_parser()
    args = parser.parse_args()
    validate_args(args)
    if args.list_templates:
        list_templates()
        return 0
    return run_conversion(args)


if __name__ == "__main__":
    raise SystemExit(main())


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)