Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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 (`<Page search>`); the policy is
loaded on the home page and on `/search/` (`<Page 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
Expand Down
9 changes: 9 additions & 0 deletions public/opensearch.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/" xmlns:moz="http://www.mozilla.org/2006/browser/search/">
<ShortName>abap2UI5</ShortName>
<Description>The documentation, the samples, the repositories and the API of abap2UI5</Description>
<InputEncoding>UTF-8</InputEncoding>
<Image width="32" height="32" type="image/png">https://abap2ui5.github.io/favicon.png</Image>
<Url type="text/html" method="get" template="https://abap2ui5.github.io/search/?q={searchTerms}"/>
<moz:SearchForm>https://abap2ui5.github.io/search/</moz:SearchForm>
</OpenSearchDescription>
127 changes: 126 additions & 1 deletion public/search-engine.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
Loading
Loading