Amili Kit
Small, honest parts for websites.
The parts we use on our own sites, free to copy. Search, tables, FAQs, calculators, a footer, an Amazon product card and more, each one tested and each one working without a framework. MIT licence. No build step, no dependencies, no tracking.
Twelve parts, one shape
Each part is one file that exports render(options), which returns HTML, plus its CSS and, where it needs any, a small script. Every part follows your own colours in light and dark, keeps tap targets at 44 pixels, and never moves the page while it loads. Each ships with tests, and with a list of planted bugs that each test has to catch.
Amili Search New
Instant, typo-tolerant site search over a static JSON index. Combobox ARIA, "/" to focus, highlighted matches, "did you mean", a clear empty state, zero-result logging to an endpoint you choose. Fast on 10,000+ pages.
Live demo
| Option | What it does |
|---|---|
index | Required. URL of the JSON index that build-index.mjs writes after your build. |
logUrl | Your endpoint for searches that found nothing (sendBeacon, no cookies). Leave it out to log nothing. |
suggest | Words offered when nothing matches. |
action | A search page for browsers without JavaScript. |
prefillFromPath | For 404 pages: starts searching for the words of the missing address. |
brand | The tiny amili mark in the results. On by default here; false hides it. |
Show the code
// Amili Kit: Amili Search. https://amili.ai/kit#kit-search
// SPDX-License-Identifier: MIT. Copyright (c) 2026 Amili (Paulo de Vries). Made by Amili.
// Tests and more parts: https://amili.ai/kit. The "brand" option shows a tiny "amili" mark linking to the kit; set
// brand: false to hide it.
//
// import { renderSearch, SEARCH_CSS, SEARCH_JS } from './kit/search.mjs';
// html = `<style>${SEARCH_CSS}</style>` + renderSearch({ index: '/search-index.json' }) + `<script>${SEARCH_JS}</script>`
// index: node kit/search-build-index.mjs <out-dir> (writes <out-dir>/search-index.json)
//
// opts = {
// index: '/search-index.json', // required: the JSON rows [title, description, url, label?, keywords?]
// placeholder: 'Search', // input placeholder
// label: 'Search this site', // accessible name (visually hidden label)
// action: '/search/', // optional no-JS fallback: GET <action>?q=… (omit = JS only)
// max: 8, // results shown
// suggest: ['guides', 'tools'], // shown when nothing matches (buttons that fill the box)
// logUrl: 'https://your-endpoint.example/search-log', // optional: POST {q, results, path} via sendBeacon
// logAll: false, // false = log zero-result queries only; true = every settled query
// ga4: false, // true = also fire GA4 `search` + `search_no_results` if gtag exists
// clarity: false, // true = also fire Clarity Search / SearchNoResults events + tags if clarity exists
// prefillFromPath: false, // 404 pages: start with the words of the missing URL as the query
// hotkey: true, // "/" and Ctrl/Cmd+K focus the box
// brand: BRAND_DEFAULT, // tiny "amili" mark in the results corner, links to amili.ai/kit
// id: 'site-search', // unique per page if you place two
// }
// The page also gets a bubbling `ak-search` CustomEvent {q, results} on the form for your own analytics.
const BRAND_DEFAULT = true; // a tiny "amili" mark linking to amili.ai/kit; pass brand: false to hide it
const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
// ---------------------------------------------------------------------------------------------------------------
// The engine. Plain ES5 functions: they run in Node for the tests AND are serialised verbatim into SEARCH_JS, so the
// code the tests prove is byte-for-byte the code the browser runs.
// ---------------------------------------------------------------------------------------------------------------
function norm(s) {
s = String(s == null ? '' : s).toLowerCase();
if (/[^\x00-\x7f]/.test(s)) s = s.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
return s.replace(/[^a-z0-9]+/g, ' ').trim();
}
// Bounded Damerau-Levenshtein (adjacent swaps count as one typo). Returns k+1 as soon as the distance must exceed k.
function within(a, b, k) {
var la = a.length, lb = b.length, i, j;
if (Math.abs(la - lb) > k) return k + 1;
var pp = null, p = [], c;
for (j = 0; j <= lb; j++) p[j] = j;
for (i = 1; i <= la; i++) {
c = [i];
var lo = i;
for (j = 1; j <= lb; j++) {
var v = Math.min(p[j] + 1, c[j - 1] + 1, p[j - 1] + (a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1));
if (i > 1 && j > 1 && a.charCodeAt(i - 1) === b.charCodeAt(j - 2) && a.charCodeAt(i - 2) === b.charCodeAt(j - 1)) v = Math.min(v, pp[j - 2] + 1);
c[j] = v;
if (v < lo) lo = v;
}
if (lo > k) return k + 1;
pp = p; p = c;
}
return p[lb];
}
function typos(len) { return len >= 8 ? 2 : len >= 4 ? 1 : 0; }
// rows: [title, description, url, label?, keywords?] or {t, d, u, g, k, b}. Builds a vocabulary with postings once.
function makeIndex(rows) {
var docs = [], vocab = Object.create(null), words = [];
for (var i = 0; i < rows.length; i++) {
var r = rows[i], o = Array.isArray(r) ? { t: r[0], d: r[1], u: r[2], g: r[3], k: r[4] } : r;
var tn = norm(o.t), doc = { t: String(o.t || ''), d: String(o.d || ''), u: String(o.u || '/'), g: o.g ? String(o.g) : '', b: +o.b || 0, tn: tn };
docs.push(doc);
var seen = Object.create(null), tw = tn ? tn.split(' ') : [], ow = norm((o.d || '') + ' ' + (o.k || '') + ' ' + (o.g || '')).split(' ');
for (var a = 0; a < tw.length + ow.length; a++) {
var w = a < tw.length ? tw[a] : ow[a - tw.length], f = a < tw.length ? 3 : 1;
if (!w || (seen[w] || 0) >= f) continue;
if (!vocab[w]) { vocab[w] = []; words.push(w); }
if (seen[w]) { var pl = vocab[w]; pl[pl.length - 1] = i * 4 + f; } else vocab[w].push(i * 4 + f);
seen[w] = f;
}
}
return { docs: docs, vocab: vocab, words: words };
}
// Which vocabulary words a query token hits, and how well. The last token also matches as a prefix (typing).
function termHits(ix, tok, isLast) {
var out = [], k = typos(tok.length), ws = ix.words;
for (var i = 0; i < ws.length; i++) {
var w = ws[i], s = 0;
if (w === tok) s = 1;
else if (isLast && w.length > tok.length && w.lastIndexOf(tok, 0) === 0) s = 0.85;
else if (k) {
var d = within(tok, w, k);
if (d <= k) s = d === 1 ? 0.6 : 0.4;
else if (isLast && w.length > tok.length && tok.length >= 4) {
d = within(tok, w.slice(0, tok.length), k);
if (d <= k) s = d === 1 ? 0.5 : 0.3;
}
}
if (s) out.push([w, s]);
}
return out;
}
// Returns { hits: [{t,d,u,g}], terms: {word: 1}, partial: bool }. All tokens must match; if none do, the rows that
// match the most tokens come back with partial=true (never an empty screen when something is close).
function query(ix, q, max) {
var toks = norm(q).split(' ').filter(Boolean).slice(0, 16), terms = {}, per = [], n = ix.docs.length, i, j;
if (!toks.length) return { hits: [], terms: terms, partial: false };
for (i = 0; i < toks.length; i++) {
var th = termHits(ix, toks[i], i === toks.length - 1), best = Object.create(null);
for (j = 0; j < th.length; j++) {
terms[th[j][0]] = 1;
var pl = ix.vocab[th[j][0]];
for (var p = 0; p < pl.length; p++) {
var id = pl[p] >> 2, sc = th[j][1] * (pl[p] & 3);
if (!(best[id] >= sc)) best[id] = sc;
}
}
per.push(best);
}
var score = Object.create(null), cnt = Object.create(null), top = 0;
for (i = 0; i < per.length; i++) for (var key in per[i]) { score[key] = (score[key] || 0) + per[i][key]; cnt[key] = (cnt[key] || 0) + 1; if (cnt[key] > top) top = cnt[key]; }
var phrase = toks.join(' '), res = [];
for (var id2 in score) {
if (cnt[id2] < top) continue;
var d = ix.docs[id2], s = score[id2] + d.b;
if (d.tn.lastIndexOf(phrase, 0) === 0) s += 3; else if (d.tn.indexOf(phrase) !== -1) s += 1.5;
s -= Math.min(d.t.length, 160) / 200;
res.push([s, +id2]);
}
res.sort(function (a, b) { return b[0] - a[0] || a[1] - b[1]; });
var hits = [];
for (i = 0; i < res.length && i < (max || 8); i++) hits.push(ix.docs[res[i][1]]);
return { hits: hits, terms: terms, partial: top < toks.length && hits.length > 0, total: res.length };
}
// "Did you mean": the closest vocabulary word (most postings wins a tie) for every token that found nothing.
function didYouMean(ix, q) {
var toks = norm(q).split(' ').filter(Boolean), out = [], changed = false;
for (var i = 0; i < toks.length; i++) {
var t = toks[i], best = null, bd = 99, bn = 0;
if (ix.vocab[t]) { out.push(t); continue; }
var k = Math.max(2, Math.floor(t.length / 3));
for (var j = 0; j < ix.words.length; j++) {
var w = ix.words[j], d = within(t, w, k);
if (d <= k && (d < bd || (d === bd && ix.vocab[w].length > bn))) { best = w; bd = d; bn = ix.vocab[w].length; }
}
if (best) { out.push(best); changed = true; } else out.push(t);
}
return changed ? out.join(' ') : '';
}
// Escaped HTML with <mark> around the words the query matched.
function highlight(text, terms) {
var parts = String(text).split(/([A-Za-z0-9\u00c0-\u024f]+)/), h = '';
for (var i = 0; i < parts.length; i++) {
var e = parts[i].replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
h += (i % 2 && terms[norm(parts[i])]) ? '<mark>' + e + '</mark>' : e;
}
return h;
}
// Logging privacy: lowercase, no e-mail addresses, no phone-shaped digits, 60 chars. One typing session logs once.
function cleanQuery(s) {
return String(s || '').toLowerCase().replace(/[\w.+-]+@[\w-]+\.[\w.-]+/g, ' ').replace(/\+?\d[\d\s().-]{7,}\d/g, ' ')
.replace(/\s+/g, ' ').trim().slice(0, 60);
}
function sameTypingSession(a, b) {
var s = a.length < b.length ? a : b, i = 0;
while (i < s.length && a.charAt(i) === b.charAt(i)) i++;
return i >= s.length * 0.6;
}
export const engine = { norm, within, typos, makeIndex, termHits, query, didYouMean, highlight, cleanQuery, sameTypingSession };
// ---------------------------------------------------------------------------------------------------------------
// Markup (server side). The results box is absolutely positioned, so opening it never moves the page.
// ---------------------------------------------------------------------------------------------------------------
export function brandMark(on) {
return on ? '<a class="ak-brand" href="https://amili.ai/kit" rel="noopener" aria-label="Made with Amili Kit"><span class="ak-brand-dot" aria-hidden="true"></span>amili</a>' : '';
}
export function renderSearch(opts = {}) {
if (!opts.index) throw new Error('Amili Search: opts.index (URL of the JSON index) is required');
if (opts.logUrl && !/^(https:\/\/|\/)/.test(opts.logUrl)) throw new Error('Amili Search: logUrl must be https:// or a same-site path');
const id = esc(opts.id || 'site-search');
const brand = opts.brand === undefined ? BRAND_DEFAULT : !!opts.brand;
const data = [
`data-ak-search`, `data-index="${esc(opts.index)}"`,
opts.max ? `data-max="${+opts.max}"` : '',
opts.suggest?.length ? `data-suggest="${esc(opts.suggest.join('|'))}"` : '',
opts.logUrl ? `data-log="${esc(opts.logUrl)}"` : '', opts.logAll ? 'data-log-all' : '',
opts.ga4 ? 'data-ga4' : '', opts.clarity ? 'data-clarity' : '', opts.prefillFromPath ? 'data-prefill-path' : '', opts.hotkey === false ? 'data-no-hotkey' : '',
].filter(Boolean).join(' ');
const action = opts.action ? ` action="${esc(opts.action)}" method="get"` : '';
return `<form class="ak-search" role="search"${action} ${data}>`
+ `<label class="ak-s-label" for="${id}">${esc(opts.label || 'Search this site')}</label>`
+ `<div class="ak-s-box"><svg class="ak-s-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="11" cy="11" r="6.5"/><path d="M16 16l4.5 4.5"/></svg>`
+ `<input id="${id}" name="q" type="search" placeholder="${esc(opts.placeholder || 'Search')}" autocomplete="off" autocapitalize="off" spellcheck="false" enterkeyhint="search" role="combobox" aria-expanded="false" aria-controls="${id}-list" aria-autocomplete="list" aria-describedby="${id}-status">`
+ (opts.hotkey === false ? '' : `<kbd class="ak-s-kbd" aria-hidden="true">/</kbd>`)
+ `</div>`
+ `<div class="ak-s-pop" hidden><ul class="ak-s-list" id="${id}-list" role="listbox" aria-label="Results"></ul><div class="ak-s-empty" hidden></div>`
+ `<div class="ak-s-foot"><span class="ak-s-keys" aria-hidden="true"><kbd>↑</kbd><kbd>↓</kbd> move <kbd>Enter</kbd> open <kbd>Esc</kbd> close</span>${brandMark(brand)}</div></div>`
+ `<p class="ak-s-status" id="${id}-status" role="status" aria-live="polite"></p>`
+ `</form>`;
}
export const BRAND_CSS = `.ak-brand{display:inline-flex;align-items:center;gap:.3em;min-height:44px;padding:0 .25rem;font:400 11px/1 Georgia,"Times New Roman",serif;letter-spacing:.01em;color:inherit;opacity:.5;text-decoration:none}
.ak-brand:hover,.ak-brand:focus-visible{opacity:.85}
.ak-brand-dot{width:.5em;height:.5em;border-radius:50%;background:#2F5D50;order:2;margin-top:-.45em}
@media (prefers-color-scheme:dark){.ak-brand-dot{background:#7FB3A1}}`;
export const SEARCH_CSS = `.ak-search{position:relative;max-width:var(--ak-max,40rem);margin:0;font:inherit;color:inherit}
.ak-s-label{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
.ak-s-box{position:relative;display:flex;align-items:center;height:48px;border:1px solid var(--ak-line,color-mix(in srgb,currentColor 22%,transparent));border-radius:var(--ak-radius,12px);background:var(--ak-bg,Canvas);color:var(--ak-fg,CanvasText)}
.ak-s-box:focus-within{border-color:var(--ak-accent,currentColor);box-shadow:0 0 0 3px color-mix(in srgb,var(--ak-accent,currentColor) 18%,transparent)}
.ak-s-icon{flex:0 0 auto;width:20px;height:20px;margin:0 0 0 14px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;opacity:.6}
.ak-search input{flex:1;min-width:0;height:100%;border:0;background:transparent;color:inherit;font:inherit;font-size:16px;padding:0 12px;outline:0;-webkit-appearance:none;appearance:none}
.ak-search input::-webkit-search-cancel-button{-webkit-appearance:none}
.ak-s-kbd{margin-right:12px;padding:1px 7px;border:1px solid var(--ak-line,color-mix(in srgb,currentColor 25%,transparent));border-radius:6px;font:12px/1.4 ui-monospace,Menlo,monospace;opacity:.6}
.ak-search:focus-within .ak-s-kbd{display:none}
.ak-s-pop{position:absolute;z-index:50;left:0;right:0;top:calc(100% + 6px);border:1px solid var(--ak-line,color-mix(in srgb,currentColor 18%,transparent));border-radius:var(--ak-radius,12px);background:var(--ak-bg,Canvas);color:var(--ak-fg,CanvasText);box-shadow:0 18px 40px -18px rgba(0,0,0,.35);overflow:hidden}
.ak-s-pop[hidden]{display:none}
.ak-s-list{list-style:none;margin:0;padding:6px;max-height:min(60vh,28rem);overflow-y:auto;overscroll-behavior:contain}
.ak-s-list li{display:block;min-height:44px;padding:9px 12px;border-radius:8px;cursor:pointer;line-height:1.35}
.ak-s-list li[aria-selected=true],.ak-s-list li:hover{background:color-mix(in srgb,currentColor 8%,transparent)}
.ak-s-t{display:block;font-weight:600}
.ak-s-g{float:right;margin-left:8px;font-size:12px;opacity:.6;font-weight:400}
.ak-s-d{display:block;font-size:.875rem;opacity:.72;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ak-search mark{background:color-mix(in srgb,var(--ak-mark,#e9c46a) 45%,transparent);color:inherit;border-radius:2px}
.ak-s-empty{padding:14px 16px;font-size:.9375rem}
.ak-s-empty p{margin:0 0 8px}
.ak-s-sug{display:flex;flex-wrap:wrap;gap:6px}
.ak-s-sug button{min-height:44px;padding:0 14px;border:1px solid var(--ak-line,color-mix(in srgb,currentColor 22%,transparent));border-radius:999px;background:transparent;color:inherit;font:inherit;font-size:.875rem;cursor:pointer}
.ak-s-foot{display:flex;align-items:center;justify-content:space-between;gap:8px;min-height:36px;padding:0 10px 0 14px;border-top:1px solid var(--ak-line,color-mix(in srgb,currentColor 12%,transparent));font-size:12px;opacity:.85}
.ak-s-foot kbd{font:11px/1 ui-monospace,Menlo,monospace;padding:2px 4px;border:1px solid var(--ak-line,color-mix(in srgb,currentColor 25%,transparent));border-radius:4px}
.ak-s-status{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
@media (pointer:coarse){.ak-s-keys,.ak-s-kbd{display:none}.ak-s-foot:not(:has(.ak-brand)){display:none}}
:root[data-theme=dark] .ak-search{color-scheme:dark}
${BRAND_CSS}`;
// ---------------------------------------------------------------------------------------------------------------
// The browser script: the engine above (verbatim) + the widget. Self-initialises every [data-ak-search] form.
// ---------------------------------------------------------------------------------------------------------------
function widget() {
var E = { norm: norm, makeIndex: makeIndex, query: query, didYouMean: didYouMean, highlight: highlight, cleanQuery: cleanQuery, sameTypingSession: sameTypingSession };
var cache = {};
function loadIndex(url, cb) {
var c = cache[url] || (cache[url] = { ix: null, q: [], busy: false });
if (c.ix) return cb(c.ix);
c.q.push(cb);
if (c.busy) return;
c.busy = true;
fetch(url, { credentials: 'omit' }).then(function (r) { if (!r.ok) throw new Error(r.status); return r.json(); })
.then(function (rows) { c.ix = E.makeIndex(rows || []); }, function () { c.ix = E.makeIndex([]); c.failed = true; })
.then(function () { var w = c.q, ix = c.ix; c.q = []; c.busy = false; if (c.failed) { c.ix = null; c.failed = false; } for (var i = 0; i < w.length; i++) w[i](ix); }); // a failed fetch is retried on the next keystroke, never cached as an empty index
}
function init(form, n) {
if (form.__ak) return; form.__ak = 1;
var inp = form.querySelector('input'), pop = form.querySelector('.ak-s-pop'), list = form.querySelector('.ak-s-list'),
empty = form.querySelector('.ak-s-empty'), status = form.querySelector('.ak-s-status');
var url = form.getAttribute('data-index'), max = +form.getAttribute('data-max') || 8, logUrl = form.getAttribute('data-log'),
logAll = form.hasAttribute('data-log-all'), ga4 = form.hasAttribute('data-ga4'), cla = form.hasAttribute('data-clarity'),
sug = (form.getAttribute('data-suggest') || '').split('|').filter(Boolean);
var hits = [], sel = -1, last = '', timer = 0, frame = 0, logged = '', pid = inp.id || ('ak-s' + n);
function log(q, count) {
q = E.cleanQuery(q);
if (!q) return;
if (logged && E.sameTypingSession(q, logged)) { if (q.length > logged.length) logged = q; return; }
logged = q;
try { form.dispatchEvent(new CustomEvent('ak-search', { bubbles: true, detail: { q: q, results: count } })); } catch (e) {}
try {
if (ga4 && window.gtag) { gtag('event', 'search', { search_term: q, results: count }); if (!count) gtag('event', 'search_no_results', { search_term: q }); }
if (cla && window.clarity) { clarity('event', count ? 'Search' : 'SearchNoResults'); clarity('set', count ? 'search_query' : 'search_noresult', q); }
if (logUrl && (logAll || !count) && navigator.sendBeacon) navigator.sendBeacon(logUrl, JSON.stringify({ q: q, results: count, path: location.pathname }));
} catch (e) { /* analytics never breaks search */ }
}
function flush() { if (timer) { clearTimeout(timer); timer = 0; if (last.trim()) log(last, hits.length); } }
function show(on) { pop.hidden = !on; inp.setAttribute('aria-expanded', on && hits.length ? 'true' : 'false'); }
function paint(res, q, ix) {
hits = res.hits; sel = hits.length ? 0 : -1;
var h = '';
for (var i = 0; i < hits.length; i++) {
var r = hits[i];
h += '<li role="option" id="' + pid + '-o' + i + '" aria-selected="' + (i === sel) + '" data-i="' + i + '">'
+ '<span class="ak-s-t">' + (r.g ? '<span class="ak-s-g">' + E.highlight(r.g, {}) + '</span>' : '') + E.highlight(r.t, res.terms) + '</span>'
+ (r.d ? '<span class="ak-s-d">' + E.highlight(r.d, res.terms) + '</span>' : '') + '</li>';
}
list.innerHTML = h;
list.hidden = !hits.length;
if (!hits.length) {
var dym = ix ? E.didYouMean(ix, q) : '', b = '';
if (dym) b += '<button type="button" data-q="' + E.highlight(dym, {}) + '">' + E.highlight(dym, {}) + '</button>';
for (var j = 0; j < sug.length && j < 5; j++) if (sug[j] !== dym) b += '<button type="button" data-q="' + E.highlight(sug[j], {}) + '">' + E.highlight(sug[j], {}) + '</button>';
empty.innerHTML = '<p>No results for <strong>' + E.highlight(q.trim().slice(0, 60), {}) + '</strong>.' + (dym ? ' Did you mean:' : b ? ' Try:' : ' Try a shorter word.') + '</p>' + (b ? '<div class="ak-s-sug">' + b + '</div>' : '');
empty.hidden = false;
} else empty.hidden = true;
status.textContent = hits.length ? hits.length + (res.total > hits.length ? ' of ' + res.total : '') + ((res.total || hits.length) === 1 ? ' result' : ' results') + (res.partial ? ', closest matches' : '') : 'No results';
aria();
show(true);
}
function aria() {
if (sel >= 0) inp.setAttribute('aria-activedescendant', pid + '-o' + sel); else inp.removeAttribute('aria-activedescendant');
var li = list.children;
for (var i = 0; i < li.length; i++) li[i].setAttribute('aria-selected', i === sel ? 'true' : 'false');
if (sel >= 0 && li[sel] && li[sel].scrollIntoView) li[sel].scrollIntoView({ block: 'nearest' });
}
function run() {
frame = 0;
var q = inp.value;
if (q === last && !pop.hidden) return;
last = q;
clearTimeout(timer); timer = 0;
if (!q.trim()) { hits = []; list.innerHTML = ''; empty.hidden = true; status.textContent = ''; show(false); return; }
loadIndex(url, function (ix) {
if (inp.value !== q) return;
paint(E.query(ix, q, max), q, ix);
timer = setTimeout(function () { timer = 0; log(q, hits.length); }, 1800);
});
}
function go(i, newTab) {
var r = hits[i]; if (!r) return;
flush();
if (newTab) window.open(r.u, '_blank', 'noopener'); else location.href = r.u;
}
inp.addEventListener('focus', function () { loadIndex(url, function () {}); if (inp.value.trim()) { last = ''; run(); } });
inp.addEventListener('input', function () { if (!frame) frame = requestAnimationFrame(run); });
inp.addEventListener('keydown', function (e) {
var n2 = hits.length;
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
if (pop.hidden) { last = ''; run(); return; }
if (n2) { sel = (sel + (e.key === 'ArrowDown' ? 1 : -1) + n2) % n2; aria(); }
} else if (e.key === 'Enter') {
if (n2 && !pop.hidden) { e.preventDefault(); go(sel < 0 ? 0 : sel, e.metaKey || e.ctrlKey); }
else if (!form.getAttribute('action')) e.preventDefault();
else flush();
} else if (e.key === 'Escape') {
if (!pop.hidden) { e.preventDefault(); flush(); show(false); } else if (inp.value) { e.preventDefault(); inp.value = ''; run(); }
}
});
list.addEventListener('mousedown', function (e) { e.preventDefault(); });
list.addEventListener('click', function (e) {
var li = e.target.closest && e.target.closest('li[data-i]');
if (li) go(+li.getAttribute('data-i'), e.metaKey || e.ctrlKey || e.button === 1);
});
empty.addEventListener('mousedown', function (e) { e.preventDefault(); });
empty.addEventListener('click', function (e) {
var b = e.target.closest && e.target.closest('button[data-q]');
if (b) { inp.value = b.getAttribute('data-q'); inp.focus(); last = ''; run(); }
});
form.addEventListener('submit', function (e) { if (!form.getAttribute('action')) e.preventDefault(); });
form.addEventListener('focusout', function (e) { if (!form.contains(e.relatedTarget)) { flush(); show(false); } });
if (form.hasAttribute('data-prefill-path')) {
var words = decodeURIComponent(location.pathname).split(/[\/_.\-+]+/).filter(function (w) { return w && !/^(index|html?|php)$/i.test(w); }).slice(-4).join(' ');
if (words) { inp.value = words; loadIndex(url, function () { last = ''; run(); }); }
}
}
var forms = document.querySelectorAll('form[data-ak-search]');
for (var i = 0; i < forms.length; i++) init(forms[i], i);
document.addEventListener('keydown', function (e) {
var t = e.target, typing = t && (t.isContentEditable || /^(input|textarea|select)$/i.test(t.tagName));
if ((e.key === '/' && !typing && !e.ctrlKey && !e.metaKey && !e.altKey) || (e.key && e.key.toLowerCase() === 'k' && (e.metaKey || e.ctrlKey))) {
var f = document.querySelector('form[data-ak-search]:not([data-no-hotkey]) input');
if (f) { e.preventDefault(); var dlg = f.closest && f.closest('dialog'); if (dlg && !dlg.open && dlg.showModal) dlg.showModal(); f.focus(); if (f.select) f.select(); }
}
});
}
// ---------------------------------------------------------------------------------------------------------------
// Site-wide launcher (the rollout shape): one script file adds Amili Search to every page of a site without touching
// the page templates. It puts the search form in a native <dialog> (focus trap, Esc, backdrop for free), opens it
// from "/", Ctrl/Cmd+K, a small 44px button, and any existing search box the site already has (takeover), so a
// hand-made search is replaced in place. The button is fixed (no layout shift) and lifts itself above any bar that
// is pinned to the bottom of the screen (a sticky buy bar, a consent bar).
// ---------------------------------------------------------------------------------------------------------------
function launcher(cfg) {
var dlg = document.createElement('dialog');
dlg.className = 'ak-s-dlg';
dlg.setAttribute('aria-label', cfg.label || 'Search this site');
dlg.innerHTML = cfg.form + '<button type="button" class="ak-s-x" aria-label="Close search"><span aria-hidden="true">×</span></button>';
document.body.appendChild(dlg);
var st = document.createElement('style'); st.textContent = cfg.css; document.head.appendChild(st);
var inp = dlg.querySelector('input');
function open(q) {
if (!dlg.open) { try { dlg.showModal(); } catch (e) { dlg.setAttribute('open', ''); } }
if (typeof q === 'string' && q) { inp.value = q; inp.dispatchEvent(new Event('input', { bubbles: true })); }
inp.focus(); if (!q && inp.select) inp.select();
}
function close() { if (dlg.open && dlg.close) dlg.close(); else dlg.removeAttribute('open'); }
dlg.querySelector('.ak-s-x').addEventListener('click', close);
dlg.addEventListener('click', function (e) { if (e.target === dlg) close(); });
dlg.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && dlg.querySelector('.ak-s-pop').hidden) { e.preventDefault(); close(); }
});
var take = cfg.takeover ? document.querySelectorAll(cfg.takeover) : [];
for (var i = 0; i < take.length; i++) (function (el) {
if (dlg.contains(el)) return;
var form = el.closest && el.closest('form');
function go(e) { if (e) { e.preventDefault(); } var v = (el.value || '').trim(); if (el.blur) el.blur(); open(v); }
if (/^(input|textarea)$/i.test(el.tagName)) {
el.addEventListener('focus', function () { go(); });
el.setAttribute('autocomplete', 'off');
if (form) form.addEventListener('submit', go);
} else el.addEventListener('click', go);
})(take[i]);
var opens = document.querySelectorAll('[data-ak-search-open]');
for (var j = 0; j < opens.length; j++) opens[j].addEventListener('click', function (e) { e.preventDefault(); open(); });
if (!take.length && !opens.length && cfg.button !== false) {
var b = document.createElement('button');
b.type = 'button'; b.className = 'ak-s-fab ak-s-fab-' + (cfg.position === 'right' ? 'r' : 'l');
b.setAttribute('aria-label', (cfg.label || 'Search this site') + ' (press /)');
b.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><circle cx="11" cy="11" r="6.5"/><path d="M16 16l4.5 4.5"/></svg>';
b.addEventListener('click', function () { open(); });
document.body.appendChild(b);
var lifting = 0;
function lift() {
lifting = 0;
var x = cfg.position === 'right' ? innerWidth - 38 : 38, y = innerHeight - 38, top = innerHeight, els = [];
b.style.visibility = 'hidden';
try { els = document.elementsFromPoint(x, y); } catch (e) {}
b.style.visibility = '';
for (var k = 0; k < els.length; k++) {
for (var n = els[k]; n && n !== document.body && n !== document.documentElement; n = n.parentElement) {
var p = getComputedStyle(n).position;
if (p === 'fixed' || p === 'sticky') { var r = n.getBoundingClientRect(); if (r.bottom >= innerHeight - 2 && r.top < top) top = r.top; break; }
}
}
b.style.transform = top < innerHeight ? 'translateY(' + -(innerHeight - top) + 'px)' : '';
}
function soon() { if (!lifting) lifting = requestAnimationFrame(lift); }
addEventListener('scroll', soon, { passive: true }); addEventListener('resize', soon); setTimeout(lift, 400); lift();
}
window.amiliSearch = { open: open, close: close };
}
export const LAUNCHER_CSS = `.ak-s-dlg{width:min(40rem,calc(100% - 24px));max-width:none;margin:10vh auto auto;padding:12px;border:0;border-radius:16px;background:var(--ak-bg,Canvas);color:var(--ak-fg,CanvasText);box-shadow:0 24px 60px -12px rgba(0,0,0,.45);overflow:visible;font:16px/1.4 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif}
.ak-s-dlg::backdrop{background:rgba(10,12,18,.45)}
.ak-s-dlg .ak-search{max-width:none;margin-right:48px}
.ak-s-dlg .ak-s-pop{position:static;margin-top:8px;margin-right:-48px;box-shadow:none;border:0;border-top:1px solid var(--ak-line,color-mix(in srgb,currentColor 12%,transparent));border-radius:0}
.ak-s-dlg .ak-s-list{padding:6px 0}
.ak-s-x{position:absolute;top:14px;right:12px;width:44px;height:44px;border:0;border-radius:10px;background:transparent;color:inherit;font-size:26px;line-height:1;cursor:pointer;opacity:.7}
.ak-s-x:hover{opacity:1}.ak-s-x:focus-visible{outline:2px solid currentColor}
.ak-s-fab{position:fixed;bottom:16px;z-index:2147482990;width:44px;height:44px;padding:0;border:1px solid var(--ak-line,rgba(0,0,0,.14));border-radius:22px;background:var(--ak-bg,Canvas);color:var(--ak-fg,CanvasText);box-shadow:0 4px 14px rgba(0,0,0,.18);display:inline-flex;align-items:center;justify-content:center;cursor:pointer;transition:transform .15s}
.ak-s-fab-l{left:16px}.ak-s-fab-r{right:16px}
.ak-s-fab svg{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round}
.ak-s-fab:focus-visible{outline:2px solid currentColor;outline-offset:2px}
@media (prefers-reduced-motion:reduce){.ak-s-fab{transition:none}}
@media print{.ak-s-fab{display:none}}`;
// One self-contained script for a whole site: the engine, the widget and the launcher, with this site's settings.
// opts: { index, label, placeholder, suggest, logUrl, logAll, ga4, clarity, logJs: 'function(q,n){...}', position: 'left'|'right',
// takeover: 'css selector', button, max, brand }
export function searchScript(opts = {}) {
const form = renderSearch({ ...opts, id: 'ak-site-search', hotkey: true });
const cfg = { form, css: SEARCH_CSS + '\n' + LAUNCHER_CSS, label: opts.label, position: opts.position, takeover: opts.takeover || '', button: opts.button };
return '/*! Amili Search (Amili Kit). https://amili.ai/kit */\n(function(){"use strict";if(window.__akSearch)return;window.__akSearch=1;'
+ [norm, within, typos, makeIndex, termHits, query, didYouMean, highlight, cleanQuery, sameTypingSession, widget, launcher].map((f) => f.toString()).join('\n')
// logJs: the site's OWN existing search logging, kept as it was (e.g. its Plausible/GA4 event names), as the source of
// a function (q, results) that runs for every settled search, after the kit's privacy scrub and typing-session dedupe.
+ (opts.logJs ? `\ndocument.addEventListener("ak-search",function(e){try{(${String(opts.logJs).replace(/<\//g, '<\\/')})(e.detail.q,e.detail.results)}catch(x){}});` : '')
+ `\nfunction start(){launcher(${JSON.stringify(cfg).replace(/</g, '\\u003c')});widget();}`
+ '\nif(document.readyState==="loading")document.addEventListener("DOMContentLoaded",start);else start();})();';
}
export const SEARCH_JS = '(function(){"use strict";if(window.__akSearch)return;window.__akSearch=1;'
+ [norm, within, typos, makeIndex, termHits, query, didYouMean, highlight, cleanQuery, sameTypingSession, widget].map((f) => f.toString()).join('\n')
+ '\nif(document.readyState==="loading")document.addEventListener("DOMContentLoaded",widget);else widget();})();';
// The kit's common shape: every component exports { name, render(opts) -> html, css, js }.
export default { name: 'search', title: 'Amili Search', render: renderSearch, css: SEARCH_CSS, js: SEARCH_JS };
Comparison table
A real table with a caption, the best value per column marked, a pinned name column, a scroll region you can reach by keyboard, optional sorting and a one-card-per-row mode on phones.
Live demo
| Tripod | Head | Where to buy | |||
|---|---|---|---|---|---|
| Example A | 1.4 kg | 150 cm | 42 cm | Ball | Check price for Example A |
| Example B | 0.9 kgBest | 132 cm | 31 cmBest | Ball | Check price for Example B |
| Example C | 2.1 kg | 165 cmBest | 58 cm | – not listed | Check price for Example C |
| Option | What it does |
|---|---|
caption | Required. What the table compares; also names the scroll region. |
columns | best: "min" or "max" marks the best value; sort: true makes the header a sort button. |
pin | "first" keeps names visible when scrolling sideways, "last" keeps the buy column. |
phone | "stack" shows one card per row on narrow screens. |
sponsored | Buy links get rel="sponsored nofollow noopener" (default). |
Show the code
// Amili Kit: Comparison table. https://amili.ai/kit#kit-compare
// SPDX-License-Identifier: MIT. Copyright (c) 2026 Amili (Paulo de Vries). Made by Amili.
// Tests and more parts: https://amili.ai/kit. The "brand" option shows a tiny "amili" mark linking to the kit; set
// brand: false to hide it.
//
// import { renderCompare, COMPARE_CSS, COMPARE_JS } from './kit/compare.mjs';
// html = `<style>${COMPARE_CSS}</style>` + renderCompare(opts) + `<script>${COMPARE_JS}</script>` // JS only for sorting
//
// opts = {
// caption: 'Five travel tripods compared', // required: what the table compares (also the scroll region's name)
// columns: [{ key: 'weight', label: 'Weight', unit: 'kg', best: 'min', sort: true }, ...],
// // best: 'min'|'max' marks the best value per column; sort: sortable
// rows: [{ name: 'Tripod A', href: '/tripod-a/', cells: { weight: 1.2 }, cta: { href: '/go/a', label: 'Check price' } }],
// rowHeader: 'Model', // label of the first column
// pin: 'first' | 'last' | 'none', // keep the name (or the buy column) visible while scrolling sideways
// phone: 'scroll' | 'stack', // below 600px: scroll sideways (default) or one card per row
// sponsored: true, // cta links get rel="sponsored nofollow noopener" (affiliate links)
// from: 'spec-row', // data-from on every cta, for a click beacon
// note: 'Prices change; checked 28 Sep 2026.', // optional line under the table
// }
// No brand mark: a table with buy links is a buy area.
const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
const slug = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 't';
export function validateCompare(o) {
const errs = [];
if (!o?.caption) errs.push('caption is required (it names the table and its scroll region)');
if (!o?.columns?.length) errs.push('columns: at least one');
if (!o?.rows?.length || o.rows.length < 2) errs.push('rows: at least two things to compare');
const keys = new Set((o?.columns || []).map((c) => c.key));
for (const c of o?.columns || []) {
if (!c.key || !c.label) errs.push(`column without key/label: ${JSON.stringify(c)}`);
if (c.best && !['min', 'max'].includes(c.best)) errs.push(`column ${c.key}: best must be min or max`);
}
for (const r of o?.rows || []) {
if (!r.name) errs.push('row without name');
for (const k of Object.keys(r.cells || {})) if (!keys.has(k)) errs.push(`row ${r.name}: unknown column ${k}`);
if (r.cta && !r.cta.href) errs.push(`row ${r.name}: cta without href`);
}
return errs;
}
// The best value per column (numbers only; ties all win). Returns { key: Set(rowIndex) }.
export function bestCells(columns, rows) {
const out = {};
for (const c of columns) {
if (!c.best) continue;
const vals = rows.map((r, i) => [typeof r.cells?.[c.key] === 'number' ? r.cells[c.key] : NaN, i]).filter(([v]) => Number.isFinite(v));
if (vals.length < 2) continue;
const target = c.best === 'min' ? Math.min(...vals.map(([v]) => v)) : Math.max(...vals.map(([v]) => v));
out[c.key] = new Set(vals.filter(([v]) => v === target).map(([, i]) => i));
}
return out;
}
const fmt = (v, c) => (v == null || v === '' ? '<span class="ak-cmp-na">–<span class="ak-vh"> not listed</span></span>' : typeof v === 'boolean' ? (v ? 'Yes' : 'No') : esc(v) + (c.unit && typeof v === 'number' ? ` ${esc(c.unit)}` : ''));
export function renderCompare(o) {
const errs = validateCompare(o);
if (errs.length) throw new Error(`Amili Kit compare: ${errs.join('; ')}`);
const id = `ak-cmp-${slug(o.caption)}`, best = bestCells(o.columns, o.rows);
const hasCta = o.rows.some((r) => r.cta);
const rel = o.sponsored === false ? 'noopener' : 'sponsored nofollow noopener';
const sortBtn = (c, label) => (c.sort ? `<button type="button" class="ak-cmp-sort" data-k="${esc(c.key)}">${label}<span aria-hidden="true" class="ak-cmp-arrow"></span></button>` : label);
const head = `<tr><th scope="col">${esc(o.rowHeader || 'Name')}</th>${o.columns.map((c) => `<th scope="col"${c.sort ? ' aria-sort="none"' : ''}>${sortBtn(c, esc(c.label) + (c.unit ? ` <span class="ak-cmp-unit">(${esc(c.unit)})</span>` : ''))}</th>`).join('')}${hasCta ? '<th scope="col"><span class="ak-vh">Where to buy</span></th>' : ''}</tr>`;
const body = o.rows.map((r, i) => {
const name = r.href ? `<a href="${esc(r.href)}">${esc(r.name)}</a>` : esc(r.name);
const cells = o.columns.map((c) => {
const v = r.cells?.[c.key], isBest = best[c.key]?.has(i);
const sv = typeof v === 'number' ? ` data-v="${v}"` : v == null ? '' : ` data-v="${esc(String(v).toLowerCase())}"`;
return `<td data-label="${esc(c.label)}"${sv}${isBest ? ' class="ak-cmp-best"' : ''}>${fmt(v, c)}${isBest ? '<span class="ak-cmp-tag">Best</span>' : ''}</td>`;
}).join('');
const cta = hasCta ? `<td class="ak-cmp-cta">${r.cta ? `<a href="${esc(r.cta.href)}" rel="${rel}"${o.from ? ` data-from="${esc(o.from)}"` : ''}${r.cta.target ? ` target="${esc(r.cta.target)}"` : ''}>${esc(r.cta.label || 'Check price')}<span class="ak-vh"> for ${esc(r.name)}</span></a>` : ''}</td>` : '';
return `<tr><th scope="row">${name}</th>${cells}${cta}</tr>`;
}).join('');
const pin = o.pin === 'none' ? '' : o.pin === 'last' && hasCta ? ' ak-pin-last' : ' ak-pin-first';
const phone = o.phone === 'stack' ? ' ak-cmp-stack' : '';
return `<figure class="ak-cmp${phone}"><div class="ak-cmp-wrap" role="region" aria-labelledby="${id}" tabindex="0">`
+ `<table class="ak-cmp-t${pin}"><caption id="${id}">${esc(o.caption)}</caption><thead>${head}</thead><tbody>${body}</tbody></table></div>`
+ (o.phone === 'stack' ? '' : '<p class="ak-cmp-hint" aria-hidden="true">Swipe the table sideways to see every column.</p>')
+ (o.note ? `<figcaption class="ak-cmp-note">${esc(o.note)}</figcaption>` : '') + `</figure>`;
}
export const COMPARE_CSS = `.ak-cmp{margin:1.5rem 0;max-width:100%;--ak-cmp-bg:var(--ak-bg,Canvas);--ak-cmp-line:var(--ak-line,color-mix(in srgb,currentColor 14%,transparent))}
.ak-cmp-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--ak-cmp-line);border-radius:var(--ak-radius,12px);background:var(--ak-cmp-bg);color:var(--ak-fg,inherit)}
.ak-cmp-wrap:focus-visible{outline:2px solid currentColor;outline-offset:2px}
.ak-cmp-t{border-collapse:separate;border-spacing:0;width:100%;min-width:34rem;font-size:.9375rem;line-height:1.4}
.ak-cmp-t caption{caption-side:top;text-align:left;padding:.75rem 1rem .25rem;font-weight:600}
.ak-cmp-t th,.ak-cmp-t td{padding:.7rem 1rem;text-align:left;vertical-align:top;border-bottom:1px solid var(--ak-cmp-line)}
.ak-cmp-t thead th{font-size:.8125rem;font-weight:650;letter-spacing:.01em;background:color-mix(in srgb,currentColor 5%,var(--ak-cmp-bg));white-space:nowrap}
.ak-cmp-t tbody tr:last-child>*{border-bottom:0}
.ak-cmp-t tbody th{font-weight:650}
.ak-cmp-t td[data-v]{font-variant-numeric:tabular-nums}
.ak-cmp-t a{color:inherit;text-underline-offset:.2em}
.ak-cmp-t tbody th a{display:inline-flex;align-items:center;min-height:44px;margin:-.7rem 0}
.ak-pin-first tr>:first-child{position:sticky;left:0;z-index:1;background:var(--ak-cmp-bg);box-shadow:8px 0 8px -8px color-mix(in srgb,currentColor 30%,transparent)}
.ak-pin-first thead tr>:first-child{background:color-mix(in srgb,currentColor 5%,var(--ak-cmp-bg))}
.ak-pin-last tr>:last-child{position:sticky;right:0;z-index:1;background:var(--ak-cmp-bg);box-shadow:-8px 0 8px -8px color-mix(in srgb,currentColor 30%,transparent)}
.ak-cmp-best{font-weight:650}
.ak-cmp-tag{display:inline-block;margin-left:.4em;padding:0 .4em;border-radius:4px;font-size:.6875rem;font-weight:650;line-height:1.5;vertical-align:.1em;background:color-mix(in srgb,var(--ak-good,#2f7d4f) 16%,transparent);color:inherit}
.ak-cmp-na{opacity:.5}
.ak-cmp-unit{font-weight:400;opacity:.7}
.ak-cmp-cta a{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:0 1rem;margin:-.35rem 0;border-radius:10px;background:var(--ak-accent,#b45309);color:var(--ak-on-accent,#fff);font-weight:650;text-decoration:none;white-space:nowrap}
.ak-cmp-cta a:hover{filter:brightness(1.08)}
.ak-cmp-cta a:focus-visible{outline:2px solid currentColor;outline-offset:2px}
.ak-cmp-sort{display:inline-flex;align-items:center;gap:.35em;min-height:44px;margin:-.7rem 0;padding:0;border:0;background:none;color:inherit;font:inherit;cursor:pointer}
.ak-cmp-arrow{width:0;height:0;border:4px solid transparent;border-top-color:currentColor;margin-top:4px;opacity:.35}
[aria-sort=ascending] .ak-cmp-arrow{border-top-color:transparent;border-bottom-color:currentColor;margin:-4px 0 0;opacity:1}
[aria-sort=descending] .ak-cmp-arrow{opacity:1}
.ak-cmp-hint,.ak-cmp-note{margin:.5rem 0 0;font-size:.8125rem;opacity:.7}
.ak-vh{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
@media (min-width:48rem){.ak-cmp-hint{display:none}}
@media (max-width:37.5rem){.ak-cmp-stack .ak-cmp-t{min-width:0}.ak-cmp-stack thead{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
.ak-cmp-stack .ak-cmp-t,.ak-cmp-stack tbody,.ak-cmp-stack tr,.ak-cmp-stack th,.ak-cmp-stack td{display:block}
.ak-cmp-stack tr{border-bottom:1px solid var(--ak-cmp-line);padding:.5rem 0}.ak-cmp-stack tbody tr:last-child{border-bottom:0}
.ak-cmp-stack td,.ak-cmp-stack tbody th{border:0;padding:.3rem 1rem;position:static;box-shadow:none}
.ak-cmp-stack td[data-label]::before{content:attr(data-label) ": ";font-weight:600;opacity:.7}
.ak-cmp-stack .ak-cmp-cta{padding-top:.5rem}}
@media print{.ak-cmp-wrap{overflow:visible}.ak-cmp-hint,.ak-cmp-cta{display:none}}`;
// Optional: click a sortable header to sort (numbers numerically, text alphabetically; empty cells last). Without this
// script the table is complete and readable; sorting is the only thing it adds.
export const COMPARE_JS = `(function(){"use strict";
function sort(btn){var th=btn.closest("th"),tr=th.parentNode,table=th.closest("table"),col=Array.prototype.indexOf.call(tr.children,th),
dir=th.getAttribute("aria-sort")==="ascending"?"descending":"ascending",tb=table.tBodies[0],rows=Array.prototype.slice.call(tb.rows);
Array.prototype.forEach.call(tr.children,function(h){if(h.hasAttribute("aria-sort"))h.setAttribute("aria-sort","none")});
th.setAttribute("aria-sort",dir);
rows.sort(function(a,b){var x=a.cells[col].getAttribute("data-v"),y=b.cells[col].getAttribute("data-v");
if(x===null&&y===null)return 0;if(x===null)return 1;if(y===null)return -1;
var nx=parseFloat(x),ny=parseFloat(y),c=(!isNaN(nx)&&!isNaN(ny))?nx-ny:x.localeCompare(y);return dir==="ascending"?c:-c});
rows.forEach(function(r){tb.appendChild(r)})}
document.addEventListener("click",function(e){var b=e.target.closest&&e.target.closest(".ak-cmp-sort");if(b)sort(b)});
})();`;
// The kit's common shape: every component exports { name, render(opts) -> html, css, js }.
export default { name: 'compare', title: 'Comparison table', render: renderCompare, css: COMPARE_CSS, js: COMPARE_JS };
Sticky buy bar
One product and one button at the bottom of the screen. In the page flow or pinned, fades while your own buy block is visible, closable, refuses a price in the label and an image without a size.
Live demo
| Option | What it does |
|---|---|
name, href, label | What it buys, where the button goes, the button text. A price in the label is refused. |
mode | "sticky" sits at the end of your content, "fixed" is pinned from the first paint. |
hideWhen | A selector: the bar fades while that element is on screen. |
note | "Affiliate link" by default. Keep it when the link earns you money. |
Show the code
// Amili Kit: Sticky buy bar. https://amili.ai/kit#kit-buybar
// SPDX-License-Identifier: MIT. Copyright (c) 2026 Amili (Paulo de Vries). Made by Amili.
// Tests and more parts: https://amili.ai/kit. The "brand" option shows a tiny "amili" mark linking to the kit; set
// brand: false to hide it.
//
// import { renderBuyBar, BUYBAR_CSS, BUYBAR_JS } from './kit/buybar.mjs';
// html = `<style>${BUYBAR_CSS}</style>` + renderBuyBar(opts) + `<script>${BUYBAR_JS}</script>` // JS is optional
//
// opts = {
// name: 'Sherpa Original Deluxe', // required: what the button buys (short)
// href: '/go/sherpa', // required: your own link or affiliate gate
// label: 'Check price', // button text (never a price you cannot keep fresh)
// note: 'Affiliate link', // small line under the name; '' hides it (keep it if the link earns you money)
// img: { src, width: 32, height: 32 }, // optional thumbnail (fixed size: no layout shift)
// mode: 'sticky' | 'fixed', // sticky: sits in the page flow at the end of your content (default)
// // fixed: pinned to the screen from the first paint, page padded so nothing is covered
// phoneOnly: true, // hide from 768px up (desktop has the in-page button in view)
// sponsored: true, // rel="sponsored nofollow noopener"
// from: 'sticky-bar', // data-from, for a click beacon
// hideWhen: '#buy', // with BUYBAR_JS: fade out while this element is on screen (no duplicate buttons)
// closable: true, // with BUYBAR_JS: a close button; closed stays closed for the visit
// }
// No brand mark: this is a buy area. Put it once per page, at the end of the main content (sticky mode needs that).
const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
const PRICE = /[$\u20ac\u00a3\u00a5]\s?\d|\d\s?(usd|eur|gbp)\b/i;
export function validateBuyBar(o) {
const errs = [];
if (!o?.name) errs.push('name is required');
if (!o?.href) errs.push('href is required');
if (o?.label && PRICE.test(o.label)) errs.push('label must not carry a price (prices go stale; say "Check price")');
if (o?.img && !(o.img.width && o.img.height)) errs.push('img needs width and height (no layout shift)');
if (o?.mode && !['sticky', 'fixed'].includes(o.mode)) errs.push('mode must be sticky or fixed');
return errs;
}
export function renderBuyBar(o) {
const errs = validateBuyBar(o);
if (errs.length) throw new Error(`Amili Kit buy bar: ${errs.join('; ')}`);
const cls = ['ak-bb', o.mode === 'fixed' ? 'ak-bb-fixed' : '', o.phoneOnly === false ? '' : 'ak-bb-phone'].filter(Boolean).join(' ');
const rel = o.sponsored === false ? 'noopener' : 'sponsored nofollow noopener';
const img = o.img ? `<img src="${esc(o.img.src)}" width="${+o.img.width}" height="${+o.img.height}" alt="" loading="lazy" decoding="async">` : '';
const note = o.note === '' ? '' : `<span class="ak-bb-d">${esc(o.note || 'Affiliate link')}</span>`;
const close = o.closable === false ? '' : '<button type="button" class="ak-bb-x" aria-label="Close" hidden><span aria-hidden="true">×</span></button>';
return `<aside class="${cls}" aria-label="Buy ${esc(o.name)}"${o.hideWhen ? ` data-hide-when="${esc(o.hideWhen)}"` : ''}><div class="ak-bb-in">${img}`
+ `<span class="ak-bb-t"><span class="ak-bb-n">${esc(o.name)}</span>${note}</span>`
+ `<a class="ak-bb-b" href="${esc(o.href)}" rel="${rel}"${o.from === '' ? '' : ` data-from="${esc(o.from || 'sticky-bar')}"`}>${esc(o.label || 'Check price')}<span class="ak-bb-vh"> for ${esc(o.name)}</span></a>`
+ `${close}</div></aside>`;
}
export const BUYBAR_CSS = `.ak-bb{position:sticky;bottom:0;z-index:40;margin:1.5rem 0 0;padding:0 1rem env(safe-area-inset-bottom,0px);background:var(--ak-bg,Canvas);color:var(--ak-fg,CanvasText);border-top:1px solid var(--ak-line,color-mix(in srgb,currentColor 14%,transparent));box-shadow:0 -6px 16px rgba(0,0,0,.08);transition:opacity .2s,visibility .2s}
.ak-bb-in{display:flex;align-items:center;gap:10px;min-height:56px;max-width:var(--ak-max,60rem);margin:0 auto;padding:6px 0}
.ak-bb img{width:32px;height:32px;flex:0 0 32px;object-fit:contain}
.ak-bb-t{flex:1 1 auto;min-width:0}
.ak-bb-n{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:600;font-size:.95rem;line-height:1.2}
.ak-bb-d{display:block;margin-top:1px;font-size:.7rem;line-height:1.3;opacity:.7}
.ak-bb-b{flex:0 0 auto;display:inline-flex;align-items:center;justify-content:center;min-height:44px;min-width:44px;padding:0 16px;border-radius:10px;background:var(--ak-accent,#b45309);color:var(--ak-on-accent,#fff);font-weight:700;font-size:.9rem;text-decoration:none;white-space:nowrap}
.ak-bb-b:hover{filter:brightness(1.08)}
.ak-bb-b:focus-visible,.ak-bb-x:focus-visible{outline:2px solid currentColor;outline-offset:2px}
.ak-bb-x{flex:0 0 auto;width:44px;height:44px;margin-right:-10px;border:0;background:none;color:inherit;font-size:22px;line-height:1;cursor:pointer;opacity:.6}
.ak-bb-vh{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
.ak-bb.ak-bb-fixed{position:fixed;left:0;right:0;margin:0}
body:has(.ak-bb-fixed:not(.ak-bb-off)){padding-bottom:calc(64px + env(safe-area-inset-bottom,0px))}
.ak-bb.ak-bb-hide{opacity:0;visibility:hidden}
.ak-bb.ak-bb-off{display:none}
@media (min-width:48rem){.ak-bb.ak-bb-phone{display:none}body:has(.ak-bb-fixed.ak-bb-phone){padding-bottom:0}}
@media (prefers-reduced-motion:reduce){.ak-bb{transition:none}}
@media print{.ak-bb{display:none}}`;
// Optional. Fades the bar out while the hideWhen element is on screen (visibility, so nothing moves) and wires the
// close button; closed stays closed for the rest of the visit (sessionStorage, no cookie).
export const BUYBAR_JS = `(function(){"use strict";
var KEY="ak-bb-closed";function closed(){try{return sessionStorage.getItem(KEY)==="1"}catch(e){return false}}
document.querySelectorAll(".ak-bb").forEach(function(bar){
if(closed()){bar.classList.add("ak-bb-off");return}
var x=bar.querySelector(".ak-bb-x");if(x){x.hidden=false;x.addEventListener("click",function(){bar.classList.add("ak-bb-off");try{sessionStorage.setItem(KEY,"1")}catch(e){}})}
var sel=bar.getAttribute("data-hide-when"),t=sel&&document.querySelector(sel);
if(t&&"IntersectionObserver"in window){new IntersectionObserver(function(es){es.forEach(function(e){bar.classList.toggle("ak-bb-hide",e.isIntersecting)})}).observe(t)}
});})();`;
// The kit's common shape: every component exports { name, render(opts) -> html, css, js }.
export default { name: 'buybar', title: 'Sticky buy bar', render: renderBuyBar, css: BUYBAR_CSS, js: BUYBAR_JS };
FAQ with structured data
One list of questions gives you the accordion and the FAQPage JSON-LD, so the two can never disagree. Native details and summary: no JavaScript, 48px rows.
Live demo
Questions about the kit
Is the Amili Kit free?
Yes. Every part is MIT licensed: use it on any site, commercial or not, and keep the licence notice.
Do I need a framework?
No. Each part is one JavaScript module that returns HTML and CSS. It works in a static site generator, in Next.js, in Astro or in a plain build script.
What is the small amili mark?
A tiny link back to this page, on by default in the public code. Set brand to false and it is gone. It never appears inside buy buttons, tables with prices or legal text.
| Option | What it does |
|---|---|
items | Required. [{ q, a }] or { q, aHtml } for links in the answer. |
exclusive | Opening one question closes the others (no JavaScript). |
jsonLd | false if the page already has an FAQPage block. |
Show the code
// Amili Kit: FAQ with structured data. https://amili.ai/kit#kit-faq
// SPDX-License-Identifier: MIT. Copyright (c) 2026 Amili (Paulo de Vries). Made by Amili.
// Tests and more parts: https://amili.ai/kit. The "brand" option shows a tiny "amili" mark linking to the kit; set
// brand: false to hide it.
//
// import { renderFaq, FAQ_CSS } from './kit/faq.mjs';
// html = `<style>${FAQ_CSS}</style>` + renderFaq({ items: [{ q: 'Question?', a: 'Answer.' }] })
//
// opts = {
// items: [{ q, a } | { q, aHtml }], // required; aHtml allows links and lists (JSON-LD gets the plain text)
// heading: 'Common questions', // optional heading ('' = none)
// level: 2, // heading level 2-4
// jsonLd: true, // emit <script type="application/ld+json"> FAQPage (false if the page already has one)
// open: 0, // index of a question to start open (default: all closed)
// exclusive: false, // true = opening one closes the others (native <details name>, no JS)
// id: 'faq', // section id (anchor)
// brand: BRAND_DEFAULT, // tiny "amili" mark, links to amili.ai/kit
// }
const BRAND_DEFAULT = true; // a tiny "amili" mark linking to amili.ai/kit; pass brand: false to hide it
const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
const plain = (h) => String(h).replace(/<[^>]+>/g, ' ').replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/\s+/g, ' ').trim();
// JSON inside <script> must not be able to close the tag.
export const jsonForScript = (o) => JSON.stringify(o).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');
export function brandMark(on) {
return on ? '<a class="ak-brand" href="https://amili.ai/kit" rel="noopener" aria-label="Made with Amili Kit"><span class="ak-brand-dot" aria-hidden="true"></span>amili</a>' : '';
}
export function validateFaq(opts) {
const errs = [], seen = new Set();
if (!opts?.items?.length) errs.push('items: at least one { q, a }');
for (const [i, it] of (opts?.items || []).entries()) {
if (!it?.q || !String(it.q).trim()) errs.push(`items[${i}]: empty question`);
if (!(it?.a || it?.aHtml) || !plain(it.a || it.aHtml)) errs.push(`items[${i}]: empty answer`);
const k = String(it?.q || '').trim().toLowerCase();
if (k && seen.has(k)) errs.push(`items[${i}]: duplicate question "${it.q}"`);
seen.add(k);
}
if (opts?.level && !(opts.level >= 2 && opts.level <= 4)) errs.push('level must be 2, 3 or 4');
return errs;
}
export function faqJsonLd(items) {
return {
'@context': 'https://schema.org', '@type': 'FAQPage',
mainEntity: items.map((it) => ({ '@type': 'Question', name: String(it.q).trim(), acceptedAnswer: { '@type': 'Answer', text: it.aHtml ? plain(it.aHtml) : String(it.a).trim() } })),
};
}
export function renderFaq(opts) {
const errs = validateFaq(opts);
if (errs.length) throw new Error(`Amili Kit FAQ: ${errs.join('; ')}`);
const lv = opts.level || 2, id = esc(opts.id || 'faq');
const heading = opts.heading === '' ? '' : `<h${lv} class="ak-faq-h" id="${id}-h">${esc(opts.heading || 'Common questions')}</h${lv}>`;
const name = opts.exclusive ? ` name="${id}"` : '';
const qs = opts.items.map((it, i) => `<details class="ak-faq-q"${name}${opts.open === i ? ' open' : ''}><summary>${esc(it.q)}</summary><div class="ak-faq-a">${it.aHtml || `<p>${esc(it.a)}</p>`}</div></details>`).join('');
const brand = opts.brand === undefined ? BRAND_DEFAULT : !!opts.brand;
const ld = opts.jsonLd === false ? '' : `<script type="application/ld+json">${jsonForScript(faqJsonLd(opts.items))}</script>`;
return `<section class="ak-faq" id="${id}"${heading ? ` aria-labelledby="${id}-h"` : ''}>${heading}${qs}${brand ? `<div class="ak-brand-row">${brandMark(true)}</div>` : ''}${ld}</section>`;
}
export const BRAND_CSS = `.ak-brand-row{display:flex;justify-content:flex-end;margin:-6px 0 -12px}
.ak-brand{display:inline-flex;align-items:center;gap:.3em;min-height:44px;padding:0 .25rem;font:400 11px/1 Georgia,"Times New Roman",serif;letter-spacing:.01em;color:inherit;opacity:.5;text-decoration:none}
.ak-brand:hover,.ak-brand:focus-visible{opacity:.85}
.ak-brand-dot{width:.5em;height:.5em;border-radius:50%;background:#2F5D50;order:2;margin-top:-.45em}
@media (prefers-color-scheme:dark){.ak-brand-dot{background:#7FB3A1}}`;
export const FAQ_CSS = `.ak-faq{margin:2rem 0;max-width:var(--ak-max,46rem);color:inherit}
.ak-faq-h{margin:0 0 .75rem}
.ak-faq-q{border-bottom:1px solid var(--ak-line,color-mix(in srgb,currentColor 16%,transparent))}
.ak-faq-q:first-of-type{border-top:1px solid var(--ak-line,color-mix(in srgb,currentColor 16%,transparent))}
.ak-faq-q>summary{display:flex;align-items:center;justify-content:space-between;gap:1rem;min-height:48px;padding:.6rem 0;font-weight:600;line-height:1.35;cursor:pointer;list-style:none}
.ak-faq-q>summary::-webkit-details-marker{display:none}
.ak-faq-q>summary::after{content:"";flex:0 0 auto;width:.55em;height:.55em;margin-right:.25em;border-right:2px solid currentColor;border-bottom:2px solid currentColor;transform:rotate(45deg) translateY(-25%);opacity:.6}
.ak-faq-q[open]>summary::after{transform:rotate(-135deg) translate(-25%,0)}
.ak-faq-q>summary:focus-visible{outline:2px solid currentColor;outline-offset:2px;border-radius:4px}
.ak-faq-a{padding:0 0 1rem;color:var(--ak-muted,color-mix(in srgb,currentColor 82%,transparent))}
.ak-faq-a>:first-child{margin-top:0}.ak-faq-a>:last-child{margin-bottom:0}
@media print{.ak-faq-q>summary::after{display:none}}
${BRAND_CSS}`;
// The kit's common shape: every component exports { name, render(opts) -> html, css, js }.
export default { name: 'faq', title: 'FAQ with structured data', render: renderFaq, css: FAQ_CSS, js: '' };
Table of contents
Added at build time to pages with enough sections. Gives headings ids without touching existing ones, sits after your introduction, scrolls sideways on phones and can show where you are.
Live demo
| Option | What it does |
|---|---|
min | Only pages with at least this many sections get a table of contents (default 4). |
style | "chips" (one scrolling row) or "list" (a numbered box, optionally collapsible). |
TOC_JS | Optional: marks the section you are reading. |
Show the code
// Amili Kit: Table of contents. https://amili.ai/kit#kit-toc
// SPDX-License-Identifier: MIT. Copyright (c) 2026 Amili (Paulo de Vries). Made by Amili.
// Tests and more parts: https://amili.ai/kit. The "brand" option shows a tiny "amili" mark linking to the kit; set
// brand: false to hide it.
//
// import { addToc, TOC_CSS, TOC_JS } from './kit/toc.mjs';
// body = addToc(body, { min: 4 }); // at build time: ids on your h2s + the TOC before the first h2
// html = `<style>${TOC_CSS}</style>` + body + `<script>${TOC_JS}</script>` // JS only for "you are here"
//
// opts = {
// min: 4, // only pages with at least this many h2s get a TOC (a short page navigates itself)
// title: 'On this page',
// style: 'chips' | 'list', // chips: one row of buttons that scrolls sideways on phones; list: a numbered box
// collapsible: false, // list style inside <details> (open), for long lists
// maxLabel: 36, // long headings are shortened on a word boundary (the full text stays in the heading)
// brand: BRAND_DEFAULT, // tiny "amili" mark (list style only), links to amili.ai/kit
// }
const BRAND_DEFAULT = true; // a tiny "amili" mark linking to amili.ai/kit; pass brand: false to hide it
const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
const text = (h) => String(h).replace(/<[^>]*>/g, '').replace(/ /g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'").replace(/\s+/g, ' ').trim();
export const slugify = (t) => String(t).toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^a-z0-9]+/g, '-').slice(0, 48).replace(/^-+|-+$/g, '') || 'section';
export function shortLabel(t, max = 36) {
if (t.length <= max) return t;
const sp = t.slice(0, max + 1).lastIndexOf(' ');
return (sp > max / 2 ? t.slice(0, sp) : t.slice(0, max)).replace(/[,;:.\s-]+$/, '') + '\u2026';
}
export function brandMark(on) {
return on ? '<a class="ak-brand" href="https://amili.ai/kit" rel="noopener" aria-label="Made with Amili Kit"><span class="ak-brand-dot" aria-hidden="true"></span>amili</a>' : '';
}
// Adds ids to <h2>s that lack one (unique; existing ids are kept) and returns { html, heads: [{ id, text }] }.
export function collectHeadings(html) {
const heads = [], used = new Set([...String(html).matchAll(/\sid="([^"]+)"/g)].map((m) => m[1]));
const out = String(html).replace(/<h2(\s[^>]*)?>([\s\S]*?)<\/h2>/g, (m, attrs = '', inner) => {
const t = text(inner);
if (!t) return m;
const ex = attrs.match(/\sid="([^"]+)"/);
if (ex) { heads.push({ id: ex[1], text: t }); return m; }
let base = slugify(t), id = base, n = 2;
while (used.has(id)) id = `${base}-${n++}`;
used.add(id);
heads.push({ id, text: t });
return `<h2 id="${id}"${attrs}>${inner}</h2>`;
});
return { html: out, heads };
}
export function renderToc(heads, opts = {}) {
const title = esc(opts.title || 'On this page'), max = opts.maxLabel || 36;
if (opts.style === 'list') {
const brand = opts.brand === undefined ? BRAND_DEFAULT : !!opts.brand;
const list = `<ol>${heads.map((h) => `<li><a href="#${esc(h.id)}">${esc(shortLabel(h.text, max * 2))}</a></li>`).join('')}</ol>${brand ? `<div class="ak-brand-row">${brandMark(true)}</div>` : ''}`;
return opts.collapsible
? `<nav class="ak-toc ak-toc-list" aria-label="${title}"><details open><summary>${title}</summary>${list}</details></nav>`
: `<nav class="ak-toc ak-toc-list" aria-label="${title}"><p class="ak-toc-h">${title}</p>${list}</nav>`;
}
return `<nav class="ak-toc ak-toc-chips" aria-label="${title}">${heads.map((h) => `<a href="#${esc(h.id)}">${esc(shortLabel(h.text, max))}</a>`).join('')}</nav>`;
}
// Build-time: returns the page with ids added and the TOC placed right before the first <h2> (after your intro/answer).
export function addToc(html, opts = {}) {
if (/class="ak-toc[ "]/.test(html)) return html; // already has one
const { html: out, heads } = collectHeadings(html);
if (heads.length < (opts.min ?? 4)) return html;
const first = out.search(/<h2[\s>]/);
return out.slice(0, first) + renderToc(heads, opts) + out.slice(first);
}
export const BRAND_CSS = `.ak-brand-row{display:flex;justify-content:flex-end;margin:-6px 0 -12px}
.ak-brand{display:inline-flex;align-items:center;gap:.3em;min-height:44px;padding:0 .25rem;font:400 11px/1 Georgia,"Times New Roman",serif;letter-spacing:.01em;color:inherit;opacity:.5;text-decoration:none}
.ak-brand:hover,.ak-brand:focus-visible{opacity:.85}
.ak-brand-dot{width:.5em;height:.5em;border-radius:50%;background:#2F5D50;order:2;margin-top:-.45em}
@media (prefers-color-scheme:dark){.ak-brand-dot{background:#7FB3A1}}`;
export const TOC_CSS = `h2[id]{scroll-margin-top:var(--ak-toc-offset,16px)}
.ak-toc{margin:1rem 0 1.5rem;color:inherit}
.ak-toc a{color:inherit}
.ak-toc a:focus-visible{outline:2px solid currentColor;outline-offset:2px;border-radius:6px}
.ak-toc-chips{display:flex;flex-wrap:wrap;gap:6px}
.ak-toc-chips a{display:inline-flex;align-items:center;min-height:44px;padding:0 14px;border:1px solid var(--ak-line,color-mix(in srgb,currentColor 18%,transparent));border-radius:999px;font-size:.875rem;text-decoration:none;white-space:nowrap}
.ak-toc-chips a:hover{border-color:currentColor}
.ak-toc a[aria-current]{border-color:currentColor;font-weight:650}
@media (max-width:40rem){.ak-toc-chips{flex-wrap:nowrap;overflow-x:auto;-webkit-overflow-scrolling:touch;scrollbar-width:none;margin-left:-1rem;margin-right:-1rem;padding:0 1rem 4px;scroll-padding:0 1rem}.ak-toc-chips::-webkit-scrollbar{display:none}.ak-toc-chips a{flex:0 0 auto}}
.ak-toc-list{max-width:var(--ak-max,40rem);padding:.75rem 1rem;border:1px solid var(--ak-line,color-mix(in srgb,currentColor 16%,transparent));border-radius:var(--ak-radius,12px)}
.ak-toc-h,.ak-toc-list summary{margin:0;font-weight:650;font-size:.9375rem}
.ak-toc-list summary{display:flex;align-items:center;min-height:44px;cursor:pointer}
.ak-toc-list ol{margin:.25rem 0 0;padding-left:1.4rem}
.ak-toc-list li a{display:inline-flex;align-items:center;min-height:44px;text-decoration:underline;text-decoration-color:color-mix(in srgb,currentColor 35%,transparent);text-underline-offset:.2em}
.ak-toc-list a[aria-current]{text-decoration-color:currentColor}
@media (prefers-reduced-motion:no-preference){html:has(.ak-toc){scroll-behavior:smooth}}
@media print{.ak-toc{display:none}}
${BRAND_CSS}`;
// Optional scrollspy: marks the section you are reading with aria-current and keeps its chip in view.
export const TOC_JS = `(function(){"use strict";if(!("IntersectionObserver"in window))return;
document.querySelectorAll(".ak-toc").forEach(function(nav){var links={},on=null,vis=[],heads=[];
nav.querySelectorAll('a[href^="#"]').forEach(function(a){var id=decodeURIComponent(a.getAttribute("href").slice(1)),h=document.getElementById(id);if(h){links[id]=a;heads.push(h)}});
function set(id){if(id===on)return;if(on&&links[on])links[on].removeAttribute("aria-current");on=id;var a=links[id];if(!a)return;a.setAttribute("aria-current","true");
if(nav.classList.contains("ak-toc-chips")&&nav.scrollWidth>nav.clientWidth){nav.scrollLeft=a.offsetLeft-(nav.clientWidth-a.offsetWidth)/2}}
var io=new IntersectionObserver(function(es){es.forEach(function(e){var k=vis.indexOf(e.target.id);if(e.isIntersecting&&k<0)vis.push(e.target.id);if(!e.isIntersecting&&k>=0)vis.splice(k,1)});
for(var i=0;i<heads.length;i++)if(vis.indexOf(heads[i].id)>=0)return set(heads[i].id)},{rootMargin:"0px 0px -60% 0px"});
heads.forEach(function(h){io.observe(h)})});})();`;
// The kit's common shape: every component exports { name, render(opts) -> html, css, js }.
// render({ html, ...opts }) adds the TOC to a page body; render({ headings: [{ id, text }], ...opts }) renders just the nav.
export default { name: 'toc', title: 'Table of contents', render: (o) => (o.html != null ? addToc(o.html, o) : renderToc(o.headings || [], o)), css: TOC_CSS, js: TOC_JS };
Calculator shell
You write the formula, the shell does the rest: labels, number parsing with commas, range errors, a live result for screen readers, the default result already in the HTML, a link that keeps the values.
Live demo
| Option | What it does |
|---|---|
fields | Number inputs with unit, min, max and help, or selects. |
results | What to show; main: true makes one result large. |
compute | Your formula. Throw an Error to show a message. |
share | A "Copy link to this result" button (default on). |
Show the code
// Amili Kit: Calculator shell. https://amili.ai/kit#kit-calculator
// SPDX-License-Identifier: MIT. Copyright (c) 2026 Amili (Paulo de Vries). Made by Amili.
// Tests and more parts: https://amili.ai/kit. The "brand" option shows a tiny "amili" mark linking to the kit; set
// brand: false to hide it.
//
// import { renderCalculator, CALC_CSS, CALC_JS } from './kit/calculator.mjs';
// const compute = (v) => ({ water: v.flour * v.hydration / 100 });
// html = `<style>${CALC_CSS}</style>` + renderCalculator(opts, compute)
// + `<script>${CALC_JS}</script><script>akCalc.register('dough', ${compute})</script>`
//
// opts = {
// id: 'dough', // required, unique on the page
// title: 'Dough hydration', // optional heading
// fields: [{ name: 'flour', label: 'Flour', unit: 'g', value: 500, min: 1, max: 100000, step: 1, help: '...' },
// { name: 'type', label: 'Flour type', type: 'select', value: 'bread', options: [{ value: 'bread', label: 'Bread flour' }] }],
// results: [{ name: 'water', label: 'Water', unit: 'g', decimals: 0, main: true }],
// note: 'Estimates; check your recipe.', // optional line under the result
// share: true, // "Copy link to this result" (values go in the URL, nothing stored)
// brand: BRAND_DEFAULT, // tiny "amili" mark, links to amili.ai/kit
// }
// compute(values) -> { resultName: number | string }. It runs at build time (the default result is in the HTML, so the
// page never jumps and works without JS) and again in the browser on every change. Throw an Error('message') to show it.
const BRAND_DEFAULT = true; // a tiny "amili" mark linking to amili.ai/kit; pass brand: false to hide it
const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
export function brandMark(on) {
return on ? '<a class="ak-brand" href="https://amili.ai/kit" rel="noopener" aria-label="Made with Amili Kit"><span class="ak-brand-dot" aria-hidden="true"></span>amili</a>' : '';
}
// Same formatting on server and browser: fixed decimals, thin grouping, no locale surprises in the HTML.
export function formatNumber(n, decimals = 2, lang = 'en') {
if (typeof n !== 'number') return String(n);
if (!Number.isFinite(n)) return '\u2013';
return new Intl.NumberFormat(lang, { minimumFractionDigits: 0, maximumFractionDigits: decimals }).format(n);
}
export function validateCalculator(o) {
const errs = [];
if (!o?.id || !/^[a-z][a-z0-9-]*$/.test(o.id)) errs.push('id: lowercase letters, digits and dashes, starting with a letter');
if (!o?.fields?.length) errs.push('fields: at least one');
if (!o?.results?.length) errs.push('results: at least one');
const names = new Set();
for (const f of [...(o?.fields || []), ...(o?.results || [])]) {
if (!f.name || !f.label) errs.push(`field/result without name/label: ${JSON.stringify(f)}`);
if (names.has(f.name)) errs.push(`duplicate name ${f.name}`);
names.add(f.name);
}
for (const f of o?.fields || []) {
if (f.type === 'select' && !f.options?.length) errs.push(`${f.name}: select needs options`);
if (f.type !== 'select' && f.min != null && f.max != null && f.min > f.max) errs.push(`${f.name}: min > max`);
}
return errs;
}
export function renderCalculator(o, compute) {
const errs = validateCalculator(o);
if (errs.length) throw new Error(`Amili Kit calculator: ${errs.join('; ')}`);
const id = o.id, lang = o.lang || 'en';
const defaults = Object.fromEntries(o.fields.map((f) => [f.name, f.type === 'select' ? f.value ?? f.options[0].value : Number(f.value ?? 0)]));
let initial = {}, initialError = '';
if (compute) { try { initial = compute(defaults) || {}; } catch (e) { initialError = e.message; } }
const field = (f) => {
const fid = `${id}-${f.name}`, help = f.help ? `<span class="ak-calc-help" id="${fid}-help">${esc(f.help)}</span>` : '';
const described = `${f.help ? `${fid}-help ` : ''}${fid}-err`;
const control = f.type === 'select'
? `<select id="${fid}" name="${esc(f.name)}" aria-describedby="${described}">${f.options.map((op) => `<option value="${esc(op.value)}"${String(op.value) === String(defaults[f.name]) ? ' selected' : ''}>${esc(op.label)}</option>`).join('')}</select>`
: `<span class="ak-calc-num"><input id="${fid}" name="${esc(f.name)}" type="text" inputmode="decimal" autocomplete="off" value="${esc(defaults[f.name])}"${f.min != null ? ` data-min="${+f.min}"` : ''}${f.max != null ? ` data-max="${+f.max}"` : ''} aria-describedby="${described}">${f.unit ? `<span class="ak-calc-unit" aria-hidden="true">${esc(f.unit)}</span>` : ''}</span>`;
return `<div class="ak-calc-f"><label for="${fid}">${esc(f.label)}${f.unit ? `<span class="ak-vh"> (${esc(f.unit)})</span>` : ''}</label>${control}${help}<span class="ak-calc-err" id="${fid}-err"></span></div>`;
};
const result = (r) => `<div class="ak-calc-r${r.main ? ' ak-calc-main' : ''}"><span class="ak-calc-rl">${esc(r.label)}</span><span class="ak-calc-rv"><output name="${esc(r.name)}" for="${o.fields.map((f) => `${id}-${f.name}`).join(' ')}" data-decimals="${r.decimals ?? 2}">${esc(formatNumber(initial[r.name], r.decimals ?? 2, lang))}</output>${r.unit ? ` <span class="ak-calc-unit">${esc(r.unit)}</span>` : ''}</span></div>`;
const brand = o.brand === undefined ? BRAND_DEFAULT : !!o.brand;
return `<form class="ak-calc" id="${id}" data-ak-calc="${id}" data-lang="${esc(lang)}" novalidate${o.title ? ` aria-labelledby="${id}-h"` : ` aria-label="Calculator"`}>`
+ (o.title ? `<h2 class="ak-calc-h" id="${id}-h">${esc(o.title)}</h2>` : '')
+ `<div class="ak-calc-fields">${o.fields.map(field).join('')}</div>`
+ `<div class="ak-calc-out" role="status" aria-live="polite" aria-atomic="true">${o.results.map(result).join('')}<p class="ak-calc-msg">${esc(initialError)}</p></div>`
+ (o.note ? `<p class="ak-calc-note">${esc(o.note)}</p>` : '')
+ `<div class="ak-calc-actions"><button type="reset" class="ak-calc-btn">Reset</button>${o.share === false ? '' : '<button type="button" class="ak-calc-btn" data-ak-calc-share hidden>Copy link to this result</button>'}${brand ? brandMark(true) : ''}</div>`
+ `</form>`;
}
export const BRAND_CSS = `.ak-brand{display:inline-flex;align-items:center;gap:.3em;min-height:44px;padding:0 .25rem;font:400 11px/1 Georgia,"Times New Roman",serif;letter-spacing:.01em;color:inherit;opacity:.5;text-decoration:none}
.ak-brand:hover,.ak-brand:focus-visible{opacity:.85}
.ak-brand-dot{width:.5em;height:.5em;border-radius:50%;background:#2F5D50;order:2;margin-top:-.45em}
@media (prefers-color-scheme:dark){.ak-brand-dot{background:#7FB3A1}}`;
export const CALC_CSS = `.ak-calc{max-width:var(--ak-max,36rem);margin:1.5rem 0;padding:1.25rem;border:1px solid var(--ak-line,color-mix(in srgb,currentColor 16%,transparent));border-radius:var(--ak-radius,16px);background:var(--ak-bg,transparent);color:inherit}
.ak-calc-h{margin:0 0 1rem;font-size:1.25rem}
.ak-calc-fields{display:grid;grid-template-columns:1fr;gap:14px}
@media (min-width:36rem){.ak-calc-fields{grid-template-columns:repeat(2,minmax(0,1fr))}}
.ak-calc-f{display:flex;flex-direction:column;gap:6px;min-width:0}
.ak-calc-f label{font-weight:600;font-size:.9375rem}
.ak-calc-num{position:relative;display:flex;align-items:center}
.ak-calc input,.ak-calc select{width:100%;min-height:48px;padding:0 12px;border:1px solid var(--ak-line,color-mix(in srgb,currentColor 25%,transparent));border-radius:10px;background:var(--ak-field,Field);color:var(--ak-field-fg,FieldText);font:inherit;font-size:16px;font-variant-numeric:tabular-nums}
.ak-calc-num .ak-calc-unit{position:absolute;right:12px;pointer-events:none;opacity:.6}
.ak-calc-num input{padding-right:3.25rem}
.ak-calc input:focus-visible,.ak-calc select:focus-visible,.ak-calc-btn:focus-visible{outline:2px solid currentColor;outline-offset:2px}
.ak-calc [aria-invalid=true]{border-color:var(--ak-bad,#b3261e)}
.ak-calc-help{font-size:.8125rem;opacity:.7}
.ak-calc-err{min-height:1.2em;font-size:.8125rem;color:var(--ak-bad,#b3261e)}
@media (prefers-color-scheme:dark){.ak-calc-err{color:var(--ak-bad,#f2b8b5)}.ak-calc [aria-invalid=true]{border-color:var(--ak-bad,#f2b8b5)}}
.ak-calc-out{margin:1rem 0 0;padding:1rem;border-radius:12px;background:color-mix(in srgb,currentColor 5%,transparent)}
.ak-calc-r{display:flex;justify-content:space-between;align-items:baseline;gap:1rem;padding:.25rem 0}
.ak-calc-rv{font-weight:650;font-variant-numeric:tabular-nums;text-align:right}
.ak-calc-main .ak-calc-rv{font-size:1.75rem;line-height:1.2}
.ak-calc-main .ak-calc-rl{font-weight:600}
.ak-calc-msg{margin:.25rem 0 0;min-height:1.2em;font-size:.875rem;color:var(--ak-bad,#b3261e)}
.ak-calc-msg:empty{display:none}
.ak-calc-note{margin:.75rem 0 0;font-size:.8125rem;opacity:.7}
.ak-calc-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px;margin-top:1rem}
.ak-calc-btn{min-height:44px;padding:0 16px;border:1px solid var(--ak-line,color-mix(in srgb,currentColor 25%,transparent));border-radius:999px;background:transparent;color:inherit;font:inherit;font-size:.875rem;cursor:pointer}
.ak-calc-actions .ak-brand{margin-left:auto}
.ak-vh{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
${BRAND_CSS}`;
// The browser side. akCalc.register(id, compute) wires a form rendered by renderCalculator: parses numbers (a comma
// works as decimal point), checks min/max, runs compute, writes the outputs, keeps the values in the URL (?id.name=v)
// so a result can be shared, and restores them on load. Nothing is stored anywhere else.
export const CALC_JS = `(function(){"use strict";if(window.akCalc)return;
function num(s){s=String(s).trim().replace(/\\s/g,"");if(/^-?\\d{1,3}(\\.\\d{3})+(,\\d+)?$/.test(s))s=s.replace(/\\./g,"").replace(",",".");else s=s.replace(",",".");return s===""?NaN:Number(s)}
function fmt(n,d,lang){if(typeof n!=="number")return n==null?"":String(n);if(!isFinite(n))return"\\u2013";return new Intl.NumberFormat(lang,{minimumFractionDigits:0,maximumFractionDigits:d}).format(n)}
function register(id,compute){var form=document.querySelector('form[data-ak-calc="'+id+'"]');if(!form)return;var lang=form.getAttribute("data-lang")||"en",
inputs=Array.prototype.slice.call(form.querySelectorAll("input,select")),outs=form.querySelectorAll("output"),msg=form.querySelector(".ak-calc-msg"),share=form.querySelector("[data-ak-calc-share]"),defaults={},frame=0;
inputs.forEach(function(el){defaults[el.name]=el.value});
function read(){var v={},ok=true;inputs.forEach(function(el){var err=form.querySelector("#"+el.id+"-err");var bad="";
if(el.tagName==="SELECT"){v[el.name]=el.value}else{var n=num(el.value),mn=el.getAttribute("data-min"),mx=el.getAttribute("data-max");
if(isNaN(n))bad="Enter a number.";else if(mn!==null&&n<+mn)bad="At least "+fmt(+mn,6,lang)+".";else if(mx!==null&&n>+mx)bad="At most "+fmt(+mx,6,lang)+".";v[el.name]=n}
if(bad){ok=false;el.setAttribute("aria-invalid","true")}else el.removeAttribute("aria-invalid");if(err)err.textContent=bad});return ok?v:null}
function run(){frame=0;var v=read(),r;if(!v){outs.forEach(function(o){o.value="\\u2013"});return}
try{r=compute(v)||{};msg.textContent=""}catch(e){msg.textContent=e&&e.message||"Cannot calculate this.";outs.forEach(function(o){o.value="\\u2013"});return}
outs.forEach(function(o){o.value=fmt(r[o.name],+o.getAttribute("data-decimals"),lang)});
try{var u=new URL(location.href);inputs.forEach(function(el){if(el.value!==defaults[el.name])u.searchParams.set(id+"."+el.name,el.value);else u.searchParams.delete(id+"."+el.name)});history.replaceState(history.state,"",u)}catch(e){}}
function soon(){if(!frame)frame=requestAnimationFrame(run)}
try{var q=new URL(location.href).searchParams,any=false;inputs.forEach(function(el){var p=q.get(id+"."+el.name);if(p!==null){el.value=p;any=true}});if(any)run()}catch(e){}
form.addEventListener("input",soon);form.addEventListener("change",soon);form.addEventListener("submit",function(e){e.preventDefault();run()});
form.addEventListener("reset",function(){setTimeout(run,0)});
if(share&&navigator.clipboard){share.hidden=false;share.addEventListener("click",function(){navigator.clipboard.writeText(location.href).then(function(){var t=share.textContent;share.textContent="Link copied";setTimeout(function(){share.textContent=t},1800)},function(){})})}}
window.akCalc={register:register,parse:num,format:fmt};})();`;
// The kit's common shape: every component exports { name, render(opts) -> html, css, js }. opts.compute = your formula.
export default { name: 'calculator', title: 'Calculator shell', render: (o) => renderCalculator(o, o.compute), css: CALC_CSS, js: CALC_JS };
Consent bar
One line, two equal buttons, Escape means no. Sets Google Consent Mode defaults, asks only where the law asks (or everywhere), remembers for 180 days, and reopens from any "Cookie settings" link.
Live demo
This demo shows the bar in place. Buttons here only show what was chosen. Privacy
| Option | What it does |
|---|---|
consentHead() | Put it in the head, before your analytics tag: Consent Mode defaults and the stored choice. |
ask | "eu" asks only where the law requires it; "all" asks everyone. |
ak-consent | An event on window with "granted" or "denied", for tools without Consent Mode. |
Show the code
// Amili Kit: Consent bar. https://amili.ai/kit#kit-consent
// SPDX-License-Identifier: MIT. Copyright (c) 2026 Amili (Paulo de Vries). Made by Amili.
// Tests and more parts: https://amili.ai/kit. The "brand" option shows a tiny "amili" mark linking to the kit; set
// brand: false to hide it.
//
// import { renderConsent, consentHead, CONSENT_CSS, CONSENT_JS } from './kit/consent.mjs';
// <head>: consentHead() (BEFORE your gtag.js tag) <body>: <style>CONSENT_CSS</style> renderConsent(opts) <script>CONSENT_JS</script>
//
// renderConsent opts = {
// text: 'We use analytics cookies to see which pages help.', // one plain sentence: what, and why
// policyHref: '/privacy/', policyLabel: 'Privacy',
// accept: 'Accept', reject: 'Reject', // same size, same style, same weight: no dark pattern
// ask: 'eu' | 'all', // eu: only ask where the law requires it (time-zone test, below)
// lang: 'en',
// }
// consentHead({ regions }) -> <script> that sets Google Consent Mode v2 defaults: denied in `regions` (default: EEA, UK,
// CH, US-CA), granted elsewhere, then applies a stored choice. Works without Google too: listen for the `ak-consent`
// event ({ detail: 'granted' | 'denied' }) or read localStorage "ak-consent".
// The choice is kept for 180 days, then asked again. Nothing leaves the browser. No brand mark: this is legal UI.
const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
export const EEA_UK_CH_CA = ['AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR', 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL', 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE', 'IS', 'LI', 'NO', 'GB', 'CH', 'US-CA'];
// Browser-side guess for "should we ask?": the device time zone. Europe/*, the EU's Atlantic and overseas zones, and
// America/Los_Angeles (California). A guess errs toward asking: an unknown zone asks.
export const ASK_ZONES = /^(Europe\/|Atlantic\/(Reykjavik|Faroe|Canary|Madeira|Azores|Jan_Mayen)|Arctic\/Longyearbyen|Africa\/Ceuta|Asia\/(Nicosia|Famagusta)|Indian\/(Reunion|Mayotte)|America\/(Guadeloupe|Martinique|Cayenne|St_Barthelemy|Marigot|Los_Angeles))/;
export function consentHead({ regions = EEA_UK_CH_CA } = {}) {
if (!Array.isArray(regions) || regions.some((r) => !/^[A-Z]{2}(-[A-Z0-9]{1,3})?$/.test(r))) throw new Error('consentHead: regions must be ISO codes like "NL" or "US-CA"');
const denied = "ad_storage:'denied',ad_user_data:'denied',ad_personalization:'denied',analytics_storage:'denied'";
const granted = "ad_storage:'granted',ad_user_data:'granted',ad_personalization:'granted',analytics_storage:'granted'";
return `<script>window.dataLayer=window.dataLayer||[];window.gtag=window.gtag||function(){dataLayer.push(arguments)};`
+ `gtag('consent','default',{${denied},wait_for_update:500,region:${JSON.stringify(regions).replace(/"/g, "'")}});`
+ `gtag('consent','default',{${granted}});`
+ `(function(){try{var c=JSON.parse(localStorage.getItem('ak-consent')||'null');if(c&&Date.now()-c.t<15552e6)gtag('consent','update',c.v==='granted'?{${granted}}:{${denied}})}catch(e){}})();</script>`;
}
export function renderConsent(o = {}) {
const ask = o.ask === 'all' ? 'all' : 'eu';
return `<div class="ak-cc" role="region" aria-label="Cookie choice" data-ask="${ask}" hidden><div class="ak-cc-in">`
+ `<p class="ak-cc-t">${esc(o.text || 'We use analytics cookies to learn which pages help.')} <a href="${esc(o.policyHref || '/privacy/')}">${esc(o.policyLabel || 'Privacy')}</a></p>`
+ `<div class="ak-cc-b"><button type="button" data-ak-cc="denied">${esc(o.reject || 'Reject')}</button><button type="button" data-ak-cc="granted">${esc(o.accept || 'Accept')}</button></div>`
+ `</div></div>`;
}
export const CONSENT_CSS = `.ak-cc{position:fixed;left:0;right:0;bottom:0;z-index:2147483000;padding:10px 12px calc(10px + env(safe-area-inset-bottom,0px));background:var(--ak-bg,Canvas);color:var(--ak-fg,CanvasText);border-top:1px solid var(--ak-line,color-mix(in srgb,currentColor 16%,transparent));box-shadow:0 -8px 24px rgba(0,0,0,.12);font-size:.9375rem;line-height:1.4}
.ak-cc[hidden]{display:none}
.ak-cc-in{display:flex;flex-wrap:wrap;align-items:center;gap:8px 16px;max-width:var(--ak-max,60rem);margin:0 auto}
.ak-cc-t{flex:1 1 20rem;margin:0}
.ak-cc-t a{color:inherit;text-decoration:underline;text-underline-offset:.2em;display:inline-block;padding:12px 0;margin:-12px 0}
.ak-cc-b{display:flex;gap:8px;flex:1 1 14rem;max-width:22rem}
.ak-cc-b button{flex:1 1 0;min-height:44px;padding:0 16px;border:1px solid currentColor;border-radius:10px;background:transparent;color:inherit;font:inherit;font-weight:600;cursor:pointer}
.ak-cc-b button:hover{background:color-mix(in srgb,currentColor 8%,transparent)}
.ak-cc-b button:focus-visible{outline:2px solid currentColor;outline-offset:2px}
@media print{.ak-cc{display:none}}`;
// Shows the bar when no fresh choice exists (and, with ask="eu", only where the time zone says the law asks for it).
// Both buttons, Escape (= reject) and any [data-cc="show"] element (the footer's "Cookie settings") work. Choosing
// updates Consent Mode, tells Microsoft Clarity (consentv2) when present and fires `ak-consent` on window.
export const CONSENT_JS = `(function(){"use strict";var KEY="ak-consent",MAX=15552e6,
ZONES=${ASK_ZONES.toString()};
var bar=document.querySelector(".ak-cc");if(!bar)return;
function stored(){try{var c=JSON.parse(localStorage.getItem(KEY)||"null");return c&&Date.now()-c.t<MAX?c.v:null}catch(e){return null}}
function mustAsk(){if(bar.getAttribute("data-ask")==="all")return true;try{var z=Intl.DateTimeFormat().resolvedOptions().timeZone||"";return !z||ZONES.test(z)}catch(e){return true}}
function show(){bar.hidden=false;var b=bar.querySelector("button");if(b&&document.activeElement===document.body)b.focus({preventScroll:true})}
function choose(v){try{localStorage.setItem(KEY,JSON.stringify({v:v,t:Date.now()}))}catch(e){}bar.hidden=true;
var g=v==="granted"?"granted":"denied";
if(window.gtag)gtag("consent","update",{ad_storage:g,ad_user_data:g,ad_personalization:g,analytics_storage:g});
if(window.clarity)try{clarity("consentv2",{ad_Storage:g,analytics_Storage:g})}catch(e){}
try{window.dispatchEvent(new CustomEvent("ak-consent",{detail:v}))}catch(e){}}
bar.addEventListener("click",function(e){var v=e.target.closest&&e.target.closest("[data-ak-cc]");if(v)choose(v.getAttribute("data-ak-cc"))});
bar.addEventListener("keydown",function(e){if(e.key==="Escape")choose("denied")});
document.addEventListener("click",function(e){var t=e.target.closest&&e.target.closest('[data-cc="show"]');if(t){e.preventDefault();show()}});
if(!stored()&&mustAsk())show();
window.akConsent={show:show,choose:choose,get:stored};})();`;
// The kit's common shape: every component exports { name, render(opts) -> html, css, js } (+ head() for <head>).
export default { name: 'consent', title: 'Consent bar', render: renderConsent, head: consentHead, css: CONSENT_CSS, js: CONSENT_JS };
Click beacon
Counts clicks on the links you choose without cookies or ids: which link, which kind of page, nothing else. Skips your own test browsers. Comes with the server handler.
What one click sends
POST https://your-endpoint.example/c?s=example.com&f=hero&p=guidesWhich link, which kind of page. No address, no referrer, no time, no cookie, no id.
| Option | What it does |
|---|---|
endpoint | Required. Your own counter, for example a Cloudflare Worker using makeBeaconHandler. |
site | Required. Your hostname, so one endpoint can count several sites. |
match | Which links count (default: /go/ links and Amazon). |
pages | Your page classes, for example [["^/compare/", "compare"]]. |
Show the code
// Amili Kit: Click beacon. https://amili.ai/kit#kit-beacon
// SPDX-License-Identifier: MIT. Copyright (c) 2026 Amili (Paulo de Vries). Made by Amili.
// Tests and more parts: https://amili.ai/kit. The "brand" option shows a tiny "amili" mark linking to the kit; set
// brand: false to hide it.
//
// import { beaconScript, makeBeaconHandler } from './kit/beacon.mjs';
// html += `<script>${beaconScript({ endpoint: 'https://your-endpoint.example/c', site: 'example.com' })}</script>`
// Mark links you care about: <a href="/go/x" data-from="hero"> (data-event-from works too)
//
// beaconScript opts = {
// endpoint: 'https://your-endpoint.example/c', // required: where counts go (your Worker; see makeBeaconHandler)
// site: 'example.com', // required: sent as s=, so one endpoint can count many sites
// match: '/go/|amazon\\.|amzn\\.to', // which links count (regex source, tested against the href)
// bounty: 'purchaseType=MTRIAL|/go/audible', // optional: links that are a different kind of click (t=b)
// pages: [['^/$', 'home'], ['^/compare/', 'compare']], // optional page classes; default: first path segment
// optOutParam: '__fa', // ?__fa=1 once marks this browser as yours (never counted)
// }
// Sends: s=<site>&f=<from label>&p=<page class>[&t=b]. Nothing else: no URL, no referrer, no time, no id.
//
// makeBeaconHandler({ record, sites }) -> (request) => Response. For a Cloudflare Worker or Pages Function:
// validates the four fields, adds the day (UTC) and the visitor's country from Cloudflare, calls record(point), answers
// 204. `record` is yours: Analytics Engine, D1, a queue, or kvCounter(env.KV) below for a quick start.
const LABEL = /^[a-z0-9][a-z0-9_-]{0,39}$/;
export function beaconScript(o = {}) {
if (!o.endpoint || !/^https:\/\/[^/]+\/?/.test(o.endpoint) || /[?#'"\\<>]/.test(o.endpoint)) throw new Error('beaconScript: endpoint must be a plain https:// URL (no query)');
if (!o.site || !/^[a-z0-9.-]+$/.test(o.site)) throw new Error('beaconScript: site must be a hostname like example.com');
const match = new RegExp(o.match || '/go/|amazon\\.|amzn\\.to', 'i');
const bounty = o.bounty ? new RegExp(o.bounty, 'i') : null;
const pages = (o.pages || []).map(([re, label]) => {
if (!LABEL.test(label)) throw new Error(`beaconScript: page label "${label}" must match ${LABEL}`);
return [new RegExp(re), label];
});
const opt = /^[a-z_]{1,12}$/.test(o.optOutParam || '__fa') ? (o.optOutParam || '__fa') : '__fa';
return `(function(){"use strict";var E=${JSON.stringify(o.endpoint)},S=${JSON.stringify(o.site)},M=${match},B=${bounty || 'null'},P=[${pages.map(([r, l]) => `[${r},${JSON.stringify(l)}]`).join(',')}],L=${LABEL};
var me=false;try{if(location.search.indexOf("${opt}=1")>-1)localStorage.setItem("ak-no-count","1");me=localStorage.getItem("ak-no-count")==="1"}catch(e){}
if(navigator.webdriver===true||/HeadlessChrome/.test(navigator.userAgent||""))me=true;
function page(){var p=location.pathname;for(var i=0;i<P.length;i++)if(P[i][0].test(p))return P[i][1];var s=p.split("/")[1]||"home";return L.test(s)?s:"other"}
function hit(e){try{if(me||!e.isTrusted)return;var a=e.target&&e.target.closest&&e.target.closest("a[href]");if(!a)return;var h=a.href||"";if(!M.test(h))return;
var f=(a.getAttribute("data-from")||a.getAttribute("data-event-from")||"untagged").toLowerCase();if(!L.test(f))f="other";
var q="s="+encodeURIComponent(S)+"&f="+encodeURIComponent(f)+"&p="+encodeURIComponent(page())+(B&&B.test(h)?"&t=b":"");
if(navigator.sendBeacon)navigator.sendBeacon(E+"?"+q)}catch(x){}}
document.addEventListener("click",hit,true);document.addEventListener("auxclick",function(e){if(e.button===1)hit(e)},true);})();`;
}
export function makeBeaconHandler({ record, sites } = {}) {
if (typeof record !== 'function') throw new Error('makeBeaconHandler: record(point) is required');
const allow = sites ? new Set(sites) : null;
return async (request) => {
const u = new URL(request.url);
const s = u.searchParams.get('s') || '', f = u.searchParams.get('f') || '', p = u.searchParams.get('p') || '', t = u.searchParams.get('t');
const ok = /^[a-z0-9.-]{3,80}$/.test(s) && LABEL.test(f) && LABEL.test(p) && (t === null || t === 'b') && (!allow || allow.has(s));
if (!ok) return new Response(null, { status: 400 });
const point = { day: new Date().toISOString().slice(0, 10), site: s, from: f, page: p, type: t === 'b' ? 'bounty' : 'click', country: /^[A-Z]{2}$/.test(request.cf?.country || '') ? request.cf.country : 'XX' };
try { await record(point); } catch { return new Response(null, { status: 503 }); }
return new Response(null, { status: 204, headers: { 'access-control-allow-origin': '*', 'cache-control': 'no-store' } });
};
}
// Quick start counter on Workers KV: one key per (day, site, from, page, type, country). KV is eventually consistent,
// so two clicks in the same second can count once: fine for "which placement works", not for billing. Keys expire in 400 days.
export const kvCounter = (kv) => async (pt) => {
const { day, site, from, page, type, country } = pt;
const key = `c:${day}:${site}:${from}:${page}:${type}:${country}`;
const n = Number(await kv.get(key)) || 0;
await kv.put(key, String(n + 1), { expirationTtl: 400 * 86400 });
};
// The kit's common shape: every component exports { name, render(opts) -> html, css, js }. Here render() is the <script>.
export default { name: 'beacon', title: 'Click beacon', render: (o) => `<script>${beaconScript(o)}</script>`, css: '', js: '' };
Everything the kit will contain
What is here today and what comes next, grouped by job. Sites that use the kit get new versions one at a time, each checked before it goes live, never from one shared script.
Trust and legal
- FooterHere
- Affiliate disclosure (in the footer)Here
- Consent barHere
- Privacy page from the trackers a site loadsNext
- Contact blockNext
Navigation
- BreadcrumbsHere
- Amili SearchHere
- Table of contentsHere
- Related linksHere
- Header and menuNext
- 404 page with searchNext
Content
- FAQHere
- Comparison tableHere
- Calculator shellHere
- "Data last updated" line (in the footer)Here
- Sources blockNext
Money
- Amazon product cardHere
- Sticky buy barHere
- Click beaconHere
- /go/ link gate (with the Amazon card)Here
- In-page buy blockNext
Search engines and AI answers
- FAQ and breadcrumb structured dataHere
- Article, Product and Organization structured dataNext
- Sitemap, robots.txt and llms.txt generatorsNext
- Changed-pages-only IndexNow helperNext
Quality
- Tests with planted bugs for every partHere
- Page basics checklistHere
- Visual check at phone width, light and darkNext
- Analytics muted during browser testsNext
Amazon product card
A product card for Amazon Associates sites, in five placements. It shows price, photo and title only from Amazon's own product API, and never shows star ratings, review counts or a Prime badge. Without live data it falls back to plain text.
Live demo, without API data (links do nothing)
This is the text-only fallback with a made-up product. With your API connected, the photo, Amazon's title, the price and "Price as of" fill in.
The five placements
| Variant | Where | Height |
|---|---|---|
v1 | Top, above the header. One product at a time, swipe and dots. | 150 px |
v2 | Sticky at the bottom, with a close button. The page is padded so nothing is covered. | 156 px |
v3 | In the content, after the first block. Large image, full-width button. | 368 px |
v4 | In the content. Three products in a swipe row. | 318 px |
v5 | Between results. One slim line. | 104 px |
Options
| Option | What it does |
|---|---|
products | Your products: ASIN, your own short name, your own one-line benefit, optional tags and weight. No prices or stars in your own words; it checks. |
disclosure | Required. Your Amazon Associate disclosure, word for word. |
variant, n | Which placement, and how many products it may show. |
api, gate | Your own two endpoints: live product data, and the redirect that adds your tracking ID on the server. |
lang, labels | English or Dutch, or your own words. |
You bring your own tracking ID, your own API credentials and your own product list. They are passed in by you and stay on your server; the browser only ever sees your own /go/p links.
Show the code
// Amili Kit: Amazon product card. https://amili.ai/kit
// SPDX-License-Identifier: MIT. Copyright (c) 2026 Amili (Paulo de Vries). Made by Amili.
//
// An Amazon product card in five placements (top, sticky bottom, in content, 3-up carousel, slim line), filled with
// live data from Amazon's Creators API through your own Cloudflare Pages Functions. You bring your own Associates
// tracking ID, your own API credentials and your own product list; nothing of ours is in here.
//
// THE COMPLIANCE CONTRACT, enforced in code:
// 1. Price, image and Amazon's product title come ONLY from a live API response fetched by your own Function
// (makeItemsHandler), edge-cached at most CACHE_TTL_S (1 h, well under Amazon's 24 h ceiling). Every price carries
// "Price as of <time>" and Amazon's disclaimer. The browser refuses data older than 24 h.
// 2. Star ratings, review counts and the Prime badge are never rendered. There is no code path that prints them.
// 3. Without API data (an error, no JS, a bot) the card is typographic: your own short name, your own one-line
// benefit and "See it on Amazon". No image, no price, no brand logos.
// 4. "Sponsored" plus an info button with your affiliate disclosure on every variant. Links go through your own
// /go/p gate (makeGateHandler) with rel="sponsored nofollow noopener"; the tracking ID lives only in the Function.
// 5. Every slot has a fixed height (no layout shift); the sticky one pads the page and can be closed; 44 px targets;
// prefers-reduced-motion gets no auto-advance.
//
// THE ORDER (pickProducts): candidates = your products whose tags match the page, else the site-wide list. Order =
// weighted sampling without replacement (key u^(1/w)), u from a hash of (page, UTC day, ASIN): the same all day,
// new each day, and w (default 1) is where your own click data can go. RANK_JS is the same function for the browser.
//
// THE A/B: AD_HEAD_JS gives each visitor one variant (v1..v5, uniform, kept in localStorage) before first paint; CSS
// shows only that slot. Links carry data-event-from="ad-v<N>" and data-asin for your analytics. ?akv=v3 forces one.
//
// Wiring (Cloudflare Pages):
// functions/amz/items.js export const onRequestGet = makeItemsHandler({ tag, allow, credentials: (env) => ({ id: env.YOUR_ID, secret: env.YOUR_SECRET }) });
// functions/go/p.js export const onRequestGet = makeGateHandler({ tag, allow });
export const VARIANTS = ['v1', 'v2', 'v3', 'v4', 'v5'];
export const CACHE_TTL_S = 3600; // edge cache of API data; Amazon's ceiling is 24h
export const MAX_AGE_MS = 24 * 3600 * 1000;
const ASIN_RE = /^[A-Z0-9]{10}$/;
const EMOJI = /\p{Extended_Pictographic}/u;
export const DISCLAIMER = 'Product prices and availability are accurate as of the date/time indicated and are subject to change. Any price and availability information displayed on Amazon.com at the time of purchase will apply to the purchase of this product.';
const L = {
en: { sponsored: 'Sponsored', cta: 'See it on Amazon', asOf: 'Price as of', details: 'Details', close: 'Close ad', info: 'About this ad', prev: 'Previous product', next: 'Next product', slide: 'Product' },
nl: { sponsored: 'Gesponsord', cta: 'Bekijk op Amazon', asOf: 'Prijs per', details: 'Details', close: 'Advertentie sluiten', info: 'Over deze advertentie', prev: 'Vorig product', next: 'Volgend product', slide: 'Product' },
};
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
// ---------- the algorithm ----------
export function hash32(str) { // FNV-1a, stable across Node and browsers
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 0x01000193); }
return h >>> 0;
}
export const utcDay = (d = new Date()) => d.toISOString().slice(0, 10);
export function candidatesFor(pool, tags = []) {
const want = new Set(tags.map((t) => String(t).toLowerCase()));
const hit = pool.filter((p) => (p.tags || []).some((t) => want.has(String(t).toLowerCase())));
return hit.length ? hit : pool.filter((p) => !p.onlyTagged);
}
export function pickProducts(pool, { page = '', tags = [], day = utcDay(), n = 1, weights = {} } = {}) {
const cands = candidatesFor(pool, tags);
const keyed = cands.map((p) => {
const u = (hash32(`${page}|${day}|${p.asin}`) + 1) / 4294967297; // (0,1)
const w = Math.max(0.01, Number(weights[p.asin] ?? p.w ?? 1));
return { p, k: Math.pow(u, 1 / w) };
});
keyed.sort((a, b) => b.k - a.k || (a.p.asin < b.p.asin ? -1 : 1));
return keyed.slice(0, n === Infinity ? keyed.length : n).map((x) => x.p);
}
export function validatePool(pool) {
const errs = [];
const seen = new Set();
for (const p of pool || []) {
if (!ASIN_RE.test(p?.asin || '')) errs.push(`bad asin: ${JSON.stringify(p?.asin)}`);
if (seen.has(p?.asin)) errs.push(`duplicate asin ${p.asin}`);
seen.add(p?.asin);
if (!p?.name || !p?.why) errs.push(`${p?.asin}: name and why are required (our own words)`);
for (const t of [p?.name, p?.why]) if (t && EMOJI.test(t)) errs.push(`emoji in ad text: ${t}`);
for (const t of [p?.name, p?.why]) if (t && /\d(\.\d)?\s*(stars?|★)|\$\s?\d|€\s?\d|reviews?\b|prime\b/i.test(t)) errs.push(`${p.asin}: no prices, stars, reviews or Prime in our own copy: ${t}`);
}
if (!pool?.length) errs.push('empty pool');
return errs;
}
// ---------- API normalisation (server side) ----------
// One Creators API item -> the only fields the ad may show. Returns null when there is no NEW buy-box offer in stock.
export function parseItem(it) {
if (!it || !ASIN_RE.test(it.asin || '')) return null;
const L2 = it.offersV2?.listings || [];
const bb = L2.find((l) => l.isBuyBoxWinner && (l.condition?.value || 'New') === 'New');
const avail = bb?.availability?.type;
if (!bb || (avail && avail !== 'IN_STOCK')) return null;
const img = it.images?.primary?.large || it.images?.primary?.medium;
const price = bb.price?.money?.displayAmount;
return {
title: it.itemInfo?.title?.displayValue || '',
brand: it.itemInfo?.byLineInfo?.brand?.displayValue || '',
img: img?.url && /^https:\/\/m\.media-amazon\.com\//.test(img.url) ? { url: img.url, w: img.width || 500, h: img.height || 500 } : null,
price: price && !bb.violatesMAP ? price : null,
};
}
export const API_RESOURCES = ['itemInfo.title', 'itemInfo.byLineInfo', 'images.primary.large', 'offersV2.listings.price', 'offersV2.listings.isBuyBoxWinner', 'offersV2.listings.condition', 'offersV2.listings.availability'];
// Cloudflare Pages Function factory for GET /amz/items?a=ASIN1,ASIN2 (<=10, allow-listed).
// Pass credentials: (env) => ({ id, secret }) that reads your own Creators API secrets. Rate limits: one getItems call per distinct
// candidate list per colo per hour (caches.default), token reused until expiry, and a failure is cached 5 min so an
// outage cannot turn page views into API calls. Response: {ok, asOf, items:{ASIN:{title,brand,img,price}}}.
export function makeItemsHandler({ tag, allow, credentials, marketplace = 'www.amazon.com', fetchImpl, cache } = {}) {
const allowed = new Set(allow || []);
let tok = null;
let tokExp = 0;
return async function onRequestGet(ctx) {
const { request, env = {} } = ctx;
const f = fetchImpl || fetch;
const url = new URL(request.url);
const asins = [...new Set((url.searchParams.get('a') || '').split(',').map((s) => s.trim().toUpperCase()))].filter((a) => ASIN_RE.test(a) && allowed.has(a)).sort().slice(0, 10);
const json = (body, ttl) => new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': `public, max-age=${Math.min(ttl, 600)}`, 'x-robots-tag': 'noindex' } });
if (!asins.length || !tag) return json({ ok: false, reason: !tag ? 'no-tag' : 'no-asins' }, 300);
const c = cache === undefined ? (globalThis.caches && globalThis.caches.default) : cache;
const key = new Request(`${url.origin}/amz/items?a=${asins.join(',')}&v=1`);
if (c) { const hit = await c.match(key); if (hit) return hit; }
let body;
let ttl = CACHE_TTL_S;
try {
const { id, secret } = credentials ? credentials(env) || {} : {}; // read from YOUR secrets, never hard-coded
if (!id || !secret) throw new Error('no-credentials');
if (!tok || Date.now() > tokExp) {
const t = await f('https://api.amazon.com/auth/o2/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ grant_type: 'client_credentials', client_id: id, client_secret: secret, scope: 'creatorsapi::default' }) }).then((r) => r.json());
if (!t?.access_token) throw new Error('token');
tok = t.access_token;
tokExp = Date.now() + Math.max(60, (t.expires_in || 3600) - 120) * 1000;
}
const r = await f('https://creatorsapi.amazon/catalog/v1/getItems', { method: 'POST', headers: { Authorization: `Bearer ${tok}`, 'Content-Type': 'application/json', 'x-marketplace': marketplace }, body: JSON.stringify({ itemIds: asins, itemIdType: 'ASIN', marketplace, partnerTag: tag, resources: API_RESOURCES }) });
if (!r.ok) { if (r.status === 401) tok = null; throw new Error(`api ${r.status}`); }
const j = await r.json();
const items = {};
// the live key is itemsResult (docs say itemResults); the API may echo an ASIN we did not ask for — drop it
for (const it of (j.itemsResult || j.itemResults || {}).items || []) if (asins.includes(it?.asin)) { const p = parseItem(it); if (p) items[it.asin] = p; }
body = { ok: true, asOf: new Date().toISOString(), items };
} catch (e) {
body = { ok: false, reason: String(e.message || e).slice(0, 40) };
ttl = 300;
}
const res = json(body, ttl);
if (c) { const store = new Response(res.clone().body, res); store.headers.set('cache-control', `public, max-age=${ttl}`); ctx.waitUntil ? ctx.waitUntil(c.put(key, store)) : await c.put(key, store); }
return res;
};
}
// Cloudflare Pages Function factory for GET /go/p?a=ASIN — the affiliate gate. Real same-site navigations only
// (Fetch Metadata), allow-listed ASINs only, tag only here. Anything else goes home.
function amazonUrl(host, asin, tag) {
const u = new URL(`https://${host}/dp/${asin}/`);
u.searchParams.set('tag', tag); // your own Associates tracking ID, passed in by you
for (const [k, v] of [['linkCode', 'ogi'], ['th', '1'], ['psc', '1']]) u.searchParams.set(k, v);
return u.toString();
}
export function makeGateHandler({ tag, allow, host = 'www.amazon.com' } = {}) {
const allowed = new Set(allow || []);
return function onRequestGet({ request }) {
const url = new URL(request.url);
const home = () => Response.redirect(url.origin + '/', 302);
const mode = request.headers.get('sec-fetch-mode');
const site = request.headers.get('sec-fetch-site');
if (mode !== 'navigate' || (site !== 'same-origin' && site !== 'same-site')) return home();
const a = (url.searchParams.get('a') || '').toUpperCase();
if (!tag || !ASIN_RE.test(a) || !allowed.has(a)) return home();
return new Response(null, { status: 302, headers: { Location: amazonUrl(host, a, tag), 'cache-control': 'no-store', 'x-robots-tag': 'noindex, nofollow' } });
};
}
// ---------- markup ----------
const INFO_SVG = '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" focusable="false"><circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M8 7v4.2M8 4.6v.1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>';
const X_SVG = '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true" focusable="false"><path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>';
function card(p, i, cfg, t) {
const href = `${cfg.gate || '/go/p'}?a=${p.asin}`;
// data-ak-* slots are filled by AD_JS from the live API; until then (or forever, without data) the card is typographic.
return `<li class="ak-ad-card" data-asin="${p.asin}" data-i="${i}">`
+ `<a class="ak-ad-link" href="${esc(href)}" rel="sponsored nofollow noopener" target="_blank" data-event-from="ad-${cfg.variant}" data-asin="${p.asin}">`
+ '<span class="ak-ad-img" data-ak-img></span>'
+ '<span class="ak-ad-body">'
+ `<span class="ak-ad-brand" data-ak-brand></span>`
+ `<span class="ak-ad-title" data-ak-title>${esc(p.name)}</span>`
+ `<span class="ak-ad-why">${esc(p.why)}</span>`
+ `<span class="ak-ad-price" data-ak-price></span>`
+ `<span class="ak-ad-cta">${esc(t.cta)}</span>`
+ '</span></a></li>';
}
export function validateAdConfig(cfg) {
const errs = [];
if (!VARIANTS.includes(cfg?.variant)) errs.push(`variant must be one of ${VARIANTS.join(',')}`);
if (!cfg?.disclosure) errs.push('disclosure is required (the site\'s own Amazon Associate wording, verbatim)');
if (!cfg?.products?.length) errs.push('no products');
errs.push(...validatePool(cfg?.products || []).filter((e) => e !== 'empty pool'));
return errs;
}
// One slot. cfg = { variant, products (ranked candidates, first n shown), disclosure, api='/amz/items', gate='/go/p',
// lang='en', labels, n (cards per slot; v4 default 3, v1/v2 default 5 as a one-at-a-time carousel, v3/v5 default 1),
// candidates (extra ASINs the browser may fall back to when one is not buyable) }.
export function renderAd(cfg) {
const errs = validateAdConfig(cfg);
if (errs.length) throw new Error(`Amili Kit amazon-ad: ${errs.join('; ')}`);
const t = { ...(L[cfg.lang] || L.en), ...(cfg.labels || {}) };
const v = cfg.variant;
const n = cfg.n || { v1: 5, v2: 5, v3: 1, v4: 3, v5: 1 }[v];
const shown = cfg.products.slice(0, Math.max(n, 1));
const spare = cfg.products.slice(shown.length).map((p) => p.asin);
const carousel = (v === 'v1' || v === 'v2' || v === 'v4') && shown.length > 1;
const dots = carousel && v !== 'v4' ? `<div class="ak-ad-dots" aria-hidden="true">${shown.map((_, i) => `<span${i ? '' : ' class="on"'}></span>`).join('')}</div>` : '';
const info = `<details class="ak-ad-info"><summary aria-label="${esc(t.sponsored)}: ${esc(t.info)}">${esc(t.sponsored)} ${INFO_SVG}</summary><div class="ak-ad-pop"><p>${esc(cfg.disclosure)}</p><p class="ak-ad-disc" data-ak-disc hidden>${esc(DISCLAIMER)}</p></div></details>`;
const close = v === 'v2' ? `<button type="button" class="ak-ad-x" data-ak-close aria-label="${esc(t.close)}">${X_SVG}</button>` : '';
const asOf = `<p class="ak-ad-asof" data-ak-asof hidden>${esc(t.asOf)} <time></time></p>`;
const w = cfg.products.filter((p) => p.w && p.w !== 1).map((p) => `${p.asin}:${Number(p.w)}`).join(',');
return `<aside class="ak-ad ak-ad-${v}" data-v="${v}" data-api="${esc(cfg.api || '/amz/items')}" data-page="${esc(cfg.page || '')}"${w ? ` data-w="${w}"` : ''} data-spare="${spare.join(',')}"${carousel ? ' data-carousel="1"' : ''} aria-label="${esc(t.sponsored)}">`
+ `<div class="ak-ad-in">${close}<ul class="ak-ad-track" role="list">${shown.map((p, i) => card(p, i, cfg, t)).join('')}</ul>`
+ `<div class="ak-ad-foot">${dots}${asOf}${info}</div></div></aside>`;
}
// All five slots for one page, keyed by placement, so a site drops each at its own spot:
// top (v1, before the header), sticky (v2, end of body), inContent (v3 + v4, after the first result block), between (v5).
// hostTag wraps each in-content slot (e.g. 'li' inside a results <ul>) in an element that is hidden unless that
// variant is live, so a hidden variant leaves no empty list item or flex gap behind.
export function renderAdSlots(cfg, { hostTag = 'div' } = {}) {
const out = {};
for (const v of VARIANTS) out[v] = renderAd({ ...cfg, variant: v });
const host = (v) => `<${hostTag} class="ak-host ak-host-${v}">${out[v]}</${hostTag}>`;
return { top: out.v1, sticky: out.v2, inContent: host('v3') + host('v4'), between: host('v5') };
}
// Inline in <head>, before any CSS paints: assigns the visitor's variant. Keep it tiny.
export const AD_HEAD_JS = `(function(){try{var V=['v1','v2','v3','v4','v5'],q=(location.search.match(/[?&]akv=(v[1-5]|off)/)||[])[1],v;if(q)v=q;else{try{v=localStorage.getItem('akv')}catch(e){}if(V.indexOf(v)<0){v=V[Math.floor(Math.random()*V.length)];try{localStorage.setItem('akv',v)}catch(e){}}}try{if(v==='v2'&&sessionStorage.getItem('akx'))v='v2x'}catch(e){}document.documentElement.setAttribute('data-akv',v)}catch(e){}})();`;
// The browser half of pickProducts (same hash, same key), so a static page still rotates every UTC day.
// ak_rank(page, day, asins, weights) -> asins in today's order. footer test asserts it matches pickProducts.
export const RANK_JS = `function ak_h(s){var h=0x811c9dc5;for(var i=0;i<s.length;i++){h^=s.charCodeAt(i);h=Math.imul(h,0x01000193)}return h>>>0}function ak_rank(pg,day,as,w){return as.map(function(a){var u=(ak_h(pg+'|'+day+'|'+a)+1)/4294967297;return{a:a,k:Math.pow(u,1/Math.max(0.01,(w&&w[a])||1))}}).sort(function(x,y){return y.k-x.k||(x.a<y.a?-1:1)}).map(function(x){return x.a})}`;
// End of body: fills the visible slot from /amz/items, runs the carousel, handles dismiss. No dependencies.
export const AD_JS = `(function(){${RANK_JS}var h=document.documentElement,v=h.getAttribute('data-akv');var s=v&&document.querySelector('.ak-ad[data-v="'+v+'"]');if(!s)return;
var cards=[].slice.call(s.querySelectorAll('.ak-ad-card')),spare=(s.getAttribute('data-spare')||'').split(',').filter(Boolean);
var W={};(s.getAttribute('data-w')||'').split(',').forEach(function(x){var p=x.split(':');if(p[1])W[p[0]]=+p[1]});
var want=ak_rank(s.getAttribute('data-page')||'',new Date().toISOString().slice(0,10),cards.map(function(c){return c.getAttribute('data-asin')}).concat(spare),W);
function fmt(iso){var d=new Date(iso);return d.toLocaleString(undefined,{day:'numeric',month:'short',hour:'2-digit',minute:'2-digit',hourCycle:'h23'})}
function fill(c,a,it){c.setAttribute('data-asin',a);var l=c.querySelector('a');l.setAttribute('data-asin',a);l.href=l.getAttribute('href').replace(/a=[A-Z0-9]{10}/,'a='+a);
if(it.img){var im=new Image();im.alt='';im.width=it.img.w;im.height=it.img.h;im.decoding='async';im.referrerPolicy='no-referrer';im.src=it.img.url;var b=c.querySelector('[data-ak-img]');b.textContent='';b.appendChild(im);c.classList.add('has-img')}
if(it.title)c.querySelector('[data-ak-title]').textContent=it.title;if(it.brand)c.querySelector('[data-ak-brand]').textContent=it.brand;
if(it.price){c.querySelector('[data-ak-price]').textContent=it.price;c.classList.add('has-price')}}
fetch(s.getAttribute('data-api')+'?a='+want.slice().sort().join(','),{credentials:'omit'}).then(function(r){return r.json()}).then(function(j){
if(!j||!j.ok||!j.asOf||Date.now()-Date.parse(j.asOf)>${MAX_AGE_MS})return;var items=j.items||{},pool=want.filter(function(a){return items[a]}),used=0,any=0;
cards.forEach(function(c){var a=pool[used++];if(!a)return;fill(c,a,items[a]);if(items[a].price)any=1});
if(any){var p=s.querySelector('[data-ak-asof]');p.querySelector('time').setAttribute('datetime',j.asOf);p.querySelector('time').textContent=fmt(j.asOf);p.hidden=false;s.querySelector('[data-ak-disc]').hidden=false}
s.classList.add('is-live')}).catch(function(){});
var x=s.querySelector('[data-ak-close]');if(x)x.addEventListener('click',function(){try{sessionStorage.setItem('akx','1')}catch(e){}h.setAttribute('data-akv','v2x')});
if(!s.getAttribute('data-carousel'))return;var tr=s.querySelector('.ak-ad-track'),dots=[].slice.call(s.querySelectorAll('.ak-ad-dots span')),i=0;
function cur(){var w=cards[0].getBoundingClientRect().width||1;return Math.round(tr.scrollLeft/w)}
tr.addEventListener('scroll',function(){var k=cur();if(k!==i){i=k;dots.forEach(function(d,n){d.className=n===k?'on':''})}},{passive:true});
if(v==='v4'||matchMedia('(prefers-reduced-motion: reduce)').matches)return;var paused=0;['pointerdown','focusin','mouseenter','touchstart'].forEach(function(e){s.addEventListener(e,function(){paused=1},{passive:true})});
var timer=setInterval(function(){if(paused||document.hidden)return;var k=(cur()+1)%cards.length;tr.scrollTo({left:k*cards[0].getBoundingClientRect().width,behavior:'smooth'})},6000);
if('IntersectionObserver'in window)new IntersectionObserver(function(e){paused=e[0].isIntersecting?paused:1}).observe(s)})();`;
// Scoped under .ak-ad. Fixed heights per variant (--ak-ad-h*) => zero layout shift whether or not the API answers.
// Colours: a white card like Amazon's own (the mockup), overridable with --ak-ad-bg --ak-ad-fg --ak-ad-muted
// --ak-ad-line --ak-ad-accent --ak-ad-cta-bg --ak-ad-cta-fg --ak-ad-page (the page background behind v1).
export const AD_CSS = `.ak-ad{display:none;--ak-ad-bg:#fff;--ak-ad-fg:#0f1111;--ak-ad-muted:#565959;--ak-ad-line:#d5d9d9;--ak-ad-cta-bg:#ffd814;--ak-ad-cta-fg:#0f1111;font:14px/1.35 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;color:var(--ak-ad-fg);text-align:left;box-sizing:border-box}
.ak-ad *{box-sizing:border-box}
.ak-host{display:none;list-style:none;margin:0;padding:0}
html[data-akv=v1] .ak-ad-v1,html[data-akv=v2] .ak-ad-v2,html[data-akv=v3] .ak-ad-v3,html[data-akv=v4] .ak-ad-v4,html[data-akv=v5] .ak-ad-v5,html:not([data-akv]) .ak-ad-v3,html[data-akv=v1] .ak-host-v1,html[data-akv=v3] .ak-host-v3,html[data-akv=v4] .ak-host-v4,html[data-akv=v5] .ak-host-v5,html:not([data-akv]) .ak-host-v3{display:block}
.ak-ad-in{position:relative;max-width:40rem;margin:0 auto}
.ak-ad-track{list-style:none;margin:0;padding:0;display:flex;overflow-x:auto;scroll-snap-type:x mandatory;scrollbar-width:none;overscroll-behavior-x:contain}
.ak-ad-track::-webkit-scrollbar{display:none}
.ak-ad-card{flex:0 0 100%;scroll-snap-align:start;margin:0;min-width:0}
.ak-ad-link{display:flex;gap:12px;align-items:center;height:100%;min-height:44px;padding:10px 12px;background:var(--ak-ad-bg);border:1px solid var(--ak-ad-line);border-radius:10px;color:inherit;text-decoration:none}
.ak-ad-link:focus-visible{outline:2px solid var(--ak-ad-fg);outline-offset:2px}
.ak-ad-img{flex:0 0 auto;display:none;align-items:center;justify-content:center;background:#fff;border-radius:6px;overflow:hidden}
.has-img .ak-ad-img{display:flex}
.ak-ad-img img{width:100%;height:100%;object-fit:contain}
.ak-ad-body{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1}
.ak-ad-brand{font-size:12px;color:var(--ak-ad-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ak-ad-brand:empty{display:none}
.ak-ad-title{font-weight:600;font-size:15px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.ak-ad-why{font-size:13px;color:var(--ak-ad-muted);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
.is-live .has-img .ak-ad-why{display:none}
.ak-ad-price{font-size:17px;font-weight:700}
.ak-ad-price:empty{display:none}
.ak-ad-cta{align-self:flex-start;margin-top:4px;padding:6px 14px;border-radius:999px;background:var(--ak-ad-cta-bg);color:var(--ak-ad-cta-fg);font-size:13px;font-weight:600;white-space:nowrap}
.ak-ad-foot{display:flex;align-items:center;justify-content:flex-end;gap:10px;height:36px;font-size:12px;color:var(--ak-ad-muted);white-space:nowrap}
.ak-ad-dots{display:flex;gap:6px;margin-right:auto;padding-left:4px}
.ak-ad-dots span{width:6px;height:6px;border-radius:50%;background:var(--ak-ad-line)}
.ak-ad-dots span.on{background:var(--ak-ad-muted)}
.ak-ad-asof{margin:0;font-size:11px;overflow:hidden;text-overflow:ellipsis;min-width:0}
.ak-ad-info{position:relative}
.ak-ad-info summary{list-style:none;cursor:pointer;display:inline-flex;align-items:center;gap:4px;min-height:44px;min-width:44px;justify-content:flex-end}
.ak-ad-info summary::-webkit-details-marker{display:none}
.ak-ad-pop{position:absolute;right:0;bottom:100%;z-index:3;width:min(20rem,80vw);padding:10px 12px;background:var(--ak-ad-bg);color:var(--ak-ad-fg);border:1px solid var(--ak-ad-line);border-radius:8px;box-shadow:0 6px 24px rgba(0,0,0,.18);font-size:12px}
.ak-ad-pop p{margin:0 0 6px}.ak-ad-pop p:last-child{margin:0}
.ak-ad-x{position:absolute;top:-14px;right:-6px;z-index:2;width:44px;height:44px;display:flex;align-items:center;justify-content:center;background:none;border:0;padding:0;color:var(--ak-ad-fg);cursor:pointer}
.ak-ad-x svg{width:24px;height:24px;padding:5px;background:var(--ak-ad-bg);border:1px solid var(--ak-ad-line);border-radius:50%}
.ak-ad-v1{background:var(--ak-ad-page,#f3f3f3);padding:8px 12px 0;height:var(--ak-ad-h1,150px)}
.ak-ad-v1 .ak-ad-link,.ak-ad-v2 .ak-ad-link{height:106px}
.ak-ad-v1 .ak-ad-img,.ak-ad-v2 .ak-ad-img{width:86px;height:86px}
.ak-ad-v1 .ak-ad-why,.ak-ad-v2 .ak-ad-why{-webkit-line-clamp:1}
.ak-ad-v1 .ak-ad-cta,.ak-ad-v2 .ak-ad-cta{display:none}
.ak-ad-v2{position:fixed;left:0;right:0;bottom:0;z-index:40;background:var(--ak-ad-page,#f3f3f3);padding:14px 12px env(safe-area-inset-bottom);height:calc(var(--ak-ad-h2,156px) + env(safe-area-inset-bottom));box-shadow:0 -4px 18px rgba(0,0,0,.22)}
html[data-akv=v2] body{padding-bottom:calc(var(--ak-ad-h2,156px) + env(safe-area-inset-bottom))}
.ak-ad-v3{margin:12px 0;height:var(--ak-ad-h3,368px)}
.ak-ad-v3 .ak-ad-link{flex-direction:column;align-items:stretch;height:328px;padding:12px}
.ak-ad-v3 .ak-ad-img{width:100%;height:170px}
.ak-ad-v3 .ak-ad-card:not(.has-img) .ak-ad-body{justify-content:center;gap:10px;padding:0 4px}
.ak-ad-v3 .ak-ad-card:not(.has-img) .ak-ad-title{font-size:22px;line-height:1.25}
.ak-ad-v3 .ak-ad-card:not(.has-img) .ak-ad-why{font-size:15px;-webkit-line-clamp:4}
.ak-ad-v3 .ak-ad-card:not(.has-img) .ak-ad-cta{margin-top:14px}
.ak-ad-v3 .ak-ad-title{font-size:16px}
.ak-ad-v3 .ak-ad-cta{align-self:stretch;text-align:center;padding:10px 14px;font-size:14px;margin-top:auto}
.ak-ad-v4{margin:12px 0;height:var(--ak-ad-h4,318px)}
.ak-ad-v4 .ak-ad-track{gap:10px;padding-right:28%}
.ak-ad-v4 .ak-ad-card{flex:0 0 44%}
.ak-ad-v4 .ak-ad-link{flex-direction:column;align-items:stretch;height:278px;padding:10px}
.ak-ad-v4 .ak-ad-img{width:100%;height:110px}
.ak-ad-v4 .ak-ad-title{font-size:14px}
.ak-ad-v4 .ak-ad-why{-webkit-line-clamp:3}
.ak-ad-v4 .ak-ad-cta{margin-top:auto;align-self:stretch;text-align:center;font-size:12px;padding:8px 6px;white-space:normal}
.ak-ad-v5{margin:8px 0;height:var(--ak-ad-h5,104px)}
.ak-ad-v5 .ak-ad-link{height:64px;padding:6px 10px;gap:10px}
.ak-ad-v5 .ak-ad-img{width:48px;height:48px}
.ak-ad-v5 .ak-ad-why,.ak-ad-v5 .ak-ad-brand{display:none}
.ak-ad-v5 .ak-ad-body{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;column-gap:10px;row-gap:3px}
.ak-ad-v5 .ak-ad-title{grid-column:1/-1;-webkit-line-clamp:1;font-size:14px;line-height:1.25}
.ak-ad-v5 .ak-ad-price{grid-column:1;font-size:15px;line-height:1.2}
.ak-ad-v5 .ak-ad-cta{grid-column:2;grid-row:2;margin:0;padding:5px 12px;font-size:12.5px}
.ak-ad-v5 .ak-ad-card:not(.has-price) .ak-ad-cta{grid-column:1/-1;justify-self:start}
@media (prefers-reduced-motion:reduce){.ak-ad-track{scroll-behavior:auto}}
@media (min-width:48rem){.ak-ad-v4 .ak-ad-card{flex:0 0 31%}.ak-ad-v4 .ak-ad-track{padding-right:0}}`;
The basics every site should have.
Not a component, a checklist. These are what search engines and AI answer engines read first, and most sites miss two or three.
- A unique title and description on every page, written for a person.
- A canonical link, so one address counts for one page.
- Open Graph tags, so a shared link shows a title and an image.
- Structured data (JSON-LD) that says what the page is. Only claims that are true: no made-up ratings.
- One clear H1, and headings that answer the questions people ask.
- sitemap.xml and robots.txt, with the sitemap named in robots.txt.
- llms.txt: a short plain-text summary of the site for AI tools.
- Works at 375 px: no sideways scroll, tap targets of 44 px.
- Light and dark that both read well.
- A footer with who runs it, how to reach them, privacy and terms.
Copy the head tags
<title>Unique title, what the page answers</title>
<meta name="description" content="One or two plain sentences." />
<link rel="canonical" href="https://example.com/page" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="light dark" />
<meta property="og:title" content="Title" />
<meta property="og:description" content="Description" />
<meta property="og:image" content="https://example.com/og.png" />
<script type="application/ld+json">
{ "@context": "https://schema.org", "@type": "WebSite", "name": "Example Site", "url": "https://example.com/" }
</script>Free to use. MIT licence.
Use it, change it, sell what you build with it. Keep the licence notice in the file. A "Made by Amili" link is welcome, not required.
MIT licence text
MIT License
Copyright (c) 2026 Amili (Paulo de Vries)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.