Skip to content

feat: custom css option.#21

Open
y2w8 wants to merge 3 commits into
selimacerbas:mainfrom
y2w8:main
Open

feat: custom css option.#21
y2w8 wants to merge 3 commits into
selimacerbas:mainfrom
y2w8:main

Conversation

@y2w8

@y2w8 y2w8 commented May 2, 2026

Copy link
Copy Markdown

Add custom CSS support

Moves the plugin's default styles out of index.html into a dedicated assets/style.css file.
On start, the stylesheet is inlined into the generated index.html via a <style> tag.
A new custom_css config option lets users point to their own stylesheet:

require("markdown_preview").setup({
  -- Leave it empty for default style only
  custom_css = "~/my-styles.css",
})

@selimacerbas

Copy link
Copy Markdown
Owner

Hey @y2w8, thanks for picking this up. custom_css is something a lot of users want. Before merging there are two bugs to fix, and I'd like to propose a different design that matches how comparable tools handle this.

Bugs

1. The preview loads unstyled. The diff moves the bundled CSS into assets/style.css and adds <link rel="stylesheet" href="style.css">, but write_index is not updated to copy style.css into the workspace. The workspace only contains index.html and content.md, so the browser's GET /style.css returns 404. The PR description says the stylesheet is "inlined into the generated index.html via a <style> tag" but the code doesn't do that. Easy to verify: apply the PR, run :MarkdownPreview on any file.

2. custom_css injection crashes on % in CSS. The code does:

content = content:gsub("</head>", "<style>\n" .. css .. "\n</style>\n</head>")

In gsub, the replacement string treats % as an escape. %0 through %9 are capture references and %% is a literal %. Any other % raises invalid use of '%' in replacement string. Common CSS like width: 100% will throw. Use the function form instead:

content = content:gsub("</head>", function() return "<style>\n" .. css .. "\n</style>\n</head>" end)

Function returns are inserted literally, no escaping needed.

Suggested design

Instead of extracting the bundled CSS into a separate file, I think the cleaner approach is:

  1. Keep the bundled <style> block where it is in index.html. Avoids the static-file copy problem and keeps the preview self-contained.
  2. Make custom_css accept either a string or a list of strings so users can layer multiple stylesheets:
    custom_css = "~/.config/nvim/md-preview.css"
    -- or
    custom_css = { "~/themes/base.css", "~/themes/overrides.css" }
  3. Copy each user CSS file into the workspace as user-N.css in write_index, then emit <link rel="stylesheet" href="user-N.css"> tags from a new placeholder like __USER_CSS__. Bundled styles load first, user styles last, so the cascade wins naturally without !important.
  4. Use vim.fn.expand for paths so ~ and $VAR work. Warn and skip on missing files (you already do this).
  5. Optional but cheap: a uv.fs_event per user CSS path that calls ls_server.reload for hot reload. Matches what VSCode, Marked, and Obsidian do.

This pattern matches what most editor-class tools do (VSCode markdown.styles, Pandoc --css, Obsidian snippets). The single-file-replaces-everything approach in iamcco/markdown-preview.nvim is widely cited as painful because users end up forking the whole stylesheet to tweak one rule.

Want me to push a follow-up commit on your branch, or would you rather update yourself?

@y2w8

y2w8 commented May 25, 2026

Copy link
Copy Markdown
Author

Yes, please do and thanks for the detailed review.

@y2w8

y2w8 commented May 25, 2026

Copy link
Copy Markdown
Author
  1. Copy each user CSS file into the workspace as user-N.css in write_index, then emit <link rel="stylesheet" href="user-N.css"> tags from a new placeholder like __USER_CSS__. Bundled styles load first, user styles last, so the cascade wins naturally without !important.

  2. Use vim.fn.expand for paths so ~ and $VAR work. Warn and skip on missing files (you already do this).

  3. Optional but cheap: a uv.fs_event per user CSS path that calls ls_server.reload for hot reload. Matches what VSCode, Marked, and Obsidian do.

This pattern matches what most editor-class tools do (VSCode markdown.styles, Pandoc --css, Obsidian snippets). The single-file-replaces-everything approach in iamcco/markdown-preview.nvim is widely cited as painful because users end up forking the whole stylesheet to tweak one rule.

Want me to push a follow-up commit on your branch, or would you rather update yourself?

@selimacerbas Honestly I'm not very familiar with the Lua/Nvim APIs or live_server internals yet 😅

If you could implement those changes directly. Your proposed approach makes a lot more sense than my current implementation, especially the hot reload.

I’ll handle making custom_css accept either a string or a list.
Thanks again for the detailed review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants