diff --git a/README.md b/README.md index fe8c83a1..0ac9159e 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,7 @@ For the homepage and separate [Stories](https://beta.advisory.sg/stories) page t # Typesense Search -The `/events/` and `/interviews/` pages have a typo-tolerant search box backed by [Typesense](https://typesense.org/). Three settings live under `config.custom` in `package.json` and are admin-overridable in **Ghost Admin → Settings → Design → Customize**: +The `/events/` and `/interviews/` pages have a typo-tolerant search box backed by [Typesense](https://typesense.org/), and the bottom-of-article "you might also like" widget uses Typesense for 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 | | ---------------------- | ------------------------------- | ------------------------------------------------------------------ | @@ -87,7 +87,7 @@ The flow is: `package.json` defaults → `default.hbs` injects them as `window._ **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`, `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. +**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 diff --git a/assets/js/main.js b/assets/js/main.js index ec578e58..02412556 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -9,9 +9,11 @@ import Alpine from 'alpinejs'; import 'flowbite'; import postFilterList from './post-filter-list.js'; +import relatedPosts from './related-posts.js'; window.Alpine = Alpine; Alpine.data('postFilterList', postFilterList); +Alpine.data('relatedPosts', relatedPosts); Alpine.start(); var body = $('body'); diff --git a/assets/js/related-posts.js b/assets/js/related-posts.js new file mode 100644 index 00000000..d19c8bd6 --- /dev/null +++ b/assets/js/related-posts.js @@ -0,0 +1,246 @@ +// 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 + +// 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() { + const root = this.$el; + const grid = root.querySelector('.related-feed'); + if (!grid) return; + + const source = { + id: root.dataset.postId || '', + slug: root.dataset.postSlug || '', + 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, + excludeSlug: source.slug, + limit: TARGET_COUNT, + }); + } catch (err) { + if (err.name !== 'AbortError') { + console.warn('related-posts: Typesense search failed', err); + } + return; // SSR cards stay + } + + // 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; + // 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; + }); + + 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. +// +// 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 entirely when feature_image is empty; post-card.hbs +// renders a hidden placeholder 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 = + '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='${toLocalPath(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 = toLocalPath(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. +// Both arrays are requested via RELATED_INCLUDE_FIELDS in typesense-search.js. +function pickPrimaryTag(hit) { + 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-')) { + return { name: names[i] || slugs[i], slug: slugs[i] }; + } + } + return null; +} diff --git a/assets/js/typesense-search.js b/assets/js/typesense-search.js index 7a9b606a..ab066031 100644 --- a/assets/js/typesense-search.js +++ b/assets/js/typesense-search.js @@ -1,7 +1,7 @@ // Wrapper around Typesense's search REST API for the post-filter-list component. // // Configuration source of truth is `package.json` under `config.custom.typesense_*`, -// admin-overridable in Ghost → Design → Customize → Search. `default.hbs` injects +// 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 @@ -9,7 +9,8 @@ // it client-side is intentional and safe (Typesense convention). // // Schema verified live on 2026-05-06: collection holds 283 documents with -// fields title, slug, excerpt, plaintext, tags.slug, published_at, etc. +// fields title, slug, excerpt, plaintext, feature_image, url, tags.name, +// tags.slug, published_at, etc. // Defaults match the Advisory SG production Typesense instance. They serve as // fallbacks when the inline config script is missing (e.g. partial page renders @@ -30,11 +31,20 @@ function getConfig() { }; } -// Fields and weights — see spec. +// Fields and weights used by list-page search — see spec. const QUERY_BY = 'title,excerpt,plaintext'; const QUERY_BY_WEIGHTS = '4,2,1'; const PER_PAGE = 250; +// 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 = + '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'; +const RELATED_QUERY_BY_WEIGHTS = '4,2,1'; + /** * Search the configured Typesense collection and return matching post slugs. * @@ -72,3 +82,54 @@ export async function searchSlugs(query, tagSlugs, signal) { const slugs = (data.hits || []).map((h) => h.document.slug); return new Set(slugs); } + +/** + * 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 {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} 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, excludeSlug, 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), + }); + 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()}`; + 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); +} diff --git a/default.hbs b/default.hbs index b4e4128c..40d4a6e7 100644 --- a/default.hbs +++ b/default.hbs @@ -26,7 +26,7 @@ {{!-- Typesense search config — sourced from package.json config.custom, - overridable per-install in Ghost Admin → Design → Customize → Search. + overridable per-install in Ghost Admin -> Design -> Customize. The API key here is search-only (read-only, public-scoped). --}} ` 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 `` 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). }} + +``` + +- [ ] **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} 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: ` 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 `
` 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 `
` is enough. + +- [ ] **Step 1: Edit the root `
` element** + +Find lines 1-4 of `partials/post-card.hbs` (current state): + +```handlebars +
+
+``` + +Replace the opening `
` tag (the first two lines through the closing `>`) with: + +```handlebars +
+``` + +`{{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=""` 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 `