Files
numolus/app/frontend/data/codeReference.ts
T
wkhandClaude Opus 5.5 e14bada114 Add Rails backend: accounts via magic link, server documents, sharing
The app becomes a Rails 8 application; the Vue frontend moves to
app/frontend and is served through vite_rails.

- Without an account documents stay in the browser (IndexedDB). Sign-in
  is by magic link or the 6-digit code from the same mail; afterwards
  local documents can be uploaded into the account.
- Server documents (/d/<id>) with versioned saves and a single-editor
  lock per browser tab; others see them read-only and follow by polling.
- Sharing via link (works without an account) and via e-mail invitation,
  roles viewer/editor; the owner manages links and people.
- Store gains a read-only mode that blocks all content changes.
- Solid Queue, Cache and Cable share the one PostgreSQL database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:09:06 +02:00

523 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The searchable command reference shown next to the macro and custom-
* function editors (components/CodeReference.vue). Each entry lists the
* editing contexts it applies to -- a macro, a "Mit Schleife" function body
* or a single-formula function body. Hand-maintained alongside
* engine/evaluator.ts's `execAction()` (the write actions), its GET/POST/
* JSONGET handling and the statement grammar in engine/parser.ts -- the
* formula-function entries are derived from `BUILTIN_SIGNATURES`, so a new
* built-in shows up here automatically (with a generic description until
* one is added to `FUNCTION_DESCRIPTIONS`).
*/
import { BUILTIN_SIGNATURES } from '../engine';
export type RefCategory =
| 'Zellen'
| 'Zeilen & Spalten'
| 'Tabellen'
| 'Blätter'
| 'Ablauf'
| 'Netzwerk & JSON'
| 'Operatoren'
| 'Formelfunktionen';
/** Which editor the reference is shown in. */
export type RefContext = 'macro' | 'block' | 'expression';
export interface RefEntry {
name: string;
/** Editors this entry applies to. */
contexts: RefContext[];
category: RefCategory;
/** Signature as shown in the list. */
syntax: string;
description: string;
/** A complete, working example. */
example?: string;
/** What "Einfügen" puts into the editor. */
snippet: string;
/** Extra search terms (German synonyms etc.). */
keywords?: string;
}
export const REF_CATEGORIES: RefCategory[] = [
'Zellen',
'Zeilen & Spalten',
'Tabellen',
'Blätter',
'Ablauf',
'Netzwerk & JSON',
'Operatoren',
'Formelfunktionen',
];
const ACTIONS: RefEntry[] = [
{
contexts: ['macro'],
name: 'SETVALUE',
category: 'Zellen',
syntax: 'SETVALUE(Ziel; Wert)',
description:
'Schreibt einen Wert in eine Zelle. Ist das Ziel ein Bereich, erhält jede Zelle darin denselben Wert. Beginnt der Text mit „=", wird daraus eine echte Formel. Das Ziel muss die Tabelle nennen (Umsatz::A1, nie bloß A1).',
example: 'SETVALUE(Umsatz::B2; Umsatz::B2 + 1)\nSETVALUE(Umsatz::F2; "=SUM(B2:E2)")',
snippet: 'SETVALUE(Tabelle::A1; Wert)',
keywords: 'schreiben setzen wert zelle bereich formel eintragen',
},
{
contexts: ['macro'],
name: 'INSERTROW',
category: 'Zeilen & Spalten',
syntax: 'INSERTROW(Tabelle; Index)',
description:
'Fügt eine leere Zeile ein. Index zählt ab 0: 0 fügt vor der ersten Zeile ein. Formelbezüge im ganzen Dokument werden wie beim manuellen Einfügen angepasst.',
example: 'INSERTROW("Umsatz"; 1)',
snippet: 'INSERTROW("Tabelle"; 0)',
keywords: 'zeile einfügen hinzufügen neu',
},
{
contexts: ['macro'],
name: 'DELETEROW',
category: 'Zeilen & Spalten',
syntax: 'DELETEROW(Tabelle; Index)',
description: 'Löscht die Zeile mit dem Index (ab 0). Bezüge auf gelöschte Zellen werden zu #REF!.',
example: 'DELETEROW("Umsatz"; 5)',
snippet: 'DELETEROW("Tabelle"; 0)',
keywords: 'zeile löschen entfernen',
},
{
contexts: ['macro'],
name: 'INSERTCOL',
category: 'Zeilen & Spalten',
syntax: 'INSERTCOL(Tabelle; Index)',
description: 'Fügt eine leere Spalte ein. Index zählt ab 0 (0 = vor Spalte A).',
example: 'INSERTCOL("Umsatz"; 5)',
snippet: 'INSERTCOL("Tabelle"; 0)',
keywords: 'spalte einfügen hinzufügen neu',
},
{
contexts: ['macro'],
name: 'DELETECOL',
category: 'Zeilen & Spalten',
syntax: 'DELETECOL(Tabelle; Index)',
description: 'Löscht die Spalte mit dem Index (ab 0, 0 = Spalte A).',
example: 'DELETECOL("Umsatz"; 7)',
snippet: 'DELETECOL("Tabelle"; 0)',
keywords: 'spalte löschen entfernen',
},
{
contexts: ['macro'],
name: 'ADDTABLE',
category: 'Tabellen',
syntax: 'ADDTABLE(Blatt; Name)',
description: 'Legt auf dem Blatt eine neue, leere Tabelle mit diesem Namen an.',
example: 'ADDTABLE("Analyse"; "Auswertung")',
snippet: 'ADDTABLE("Blatt"; "Name")',
keywords: 'tabelle neu anlegen erstellen hinzufügen',
},
{
contexts: ['macro'],
name: 'RENAMETABLE',
category: 'Tabellen',
syntax: 'RENAMETABLE(Tabelle; NeuerName)',
description: 'Benennt eine Tabelle um. Formeln, die sie nennen, werden dokumentweit mit umgeschrieben.',
example: 'RENAMETABLE("Umsatz"; "Umsatz 2026")',
snippet: 'RENAMETABLE("Tabelle"; "NeuerName")',
keywords: 'tabelle umbenennen name ändern',
},
{
contexts: ['macro'],
name: 'DELETETABLE',
category: 'Tabellen',
syntax: 'DELETETABLE(Tabelle)',
description: 'Löscht eine Tabelle. Bezüge darauf werden zu #REF!.',
example: 'DELETETABLE("Blatt 2!Entwurf")',
snippet: 'DELETETABLE("Tabelle")',
keywords: 'tabelle löschen entfernen',
},
{
contexts: ['macro'],
name: 'ADDSHEET',
category: 'Blätter',
syntax: 'ADDSHEET(Name)',
description: 'Legt ein neues, leeres Blatt an. Ist der Name vergeben, wird eine Nummer angehängt.',
example: 'ADDSHEET("Auswertung")',
snippet: 'ADDSHEET("Name")',
keywords: 'blatt neu anlegen erstellen hinzufügen sheet',
},
{
contexts: ['macro'],
name: 'RENAMESHEET',
category: 'Blätter',
syntax: 'RENAMESHEET(Blatt; NeuerName)',
description: 'Benennt ein Blatt um.',
example: 'RENAMESHEET("Blatt 2"; "Archiv")',
snippet: 'RENAMESHEET("Blatt"; "NeuerName")',
keywords: 'blatt umbenennen name ändern sheet',
},
{
contexts: ['macro'],
name: 'DELETESHEET',
category: 'Blätter',
syntax: 'DELETESHEET(Blatt)',
description: 'Löscht ein Blatt samt Inhalt. Das letzte Blatt bleibt immer erhalten.',
example: 'DELETESHEET("Archiv")',
snippet: 'DELETESHEET("Blatt")',
keywords: 'blatt löschen entfernen sheet',
},
];
const CONTROL_MACRO: RefEntry[] = [
{
contexts: ['macro'],
name: 'Zuweisung',
category: 'Ablauf',
syntax: 'name = Ausdruck',
description:
'Speichert einen Wert unter einem Namen. Der Name bleibt für den restlichen Code sichtbar, auch nach dem Ende eines FOR/IF.',
example: 'summe = SUM(Umsatz::B2:B6)\nSETVALUE(Umsatz::B8; summe)',
snippet: 'name = Ausdruck',
keywords: 'variable zuweisen speichern let',
},
{
contexts: ['macro'],
name: 'FOR … IN … DO … END',
category: 'Ablauf',
syntax: 'FOR x IN Bereich DO … END',
description:
'Wiederholt die Anweisungen für jede Zelle eines Bereichs; x ist dabei der jeweilige Zellbezug. Auch über ein JSON-Array (siehe GET).',
example: 'FOR z IN Umsatz::B2:B6 DO\n SETVALUE(z; z * 1.1)\nEND',
snippet: 'FOR x IN Tabelle::A1:A10 DO\n \nEND',
keywords: 'schleife loop wiederholen jede zelle iteration',
},
{
contexts: ['macro'],
name: 'IF … THEN … END',
category: 'Ablauf',
syntax: 'IF Bedingung THEN … END',
description: 'Führt die Anweisungen nur aus, wenn die Bedingung wahr ist. Beliebig verschachtelbar.',
example: 'IF Umsatz::B2 > 1000 THEN\n SETVALUE(Umsatz::C2; "Ziel erreicht")\nEND',
snippet: 'IF Bedingung THEN\n \nEND',
keywords: 'wenn bedingung verzweigung falls',
},
{
contexts: ['macro'],
name: 'IF … THEN … ELSE … END',
category: 'Ablauf',
syntax: 'IF Bedingung THEN … ELSE … END',
description: 'Wie IF, mit einem zweiten Zweig, der läuft, wenn die Bedingung falsch ist.',
example: 'IF Umsatz::B2 > 0 THEN\n SETVALUE(Umsatz::C2; "Plus")\nELSE\n SETVALUE(Umsatz::C2; "Minus")\nEND',
snippet: 'IF Bedingung THEN\n \nELSE\n \nEND',
keywords: 'wenn sonst bedingung verzweigung',
},
{
contexts: ['macro'],
name: 'RETURN',
category: 'Ablauf',
syntax: 'RETURN "Meldung"',
description:
'Beendet das Makro sofort, auch mitten in einer Schleife. Der Text erscheint als Erfolgsmeldung statt „Makro ausgeführt.".',
example: 'IF Umsatz::A2 = "" THEN RETURN "Nichts zu tun" END',
snippet: 'RETURN "Fertig"',
keywords: 'abbrechen beenden stop meldung',
},
];
const NETWORK: RefEntry[] = [
{
contexts: ['macro'],
name: 'GET',
category: 'Netzwerk & JSON',
syntax: 'r = GET(URL)',
description:
'Ruft eine Adresse ab und speichert die JSON-Antwort in r. Nur als ganze rechte Seite einer Zuweisung erlaubt. Beim ersten Kontakt zu einem neuen Host fragt die App einmalig nach.',
example: 'r = GET("https://api.example.com/kurse")\nSETVALUE(Kurse::B1; JSONGET(r; "eur.chf"))',
snippet: 'r = GET("https://")',
keywords: 'http abrufen laden api web url fetch request',
},
{
contexts: ['macro'],
name: 'POST',
category: 'Netzwerk & JSON',
syntax: 'r = POST(URL; Body)',
description:
'Sendet den Body-Text als JSON (Content-Type: application/json) und speichert die Antwort in r. Den Body setzt du selbst als Text zusammen, z. B. mit &; Anführungszeichen im Text schreibst du als \\".',
example: 'r = POST("https://api.example.com/log"; "{\\"summe\\": " & Umsatz::B8 & "}")',
snippet: 'r = POST("https://"; "{}")',
keywords: 'http senden api web url request',
},
{
contexts: ['macro'],
name: 'JSONGET',
category: 'Netzwerk & JSON',
syntax: 'JSONGET(Wert; "pfad")',
description:
'Liest ein Feld aus einem JSON-Ergebnis. Pfad punktgetrennt, Zahlen indizieren Arrays ("daten.0.name"). Ein fehlender Pfad ergibt null, keinen Fehler.',
example: 'r = GET("https://api.example.com/produkte")\nSETVALUE(Kurse::A1; JSONGET(r; "daten.0.name"))',
snippet: 'JSONGET(r; "pfad")',
keywords: 'json feld lesen pfad objekt',
},
{
contexts: ['macro'],
name: 'FOR über JSON-Array',
category: 'Netzwerk & JSON',
syntax: 'FOR item IN r DO … END',
description: 'Durchläuft die Elemente eines JSON-Arrays (z. B. aus GET oder JSONGET).',
example:
'r = GET("https://api.example.com/produkte")\nFOR p IN JSONGET(r; "items") DO\n INSERTROW("Produkte"; 1)\n SETVALUE(Produkte::A2; JSONGET(p; "name"))\nEND',
snippet: 'FOR item IN JSONGET(r; "pfad") DO\n \nEND',
keywords: 'schleife array liste json iteration',
},
];
const OPERATORS_MACRO: RefEntry[] = [
{
contexts: ['macro'],
name: 'Rechnen',
category: 'Operatoren',
syntax: '+ - * / ^',
description: 'Grundrechenarten und Potenz. ^ bindet am stärksten, dann * und /, dann + und -.',
example: 'SETVALUE(Umsatz::H3; (Umsatz::B2 + Umsatz::C2) / 2)',
snippet: ' + ',
keywords: 'plus minus mal geteilt potenz rechnen',
},
{
contexts: ['macro'],
name: 'Text verketten',
category: 'Operatoren',
syntax: 'Text & Text',
description: 'Hängt Texte und Zahlen aneinander.',
example: 'SETVALUE(Umsatz::A20; "Stand: " & TODAY())',
snippet: ' & ',
keywords: 'verketten zusammenfügen text string',
},
{
contexts: ['macro'],
name: 'Vergleichen',
category: 'Operatoren',
syntax: '= <> < <= > >=',
description: 'Vergleiche für IF-Bedingungen. Texte werden ohne Groß-/Kleinschreibung verglichen.',
example: 'IF Umsatz::B2 >= 1000 THEN … END',
snippet: ' >= ',
keywords: 'gleich ungleich größer kleiner vergleich',
},
{
contexts: ['macro'],
name: 'Bezüge',
category: 'Operatoren',
syntax: 'Tabelle::A1 Blatt!Tabelle::A1:B5',
description:
'In Makros muss jeder Zell- und Bereichsbezug die Tabelle nennen. Namen mit Leerzeichen in einfache Anführungszeichen setzen: \'Umsatz 2026\'::A1.',
example: "SETVALUE('Blatt 2'!Kosten::B2; 0)",
snippet: 'Tabelle::A1',
keywords: 'zelle bereich referenz adresse tabelle blatt',
},
];
// Function-body counterparts: same grammar, but no write actions -- examples
// compute and RETURN a value instead of writing it. Parameter names (bereich,
// umsatz, ...) stand for whatever the function declares.
const CONTROL_FUNCTION: RefEntry[] = [
{
contexts: ['block'],
name: 'Zuweisung',
category: 'Ablauf',
syntax: 'name = Ausdruck',
description:
'Speichert einen Zwischenwert unter einem Namen. Der Name bleibt für den restlichen Code sichtbar, auch nach dem Ende eines FOR/IF.',
example: 'marge = (umsatz - kosten) / umsatz\nRETURN marge * 100',
snippet: 'name = Ausdruck',
keywords: 'variable zuweisen speichern zwischenwert',
},
{
contexts: ['block'],
name: 'FOR … IN … DO … END',
category: 'Ablauf',
syntax: 'FOR x IN bereich DO … END',
description:
'Wiederholt die Anweisungen für jedes Element eines Bereichs. Die Quelle ist ein Parameter, eine Zelle oder ein Bereich, kein berechneter Ausdruck.',
example: 'ergebnis = 1\nFOR x IN bereich DO\n ergebnis = ergebnis * x\nEND\nRETURN ergebnis',
snippet: 'FOR x IN bereich DO\n \nEND',
keywords: 'schleife loop wiederholen jedes element iteration',
},
{
contexts: ['block'],
name: 'IF … THEN … END',
category: 'Ablauf',
syntax: 'IF Bedingung THEN … END',
description: 'Führt die Anweisungen nur aus, wenn die Bedingung wahr ist. Beliebig verschachtelbar, auch in einer Schleife.',
example: 'IF umsatz = 0 THEN RETURN 0 END\nRETURN (umsatz - kosten) / umsatz',
snippet: 'IF Bedingung THEN\n \nEND',
keywords: 'wenn bedingung verzweigung falls',
},
{
contexts: ['block'],
name: 'IF … THEN … ELSE … END',
category: 'Ablauf',
syntax: 'IF Bedingung THEN … ELSE … END',
description: 'Wie IF, mit einem zweiten Zweig, der läuft, wenn die Bedingung falsch ist.',
example: 'IF umsatz > kosten THEN\n RETURN "Gewinn"\nELSE\n RETURN "Verlust"\nEND',
snippet: 'IF Bedingung THEN\n \nELSE\n \nEND',
keywords: 'wenn sonst bedingung verzweigung',
},
{
contexts: ['block'],
name: 'RETURN',
category: 'Ablauf',
syntax: 'RETURN Ausdruck',
description:
'Liefert den Funktionswert und beendet die Ausführung sofort, auch mitten in einer Schleife oder einem IF-Zweig.',
example: 'FOR x IN bereich DO\n IF x < 0 THEN RETURN "negativ" END\nEND\nRETURN "ok"',
snippet: 'RETURN ',
keywords: 'ergebnis zurückgeben beenden abbrechen',
},
];
const OPERATORS_FUNCTION: RefEntry[] = [
{
contexts: ['block', 'expression'],
name: 'Rechnen',
category: 'Operatoren',
syntax: '+ - * / ^',
description: 'Grundrechenarten und Potenz. ^ bindet am stärksten, dann * und /, dann + und -.',
example: '(umsatz - kosten) / umsatz * 100',
snippet: ' + ',
keywords: 'plus minus mal geteilt potenz rechnen',
},
{
contexts: ['block', 'expression'],
name: 'Text verketten',
category: 'Operatoren',
syntax: 'Text & Text',
description: 'Hängt Texte und Zahlen aneinander.',
example: 'CONCAT("Marge: "; (umsatz - kosten) / umsatz * 100) & " %"',
snippet: ' & ',
keywords: 'verketten zusammenfügen text string',
},
{
contexts: ['block', 'expression'],
name: 'Vergleichen',
category: 'Operatoren',
syntax: '= <> < <= > >=',
description: 'Vergleiche für IF. Texte werden ohne Groß-/Kleinschreibung verglichen.',
example: 'IF(umsatz >= kosten; "Gewinn"; "Verlust")',
snippet: ' >= ',
keywords: 'gleich ungleich größer kleiner vergleich',
},
{
contexts: ['block', 'expression'],
name: 'Bereich als Parameter',
category: 'Operatoren',
syntax: 'SUM(bereich)',
description:
'Ein Parameter kann ein Bereich sein, wenn er direkt an eine Funktion wie SUM, AVG, MIN oder MAX geht. Aufruf dann z. B. =SPANNE(Umsatz!B2:E6).',
example: 'MAX(bereich) - MIN(bereich)',
snippet: 'SUM(bereich)',
keywords: 'bereich parameter range argument',
},
];
const FUNCTION_DESCRIPTIONS: Record<string, string> = {
SUM: 'Summe der Werte.',
AVG: 'Mittelwert der Werte.',
AVERAGE: 'Mittelwert der Werte (wie AVG).',
MIN: 'Kleinster Wert.',
MAX: 'Größter Wert.',
COUNT: 'Anzahl der Zahlen.',
COUNT2: 'Anzahl der nicht leeren Zellen.',
CONCAT: 'Verkettet Texte.',
CONCATENATE: 'Verkettet Texte (wie CONCAT).',
IF: 'Wählt je nach Bedingung einen von zwei Werten.',
AND: 'Wahr, wenn alle Bedingungen wahr sind.',
OR: 'Wahr, wenn mindestens eine Bedingung wahr ist.',
NOT: 'Kehrt eine Bedingung um.',
DATE: 'Datum aus Jahr, Monat und Tag.',
TIME: 'Uhrzeit aus Stunden, Minuten und Sekunden.',
TODAY: 'Heutiges Datum.',
NOW: 'Aktuelles Datum mit Uhrzeit.',
XLOOKUP: 'Sucht einen Wert und gibt den passenden Eintrag aus einem zweiten Bereich zurück.',
YEAR: 'Jahr eines Datums.',
MONTH: 'Monat eines Datums (1–12).',
DAY: 'Tag des Monats (1–31).',
HOUR: 'Stunde einer Uhrzeit (0–23).',
MINUTE: 'Minute einer Uhrzeit (0–59).',
SECOND: 'Sekunde einer Uhrzeit (0–59).',
WEEKDAY: 'Wochentag nach ISO: Montag = 1 … Sonntag = 7. Typ 1: Sonntag = 1, Typ 3: Montag = 0.',
ISOWEEKNUM: 'Kalenderwoche nach ISO 8601 (Woche 1 enthält den ersten Donnerstag des Jahres).',
WEEKNUM: 'Kalenderwoche, standardmäßig nach ISO. Typ 1/2: US-Zählung mit Wochenbeginn Sonntag/Montag.',
EDATE: 'Gleicher Tag einige Monate später (negativ: früher), am Monatsende gekürzt.',
EOMONTH: 'Letzter Tag des Monats, einige Monate vor oder nach dem Datum.',
DAYS: 'Anzahl Kalendertage zwischen zwei Daten.',
DATEDIF: 'Abstand zweier Daten in "Y" Jahren, "M" Monaten, "D" Tagen oder als Rest "YM", "MD", "YD".',
NETWORKDAYS: 'Arbeitstage (Mo–Fr) zwischen zwei Daten, beide eingeschlossen, ohne Feiertage.',
WORKDAY: 'Datum nach einer Anzahl Arbeitstage (Mo–Fr), Feiertage werden übersprungen.',
DATEVALUE: 'Datum aus einem Text wie "2026-09-02" oder "02.09.2026".',
TIMEVALUE: 'Uhrzeit aus einem Text wie "14:30".',
};
/** German search terms for built-ins whose description doesn't already say it. */
const FUNCTION_KEYWORDS: Record<string, string> = {
SUM: 'summe addieren',
AVG: 'durchschnitt mittelwert',
AVERAGE: 'durchschnitt mittelwert',
COUNT: 'anzahl zählen',
COUNT2: 'anzahl zählen',
IF: 'wenn falls bedingung',
AND: 'und',
OR: 'oder',
NOT: 'nicht',
TODAY: 'heute datum',
NOW: 'jetzt zeit',
XLOOKUP: 'suchen sverweis verweis nachschlagen',
YEAR: 'jahr datum',
MONTH: 'monat datum',
DAY: 'tag datum',
HOUR: 'stunde zeit uhrzeit',
MINUTE: 'minute zeit uhrzeit',
SECOND: 'sekunde zeit uhrzeit',
WEEKDAY: 'wochentag tag woche',
ISOWEEKNUM: 'kalenderwoche kw woche',
WEEKNUM: 'kalenderwoche kw woche',
EDATE: 'monat addieren datum',
EOMONTH: 'monatsende ultimo',
DAYS: 'tage differenz abstand',
DATEDIF: 'differenz abstand alter jahre monate tage',
NETWORKDAYS: 'arbeitstage werktage feiertage',
WORKDAY: 'arbeitstag werktag feiertage frist',
DATEVALUE: 'datum text umwandeln',
TIMEVALUE: 'zeit uhrzeit text umwandeln',
};
const FUNCTIONS: RefEntry[] = BUILTIN_SIGNATURES.map((s) => ({
contexts: ['macro', 'block', 'expression'],
name: s.name,
category: 'Formelfunktionen' as const,
syntax: s.hint,
description: FUNCTION_DESCRIPTIONS[s.name] ?? 'Formelfunktion.',
keywords: FUNCTION_KEYWORDS[s.name],
snippet: `${s.name}()`,
}));
export const CODE_REFERENCE: RefEntry[] = [
...ACTIONS,
...CONTROL_MACRO,
...CONTROL_FUNCTION,
...NETWORK,
...OPERATORS_MACRO,
...OPERATORS_FUNCTION,
...FUNCTIONS,
];
export const referenceFor = (ctx: RefContext): RefEntry[] =>
CODE_REFERENCE.filter((e) => e.contexts.includes(ctx));
/** Case-insensitive match over name, syntax, description and keywords. */
export function matchesQuery(
e: Pick<RefEntry, 'name' | 'syntax' | 'description' | 'keywords'>,
query: string,
): boolean {
const words = query.toLowerCase().split(/\s+/).filter(Boolean);
if (!words.length) return true;
const hay = `${e.name} ${e.syntax} ${e.description} ${e.keywords ?? ''}`.toLowerCase();
return words.every((w) => hay.includes(w));
}