@skaile/forge-tags
v1.0.0
Published
One tag library for hierarchical (a/b), flat (a b) and facet (facet:a) tags, with an AND/OR/NOT filter grammar
Readme
@skaile/forge-tags
One tag library for three notations that are one structure:
| Notation | Example | Read as |
|---|---|---|
| hierarchical | therapie/einzel | path [therapie, einzel] |
| flat | ergotherapie (or ergotherapie iq = two tags) | path [ergotherapie] |
| facet | beruf:ergotherapie | facet beruf, path [ergotherapie] |
Plus a filter grammar (AND, OR, NOT) that selects entries by their tags. Pure TypeScript, no dependencies, no framework.
Install
npm i @skaile/forge-tagsDuring development, link the local checkout: bun link in this directory, bun link @skaile/forge-tags in the consumer.
Store format
A tag is stored as one string, in a single format that is valid without any facet knowledge:
store := facet ? facet "/" path : path beruf/ergotherapie, thema/befunde/iq, iqberuf:ergotherapie is only the display form; storeKey writes beruf/ergotherapie. Segments follow the CLINICO tag schema: lowercase letters of any script, digits, -, _; / nests; at least one letter; at most 255 characters.
Which first segments are facets is decided by the vocabulary of the product:
interface Vocabulary { facets: readonly string[]; mode: "prefix" | "infix" }beruf/ergotherapie is a facet tag where beruf is declared and a plain nested path where it is not; the stored string is the same.
Filter grammar
expr := group ( "|" group )* OR
group := term ( WS+ term )* AND
term := "!"? "#"? ( facet ":" )? path | "*"# is optional notation (hash: "required" makes it mandatory, as CLINICO boards do). facet: may be written facet/. A bare facet name (beruf, beruf:) means "has any tag of this facet"; !beruf means "has none". * is every entry.
How one term meets a tag set
| Term | Rule |
|---|---|
| beruf:ergotherapie, thema:befunde | some tag of that facet is, or lies below, the path (prefix) |
| beruf, beruf: | some tag of that facet |
| iq, testpsychologie/iq (no facet), mode: "infix" | the words occur as a contiguous run in the path of any tag, in any facet |
| #bericht (no facet), mode: "prefix" | the whole stored path starts with the words (bericht, bericht/arztbrief; not x/bericht) |
| !term | inverts the term |
Example with the tags beruf/ergotherapie, thema/befunde/testpsychologie/iq, zeit/frueh in infix mode:
| Filter | Match | Why |
|---|---|---|
| ergotherapie | yes | infix in beruf/ergotherapie |
| beruf:ergotherapie | yes | prefix inside facet beruf |
| beruf:psychologie | no | |
| iq, befunde, testpsychologie/iq | yes | infix |
| befunde/iq | no | not contiguous |
| thema:befunde | yes | prefix inside facet thema |
| befunde iq | yes | AND, both words hit |
| !zeit:spaet | yes | no tag below zeit/spaet |
| befunde \|einschaetzung | yes | OR |
| !beruf | no | facet beruf is present |
An ambiguous word (iq under befunde and under einschaetzung) hits both; a second word or the facet separates them.
Virtual tags (computed by the product, such as done or overdue) are named in the options; the product passes the names that are true for an entry when matching, and they win over a real tag of the same name.
API
String layer (identical to CLINICO's shared/tags.ts): normalizeTag, checkTag, checkTagList, isCanonicalTag, splitTagText, formatTag, tagParent, tagAncestors, tagWithin, withAncestors, MAX_TAG_LENGTH, MAX_TAGS, TAG_SYNTAX_HINT.
Structured layer: parseTag(raw, v), storeKey(tag), displayTag(tag), tagsFromKeys(keys, v), ancestors(tag), tagStartsWith(tag, scope), tagContains(tag, term), missingFacets(tags, required).
Query layer: parseQuery(text, v, { virtual?, max?, hash? }), matchesQuery(query, tags, v, virtual?), matchesTerm(term, tags, v, virtual?), QUERY_MAX. Refusals come as German sentences ({ ok: false, message }).
Example: CLINICO (every tag is a plain path)
import { parseQuery, matchesQuery, tagsFromKeys } from "@skaile/forge-tags"
const vocab = { facets: [], mode: "prefix" } as const
const parsed = parseQuery("#bericht !#done | #orga", vocab, {
virtual: ["done", "due", "overdue", "today", "private"],
hash: "required",
})
if (parsed.ok) {
const tags = tagsFromKeys(task.tags, vocab)
matchesQuery(parsed.value, tags, vocab, task.status === "Done" ? ["done"] : [])
}Example: Epikrise (mandatory facets, infix)
import { displayTag, matchesQuery, missingFacets, parseQuery, storeKey, tagsFromKeys } from "@skaile/forge-tags"
const vocab = { facets: ["typ", "beruf", "setting", "zeit", "alter", "thema"], mode: "infix" } as const
const tags = tagsFromKeys(["beruf/ergotherapie", "thema/befunde/testpsychologie/iq", "zeit/frueh"], vocab)
const filter = parseQuery("befunde iq !zeit:spaet", vocab)
if (filter.ok) matchesQuery(filter.value, tags, vocab) // true
missingFacets(tags, ["typ", "beruf", "zeit", "alter"]) // ["typ", "alter"]
tags.map(displayTag) // ["beruf:ergotherapie", "thema:befunde/testpsychologie/iq", "zeit:frueh"]
tags.map(storeKey) // stored 1:1, no conversionDevelopment
npm test # vitest
npm run typecheck
npm run build # dist/index.mjs + dist/index.d.tsMIT
