obsidian-serial-publish
NewPandoc filter, scripts, ai skill, etc for converting obsidian md files to royalroad compatible html, epub, and more
Overview
Royal Road Converter — Pandoc Lua Filter
Recommended approach: Use the bundled rr-convert.sh script for deterministic, testable conversion:
./rr-convert.sh input.md -o output.html # Royal Road (default) ./rr-convert.sh input.md -o output.html --mode ghost # Ghost CMS
This handles \[\[...\]\] escape preprocessing and runs the appropriate Lua filter with pandoc. The script swaps escaped brackets to control characters before pandoc parses them, so the filter can distinguish literal brackets from wiki links.
Output Modes
| Mode | Flag | Headings | Inline styles | Use case |
|---|---|---|---|---|
| Royal Road (default) | --mode rr | <div> with inline CSS | Yes — required for RR parser | Publishing on Royal Road |
| Ghost CMS | --mode ghost | Semantic <h1>–<h6> | No — theme CSS handles styling | Ghost CMS posts |
Ghost mode produces clean semantic HTML wrapped in <div class="rr-theme">. The Ghost theme's CSS custom properties (--read-font-size, --read-line-height, etc.) control all typography. Callout tables retain their inline styles since Ghost has no native callout support.
Note: The SKILL.md below documents the conversion rules that the Lua filter implements. For new conversions, always prefer running the lua filter over instructing an LLM to manually apply these rules.
Conversion Order (apply sequentially)
- Strip forbidden HTML tags and their contents
- Remove ID/class/event attributes from remaining tags
- Convert heading tags (
<hN>to<div>) - Fix CSS properties that RR would break or strip
- Remove absolute positioning declarations
1. STRIP — Forbidden Elements (delete entirely)
Royal Road's parser deletes these. Do not include them in output.
Tags and Contents to Delete
Delete the opening tag, all inner HTML, and closing tag for: <script>, <style>, <iframe>, <object>, <embed>, <form>, <input>, <button>, <textarea>, <svg>, <canvas>, <video>, <audio>
Attributes to Remove from Any Tag
- •
id="..."— remove the entire attribute - •
class="..."— remove the entire attribute - •Any
on[a-z]+=event handler (onclick=,onload=, etc.) — remove the entire attribute
Note: When removing class attributes, preserve semantic meaning by adding inline styles (see heading conversion below).
2. CONVERT — Safe Replacements
Royal Road's parser breaks these properties unless you pre-convert them. Apply these transformations during markdown-to-HTML conversion.
Background Colors — RR Color Inverter
RR randomly inverts pure black (#000, #000000, "black") and pure white (#fff, #ffffff, "white") on publish/preview only (not edit mode). RR recommends #212529 for black and #f8f9fa or #fafafa for white.
Rule: When converting background-color or color values:
- •
#000,#000000,blackto#212529 - •
#fff,#ffffff,whiteto#fafafa(or#f8f9fa)
To prevent RR from stripping solid background colors, wrap them in a gradient:
background-color: #000;
/* Convert to */
background-image: linear-gradient(135deg, #212529, #212529);
background-color: #212529 !important;Border-Radius — Must Have !important
RR strips border-radius unless !important is present.
Rule: Append !important to any border-radius: declaration.
/* Before */
border-radius: 8px;
/* After */
border-radius: 8px !important;Font-Size / Line-Height — px to em Conversion
RR strips pixel-based font sizes and line heights. Convert to em units.
Rule: font-size: Npx to (N divided by 10)em, rounded to one decimal place, clamped between 0.3em and 2.4em. Same for line-height.
/* Before */
font-size: 16px; line-height: 24px;
/* After */
font-size: 1.6em; line-height: 2.4em;Heading Tags — <hN> to <div> with Inline Styling
RR mangles <h1> through <h6> into <p> tags during parsing. Convert to <div> elements with inline styles that replicate heading semantics.
Rule: Replace every <hN and </hN> (N is 1 through 6) with <div / </div>. Inline styles are sourced from rr-convert.settings.lua → headings (default values shown):
| Level | Default inline style |
|---|---|
| h1 | font-size:2.0em;font-weight:bold;margin:1.5em 0 0.8em; |
| h2 | font-size:1.6em;font-weight:bold;margin:1.3em 0 0.7em; |
| h3 | font-size:1.3em;font-weight:bold;margin:1.1em 0 0.5em; |
| h4 | font-size:1.1em;font-weight:bold;margin:0.9em 0 0.4em; |
| h5 | font-size:1.0em;font-weight:bold;margin:0.7em 0 0.3em; |
| h6 | font-size:0.9em;font-weight:bold;text-transform:uppercase;margin:0.6em 0 0.2em; |
Preserve the original element's text content and any inline style attributes (merge if needed). Do not preserve class or id attributes — they get stripped by RR anyway.
Example:
<!-- Markdown: ## Chapter Title -->
<h2>Chapter Title</h2>
<!-- Becomes -->
<div style="font-size:1.6em;font-weight:bold;margin:1.3em 0 0.7em;">Chapter Title</div>Background Shorthand — Convert to background-image:
RR strips background: shorthand declarations that contain gradients but preserves background-image: declarations.
Rule: When encountering background: with a gradient value, convert to background-image: only:
/* Before */
background: linear-gradient(135deg, #1a1a2e, #16213e) center/cover no-repeat;
/* After */
background-image: linear-gradient(135deg, #1a1a2e, #16213e);Strip any shorthand sub-properties (color, position, size, repeat). Keep only the gradient.
Absolute Positioning — Remove Entirely
RR strips position: absolute and will break your layout. There is no safe equivalent value RR accepts.
Rule: Delete all position: absolute; declarations along with their positioning properties (top, left, right, bottom). For overlapping elements, rebuild using CSS Grid or structural margins/borders instead.
/* Before */
position: absolute; top: 10px; left: 20px; z-index: 5;
/* After — delete entirely */Pixel Dimensions (width/height) — Convert to em or %
RR strips width: and height: values expressed in pixels.
Rule:
- •Convert
width: Npxto(N divided by 10)em(same clamp as font-size: min 0.3em, max 2.4em) - •Or better: remove fixed px dimensions entirely and use flexbox/grid for layout instead
- •Percentages are preserved by RR but pixels are not
3. REMOVE — Properties to Delete (RR strips these; no safe equivalent)
Do not include these in output — they waste bytes and serve zero purpose since RR deletes them regardless.
| Property | Pattern | Action |
|---|---|---|
| Clip-path: polygon | clip-path: polygon(...) | Delete from CSS block |
| Position absolute | position: absolute | Delete entire declaration + related positioning props (top, left, right, bottom) |
4. MARKDOWN TO HTML SPECIFIC RULES
When the input is Obsidian markdown, apply these conversions before applying converter rules above.
Headings
Styles are configured in rr-convert.settings.lua → headings. Default output:
# H1 -> <div style="font-size:2.0em;font-weight:bold;margin:1.5em 0 0.8em;">Heading</div>
## H2 -> <div style="font-size:1.6em;font-weight:bold;margin:1.3em 0 0.7em;">Heading</div>
### H3 -> <div style="font-size:1.3em;font-weight:bold;margin:1.1em 0 0.5em;">Heading</div>
#### H4 -> <div style="font-size:1.1em;font-weight:bold;margin:0.9em 0 0.4em;">Heading</div>
##### H5 -> <div style="font-size:1.0em;font-weight:bold;margin:0.7em 0 0.3em;">Heading</div>
###### H6 -> <div style="font-size:0.9em;font-weight:bold;text-transform:uppercase;margin:0.6em 0 0.2em;">Heading</div>Paragraphs — Wrap in <p> Tags with Spacing
Each block of text between blank lines (or after a heading) is a paragraph. Wrap each one:
Some paragraph text -> <p style="margin-bottom:1em;">Some paragraph text</p>Do not collapse consecutive paragraphs into a single <p> — RR may strip content if not separated properly.
Bold & Emphasis (inside <p>)
| Markdown | HTML Output | Notes |
|---|---|---|
**bold** or __bold__ | <b>text</b> | RR-safe |
_italic_ or *italic* | <i>text</i> | RR-safe |
~~strikethrough~~ | <strike>text</strike> | RR-safe |
Code Blocks
Inline: `code` -> <code>code</code> (safe)
Fenced block: -> <div class="sourceCode"><code>...content...</code></div>
-> No <pre> wrapper (RR strips <pre> tags)Links
- •
[text](url)-> preserved as<a href="url">text</a>(Royal Road supports links natively) - •
[[Page Name]]-> stripped entirely (Obsidian wiki-links have no RR equivalent) - •
\[\[Text\]\]-> unescaped to literal[[Text]]in output (requiresrr-convert.shpreprocessing)
Escaped Brackets — Unescape in Output
Backslash-escaped brackets are literal text in Obsidian and should output with brackets intact:
- •
\[\[Text\]\]->[[Text]](requiresrr-convert.shwhich preprocesses escapes before pandoc)
Rule: Use ./rr-convert.sh input.md -o output.html rather than raw pandoc, so that \[\[...\]\] sequences are distinguished from wiki links during preprocessing.
Lists
| Markdown | HTML Output | Notes |
|---|---|---|
- item / * item | <li>item</li> inside <ul> | RR-safe |
1. item | <li>item</li> inside <ol> | RR-safe |
Horizontal Rules & Images
| Markdown | HTML Output | Notes |
|---|---|---|
--- | styled <hr> from rr-convert.settings.lua → horizontal_rule (default: centered, max-width 80%, chromatic aberration) | RR-safe |
 | <img src="src" alt="alt"> | Ensure trusted domain; use em or % for width/height — never px |
Callouts — Styled Tables (RR-safe, no <pre> tags)
Obsidian callout syntax (> [!type]) becomes styled <table> elements wrapped in a max-width container. Royal Road strips <pre> tags, so tables are used instead.
All callout styling is configured in rr-convert.settings.lua under callouts (per-type) and callout_table (shared). Each callout type defines:
- •
color— hex color for backgrounds, borders, shadows - •
symbol— unicode symbol prepended to the title - •
heading_style— inline CSS for the title<td>(%s→ color) - •
body_style— inline CSS for body<td>cells - •
border_between— CSS for the border between body sections (%s→ color) - •
border_heading— CSS for the bottom border of the heading cell (%s→ color) - •
error_override— optional override for shadow/border (used byerrortype)
Default callout types:
| Callout Type | Color (hex) | Symbol | Notes |
|---|---|---|---|
info | #1e90ff | ℹ | Blue |
tip | #4caf50 | ▶ | Green |
warning | #ff5722 | ⚠ | Deep orange/red |
error | #f44336 | ✖ | Red + chromatic aberration |
note | #8bc34a | ✎ | Light green |
task | #9c27b0 | ☑ | Purple |
quote | #607d8b | ❝ | Slate blue |
example | #ba68c8 | ☷ | Lavender |
Structure: Each callout renders as:
- •Outer wrapper div (
callout_table.wrapper_style) — caps width, centers on page - •Table with dark background and monospace font (
callout_table.table_style) - •Traffic light dots prefix (
callout_table.title_prefix) before the title - •Title row: colored bold text with symbol prefix; bottom border separator
- •Body row: content with
<br />preserving line breaks - •Outer table border and shadow (
callout_table.border/callout_table.shadow)
All critical CSS properties use `!important` to override Royal Road defaults.
Special handling for `[!error]`:
- •Chromatic aberration via multi-layer box-shadow (red/cyan offsets in multiple directions)
- •All body text is bold — wrapped in
<b>tags
Note: Type variants with numeric suffixes (e.g., task-1) are normalized to their base type (task). Unknown types default to info. ---
5. SAFE ELEMENTS & PROPERTIES (no conversion needed)
HTML Elements That Survive Unchanged
<div>, <span>, <p>, <br>, <hr>, <ul>, <ol>, <li>, <a href="">, <b>, <i>, <u>, <strike>, <sub>, <sup>, <code>, <figure>, <figcaption>, <blockquote>
HTML Elements That Are Stripped
<pre> — RR strips <pre> tags. Use <code> without <pre> wrapper. <font> — RR converts <font color="..."> to <span style="color:...">. Use <span> directly.
CSS Properties That Survive (with correct values)
- •
color:— safe as long as value is not pure #000/#fff (use conversions above) - •
background-image:— gradients via this property survive; shorthand does not - •
border:/border-top:etc. — safe - •
padding:,margin:— use em or %, avoid px - •
display: flex | grid | block | inline-block— all safe - •
text-shadow:— safe - •
font-weight: bold— safe
EXAMPLE: Full Conversion Walkthrough
Input markdown:
# My Chapter
## Introduction
This is **bold** and _italic_ text with a black background.

> [!info] Note
> This is an informational note about the chapter.
> [!error] Warning
> The system failed to initialize.
[Read more](https://example.com)Output HTML (RR-safe):
<div style="font-size:2.0em;font-weight:bold;margin:1.5em 0 0.8em;">My Chapter</div>
<div style="font-size:1.6em;font-weight:bold;margin:1.3em 0 0.7em;">Introduction</div>
<p>This is <b>bold</b> and <i>italic</i> text with a black background.</p>
<img src="https://example.com/image.png" alt="Image">
<div style="max-width:90ch!important;margin:auto;">
<table style="background:#1a1a2e!important;color:#ddd!important;width:100%!important;font-family:monospace!important;font-size:0.9em!important;white-space:pre-wrap!important;border-radius:8px !important;box-shadow:-4px 4px 0 #1e90ff66!important;border:4px solid #1e90ff!important;">
<tr><td colspan="2" style="color:#1e90ff!important;font-weight:bold!important;padding:0.5em 1em!important;border-top:0!important;border-right:0!important;border-left:0!important;border-bottom:2px solid #1e90ff!important;">ℹ Note</td></tr>
<tr><td colspan="2" style="padding:0.5em 1em!important;border-top:0!important;border-right:0!important;border-left:0!important;border-bottom:0!important;"><span style="display:block!important;padding-left:1em!important;text-indent:-1em!important;">This is an informational note about the chapter.</span></td></tr>
</tbody></table>
</div>
<div style="max-width:90ch!important;margin:auto;">
<table style="background:#1a1a2e!important;color:#ddd!important;width:100%!important;font-family:monospace!important;font-size:0.9em!important;white-space:pre-wrap!important;border-radius:8px !important;box-shadow:-4px 4px 0 #f4433666!important,-2px -1px 0 #ff0000!important,2px 1px 0 #00ffff!important,-3px 2px 0 rgba(255,0,0,0.4)!important,3px -2px 0 rgba(0,255,255,0.4)!important;border:4px solid #f44336!important;">
<tr><td colspan="2" style="color:#f44336!important;font-weight:bold!important;padding:0.5em 1em!important;border-top:0!important;border-right:0!important;border-left:0!important;border-bottom:2px solid #f44336!important;">✖ Warning</td></tr>
<tr><td colspan="2" style="padding:0.5em 1em!important;border-top:0!important;border-right:0!important;border-left:0!important;border-bottom:0!important;"><span style="display:block!important;padding-left:1em!important;text-indent:-1em!important;"><b>The system failed to initialize.</b></span></td></tr>
</tbody></table>
</div>(Links [text](url) are preserved as <a> tags. Wiki links [[wiki]] are removed entirely, leaving surrounding text intact.)
SUMMARY CHECKLIST (Lua Filter Output)
For every markdown-to-RR-HTML conversion via the Lua filter, verify:
- •[ ] No
<script>,<style>,<iframe>,<svg>, etc. tags remain - •[ ] All headings are
<div>with inline styles (not<hN>) - •[ ] Links
[text](url)preserved as<a href="">tags - •[ ] Wiki links
[[wiki]]are removed entirely - •[ ] Escaped brackets
\[\[...\]\]rendered as literal[[...]](userr-convert.sh) - •[ ] Callouts rendered as styled tables in max-width wrapper divs
- •[ ]
[!error]callouts have single combined box-shadow (chromatic aberration + base) and bold body text - •[ ] Callout titles use
<span style="color:...">for traffic light dots (not<font>) - •[ ] Code blocks use
<code>without<pre>wrapper - •[ ] All
box-shadowproperties are single declarations (RR only keeps the first)
Note: CSS-level conversions (color inverter, border-radius !important, px→em, background shorthand → background-image, absolute positioning removal) apply only when the input already contains inline styles. The Lua filter handles markdown AST transformations; CSS property rewriting would need a separate post-processor if your source uses style attributes extensively.
Install & Usage
mkdir -p .claude/skillsmkdir -p .claude/skills && curl -o .claude/skills/obsidian-serial-publish.md https://raw.githubusercontent.com/Lknechtli/obsidian-serial-publish/main/SKILL.md/obsidian-serial-publishSecurity Audits
Frequently Asked Questions
What is obsidian-serial-publish?
Pandoc filter, scripts, ai skill, etc for converting obsidian md files to royalroad compatible html, epub, and more
How to install obsidian-serial-publish?
To install obsidian-serial-publish: create the skills directory (mkdir -p .claude/skills), then run: mkdir -p .claude/skills && curl -o .claude/skills/obsidian-serial-publish.md https://raw.githubusercontent.com/Lknechtli/obsidian-serial-publish/main/SKILL.md. Finally, /obsidian-serial-publish in Claude Code.
What is obsidian-serial-publish best for?
obsidian-serial-publish is a skill categorized under Documentation. Created by Lknechtli.