Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
| ---------------------- | ------------------------------- | ------------------------------------------------------------------ |
Expand All @@ -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

Expand Down
2 changes: 2 additions & 0 deletions assets/js/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
246 changes: 246 additions & 0 deletions assets/js/related-posts.js
Original file line number Diff line number Diff line change
@@ -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 <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 =
'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;
}
67 changes: 64 additions & 3 deletions assets/js/typesense-search.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
// 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
// API key. It is read-only and scoped to the configured collection. Embedding
// 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
Expand All @@ -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.
*
Expand Down Expand Up @@ -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<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, 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);
}
2 changes: 1 addition & 1 deletion default.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
</script>

{{!-- 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). --}}
<script>
window.__TYPESENSE_CONFIG__ = {
Expand Down
Loading
Loading