diff --git a/AGENTS.md b/AGENTS.md index 53b8c08..eddde3f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,10 @@ 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 | +| `src/components/Search.astro` | The search box's markup, on the home page and on `/search/` (`page`) | +| `src/pages/search.astro` | The search as a page: `/search/?q=…`, all results, an address to link and bookmark | +| `public/search.js` | The search box - the one script of the site | +| `public/opensearch.xml` | Lets a browser add the search as a search engine; its template is `/search/?q={searchTerms}` | | `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` | @@ -68,13 +71,22 @@ imported; a change to it starts here. channel yet; their tiles lead to the platform until there is one. Never point a tile into nothing. - **One script: the search.** `public/search.js`, a module of this origin, - loaded on the home page only (``); the policy is + loaded on the home page and on `/search/` (``); 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 + the whole family - the documentation, the three sample catalogues, every + repository of both organisations, the tools, the essays, the page for + decision makers, the addons, the client API and the framework's classes - + from the one index the docs publish on this origin, + `/docs/search-index.json` (abap2UI5/docs, `scripts/generate-search.mjs` + and `scripts/lib/search-family.mjs`), fetched the first time the box is + used, never at load; nothing is stored. A query that names something + (`#1234`, a class, a method, a repository, a tool) gets it as the best + answer; a hit carries its actions (source, README, issues; Right and Left + walk them); a scope narrows to one area (`repo:`, `api:`, `sample:`, + `doc:`, `addon:` …). Issues, discussions and the code of every repository + are searched by handing the query to GitHub's search - links, never a + request from the page, so `connect-src` stays `'self'`. 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 diff --git a/public/opensearch.xml b/public/opensearch.xml new file mode 100644 index 0000000..d7c0094 --- /dev/null +++ b/public/opensearch.xml @@ -0,0 +1,9 @@ + + + abap2UI5 + The documentation, the samples, the repositories and the API of abap2UI5 + UTF-8 + https://abap2ui5.github.io/favicon.png + + https://abap2ui5.github.io/search/ + diff --git a/public/search-engine.js b/public/search-engine.js index 9c10245..36770f8 100644 --- a/public/search-engine.js +++ b/public/search-engine.js @@ -1,5 +1,5 @@ /* - * The matcher behind the search box in the bar — framework-free on purpose. + * The matcher behind the search boxes of the family — 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 @@ -292,6 +292,21 @@ function nearest(entries, term) { * (above) and the list says so: `hits.relaxedTo` is the query that answered. */ export function search(entries, query, { limit = 30 } = {}) { + /* A scope - `repo: popup` - narrows the entries before anything is scored, + * and a scope alone - `repo:` - lists its area, which is how a reader asks + * "what repositories are there". */ + const scoped = parseScope(query); + if (scoped.area) { + entries = entries.filter((e) => e.area === scoped.area); + query = scoped.query; + if (!queryTerms(query).length) { + return entries + .slice() + .sort((a, b) => (a.title || '').localeCompare(b.title || '')) + .slice(0, limit) + .map((entry) => ({ entry, score: 0, heading: null, strong: true })); + } + } /* 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 @@ -476,3 +491,113 @@ export function recallQuery() { const q = record.q.trim(); return q && q.length <= QUERY_MAX ? q : ''; } + +/* ── SCOPES, JUMPS AND THE WAY OUT ────────────────────────────────────────── + * + * The index carries more than the manual and the samples since the home page + * got its box: where to go (`tools`), the repositories (`repos`), the sister + * sites (`insights`, `why`, `addons`) and the code (`api`) - see + * scripts/lib/search-family.mjs. Three things go with that, all of them data + * about the QUERY rather than about the ranking, so a box that does not use + * them (the playground's, today) loses nothing: + * + * parseScope( ) `repo: popup`, `api: bind` - a word and a colon in front + * narrow the search to one area; + * jumps( ) the query that NAMES something - `#1234`, a class name, + * a repository, a tool - answered with that thing, before + * any list; + * elsewhere( ) the query handed to GitHub's own search, for what no index + * of ours carries: the code of every repository, the issues, + * the discussions. Links, not requests - the box fetches + * nothing from GitHub, and the answer is never a day old. + */ + +/** The words a scope can be written as, and the area each means. */ +export const SCOPES = { + doc: 'docs', docs: 'docs', + sample: 'samples', samples: 'samples', + repo: 'repos', repos: 'repos', + api: 'api', code: 'api', class: 'api', + tool: 'tools', tools: 'tools', go: 'tools', + insight: 'insights', insights: 'insights', essay: 'insights', + why: 'why', + addon: 'addons', addons: 'addons', +}; + +/** `{ area, query }`: the area a leading `word:` names, and the rest. A word + * that is no scope leaves the query as it is - `z2ui5:` is a search. */ +export function parseScope(query) { + const m = /^\s*([a-z]+):\s*(.*)$/is.exec(query || ''); + const area = m && SCOPES[m[1].toLowerCase()]; + return area ? { area, query: m[2] } : { area: null, query: query || '' }; +} + +const GITHUB = 'https://github.com'; +const FRAMEWORK = `${GITHUB}/abap2UI5/abap2UI5`; +const enc = encodeURIComponent; + +/** GitHub's code search over both organisations. */ +export const codeSearchUrl = (q) => `${GITHUB}/search?q=${enc(`(org:abap2UI5 OR org:abap2UI5-addons) ${q}`)}&type=code`; + +/** + * What the query NAMES, if it names something: at most three targets, each + * `{ entry }` (a hit of the index) or `{ title, text, url }` (a place + * outside it). Empty when the query is a question rather than a name - the + * list answers those. + * + * #1234, issue 1234, pr 1234 that issue or pull request of the framework + * a class, a method, a repo the entry whose `code` it is, whole + * a tool or a repository name the entry whose title it is, whole + * z2ui5_… in no entry the code search for it + */ +export function jumps(entries, query) { + const q = parseScope(query).query.trim(); + const out = []; + const m = /^(?:#|(?:issue|pr|pull)\s*#?)(\d{1,6})$/i.exec(q); + if (m) out.push({ title: `#${m[1]}`, text: 'Issue or pull request of abap2UI5/abap2UI5', url: `${FRAMEWORK}/issues/${m[1]}` }); + + const low = normalise(q).replace(/\s+/g, ' '); + if (low.length >= 3) { + for (const e of entries) { + const code = e.code || ''; + const named = code === low || code.split('/').pop() === low + || ((e.area === 'tools' || e.area === 'repos') && normalise(e.title) === low); + if (named) out.push({ entry: e }); + } + } + if (!out.length && /^z2ui5_\w{3,}$/i.test(q)) { + out.push({ title: q, text: 'Not in the index - every repository’s code, on GitHub', url: codeSearchUrl(q) }); + } + const seen = new Set(); + return out.filter((j) => { + const url = j.entry ? j.entry.url : j.url; + if (seen.has(url)) return false; + seen.add(url); + return true; + }).slice(0, 3); +} + +/** The query handed on to GitHub: `[{ label, url }]`, empty for an empty + * query. Issues and discussions are never indexed (a nightly copy of them + * is a day old exactly when it matters), so this is how they are searched. */ +export function elsewhere(query) { + const q = parseScope(query).query.trim(); + if (!q) return []; + return [ + { label: 'Code', url: codeSearchUrl(q) }, + { label: 'Issues', url: `${GITHUB}/search?q=${enc(`org:abap2UI5 org:abap2UI5-addons ${q}`)}&type=issues` }, + { label: 'Discussions', url: `${GITHUB}/search?q=${enc(`org:abap2UI5 ${q}`)}&type=discussions` }, + ]; +} + +/** The actions beside a hit: what its entry carries (`links` - the source of + * a sample, the README of a repository) and, for a repository, its code + * searched for what was typed. */ +export function actions(entry, query = '') { + const out = Array.isArray(entry?.links) ? entry.links.filter((l) => l && l.label && l.url) : []; + const q = parseScope(query).query.trim(); + if (entry?.area === 'repos' && entry.code && q && normalise(q) !== entry.code.split('/').pop()) { + out.push({ label: 'Search code', url: `${GITHUB}/search?q=${enc(`repo:${entry.code} ${q}`)}&type=code` }); + } + return out; +} diff --git a/public/search.js b/public/search.js index 2b41f06..cbfd34d 100644 --- a/public/search.js +++ b/public/search.js @@ -1,31 +1,53 @@ /* - * The search box under the greeting - the one script of this site. + * The search box - the one script of this site, on the home page and on + * /search/. * - * 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. + * It searches the whole family from one index: the pages of the + * documentation, every sample of the three catalogues, every repository of + * abap2UI5 and abap2UI5-addons, the tools, the essays, the page for decision + * makers, the addons, the client API and the classes of the framework. The + * docs publish that index at /docs/search-index.json (abap2UI5/docs, + * scripts/generate-search.mjs and scripts/lib/search-family.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. + * + * What a query gets, top to bottom: + * - the best answer, when the query NAMES something: #1234 (an issue), a + * class, a method, a repository, a tool - jumps( ) + * - the hits, grouped by area, each with its actions on the right (the + * source of a sample, the README and issues of a repository) - actions( ) + * - the way out: the query handed to GitHub's code, issue and discussion + * search - elsewhere( ). Links only: nothing is fetched from GitHub, and + * issues and discussions are never a day old. + * A scope in front narrows to one area: `repo: popup`, `api: bind`, + * `sample: table`, `doc:`, `tool:`, `insight:`, `why:`, `addon:`. An empty + * field offers the places to go. * * 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. + * Keys: Ctrl K / Cmd K or "/" go to the box; Up and Down move through the + * hits, Right and Left through the actions of the chosen one, Enter opens + * what is chosen (the first hit if nothing is), Escape closes the list and, + * pressed again, clears the field. + * + * On /search/ (data-mode="page") the results stay on the page, with more per + * group, and the query is the address: /search/?q=popup. */ -import { search, grouped, highlight, loadIndex } from './search-engine.js'; +import { search, grouped, highlight, loadIndex, jumps, elsewhere, actions, parseScope } 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) { + const page = box.dataset.mode === 'page'; + const LIMIT = page ? 400 : 80; + const PER_GROUP = page ? 12 : 4; + box.hidden = false; const mac = /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent); const key = box.querySelector('.search-key'); @@ -33,8 +55,10 @@ if (box && input && list) { let entries = null; let failed = false; - let options = []; + /* one row per hit: its main link and its actions */ + let rows = []; let active = -1; + let action = -1; const el = (tag, cls, text) => { const e = document.createElement(tag); @@ -42,6 +66,11 @@ if (box && input && list) { if (text != null) e.textContent = text; return e; }; + const link = (cls, href, text) => { + const a = el('a', cls, text); + a.href = href; + return a; + }; /* The parts of `text` the query matched, in - built as nodes, so no * title in the index is ever read as markup. */ @@ -52,41 +81,122 @@ if (box && input && list) { }; const open = (yes) => { + if (page) yes = true; list.hidden = !yes; input.setAttribute('aria-expanded', String(yes)); - if (!yes) setActive(-1); + if (!yes) select(-1); }; - const setActive = (i) => { - if (options[active]) options[active].removeAttribute('aria-selected'); + const select = (i, a = -1) => { + const was = rows[active]; + if (was) { + was.main.removeAttribute('aria-selected'); + was.row.classList.remove('is-active'); + for (const x of was.actions) x.classList.remove('is-active'); + } active = i; - if (options[active]) { - options[active].setAttribute('aria-selected', 'true'); - options[active].scrollIntoView({ block: 'nearest' }); - input.setAttribute('aria-activedescendant', options[active].id); + action = a; + const now = rows[active]; + if (now) { + now.main.setAttribute('aria-selected', 'true'); + now.row.classList.add('is-active'); + if (now.actions[action]) now.actions[action].classList.add('is-active'); + now.row.scrollIntoView({ block: 'nearest' }); + input.setAttribute('aria-activedescendant', now.main.id); } else { input.removeAttribute('aria-activedescendant'); } }; - const note = (text, link) => { + const note = (text, more) => { 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); - } + if (more) p.append(' ', link(null, more.href, more.text)); return p; }; + const section = (label, extra) => { + const head = el('div', 'search-group'); + head.setAttribute('role', 'presentation'); + head.append(el('span', null, label)); + if (extra) head.append(el('span', 'search-count', extra)); + return head; + }; + + /* One hit: the main link (the option the arrows move through) and its + * actions beside it. `item` is an entry of the index or a place outside it + * ({ title, text, url }). */ + const addRow = (item, query, { heading = null, best = false } = {}) => { + const entry = item.entry || null; + const row = el('div', best ? 'search-row is-best' : 'search-row'); + row.setAttribute('role', 'presentation'); + const main = el('a', 'search-hit'); + main.id = `search-hit-${rows.length}`; + main.setAttribute('role', 'option'); + main.tabIndex = -1; + + let url; + let title; + let sub; + if (entry) { + url = heading && !entry.url.includes('#') ? `${entry.url}#${heading.anchor}` : entry.url; + title = entry.title; + if (heading) sub = `${entry.group} › ${heading.text}`; + else if (entry.area === 'samples' || (entry.area === 'api' && entry.group === 'Framework source')) sub = entry.code; + else sub = entry.text || entry.group; + if (best) sub = `${entry.group} · ${sub}`; + } else { + url = item.url; + title = item.title; + sub = item.text; + main.classList.add('is-out'); + } + main.href = url; + const t = el('span', 'search-title'); + t.append(marked(title, query)); + main.append(t); + if (sub) main.append(el('span', 'search-sub', sub)); + row.append(main); + + const acts = entry ? actions(entry, query).map((x) => link('search-action', x.url, x.label)) : []; + if (acts.length) { + const bar = el('span', 'search-actions'); + for (const a of acts) { + a.tabIndex = -1; + bar.append(a); + } + row.append(bar); + } + + const i = rows.length; + row.addEventListener('mousemove', () => { if (active !== i) select(i); }); + rows.push({ row, main, actions: acts }); + list.append(row); + }; + + const footer = (query) => { + const out = el('div', 'search-out'); + out.append(el('span', 'search-out-label', `“${query}” on GitHub:`)); + for (const e of elsewhere(query)) out.append(link('search-out-link', e.url, e.label)); + if (!page) out.append(link('search-all', `/search/?q=${encodeURIComponent(input.value.trim())}`, 'All results →')); + list.append(out); + }; + + const startHere = () => { + const tools = (entries || []).filter((e) => e.area === 'tools'); + if (tools.length) { + list.append(section('Go to')); + for (const e of tools.slice(0, page ? tools.length : 6)) addRow({ entry: e }, ''); + } + list.append(note('Try #123 for an issue, a class name, or a scope: repo: · api: · sample: · doc: · addon:')); + }; + const render = () => { const query = input.value.trim(); - options = []; + rows = []; active = -1; + action = -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/' })); @@ -98,40 +208,32 @@ if (box && input && list) { open(true); return; } + if (!query) { + startHere(); + open(document.activeElement === input || page); + return; + } + const words = parseScope(query).query; + const best = jumps(entries, query); const hits = search(entries, query, { limit: LIMIT }); - if (!hits.length) { - list.append(note(`Nothing found for “${query}”.`)); - open(true); - return; + + if (best.length) { + list.append(section('Best answer')); + for (const j of best) addRow(j, words, { best: true }); } - if (hits.relaxedTo) list.append(note(`Nothing for “${query}” - showing “${hits.relaxedTo}”.`)); - const shown = hits.relaxedTo || query; + if (hits.relaxedTo) list.append(note(`Nothing for “${words}” - showing “${hits.relaxedTo}”.`)); + const shown = hits.relaxedTo || words; + const taken = new Set(best.map((j) => (j.entry ? j.entry.url : j.url))); 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); - } + const rest = group.hits.filter((h) => !taken.has(h.entry.url) || h.heading); + if (!rest.length) continue; + list.append(section(group.label, group.total > group.hits.length ? `${group.hits.length} of ${group.total}` : null)); + for (const { entry, heading } of rest) addRow({ entry }, shown, { heading }); } + if (!best.length && !hits.length) list.append(note(`Nothing found for “${words || query}” here.`)); + if (words.trim()) footer(words.trim()); open(true); }; @@ -143,23 +245,39 @@ if (box && input && list) { ); }; - input.addEventListener('focus', () => { ensureIndex(); if (input.value.trim()) render(); }); - input.addEventListener('input', () => { ensureIndex(); render(); }); + /* the page: the query is its address */ + const remember = () => { + if (!page) return; + const q = input.value.trim(); + history.replaceState(null, '', q ? `?q=${encodeURIComponent(q)}` : location.pathname); + document.title = q ? `${q} — Search — abap2UI5` : 'Search — abap2UI5'; + }; + + input.addEventListener('focus', () => { ensureIndex(); render(); }); + input.addEventListener('input', () => { ensureIndex(); remember(); render(); }); input.addEventListener('keydown', (e) => { + const row = rows[active]; if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { - if (!options.length) return; + if (!rows.length) return; e.preventDefault(); if (list.hidden) open(true); const step = e.key === 'ArrowDown' ? 1 : -1; - setActive((active + step + options.length) % options.length); + select((active + step + rows.length) % rows.length); + } else if ((e.key === 'ArrowRight' || e.key === 'ArrowLeft') && row) { + /* the arrows move the caret until a hit is chosen; after that they walk + * its actions - Left from the first action is back on the hit */ + e.preventDefault(); + const next = e.key === 'ArrowRight' ? Math.min(action + 1, row.actions.length - 1) : Math.max(action - 1, -1); + select(active, next); } else if (e.key === 'Enter') { - const target = options[active] || options[0]; + const target = row ? (row.actions[action] || row.main) : rows[0]?.main; if (!target) return; e.preventDefault(); - target.click(); + if (e.metaKey || e.ctrlKey) window.open(target.href, '_blank', 'noopener'); + else target.click(); } else if (e.key === 'Escape') { - if (!list.hidden) { e.preventDefault(); open(false); } else if (input.value) { e.preventDefault(); input.value = ''; } + if (!page && !list.hidden) { e.preventDefault(); open(false); } else if (input.value) { e.preventDefault(); input.value = ''; remember(); render(); } } }); @@ -175,14 +293,22 @@ if (box && input && list) { } }); - /* 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); - }); + if (!page) { + /* 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); + }); + } else { + input.value = new URLSearchParams(location.search).get('q') || ''; + document.title = input.value ? `${input.value} — Search — abap2UI5` : document.title; + ensureIndex(); + render(); + input.focus(); + } } diff --git a/src/components/Search.astro b/src/components/Search.astro new file mode 100644 index 0000000..c1d0dca --- /dev/null +++ b/src/components/Search.astro @@ -0,0 +1,20 @@ +--- +/* The search box: the docs, the samples, the repositories, the tools, the + * sister sites and the API, from the one index the docs publish + * (/docs/search-index.json). public/search.js shows it and drives it: without + * a script there is nothing it could do, so the markup ships it hidden. + * + * `page`: the box of /search/ - the results stay on the page under it instead + * of opening as a list under the field, and the query is the page's ?q=. */ +interface Props { page?: boolean } +const { page = false } = Astro.props; +--- + diff --git a/src/layouts/Page.astro b/src/layouts/Page.astro index 22cfd39..3c7c2a3 100644 --- a/src/layouts/Page.astro +++ b/src/layouts/Page.astro @@ -7,8 +7,9 @@ * 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 one script is the search box on the home page, - * a file of this origin (public/search.js) - script-src is 'self', no + * Policy is a . The one script is the search box on the home page + * and on /search/, 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. */ @@ -51,6 +52,7 @@ const CSP = [ {search && } +
diff --git a/src/pages/index.astro b/src/pages/index.astro index 6956153..1cff8f7 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -14,6 +14,7 @@ * and the second tab of the top line lead there. */ import Page from "../layouts/Page.astro"; import { MARKS } from "../components/marks"; +import Search from "../components/Search.astro"; type Tile = { href: string; title: string; text?: string; mark?: keyof typeof MARKS; for?: string; inside?: boolean }; type Group = { id: string; title: string; tiles: Tile[] }; @@ -66,19 +67,7 @@ const base = import.meta.env.BASE_URL;

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. */} - +
diff --git a/src/pages/search.astro b/src/pages/search.astro new file mode 100644 index 0000000..7e9daa0 --- /dev/null +++ b/src/pages/search.astro @@ -0,0 +1,15 @@ +--- +/* The search as a page of its own: https://abap2ui5.github.io/search/?q=popup. + * The same box and the same index as on the home page, with room for every + * group - and an address, so a search can be linked, bookmarked, and added + * to the browser as a search engine (public/opensearch.xml points here). */ +import Page from "../layouts/Page.astro"; +import Search from "../components/Search.astro"; +--- + +
+

Search

+ + +
+
diff --git a/src/styles/global.css b/src/styles/global.css index 0077158..d89164b 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -132,13 +132,49 @@ img { max-width: 100%; height: auto; } 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); } +/* a hit: the link and, on the right, its actions (source, README, issues) */ +.search-row { display: flex; align-items: center; gap: 8px; border-radius: 8px; } +.search-row.is-active { background: var(--brand-soft); } +.search-hit { flex: 1 1 auto; min-width: 0; display: grid; gap: 1px; padding: 7px 10px; border-radius: 8px; color: var(--text); text-decoration: none; } +.search-hit:hover { color: var(--text); } .search-title { color: var(--ink); font-size: 14.5px; font-weight: 550; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.search-hit.is-out .search-title::after { content: " ↗"; color: var(--muted); font-weight: 400; } +.search-row.is-best .search-title { font-size: 15px; } .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-actions { flex: none; display: flex; gap: 4px; padding-right: 8px; } +.search-action { + padding: 1px 7px; border: 1px solid var(--line); border-radius: 5px; background: var(--card); + font-size: 11.5px; line-height: 1.6; color: var(--muted); text-decoration: none; white-space: nowrap; +} +.search-action:hover, .search-action.is-active { border-color: var(--brand); color: var(--brand); } +/* the way out: the query on GitHub, and on the home page the whole list */ +.search-out { + display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; margin-top: 6px; padding: 10px 10px 6px; + border-top: 1px solid var(--line); font-size: 13px; color: var(--muted); +} +.search-out a { color: var(--ink); text-decoration: none; font-weight: 500; } +.search-out a:hover { color: var(--brand); } +.search-out .search-out-link::after { content: " ↗"; color: var(--muted); font-weight: 400; } +.search-out .search-all { margin-left: auto; color: var(--brand); } +/* in the list under the box it stays in view at the bottom, however far + * the hits scroll - it is the answer when they are not */ +.search[data-mode="box"] .search-out { position: sticky; bottom: -6px; margin-bottom: -6px; padding-bottom: 10px; background: var(--card); } +@media (max-width: 620px) { .search-actions { display: none; } } .search-note { margin: 0; padding: 10px; font-size: 14px; color: var(--muted); } +/* ---- /search/: the same box, the results on the page under it ---------- */ +.search-page { padding-top: 28px; padding-bottom: 48px; } +.search-page h1 { margin: 0 0 20px; color: var(--ink); font-size: clamp(28px, 4vw, 40px); line-height: 1.15; letter-spacing: -.025em; } +.search[data-mode="page"] { max-width: 760px; } +.search[data-mode="page"] .search-field { height: 40px; border-radius: 20px; padding: 0 12px 0 14px; } +.search[data-mode="page"] .search-field input { font-size: 15px; } +.search[data-mode="page"] .search-results { + position: static; width: auto; max-height: none; overflow: visible; margin-top: 16px; padding: 0; + border: 0; box-shadow: none; background: none; +} +@media (max-width: 620px) { .search-page { padding-top: 24px; } } + /* ---- 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 * into three columns at most, two on a tablet, one on a phone. A tile keeps diff --git a/test/site.test.mjs b/test/site.test.mjs index f2162a5..f66141e 100644 --- a/test/site.test.mjs +++ b/test/site.test.mjs @@ -39,7 +39,7 @@ test("the one script is the search box, a file of this origin, and the policy sa }); test("the search box: hidden until its script shows it, reading the docs' index with the docs' matcher", () => { - assert.match(html, /