Publisher

unlisted

by Mark Ayers

Publish notes to GitHub for Hugo processing. Automatically converts wikilinks, images, and filenames to Hugo-compatible formats.

Updated 12d agoMIT
View on GitHub

Obsidian Publisher

Publish Obsidian notes to GitHub for Hugo processing. Tailored for philoserf.com.

You probably shouldn't install this

This is personal tooling, not a general-purpose plugin. It is opinionated in ways that only make sense for one person's workflow:

  • Single user. The only known installation is the maintainer's. Breaking changes ship without migration paths (see CHANGELOG.md — 1.4.0 renamed the publish sentinel, 1.5.0 retired removePublishFlag and the {{< ref >}} wikilinks, 1.6.0 retired the direct-commit publish mode entirely).
  • One target shape. The Hugo destination is expected to use content/posts/, static/images/, and the shipped hugo-shortcodes/ (callout + mermaid) installed in the site's theme. Other layouts will see broken links or missing renderers.
  • PR workflow only, mandatory. Every publish creates a timestamped feature branch and opens a PR against baseBranch. There is no direct-commit escape hatch. A second publish is refused while one is still running — on a slow connection, re-tapping a button that has not visibly responded would otherwise open a duplicate branch and PR, and there is no delete path to clean either up.
  • Required frontmatter. Notes must carry status: publish, plus a non-empty title and date. Missing or empty required fields fail the publish per-note.
  • No issue triage for feature requests. Bugs are welcome; feature requests from other users will almost always be closed as out-of-scope.

If you want something similar, the code is MIT-licensed — fork it and adapt. Don't expect upstream to accommodate your workflow.

Content Transformations

The plugin converts Obsidian-specific syntax to Hugo-compatible markdown during publish:

ObsidianHugo
[[Page Name]][Page Name](/posts/page-name/) (if in publish set)
[[Page Name#Heading]][Page Name#Heading](/posts/page-name/#heading)
[[Page|Display]][Display](/posts/page-name/)
[[#Heading]][Heading](#heading) (same page, always resolves)
![[image.png]]![image.png](/images/image.png)
![[image.png|alt]]![alt](/images/image.png)
![[image.png|300]]![image.png](/images/image.png) (sizing stripped)
![[image.png|alt|300]]![alt](/images/image.png)
![[Note Name]] (embed)[Note Name](/posts/note-name/) (if in publish set)
![[Note#Heading]][Note#Heading](/posts/note/#heading)
[[folder/Page Name]][folder/Page Name](/posts/page-name/)
![[folder/image.png]]![image.png](/images/image.png)
%%comment%%Removed, including when it wraps code
==highlight==<mark>highlight</mark>
> [!note] Title{{< callout note "Title" >}} … {{< /callout >}}
```mermaid{{< mermaid >}} … {{< /mermaid >}} (any fence)

Wikilinks and note embeds only resolve to URLs for notes in the current publish set (the notes being published in this operation). Out-of-set references degrade to plain text — so you can't publish a link to a note that isn't also being published. The one exception is a same-page anchor ([[#Heading]]): its target is the document itself, so it always resolves.

Blocks nest. A callout holds whatever you put in it — paragraphs separated by a bare >, fenced code samples, comments — and the whole block converts, not just the lines above the first fence. A %% comment is removed whether or not it wraps a code span or a fenced block, and an image referenced only inside one is never uploaded. Mermaid is recognized in any fence the parser accepts: tildes, four or more markers, and an info string beyond the bare language.

Paths are addressing, not names. Obsidian writes a reference path-qualified when the bare filename would be ambiguous, and unconditionally when Files & Links -> New link format is set to "Absolute path in vault" or "Relative path to file". The directory tells the plugin which file you mean; the slug, the committed filename and the URL all come from the filename alone. A link that does not resolve still degrades to the full text you wrote.

Code is left alone. Everything inside a fenced code block or an inline code span is exempt from every transformation in the table — a ==x== or [[Page]] in a code sample publishes verbatim rather than being rewritten. Mermaid is the deliberate exception: it is the one transformation that acts on a fenced block.

Aliases become redirects. A note's aliases frontmatter is rewritten into the URLs those titles produced (DNA as Remix Culture/posts/dna-as-remix-culture/), so Hugo emits a working redirect stub instead of one at a path the site never served. Values that already start with / pass through untouched, which is how you pin an exact old URL.

Settings worth knowing

  • Stripped frontmatter fields. Removed from published notes; defaults to status, lastmod, cssclass, cssclasses, position, created, modified. aliases is deliberately not in that list — stripping it would suppress the redirects above.
  • Frontmatter template. Fields injected into every published note, without overriding what the note already sets. Input that is not key: value lines is ignored with a notice rather than half-parsed — a partial recovery was the one way a value you never wrote could reach a commit.
  • Shortcode names. callout and mermaid by default; change them to match your theme.
  • contentDir / imageDir. Destination paths in the site repo; the URL prefix in the table above is derived from contentDir. A path with a . or .. segment, or a ~ anywhere in it, is rejected whole rather than repaired — the publish then fails with "… is required" instead of quietly writing somewhere else. Surrounding whitespace and edge slashes are still normalized away; what is never done is stripping the dangerous parts, because removing them can construct the thing being removed (.~./posts used to become ../posts).

Callout types pass through from Obsidian verbatim (no collapse to a fixed set). The destination Hugo site must define callout and mermaid shortcodes in layouts/shortcodes/. Reference implementations ship in hugo-shortcodes/ — copy them into your theme. The shortcode names are configurable in the plugin settings (default callout and mermaid).

Heading anchors ([[Page#Heading]]) assume the destination Hugo site uses the default autoIDType: "github", which preserves Unicode letters. Sites configured with the opt-in autoIDType: "github-ascii" will see mismatched #fragment links — pages load, but in-page jumps to non-ASCII headings won't resolve.

Page slugs, committed filenames, and heading anchors all share one rule: NFC-normalize, lowercase, keep Unicode letters and digits, and turn whitespace into hyphens. So a note titled Rōnin… publishes at /posts/rōnin-…/, matching what Hugo generates by default. Sites that set removePathAccents: true will see mismatched links, for the same reason as github-ascii above.

Because the destination filename follows that same rule, renaming a note renames its published file — and the plugin has no delete path, so the previous file stays in the site repo until you remove it by hand. Add the old URL to the note's aliases before republishing if you want it to keep resolving.

Security

This plugin requires a GitHub Personal Access Token (PAT) to publish content.

  • Use a fine-grained token scoped to your target repository with contents:write and pull_requests:write permissions. Avoid classic tokens with broad repo scope.
  • Token storage: The token is stored in Obsidian's plugin data file (data.json) as plaintext. This is an Obsidian platform constraint — there is no encrypted storage API. Anyone with file system access to your vault can read the token.
  • Recommendations: Use a token scoped to a single repository. On shared devices, be aware that the token is accessible on disk.

Alternatives

  • Enveloppe — publish Obsidian notes to GitHub with broader CMS support (Jekyll, Hugo, etc.).
  • Digital Garden — publish notes to a static site with its own theme and hosting.

For plugin developers

Search results and similarity scores are powered by semantic analysis of your plugin's README. If your plugin isn't appearing for searches you'd expect, try updating your README to clearly describe your plugin's purpose, features, and use cases.