diff --git a/AGENTS.md b/AGENTS.md index 99f9337..53b8c08 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,8 +5,9 @@ https://abap2ui5.github.io/ that points at everything the project publishes - the documentation, the why-site, the browser tools, the development tools, the addons and the community channels. A signpost, not a manual: it explains nothing that another site explains, every tile leaves the page. Built the way -abap2UI5/why is built (Astro, no script, nothing from another origin), and -nothing in it depends on another repository at build time. +abap2UI5/why is built (Astro, nothing from another origin), with one script - +the search box - and nothing in it depends on another repository at build +time. ## Layout @@ -15,6 +16,8 @@ nothing in it depends on another repository at build time. | `src/pages/index.astro` | The page: the four groups and their tiles as data at the top, the markup below | | `src/layouts/Page.astro` | The document: head, the Content-Security-Policy, the top line (the logo, the wordmark and Home, CONVENTION.md section 1), the one-line foot at the bottom of the window (the licence and About, section 5) | | `src/styles/global.css` | The one stylesheet; light and dark follow the system | +| `public/search.js` | The search box under the greeting - the one script of the site | +| `public/search-engine.js` | The matcher: a copy of `scripts/lib/search-engine.js` in abap2UI5/docs, as it is there | | `public/` | Font (Inter, self-hosted), logo, icons | | `test/site.test.mjs` | `node:test` over the built `dist/index.html` | | `.github/workflows/pages.yml` | Build + test on every PR; deploy on main | @@ -42,8 +45,9 @@ imported; a change to it starts here. ## Rules -- **A greeting and four groups.** The `

` says "Welcome to abap2UI5" and - nothing else introduces the page. The ids `introduction`, `try-it`, `more`, `community` +- **A greeting, the search and four groups.** The `

` says "Welcome to abap2UI5" and + nothing else introduces the page; the search box sits under it, the logo + beside both. The ids `introduction`, `try-it`, `more`, `community` are what the test names and what other sites may link; reword a heading freely, keep the `id`. - **A tile is one line of data.** Add, reorder or drop a tool in the `groups` @@ -63,8 +67,21 @@ imported; a change to it starts here. are the tiles to the sister sites, Samples, Insights and Addons & Tools (`inside`): same tab, straight arrow (→); their top line leads back by its Home tab. SAP Community and Bluesky have no abap2UI5 channel yet; their tiles lead to the platform until there is one. Never point a tile into nothing. -- **No script.** The policy is `script-src 'self'` and the page ships none: - no inline script, no `on…=` handler, no inline `style=""`. +- **One script: the search.** `public/search.js`, a module of this origin, + loaded on the home page only (``); the policy is + `script-src 'self'` and `connect-src 'self'`, and nothing else runs: no + inline script, no `on…=` handler, no inline `style=""`. The box searches + the documentation and the three sample catalogues from the index the docs + publish on this origin, `/docs/search-index.json` (abap2UI5/docs, + `scripts/generate-search.mjs`), fetched the first time the box is used, + never at load; nothing is stored. The box is `hidden` in the markup and the + script shows it, so without a script there is no field that does nothing. + The matcher, `public/search-engine.js`, is a copy of the docs' - the + playground carries one too: never edit it here, take a new copy when the + docs change theirs. Locally (`npm run dev`) there is no `/docs/` beside the + page, and the box says the search is not available; to try it, serve + `dist/` with the index saved as `docs/search-index.json` next to it. + A result opens in the same tab: it is where the reader asked to go. - **Nothing from another origin.** No CDN, no web font service, no analytics; the font and the images are in `public/`. - **Short.** The page is scanned, not read: one sentence per tile, the visible diff --git a/public/search-engine.js b/public/search-engine.js new file mode 100644 index 0000000..9c10245 --- /dev/null +++ b/public/search-engine.js @@ -0,0 +1,478 @@ +/* + * The matcher behind the search box in the bar — framework-free on purpose. + * + * Plain JavaScript with no imports, and this site's own: the index it reads + * is /docs/search-index.json, generated by scripts/generate-search.mjs from + * the pages of this site and the three sample catalogues. + * + * The ranking, in one sentence: a word you typed is worth most in a title, + * then in a class name, then in a heading, then in the summary or the + * keywords — and every word has to appear somewhere, so a second word narrows + * a search rather than widening it. + * + * There is no stemming and no fuzzy matching. The corpus is ~950 short + * entries of technical vocabulary — control names, ABAP class names, chapter + * titles — where a near-miss is usually a DIFFERENT control, and "close + * enough" answers are worse than none. A prefix match is as far as it goes, + * because that is what typing looks like before you have finished. + */ + +/* The store, or null where there is none. `typeof localStorage` guarded + * nothing: with storage blocked (cookies off for the site, the embedded case) + * the getter itself throws a SecurityError, and that throw - outside every + * try - stopped the whole bundled site.js at its first call, search box and + * all. The playground's copies have always gone through a guard like this. */ +function store() { + try { + return globalThis.localStorage ?? null; + } catch { + return null; + } +} + +/** Where the index lives — one document, on the origin all four sites share. */ +export const INDEX_URL = 'https://abap2ui5.github.io/docs/search-index.json'; + +/* Lower-cased without changing the length. "İ" (the Turkish capital I, which + * a Turkish keyboard types for Shift+i) lower-cases to "i" plus a combining + * dot - two code units - so "İNPUT" split into "i" and "nput", matched the + * wrong entries, and every highlight after it landed one character late. */ +const normalise = (s) => (s || '').replace(/\u0130/g, 'I').toLowerCase().replace(/\u0131/g, 'i'); +/* A query is split the way the index was built (scripts/lib/pages.mjs): the + * dot and the underscore stay inside a word, everything else is a boundary. So + * `client->view_display( )` pasted out of a page is two terms, both of which + * are in the index, rather than one that is in nothing. */ +const words = (s) => normalise(s).split(/[^\p{L}\p{N}._]+/u) + .map((w) => w.replace(/^[._]+|[._]+$/g, '')) + .filter(Boolean); + +/* WORDS THAT DECIDE NOTHING. "how do i install" is one question about one + * word, and the other three were being scored like it: `a` and `i` are a + * prefix of something in every entry, `to` and `do` in most, and each one + * earned the entry a title-hit's worth of points for a word nobody was asking + * about. The best answer to that query was Client API › check_on_init, and + * Installation was not in the first four. They are dropped from the matching + * as long as a real word is left - a query that is ONLY stop words ("is it", + * or a reader typing "a") still searches for what it says. The list is the + * short one: a word wrongly dropped is a search that finds nothing. */ +const STOP = new Set(('a an and are as at be by can do does for from has have how i in into is it its' + + ' my not of on or that the this to use using what when where which who will with you your').split(' ')); + +/* One term as it is matched. The plural is the singular with an `s` on it - + * "tables" found 33 entries and "table" 202, and every one of the 169 it + * missed was about tables. The `s` comes off and the prefix match does the + * rest: "table" is the start of "tables". Not from `ss` (class, address), and + * not from anything short enough for the rule to be a guess. Only the letters + * a reader types are ever stemmed; a class name or a control name in the index + * is matched as it is. */ +const stem = (w) => (w.length > 3 && /^\p{L}+s$/u.test(w) && !w.endsWith('ss') ? w.slice(0, -1) : w); + +/** The words a query is matched by, in order, and the ones it was not. */ +export function queryTerms(query) { + const all = [...new Set(words(query))]; + const kept = all.filter((w) => !STOP.has(w)); + return (kept.length ? kept : all).map(stem); +} + +/* How much a word found in each field is worth. A title hit beats everything: + * the entries are short and their titles are what a reader is trying to + * remember. `code` is the ABAP class name, which is either exactly what + * somebody pasted in or irrelevant - hence high, and only on a prefix. */ +const FIELD = { title: 10, code: 8, heading: 5, text: 2, terms: 2, group: 1 }; + +function scoreField(value, term, weight) { + if (!value) return 0; + const hay = normalise(value); + const at = hay.indexOf(term); + if (at < 0) return joined(hay, term, weight); + /* Where the word sits decides how much of the weight it earns: the whole + * field, the start of it, the start of any word in it, or somewhere inside + * a longer word - `list` in `ActionListItem` is a real hit and a weaker one + * than `list` in `List Report`. A term of one or two letters - `ui`, `f4`, + * `v2` - counts at a word start only: inside a word it is a coincidence + * (`ui` is in `build`), not a hit. */ + if (hay === term) return weight * 3; + if (at === 0) return weight * 2; + if (/[^\p{L}\p{N}]/u.test(hay[at - 1] || '')) return weight; + if (term.length <= 2) { + // Every later occurrence, not only the next one: "ui" sits inside + // "build" and "guide" before it starts a word in "Build guide UI". + for (let i = hay.indexOf(term, at + 1); i > 0; i = hay.indexOf(term, i + 1)) { + if (/[^\p{L}\p{N}]/u.test(hay[i - 1])) return weight; + } + return 0; + } + return weight / 2; +} + +/* THE FIELD WITH ITS SPACES TAKEN OUT. The manual writes "Message Box" and + * the UI5 control is MessageBox; the reader types either. "messagebox" found + * the three samples whose title is spelled that way and not the chapter whose + * heading is two words - nor "selectdialog" a single page, nor "valuehelp" the + * Value Help chapter. A term that is in no word of the field is looked for in + * the field closed up, and earns what a word-start hit would: it is one, with + * the space on the other side of the keyboard. Five letters or more, so that + * `sap` cannot land across "is a p…". The other way round costs nothing: + * "select dialog" already finds "SelectDialog" by prefix. */ +function joined(hay, term, weight) { + if (term.length < 5 || !/\s/.test(hay)) return 0; + const tight = hay.replace(/[^\p{L}\p{N}._]+/gu, ''); + const at = tight.indexOf(term); + if (at < 0) return 0; + if (tight === term) return weight * 3; + return at === 0 ? weight * 2 : weight / 2; +} + +/* THE WORDS NEXT TO EACH OTHER. Two terms scored one at a time make "abap + * cloud" prefer Toolchain › abap-cleaner (a title hit on `abap`, a mention of + * `cloud`) over In a Nutshell › ABAP Cloud, which is the two words the reader + * typed, in that order, as a heading. A field that carries the phrase whole + * earns a bonus of twice its weight on top - three times when the phrase IS + * the field, which is the title the reader was remembering. The phrase is the + * query as typed ("drag and drop" is a title) and the query as matched ("drag + * drop"), whichever the field has. */ +function phraseBonus(value, phrases, weight) { + if (!value || !phrases.length) return 0; + const hay = normalise(value).replace(/\s+/g, ' '); + for (const p of phrases) { + if (hay === p) return weight * 3; + if (hay.includes(p)) return weight * 2; + } + return 0; +} + +function phrasesOf(query, terms) { + if (terms.length < 2) return []; + const typed = words(query).join(' '); + const matched = terms.join(' '); + return typed === matched ? [typed] : [typed, matched]; +} + +/** One entry scored against the terms, or null if a term is in none of it. */ +function scoreEntry(e, terms, phrases) { + let total = 0; + /* Which heading matched, so the result can offer the SECTION rather than + * the page - the difference between "Cookbook: Tables" and the paragraph + * about sorting. */ + let heading = null; + /* Did anything but the long tail match. A hit made of `terms` alone is a + * page that mentions the word somewhere, which is an answer and a weak one; + * the box can draw it as such. */ + let strong = false; + + for (const term of terms) { + const head = scoreField(e.title, term, FIELD.title) + + scoreField(e.code, term, FIELD.code) + + scoreField(e.text, term, FIELD.text); + let best = head + scoreField(e.terms, term, FIELD.terms) + scoreField(e.group, term, FIELD.group); + if (head) strong = true; + + for (const [text, anchor] of e.headings || []) { + const s = scoreField(text, term, FIELD.heading); + if (!s) continue; + best += s; + strong = true; + if (!heading || s > heading.score) heading = { text, anchor, score: s }; + } + + if (!best) return null; + total += best; + } + if (!total) return null; + + total += phraseBonus(e.title, phrases, FIELD.title) + phraseBonus(e.text, phrases, FIELD.text); + for (const [text, anchor] of e.headings || []) { + const s = phraseBonus(text, phrases, FIELD.heading); + if (!s) continue; + total += s; + if (!heading || s > heading.score) heading = { text, anchor, score: s }; + } + + /* A shorter entry that scored the same is the better answer: the words + * are a larger part of what it is about. */ + return { entry: e, score: total - Math.min(4, (e.title || '').length / 40), heading, strong }; +} + +function rank(entries, terms, phrases, limit) { + const hits = []; + for (const e of entries) { + const hit = scoreEntry(e, terms, phrases); + if (hit) hits.push(hit); + } + hits.sort((a, b) => b.score - a.score || (a.entry.title || '').localeCompare(b.entry.title || '')); + return hits.slice(0, limit); +} + +/* ── WHEN NOTHING MATCHES ────────────────────────────────────────────────── + * + * "tabel" answered with nothing, and so did "table sort backend" - one for a + * transposed pair of letters, the other for one word too many. Both are the + * box telling a reader who is one keystroke from the answer that the project + * has no page about it. So, and only when the terms as typed find nothing: + * + * 1. one word at a time is set aside, rarest first - the word that matched + * the fewest entries is the one most likely to be the mistake - until + * something answers; + * 2. a single word that still answers nothing is tried against every word in + * every title, one edit away (a letter dropped, added, changed or two of + * them swapped): "tabel" is "table", "dialgo" is "dialog". + * + * Whatever answered is reported on the result (`hits.relaxedTo`), because a + * list that silently answers a different question than the one typed is worse + * than an empty one. Titles only, for the spelling: the long tail of every + * page is six thousand words, and a near-miss of one of those is a different + * word more often than a typo of this one. + */ + +/** How many entries carry the term anywhere - the measure of a term that is + * probably the wrong one. */ +function reach(entries, term) { + let n = 0; + for (const e of entries) { + if (scoreEntry(e, [term], [])) n++; + } + return n; +} + +const vocabularies = new WeakMap(); +function vocabulary(entries) { + let v = vocabularies.get(entries); + if (!v) { + v = new Set(); + /* The titles' words and their singulars - "Tables" is a title, and the + * word a typo is one edit from is "table". */ + for (const e of entries) for (const w of words(e.title)) if (w.length > 2) { v.add(w); v.add(stem(w)); } + vocabularies.set(entries, v); + } + return v; +} + +/** Is `b` within one edit of `a` - a letter added, dropped or changed, or two + * adjacent ones swapped. Bounded, so it costs nothing on the words that are + * not close: most of the vocabulary is out on the length check alone. */ +function oneEditAway(a, b) { + if (a === b) return false; + const d = a.length - b.length; + if (d > 1 || d < -1) return false; + if (d) { + const [long, short] = d > 0 ? [a, b] : [b, a]; + let i = 0; + while (i < short.length && long[i] === short[i]) i++; + return long.slice(i + 1) === short.slice(i); + } + let i = 0; + while (i < a.length && a[i] === b[i]) i++; + if (i === a.length) return false; + if (a.slice(i + 1) === b.slice(i + 1)) return true; + return a[i] === b[i + 1] && a[i + 1] === b[i] && a.slice(i + 2) === b.slice(i + 2); +} + +function nearest(entries, term) { + if (term.length < 4) return null; + let best = null; + for (const w of vocabulary(entries)) { + if (!oneEditAway(term, w)) continue; + /* Of two candidates, the one that starts the way the reader started: + * "tabel" is one edit from "table" and one from "label", and nobody who + * typed a `t` meant an `l`. Then the one on more titles - the word this + * project actually uses. */ + const n = reach(entries, w) + (w[0] === term[0] ? entries.length : 0); + if (!best || n > best.n) best = { w, n }; + } + return best?.w ?? null; +} + +/** + * The entries that match `query`, best first. + * + * Every term must be found somewhere in an entry, or the entry is out: typing + * a second word is how a reader narrows a result list, and a search that + * treats the words as alternatives grows the list instead, which reads as the + * box ignoring what you typed. When that leaves nothing, the query is relaxed + * (above) and the list says so: `hits.relaxedTo` is the query that answered. + */ +export function search(entries, query, { limit = 30 } = {}) { + /* Eight words at most. Nobody narrows a list with a ninth, and a pasted + * error message or a line of ABAP is thirty: every relaxing pass below + * scored the whole index once per word, and the box froze for seconds on + * each keystroke after the paste. */ + let terms = queryTerms(query).slice(0, 8); + if (!terms.length) return []; + + let hits = rank(entries, terms, phrasesOf(query, terms), limit); + if (hits.length) return hits; + + // Each word's reach once, not once per comparison per pass. + const reached = new Map(terms.map((t) => [t, reach(entries, t)])); + while (terms.length > 1) { + /* The word on the fewest entries goes; of two on as few, the one typed + * last - the reader's most recent word is the one most likely one too + * many. The rest keep their order, which is the reader's. */ + let out = 0; + terms.forEach((t, i) => { + if (i && reached.get(t) <= reached.get(terms[out])) out = i; + }); + terms = terms.filter((_, i) => i !== out); + hits = rank(entries, terms, [terms.join(' ')], limit); + if (hits.length) { hits.relaxedTo = terms.join(' '); return hits; } + } + + const near = nearest(entries, terms[0]); + if (near) { + hits = rank(entries, [near], [], limit); + if (hits.length) hits.relaxedTo = near; + } + return hits; +} + +/** + * The hits, grouped the way the index declares its areas — with at most + * `perGroup` in each, so one corpus of 636 ports cannot bury the four pages + * that explain them. + * + * The groups come in the order of their best hit. The documentation used to + * come first whenever it was in the answer at all, and for "dialog" that put + * Value Help, Popup, PDF and Lock - four pages that mention the word - above + * forty-seven samples that are dialogs. It still leads whenever it has a real + * answer: a documentation hit within half of the best hit anywhere is offered + * first, because the reader who typed a word that is both a chapter and a + * control wants the explanation before the seven hundred examples of it. A + * page that only mentions the word comes after the samples that are about it. + */ +export function grouped(hits, { perGroup = 8 } = {}) { + const byGroup = new Map(); + /* How many a group HAS, beside how many it shows. A reader who typed + * "table" and sees eight is looking at eight of two hundred and thirty-one, + * and the difference between those two numbers is the difference between + * "that is all there is" and "there is a whole shelf of this". */ + for (const hit of hits) { + const key = hit.entry.area === 'docs' ? 'Documentation' : hit.entry.group; + let g = byGroup.get(key); + if (!g) { g = { label: key, hits: [], total: 0, best: hit.score }; byGroup.set(key, g); } + g.total++; + if (hit.score > g.best) g.best = hit.score; + if (g.hits.length < perGroup) g.hits.push(hit); + } + const groups = [...byGroup.values()]; + const top = groups.reduce((m, g) => Math.max(m, g.best), 0); + groups.sort((a, b) => { + if (a.label === 'Documentation' && a.best * 2 >= top) return -1; + if (b.label === 'Documentation' && b.best * 2 >= top) return 1; + return b.best - a.best; + }); + return groups.map(({ label, hits: rows, total }) => ({ label, hits: rows, total })); +} + +/** The index, fetched once. Callers await this when the box is opened, never + * at load: a reader who does not search pays nothing for the box. */ +let pending = null; +export function loadIndex(url = INDEX_URL, { fetchFn = globalThis.fetch } = {}) { + pending ??= fetchFn(url) + .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`search index: HTTP ${r.status}`)))) + .catch((e) => { pending = null; throw e; }); + return pending; +} + +/** `text` split into the parts that matched `query` and the parts that did + * not, as `[string, boolean][]` — for a result row that shows WHY it is in + * the list. Markup is the caller's business; three documents draw it three + * ways and none of them wants a string of HTML from here. */ +export function highlight(text, query) { + /* What is marked is what was MATCHED: the stop words are not, and "tables" + * typed marks "table" in "Tables" - which is the prefix the match was on. */ + const terms = [...new Set(queryTerms(query))].sort((a, b) => b.length - a.length); + const hay = text || ''; + if (!terms.length || !hay) return [[hay, false]]; + const marks = []; + const low = normalise(hay); + for (const term of terms) { + let from = 0; + for (;;) { + const at = low.indexOf(term, from); + if (at < 0) break; + from = at + term.length; + // A one- or two-letter term is a hit at a word start only (scoreField), + // so only there is it marked - not the "ui" inside "Build". + if (term.length <= 2 && at > 0 && !/[^\p{L}\p{N}]/u.test(low[at - 1])) continue; + marks.push([at, at + term.length]); + } + } + if (!marks.length) return [[hay, false]]; + marks.sort((a, b) => a[0] - b[0]); + const out = []; + let at = 0; + for (const [start, end] of marks) { + if (end <= at) continue; + const from = Math.max(start, at); + if (from > at) out.push([hay.slice(at, from), false]); + out.push([hay.slice(from, end), true]); + at = end; + } + if (at < hay.length) out.push([hay.slice(at), false]); + return out; +} + +/* ── THE LAST THING YOU SEARCHED FOR ──────────────────────────────────────── + * + * A search that finds something ends on another page — often on another + * deployment — and the box that opens there is a new one, empty. A reader + * comparing three samples of the same control typed the same word three times, + * which is the search asking them to remember what they had just told it. + * + * So the query is written down when a hit is opened, and the next box on this + * origin opens with it, SELECTED, so the first keystroke replaces it rather + * than appending to it: the field is a suggestion, not a state to clear. + * + * It is the one-origin-one-localStorage the theme and the position memory + * already use, and it is CHECKED rather than followed — anything on this + * origin can write anything into that key. A string, short enough to have been + * typed, and recent: after half an hour a prefilled field is a question the + * reader has stopped asking. + */ + +const QUERY_KEY = 'abap2ui5-docs:search'; +const QUERY_TTL = 30 * 60 * 1000; +const QUERY_MAX = 120; + +/** Write down what was typed, as a hit is opened. An empty or absurd query + * clears the memory rather than storing itself. */ +export function rememberQuery(query) { + if (!store()) return; + const q = (query || '').trim(); + try { + if (!q || q.length > QUERY_MAX) store().removeItem(QUERY_KEY); + else store().setItem(QUERY_KEY, JSON.stringify({ q, at: Date.now() })); + } catch { + /* A refused or full storage. The reader types it again, as before. */ + } +} + +/** Forget what was searched for - the box calls it on a reload, which starts + * the site over (search-box.mjs in abap2UI5/playground imports it from its + * copy of this file). */ +export function forgetQuery() { + try { + store()?.removeItem(QUERY_KEY); + } catch { + /* A refused storage has nothing to forget. */ + } +} + +/** What to open the box with, or `''` — which is every case that is not a + * recent query written by this box. */ +export function recallQuery() { + if (!store()) return ''; + let record = null; + try { + record = JSON.parse(store().getItem(QUERY_KEY) || 'null'); + } catch { + return ''; /* not JSON: not something this wrote */ + } + if (!record || typeof record.q !== 'string' || typeof record.at !== 'number') return ''; + const age = Date.now() - record.at; + /* Backwards too. A clock that moved, or a timestamp somebody put in the + * future, is not an age this trusts. */ + if (!(age >= 0 && age < QUERY_TTL)) return ''; + const q = record.q.trim(); + return q && q.length <= QUERY_MAX ? q : ''; +} diff --git a/public/search.js b/public/search.js new file mode 100644 index 0000000..2b41f06 --- /dev/null +++ b/public/search.js @@ -0,0 +1,188 @@ +/* + * The search box under the greeting - the one script of this site. + * + * It searches what the search in the docs' playground searches: every page of + * the documentation and every sample of the three catalogues, from the index + * the docs publish at /docs/search-index.json (abap2UI5/docs, + * scripts/generate-search.mjs). The matcher is search-engine.js beside this + * file, a copy of scripts/lib/search-engine.js of abap2UI5/docs, as it is + * there - take a new copy when that one changes, never edit this one. + * + * The index is fetched the first time the box is used, never at load: a + * reader who does not search pays nothing for the box. Nothing is stored. + * + * Keys: Ctrl K / Cmd K or "/" go to the box; the arrows move through the + * results, Enter opens one (the first, if none is chosen), Escape closes the + * list and, pressed again, clears the field. + */ +import { search, grouped, highlight, loadIndex } from './search-engine.js'; + +const INDEX = '/docs/search-index.json'; +const LIMIT = 60; +const PER_GROUP = 5; + +const box = document.getElementById('search'); +const input = document.getElementById('search-input'); +const list = document.getElementById('search-results'); + +if (box && input && list) { + box.hidden = false; + const mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent); + const key = box.querySelector('.search-key'); + if (key && mac) key.textContent = '⌘ K'; + + let entries = null; + let failed = false; + let options = []; + let active = -1; + + const el = (tag, cls, text) => { + const e = document.createElement(tag); + if (cls) e.className = cls; + if (text != null) e.textContent = text; + return e; + }; + + /* The parts of `text` the query matched, in - built as nodes, so no + * title in the index is ever read as markup. */ + const marked = (text, query) => { + const f = document.createDocumentFragment(); + for (const [part, hit] of highlight(text, query)) f.append(hit ? el('mark', null, part) : document.createTextNode(part)); + return f; + }; + + const open = (yes) => { + list.hidden = !yes; + input.setAttribute('aria-expanded', String(yes)); + if (!yes) setActive(-1); + }; + + const setActive = (i) => { + if (options[active]) options[active].removeAttribute('aria-selected'); + active = i; + if (options[active]) { + options[active].setAttribute('aria-selected', 'true'); + options[active].scrollIntoView({ block: 'nearest' }); + input.setAttribute('aria-activedescendant', options[active].id); + } else { + input.removeAttribute('aria-activedescendant'); + } + }; + + const note = (text, link) => { + const p = el('p', 'search-note', text); + if (link) { + p.append(' '); + const a = el('a', null, link.text); + a.href = link.href; + p.append(a); + } + return p; + }; + + const render = () => { + const query = input.value.trim(); + options = []; + active = -1; + input.removeAttribute('aria-activedescendant'); + list.replaceChildren(); + if (!query) { open(false); return; } + + if (failed) { + list.append(note('The search is not available right now.', { text: 'Search the documentation instead.', href: 'https://abap2ui5.github.io/docs/' })); + open(true); + return; + } + if (!entries) { + list.append(note('Loading…')); + open(true); + return; + } + + const hits = search(entries, query, { limit: LIMIT }); + if (!hits.length) { + list.append(note(`Nothing found for “${query}”.`)); + open(true); + return; + } + if (hits.relaxedTo) list.append(note(`Nothing for “${query}” - showing “${hits.relaxedTo}”.`)); + const shown = hits.relaxedTo || query; + + for (const group of grouped(hits, { perGroup: PER_GROUP })) { + const head = el('div', 'search-group'); + head.setAttribute('role', 'presentation'); + head.append(el('span', null, group.label)); + if (group.total > group.hits.length) head.append(el('span', 'search-count', `${group.hits.length} of ${group.total}`)); + list.append(head); + + for (const { entry, heading } of group.hits) { + const a = el('a', 'search-hit'); + a.id = `search-hit-${options.length}`; + a.setAttribute('role', 'option'); + a.tabIndex = -1; + a.href = heading && entry.area === 'docs' ? `${entry.url}#${heading.anchor}` : entry.url; + const title = el('span', 'search-title'); + title.append(marked(entry.title, shown)); + a.append(title); + const sub = heading && entry.area === 'docs' ? `${entry.group} › ${heading.text}` : entry.code || entry.group; + if (sub) a.append(el('span', 'search-sub', sub)); + const i = options.length; + a.addEventListener('mousemove', () => { if (active !== i) setActive(i); }); + options.push(a); + list.append(a); + } + } + open(true); + }; + + const ensureIndex = () => { + if (entries || failed) return; + loadIndex(INDEX).then( + (index) => { entries = index.entries || []; render(); }, + () => { failed = true; render(); }, + ); + }; + + input.addEventListener('focus', () => { ensureIndex(); if (input.value.trim()) render(); }); + input.addEventListener('input', () => { ensureIndex(); render(); }); + + input.addEventListener('keydown', (e) => { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + if (!options.length) return; + e.preventDefault(); + if (list.hidden) open(true); + const step = e.key === 'ArrowDown' ? 1 : -1; + setActive((active + step + options.length) % options.length); + } else if (e.key === 'Enter') { + const target = options[active] || options[0]; + if (!target) return; + e.preventDefault(); + target.click(); + } else if (e.key === 'Escape') { + if (!list.hidden) { e.preventDefault(); open(false); } else if (input.value) { e.preventDefault(); input.value = ''; } + } + }); + + document.addEventListener('keydown', (e) => { + const typing = e.target instanceof HTMLElement && (e.target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)); + if ((e.key === 'k' || e.key === 'K') && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + input.focus(); + input.select(); + } else if (e.key === '/' && !typing && !e.metaKey && !e.ctrlKey && !e.altKey) { + e.preventDefault(); + input.focus(); + } + }); + + /* A press on a result keeps the focus in the field: Safari does not focus a + * link it is clicked on, so the list would close under the pointer before + * the click arrived. */ + list.addEventListener('pointerdown', (e) => e.preventDefault()); + document.addEventListener('pointerdown', (e) => { + if (!box.contains(e.target)) open(false); + }); + box.addEventListener('focusout', (e) => { + if (!box.contains(e.relatedTarget)) open(false); + }); +} diff --git a/src/layouts/Page.astro b/src/layouts/Page.astro index 4607ddb..22cfd39 100644 --- a/src/layouts/Page.astro +++ b/src/layouts/Page.astro @@ -7,15 +7,17 @@ * the same line with the other tab current. * * THE POLICY. GitHub Pages sets no headers of ours, so the Content-Security- - * Policy is a . The page runs no script at all - script-src is 'self', - * no inline script, no handler attribute - and everything it loads is this - * origin too. test/site.test.mjs holds the page to it. */ + * Policy is a . The one script is the search box on the home page, + * a file of this origin (public/search.js) - script-src is 'self', no + * inline script, no handler attribute - and everything it loads or fetches + * is this origin too: the index is /docs/search-index.json, published by the + * docs next to this page. test/site.test.mjs holds the page to it. */ import "../styles/global.css"; /* `trail`: a page of this site below Home, named in the top line as the * breadcrumb the sister sites use: Home / About, only the last entry marked. */ -interface Props { title: string; description: string; trail?: string } -const { title, description, trail } = Astro.props; +interface Props { title: string; description: string; trail?: string; search?: boolean } +const { title, description, trail, search } = Astro.props; const base = import.meta.env.BASE_URL; const canonical = new URL(Astro.url.pathname, Astro.site).href; const CSP = [ @@ -24,6 +26,7 @@ const CSP = [ "style-src 'self'", "img-src 'self'", "font-src 'self'", + "connect-src 'self'", "object-src 'none'", "base-uri 'self'", "form-action 'none'", @@ -47,6 +50,7 @@ const CSP = [ +{search && }
diff --git a/src/pages/index.astro b/src/pages/index.astro index e4dd04e..4399d81 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -60,10 +60,28 @@ const groups: Group[] = [ ]; const base = import.meta.env.BASE_URL; --- - +
-

Welcome to abap2UI5

+
+
+

Welcome to abap2UI5

+ {/* The search: the docs and the three sample catalogues, from the index + * the docs publish (/docs/search-index.json). It is the page's one + * script, search.js, that shows the box - without a script there is + * nothing it could do, so it stays hidden. */} + +
+ +
{groups.map((g) => g.id === "community" ? (
diff --git a/src/styles/global.css b/src/styles/global.css index 6013e3d..a40b30e 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1,7 +1,7 @@ /* The entry page of abap2UI5 - the one stylesheet. A greeting and the tiles, * nothing else: ink and white, Inter, the brand red of the logo for the * hover of a tile and for a link. Light and dark follow the reader's system. - * Nothing that needs a script. */ + * The one thing a script adds is the search box (public/search.js). */ @font-face { font-family: "Inter"; @@ -70,20 +70,71 @@ img { max-width: 100%; height: auto; } .sites .brand img { width: 22px; height: 22px; } .sites [aria-current="page"] { color: var(--ink); font-weight: 600; border-bottom: 2px solid var(--brand); padding-bottom: 1px; margin-bottom: -3px; /* the line takes no height: the word stays level with the trail */ } -/* ---- the greeting -------------------------------------------------------- */ -/* the text stands at the top of the row, where Samples, Addons and Insights - * put their heading - centred on the 104px logo it sat 29px lower */ +/* ---- the greeting and the search ---------------------------------------- */ +/* the greeting stands at the top of the row, where Samples, Addons and + * Insights put their heading; the search sits under it, and the 104px logo + * on the right spans both */ +[hidden] { display: none !important; } +.sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; } +.hello { display: flex; align-items: center; gap: 24px; margin: 28px 0 40px; } +.hello-text { flex: 1 1 auto; min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: 20px; } .welcome { - display: flex; align-items: flex-start; gap: 14px; - margin: 28px 0 40px; color: var(--ink); + margin: 0; color: var(--ink); font-size: clamp(28px, 4vw, 40px); font-weight: 700; letter-spacing: -.025em; line-height: 1.15; } -/* the logo is centred on the heading's line (1.15em high), not on the - * row: it rises by half of what it is taller than the line, 29px at 40px - * type. That also keeps the row short, so the first group starts close - * under the greeting */ -.welcome img { width: 104px; height: 104px; flex: none; margin-left: auto; margin-top: calc((1.15em - 104px) / 2); } -@media (max-width: 620px) { .welcome { margin: 24px 0 28px; } .welcome img { width: 64px; height: 64px; margin-top: calc((1.15em - 64px) / 2); } } +.hello-logo { width: 104px; height: 104px; flex: none; align-self: flex-start; } +/* on a phone the logo stays beside the greeting and the search takes the + * whole width under both */ +@media (max-width: 620px) { + .hello { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 16px; margin: 24px 0 28px; } + .hello-text { display: contents; } + .welcome { grid-area: 1 / 1; } + .hello-logo { grid-area: 1 / 2; width: 64px; height: 64px; align-self: center; } + .search { grid-area: 2 / 1 / 3 / 3; max-width: none; } + .search-key { display: none; } +} + +/* the box: as wide as a tile and a half, a soft field with the magnifier, + * the word and the shortcut - the look of the search in the docs' bar */ +.search { position: relative; max-width: 480px; } +.search-field { + display: flex; align-items: center; gap: 10px; height: 44px; padding: 0 10px 0 14px; + border: 1px solid var(--line); border-radius: 12px; background: var(--card); cursor: text; + transition: border-color .15s, box-shadow .15s; +} +.search-field:hover { border-color: var(--muted); } +.search-field:focus-within { border-color: var(--brand); box-shadow: 0 0 0 3px var(--brand-soft); } +.search-icon { width: 18px; height: 18px; flex: none; color: var(--muted); } +.search-field input { + flex: 1 1 auto; min-width: 0; height: 100%; padding: 0; border: 0; outline: none; background: none; + color: var(--ink); font: inherit; font-size: 15px; +} +.search-field input::placeholder { color: var(--muted); } +.search-field input::-webkit-search-cancel-button { display: none; } +.search-key { + flex: none; padding: 1px 7px; border: 1px solid var(--line); border-radius: 6px; + font: 500 12px/1.6 var(--font); color: var(--muted); white-space: nowrap; +} +.search-field:focus-within .search-key { visibility: hidden; } +@media (hover: none) { .search-key { display: none; } } + +/* the results: a list under the box, grouped the way the index groups them */ +.search-results { + position: absolute; z-index: 10; top: calc(100% + 6px); left: 0; width: min(640px, calc(100vw - 32px)); + max-height: min(70vh, 520px); overflow-y: auto; padding: 6px; + border: 1px solid var(--line); border-radius: 12px; background: var(--card); box-shadow: var(--shadow); +} +.search-group { + display: flex; justify-content: space-between; gap: 12px; padding: 10px 10px 4px; + font-size: 11px; font-weight: 650; letter-spacing: .1em; text-transform: uppercase; color: var(--muted); +} +.search-count { font-weight: 500; letter-spacing: 0; text-transform: none; } +.search-hit { display: grid; gap: 1px; padding: 7px 10px; border-radius: 8px; color: var(--text); text-decoration: none; } +.search-hit[aria-selected="true"] { background: var(--brand-soft); color: var(--text); } +.search-title { color: var(--ink); font-size: 14.5px; font-weight: 550; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.search-sub { color: var(--muted); font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.search-hit mark { background: none; color: var(--brand); } +.search-note { margin: 0; padding: 10px; font-size: 14px; color: var(--muted); } /* ---- the groups: a label, three tiles to a row at most ------------------- */ /* the page is as wide as the Insights site (--width); in it the tiles fall diff --git a/test/site.test.mjs b/test/site.test.mjs index 0e48fe4..f2162a5 100644 --- a/test/site.test.mjs +++ b/test/site.test.mjs @@ -1,8 +1,9 @@ // What the built page must keep, read off dist/ after `npm run build`. // node:test, no browser: the page is static HTML, so the properties worth -// holding are in the file - the greeting and the four groups, the policy, no -// script, nothing that points at a file that is not there, and every tile -// leaving the page. +// holding are in the file - the greeting and the four groups, the policy, the +// one script (the search box) and nothing inline, nothing that points at a +// file that is not there, and every tile leaving the page. What the box does +// in a browser is the docs' matcher's business, tested there. import { test } from "node:test"; import assert from "node:assert/strict"; import fs from "node:fs"; @@ -21,15 +22,36 @@ test("the page is the greeting and the four groups", () => { assert.doesNotMatch(html, /