Add lnurl list/detail routes, OG fixtures, i18n strings, and the write/ index client flows so the site surfaces the new mint type end to end. Co-authored-by: Cursor <cursoragent@cursor.com>
321 lines
13 KiB
Plaintext
321 lines
13 KiB
Plaintext
---
|
|
/**
|
|
* The language switcher. One control, two placements.
|
|
*
|
|
* `bar` is the header's: a compact button showing the current language's code. `footer`
|
|
* is the Language column's: the same button, showing the language's full name, opening
|
|
* upward so the panel is never clipped off the bottom of the document. Same list, same
|
|
* hrefs, same markup, same script — the only differences are which label the trigger
|
|
* shows and which way the panel opens, because a reader who finds one and then the
|
|
* other should not have to work out whether they do the same thing.
|
|
*
|
|
* The footer used to carry a second, different control: every locale as a flat link,
|
|
* ~20 of them wrapped over two rows. That was not a switcher, it was a wall, and it
|
|
* grew by one row every time a language was added.
|
|
*
|
|
* Text, not flags, in both. A flag is a country: Spanish is not Spain, Dutch is not the
|
|
* Netherlands, and English belongs to nobody in particular. Each language is written in
|
|
* its own name, because someone looking for Nederlands is not scanning for "Dutch".
|
|
*
|
|
* Every link points at this same page in that language, built from the unprefixed path,
|
|
* so this works identically on a mint page: /mint/kashu.me and /es/mint/kashu.me are
|
|
* the same mint. There is no "switch to Spanish and land on the home page" case, which
|
|
* is the usual failure of a switcher that only knows about the site root. They are real
|
|
* `<a href>` elements, so a crawler follows them and a reader with no JavaScript uses
|
|
* them.
|
|
*
|
|
* The control is a `<details>` rather than a scripted dropdown. It opens, closes and
|
|
* takes the keyboard with no JavaScript at all, which matters because the whole point
|
|
* of this control is being able to leave a page you cannot read. The script below only
|
|
* adds what `<details>` does not do by itself: closing on Escape and on a click
|
|
* elsewhere, and filtering the list as you type.
|
|
*/
|
|
import { LOCALES, localePath, localeInfo, useI18n } from '../i18n';
|
|
import { pageLocale } from '../i18n/paths';
|
|
|
|
interface Props {
|
|
/** `bar` is the header control; `footer` is the one in the Language column. */
|
|
placement?: 'bar' | 'footer';
|
|
}
|
|
const { placement = 'bar' } = Astro.props;
|
|
|
|
const { locale, path } = pageLocale(Astro);
|
|
const t = useI18n(locale);
|
|
|
|
const links = LOCALES.map((other) => ({
|
|
code: other.code,
|
|
label: other.label,
|
|
href: localePath(path, other.code),
|
|
current: other.code === locale,
|
|
}));
|
|
|
|
/*
|
|
* The header shows two letters, because the full name of the current language is a word
|
|
* the reader already knows they are reading, and the header row is full. The footer
|
|
* column has the width for the name and no adjacent nav to crowd, and a column headed
|
|
* "Language" reads better with "Nederlands" under it than with "NL".
|
|
*/
|
|
const trigger = placement === 'footer' ? localeInfo(locale).label : locale.toUpperCase();
|
|
|
|
/*
|
|
* The search field earns its place at 20-odd languages and is noise at three. It is
|
|
* the one part of this control that needs JavaScript, so it is also the one part that
|
|
* is allowed to be absent: every link is in the panel either way.
|
|
*/
|
|
const searchable = LOCALES.length > 8;
|
|
---
|
|
|
|
<details class={`lang-menu lang-${placement}`} data-lang-menu>
|
|
<summary aria-haspopup="true">
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" aria-hidden="true">
|
|
<circle cx="12" cy="12" r="9" />
|
|
<path d="M3 12h18M12 3a15 15 0 0 1 4 9 15 15 0 0 1-4 9 15 15 0 0 1-4-9 15 15 0 0 1 4-9Z" />
|
|
</svg>
|
|
{/* The trigger label does not say what it is on its own, so the word is in the
|
|
accessibility tree and out of the way for everyone else. */}
|
|
<span class="sr-only">{t('lang.label')}</span>
|
|
<span class="lang-current">{trigger}</span>
|
|
<svg class="lang-caret" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
|
<path d="m6 9 6 6 6-6" />
|
|
</svg>
|
|
</summary>
|
|
|
|
<div class="lang-panel">
|
|
{searchable && (
|
|
<div class="lang-search">
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
|
<circle cx="11" cy="11" r="7" />
|
|
<path d="m20 20-3.5-3.5" />
|
|
</svg>
|
|
<input
|
|
type="search"
|
|
placeholder={`${t('lang.label')}…`}
|
|
aria-label={t('lang.switcher')}
|
|
autocomplete="off"
|
|
spellcheck="false"
|
|
data-lang-search
|
|
/>
|
|
</div>
|
|
)}
|
|
<nav class="lang-list" aria-label={t('lang.switcher')}>
|
|
{
|
|
/*
|
|
* The current language is still a link, and it goes where it says it goes.
|
|
* `aria-current` is what tells a screen reader it is where you already are; the
|
|
* weight and the tick are what tell everyone else.
|
|
*/
|
|
links.map((other) => (
|
|
<a
|
|
href={other.href}
|
|
hreflang={other.code}
|
|
lang={other.code}
|
|
aria-current={other.current ? 'true' : undefined}
|
|
data-lang-option
|
|
data-search={`${other.label} ${other.code}`}
|
|
>
|
|
<span>{other.label}</span>
|
|
<span class="lang-option-code">{other.code.toUpperCase()}</span>
|
|
{other.current && (
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" aria-hidden="true">
|
|
<path d="m5 13 4 4L19 7" />
|
|
</svg>
|
|
)}
|
|
</a>
|
|
))
|
|
}
|
|
</nav>
|
|
</div>
|
|
</details>
|
|
|
|
<style>
|
|
.lang-menu { position: relative; flex-shrink: 0; }
|
|
|
|
.lang-menu summary {
|
|
display: flex; align-items: center; gap: 6px;
|
|
padding: 7px 10px; border-radius: 8px;
|
|
color: var(--muted); font-size: 13.5px; font-weight: 500;
|
|
cursor: pointer; list-style: none; user-select: none;
|
|
transition: color .15s, background .15s;
|
|
}
|
|
/* The default disclosure triangle, in both engines that draw one. */
|
|
.lang-menu summary::-webkit-details-marker { display: none; }
|
|
.lang-menu summary::marker { content: ''; }
|
|
|
|
.lang-menu summary:hover { color: var(--text); background: var(--card2); }
|
|
.lang-menu[open] summary { color: var(--text); background: var(--card2); }
|
|
|
|
.lang-caret { color: var(--faint); transition: transform var(--dur-quick) var(--ease-out); }
|
|
.lang-menu[open] .lang-caret { transform: rotate(180deg); }
|
|
|
|
/* The same panel the account menu uses, so the two controls beside each other in the
|
|
header open into the same shape. */
|
|
.lang-panel {
|
|
position: absolute; z-index: 60;
|
|
width: min(260px, calc(100vw - 24px));
|
|
background: var(--card); border: 1px solid var(--line); border-radius: 12px;
|
|
padding: 6px;
|
|
box-shadow: 0 18px 40px rgba(0, 0, 0, .45);
|
|
}
|
|
.lang-search {
|
|
display: flex; align-items: center; gap: 8px;
|
|
min-height: 40px; padding: 0 10px;
|
|
color: var(--faint); background: var(--card2);
|
|
border: 1px solid var(--line); border-radius: 8px;
|
|
}
|
|
.lang-search:focus-within {
|
|
color: var(--text);
|
|
border-color: var(--amber);
|
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--amber) 18%, transparent);
|
|
}
|
|
.lang-search input {
|
|
width: 100%; min-width: 0; padding: 9px 0;
|
|
border: 0; outline: 0; background: transparent;
|
|
color: var(--text); font: inherit; font-size: 13.5px;
|
|
}
|
|
.lang-search input::placeholder { color: var(--faint); opacity: 1; }
|
|
.lang-search input::-webkit-search-cancel-button { cursor: pointer; }
|
|
.lang-list {
|
|
max-height: min(360px, calc(100dvh - 150px));
|
|
display: flex; flex-direction: column; gap: 2px;
|
|
overflow-y: auto; overscroll-behavior: contain;
|
|
scrollbar-width: thin; scrollbar-color: var(--line) transparent;
|
|
}
|
|
.lang-search + .lang-list { margin-top: 6px; padding-right: 2px; }
|
|
.lang-list a {
|
|
display: flex; align-items: center; gap: 8px;
|
|
padding: 8px 10px; border-radius: 8px;
|
|
font-size: 13.5px; color: var(--muted);
|
|
transition: color .15s, background .15s;
|
|
}
|
|
.lang-list a[hidden] { display: none; }
|
|
.lang-list a:hover { color: var(--text); background: var(--card2); }
|
|
.lang-list a[aria-current] { color: var(--text); font-weight: 600; }
|
|
.lang-option-code {
|
|
margin-left: auto; color: var(--faint);
|
|
font-family: var(--mono); font-size: 10.5px; letter-spacing: .04em;
|
|
}
|
|
.lang-list a svg { color: var(--amber); flex-shrink: 0; }
|
|
|
|
/* ---------- the header placement ---------- */
|
|
|
|
.lang-bar .lang-current { font-family: var(--mono); font-size: 12.5px; letter-spacing: .04em; }
|
|
.lang-bar .lang-panel { top: calc(100% + 8px); right: 0; }
|
|
|
|
/*
|
|
* Narrow: the globe alone.
|
|
*
|
|
* Below 760px the header is already dropping things (the search affordance goes at
|
|
* the same breakpoint) and four nav items, an account control and this have to share
|
|
* one row. The globe is the part that makes the control findable; the code and the
|
|
* caret are the parts that only confirm what it is once you have found it. The
|
|
* accessible name stays either way, because it was never the visible text.
|
|
*/
|
|
@media (max-width: 760px) {
|
|
.lang-bar summary { padding: 7px 8px; gap: 0; }
|
|
.lang-bar .lang-current,
|
|
.lang-bar .lang-caret { display: none; }
|
|
}
|
|
@media (max-width: 420px) {
|
|
.lang-bar summary { padding: 6px 6px; }
|
|
}
|
|
|
|
/* ---------- the footer placement ---------- */
|
|
|
|
/*
|
|
* Aligned with the column's other text rather than inset by the trigger's padding,
|
|
* so the globe sits on the same vertical line as the "Language" heading above it and
|
|
* the links in the columns beside it.
|
|
*/
|
|
.lang-footer { display: inline-block; margin-inline-start: -10px; }
|
|
.lang-footer summary { font-size: 13.5px; }
|
|
.lang-footer .lang-current { font-weight: 500; }
|
|
|
|
/*
|
|
* Opens upward. The control is a few hundred pixels from the bottom of the document,
|
|
* so a panel hanging below it would be a scroll into empty space at best and clipped
|
|
* at worst. `inset-inline-start: 0` rather than `left`, because two of the shipped
|
|
* locales are right-to-left and the panel should hang from the trigger's leading
|
|
* edge in both directions.
|
|
*/
|
|
.lang-footer .lang-panel { bottom: calc(100% + 8px); inset-inline-start: 0; }
|
|
.lang-footer .lang-list { max-height: min(300px, calc(100dvh - 200px)); }
|
|
</style>
|
|
|
|
<script>
|
|
import { onReady } from '../scripts/reveal';
|
|
|
|
/**
|
|
* The three things `<details>` does not do on its own.
|
|
*
|
|
* Everything else about this control (open, close, keyboard, focus) is the element's
|
|
* own behaviour and works with this script blocked, which is the point of using one.
|
|
*
|
|
* Attached to the document once rather than to the element, because the view
|
|
* transition router replaces the header and the footer on every navigation and a
|
|
* per-element listener would be re-bound, and leak, once per page visited. One
|
|
* listener set serves both placements.
|
|
*/
|
|
let wired = false;
|
|
|
|
const normalise = (value: string): string =>
|
|
value.normalize('NFKD').replace(/\p{Diacritic}/gu, '').toLocaleLowerCase().trim();
|
|
|
|
const resetSearch = (menu: HTMLDetailsElement): void => {
|
|
const input = menu.querySelector<HTMLInputElement>('[data-lang-search]');
|
|
if (input) input.value = '';
|
|
for (const option of menu.querySelectorAll<HTMLElement>('[data-lang-option]')) {
|
|
option.hidden = false;
|
|
}
|
|
};
|
|
|
|
const closeAll = (except?: Element | null): void => {
|
|
for (const menu of document.querySelectorAll<HTMLDetailsElement>('[data-lang-menu][open]')) {
|
|
if (menu !== except) menu.open = false;
|
|
}
|
|
};
|
|
|
|
onReady(() => {
|
|
if (wired) return;
|
|
wired = true;
|
|
|
|
document.addEventListener('click', (event) => {
|
|
const target = event.target as HTMLElement | null;
|
|
// A click inside the open menu is either the summary (which toggles itself) or a
|
|
// link (which is about to navigate). Neither wants closing from here.
|
|
closeAll(target?.closest('[data-lang-menu]'));
|
|
});
|
|
|
|
document.addEventListener('keydown', (event) => {
|
|
if (event.key !== 'Escape') return;
|
|
const open = document.querySelector<HTMLDetailsElement>('[data-lang-menu][open]');
|
|
if (!open) return;
|
|
open.open = false;
|
|
// Back to the control that opened it, not to the top of the document.
|
|
open.querySelector('summary')?.focus();
|
|
});
|
|
|
|
document.addEventListener('input', (event) => {
|
|
const input = (event.target as Element | null)?.closest<HTMLInputElement>('[data-lang-search]');
|
|
if (!input) return;
|
|
const menu = input.closest<HTMLDetailsElement>('[data-lang-menu]');
|
|
if (!menu) return;
|
|
const query = normalise(input.value);
|
|
for (const option of menu.querySelectorAll<HTMLElement>('[data-lang-option]')) {
|
|
option.hidden = !normalise(option.dataset.search ?? '').includes(query);
|
|
}
|
|
});
|
|
|
|
// `toggle` does not bubble, so capture it. Starting in the search field makes a
|
|
// long language list keyboard-friendly; clearing on close keeps every reopen useful.
|
|
document.addEventListener('toggle', (event) => {
|
|
const menu = event.target as HTMLDetailsElement;
|
|
if (!menu.matches?.('[data-lang-menu]')) return;
|
|
if (menu.open) {
|
|
requestAnimationFrame(() => menu.querySelector<HTMLInputElement>('[data-lang-search]')?.focus());
|
|
} else {
|
|
resetSearch(menu);
|
|
}
|
|
}, true);
|
|
});
|
|
</script>
|