[{"content":"I recently finalized a pretty neat automation for my static blog. I built a custom Gemini skill that takes my Obsidian drafts and automatically generates wobbly, hand-drawn-style cover banners and spot illustrations. The workflow is entirely frictionless: I write in Obsidian, sync via the iOS Git plugin, and GitHub Actions takes over to build the Hugo site.\nThe pipeline worked flawlessly—until I tried to share the latest post on X (Twitter).\nThe Ghost Banner The GitHub Action passed. The site was live, and the banner image loaded perfectly on the actual webpage. But when I pasted the link into a tweet draft, the social preview card was completely blank.\nMy setup uses the Hugo PaperMod theme with page bundles, meaning the image file sits in the exact same folder as the markdown file. In the front matter, it looks like this:\n[params.cover] image = \u0026#34;banner.jpeg\u0026#34; alt = \u0026#34;How to create blogging visual skill\u0026#34; relative = true Since this exact configuration works perfectly on my other Hugo repository, I knew the issue wasn\u0026rsquo;t the front matter itself.\nInvestigating the Crawler Logs I ran the URL through the X Card Validator. The logs returned something interesting:\nINFO: Page fetched successfully INFO: twitter:card = summary_large_image tag found INFO: Card loaded successfully What was missing? There was no line confirming the image URL was found. The crawler was reading the metadata but completely dropping the image.\nThe Culprit: Hugo\u0026rsquo;s baseURL Trailing Slash The root cause came down to a single character in my global configuration. In my hugo.toml file, I had defined the repository URL like this:\nbaseURL = \u0026#34;https://robertluwang.github.io/life\u0026#34; Notice what is missing? The trailing slash.\nBecause Hugo is built on Go, it follows strict web protocol path resolution. When you set relative = true, Hugo attempts to construct the absolute Open Graph URL (og:image) by appending your local image path to the baseURL.\nWithout the trailing slash, Go treats /life as a file endpoint rather than a base directory. When it concatenates the image path, it strips the \u0026ldquo;file\u0026rdquo; out entirely. This resulted in Hugo outputting a broken, nonexistent absolute URL in the metadata header, completely skipping the sub-directory. Social crawlers require a strict, absolute URL. They hit a 404 error and failed silently.\nWhy the Pipeline Didn\u0026rsquo;t Catch It It is easy to get a false sense of security when the CI/CD pipeline gives you a green checkmark.\nGitHub Actions: The hugo \u0026ndash;minify command merely compiles files. It does not validate external link resolution. It compiled without syntax errors and exited with code 0.\nWeb Browsers: Browsers use DOM-relative paths (e.g., ). Because the HTML and the image sit in the same folder on the live server, the browser resolves the local path natively, ignoring the broken Open Graph metadata completely.\nThe Fix and the Cache Trap The technical fix was trivial: change the configuration to baseURL = \u0026ldquo;https://robertluwang.github.io/life/\u0026quot;\nHowever, X\u0026rsquo;s caching is notoriously aggressive. Simply pushing the fix wasn\u0026rsquo;t enough, because Twitter had already locked in the broken preview for that specific URL. Rather than messing around with URL query strings to bust the cache, I took the nuclear option. I completely deleted the broken post folder, created a brand new one with a slightly altered title, and copied all the markdown and images over.\nBy forcing a completely new URL, X was forced to scrape the fresh metadata, and the wobbly banner finally appeared. A frustrating hour of debugging, but a necessary reminder: in static site generation, every slash matters.\n","permalink":"https://robertluwang.github.io/artark-ai/posts/2026-08-27-hugo-baseurl-missing-slash/","summary":"\u003cp\u003eI recently finalized a pretty neat automation for my static blog. I built a custom Gemini skill that takes my Obsidian drafts and automatically generates wobbly, hand-drawn-style cover banners and spot illustrations. The workflow is entirely frictionless: I write in Obsidian, sync via the iOS Git plugin, and GitHub Actions takes over to build the Hugo site.\u003c/p\u003e\n\u003cp\u003eThe pipeline worked flawlessly—until I tried to share the latest post on X (Twitter).\u003c/p\u003e","title":"The Missing Slash: Troubleshooting Hugo Twitter Cards and baseURL Paths"},{"content":"I open-sourced the tooling I use to run this blog. It is a set of scripts that sit on top of Hugo and GitHub Pages, handling the things Hugo does not: scaffolding posts correctly, validating front matter before deploy, fitting banner images for social cards, and keeping two devices in sync through git alone.\nThe repo is here: github.com/robertluwang/hugo-blog-pipeline\nThis post explains why it exists and how to set up a new blog from scratch using it.\nWhy Not Just Hugo? Hugo is excellent at one thing: turning markdown into a fast static site. But between writing a post and having it live with a working social card, there are several steps Hugo does not cover:\nFront matter correctness. PaperMod (and similar themes) silently emit a broken og:image if you forget relative = true in your cover config. The page looks fine; only social crawlers see the 404. hugo build cannot catch this — the HTML is valid, it just points somewhere empty.\nBanner sizing. Social platforms render cards near 1.91:1. An AI-generated 3:2 image gets centre-cropped by the platform, cutting whatever was at the top and bottom. A 2 MB PNG is re-fetched on every share.\nMulti-device sync. The moment you write from both a phone and a laptop, you need a sync mechanism that understands history, not just timestamps. I learned this the hard way — an rsync script silently deleted a section from a published post.\nDraft safety. Hugo\u0026rsquo;s draft = true excludes a post from the build with no other signal. If you forget to flip it, you end up debugging a working pipeline while the post simply is not there.\nThese are not Hugo bugs. They are gaps between \u0026ldquo;Hugo builds a site\u0026rdquo; and \u0026ldquo;I have a reliable publishing pipeline.\u0026rdquo; The scripts fill exactly those gaps.\nWhat the Pipeline Does Script Purpose scripts/new-post.sh Scaffold a page bundle with correct front matter scripts/fit-banner.py Normalise banners to 1200×630, archive originals scripts/check-posts.sh CI gate — fail on broken og:image, warn on oversized banners publish.sh Pull, scan banners, validate, build, push Plus:\n_templates/ — Obsidian Templater templates for iPhone .github/workflows/hugo.yml — GitHub Actions: validate → build → deploy Setting Up a New Blog Prerequisites Linux, macOS, or WSL Hugo (install guide) Python 3 + Pillow: python3 -m venv .venv source .venv/bin/activate pip install pillow A GitHub account Step 1: Create the Hugo Site hugo new site my-blog cd my-blog git init Step 2: Add the Pipeline Option A — git clone (recommended):\ngit clone https://github.com/robertluwang/hugo-blog-pipeline.git /tmp/hugo-blog-pipeline cp -r /tmp/hugo-blog-pipeline/scripts . cp -r /tmp/hugo-blog-pipeline/_templates . cp -r /tmp/hugo-blog-pipeline/.github . cp /tmp/hugo-blog-pipeline/publish.sh . cp /tmp/hugo-blog-pipeline/.gitattributes . cp /tmp/hugo-blog-pipeline/.gitignore . cp /tmp/hugo-blog-pipeline/hugo.toml.example . rm -rf /tmp/hugo-blog-pipeline Option B — one-liner (no clone needed):\ncurl -sL https://github.com/robertluwang/hugo-blog-pipeline/archive/main.tar.gz \\ | tar xz --strip-components=1 --wildcards \\ \u0026#39;*/scripts/*\u0026#39; \u0026#39;*/.github/*\u0026#39; \u0026#39;*/publish.sh\u0026#39; \u0026#39;*/_templates/*\u0026#39; \\ \u0026#39;*/.gitattributes\u0026#39; \u0026#39;*/.gitignore\u0026#39; \u0026#39;*/hugo.toml.example\u0026#39; Both drop in the scripts, CI workflow, templates, and config files. Nothing else — no sample posts, no theme.\nStep 3: Set Up Python Venv python3 -m venv .venv source .venv/bin/activate pip install pillow The venv is gitignored. fit-banner.py needs Pillow; without it, new-post.sh --banner falls back to a plain copy and check-posts.sh skips the dimension check.\nStep 4: Add a Theme Any Hugo theme works. PaperMod is minimal and fast:\ngit submodule add https://github.com/adityatelange/hugo-PaperMod.git themes/papermod Step 5: Configure cp hugo.toml.example hugo.toml Edit hugo.toml:\nbaseURL = \u0026#39;https://yourname.github.io/my-blog/\u0026#39; title = \u0026#39;My Blog\u0026#39; theme = \u0026#39;papermod\u0026#39; [params] env = \u0026#34;production\u0026#34; title = \u0026#34;My Blog\u0026#34; description = \u0026#34;Your description\u0026#34; author = \u0026#34;yourname\u0026#34; Also edit .github/workflows/hugo.yml — the --baseURL on the build line:\nrun: hugo --minify --baseURL \u0026#34;https://yourname.github.io/my-blog/\u0026#34; Step 6: Create Your First Post source .venv/bin/activate ./scripts/new-post.sh \u0026#34;My First Post\u0026#34; --slug first-post --publish Add a banner:\n./scripts/fit-banner.py ~/Downloads/banner-art.png content/posts/2026-08-23-first-post/banner.jpg Preview:\nhugo server -D # → http://localhost:1313/ Step 7: Push to GitHub Create an empty repo on GitHub (no README, no .gitignore, no license — you already have them).\ngit add -A git commit -m \u0026#34;initial blog setup\u0026#34; git remote add origin git@github.com:yourname/my-blog.git git push -u origin main Go to Settings → Pages → Source → GitHub Actions. That is the only manual step on GitHub — everything else is automated.\nWithin a minute your site is live at https://yourname.github.io/my-blog/.\nStep 8 (Optional): Add the iPhone If you also want to write from your phone:\nInstall Obsidian + the Git community plugin Clone the same repo via HTTPS + a fine-grained Personal Access Token Install the Templater plugin, set template folder to _templates Create posts with the new-post template — it produces byte-identical front matter Push from the phone lands on GitHub, CI validates and deploys. Next time you are at the laptop: git pull.\nDaily Workflow Laptop:\n./scripts/new-post.sh \u0026#34;New Post\u0026#34; --slug my-post --tags hugo,git --banner ~/img.png --publish # write... ./publish.sh \u0026#34;new post - my post\u0026#34; iPhone: Templater → new-post → write → Commit and Sync.\nAfter phone posts, on the laptop:\ngit pull --rebase origin main ./scripts/fit-banner.py --scan # fits any oversized phone banners How the CI Gate Works The workflow runs scripts/check-posts.sh before hugo build. It catches what Hugo cannot:\nERROR my-post: cover \u0026#34;banner.jpg\u0026#34; declared but content/posts/my-post/banner.jpg is missing. og:image would 404 and the social card would show no banner. ERROR my-post: cover set but \u0026#39;relative = true\u0026#39; missing from [params.cover]. og:image would resolve against the site root, not the page bundle. These fail the build and block deployment. Warnings (oversized banners, odd ratios) appear in the log but do not block:\nWARN my-post: cover is 1591KB (\u0026gt; 500KB) — slow for every scrape. Posts from any device — phone or laptop — pass through the same gate. A local hook would only protect the machine you were already careful on.\nBanner Handling fit-banner.py normalises any image to 1200×630:\nCrops when the source ratio is near the target (16:9 → 1.91:1, minimal loss) Pads when it is far off (3:2), using the border colour sampled from the image, so nothing is cut Never upscales — a 1024px source stays sharp at 1024×538 Archives the original to banners/originals/ (gitignored) before writing the fitted copy AI-generated art cannot be regenerated identically, so the downscaled card image must not be the only copy.\nFor a batch fix after phone posts:\n./scripts/fit-banner.py --scan --dry-run # what needs adjusting ./scripts/fit-banner.py --scan # archive + fit + rewrite front matter What You End Up With my-blog/ ├── .github/workflows/hugo.yml # validate → build → deploy ├── .gitattributes # LF enforcement ├── .gitignore ├── _templates/ # Obsidian (iPhone) ├── content/posts/ # your posts (page bundles) ├── hugo.toml ├── publish.sh # one-command publish ├── scripts/ │ ├── check-posts.sh # CI gate │ ├── fit-banner.py # banner normalisation │ └── new-post.sh # scaffolding └── themes/papermod/ # or any theme No database. No CMS. No block editor. Write markdown, push, site is live — with a CI gate that stops broken social cards before they reach the world.\nThe repo: github.com/robertluwang/hugo-blog-pipeline\n","permalink":"https://robertluwang.github.io/artark-ai/posts/2026-08-23-hugo-blog-pipeline/","summary":"\u003cp\u003eI open-sourced the tooling I use to run this blog. It is a set of scripts that sit on top of Hugo and GitHub Pages, handling the things Hugo does not: scaffolding posts correctly, validating front matter before deploy, fitting banner images for social cards, and keeping two devices in sync through git alone.\u003c/p\u003e\n\u003cp\u003eThe repo is here: \u003ca href=\"https://github.com/robertluwang/hugo-blog-pipeline\"\u003egithub.com/robertluwang/hugo-blog-pipeline\u003c/a\u003e\u003c/p\u003e\n\u003cp\u003eThis post explains why it exists and how to set up a new blog from scratch using it.\u003c/p\u003e","title":"Hugo Blog Pipeline: From Markdown to Live Site in One Push"},{"content":"This is the whole pipeline I use to write and publish a Hugo blog from Linux (including WSL on Windows 11): repo layout, scaffolding, banner handling, a validation gate in CI, and one command to publish. It also covers writing from an iPhone, because the moment a second device exists most of the interesting failures appear.\nIt replaces an earlier setup of mine that used an Obsidian vault on Windows and an rsync script. That approach is fine for one device — the original post still stands if that is you — but it silently deleted a section from a published post once I added a phone. That failure shaped everything below.\nThe Architecture Two working copies, both git clones of the same repo:\niPhone (Obsidian + Obsidian Git) Laptop (Linux / WSL) ┌────────────────────────────┐ ┌────────────────────────────┐ │ vault = git clone │ │ ~/hugo-site = git clone │ │ content/posts/ │ │ content/posts/ │ │ 2026-08-17-my-post/ │ │ 2026-08-17-my-post/ │ │ index.md │ │ index.md │ │ banner.jpg │ │ banner.jpg │ └─────────────┬──────────────┘ └─────────────┬──────────────┘ │ push/pull (HTTPS + PAT) │ push/pull (SSH) └──────────────────┬──────────────────────┘ ▼ GitHub: your-blog repo Actions: validate → build Pages: live site Git is the only sync mechanism. Edit the same post on both devices and you get a merge conflict you can see and resolve.\nThe rule that produces this shape:\nIf a copy of your content is not a git clone, it will eventually overwrite one that is.\nMy old script had three copies and only two were clones. The Windows vault was maintained solely by rsync:\n# repo → vault: bring in posts written elsewhere rsync -av --ignore-existing \u0026#34;$HUGO_POSTS/\u0026#34; \u0026#34;$VAULT/\u0026#34; # vault → repo: vault is the source of truth for local edits rsync -av --delete --exclude=\u0026#39;.obsidian\u0026#39; \u0026#34;$VAULT/\u0026#34; \u0026#34;$HUGO_POSTS/\u0026#34; --ignore-existing copies only files the vault does not already have, so a new post from the phone arrived but a modified one was skipped — the file existed on both sides. --delete then made the stale vault copy authoritative and overwrote the repo. Twenty lines vanished from a live post with no error and nothing in git status. New posts survived, edits did not, which made it look random rather than systematic.\nrsync compares filenames and timestamps; it cannot tell \u0026ldquo;newer\u0026rdquo; from \u0026ldquo;correct\u0026rdquo;. Git can, because it knows which version descends from which.\nThe trade-off of dropping the mirror is explicit: no Obsidian on the laptop (unless you clone the repo into a path Obsidian can reach and run Obsidian Git there too). On the laptop you write in whatever editor you already use — vim, VS Code, or Obsidian if the repo lives on a native filesystem it can watch. Either layout obeys the rule: every copy must be a git clone.\nSetup Hugo. Skip the package manager, which drags in a Go toolchain. Take the prebuilt binary from the releases page and keep the single executable in your workspace:\n./hugo version The repo. Clone into your home directory. If you are on WSL, that means the Linux filesystem — not /mnt/c — since Hugo\u0026rsquo;s file watcher and git are both markedly faster on the native filesystem:\ncd ~ \u0026amp;\u0026amp; git clone git@github.com:yourname/your-blog.git hugo-site cd hugo-site \u0026amp;\u0026amp; git submodule update --init --recursive # theme Line endings. This matters when multiple operating systems touch the same repo (Windows + Linux, or macOS + Linux). Without it, a shell script committed from Windows reaches CI with CRLF and fails with a misleading bad interpreter. In .gitattributes:\n* text=auto eol=lf *.md text eol=lf *.sh text eol=lf *.yml text eol=lf *.png binary *.jpg binary Ignores. In .gitignore:\n/public/ /resources/ .hugo_build.lock .obsidian/ /banners/ That last one is the local archive of full-resolution banners — more on it below.\nScaffolding a Post A Hugo page bundle is a folder holding index.md plus its images. Hand-building one invites front matter typos, so scripts/new-post.sh does it:\n./scripts/new-post.sh \u0026#34;My Post Title\u0026#34; ./scripts/new-post.sh \u0026#34;My Post Title\u0026#34; --tags hugo,wsl --banner ~/Downloads/img.png ./scripts/new-post.sh \u0026#34;My Post Title\u0026#34; --slug short-name ./scripts/new-post.sh \u0026#34;My Post Title\u0026#34; --publish It slugifies the title, creates content/posts/YYYY-MM-DD-slug/, writes the front matter, and runs the banner through the fitter if you pass one.\nBy default the URL is derived from the title, which is fine until the title runs long — this post would otherwise have landed at 2026-08-23-the-complete-hugo-blogging-pipeline/. --slug decouples the two:\n./scripts/new-post.sh \u0026#34;The Complete Hugo Blogging Pipeline on Windows 11 WSL\u0026#34; \\ --slug hugo-pipeline Short URL, full title on the page and in the social card. It also means retitling later never tempts you into renaming the folder, so links you have already shared keep working. An explicit slug is normalised the same way a derived one is, and you are told when it changes.\nPosts default to draft = true. The two failure modes are asymmetric: publishing half a post is visible and fixable in a minute, whereas forgetting to flip a draft means the post never appears and gives you no signal at all — so the default guards the confusing one, and --publish opts out.\nOne detail worth stealing, because it fails as a build error rather than a typo. TOML has two string forms, and the single-quoted literal form cannot contain an apostrophe — there is no escape for it. title = 'Google's Spark' is a parse error. So emit a literal string normally and switch to a double-quoted basic string when the title needs it:\nif [[ \u0026#34;$TITLE\u0026#34; == *\u0026#34;\u0026#39;\u0026#34;* ]]; then TITLE_TOML=\u0026#34;\\\u0026#34;$(esc_basic \u0026#34;$TITLE\u0026#34;)\\\u0026#34;\u0026#34; # \u0026#34;Google\u0026#39;s Spark\u0026#34; else TITLE_TOML=\u0026#34;\u0026#39;$TITLE\u0026#39;\u0026#34; # \u0026#39;Plain Title\u0026#39; fi Banners Social platforms render link previews near 1.91:1, and 1200×630 is the standard size. This matters more than it looks, because og:image points at your original file — the theme\u0026rsquo;s responsive srcset only affects on-page display, not the card.\nTwo consequences. A 2 MB PNG is re-fetched by every platform that scrapes the link. And a 1.50:1 image — a common AI output ratio — loses roughly a fifth of its height to centre-cropping, which is exactly where a title or footer usually sits.\nscripts/fit-banner.py normalises to 1200×630 and picks its method per image:\n./scripts/fit-banner.py ~/Downloads/img.png content/posts/my-post/banner.jpg crop when the source ratio is already close, e.g. 16:9 — minimal loss pad when it is not, using a colour sampled from the image border, so nothing is cut never upscale, since a 1024px source gains no detail from being stretched to 1200 It also archives the full-resolution source to banners/originals/\u0026lt;slug\u0026gt;-banner.\u0026lt;ext\u0026gt; before writing the downscaled copy. AI-generated art cannot be regenerated identically, so the card image must not be the only copy. That folder is gitignored — the originals stay on the laptop, and posts written on the phone commit their full-resolution image anyway.\nFormat follows content: PNG for flat graphics and diagrams, JPEG for photographic art. The difference is not subtle — one of my banners went from 599 KB as a fitted PNG to 79 KB as JPEG. Across the whole site, converting five old banners took total weight from about 8.2 MB to 0.66 MB.\nFor a sweep rather than one file:\n./scripts/fit-banner.py --scan --dry-run # what needs adjusting ./scripts/fit-banner.py --scan # archive, fit, rewrite front matter --scan skips covers already within limits, and for the rest archives the original, writes a fitted banner.jpg, updates the front matter and removes the old file. The dry run exits 2 when work is pending, which is what lets publish.sh ask you about it. This is the command for after publishing from a phone, where nothing resizes anything.\nThe Validation Gate Here is the bug that motivated all of this. The following front matter builds cleanly, renders perfectly in a browser, and produces a social card with no banner:\n[params.cover] image = \u0026#34;banner.jpg\u0026#34; alt = \u0026#34;My Post\u0026#34; PaperMod resolves a cover differently depending on one flag:\n{{- if (ne .Params.cover.relative true) }} \u0026lt;meta property=\u0026#34;og:image\u0026#34; content=\u0026#34;{{ .Params.cover.image | absURL }}\u0026#34;\u0026gt; {{- else}} \u0026lt;meta property=\u0026#34;og:image\u0026#34; content=\u0026#34;{{ (path.Join .RelPermalink .Params.cover.image) | absURL }}\u0026#34;\u0026gt; {{- end}} Without relative = true, the filename resolves against the site root instead of the page bundle, so og:image points at a URL that does not exist. The page still looks right, because the visible cover comes from a different partial that resolves the image from the bundle and ignores relative entirely. Only the crawler sees the 404.\nhugo build cannot catch this — the HTML is valid, it just points somewhere empty. So scripts/check-posts.sh runs in CI ahead of the build:\n- name: Validate posts run: ./scripts/check-posts.sh - name: Build run: hugo --minify --baseURL \u0026#34;https://yourname.github.io/your-repo/\u0026#34; It fails on a cover whose file is missing, or a cover without relative = true. It warns on a banner over 500 KB, outside 1.7–2.1:1, or under 600px wide — quality issues, not broken deploys. It lists drafts rather than skipping them silently, because a post accidentally left at draft = true gives no other signal:\nDRAFT 2026-08-24-half-written: draft = true — will NOT be published. Running it in Actions rather than a local git hook is the important decision. Posts pushed from the phone never touch your laptop tooling. A pre-push hook would guard only the machine where you already have scripts, a terminal and a preview server. Put the gate where every device\u0026rsquo;s commits must pass, and it covers all of them.\nWarnings are also kept meaningful by fixing them: eight permanent warnings train you to ignore the output, so the next real one is invisible.\nPublishing ./publish.sh \u0026#34;new post - my post title\u0026#34; That runs: git pull --rebase → banner scan → validate → build → commit → push. The banner scan is a dry run that asks before changing anything, so a banner you dropped in by hand or one that arrived from the phone gets caught without files being rewritten behind your back. Declining still publishes, since an oversized banner is a quality issue. There is no prompt when stdin is not a terminal, so nothing hangs in a non-interactive run.\nPlain git works too, and the pull is not optional:\ngit pull --rebase origin main git add -A \u0026amp;\u0026amp; git commit -m \u0026#34;new post - my post title\u0026#34; \u0026amp;\u0026amp; git push origin main Anything written on the phone is already on GitHub; skip the pull and you earn a rejected non-fast-forward push. That is git protecting you — precisely the protection rsync never offered.\nWhere each path leaves your banner:\nHow the banner is added Fitted? new-post.sh --banner automatically by hand, published with publish.sh prompted by hand, published with plain git no — warned at scaffold time, warned again in CI written on the phone no — the next publish.sh prompts Preview hugo server -D # http://localhost:1313/ -D renders drafts. Production builds omit them, so a post left at draft = true looks fine here and is absent from the live site — which is why the validator lists drafts out loud.\nTesting a Social Card Never judge a card fix by re-sharing the same link. X and LinkedIn cache card metadata per URL for roughly a week, including failures, so a working fix can look broken and a broken page can look fixed. I lost a morning to exactly this.\nVerify the page, not the platform:\ncurl -s \u0026lt;post-url\u0026gt; | grep -oE \u0026#39;\u0026lt;meta (property=\u0026#34;og:image\u0026#34;|name=twitter:image)[^\u0026gt;]*\u0026gt;\u0026#39; curl -s -o /dev/null -w \u0026#34;%{http_code}\\n\u0026#34; \u0026lt;that-image-url\u0026gt; If the second command prints 404, no amount of cache-busting will help. When the tags are right and you want a fresh scrape, use a throwaway query string:\nhttps://yourname.github.io/your-repo/posts/my-post/?v=2 The Full Stack new-post.sh — scaffold the page bundle, fit the banner $EDITOR — write markdown fit-banner.py — normalise banners, archive originals hugo server -D — preview, drafts included publish.sh — pull, scan, validate, build, push git — the ONLY sync mechanism, both directions GitHub Actions — validate front matter, then build GitHub Pages — serve the site Three lessons, in order of how much time each would have saved me.\nCount your copies and check each has a .git. Three copies with two clones is not an extra backup, it is an unmanaged writer with authority over your content.\nDistrust --delete in anything you run habitually. It turns a stale directory into an instruction to remove work.\nPut safety checks where every device pushes through. Local hooks protect the machine you were already careful on.\nNo database. No CMS login. No block editor. And no unmanaged copy of your content waiting to overwrite the good one.\n","permalink":"https://robertluwang.github.io/artark-ai/posts/2026-08-23-hugo-pipeline/","summary":"\u003cp\u003eThis is the whole pipeline I use to write and publish a Hugo blog from Linux (including WSL on Windows 11): repo layout, scaffolding, banner handling, a validation gate in CI, and one command to publish. It also covers writing from an iPhone, because the moment a second device exists most of the interesting failures appear.\u003c/p\u003e\n\u003cp\u003eIt replaces an earlier setup of mine that used an Obsidian vault on Windows and an \u003ccode\u003ersync\u003c/code\u003e script. That approach is fine for one device — \u003ca href=\"/artark-ai/posts/2026-08-15-obsidian-hugo-pipeline/\"\u003ethe original post\u003c/a\u003e still stands if that is you — but it silently deleted a section from a published post once I added a phone. That failure shaped everything below.\u003c/p\u003e","title":"The Complete Hugo Blogging Pipeline"},{"content":"You share a post link on X or LinkedIn and the card comes up as a bare title with no banner. The image is right there on the page, so the obvious guess is that the crawler cannot find it. That guess sends most people down the wrong path.\nThe tags the crawler actually reads Social platforms never look at the images in your page body. They read two meta tags in \u0026lt;head\u0026gt;:\n\u0026lt;meta property=\u0026#34;og:image\u0026#34; content=\u0026#34;...\u0026#34;\u0026gt; \u0026lt;meta name=\u0026#34;twitter:image\u0026#34; content=\u0026#34;...\u0026#34;\u0026gt; og:image is Open Graph, read by LinkedIn, Slack, Discord, WhatsApp and iMessage. twitter:image is X\u0026rsquo;s own tag; X prefers it and falls back to og:image. Both must be absolute URLs that return HTTP 200 to an anonymous request.\nSo the first thing to do is not to edit your front matter — it is to look at what your site actually emits:\ncurl -s https://yourname.github.io/your-repo/posts/your-post/ \\ | grep -oE \u0026#39;\u0026lt;meta (property=\u0026#34;og:image\u0026#34;|name=\u0026#34;twitter:image\u0026#34;)[^\u0026gt;]*\u0026gt;\u0026#39; Then confirm the URL you find is real:\ncurl -s -o /dev/null -w \u0026#34;%{http_code}\\n\u0026#34; \u0026lt;that-url\u0026gt; If that prints 404, you have found your bug, and no amount of cache-busting will fix it.\nWhere PaperMod trips you up PaperMod builds og:image from your cover config, and the relative flag decides how the path is resolved:\n{{- if .Params.cover.image -}} {{- if (ne .Params.cover.relative true) }} \u0026lt;meta property=\u0026#34;og:image\u0026#34; content=\u0026#34;{{ .Params.cover.image | absURL }}\u0026#34;\u0026gt; {{- else}} \u0026lt;meta property=\u0026#34;og:image\u0026#34; content=\u0026#34;{{ (path.Join .RelPermalink .Params.cover.image) | absURL }}\u0026#34;\u0026gt; {{- end}} {{- end }} With a page bundle — content/posts/my-post/index.md sitting next to banner.png — omitting relative or setting it to false resolves banner.png against the site root:\nhttps://yourname.github.io/your-repo/banner.png ← 404 The page still looks perfect in a browser, because the visible cover is rendered by a different partial that resolves the image from the page bundle and ignores relative entirely. Only the crawler sees the broken URL.\nSetting relative = true joins the image to the page\u0026rsquo;s own permalink:\nhttps://yourname.github.io/your-repo/posts/my-post/banner.png ← 200 So for a page bundle, this is the whole fix:\n[params.cover] image = \u0026#34;banner.png\u0026#34; alt = \u0026#34;My post title\u0026#34; relative = true Why hardcoding the full URL does not help The tempting workaround is to paste an absolute URL into a top-level images parameter:\nimages = [\u0026#34;https://yourname.github.io/your-repo/posts/my-post/banner.png\u0026#34;] PaperMod ignores it whenever cover.image is set. Look at the template again — images is only read in the else branch, as a fallback for pages with no cover. With a cover present that line is dead config, and you are left debugging a tag that never changed. Even where it does apply, a hardcoded URL breaks your local hugo server preview and silently rots the day you change domain or baseURL.\nCard caches will lie to you This is what makes the bug so confusing to test. X and LinkedIn cache card metadata per URL for roughly a week, and they cache failures too. Share a URL once before the banner exists and you can keep seeing an image-less card long after the tags are correct — or keep seeing a banner the live page no longer serves.\nNever judge a fix by re-sharing the same URL. Force a fresh scrape with a throwaway query string:\nhttps://yourname.github.io/your-repo/posts/my-post/?v=2 Different URL, no cache entry, honest answer. Verify with curl first, then test the card.\nA checklist that works curl the page and read og:image and twitter:image.\ncurl that image URL and confirm 200.\nFor page bundles, set relative = true.\nAdd a site-wide fallback in hugo.toml for pages with no cover:\n[params] images = [\u0026#34;og-default.png\u0026#34;] Keep the banner at 1200×630 and a few hundred KB or less. X allows up to 5 MB, but a 2 MB PNG makes the crawler work harder than it needs to.\nTest with a cache-busting query string, not by re-sharing.\nThe banner on this post is 1200×630 and 36 KB, configured with relative = true. If you can see it on the card that brought you here, the config is correct.\n","permalink":"https://robertluwang.github.io/artark-ai/posts/2026-08-23-hugo-social-card/","summary":"\u003cp\u003eYou share a post link on X or LinkedIn and the card comes up as a bare title with no banner. The image is right there on the page, so the obvious guess is that the crawler cannot find it. That guess sends most people down the wrong path.\u003c/p\u003e\n\u003ch2 id=\"the-tags-the-crawler-actually-reads\"\u003eThe tags the crawler actually reads\u003c/h2\u003e\n\u003cp\u003eSocial platforms never look at the images in your page body. They read two meta tags in \u003ccode\u003e\u0026lt;head\u0026gt;\u003c/code\u003e:\u003c/p\u003e","title":"Fix the Missing Social Card Banner in Hugo PaperMod"},{"content":"I set up two morning automations to test how Gemini handles recurring routines: one through the standard chat interface using a regular scheduled prompt, and another inside Gemini Spark using a dedicated skill called ai-news-briefing. Both were told to scan the web for recent AI news and prepare an email draft before eight in the morning.\nWhen the notifications showed up on my phone, the output looked completely different.\nStandard Gemini Action The standard Gemini schedule runs as a basic query execution. You type a prompt into the conversation box, pick a time under the settings menu, and Gemini stores that string as an event. When the clock hits eight, it acts as if you just sat down and pasted that exact text into the chat.\nFor my test, the prompt was:\nSearch the web for the latest AI trending news from the past day and draft an email summarizing the top stories. The resulting notification opened a standard conversation thread. The model searched for news, picked five items, and wrote a conversational summary. It included greetings, small talk, broad bullet points, and commentary. Because a standard prompt is open to interpretation every time it fires, the output changes format depending on whatever the base model decides on that day. There were no fixed boundaries on how many stories to grab, no uniform structure for citations, and no persistent state. It did not create a clean draft in Gmail; it just printed text inside a chat bubble for me to copy and paste.\nGemini Spark Schedule Task Setting up the same routine in Gemini Spark required a different path. Spark does not treat recurring tasks as loose text prompts. It treats them as structured playbooks called skills, running on isolated virtual machines in the background.\nI defined a skill package for Spark with a clear instruction file:\n--- name: ai-news-briefing description: Searches for daily AI news and drafts a structured briefing email. --- # Instructions When executed, perform the following steps: 1. Search the web for the top 3 trending AI news stories from the past 24 hours. 2. For each story, extract the headline, a 2-sentence summary, and the source. 3. Draft a professional email summarizing these stories, formatted cleanly with bullet points. 4. Save the drafted email for my review. Inside the Spark dashboard, I went to the Schedules tab, created a new daily trigger for 8:00 AM, and pointed the instruction field directly to the skill by typing:\nRun my /ai-news-briefing skill. When Spark executed the job, it loaded the sandbox environment, parsed the four-step logic, and ran Google Search through its native web tools. The output followed the schema to the letter: exactly three stories, each with a title line, two descriptive sentences, and the direct source link. It skipped the introductory conversational pleasantries entirely and dropped the formatted text straight into my Gmail drafts folder, ready for a final check.\nOperational Differences The operational differences between the two systems boil down to a few practical facts:\nStandard Scheduled Actions live inside the chat interface. They are designed for quick lookups on a timer. You use them when you want simple information delivered to your screen, such as checking a weather report, tracking a flight, or getting a rough overview of a topic. They cannot load custom skill configurations, they cannot interact deeply with file structures, and their output structure drifts between runs.\nGemini Spark operates as an agentic workspace. It separates the execution trigger from the task instructions. The skill acts as an immutable configuration file that dictates parameters, data schemas, and app actions. If you want to change the number of stories or rewrite the summary layout, you update the skill once. Every schedule tied to that skill picks up the change immediately without needing to reconfigure the timer.\nRunning both side by side made the distinction straightforward. Standard scheduled tasks are automated chat prompts. Spark tasks are background pipelines.\n","permalink":"https://robertluwang.github.io/artark-ai/posts/2026-08-17-gemini-action-vs-spark-action/","summary":"\u003cp\u003eI set up two morning automations to test how Gemini handles recurring routines: one through the standard chat interface using a regular scheduled prompt, and another inside Gemini Spark using a dedicated skill called ai-news-briefing. Both were told to scan the web for recent AI news and prepare an email draft before eight in the morning.\u003c/p\u003e\n\u003cp\u003eWhen the notifications showed up on my phone, the output looked completely different.\u003c/p\u003e\n\u003ch2 id=\"standard-gemini-action\"\u003eStandard Gemini Action\u003c/h2\u003e\n\u003cp\u003eThe standard Gemini schedule runs as a basic query execution. You type a prompt into the conversation box, pick a time under the settings menu, and Gemini stores that string as an event. When the clock hits eight, it acts as if you just sat down and pasted that exact text into the chat.\u003c/p\u003e","title":"Timers vs. True Agents: What Happened When I Tested Gemini Spark Against Standard Chat"},{"content":"Most interactions with modern AI follow a predictable, reactive cycle: you open a chat interface, type a prompt, wait for a response, copy the output, and close the tab. The moment that session ends, the compute stops, the context clears, and the AI goes dormant until you prompt it again.\nWith the launch of Gemini Spark, Google is shifting away from the traditional conversational interface toward persistent, autonomous background execution. Instead of acting as a passive chatbot waiting for human input, Spark operates as an always-on personal agent designed to execute multi-step workflows across your digital environment.\nHere is a technical look under the hood at how Spark is structured, what makes it distinct from standard chat models, and how to think about its execution model.\nThe Architecture: From Session Chat to Background Daemon To understand Spark, it helps to separate traditional conversational LLMs from autonomous background agents.\nTraditional Chat: [User Prompt] ──\u0026gt; [Stateless LLM Call] ──\u0026gt; [Text Response] ──\u0026gt; [Session Terminated] Gemini Spark: [Triggers / Schedules / Events] │ ▼ [Persistent Agent Runner (Cloud VM)] │ ┌────────┼────────────────────────┐ ▼ ▼ ▼ [Skills] [Workspace Context] [MCP / Tool Protocols] │ ▼ [Multi-Step Autonomous Execution \u0026amp; Background Delivery] A standard chat interface relies on ephemeral, user-initiated sessions. Spark, by contrast, runs inside dedicated, sandboxed environments on Google Cloud. This architectural shift enables three fundamental capabilities:\nContinuous Operation: Spark does not require an active browser tab or an open terminal. It runs 24/7 in the cloud, allowing it to complete tasks while your local machines are powered down.\nEvent-Driven Triggers: Instead of waiting for a manual prompt, Spark can listen to environmental signals: cron intervals, incoming emails, updated files, or specific conditions detected across the web.\nState and Context Persistence: Spark maintains a contextual layer across your Workspace apps (Gmail, Drive, Docs, Sheets, Calendar, and Tasks), referencing past interactions, workflows, and preferences to execute tasks without requiring you to re-explain context in every prompt.\nThe Core Triad: Tasks, Skills, and Schedules Under the hood, Spark\u0026rsquo;s orchestration engine is built on three core building blocks: Tasks, Skills, and Schedules.\n1. Tasks: The Execution Engine A Task is an end-to-end objective delegated to the agent. Unlike simple single-turn prompts, Spark evaluates tasks by decomposing them into a dependency graph:\nIt determines which data sources need querying.\nIt identifies required external tools and APIs.\nIt executes each step iteratively, evaluating intermediate results and adjusting its execution path if a step fails or yields incomplete data.\n2. Skills: Reusable Operational Handbooks Skills are modular, structured instruction sets that teach the agent how to perform specific classes of work.\nSystem Skills: Pre-configured workflows for interacting with core platforms, document formats, and data structures.\nUser Skills: Custom, user-defined procedural knowledge stored as Markdown definitions with structured metadata. When a task references a skill, Spark dynamically loads the specific rules, constraints, and tool definitions required to complete the job according to your exact preferences.\n3. Schedules: Autonomous Triggers Schedules turn Spark from an on-demand tool into an automated background pipeline. Spark supports multiple trigger mechanisms:\nTime-Based (Cron): Executes tasks periodically at designated local time intervals (e.g., daily briefing generation or weekly repository synchronization).\nEmail-Based: Listens for incoming messages matching specific query filters (e.g., invoices from a specific vendor, server alerts, or status reports) and triggers downstream processing.\nConditional \u0026amp; Search-Based: Periodically evaluates semantic conditions across data feeds or web signals, executing workflows only when defined criteria are met.\nReal-World Workflows: How Spark Operates in Practice To see how these components interact, consider two common power-user scenarios:\nScenario A: Asynchronous Research \u0026amp; Briefing The Trigger: You set a scheduled task to run at 6:00 AM every weekday.\nExecution: Spark spins up in the cloud, searches configured technical feeds and documentation updates, synthesizes key developments, and extracts action items.\nDelivery: It compiles the findings into a structured Google Doc, links primary sources, and prepares a concise summary delivered directly to your chat interface before you start your day.\nScenario B: Cross-App Event Triage The Trigger: An email arrives containing an updated contract or project milestone.\nContext Synthesis: Spark identifies the project, cross-references existing timelines in Google Drive, and checks Google Calendar for scheduling conflicts.\nAction: It creates corresponding action items in Google Tasks, drafts a context-aware reply for your review, and flags deadlines on your calendar.\nSecurity, Guardrails, and Execution Boundaries Allowing an AI agent to execute tasks autonomously requires strict operational boundaries:\nIsolated Execution: Actions take place in hardened cloud runtime environments, preventing cross-tenant leakage and ensuring execution integrity.\nPermission Scopes \u0026amp; Confirmation Gates: Spark operates under explicit permission boundaries. Read operations and non-destructive tasks run autonomously, while destructive or external mutating actions (such as sending emails or modifying critical shared files) can be configured to require explicit confirmation before execution.\nProtocol Standardization: By leveraging the Model Context Protocol (MCP) and standardized tool interfaces, Spark maintains structured, type-safe communication between the model and external services.\nKey Takeaway The significance of Gemini Spark is not simply that the underlying language model has become faster or more knowledgeable. The real evolution is architectural: moving from a stateless chat session to a persistent, event-driven background agent.\nWhen AI can manage its own execution loop, load specialized skills, and monitor triggers autonomously, it ceases to be just a writing assistant and becomes a reliable, background automation engine.\n","permalink":"https://robertluwang.github.io/artark-ai/posts/2026-08-17-google-spark/","summary":"\u003cp\u003eMost interactions with modern AI follow a predictable, reactive cycle: you open a chat interface, type a prompt, wait for a response, copy the output, and close the tab. The moment that session ends, the compute stops, the context clears, and the AI goes dormant until you prompt it again.\u003c/p\u003e\n\u003cp\u003eWith the launch of \u003ca href=\"https://gemini.google/overview/agent/spark/\"\u003eGemini Spark\u003c/a\u003e, Google is shifting away from the traditional conversational interface toward persistent, autonomous background execution. Instead of acting as a passive chatbot waiting for human input, Spark operates as an always-on personal agent designed to execute multi-step workflows across your digital environment.\u003c/p\u003e","title":"Inside Google Spark: How Google's 24/7 Background Agent Actually Works"},{"content":"You can write and publish blog posts from your iPhone using Obsidian and the Obsidian Git community plugin. No computer needed — write a post on the train, push to GitHub, and your Hugo site updates automatically.\nHere is the setup.\nWhat You Need iPhone with Obsidian installed (free from App Store) GitHub repo with Hugo + GitHub Actions pipeline already working GitHub Personal Access Token (fine-grained) Step 1: Generate a GitHub Token Go to https://github.com/settings/tokens Generate new token → Fine-grained token Token name: obsidian-iphone Expiration: 90 days (or longer) Repository access: Only select repositories → pick your Hugo repo Under Permissions, click \u0026quot;+ Add permissions\u0026quot; Find \u0026ldquo;Contents\u0026rdquo; → set to Read and write Metadata (Read-only) is auto-added Generate token → copy it immediately Step 2: Create a Vault Open Obsidian on iPhone → Create new vault:\nVault name: anything (e.g. my-blog) Storage: On my device (NOT iCloud — Obsidian Git needs local storage) Step 3: Install and Configure Obsidian Git Settings → Community plugins → Turn off Restricted Mode Browse → search \u0026ldquo;Git\u0026rdquo; → Install → Enable Settings → Git (under Community plugins): Authentication → Username: your GitHub username Authentication → Password/Token: paste the Personal Access Token Pull on startup: On Push on commit-and-sync: On Pull on commit-and-sync: On Leave everything else at default Step 4: Clone Your Repo Open command palette (swipe down on the editor) Run: Obsidian Git: Clone an existing remote repo Enter URL: https://github.com/yourusername/your-repo.git Leave \u0026ldquo;custom git directory path\u0026rdquo; empty Wait for clone to complete After cloning, you will see the full repo structure in Obsidian\u0026rsquo;s file browser. Your posts live in content/posts/.\nStep 5: Set Up Hugo Template Create a folder _templates/ at the vault root Create a note _templates/hugo-post (Obsidian auto-adds .md) with this content: +++ date = \u0026#39;{{date:YYYY-MM-DDTHH:mm:ssZ}}\u0026#39; draft = false title = \u0026#39;\u0026#39; tags = [] +++ Settings → Core plugins → Templates → toggle On Settings → Templates → set Template folder location: _templates Writing and Publishing Write a new post:\nOpen Obsidian → it auto-pulls the latest from GitHub Navigate to content/posts/ Create a new note (name it like 2026-08-15-my-topic) Tap the Templates icon in the ribbon (bottom-right) → select hugo-post Date fills in automatically. Type your title and start writing. Publish:\nOpen command palette (swipe down) Run: Obsidian Git: Commit-and-sync Enter a commit message Your site rebuilds via GitHub Actions and is live in about 60 seconds.\nPulling Updates from Desktop If you wrote posts on your desktop since last opening Obsidian on iPhone, the plugin auto-pulls when you open the app. You can also manually pull:\nCommand palette → Obsidian Git: Pull Troubleshooting Error Cause Fix 403 on push Token missing Contents: Read and write permission Regenerate token with correct permissions Push rejected: not a fast-forward Desktop force-pushed or history diverged Delete vault, re-clone fresh Merges with conflicts not supported Same file edited on two devices Delete vault, re-clone, re-do your edit Template shows {{date}} literally Core Templates plugin not enabled Settings → Core plugins → Templates → On Extra \u0026quot; in date field Template has stray quote Fix template: use '{{date:YYYY-MM-DDTHH:mm:ssZ}}' Recommended Git Plugin Settings If you also write posts from a desktop, the default auto-sync can cause data loss — the plugin may commit deletions of files that exist in git but not yet on your iPhone disk.\nSetting Value Why Auto commit-and-sync interval 0 (disabled) Stop blind auto-commits that delete desktop-created files Pull on startup Enabled Always get latest when you open Obsidian Merge strategy on conflicts Their changes Remote (desktop) wins if conflict Then when you finish writing a post, manually trigger \u0026ldquo;Commit and Sync\u0026rdquo; from command palette. That\u0026rsquo;s safe because:\nYou just pulled on startup (desktop posts are on disk) You\u0026rsquo;re only adding your new post The commit won\u0026rsquo;t contain deletions of files you didn\u0026rsquo;t touch The rule is simple: don\u0026rsquo;t let it auto-commit when you haven\u0026rsquo;t checked what\u0026rsquo;s on disk first.\nAlso: don\u0026rsquo;t use the iPhone GitHub app to edit files in the same repo. It creates commits that Obsidian doesn\u0026rsquo;t know about, causing merge conflicts on the next pull.\nConflict Prevention Always let Obsidian pull on open before editing Do not edit the same post on phone and desktop at the same time If conflicts happen: easiest fix is delete the vault and re-clone Limitations Limitation Workaround No Hugo preview on phone Trust your markdown, or check the live site after push Full repo visible (config, themes) Ignore them, only work in content/posts/ HTTPS only (no SSH) Use Personal Access Token Token expires Regenerate on GitHub, update in plugin settings Slow clone on large repos Only happens once — incremental pulls are fast Security Use a fine-grained token limited to your blog repo only Token is stored locally in .obsidian/ on your device Set a reasonable expiry (90 days) and rotate when it expires If you lose your phone, revoke the token immediately at GitHub → Settings → Tokens The Complete Multi-Device Pipeline iPhone (Obsidian Git) Desktop (Obsidian + publish.sh) │ │ ▼ ▼ git push (HTTPS) git push (SSH) │ │ └──────────┬─────────────────────┘ ▼ GitHub: artark-ai repo GitHub Actions → Hugo build GitHub Pages → live site Write anywhere. Push from anywhere. One pipeline builds it all.\n","permalink":"https://robertluwang.github.io/artark-ai/posts/2026-08-15-iphone-obsidian-git/","summary":"\u003cp\u003eYou can write and publish blog posts from your iPhone using Obsidian and the Obsidian Git community plugin. No computer needed — write a post on the train, push to GitHub, and your Hugo site updates automatically.\u003c/p\u003e\n\u003cp\u003eHere is the setup.\u003c/p\u003e\n\u003ch3 id=\"what-you-need\"\u003eWhat You Need\u003c/h3\u003e\n\u003cul\u003e\n\u003cli\u003eiPhone with Obsidian installed (free from App Store)\u003c/li\u003e\n\u003cli\u003eGitHub repo with Hugo + GitHub Actions pipeline already working\u003c/li\u003e\n\u003cli\u003eGitHub Personal Access Token (fine-grained)\u003c/li\u003e\n\u003c/ul\u003e\n\u003ch3 id=\"step-1-generate-a-github-token\"\u003eStep 1: Generate a GitHub Token\u003c/h3\u003e\n\u003col\u003e\n\u003cli\u003eGo to \u003ca href=\"https://github.com/settings/tokens\"\u003ehttps://github.com/settings/tokens\u003c/a\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eGenerate new token → Fine-grained token\u003c/strong\u003e\n\u003cul\u003e\n\u003cli\u003e\u003cstrong\u003eToken name:\u003c/strong\u003e \u003ccode\u003eobsidian-iphone\u003c/code\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eExpiration:\u003c/strong\u003e 90 days (or longer)\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eRepository access:\u003c/strong\u003e Only select repositories → pick your Hugo repo\u003c/li\u003e\n\u003c/ul\u003e\n\u003c/li\u003e\n\u003cli\u003eUnder \u003cstrong\u003ePermissions\u003c/strong\u003e, click \u003cstrong\u003e\u0026quot;+ Add permissions\u0026quot;\u003c/strong\u003e\n\u003cul\u003e\n\u003cli\u003eFind \u003cstrong\u003e\u0026ldquo;Contents\u0026rdquo;\u003c/strong\u003e → set to \u003cstrong\u003eRead and write\u003c/strong\u003e\u003c/li\u003e\n\u003cli\u003e\u003cstrong\u003eMetadata\u003c/strong\u003e (Read-only) is auto-added\u003c/li\u003e\n\u003c/ul\u003e\n\u003c/li\u003e\n\u003cli\u003eGenerate token → copy it immediately\u003c/li\u003e\n\u003c/ol\u003e\n\u003ch3 id=\"step-2-create-a-vault\"\u003eStep 2: Create a Vault\u003c/h3\u003e\n\u003cp\u003eOpen Obsidian on iPhone → \u003cstrong\u003eCreate new vault\u003c/strong\u003e:\u003c/p\u003e","title":"Blog from iPhone with Obsidian Git"},{"content":"Obsidian is a beautiful markdown editor. Hugo is a fast static site generator. GitHub Pages is free hosting with CI/CD. The challenge is wiring them together on Windows 11 where Obsidian runs natively but Hugo and git live in WSL.\nHere is the setup that works.\nThe Problem Obsidian is a Windows desktop app. Hugo and git run in WSL (Ubuntu). They need to read and write the same markdown files. The obvious answers all fail:\nApproach Problem Obsidian vault at \\\\wsl.localhost\\... EISDIR error — Obsidian cannot watch WSL network paths Windows junction (mklink /J) to WSL path \u0026ldquo;Local volumes required\u0026rdquo; — junctions need local drives Symlink from Hugo repo to Windows folder GitHub Actions runner does not have your Windows path — CI breaks Obsidian Git plugin pushing directly Two git clients on same remote = merge conflicts The Solution: Vault + Publish Script Keep two copies: Obsidian writes to a Windows-native folder, a one-line publish script syncs to the Hugo repo and pushes. The vault uses the same content/posts/ structure as the Hugo repo — same layout on both desktop and iPhone.\nObsidian (Win11) WSL ┌─────────────────────────────┐ ┌──────────────────────────────┐ │ C:\\Users\\you\\ │ │ ~/hugo-site/ │ │ obsidian-vaults\\ │ publish.sh │ content/posts/ │ │ my-blog\\ │ ──────────▶ │ 2026-08-17-my-post/ │ │ content/posts/ │ rsync │ index.md │ │ 2026-08-17-my-post/ │ │ banner.png │ │ index.md │ │ │ │ banner.png │ │ │ └─────────────────────────────┘ └──────────────┬───────────────┘ │ git push ▼ GitHub Actions → Pages (live) Step 1: Create the Vault Folder From WSL:\nmkdir -p /mnt/c/Users/you/obsidian-vaults/my-blog/content/posts Copy any existing posts into it:\ncp -r ~/hugo-site/content/posts/* /mnt/c/Users/you/obsidian-vaults/my-blog/content/posts/ Step 2: Open the Vault in Obsidian In Obsidian on Windows → Create new vault:\nVault name: my-blog Location: C:\\Users\\you\\obsidian-vaults You should see your content/posts/ folder in the sidebar immediately.\nStep 3: Configure Obsidian Under Settings → Files \u0026amp; Links:\nNew link format: Relative path to file Default location for new attachments: Same folder as current file Use [[Wikilinks]]: turn OFF Tip: Turning off Wikilinks makes Obsidian output ![](image.png) instead of ![[image.png]] — standard Markdown that Hugo understands. Setting attachments to \u0026ldquo;Same folder as current file\u0026rdquo; ensures images land inside the page bundle alongside index.md.\nStep 4: Create the Publish Script Save this as publish.sh in your Hugo site root:\n#!/bin/bash # publish.sh — Sync posts between Obsidian vault and Hugo repo, build, push # Usage: ./publish.sh [commit message] # # Configure this environment variable (e.g. in ~/.bashrc): # OBSIDIAN_VAULT_POSTS - path to Obsidian vault content/posts folder SCRIPT_DIR=\u0026#34;$(cd \u0026#34;$(dirname \u0026#34;$0\u0026#34;)\u0026#34; \u0026amp;\u0026amp; pwd)\u0026#34; HUGO_SITE=\u0026#34;${HUGO_SITE_DIR:-$SCRIPT_DIR}\u0026#34; HUGO_POSTS=\u0026#34;$HUGO_SITE/content/posts\u0026#34; VAULT=\u0026#34;${OBSIDIAN_VAULT_POSTS:?Set OBSIDIAN_VAULT_POSTS to your Obsidian vault content/posts folder}\u0026#34; cd \u0026#34;$HUGO_SITE\u0026#34; # Pull latest (in case iPhone pushed new posts) git pull --rebase origin main # Sync NEW files from repo → vault (posts from other devices) rsync -av --ignore-existing \u0026#34;$HUGO_POSTS/\u0026#34; \u0026#34;$VAULT/\u0026#34; # Sync VAULT → REPO (vault is the source of truth for local edits) rsync -av --delete --exclude=\u0026#39;.obsidian\u0026#39; \u0026#34;$VAULT/\u0026#34; \u0026#34;$HUGO_POSTS/\u0026#34; # Show changes git status --short # Verify build hugo --minify --quiet if [ $? -ne 0 ]; then echo \u0026#34;ERROR: Hugo build failed.\u0026#34; exit 1 fi # Commit and push MSG=\u0026#34;${1:-update blog posts}\u0026#34; git add -A if git diff --cached --quiet; then echo \u0026#34;Nothing to publish.\u0026#34; exit 0 fi git commit -m \u0026#34;$MSG\u0026#34; git push origin main echo \u0026#34;✓ Published. Site live in ~60 seconds.\u0026#34; Make it executable and set the env var:\nchmod +x publish.sh echo \u0026#39;export OBSIDIAN_VAULT_POSTS=\u0026#34;/mnt/c/Users/you/obsidian-vaults/my-blog/content/posts\u0026#34;\u0026#39; \u0026gt;\u0026gt; ~/.bashrc source ~/.bashrc Step 5: Set Up Templater for New Posts The Templater community plugin creates a complete page bundle (folder + index.md + front matter) in one action.\nSettings → Community plugins → Browse → install Templater → Enable Create a folder _templates/ at the vault root Create _templates/new-post.md: \u0026lt;%* const title = await tp.system.prompt(\u0026#34;Post title\u0026#34;); if (!title) return; const tags = await tp.system.prompt(\u0026#34;Tags (comma-separated, or leave empty)\u0026#34;); const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, \u0026#39;-\u0026#39;).replace(/^-|-$/g, \u0026#39;\u0026#39;); const date = tp.date.now(\u0026#34;YYYY-MM-DD\u0026#34;); const datetime = tp.date.now(\u0026#34;YYYY-MM-DDTHH:mm:ssZ\u0026#34;); const folder = `content/posts/${date}-${slug}`; // Format tags let tagLine = \u0026#34;tags = []\u0026#34;; if (tags \u0026amp;\u0026amp; tags.trim()) { const tagArray = tags.split(\u0026#39;,\u0026#39;).map(t =\u0026gt; `\u0026#39;${t.trim()}\u0026#39;`).join(\u0026#39;, \u0026#39;); tagLine = `tags = [${tagArray}]`; } const content = `+++ date = \u0026#39;${datetime}\u0026#39; draft = false title = \u0026#34;${title}\u0026#34; ${tagLine} [params.cover] image = \u0026#34;banner.jpg\u0026#34; alt = \u0026#34;${title}\u0026#34; relative = true +++ `; await app.vault.createFolder(folder); await app.vault.create(`${folder}/index.md`, content); await app.workspace.openLinkText(`${folder}/index.md`, \u0026#34;\u0026#34;); // Remove the temporary note that triggered this template if (tp.file.title !== \u0026#34;index\u0026#34;) { await app.vault.trash(tp.file); } %\u0026gt; Settings → Templater → set Template folder location: _templates Usage: Create any new note → run Templater → select new-post. It prompts for title and tags, creates the page bundle folder with index.md, and opens it for editing. Drop a banner.png into the same folder for the cover image.\nImportant: The title uses double quotes (title = \u0026quot;...\u0026quot;) in the TOML front matter. Single quotes break on apostrophes (e.g. Google's would terminate the string early).\nYou can also keep the core Templates plugin with a simple hugo-post template for quick notes on iPhone (where Templater isn\u0026rsquo;t available):\n+++ date = \u0026#39;{{date:YYYY-MM-DDTHH:mm:ssZ}}\u0026#39; draft = false title = \u0026#34;\u0026#34; tags = [] +++ Daily Workflow Write — Create any new note → run Templater → new-post → fills title, tags, creates page bundle:\ncontent/posts/2026-08-17-my-post/ ├── index.md ← front matter + content └── banner.png ← cover image (drop in manually) Preview — In WSL:\nrsync -av /mnt/c/Users/you/obsidian-vaults/my-blog/content/posts/ ~/hugo-site/content/posts/ hugo server -D Publish — When satisfied:\n./publish.sh \u0026#34;add post: my post title\u0026#34; That is the entire process. Write in Obsidian, run one command, site is live.\nWhy Not a Symlink? It seems elegant — symlink content/posts/ to the Windows vault folder and everything is one source of truth. It works locally. Hugo builds through it. But git stores the symlink target path literally:\ncontent/posts -\u0026gt; /mnt/c/Users/you/obsidian-vaults/my-blog/content/posts When GitHub Actions checks out the repo, that path does not exist on the Ubuntu runner. The build fails with missing content. Real files in the repo are the only way CI/CD works.\nThe rsync approach costs one extra command but keeps the pipeline reliable.\nWhy Not Obsidian Git Plugin on Desktop? The Obsidian Git community plugin can auto-commit and push on a timer. Sounds perfect for desktop, but:\nYou lose the Hugo build verification step — a broken front matter goes straight to production Two git clients (Obsidian on Windows + terminal on WSL) hitting the same remote causes conflicts Auto-commits every 5 minutes create noisy history Keeping git in WSL gives you control: preview before publish, meaningful commit messages, and one source of authority.\nNote: Obsidian Git works great on iPhone where WSL isn\u0026rsquo;t available — it pushes directly to GitHub via HTTPS + Personal Access Token. See the companion post on iPhone setup.\nThe Full Stack Obsidian (Win11 desktop) — write markdown (Templater creates page bundles) rsync (WSL) — sync vault content/posts/ to Hugo repo Hugo (WSL) — build static HTML Git (WSL) — push to GitHub GitHub Actions (cloud) — build + deploy GitHub Pages (cloud) — serve the site No database. No CMS login. No block editor. No WordPress plugins. Just markdown files, a terminal, and a one-line publish command.\n","permalink":"https://robertluwang.github.io/artark-ai/posts/2026-08-15-obsidian-hugo-pipeline/","summary":"\u003cp\u003eObsidian is a beautiful markdown editor. Hugo is a fast static site generator. GitHub Pages is free hosting with CI/CD. The challenge is wiring them together on Windows 11 where Obsidian runs natively but Hugo and git live in WSL.\u003c/p\u003e\n\u003cp\u003eHere is the setup that works.\u003c/p\u003e\n\u003ch3 id=\"the-problem\"\u003eThe Problem\u003c/h3\u003e\n\u003cp\u003eObsidian is a Windows desktop app. Hugo and git run in WSL (Ubuntu). They need to read and write the same markdown files. The obvious answers all fail:\u003c/p\u003e","title":"Obsidian + Hugo + GitHub Pages on Windows 11"},{"content":"I got tired of WordPress. Not because it crashed or ran slow — it worked fine — but because every time I sat down to write, I was fighting the editor instead of writing. The block system turns a simple paragraph into a drag-and-drop puzzle. I wanted a blogging pipeline where I write a markdown file, push it to git, and the post goes live. That is it. No block picker, no sidebar toggles, no plugin updates, no database.\nHugo gives you exactly that. You write a .md file, commit, push, and your CI/CD pipeline builds and publishes the site automatically. The entire workflow lives in your terminal and text editor — the same tools you already use for code. No browser tab open to a CMS, no context switching.\nHere is the complete blueprint to set up a clean, zero-maintenance Hugo tech blog from scratch.\nStep 1: Get the Standalone Binary On Linux under WSL, standard package managers pull in a massive chain of Go dependencies. Skip that. Grab the prebuilt binary directly from the official GitHub releases page.\nExtract the tarball, and drop the single executable into your workspace. Verify it by running:\n./hugo version You will see output confirming the version and environment. No runtime overhead, no background services.\nStep 2: Initialize the Site Navigate to your workspace terminal and create a new site structure:\n./hugo new site my-tech-blog cd my-tech-blog git init Step 3: Add a Minimal Theme A tech blog needs a clean layout. PaperMod is fast, minimal, and stays out of your way. Add it as a git submodule:\ngit submodule add https://github.com/adityatelange/hugo-PaperMod.git themes/papermod Next, open or create your hugo.toml file in the root directory and paste the configuration (update the baseURL depending on which platform you choose to deploy to):\nbaseURL = \u0026#39;https://yourusername.github.io/your-repo/\u0026#39; defaultContentLanguage = \u0026#39;en\u0026#39; title = \u0026#39;My Tech Blog\u0026#39; theme = \u0026#39;papermod\u0026#39; [params] env = \u0026#34;production\u0026#34; title = \u0026#34;My Tech Blog\u0026#34; description = \u0026#34;Minimal tech notes and code snippets\u0026#34; author = \u0026#34;Me\u0026#34; showReadingTime = true showShareButtons = true showPostNavLinks = true [outputs] home = [ \u0026#34;HTML\u0026#34;, \u0026#34;RSS\u0026#34;, \u0026#34;JSON\u0026#34;] Note: Hugo v0.158+ deprecated languageCode. Use defaultContentLanguage instead.\nStep 4: Write Content \u0026amp; Choose Your Image Handling Strategy As you accumulate technical notes, organizing your posts and handling images properly matters. In Hugo, you can structure your posts using one of two approaches: Page Bundles or Single Markdown Files.\nOption A: Page Bundles (Recommended) A page bundle keeps your markdown file and all its corresponding images bundled together inside a dedicated folder.\nUse Hugo\u0026rsquo;s built-in command to generate your post bundle: ../hugo new content/posts/2026/08/10/my-new-post/index.md Drop your images (like diagram.png) directly into that same folder. Reference them using a clean, portable relative path inside your index.md: ![Architecture Diagram](diagram.png) Option B: Single Markdown Files + Static Folder If you prefer a flat structure where each post is just a single .md file, you must place your images in the global static/ directory.\nPlace your image in the static folder: mkdir -p static/images/ mv banner.png static/images/ Reference the image using Hugo\u0026rsquo;s relURL function in an HTML tag so it correctly respects repository subpaths: \u0026lt;img src=\u0026#34;{{ \u0026#34;images/banner.png\u0026#34; | relURL }}\u0026#34; alt=\u0026#34;banner\u0026#34;\u0026gt; (Note: Ensure you remove any duplicate # Title headings from your markdown body text, as Hugo automatically renders the title from your front matter metadata).\nStep 5: Publish Your Post Hugo\u0026rsquo;s hugo new command creates posts with draft = true in the front matter by default. Draft posts are not included in production builds. Before deploying, make sure your post\u0026rsquo;s front matter has:\ndraft = false To test locally including drafts, run:\n../hugo server -D The -D flag renders drafts for local preview only. Your CI/CD pipeline runs hugo --minify without -D, so any post still marked draft = true will be invisible on the live site.\nOpen http://localhost:1313/ in your browser. The server watches for changes in real time. When you save a markdown file, the page updates instantly.\nStep 6: Configure .gitignore To ensure you only push your source files while excluding local caches and generated HTML outputs, create a .gitignore file in your root directory containing:\n/public/ /resources/ .hugo_build.lock Step 7: Choose Your Hosting Platform \u0026amp; Automate via CI/CD You do not need to compile HTML locally and push built files to git. Let the cloud platform handle the build on every push. Choose your preferred CI/CD setup below.\nOption A: GitHub Actions Create a workflow file at .github/workflows/hugo.yml:\nname: Deploy Hugo site to GitHub Pages on: push: branches: - main permissions: contents: write pages: write id-token: write concurrency: group: \u0026#34;pages\u0026#34; cancel-in-progress: false jobs: build: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: submodules: recursive - name: Setup Hugo uses: peaceiris/actions-hugo@v3 with: hugo-version: \u0026#39;latest\u0026#39; extended: true - name: Build run: hugo --minify --baseURL \u0026#34;https://yourusername.github.io/your-repo/\u0026#34; - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: path: ./public deploy: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest needs: build steps: - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 Important: In your GitHub repo settings, go to Settings → Pages and change the Source dropdown to \u0026ldquo;GitHub Actions\u0026rdquo;. It saves automatically when you select it — there is no Save button.\nOption B: GitLab CI/CD Create a pipeline file at .gitlab-ci.yml in your root directory:\ndefault: image: alpine:latest stages: - build - deploy pages: stage: deploy script: - apk add --no-cache hugo git - hugo --minify --baseURL \u0026#34;https://yourusername.gitlab.io/your-repo\u0026#34; artifacts: paths: - public rules: - if: \u0026#39;$CI_COMMIT_BRANCH == \u0026#34;main\u0026#34;\u0026#39; Final Step: Push to Repository A common pitfall when connecting a local Hugo project to a new GitHub repo is ending up with diverged histories. This happens when you create the GitHub repo with a README or license (which creates an initial commit on the remote), then separately run git init locally and commit. The two histories are unrelated and git refuses to push.\nRecommended approach — empty remote (cleanest):\nWhen creating the repo on GitHub, uncheck \u0026ldquo;Add a README file\u0026rdquo;, set .gitignore to \u0026ldquo;None\u0026rdquo;, and License to \u0026ldquo;None\u0026rdquo;. GitHub will show the \u0026ldquo;push an existing repository\u0026rdquo; instructions, confirming the remote has zero commits. Then locally:\nhugo new site tech-blog cd tech-blog git init git add . git commit -m \u0026#34;Initial Hugo setup\u0026#34; git remote add origin git@github.com:yourusername/your-repo.git git branch -M main git push -u origin main No rebase needed, no unrelated histories, no conflicts.\nAlternative — if you initialized the remote with a README/license:\nIf the GitHub repo already has commits (README, LICENSE, etc.), adopt the remote history before committing your files:\nhugo new site tech-blog cd tech-blog git init git remote add origin git@github.com:yourusername/your-repo.git git fetch origin git reset --mixed origin/main git add . git commit -m \u0026#34;Initial Hugo setup\u0026#34; git branch -M main git push -u origin main This grafts your local files onto the remote\u0026rsquo;s existing commit cleanly.\nIf you already pushed and got rejected with non-fast-forward, fix it with:\ngit pull origin main --rebase --allow-unrelated-histories git push origin main For GitLab, the same principles apply — just swap the remote URL:\ngit remote add origin git@gitlab.com:yourusername/your-repo.git git push -u origin main Enable Pages in your repository settings (on GitHub, set the source to GitHub Actions; on GitLab, ensure project visibility is Public). From then on, every push automatically triggers a cloud build and updates your live site. Moving away from heavy CMS platforms means your writing process finally becomes just writing.\n","permalink":"https://robertluwang.github.io/artark-ai/posts/2026-08-08-hugo-blog/","summary":"\u003cp\u003eI got tired of WordPress. Not because it crashed or ran slow — it worked fine — but because every time I sat down to write, I was fighting the editor instead of writing. The block system turns a simple paragraph into a drag-and-drop puzzle. I wanted a blogging pipeline where I write a markdown file, push it to git, and the post goes live. That is it. No block picker, no sidebar toggles, no plugin updates, no database.\u003c/p\u003e","title":"Hugo Blog Pipeline"}]