From 2003db448ef953a93c56e77fbc2e7bba359fffa3 Mon Sep 17 00:00:00 2001 From: Terence Chan Zun Mun Date: Wed, 6 May 2026 13:44:34 +0200 Subject: [PATCH 01/13] docs(specs): add targeted related-posts design Replaces the bottom-of-article widget's "any shared tag" matching with Typesense semantic ranking on title+excerpt, with the existing tag-matched SSR rendering serving as both the no-JS fallback AND the topup pool when Typesense returns < 3 hits. Cross-collection scope. 3-card layout preserved. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...026-05-06-targeted-related-posts-design.md | 228 ++++++++++++++++++ 1 file changed, 228 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-06-targeted-related-posts-design.md diff --git a/docs/superpowers/specs/2026-05-06-targeted-related-posts-design.md b/docs/superpowers/specs/2026-05-06-targeted-related-posts-design.md new file mode 100644 index 00000000..8075a7da --- /dev/null +++ b/docs/superpowers/specs/2026-05-06-targeted-related-posts-design.md @@ -0,0 +1,228 @@ +# Targeted Related Posts — Design + +**Date:** 2026-05-06 +**Status:** Drafted, awaiting user review +**Branch:** `suggested-articles-improvements` (branched from `main`) + +## Goal + +Replace the bottom-of-article "You might also like..." widget's current "any shared tag" matching with content-relevance ranking from Typesense. Result: an interview about venture capital surfaces other VC content (events, posts, interviews) instead of "3 random recent interviews" — the current behavior whenever the post's only shared tags are routing tags like `hash-conversations`. + +## Non-goals + +- Vector / embedding-based search (Typesense supports it but requires indexed vectors; the existing collection doesn't include them). +- Live updates as the user scrolls or interacts with the article. +- A/B testing alternate ranking weights. +- Click tracking on related cards (analytics — separate concern). +- "Similar by author" or "Similar by recency" — pure topic relevance only. +- Increasing the number of related cards beyond 3 (the existing grid is `lg:grid-cols-3`). +- Renaming `partials/related.hbs` (keep current path for git history). +- Maintaining the Typesense → Ghost sync (out of scope; assumed operational). +- Server-side stripping of `hash-*` routing tags from the SSR baseline filter (Ghost's template syntax doesn't compose strings cleanly enough; see Decisions table). + +## Decisions and rationale + +| Decision | Choice | Rationale | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Primary ranker | Typesense semantic similarity | Same Typesense instance as the search feature on the other branch. Content-based ranking is exactly "more targeted." No new infrastructure. | +| Fallback | Today's tag-match SSR (`tags:[{{post.tags}}]+id:-{{post.id}}`) used as a topup pool | When Typesense returns < 3 hits or fails, the tag-matched cards already in DOM fill the gap. Same query path, single source of truth, doubles as no-JS baseline. | +| Card count | 3 | Matches existing `lg:grid-cols-3` layout. No row breakage. | +| Topup behavior | Render Typesense hits in order; top up with SSR cards skipped by Typesense (deduplicated by slug) until count = 3 | Always show 3 cards if any related content exists at all. Mixing sources is invisible — the "you might also like" label doesn't claim a specific algorithm. | +| Collection scope | Cross-collection (no Typesense `filter_by` on routing tags) | Matches existing widget behavior. "More targeted" is about content relevance, not file-cabinet purity. A VC event is genuinely a great suggestion on a VC interview. | +| Query construction | `q = "\n<excerpt>"` of current post | Title alone is too narrow. Full body produces noisy BM25 ranking and inflates URL length. Excerpt is the post's hand-curated summary — the right signal density. | +| Architecture | SSR baseline (existing `{{#get}}`) renders 3 tag-matched cards always; JS upgrades to Typesense ranking on init | Progressive enhancement. No-JS users get the existing widget. JS users get the upgrade. Single rendering path through `partials/related.hbs`. | +| SSR fallback uses ALL tags | Yes — including routing `hash-*` tags | Ghost's template syntax doesn't expose a clean way to compose `tags:[…]` from a filtered subset of `{{post.tags}}` (no `concat` helper, no string-builder for filter expressions). The current widget's behavior is "good enough as a fallback" because Typesense provides the actual targeting; the SSR is just the safety net. | +| Card builder | JS template literal in `assets/js/related-posts.js` mirroring `partials/post-card.hbs` shape | Allows cross-collection Typesense hits not in the SSR pool. Maintenance cost: comments in both files cross-reference each other. | +| AbortController | Optional but used | Not strictly needed (single fetch per page load), but consistent with the search wrapper pattern. Allows future "fire on filter change" without refactor. | +| Failure UX | Silent — SSR cards stay; console warning only | This is "you might also like," not core functionality. A red banner here would be UX overkill. | +| Equal-set short-circuit | If Typesense's top-3 slugs == SSR's slugs (any order), skip DOM swap | Avoids visible re-layout flash for the common case where the SSR fallback was already correct. | +| `@custom.show_related_posts` | Existing toggle preserved — `{{#if @custom.show_related_posts}}{{> related}}{{/if}}` in `post.hbs` remains the gate | Honors existing admin preference. Off → no widget, no JS, no Typesense call. | +| Empty-SSR case | Always render the JS-enhanceable wrapper (even if SSR `{{#get}}` returns 0 cards) so JS can populate from Typesense | Without this, the rare post with no shared-tag matches would never get JS-enhanced even if Typesense has cross-collection hits. | +| Query length cap | `q.slice(0, 500)` | Keeps URL under the 8KB practical browser URL cap with all other Typesense params. | + +## Architecture + +``` +post.hbs (article page) + ↓ +{{#if @custom.show_related_posts}}{{> related}}{{/if}} + ↓ +partials/related.hbs + │ + └── <section class="related-wrapper" + x-data="relatedPosts" + data-post-id="{{post.id}}" + data-post-title="{{post.title}}" + data-post-excerpt="{{post.excerpt}}"> + <h3>You might also like…</h3> + <div class="related-feed grid grid-cols-1 lg:grid-cols-3 ..."> + {{!-- SSR baseline: same {{#get}} block as today, always rendered. + Cards are the no-JS fallback AND the JS-side topup pool. --}} + {{#get "posts" limit="3" filter="tags:[{{post.tags}}]+id:-{{post.id}}" + include="tags" as |related|}} + {{#foreach related}} + {{> "post-card"}} + {{/foreach}} + {{/get}} + </div> + </section> + + ↓ Alpine init() in relatedPosts component + +assets/js/related-posts.js + │ + ├── Read source from $el.dataset (postId, postTitle, postExcerpt) + ├── Read SSR cards from .related-feed [data-slug] + ├── Call searchSimilar(`${title}\n${excerpt}`.slice(0,500), { excludeId, limit: 3 }) + │ │ + │ └── delegated to assets/js/typesense-search.js + │ + ├── If response empty or fetch fails → no DOM mutation + ├── If response slugs equal SSR slugs (any order) → no DOM mutation + └── Otherwise → build hits as cards (or reuse SSR DOM where slugs match), + top up from SSR cards to reach 3, replace grid contents + +assets/js/typesense-search.js + │ + ├── existing `searchSlugs(query, tagSlugs, signal)` — unchanged + └── new `searchSimilar(query, { excludeId, limit, signal })` — returns + full hit objects (slug, title, excerpt, feature_image, url, tags, + published_at) for client-side card rendering +``` + +## Files + +| Path | Action | Purpose | +| ------------------------------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `partials/related.hbs` | Modify | Wrap existing content in `<section x-data="relatedPosts" data-post-id=… data-post-title=… data-post-excerpt=…>`. Always render the wrapper (move the `{{#if related}}` gate inside the foreach so the wrapper renders even with 0 SSR results). Source data goes on `data-*` attributes (Handlebars escaping handles HTML attribute encoding cleanly; a `<script type="application/json">` block would need custom JSON-safe escaping). | +| `partials/post-card.hbs` | Modify (one line) | Add `data-slug="{{slug}}"` to `<article>` so JS can match SSR cards to Typesense hits by slug. (Same change as Feature 1; will collide trivially on merge.) | +| `assets/js/related-posts.js` | **New** | Alpine component: orchestrate fetch, dedupe, swap. ~80 lines. | +| `assets/js/typesense-search.js` | **New** (or extend if Feature 1 is merged first) | `searchSlugs` + `searchSimilar`. Three constants (host, key, collection) at top. Read from `window.__TYPESENSE_CONFIG__` with same defaults as fallback. | +| `assets/js/main.js` | Modify | `import relatedPosts from './related-posts.js'; Alpine.data('relatedPosts', relatedPosts);` before `Alpine.start()`. | +| `default.hbs` | Modify | Add `<script>window.__TYPESENSE_CONFIG__ = {...}</script>` injecting `{{@custom.typesense_*}}`. (Same block as Feature 1; will collide trivially on merge.) | +| `package.json` | Modify | Add `config.custom.typesense_host`, `typesense_api_key`, `typesense_collection`. (Same as Feature 1; will collide trivially on merge.) | +| `README.md` | Modify | Add the Typesense Search section. (Same as Feature 1; trivial conflict.) | + +**Untouched:** + +- `post.hbs` — the `{{#if @custom.show_related_posts}}{{> related}}{{/if}}` block already does the right thing. +- `assets/css/` — Tailwind utilities only, applied inline in the JS card builder. +- `partials/post-filter-list.hbs` and any other listing partial — orthogonal. + +## Data flow + +State (per Alpine instance, one per article view): + +``` +source : { id: string, title: string, excerpt: string } // from $el.dataset +ssrCards : Array<{ slug: string, el: HTMLElement }> // from SSR DOM +typesenseHits : Array<{ slug, title, excerpt, feature_image, url, primary_tag, published_at }> | null + // null = fetch in progress or not started +``` + +Pipeline (one shot, on init): + +``` +init() + ↓ source = { id: $el.dataset.postId, title: $el.dataset.postTitle, excerpt: $el.dataset.postExcerpt } + ↓ ssrCards = grid.querySelectorAll('.feed-card[data-slug]') + ↓ if (!source.title && !source.excerpt) return // nothing to query with + ↓ + fetch Typesense with q = (title + "\n" + excerpt).slice(0, 500) + filter_by = id:!=<source.id> + include_fields = slug,title,excerpt,feature_image,url,tags,published_at + per_page = 3 + ↓ + if fetch fails OR returns 0 hits: + return // SSR cards stay + ↓ + if hits.slugs.sort() == ssrCards.slugs.sort(): + return // identical set, skip DOM swap (no flash) + ↓ + finalCards = [] + for hit in hits: + ssr = ssrCards.find(c => c.slug === hit.slug) + finalCards.push(ssr ? ssr.el : buildCardElement(hit)) + for ssr in ssrCards: + if finalCards.length >= 3: break + if !finalCards.includes(ssr.el): finalCards.push(ssr.el) + ↓ + grid.replaceChildren(...finalCards) +``` + +## Card-building contract + +`buildCardElement(hit)` produces DOM byte-equivalent to what `partials/post-card.hbs` emits, modulo: + +- No `data-tags` / `data-tag-names` (those are filter-page concerns; `related-posts.js` doesn't use them). +- The `data-slug="{{slug}}"` IS preserved. +- `data-title` and `data-published-at` from Feature 1 are NOT added (filter-page only). + +Required Typesense fields per hit: + +- `slug` (string) — DOM identity / dedup +- `title` (string) — heading +- `excerpt` (string) — body copy (line-clamped to 4) +- `feature_image` (string | null) — `<img>` src; if null, the `<img>` is omitted +- `url` (string) — anchor href +- `tags` (array of objects with `name`, `slug`, `url`) — first one becomes the primary-tag chip +- `published_at` (number, Unix ms) — formatted client-side as `D MMM YYYY` to match `{{date format="D MMM YYYY"}}` + +Verified against schema probe done earlier (collection `ghost`, 283 documents): all fields present. + +**Cross-file maintenance:** comments in both `partials/post-card.hbs` and `assets/js/related-posts.js` point at each other. If either changes structure, the comment is the breadcrumb. + +## Failure modes and edge cases + +| Case | Behavior | +| ---------------------------------------------------- | --------------------------------------------------------------------------- | +| Typesense unreachable / 5xx / malformed JSON | Silent. SSR cards stay. `console.warn` for ops debugging. | +| AbortError (in-flight aborted by navigation) | Silent. No DOM mutation. | +| Typesense returns 0 hits | SSR cards stay. | +| Typesense returns 1-2 hits | Render those, top up from SSR cards skipping duplicates. | +| Typesense returns the current post (despite filter) | Defensive client-side filter: drop hits where `slug === source.slug`. | +| Top-3 Typesense slugs == top-3 SSR slugs (any order) | No DOM mutation. Avoids re-layout flash. | +| Empty SSR baseline (post has no tag overlap) | Wrapper still rendered; JS populates if Typesense succeeds. | +| Post with no excerpt | Query is just title. Acceptable. | +| Long title + long excerpt | Query capped at 500 chars (`q.slice(0, 500)`). | +| `@custom.show_related_posts` = false | `post.hbs` doesn't include the partial. Zero work happens. | +| Missing `window.__TYPESENSE_CONFIG__` | Wrapper falls back to in-file defaults. Same defaults as Feature 1. | +| `feature_image` empty in Typesense response | `<img>` element omitted (mirrors `{{#if feature_image}}` in post-card.hbs). | +| `tags` array empty | Primary tag span shows "[No tag]" (mirrors `{{else}}` in post-card.hbs). | + +## Network traffic + +- **Per page load on a post:** one Typesense request (~500 bytes out, ~1-3 KB in for 3 hits with full fields). Plus one CORS preflight on first request from each origin. Total ~5 KB per article view. +- **Per page load on a non-post (homepage, listings):** zero — `partials/related.hbs` only renders inside `{{#is "post"}}` in `post.hbs`. +- Fits well within the spec acceptable budget; comparable to or lighter than the SSR widget's existing Ghost server-side `{{#get}}` cost (which it does not eliminate, but does add to). + +## Accessibility + +- Existing `<h3>` heading preserved. +- `<article>` with anchor inside — same keyboard-navigable structure as today. +- No live region, no ARIA gymnastics — the section content is determined at page load and remains stable. +- DOM swap on Typesense response happens before screen readers typically reach the related section, so re-announcement is rare. If it occurs, the heading is unchanged so context is preserved. +- No focus management needed — user has not interacted with this section yet by the time JS runs. + +## Testing and verification + +No automated tests (theme has no test framework). Manual verification matrix: + +1. Open any post with `@custom.show_related_posts` = true. Related section appears with 3 cards. +2. View page source: SSR cards present in HTML (no-JS baseline works). +3. Network panel: one Typesense request fires per page load. +4. Compare SSR cards (visible briefly) to final cards. If different, the swap should happen quickly and only once. +5. Pick a post with very specific topic (e.g., a VC interview). Related cards should now lean toward other VC content even from different collections. +6. Block `typesense.advisory.sg` in DevTools → reload. SSR cards stay, console warning logged, no error banner. +7. Disable JavaScript → reload. SSR cards still render; no JS-driven swap. +8. Toggle `@custom.show_related_posts` to false in Ghost Admin → reload post. Related section absent. +9. Test on a post where SSR returns 0 cards (rare). Related section present (empty wrapper); JS populates if Typesense has cross-collection hits. +10. Mobile viewport — cards stack into a single column (existing grid behavior, preserved). + +## Future work (not part of this spec) + +- Vector embeddings + Typesense vector search for true semantic similarity (requires an indexer change). +- Click-tracking analytics to measure whether targeted cards lift engagement. +- Personalization (different related cards per reader based on history). +- "More like this" pagination (load 3 more from Typesense on user request). +- Server-side tag stripping if Ghost's template syntax ever gains a string-builder helper. From a866791058fccbba7d326b77dfce67d87149215b Mon Sep 17 00:00:00 2001 From: Terence Chan Zun Mun <zunmun@gmail.com> Date: Wed, 6 May 2026 14:01:29 +0200 Subject: [PATCH 02/13] docs(plans): add implementation plan for targeted related-posts 8 tasks: Typesense plumbing (config + wrapper), post-card data-slug, Alpine component with card builder, partial wrapper changes, main.js registration, README, end-to-end manual verification. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .../2026-05-06-targeted-related-posts.md | 819 ++++++++++++++++++ 1 file changed, 819 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-06-targeted-related-posts.md diff --git a/docs/superpowers/plans/2026-05-06-targeted-related-posts.md b/docs/superpowers/plans/2026-05-06-targeted-related-posts.md new file mode 100644 index 00000000..28de39f9 --- /dev/null +++ b/docs/superpowers/plans/2026-05-06-targeted-related-posts.md @@ -0,0 +1,819 @@ +# Targeted Related Posts Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the bottom-of-article "You might also like…" widget's "any shared tag" matching with content-relevance ranking from Typesense, while keeping today's tag-match SSR rendering as both the no-JS fallback and a topup pool when Typesense returns < 3 hits. + +**Architecture:** Progressive enhancement on top of the existing `partials/related.hbs`. The current SSR `{{#get}}` block stays — those 3 cards land in the DOM at first paint and are the no-JS baseline. An Alpine.js component on the wrapper reads the current post's title + excerpt from `data-*` attributes, fires one Typesense REST call (`searchSimilar`), and on response either swaps in Typesense-ranked cards or tops up the SSR cards with hits not already present. Cross-collection scope. 3-card layout preserved. + +**Tech Stack:** Ghost theme (Handlebars partials), Alpine.js (already a dep), Tailwind CSS (already a dep), webpack via gulp (already configured). New external: Typesense REST endpoint at `typesense.advisory.sg`. New deps: none. + +**Spec:** `docs/superpowers/specs/2026-05-06-targeted-related-posts-design.md` + +## Working environment + +- Local Ghost instance at `http://localhost:2368` with this theme installed. +- Build: `npx gulp build` (compiles HBS, CSS, JS into `assets/built/`). +- Watch mode for active dev: `npm run dev` (build + livereload + watch). Leave it running in a separate terminal. +- Lint: `npm run test` (`gscan .`). Pre-existing baseline: 1 error (`page.hbs` page features) + 1 warning (`card_assets`). Tolerate those, flag any NEW errors. +- Pre-commit hook (husky + pretty-quick) runs Prettier on staged files. Don't fight it — let it reformat. +- All tasks commit to the current branch (`suggested-articles-improvements`). +- Typesense schema verified live on 2026-05-06: collection `ghost`, 283 documents. Indexed fields include `id`, `title`, `slug`, `excerpt`, `plaintext`, `feature_image`, `url`, `tags` (array of names), `tags.name`, `tags.slug`, `published_at` (Unix ms). + +## File map + +**Created:** + +- `assets/js/typesense-search.js` — wrapper exporting `searchSimilar(query, options): Promise<Hit[]>`. ~60 lines. +- `assets/js/related-posts.js` — Alpine component: orchestrate fetch, dedupe, swap, build cards. ~140 lines. + +**Modified:** + +- `partials/related.hbs` — wrap existing content in `<section x-data="relatedPosts" data-post-id=… data-post-title=… data-post-excerpt=…>`. Always render the wrapper (move the `{{#if related}}` gate so it only gates the inner `{{#foreach}}`). +- `partials/post-card.hbs` — add `data-slug="{{slug}}"` to the root `<article>` element. +- `assets/js/main.js` — `import relatedPosts from './related-posts.js';` and `Alpine.data('relatedPosts', relatedPosts);` before `Alpine.start()`. +- `default.hbs` — add `<script>window.__TYPESENSE_CONFIG__ = {...}</script>` block that injects `{{@custom.typesense_*}}`. +- `package.json` — add three `config.custom` fields: `typesense_host`, `typesense_api_key`, `typesense_collection`. +- `README.md` — add a "Typesense Search" section explaining the three settings. + +**Untouched:** + +- `post.hbs` — already has `{{#if @custom.show_related_posts}}{{> related}}{{/if}}` doing the right gating. +- `config/routes.yaml`, `tag.hbs`, listings — orthogonal. +- CSS files — Tailwind utilities only, applied inline in the JS card builder. + +**Note on cross-branch overlap:** `default.hbs`, `package.json`, `README.md`, and `assets/js/typesense-search.js` are also touched by the `filter-addition` branch (Feature 1). When the branches merge, expect trivial conflicts on these files — content is byte-identical or strictly additive. + +--- + +## Task 1: Typesense config plumbing — package.json + default.hbs + +**Files:** + +- Modify: `package.json` (`config.custom` block) +- Modify: `default.hbs` (head section) + +Three settings live under `config.custom` in `package.json` and become admin-overridable in **Ghost Admin → Design → Customize**. `default.hbs` injects the resolved values into a `window.__TYPESENSE_CONFIG__` global so the JS wrapper can read them at runtime. Defaults match the production Advisory SG instance. + +- [ ] **Step 1: Add three custom-config fields to `package.json`** + +Find the `config.custom` block. After the existing `show_related_posts` field (which ends with `"group": "post"`), insert three new fields: + +```json + "show_related_posts": { + "type": "boolean", + "default": true, + "group": "post" + }, + "typesense_host": { + "type": "text", + "default": "https://typesense.advisory.sg" + }, + "typesense_api_key": { + "type": "text", + "default": "LWQ1uyADTZBRVJa0XuFY5BcipgnhvQ8g" + }, + "typesense_collection": { + "type": "text", + "default": "ghost" + } +``` + +The three fields are intentionally ungrouped — Ghost recognises a fixed set of group names (`homepage`, `post`) and gscan flags unknown groups as a recommendation. The fields will appear at the bottom of the Customize panel without a group header. + +- [ ] **Step 2: Add the inline config script to `default.hbs`** + +Find the existing `<script>` block in `<head>` that sets `var siteUrl`. Immediately AFTER that closing `</script>` and BEFORE `{{ghost_head}}`, insert: + +```handlebars +{{! Typesense search config — sourced from package.json config.custom, + overridable per-install in Ghost Admin → Design → Customize. + The API key here is search-only (read-only, public-scoped). }} +<script> + window.__TYPESENSE_CONFIG__ = { host: '{{@custom.typesense_host}}', apiKey: + '{{@custom.typesense_api_key}}', collection: '{{@custom.typesense_collection}}', + }; +</script> +``` + +- [ ] **Step 3: Build and lint** + +``` +npx gulp build +npm run test +``` + +Both should complete with no NEW errors. The pre-existing baseline (1 error in `page.hbs`, 1 warning in `package.json` about `card_assets`) is acceptable. + +- [ ] **Step 4: Commit** + +``` +git add package.json default.hbs +git commit -m "feat(config): add Typesense config to package.json + default.hbs" +``` + +--- + +## Task 2: Create `assets/js/typesense-search.js` wrapper + +**Files:** + +- Create: `assets/js/typesense-search.js` + +Self-contained ES module exporting one async function `searchSimilar(query, options)`. The function reads its host / API key / collection from `window.__TYPESENSE_CONFIG__` (set in Task 1) at search time, with the same defaults baked in as fallback for partial-page-render contexts. + +The function returns rich hit data (title, excerpt, slug, feature_image, url, tags.name, tags.slug, published_at) — different from the search-bar variant on the other branch which only needs slugs. When that branch's `searchSlugs` function lands here on merge, it'll be a sibling export. + +- [ ] **Step 1: Create `assets/js/typesense-search.js`** + +```javascript +// Wrapper around Typesense's search REST API. +// +// Configuration source of truth is `package.json` under `config.custom.typesense_*`, +// admin-overridable in Ghost → Design → Customize. `default.hbs` injects the +// resolved values into `window.__TYPESENSE_CONFIG__` before this bundle loads. +// +// The API key is a *search-only* key — Typesense's analogue of Ghost's Content +// API key. It is read-only and scoped to the configured collection. Embedding +// it client-side is intentional and safe (Typesense convention). + +const DEFAULTS = { + host: "https://typesense.advisory.sg", + apiKey: "LWQ1uyADTZBRVJa0XuFY5BcipgnhvQ8g", + collection: "ghost", +}; + +function getConfig() { + const cfg = + (typeof window !== "undefined" && window.__TYPESENSE_CONFIG__) || {}; + return { + host: cfg.host || DEFAULTS.host, + apiKey: cfg.apiKey || DEFAULTS.apiKey, + collection: cfg.collection || DEFAULTS.collection, + }; +} + +// Fields the related-posts card builder needs. Keep in sync with the field +// list in assets/js/related-posts.js → buildCardElement(). +const RELATED_INCLUDE_FIELDS = + "slug,title,excerpt,feature_image,url,tags,tags.name,tags.slug,published_at"; + +// Title hits weighted highest, then excerpt, then plaintext. +const RELATED_QUERY_BY = "title,excerpt,plaintext"; +const RELATED_QUERY_BY_WEIGHTS = "4,2,1"; + +/** + * Find documents semantically similar to a free-text query (typically a + * post's "title\nexcerpt"). Returns rich hit objects sufficient for + * client-side card rendering. + * + * @param {string} query - Free-text query, expected to be already trimmed and + * length-capped by the caller (see related-posts.js). + * @param {object} options + * @param {string} [options.excludeId] - Document id to exclude (e.g. the current post). + * @param {number} [options.limit=3] - Max hits to return. + * @param {AbortSignal} [options.signal] - Aborts the request. + * @returns {Promise<object[]>} Array of hit documents (slug, title, excerpt, + * feature_image, url, tags, tags.slug, published_at). + * Empty array on zero hits. + * @throws {Error} on network failure, non-2xx, or malformed JSON. AbortError + * is propagated as-is so callers can distinguish. + */ +export async function searchSimilar(query, options = {}) { + const { excludeId, limit = 3, signal } = options; + const { host, apiKey, collection } = getConfig(); + + const params = new URLSearchParams({ + q: query, + query_by: RELATED_QUERY_BY, + query_by_weights: RELATED_QUERY_BY_WEIGHTS, + include_fields: RELATED_INCLUDE_FIELDS, + per_page: String(limit), + }); + if (excludeId) { + params.set("filter_by", `id:!=${excludeId}`); + } + + const url = `${host}/collections/${collection}/documents/search?${params.toString()}`; + const response = await fetch(url, { + headers: { "X-TYPESENSE-API-KEY": apiKey }, + signal, + }); + if (!response.ok) { + throw new Error(`Typesense HTTP ${response.status}`); + } + const data = await response.json(); + return (data.hits || []).map((h) => h.document); +} +``` + +- [ ] **Step 2: Build and lint** + +``` +npx gulp build +npm run test +``` + +The build should bundle the new file into `assets/built/main.js` only once it's imported (Task 4). For now it just compiles standalone via webpack's module resolution. + +- [ ] **Step 3: Manually verify in browser (DevTools console)** + +Load any page on `http://localhost:2368/`. Open DevTools → Console. Paste a one-off probe (the function isn't yet imported anywhere, so we test via direct fetch using the same URL shape): + +```javascript +fetch( + "https://typesense.advisory.sg/collections/ghost/documents/search?q=stoic&query_by=title,excerpt,plaintext&query_by_weights=4,2,1&include_fields=slug,title,excerpt,feature_image,url,tags,tags.name,tags.slug,published_at&per_page=3", + { + headers: { "X-TYPESENSE-API-KEY": "LWQ1uyADTZBRVJa0XuFY5BcipgnhvQ8g" }, + }, +) + .then((r) => r.json()) + .then((d) => + console.log( + "found", + d.found, + "hits", + d.hits.map((h) => ({ + slug: h.document.slug, + title: h.document.title.slice(0, 40), + feature_image: !!h.document.feature_image, + primary_tag: + h.document["tags.slug"] && + h.document["tags.slug"].find((s) => !s.startsWith("hash-")), + })), + ), + ); +``` + +Expected: `found: <some count>` and 0-3 hits with non-empty slug/title fields. The `primary_tag` field should be a non-hash slug (or undefined if all tags are routing tags). + +- [ ] **Step 4: Commit** + +``` +git add assets/js/typesense-search.js +git commit -m "feat(search): add Typesense searchSimilar wrapper" +``` + +--- + +## Task 3: Add `data-slug` to `partials/post-card.hbs` + +**Files:** + +- Modify: `partials/post-card.hbs:1-4` (root `<article>` element) + +The Alpine component (Task 4) needs to match SSR-rendered cards to Typesense hits by slug — both for deduplication (don't show the same card twice) and for reuse (don't rebuild a card from Typesense data when the SSR card already exists in DOM). One attribute on the root `<article>` is enough. + +- [ ] **Step 1: Edit the root `<article>` element** + +Find lines 1-4 of `partials/post-card.hbs` (current state): + +```handlebars +<article + class="feed-card {{post_class}} rounded-lg shadow-2xl m-auto w-full max-w-2xl md:max-w-full lg:h-full hover:shadow-md hover:border-opacity-0 transform hover:-translate-y-1 transition-all duration-200"> + <div onclick="location.href='{{url}}'" class="flex flex-col cursor-pointer h-full rounded-xl overflow-hidden md:flex-row md:h-80 lg:flex-col lg:h-full"> +``` + +Replace the opening `<article>` tag (the first two lines through the closing `>`) with: + +```handlebars +<article + class="feed-card {{post_class}} rounded-lg shadow-2xl m-auto w-full max-w-2xl md:max-w-full lg:h-full hover:shadow-md hover:border-opacity-0 transform hover:-translate-y-1 transition-all duration-200" + data-slug="{{slug}}"> +``` + +`{{slug}}` is single-brace (Handlebars HTML-escapes by default) — safe for any post slug since slugs are restricted to URL-safe characters by Ghost. + +- [ ] **Step 2: Build and lint** + +``` +npx gulp build +npm run test +``` + +- [ ] **Step 3: Manually verify in browser** + +Reload any page that renders `post-card.hbs` (e.g. `http://localhost:2368/`). Open DevTools, inspect any `.feed-card`, confirm `data-slug="<some-post-slug>"` is present. + +- [ ] **Step 4: Commit** + +``` +git add partials/post-card.hbs +git commit -m "feat(post-card): expose slug as data attribute for related-posts JS" +``` + +--- + +## Task 4: Create `assets/js/related-posts.js` Alpine component + +**Files:** + +- Create: `assets/js/related-posts.js` + +The orchestration component. ~140 lines total. Reads the source post's metadata from `data-*` attributes on its own root element, reads SSR cards via `[data-slug]` query, fires one Typesense `searchSimilar` call, then either replaces the SSR cards with Typesense-ranked cards (topping up from SSR if Typesense returned < 3) or leaves them untouched on failure / empty response / identical-set short-circuit. + +Includes a `buildCardElement(hit)` helper that mirrors `partials/post-card.hbs` structure — see Task 3's data-slug change and the spec's "Card-building contract" section. + +- [ ] **Step 1: Create `assets/js/related-posts.js`** + +```javascript +// Alpine component for the bottom-of-article "You might also like…" widget. +// Used by partials/related.hbs. +// +// Reads the source post's id, title, and excerpt from data-* attributes +// on its own root element ($el.dataset). Fires one Typesense searchSimilar +// call, then merges the response with the SSR cards already in the DOM: +// +// - If Typesense returns 3 hits and they differ from the SSR slugs: +// replace the grid contents with 3 cards (reusing SSR DOM where slug +// matches, building new DOM via buildCardElement() otherwise). +// - If Typesense returns 1-2 hits: render those, then top up from SSR +// cards (skipping duplicates) until 3. +// - If Typesense returns 0 / fails / aborts / matches the SSR set: +// do nothing — SSR cards stay. +// +// IMPORTANT: buildCardElement() below mirrors partials/post-card.hbs. +// If post-card.hbs changes shape, mirror the change here too. + +import { searchSimilar } from "./typesense-search.js"; + +const TARGET_COUNT = 3; +const QUERY_MAX_CHARS = 500; // keeps Typesense URL well under 8KB + +export default function relatedPosts() { + return { + async init() { + const root = this.$el; + const grid = root.querySelector(".related-feed"); + if (!grid) return; + + const source = { + id: root.dataset.postId || "", + title: root.dataset.postTitle || "", + excerpt: root.dataset.postExcerpt || "", + }; + + // Nothing to query with — leave SSR cards alone. + if (!source.title && !source.excerpt) return; + + const ssrCards = Array.from( + grid.querySelectorAll(".feed-card[data-slug]"), + ).map((el) => ({ slug: el.dataset.slug, el })); + + const query = `${source.title}\n${source.excerpt}` + .trim() + .slice(0, QUERY_MAX_CHARS); + + let hits; + try { + hits = await searchSimilar(query, { + excludeId: source.id, + limit: TARGET_COUNT, + }); + } catch (err) { + if (err.name !== "AbortError") { + console.warn("related-posts: Typesense search failed", err); + } + return; // SSR cards stay + } + + // Defensive: drop any hit matching the source post (in case + // filter_by didn't work for some reason). + hits = hits.filter((h) => h.slug !== source.id && h.slug); + + if (hits.length === 0) return; // SSR cards stay + + // Equal-set short-circuit (any order): skip DOM swap to avoid + // a visible re-layout flash for the common case where the SSR + // fallback already had the right posts. + const hitSlugs = hits.map((h) => h.slug).sort(); + const ssrSlugs = ssrCards.map((c) => c.slug).sort(); + if ( + hitSlugs.length === ssrSlugs.length && + hitSlugs.every((s, i) => s === ssrSlugs[i]) + ) { + return; + } + + // Build the final card list: Typesense hits first (reusing SSR + // DOM where slugs match), then top up with SSR cards not yet + // included, until TARGET_COUNT. + const finalEls = []; + const usedSlugs = new Set(); + for (const hit of hits) { + const ssr = ssrCards.find((c) => c.slug === hit.slug); + finalEls.push(ssr ? ssr.el : buildCardElement(hit)); + usedSlugs.add(hit.slug); + } + for (const ssr of ssrCards) { + if (finalEls.length >= TARGET_COUNT) break; + if (!usedSlugs.has(ssr.slug)) { + finalEls.push(ssr.el); + usedSlugs.add(ssr.slug); + } + } + + grid.replaceChildren(...finalEls); + }, + }; +} + +// IMPORTANT: this builder mirrors partials/post-card.hbs. Keep the structure +// (article > div.flex > optional img + div.grow > primary tag span + title + +// excerpt + date / stretch-link anchor) byte-equivalent (modulo data +// attributes specific to the filter feature, which related-posts doesn't +// need). +function buildCardElement(hit) { + const article = document.createElement("article"); + article.className = + "feed-card post rounded-lg shadow-2xl m-auto w-full max-w-2xl md:max-w-full lg:h-full hover:shadow-md hover:border-opacity-0 transform hover:-translate-y-1 transition-all duration-200"; + article.dataset.slug = hit.slug || ""; + + const inner = document.createElement("div"); + inner.className = + "flex flex-col cursor-pointer h-full rounded-xl overflow-hidden md:flex-row md:h-80 lg:flex-col lg:h-full"; + inner.setAttribute("onclick", `location.href='${hit.url || "#"}'`); + + if (hit.feature_image) { + const img = document.createElement("img"); + img.className = + "object-cover m-0 h-full md:aspect-[9/5] lg:h-auto lg:aspect-[15/8]"; + img.src = hit.feature_image; + img.alt = hit.title || ""; + inner.appendChild(img); + } + + const body = document.createElement("div"); + body.className = "grow bg-white px-3 pt-3 pb-5 relative"; + + // Primary tag = first non-routing tag (routing tags start with "hash-"). + const primaryTag = pickPrimaryTag(hit); + if (primaryTag) { + const tagSpan = document.createElement("span"); + tagSpan.className = "single-meta-item single-meta-tag"; + const tagAnchor = document.createElement("a"); + tagAnchor.className = `post-tag post-tag-${primaryTag.slug} text-lg uppercase font-extrabold`; + tagAnchor.href = `/tag/${primaryTag.slug}/`; + tagAnchor.textContent = primaryTag.name; + tagSpan.appendChild(tagAnchor); + body.appendChild(tagSpan); + } else { + const noTag = document.createElement("span"); + noTag.className = "text-lg uppercase font-extrabold"; + noTag.textContent = "[No tag]"; + body.appendChild(noTag); + } + + const titleEl = document.createElement("p"); + titleEl.className = "text-2xl font-bold my-2"; + titleEl.textContent = hit.title || ""; + body.appendChild(titleEl); + + const excerptEl = document.createElement("p"); + excerptEl.className = "text-xl my-1 line-clamp-4"; + excerptEl.textContent = hit.excerpt || ""; + body.appendChild(excerptEl); + + const spacer = document.createElement("div"); + spacer.className = "h-8"; + body.appendChild(spacer); + + const dateWrap = document.createElement("div"); + dateWrap.className = "text-xl absolute right-4 bottom-4"; + const clockIcon = document.createElement("i"); + clockIcon.className = "far fa-clock"; + dateWrap.appendChild(clockIcon); + if (hit.published_at) { + const date = new Date(hit.published_at); + const timeEl = document.createElement("time"); + timeEl.dateTime = date.toISOString().split("T")[0]; + timeEl.textContent = + " " + + date.toLocaleDateString("en-GB", { + day: "numeric", + month: "short", + year: "numeric", + }); + dateWrap.appendChild(timeEl); + } + body.appendChild(dateWrap); + + inner.appendChild(body); + + const stretchAnchor = document.createElement("a"); + stretchAnchor.href = hit.url || "#"; + const stretchSpan = document.createElement("span"); + stretchSpan.className = "stretch-link"; + stretchAnchor.appendChild(stretchSpan); + inner.appendChild(stretchAnchor); + + article.appendChild(inner); + return article; +} + +// First non-hash-prefixed tag from the parallel tags.name / tags.slug arrays. +// Returns null if all tags are routing tags or arrays are missing/empty. +function pickPrimaryTag(hit) { + const names = hit["tags.name"] || hit.tags || []; + const slugs = hit["tags.slug"] || []; + for (let i = 0; i < slugs.length; i++) { + if (slugs[i] && !slugs[i].startsWith("hash-")) { + return { name: names[i] || slugs[i], slug: slugs[i] }; + } + } + return null; +} +``` + +- [ ] **Step 2: Build and lint** + +``` +npx gulp build +npm run test +``` + +The bundle will now actually include `typesense-search.js` (it's imported here). Build should succeed. + +- [ ] **Step 3: Commit** + +``` +git add assets/js/related-posts.js +git commit -m "feat(related): add Alpine component with Typesense fetch + card builder" +``` + +--- + +## Task 5: Wrap `partials/related.hbs` with the Alpine component + +**Files:** + +- Modify: `partials/related.hbs` (whole file replaced) + +Wrap the existing `<section class="related-wrapper">` with `x-data="relatedPosts"`. Add `data-post-id`, `data-post-title`, `data-post-excerpt` so the JS can read the source post's metadata. Move the `{{#if related}}` gate so it only suppresses the inner `{{#foreach}}` — the wrapper itself always renders so the JS can populate it from Typesense even when SSR returns 0 cards (rare: post with no shared-tag matches). + +- [ ] **Step 1: Replace `partials/related.hbs` with the updated content** + +Replace the entire file with: + +```handlebars +{{!-- Bottom-of-article "You might also like…" widget. + The SSR {{#get}} block below renders 3 tag-matched cards as the + no-JS baseline AND as a topup pool for the JS-side Typesense + enhancement (see assets/js/related-posts.js). --}} +<section + class="related-wrapper" + x-data="relatedPosts" + data-post-id="{{post.id}}" + data-post-title="{{post.title}}" + data-post-excerpt="{{post.excerpt}}"> + <div class="container large"> + <h3 class="related-title">You might also like...</h3> + <div class="related-feed grid grid-cols-1 lg:grid-cols-3 gap-x-20 gap-y-10 m-auto"> + {{!-- SSR baseline: same query as before. The {{#if related}} + gate is intentionally INSIDE so the wrapper always renders; + the JS may populate from Typesense even when SSR returns 0. --}} + <!-- djlint:off --> + {{#get "posts" limit="3" filter="tags:[{{post.tags}}]+id:-{{post.id}}" include="tags" as |related|}} + <!-- djlint:on --> + {{#if related}} + {{#foreach related}} + {{> "post-card"}} + {{/foreach}} + {{/if}} + {{/get}} + </div> + </div> +</section> +``` + +Notes: + +- The `<section>` is now the Alpine root — `$el.dataset.postId` etc. read off it. +- `data-post-title` and `data-post-excerpt` use Handlebars HTML-attribute escaping (handles quotes, ampersands, etc. correctly — safer than a `<script type="application/json">` block which would need custom JSON-quote escaping for titles containing `"`). +- The grid keeps the `related-feed` class so JS can find it. + +- [ ] **Step 2: Build and lint** + +``` +npx gulp build +npm run test +``` + +- [ ] **Step 3: Commit** + +``` +git add partials/related.hbs +git commit -m "feat(related): wrap with Alpine component, source data on data-attrs" +``` + +--- + +## Task 6: Register the Alpine component in `assets/js/main.js` + +**Files:** + +- Modify: `assets/js/main.js` + +Add the import and `Alpine.data(...)` registration before `Alpine.start()` so the `x-data="relatedPosts"` binding in the partial resolves. + +- [ ] **Step 1: Read the current `main.js` to find the right insertion points** + +Open `assets/js/main.js`. Look for: + +- The existing `import` block at the top. +- The `Alpine.start()` call. + +The component must be registered between Alpine being available (already imported) and `Alpine.start()` running. + +- [ ] **Step 2: Add the import** + +Near the top of `main.js`, alongside the other imports, add: + +```javascript +import relatedPosts from "./related-posts.js"; +``` + +- [ ] **Step 3: Register the component before `Alpine.start()`** + +Find the line `Alpine.start();` (or equivalent — Alpine.start may be wrapped). Immediately BEFORE it, add: + +```javascript +Alpine.data("relatedPosts", relatedPosts); +``` + +- [ ] **Step 4: Build and lint** + +``` +npx gulp build +npm run test +``` + +The bundle should now wire the component to its DOM binding. + +- [ ] **Step 5: Manually verify in browser** + +Reload any post page (e.g. `http://localhost:2368/<some-post-url>/`) where `@custom.show_related_posts` is true. + +1. The "You might also like…" section appears with 3 cards (the SSR baseline). +2. Open DevTools → Network → filter for `typesense.advisory.sg`. One request fires shortly after page load. +3. Check Console for any errors. There should be none. A warning is acceptable only if Typesense is unreachable. +4. Inspect the `<section class="related-wrapper">`. It should have all three `data-post-*` attributes populated. Open Alpine DevTools (or run `Alpine.$data(document.querySelector('.related-wrapper'))` in console) — confirm the component initialized. +5. If Typesense returned different cards from the SSR set, the grid contents should be visibly different from the initial paint. (For pages where the SSR set was already optimal, no swap will occur.) + +- [ ] **Step 6: Commit** + +``` +git add assets/js/main.js +git commit -m "feat(related): register relatedPosts Alpine component" +``` + +--- + +## Task 7: Document Typesense in `README.md` + +**Files:** + +- Modify: `README.md` + +Add a new section explaining the three Typesense settings, the security model of the embedded API key, and where to override per-install. + +- [ ] **Step 1: Add the "Typesense Search" section** + +Find the line `# PostCSS Features Used` near the bottom of `README.md`. Immediately BEFORE that heading, insert: + +```markdown +# Typesense Search + +The `/events/`, `/interviews/`, and bottom-of-article "you might also like" widget all use [Typesense](https://typesense.org/) for typo-tolerant, content-relevance search and ranking. Three settings live under `config.custom` in `package.json` and are admin-overridable in **Ghost Admin → Settings → Design → Customize**: + +| Setting | Default | What it is | +| ---------------------- | ------------------------------- | ------------------------------------------------------------------ | +| `typesense_host` | `https://typesense.advisory.sg` | Typesense host URL (no trailing slash). HTTPS recommended. | +| `typesense_api_key` | (Advisory SG search-only key) | Typesense **search-only** API key. Embedded client-side by design. | +| `typesense_collection` | `ghost` | Name of the indexed Ghost-posts collection on the Typesense host. | + +The flow is: `package.json` defaults → `default.hbs` injects them as `window.__TYPESENSE_CONFIG__` → `assets/js/typesense-search.js` reads from that global at search time, falling back to the same defaults if the global is missing. + +**About the API key in source.** Typesense splits keys into _admin_ (read/write, secret) and _search-only_ (read-only, scoped to a collection). The search-only key is the analogue of Ghost's Content API key — it's designed to ship in client-side JavaScript and only grants read access to data that's already public. Don't paste an admin key here. + +**Deploying to a different Ghost instance.** If your instance points at a different Typesense backend, override the three settings in **Design → Customize** rather than editing the theme. The defaults in `package.json` are only the fallback for installs that don't override. + +**About the indexer.** This theme expects an existing Typesense collection populated with Ghost posts (`title`, `slug`, `excerpt`, `plaintext`, `feature_image`, `url`, `tags.name`, `tags.slug`, `published_at`, etc.). The sync mechanism (e.g. [MagicPages' Ghost-Typesense integration](https://github.com/magicpages/ghost-typesense)) is **not** part of this theme. +``` + +- [ ] **Step 2: Commit** + +(No build / lint needed for README-only changes.) + +``` +git add README.md +git commit -m "docs(readme): document Typesense search configuration" +``` + +--- + +## Task 8: End-to-end manual verification + +**Files:** none (verification only) + +A final pass against the spec's testing matrix (section "Testing and verification"). No code changes. Catch anything missed before handing back to the user. + +- [ ] **Step 1: Verify the SSR baseline (no-JS path)** + +In DevTools, disable JavaScript (Settings → Debugger → Disable JavaScript, or per-tab via the Command Menu). Reload any post page where `@custom.show_related_posts` is true. + +1. The "You might also like…" section appears with up to 3 cards from the existing tag-match query. +2. No console errors (none should be possible — JS is off). +3. Each card is a working link (anchor click navigates). + +Re-enable JavaScript. + +- [ ] **Step 2: Verify the JS-enhanced path** + +Reload the same post page with JS enabled. + +1. Cards visible at first paint (the SSR baseline). +2. DevTools → Network: one request to `typesense.advisory.sg` fires within ~500ms of page load. +3. If Typesense returned different cards, the grid swaps once and stays. No flash beyond that one swap. +4. Inspect the new card DOM (if a swap occurred). Confirm structure matches a regular `post-card.hbs` render: `<article class="feed-card …">` with inner `<div>`, optional `<img>`, primary tag span, title `<p>`, excerpt `<p>`, time element, stretch-link anchor. + +- [ ] **Step 3: Verify cross-collection ranking** + +Pick a post on a specific topic (e.g. an interview about venture capital). Reload. Among the related cards, look for cross-collection items (a VC event, a VC post in another collection). Compare to the SSR baseline (which would show only same-collection posts because of routing-tag overlap). The Typesense version should be more topically targeted. + +- [ ] **Step 4: Verify Typesense-failure path** + +DevTools → Network → block request URL on `https://typesense.advisory.sg/*`. Reload a post page. + +1. SSR cards remain visible (no JS swap). +2. Console shows a warning: `related-posts: Typesense search failed Error: …` (or similar). +3. No error banner visible to the user. + +Unblock the URL. + +- [ ] **Step 5: Verify the `@custom.show_related_posts` toggle** + +In Ghost Admin → Settings → Design → Customize, set `Show related posts` to **off**. Reload a post page. + +1. The related section is absent from the page. +2. DevTools → Network: NO request to `typesense.advisory.sg` fires (the JS component never mounts). + +Re-enable the toggle. + +- [ ] **Step 6: Verify the equal-set short-circuit** + +Find a post where the SSR baseline already returns the most-relevant 3 posts (often true for posts with strong, narrow tag overlap). Reload. The Typesense response should equal the SSR set, and you should see NO grid mutation in DevTools (use the "Animations" panel or the "Highlight DOM updates" feature). + +- [ ] **Step 7: Verify mobile viewport** + +Resize viewport to 375px. Cards stack into a single column (existing grid behavior). No layout regressions. + +- [ ] **Step 8: Final lint + git status** + +``` +npx gulp build +npm run test +git status --short +``` + +Build succeeds. Lint shows pre-existing baseline only (1 error in page.hbs, 1 warning about card_assets). `git status` is clean (no uncommitted changes). + +- [ ] **Step 9: Hand back to user** + +Report: + +- Total commits added on this branch since the start of the plan. +- Any verification step that didn't behave as expected. +- Any deviations from the spec the implementer made. +- Suggested next step (typically: invoke `superpowers:finishing-a-development-branch` for PR/merge). + +--- + +## Self-review notes (writer's check) + +**Spec coverage:** + +- Goal (replace tag-match with Typesense semantic ranking) → Tasks 1, 2, 4, 5, 6. +- Cross-collection scope (no `filter_by` on routing tags) → Task 2 (`searchSimilar` doesn't accept `tagSlugs`). +- Query construction (title + excerpt, capped at 500 chars) → Task 4 (`QUERY_MAX_CHARS`). +- 3-card target with topup behavior → Task 4 (`TARGET_COUNT`, dedup loop). +- SSR baseline always renders → Task 5 (the `{{#if related}}` is moved inside the foreach so the wrapper itself doesn't depend on it). +- Equal-set short-circuit → Task 4 (the sort+every check). +- Card-building contract mirrors `post-card.hbs` → Task 4 (`buildCardElement`, with cross-reference comment). +- `@custom.show_related_posts` preserved → Untouched in `post.hbs`, noted in file map. +- API key handling (search-only, public-readable) → Tasks 1, 2 (constants + comments). +- Defaults match production → Task 1 (package.json defaults), Task 2 (DEFAULTS in JS). +- Failure modes (network, AbortError, 0 hits, current-post-in-results) → Task 4 try/catch + filter. +- Documentation in README → Task 7. +- Manual verification matrix → Task 8. + +**Type / name consistency:** + +- `searchSimilar(query, options)` introduced in Task 2, called in Task 4. Same signature. +- `getConfig()` introduced in Task 2 — internal to typesense-search.js, no consumers reference it. +- `relatedPosts` exported function in Task 4, registered as `Alpine.data('relatedPosts', relatedPosts)` in Task 6, bound via `x-data="relatedPosts"` in Task 5. Names match. +- `data-post-id`, `data-post-title`, `data-post-excerpt` set in Task 5 (`<section data-post-id={{post.id}}…>`), read in Task 4 (`root.dataset.postId`, etc.). Camelcase auto-conversion correct (`post-id` → `postId`). +- `data-slug` added in Task 3 (`<article data-slug={{slug}}>`), queried in Task 4 (`grid.querySelectorAll('.feed-card[data-slug]')`). Names match. +- `TARGET_COUNT = 3` and `per_page: String(limit)` with `limit = 3` default match. The grid's `lg:grid-cols-3` from Task 5 also matches. +- `RELATED_INCLUDE_FIELDS` in Task 2 includes `slug,title,excerpt,feature_image,url,tags,tags.name,tags.slug,published_at`. Task 4's `buildCardElement` reads `hit.slug`, `hit.url`, `hit.feature_image`, `hit.title`, `hit.excerpt`, `hit['tags.name']`, `hit['tags.slug']`, `hit.published_at`. All accounted for. +- `pickPrimaryTag` falls back to `hit.tags` (just names) if `hit['tags.name']` is missing — defensive, harmless. From 4fdf3813cb5c04b36ff557f8019f4f42a8657c57 Mon Sep 17 00:00:00 2001 From: Terence Chan Zun Mun <zunmun@gmail.com> Date: Wed, 6 May 2026 14:05:11 +0200 Subject: [PATCH 03/13] feat(config): add Typesense config to package.json + default.hbs --- default.hbs | 11 +++++++++++ package.json | 12 ++++++++++++ 2 files changed, 23 insertions(+) diff --git a/default.hbs b/default.hbs index 37a982db..4fd00adb 100644 --- a/default.hbs +++ b/default.hbs @@ -25,6 +25,17 @@ var siteUrl = '{{@site.url}}'; </script> + {{!-- Typesense search config — sourced from package.json config.custom, + overridable per-install in Ghost Admin → Design → Customize. + The API key here is search-only (read-only, public-scoped). --}} + <script> + window.__TYPESENSE_CONFIG__ = { + host: '{{@custom.typesense_host}}', + apiKey: '{{@custom.typesense_api_key}}', + collection: '{{@custom.typesense_collection}}', + }; + </script> + {{ghost_head}} </head> diff --git a/package.json b/package.json index aaf31eee..d0995c0f 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,18 @@ "type": "boolean", "default": true, "group": "post" + }, + "typesense_host": { + "type": "text", + "default": "https://typesense.advisory.sg" + }, + "typesense_api_key": { + "type": "text", + "default": "LWQ1uyADTZBRVJa0XuFY5BcipgnhvQ8g" + }, + "typesense_collection": { + "type": "text", + "default": "ghost" } } }, From c19b5e9c8b9d531f588db8c525c244e9b27bc8cb Mon Sep 17 00:00:00 2001 From: Terence Chan Zun Mun <zunmun@gmail.com> Date: Wed, 6 May 2026 14:09:49 +0200 Subject: [PATCH 04/13] feat(search): add Typesense searchSimilar wrapper --- assets/js/typesense-search.js | 78 +++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 assets/js/typesense-search.js diff --git a/assets/js/typesense-search.js b/assets/js/typesense-search.js new file mode 100644 index 00000000..87ddf57c --- /dev/null +++ b/assets/js/typesense-search.js @@ -0,0 +1,78 @@ +// Wrapper around Typesense's search REST API. +// +// Configuration source of truth is `package.json` under `config.custom.typesense_*`, +// admin-overridable in Ghost → Design → Customize. `default.hbs` injects the +// resolved values into `window.__TYPESENSE_CONFIG__` before this bundle loads. +// +// The API key is a *search-only* key — Typesense's analogue of Ghost's Content +// API key. It is read-only and scoped to the configured collection. Embedding +// it client-side is intentional and safe (Typesense convention). + +const DEFAULTS = { + host: 'https://typesense.advisory.sg', + apiKey: 'LWQ1uyADTZBRVJa0XuFY5BcipgnhvQ8g', + collection: 'ghost', +}; + +function getConfig() { + const cfg = + (typeof window !== 'undefined' && window.__TYPESENSE_CONFIG__) || {}; + return { + host: cfg.host || DEFAULTS.host, + apiKey: cfg.apiKey || DEFAULTS.apiKey, + collection: cfg.collection || DEFAULTS.collection, + }; +} + +// Fields the related-posts card builder needs. Keep in sync with the field +// list in assets/js/related-posts.js → buildCardElement(). +const RELATED_INCLUDE_FIELDS = + 'slug,title,excerpt,feature_image,url,tags,tags.name,tags.slug,published_at'; + +// Title hits weighted highest, then excerpt, then plaintext. +const RELATED_QUERY_BY = 'title,excerpt,plaintext'; +const RELATED_QUERY_BY_WEIGHTS = '4,2,1'; + +/** + * Find documents semantically similar to a free-text query (typically a + * post's "title\nexcerpt"). Returns rich hit objects sufficient for + * client-side card rendering. + * + * @param {string} query - Free-text query, expected to be already trimmed and + * length-capped by the caller (see related-posts.js). + * @param {object} options + * @param {string} [options.excludeId] - Document id to exclude (e.g. the current post). + * @param {number} [options.limit=3] - Max hits to return. + * @param {AbortSignal} [options.signal] - Aborts the request. + * @returns {Promise<object[]>} Array of hit documents (slug, title, excerpt, + * feature_image, url, tags, tags.slug, published_at). + * Empty array on zero hits. + * @throws {Error} on network failure, non-2xx, or malformed JSON. AbortError + * is propagated as-is so callers can distinguish. + */ +export async function searchSimilar(query, options = {}) { + const { excludeId, limit = 3, signal } = options; + const { host, apiKey, collection } = getConfig(); + + const params = new URLSearchParams({ + q: query, + query_by: RELATED_QUERY_BY, + query_by_weights: RELATED_QUERY_BY_WEIGHTS, + include_fields: RELATED_INCLUDE_FIELDS, + per_page: String(limit), + }); + if (excludeId) { + params.set('filter_by', `id:!=${excludeId}`); + } + + const url = `${host}/collections/${collection}/documents/search?${params.toString()}`; + const response = await fetch(url, { + headers: { 'X-TYPESENSE-API-KEY': apiKey }, + signal, + }); + if (!response.ok) { + throw new Error(`Typesense HTTP ${response.status}`); + } + const data = await response.json(); + return (data.hits || []).map((h) => h.document); +} From 426823b431753ec8ea6a035ffe0435ad00b6bd01 Mon Sep 17 00:00:00 2001 From: Terence Chan Zun Mun <zunmun@gmail.com> Date: Wed, 6 May 2026 14:13:46 +0200 Subject: [PATCH 05/13] feat(post-card): expose slug as data attribute for related-posts JS --- partials/post-card.hbs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/partials/post-card.hbs b/partials/post-card.hbs index cc14ca4d..48773c58 100644 --- a/partials/post-card.hbs +++ b/partials/post-card.hbs @@ -1,4 +1,6 @@ -<article class="feed-card {{post_class}} rounded-lg shadow-2xl m-auto w-full max-w-2xl md:max-w-full lg:h-full hover:shadow-md hover:border-opacity-0 transform hover:-translate-y-1 transition-all duration-200"> +<article + class="feed-card {{post_class}} rounded-lg shadow-2xl m-auto w-full max-w-2xl md:max-w-full lg:h-full hover:shadow-md hover:border-opacity-0 transform hover:-translate-y-1 transition-all duration-200" + data-slug="{{slug}}"> <div onclick="location.href='{{url}}'" class="flex flex-col cursor-pointer h-full rounded-xl overflow-hidden md:flex-row md:h-80 lg:flex-col lg:h-full"> {{#if feature_image}} <img class="object-cover m-0 h-full md:aspect-[9/5] lg:h-auto lg:aspect-[15/8]" src="{{img_url feature_image}}" alt="{{title}}" /> From 40d83c7c2f1e73d092adf31dbdd32b7495d581d6 Mon Sep 17 00:00:00 2001 From: Terence Chan Zun Mun <zunmun@gmail.com> Date: Wed, 6 May 2026 14:16:55 +0200 Subject: [PATCH 06/13] feat(related): add Alpine component with Typesense fetch + card builder --- assets/js/related-posts.js | 206 +++++++++++++++++++++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 assets/js/related-posts.js diff --git a/assets/js/related-posts.js b/assets/js/related-posts.js new file mode 100644 index 00000000..8fa72e45 --- /dev/null +++ b/assets/js/related-posts.js @@ -0,0 +1,206 @@ +// Alpine component for the bottom-of-article "You might also like…" widget. +// Used by partials/related.hbs. +// +// Reads the source post's id, title, and excerpt from data-* attributes +// on its own root element ($el.dataset). Fires one Typesense searchSimilar +// call, then merges the response with the SSR cards already in the DOM: +// +// - If Typesense returns 3 hits and they differ from the SSR slugs: +// replace the grid contents with 3 cards (reusing SSR DOM where slug +// matches, building new DOM via buildCardElement() otherwise). +// - If Typesense returns 1-2 hits: render those, then top up from SSR +// cards (skipping duplicates) until 3. +// - If Typesense returns 0 / fails / aborts / matches the SSR set: +// do nothing — SSR cards stay. +// +// IMPORTANT: buildCardElement() below mirrors partials/post-card.hbs. +// If post-card.hbs changes shape, mirror the change here too. + +import { searchSimilar } from './typesense-search.js'; + +const TARGET_COUNT = 3; +const QUERY_MAX_CHARS = 500; // keeps Typesense URL well under 8KB + +export default function relatedPosts() { + return { + async init() { + const root = this.$el; + const grid = root.querySelector('.related-feed'); + if (!grid) return; + + const source = { + id: root.dataset.postId || '', + title: root.dataset.postTitle || '', + excerpt: root.dataset.postExcerpt || '', + }; + + // Nothing to query with — leave SSR cards alone. + if (!source.title && !source.excerpt) return; + + const ssrCards = Array.from( + grid.querySelectorAll('.feed-card[data-slug]'), + ).map((el) => ({ slug: el.dataset.slug, el })); + + const query = `${source.title}\n${source.excerpt}` + .trim() + .slice(0, QUERY_MAX_CHARS); + + let hits; + try { + hits = await searchSimilar(query, { + excludeId: source.id, + limit: TARGET_COUNT, + }); + } catch (err) { + if (err.name !== 'AbortError') { + console.warn('related-posts: Typesense search failed', err); + } + return; // SSR cards stay + } + + // Defensive: drop any hit matching the source post (in case + // filter_by didn't work for some reason). + hits = hits.filter((h) => h.slug !== source.id && h.slug); + + if (hits.length === 0) return; // SSR cards stay + + // Equal-set short-circuit (any order): skip DOM swap to avoid + // a visible re-layout flash for the common case where the SSR + // fallback already had the right posts. + const hitSlugs = hits.map((h) => h.slug).sort(); + const ssrSlugs = ssrCards.map((c) => c.slug).sort(); + if ( + hitSlugs.length === ssrSlugs.length && + hitSlugs.every((s, i) => s === ssrSlugs[i]) + ) { + return; + } + + // Build the final card list: Typesense hits first (reusing SSR + // DOM where slugs match), then top up with SSR cards not yet + // included, until TARGET_COUNT. + const finalEls = []; + const usedSlugs = new Set(); + for (const hit of hits) { + const ssr = ssrCards.find((c) => c.slug === hit.slug); + finalEls.push(ssr ? ssr.el : buildCardElement(hit)); + usedSlugs.add(hit.slug); + } + for (const ssr of ssrCards) { + if (finalEls.length >= TARGET_COUNT) break; + if (!usedSlugs.has(ssr.slug)) { + finalEls.push(ssr.el); + usedSlugs.add(ssr.slug); + } + } + + grid.replaceChildren(...finalEls); + }, + }; +} + +// IMPORTANT: this builder mirrors partials/post-card.hbs. Keep the structure +// (article > div.flex > optional img + div.grow > primary tag span + title + +// excerpt + date / stretch-link anchor) byte-equivalent (modulo data +// attributes specific to the filter feature, which related-posts doesn't +// need). +function buildCardElement(hit) { + const article = document.createElement('article'); + article.className = + 'feed-card post rounded-lg shadow-2xl m-auto w-full max-w-2xl md:max-w-full lg:h-full hover:shadow-md hover:border-opacity-0 transform hover:-translate-y-1 transition-all duration-200'; + article.dataset.slug = hit.slug || ''; + + const inner = document.createElement('div'); + inner.className = + 'flex flex-col cursor-pointer h-full rounded-xl overflow-hidden md:flex-row md:h-80 lg:flex-col lg:h-full'; + inner.setAttribute('onclick', `location.href='${hit.url || '#'}'`); + + if (hit.feature_image) { + const img = document.createElement('img'); + img.className = + 'object-cover m-0 h-full md:aspect-[9/5] lg:h-auto lg:aspect-[15/8]'; + img.src = hit.feature_image; + img.alt = hit.title || ''; + inner.appendChild(img); + } + + const body = document.createElement('div'); + body.className = 'grow bg-white px-3 pt-3 pb-5 relative'; + + // Primary tag = first non-routing tag (routing tags start with "hash-"). + const primaryTag = pickPrimaryTag(hit); + if (primaryTag) { + const tagSpan = document.createElement('span'); + tagSpan.className = 'single-meta-item single-meta-tag'; + const tagAnchor = document.createElement('a'); + tagAnchor.className = `post-tag post-tag-${primaryTag.slug} text-lg uppercase font-extrabold`; + tagAnchor.href = `/tag/${primaryTag.slug}/`; + tagAnchor.textContent = primaryTag.name; + tagSpan.appendChild(tagAnchor); + body.appendChild(tagSpan); + } else { + const noTag = document.createElement('span'); + noTag.className = 'text-lg uppercase font-extrabold'; + noTag.textContent = '[No tag]'; + body.appendChild(noTag); + } + + const titleEl = document.createElement('p'); + titleEl.className = 'text-2xl font-bold my-2'; + titleEl.textContent = hit.title || ''; + body.appendChild(titleEl); + + const excerptEl = document.createElement('p'); + excerptEl.className = 'text-xl my-1 line-clamp-4'; + excerptEl.textContent = hit.excerpt || ''; + body.appendChild(excerptEl); + + const spacer = document.createElement('div'); + spacer.className = 'h-8'; + body.appendChild(spacer); + + const dateWrap = document.createElement('div'); + dateWrap.className = 'text-xl absolute right-4 bottom-4'; + const clockIcon = document.createElement('i'); + clockIcon.className = 'far fa-clock'; + dateWrap.appendChild(clockIcon); + if (hit.published_at) { + const date = new Date(hit.published_at); + const timeEl = document.createElement('time'); + timeEl.dateTime = date.toISOString().split('T')[0]; + timeEl.textContent = + ' ' + + date.toLocaleDateString('en-GB', { + day: 'numeric', + month: 'short', + year: 'numeric', + }); + dateWrap.appendChild(timeEl); + } + body.appendChild(dateWrap); + + inner.appendChild(body); + + const stretchAnchor = document.createElement('a'); + stretchAnchor.href = hit.url || '#'; + const stretchSpan = document.createElement('span'); + stretchSpan.className = 'stretch-link'; + stretchAnchor.appendChild(stretchSpan); + inner.appendChild(stretchAnchor); + + article.appendChild(inner); + return article; +} + +// First non-hash-prefixed tag from the parallel tags.name / tags.slug arrays. +// Returns null if all tags are routing tags or arrays are missing/empty. +function pickPrimaryTag(hit) { + const names = hit['tags.name'] || hit.tags || []; + const slugs = hit['tags.slug'] || []; + for (let i = 0; i < slugs.length; i++) { + if (slugs[i] && !slugs[i].startsWith('hash-')) { + return { name: names[i] || slugs[i], slug: slugs[i] }; + } + } + return null; +} From 89c4ad286fb54cb6346c0eb480c0325b1445c19c Mon Sep 17 00:00:00 2001 From: Terence Chan Zun Mun <zunmun@gmail.com> Date: Wed, 6 May 2026 14:23:11 +0200 Subject: [PATCH 07/13] fix(related): self-exclusion uses id, document post-card divergences MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three cleanups from code review of Task 4: 1. The defensive filter dropping the source post compared h.slug to source.id — different value spaces (slug vs ObjectId hex), so the guard was dead code. Add 'id' to Typesense include_fields and compare on h.id, restoring the safety net the comment claimed. 2. Document the two intentional divergences from post-card.hbs in the buildCardElement comment block: hardcoded "feed-card post" instead of {{post_class}} (verified safe — no CSS targets per-post tag-{slug} classes), and omitted <img> when feature_image is empty (post-card uses a hidden placeholder; both are display:none). 3. pickPrimaryTag drops the type-confusing "|| hit.tags" fallback — tags.name is always requested in include_fields so the fallback couldn't help, and hit.tags has a different shape that would make names[i] return an object instead of a string. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- assets/js/related-posts.js | 22 +++++++++++++++------- assets/js/typesense-search.js | 2 +- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/assets/js/related-posts.js b/assets/js/related-posts.js index 8fa72e45..2a8e473f 100644 --- a/assets/js/related-posts.js +++ b/assets/js/related-posts.js @@ -58,9 +58,10 @@ export default function relatedPosts() { return; // SSR cards stay } - // Defensive: drop any hit matching the source post (in case - // filter_by didn't work for some reason). - hits = hits.filter((h) => h.slug !== source.id && h.slug); + // Defensive: drop the source post if filter_by didn't suppress + // it (compares by id since source.id is a Ghost ObjectId, not + // a slug). Also drops malformed hits with no slug. + hits = hits.filter((h) => h.id !== source.id && h.slug); if (hits.length === 0) return; // SSR cards stay @@ -101,9 +102,15 @@ export default function relatedPosts() { // IMPORTANT: this builder mirrors partials/post-card.hbs. Keep the structure // (article > div.flex > optional img + div.grow > primary tag span + title + -// excerpt + date / stretch-link anchor) byte-equivalent (modulo data -// attributes specific to the filter feature, which related-posts doesn't -// need). +// excerpt + date / stretch-link anchor) byte-equivalent. +// +// Two intentional divergences from post-card.hbs: +// 1. We hardcode "feed-card post" instead of post-card's "feed-card {{post_class}}". +// {{post_class}} also emits per-post tag-{slug} classes; no CSS in this +// project targets those (verified against assets/css/), so skipping is safe. +// 2. We omit the <img> entirely when feature_image is empty; post-card.hbs +// renders a hidden placeholder <img> instead. Both are display:none in +// effect, so layout is equivalent for the related-posts grid. function buildCardElement(hit) { const article = document.createElement('article'); article.className = @@ -194,8 +201,9 @@ function buildCardElement(hit) { // First non-hash-prefixed tag from the parallel tags.name / tags.slug arrays. // Returns null if all tags are routing tags or arrays are missing/empty. +// Both arrays are requested via RELATED_INCLUDE_FIELDS in typesense-search.js. function pickPrimaryTag(hit) { - const names = hit['tags.name'] || hit.tags || []; + const names = hit['tags.name'] || []; const slugs = hit['tags.slug'] || []; for (let i = 0; i < slugs.length; i++) { if (slugs[i] && !slugs[i].startsWith('hash-')) { diff --git a/assets/js/typesense-search.js b/assets/js/typesense-search.js index 87ddf57c..dd7ce8a2 100644 --- a/assets/js/typesense-search.js +++ b/assets/js/typesense-search.js @@ -27,7 +27,7 @@ function getConfig() { // Fields the related-posts card builder needs. Keep in sync with the field // list in assets/js/related-posts.js → buildCardElement(). const RELATED_INCLUDE_FIELDS = - 'slug,title,excerpt,feature_image,url,tags,tags.name,tags.slug,published_at'; + 'id,slug,title,excerpt,feature_image,url,tags.name,tags.slug,published_at'; // Title hits weighted highest, then excerpt, then plaintext. const RELATED_QUERY_BY = 'title,excerpt,plaintext'; From ccd8e161002d7ed646b728fc9576e316c51ae1a4 Mon Sep 17 00:00:00 2001 From: Terence Chan Zun Mun <zunmun@gmail.com> Date: Wed, 6 May 2026 14:24:43 +0200 Subject: [PATCH 08/13] feat(related): wrap with Alpine component, source data on data-attrs --- partials/related.hbs | 40 ++++++++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/partials/related.hbs b/partials/related.hbs index 5f952645..4196224e 100644 --- a/partials/related.hbs +++ b/partials/related.hbs @@ -1,16 +1,28 @@ -<!-- djlint:off --> -{{#get "posts" limit="3" filter="tags:[{{post.tags}}]+id:-{{post.id}}" include="tags" as |related|}} -<!-- djlint:on --> - {{#if related}} - <section class="related-wrapper"> - <div class="container large"> - <h3 class="related-title">You might also like...</h3> - <div class="related-feed grid grid-cols-1 lg:grid-cols-3 gap-x-20 gap-y-10 m-auto"> - {{#foreach posts}} +{{!-- Bottom-of-article "You might also like…" widget. + The SSR {{#get}} block below renders 3 tag-matched cards as the + no-JS baseline AND as a topup pool for the JS-side Typesense + enhancement (see assets/js/related-posts.js). --}} +<section + class="related-wrapper" + x-data="relatedPosts" + data-post-id="{{post.id}}" + data-post-title="{{post.title}}" + data-post-excerpt="{{post.excerpt}}"> + <div class="container large"> + <h3 class="related-title">You might also like...</h3> + <div class="related-feed grid grid-cols-1 lg:grid-cols-3 gap-x-20 gap-y-10 m-auto"> + {{!-- SSR baseline: same query as before. The {{#if related}} + gate is intentionally INSIDE so the wrapper always renders; + the JS may populate from Typesense even when SSR returns 0. --}} + <!-- djlint:off --> + {{#get "posts" limit="3" filter="tags:[{{post.tags}}]+id:-{{post.id}}" include="tags" as |related|}} + <!-- djlint:on --> + {{#if related}} + {{#foreach related}} {{> "post-card"}} {{/foreach}} - </div> - </div> - </section> - {{/if}} -{{/get}} \ No newline at end of file + {{/if}} + {{/get}} + </div> + </div> +</section> From 943358d843a725c32daef7d05997d9585a16977f Mon Sep 17 00:00:00 2001 From: Terence Chan Zun Mun <zunmun@gmail.com> Date: Wed, 6 May 2026 14:27:20 +0200 Subject: [PATCH 09/13] feat(related): register relatedPosts Alpine component --- assets/js/main.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/assets/js/main.js b/assets/js/main.js index c0f47419..1753f1a5 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -8,7 +8,10 @@ import Glide from '@glidejs/glide'; import Alpine from 'alpinejs'; import 'flowbite'; +import relatedPosts from './related-posts.js'; + window.Alpine = Alpine; +Alpine.data('relatedPosts', relatedPosts); Alpine.start(); var body = $('body'); From b3f937e765cfd1d466a9037f9a8dad0ebf41cdb4 Mon Sep 17 00:00:00 2001 From: Terence Chan Zun Mun <zunmun@gmail.com> Date: Wed, 6 May 2026 14:28:33 +0200 Subject: [PATCH 10/13] docs(readme): document Typesense search configuration Adds a new section explaining the three custom-config fields, the search-only key security model, and where to override per-install. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- README.md | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 8098eaad..e76ccf44 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ npm run zip For new developers, please ask Tech Management for the posts and settings to be exported, and to pass you the output JSON file. This file can be imported into your local instance of Ghost, at `Settings > Labs > Migration Options > Import content`. Take note that this will not remove existing posts/pages. -Optionally, if you have access to the [Admin panel of Advisory](https://beta.advisory.sg/ghost/), you can go to `Settings > Labs > Migration Options > Export your content` in order to export the posts and settings used for the actual website as a JSON file. +Optionally, if you have access to the [Admin panel of Advisory](https://beta.advisory.sg/ghost/), you can go to `Settings > Labs > Migration Options > Export your content` in order to export the posts and settings used for the actual website as a JSON file. Furthermore, also make sure to setup `routes.yaml` as explained further down below @@ -71,9 +71,27 @@ For the homepage and separate [Stories](https://beta.advisory.sg/stories) page t **Note**: The `routes.yaml` file supplied in the repository is not automatically deployed onto the main website. +# Typesense Search + +The bottom-of-article "you might also like" widget uses [Typesense](https://typesense.org/) for typo-tolerant, content-relevance ranking. Three settings live under `config.custom` in `package.json` and are admin-overridable in **Ghost Admin → Settings → Design → Customize**: + +| Setting | Default | What it is | +| ---------------------- | ------------------------------- | ------------------------------------------------------------------ | +| `typesense_host` | `https://typesense.advisory.sg` | Typesense host URL (no trailing slash). HTTPS recommended. | +| `typesense_api_key` | (Advisory SG search-only key) | Typesense **search-only** API key. Embedded client-side by design. | +| `typesense_collection` | `ghost` | Name of the indexed Ghost-posts collection on the Typesense host. | + +The flow is: `package.json` defaults → `default.hbs` injects them as `window.__TYPESENSE_CONFIG__` → `assets/js/typesense-search.js` reads from that global at search time, falling back to the same defaults if the global is missing. + +**About the API key in source.** Typesense splits keys into _admin_ (read/write, secret) and _search-only_ (read-only, scoped to a collection). The search-only key is the analogue of Ghost's Content API key — it's designed to ship in client-side JavaScript and only grants read access to data that's already public. Don't paste an admin key here. + +**Deploying to a different Ghost instance.** If your instance points at a different Typesense backend, override the three settings in **Design → Customize** rather than editing the theme. The defaults in `package.json` are only the fallback for installs that don't override. + +**About the indexer.** This theme expects an existing Typesense collection populated with Ghost posts (`title`, `slug`, `excerpt`, `plaintext`, `feature_image`, `url`, `tags.name`, `tags.slug`, `published_at`, etc.). The sync mechanism (e.g. [MagicPages' Ghost-Typesense integration](https://github.com/magicpages/ghost-typesense)) is **not** part of this theme. + # PostCSS Features Used -- Autoprefixer - Don't worry about writing browser prefixes of any kind, it's all done automatically with support for the latest 2 major versions of every browser. +- Autoprefixer - Don't worry about writing browser prefixes of any kind, it's all done automatically with support for the latest 2 major versions of every browser. # Copyright & License From f87d0f2a365cba17b64525baba96f2b4c292b3f5 Mon Sep 17 00:00:00 2001 From: Terence Chan Zun Mun <zunmun@gmail.com> Date: Wed, 6 May 2026 14:31:36 +0200 Subject: [PATCH 11/13] docs(post-card): cross-reference the JS card builder mirror MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final-review item: makes the JS↔HBS mirror contract discoverable from the HBS side. A future contributor changing post-card.hbs now has a breadcrumb to assets/js/related-posts.js → buildCardElement(). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- partials/post-card.hbs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/partials/post-card.hbs b/partials/post-card.hbs index 48773c58..4de50af2 100644 --- a/partials/post-card.hbs +++ b/partials/post-card.hbs @@ -1,3 +1,7 @@ +{{!-- NOTE: assets/js/related-posts.js → buildCardElement() mirrors this + structure for JS-rendered related-posts cards. Keep them in sync — + class names, child element order, and tag/title/excerpt/date + placement are all reproduced there. --}} <article class="feed-card {{post_class}} rounded-lg shadow-2xl m-auto w-full max-w-2xl md:max-w-full lg:h-full hover:shadow-md hover:border-opacity-0 transform hover:-translate-y-1 transition-all duration-200" data-slug="{{slug}}"> From 1cf210fed9b00d652ccc0d3b3111715402439d92 Mon Sep 17 00:00:00 2001 From: Terence Chan Zun Mun <zunmun@gmail.com> Date: Wed, 6 May 2026 15:09:56 +0200 Subject: [PATCH 12/13] fix(related): exclude self by id AND slug, in case one identifier is empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User reported the recommended-articles widget was including the article itself. Live verification of Typesense's filter_by=id:!=X showed it works correctly, so the bug must be on our side: source.id from data-post-id="{{post.id}}" was likely empty for some reason, making the wrapper's `if (excludeId)` gate skip the filter, AND making the JS defensive filter useless (h.id !== '' is always true). Three layers of defense added: 1. Partial now exposes data-post-slug="{{post.slug}}" alongside data-post-id. Slug is the most reliable identifier (also in the page URL and on every post-card). 2. typesense-search.js gains an excludeSlug option. When both are passed, filter_by combines them with && so a doc matching either identifier is excluded server-side. 3. JS defensive filter now checks id, slug, AND url — any one match triggers exclusion. Empty source identifiers are skipped (no false positives where empty string matches empty field). Verified live: filter_by=slug:!=X excludes the named post correctly. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- assets/js/related-posts.js | 18 ++++++++++++++---- assets/js/typesense-search.js | 13 ++++++++++--- partials/related.hbs | 1 + 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/assets/js/related-posts.js b/assets/js/related-posts.js index 2a8e473f..2b4eb8ec 100644 --- a/assets/js/related-posts.js +++ b/assets/js/related-posts.js @@ -30,6 +30,7 @@ export default function relatedPosts() { const source = { id: root.dataset.postId || '', + slug: root.dataset.postSlug || '', title: root.dataset.postTitle || '', excerpt: root.dataset.postExcerpt || '', }; @@ -49,6 +50,7 @@ export default function relatedPosts() { try { hits = await searchSimilar(query, { excludeId: source.id, + excludeSlug: source.slug, limit: TARGET_COUNT, }); } catch (err) { @@ -58,10 +60,18 @@ export default function relatedPosts() { return; // SSR cards stay } - // Defensive: drop the source post if filter_by didn't suppress - // it (compares by id since source.id is a Ghost ObjectId, not - // a slug). Also drops malformed hits with no slug. - hits = hits.filter((h) => h.id !== source.id && h.slug); + // Defensive: drop the source post if Typesense filter_by didn't + // suppress it (e.g. data-post-id was empty so excludeId never + // reached the wrapper). Compares by id, slug, AND url so any one + // matching identifier triggers exclusion. Also drops malformed + // hits with no slug. + hits = hits.filter((h) => { + if (!h.slug) return false; + if (source.id && h.id === source.id) return false; + if (source.slug && h.slug === source.slug) return false; + if (h.url && h.url === window.location.href) return false; + return true; + }); if (hits.length === 0) return; // SSR cards stay diff --git a/assets/js/typesense-search.js b/assets/js/typesense-search.js index dd7ce8a2..fea8e7d6 100644 --- a/assets/js/typesense-search.js +++ b/assets/js/typesense-search.js @@ -42,6 +42,10 @@ const RELATED_QUERY_BY_WEIGHTS = '4,2,1'; * length-capped by the caller (see related-posts.js). * @param {object} options * @param {string} [options.excludeId] - Document id to exclude (e.g. the current post). + * @param {string} [options.excludeSlug] - Document slug to ALSO exclude. Belt- + * and-braces in case excludeId is empty/wrong; both + * conditions are AND'd in filter_by so a doc matching + * either identifier is excluded. * @param {number} [options.limit=3] - Max hits to return. * @param {AbortSignal} [options.signal] - Aborts the request. * @returns {Promise<object[]>} Array of hit documents (slug, title, excerpt, @@ -51,7 +55,7 @@ const RELATED_QUERY_BY_WEIGHTS = '4,2,1'; * is propagated as-is so callers can distinguish. */ export async function searchSimilar(query, options = {}) { - const { excludeId, limit = 3, signal } = options; + const { excludeId, excludeSlug, limit = 3, signal } = options; const { host, apiKey, collection } = getConfig(); const params = new URLSearchParams({ @@ -61,8 +65,11 @@ export async function searchSimilar(query, options = {}) { include_fields: RELATED_INCLUDE_FIELDS, per_page: String(limit), }); - if (excludeId) { - params.set('filter_by', `id:!=${excludeId}`); + const filters = []; + if (excludeId) filters.push(`id:!=${excludeId}`); + if (excludeSlug) filters.push(`slug:!=${excludeSlug}`); + if (filters.length > 0) { + params.set('filter_by', filters.join(' && ')); } const url = `${host}/collections/${collection}/documents/search?${params.toString()}`; diff --git a/partials/related.hbs b/partials/related.hbs index 4196224e..5970e4f3 100644 --- a/partials/related.hbs +++ b/partials/related.hbs @@ -6,6 +6,7 @@ class="related-wrapper" x-data="relatedPosts" data-post-id="{{post.id}}" + data-post-slug="{{post.slug}}" data-post-title="{{post.title}}" data-post-excerpt="{{post.excerpt}}"> <div class="container large"> From e852320b278b263737d71360034920a182f2dce6 Mon Sep 17 00:00:00 2001 From: Terence Chan Zun Mun <zunmun@gmail.com> Date: Wed, 6 May 2026 15:37:39 +0200 Subject: [PATCH 13/13] fix(related): rewrite Typesense URLs to current origin (no more cross-site jumps) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typesense indexes posts with production-absolute URLs (https://advisory.sg/...). The JS card builder consumed hit.url verbatim for both the inner div onclick and the stretch-link href, so clicking a related card on localhost:2368 navigated the user OFF-SITE to the prod domain. SSR cards were already correct because Ghost's {{url}} helper resolves against @site.url. Add a toLocalPath() helper that returns just pathname+search+hash from any URL (absolute, relative, or malformed). The browser then resolves the result against the current page's origin: localhost stays on localhost, prod stays on prod, no environment-specific config. Apply at three sites: the onclick interpolation, the stretch-link href, and the defensive self-exclusion URL check (which previously compared full URLs and never matched on localhost — now compares paths). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- assets/js/related-posts.js | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/assets/js/related-posts.js b/assets/js/related-posts.js index 2b4eb8ec..d19c8bd6 100644 --- a/assets/js/related-posts.js +++ b/assets/js/related-posts.js @@ -21,6 +21,21 @@ import { searchSimilar } from './typesense-search.js'; const TARGET_COUNT = 3; const QUERY_MAX_CHARS = 500; // keeps Typesense URL well under 8KB +// Convert an absolute URL (Typesense indexes posts with production +// absolute URLs like https://advisory.sg/path/) into a same-origin +// path so the browser resolves it against the current site. On +// localhost:2368 a click stays on localhost; on prod it stays on prod. +// Falls back to the original string on parse failure. +function toLocalPath(url) { + if (!url) return url; + try { + const parsed = new URL(url, window.location.origin); + return parsed.pathname + parsed.search + parsed.hash; + } catch { + return url; + } +} + export default function relatedPosts() { return { async init() { @@ -69,7 +84,11 @@ export default function relatedPosts() { if (!h.slug) return false; if (source.id && h.id === source.id) return false; if (source.slug && h.slug === source.slug) return false; - if (h.url && h.url === window.location.href) return false; + // Compare paths instead of full URLs so the localhost ↔ + // production origin mismatch in Typesense data doesn't + // defeat this check. + if (h.url && toLocalPath(h.url) === window.location.pathname) + return false; return true; }); @@ -130,7 +149,10 @@ function buildCardElement(hit) { const inner = document.createElement('div'); inner.className = 'flex flex-col cursor-pointer h-full rounded-xl overflow-hidden md:flex-row md:h-80 lg:flex-col lg:h-full'; - inner.setAttribute('onclick', `location.href='${hit.url || '#'}'`); + inner.setAttribute( + 'onclick', + `location.href='${toLocalPath(hit.url) || '#'}'`, + ); if (hit.feature_image) { const img = document.createElement('img'); @@ -199,7 +221,7 @@ function buildCardElement(hit) { inner.appendChild(body); const stretchAnchor = document.createElement('a'); - stretchAnchor.href = hit.url || '#'; + stretchAnchor.href = toLocalPath(hit.url) || '#'; const stretchSpan = document.createElement('span'); stretchSpan.className = 'stretch-link'; stretchAnchor.appendChild(stretchSpan);