amiliJoin the beta

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.

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

Three example tripods
TripodHeadWhere to buy
Example A1.4 kg150 cm42 cmBallCheck price for Example A
Example B0.9 kgBest132 cm31 cmBestBallCheck price for Example B
Example C2.1 kg165 cmBest58 cm– not listedCheck price for Example C
Example data. Links in this demo go nowhere.
OptionWhat it does
captionRequired. What the table compares; also names the scroll region.
columnsbest: "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.
sponsoredBuy 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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">&ndash;<span class="ak-vh"> not listed</span></span>' : typeof v === 'boolean' ? (v ? 'Yes' : 'No') : esc(v) + (c.unit && typeof v === 'number' ? `&nbsp;${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

OptionWhat it does
name, href, labelWhat 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.
hideWhenA 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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">&times;</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.

OptionWhat it does
itemsRequired. [{ q, a }] or { q, aHtml } for links in the answer.
exclusiveOpening one question closes the others (no JavaScript).
jsonLdfalse 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const plain = (h) => String(h).replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/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: '' };

Breadcrumbs with structured data

One list gives you the visible trail and the BreadcrumbList JSON-LD. The current page is marked, separators are silent for screen readers, long trails fold on phones without JavaScript.

Live demo

OptionWhat it does
originRequired. Makes the structured data addresses absolute.
itemsEvery item but the last needs an href; the last is the current page.
collapseFolds the middle of long trails on phones (default on).
Show the code
// Amili Kit: Breadcrumbs with structured data. https://amili.ai/kit#kit-breadcrumbs
// 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 { renderBreadcrumbs, BREADCRUMBS_CSS } from './kit/breadcrumbs.mjs';
//   html = `<style>${BREADCRUMBS_CSS}</style>` + renderBreadcrumbs({ origin: 'https://example.com', items })
//
// opts = {
//   origin: 'https://example.com',              // required: makes the JSON-LD URLs absolute
//   items: [{ name: 'Home', href: '/' }, { name: 'Guides', href: '/guides/' }, { name: 'This page' }],
//                                                // the last item is the current page (not a link, aria-current)
//   label: 'Breadcrumb',                         // nav accessible name
//   jsonLd: true,                                // false if the page already carries a BreadcrumbList
//   separator: '/',                              // decorative, hidden from screen readers
//   collapse: true,                              // at 375px, middle items fold into "…" when there are 4+ (no JS)
// }
// No brand mark here on purpose: a trail is one line of navigation, a mark would crowd it.


const esc = (s) => String(s == null ? '' : s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
export const jsonForScript = (o) => JSON.stringify(o).replace(/</g, '\\u003c').replace(/>/g, '\\u003e').replace(/&/g, '\\u0026');

export function validateBreadcrumbs(opts) {
  const errs = [];
  try { if (!/^https?:$/.test(new URL(opts?.origin).protocol)) throw 0; } catch { errs.push('origin must be an absolute http(s) URL'); }
  const items = opts?.items || [];
  if (items.length < 2) errs.push('items: at least 2 (home and the current page)');
  items.forEach((it, i) => {
    if (!it?.name) errs.push(`items[${i}]: missing name`);
    if (i < items.length - 1 && !it?.href) errs.push(`items[${i}]: every item but the last needs an href`);
  });
  return errs;
}

export function breadcrumbJsonLd(origin, items) {
  const abs = (h) => new URL(h, origin).href;
  return {
    '@context': 'https://schema.org', '@type': 'BreadcrumbList',
    itemListElement: items.map((it, i) => ({ '@type': 'ListItem', position: i + 1, name: String(it.name), ...(it.href ? { item: abs(it.href) } : {}) })),
  };
}

export function renderBreadcrumbs(opts) {
  const errs = validateBreadcrumbs(opts);
  if (errs.length) throw new Error(`Amili Kit breadcrumbs: ${errs.join('; ')}`);
  const items = opts.items, last = items.length - 1, sep = esc(opts.separator || '/');
  const fold = opts.collapse !== false && items.length >= 4;
  const lis = items.map((it, i) => {
    const mid = fold && i > 0 && i < last - 1 ? ' class="ak-bc-mid"' : '';
    const body = i === last ? `<span aria-current="page">${esc(it.name)}</span>` : `<a href="${esc(it.href)}">${esc(it.name)}</a>`;
    const ell = fold && i === 1 ? `<li class="ak-bc-ell" aria-hidden="true">&hellip;<span class="ak-bc-sep">${sep}</span></li>` : '';
    return `${ell}<li${mid}>${body}${i < last ? `<span class="ak-bc-sep" aria-hidden="true">${sep}</span>` : ''}</li>`;
  }).join('');
  const ld = opts.jsonLd === false ? '' : `<script type="application/ld+json">${jsonForScript(breadcrumbJsonLd(opts.origin, items))}</script>`;
  return `<nav class="ak-bc" aria-label="${esc(opts.label || 'Breadcrumb')}"><ol>${lis}</ol></nav>${ld}`;
}

export const BREADCRUMBS_CSS = `.ak-bc{font-size:.875rem;line-height:1.3;color:var(--ak-muted,color-mix(in srgb,currentColor 72%,transparent))}
.ak-bc ol{display:flex;flex-wrap:wrap;align-items:center;margin:0;padding:0;list-style:none}
.ak-bc li{display:inline-flex;align-items:center;min-width:0}
.ak-bc a{display:inline-flex;align-items:center;min-height:44px;color:inherit;text-decoration:underline;text-decoration-color:color-mix(in srgb,currentColor 35%,transparent);text-underline-offset:.2em}
.ak-bc a:hover{text-decoration-color:currentColor}
.ak-bc a:focus-visible{outline:2px solid currentColor;outline-offset:2px;border-radius:2px}
.ak-bc [aria-current]{color:var(--ak-fg,inherit);font-weight:600;overflow-wrap:anywhere}
.ak-bc-sep{margin:0 .5em;opacity:.55}
.ak-bc-ell{display:none}
@media (max-width:30rem){.ak-bc-mid{display:none}.ak-bc-ell{display:inline-flex}}`;

// The kit's common shape: every component exports { name, render(opts) -> html, css, js }.
export default { name: 'breadcrumbs', title: 'Breadcrumbs with structured data', render: renderBreadcrumbs, css: BREADCRUMBS_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.

OptionWhat it does
minOnly 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_JSOptional: 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
const text = (h) => String(h).replace(/<[^>]*>/g, '').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/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

Example: bread dough

Water as a share of the flour weight.
Water350 g
Salt10 g
Dough weight860 g

Example formula: water = flour x hydration, salt = 2% of flour.

amili
OptionWhat it does
fieldsNumber inputs with unit, min, max and help, or selects.
resultsWhat to show; main: true makes one result large.
computeYour formula. Throw an Error to show a message.
shareA "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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
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 };

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=guides

Which link, which kind of page. No address, no referrer, no time, no cookie, no id.

OptionWhat it does
endpointRequired. Your own counter, for example a Cloudflare Worker using makeBeaconHandler.
siteRequired. Your hostname, so one endpoint can count several sites.
matchWhich links count (default: /go/ links and Amazon).
pagesYour 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

VariantWhereHeight
v1Top, above the header. One product at a time, swipe and dots.150 px
v2Sticky at the bottom, with a close button. The page is padded so nothing is covered.156 px
v3In the content, after the first block. Large image, full-width button.368 px
v4In the content. Three products in a swipe row.318 px
v5Between results. One slim line.104 px

Options

OptionWhat it does
productsYour 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.
disclosureRequired. Your Amazon Associate disclosure, word for word.
variant, nWhich placement, and how many products it may show.
api, gateYour own two endpoints: live product data, and the redirect that adds your tracking ID on the server.
lang, labelsEnglish 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');

// ---------- 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.