Relatinator architecture
This document is for maintainers. It gives the reasons for the 3.x design, the checks behind the claims in the READMEs, and the known gaps. For the API, read the package READMEs:
Packages and modules
relatinator is the scoring library. It has no framework code.
| File | Contents |
|---|---|
src/index.ts |
The public types. It exports createModel(), classify(), and tokenize(). |
src/tokenize.ts |
tokenize(). It removes elisions, splits words, removes stopwords, and stems each word. It keeps a cache of stems. |
src/stopwords.ts |
The English stopword list. |
src/model.ts |
createModel(). It indexes the documents, weighs the terms, and ranks the results. createScorer() makes the index and scores queries. createModel() and classify() share it. |
src/classify.ts |
classify(). It scores a text against a fixed topic list with createScorer(). checkTopicTags() rejects equal tags for classify() and classifyWithLLM(). |
src/llm.ts |
The relatinator/llm entry point. classifyWithLLM(), the two judges logprobJudge() and jsonJudge(), and describeTopics(). It sends requests to an OpenAI-compatible chat endpoint with fetch. |
scripts/check-portability.js |
The portability check. Automated checks describes it. |
eval/classify.mjs |
The topic eval. It scores classify() or classifyWithLLM() against approved labels. Topics from a language model describes it. |
bench/bench.mjs |
The benchmark. Automated checks describes it. |
bench/corpus.js |
The synthetic posts of the benchmarks and of test/equivalence.test.ts. The same size and seed always give the same posts. |
astro-relatinator reads entries, makes the text of each entry, and gives the text to relatinator.
| File | Contents |
|---|---|
src/get-related.ts |
getRelated() for Astro content collections. |
src/get-live-related.ts |
getLiveRelated() for Astro live content collections. |
src/astro-live-module.d.ts |
The live collection types of astro:content for the build. Live collections describes them. |
src/emdash.ts |
getEmDashRelated() for EmDash collections. It keeps the last full read of each cache key for maxAge. This file is the astro-relatinator/emdash entry point. |
src/related-index.ts |
relatedIndex(), which all three helpers call. It makes the fingerprint, gets the model, and maps ids to entries. modelKey() makes the cache key. |
src/model-cache.ts |
getCachedModel(). It keeps one model for each cache key. |
src/entry-text.ts |
entryText(), the default text of an Astro entry, and the ContentEntry type. src/index.ts exports entryText(). |
src/emdash-text.ts |
emdashEntryText(), the default text of an EmDash entry. src/emdash.ts exports it. |
src/portable-text.ts |
portableTextToPlainText(). |
src/emdash-topics.ts |
suggestTerms() and topicText(). They score the terms of EmDash taxonomies with classify() and find the words that matched. They do not import emdash. |
src/emdash-plugin.ts |
The plugin descriptor. This file is the astro-relatinator/emdash-plugin entry point. |
src/emdash-plugin-entry.ts |
The route of the “Suggested topics” editor panel. This file is the astro-relatinator/emdash-plugin/sandbox entry point. The build bundles it into one file. |
src/integration.ts |
The optional Astro integration. It only warns about 2.x options. |
scripts/bundle-sandbox.js |
The bundle step of the plugin entry. Compiled JavaScript and type declarations gives the reason. |
scripts/check-sandbox.js |
The sandbox check. Automated checks describes it. |
bench/bench.mjs |
The benchmark of both helpers. Automated checks describes it. |
bench/hooks.mjs |
A module hook for the benchmark. It loads bench/store.mjs in place of emdash and astro:content. |
bench/store.mjs |
The in-memory collections of the benchmark. |
bench/workerd/ |
The scripts of the workerd measurement. No automated check runs them. |
Design decisions
Each model is a value, not global state
In version 2.x, the integration trained one model in the Astro config process. It used the astro:build:setup and astro:server:setup hooks. The library kept the model on globalThis, and pages read it from there.
This design works only when pages render in the same JavaScript runtime as the Astro config. In Astro 6 and 7, pages can render in a different runtime. For example, the Cloudflare adapter prerenders pages in workerd. A page in workerd cannot read a value that the config process put on globalThis.
In version 3.0, createModel() returns a model value. relatinator keeps no global state. Since version 3.1, its only mutable module state is the stem cache in src/tokenize.ts. This cache does not change an output. Performance without output changes describes it. astro-relatinator builds the model on the first call, in the runtime that renders the page. Its mutable module state is the model cache in src/model-cache.ts and the last full read of each cache key in src/emdash.ts. A freshness window, not a saved model describes the second.
Own scoring code with one runtime dependency
Version 2.x used natural for TF-IDF, and wink-nlp with the wink-eng-lite-web-model language model for BM25. A bundle of these packages was 3.98 MB. On Astro 5.3, the 2.x playground build failed with Maximum call stack size exceeded.
Version 3.0 has its own tokenizer and its own TF-IDF and BM25 code. The only runtime dependency is wink-porter2-stemmer. src/stopwords.ts is a copy of the English stopword list of wink-nlp-utils 2.1.0. The copy leaves out the entries with an apostrophe. These entries can never match a token, because tokenize() splits words at apostrophes.
The minified bundle of dist/index.js is 9.3 KB and has no imports. It does not import Node.js modules, so it runs in browsers and in Workers. The portability check enforces this.
The tokenizer steps, the formulas, and the ranking rules have one home, How scoring works in the relatinator README. test/model.test.ts compares the weights with hand-computed values. test/fixtures.test.ts checks that the TF-IDF top terms of a fixture post are the same as in 2.x.
Entry ids, not titles
Version 2.x read the Markdown files itself and used the title frontmatter field as the document id. The 2.x README and the 2.x playground queried the model with entry.id. They also matched the results against post.id. An entry id is not a title, so these lookups found no entries.
Version 3.0 uses entry.id for the documents, the queries, and the results. getRelated() returns entries, not ids, so a page does no lookup.
Read the collection at render time
getRelated() calls getCollection() each time that a page calls it. There is no build hook and no file watcher. In astro dev, an edit to a content file changes the digest of the entry. The next call then builds a new model.
getRelated() imports astro:content inside the function, not at the top of the module. The Astro config imports the package to get the integration, and astro:content does not exist at that time. A top-level import would make the config fail. getLiveRelated() imports astro:content in the same way. src/emdash.ts imports emdash inside the function for the same reason.
The integration is optional
Only Vite can resolve astro:content. Vite resolves it in the packages that it processes, but not in the packages that Node.js loads directly.
Astro 7.3.8 selects the packages for Vite with crawlFrameworkPkgs() from vitefu. The isFrameworkPkgByJson callback in astro/dist/core/create-vite.js selects a package if one of these conditions is true:
- The package has
astroinpeerDependenciesor independencies. - Its
keywordscontainastroorastro-component. - Its name starts with
astro-.
The callback skips a package that sets "astro": { "external": true } in its package.json.
astro-relatinator meets all three conditions. Thus Vite processes it in every Astro project, with or without the integration in the config. One condition is enough, but keep all three in package.json.
A negative control during the 3.0 work showed the failure. With the package marked external, the build failed with ERR_UNSUPPORTED_ESM_URL_SCHEME ... 'astro:'.
The integration now only logs a warning when it gets 2.x options. npx astro add astro-relatinator still adds the integration to the config. Users can keep it or remove it.
One cached model for each key
Both helpers call relatedIndex() in src/related-index.ts. relatedIndex() gets the model from getCachedModel() in src/model-cache.ts. The cache keeps one model for each key, with the fingerprint of the entries of that model. When the fingerprint for a key changes, the cache builds a new model and replaces the old one.
modelKey() in src/related-index.ts makes the cache key. The key has three parts:
- The corpus key. For Astro, this is the collection name. For an Astro live collection, it is
liveand the collection name. For EmDash, it isemdash, the collection name, and the locale. Thus collections of different types with the same name get different models, and each EmDash locale gets its own model. - The
method. - The text source. This is the
fieldsarray as JSON, or the source code of thetextfunction.
The locale of the EmDash key is the locale option. Without the option, it is the locale of the EmDash request context, from getRequestContext(). getEmDashCollection() uses the same locale. The resolveLocale() function in emdash 1.3.1 src/i18n/resolve.ts selects an explicit locale first, then the locale of the request context, then the default locale when the site has more than one locale. The middleware in src/astro/middleware/request-context.ts puts the locale in the context only for a request with a preview token or the edit-mode cookie. Without a locale in the key, a preview in a different locale and a normal request built a new model each time they followed each other. The default locale does not change in a process, so the key does not need it.
limit and filter are not a part of the key. They change only the results, not the model. filter gets the entry of each id through the id-to-entry map of relatedIndex(). The model applies it before limit, so the result still has limit entries when enough entries match.
The fingerprint is a list of strings. It contains the id and then the version of each entry, in collection order. getCachedModel() compares the new list with the stored list, one item at a time. If the lengths are different or one item is different, the fingerprint changed. The version depends on the helper and the options:
| Call | Version of an entry |
|---|---|
getRelated(), the entry has a digest |
The digest |
getRelated(), the entry has no digest |
The default text of the entry |
getRelated() with text |
The text that text returns |
getLiveRelated() |
The default text, or the text that text returns |
getEmDashRelated() |
The default text, or the text that text returns |
With text, the version is the text that text returns. The cache key holds only the source code of text. Two closures with the same source code can return different text. The returned text tells them apart.
getEmDashRelated() always uses the text as the version. EmDash does not change updatedAt when only the terms or the bylines of an entry change. The text changes when a term label changes, so the model stays current.
Versions 3.0.0 and 3.0.1 joined the fingerprint into one string. They put U+0000 between an id and its version, and U+0001 between two entries. When an id or a text contained one of these characters, two different collections could give the same string. The cache then kept a model that was out of date. The item-by-item compare uses no separator. Two fingerprints are equal only when each id and each version are equal.
The cache never removes a key. Each set of options that a site uses keeps one model for the life of the process or isolate.
Live collections get their own helper
getLiveRelated() is a separate function from getRelated(). In the types that Astro 7.3.8 generates (templates/content/types.d.ts), the two kinds of collections have different keys and different entries:
getCollection()acceptskeyof DataEntryMap.getLiveCollection()acceptskeyof LiveContentConfig['collections']. These are the collections ofsrc/content.config.tsand ofsrc/live.config.ts.- At runtime,
getCollection()throws an error for the name of a live collection.getLiveCollection()returns an error for the name of a content collection (createGetCollection()andcreateGetLiveCollection()indist/content/runtime.js). getLiveCollection()returns{ entries, error, cacheHint }, not an array. A live entry is aLiveDataEntry, withid,data,rendered, andcacheHint. It has nodigestand nobody. Astro 6.0.0 has the sameLiveDataEntrytype (dist/types/public/content.d.ts:145-156of the npm package).
One function for both kinds would need a union of both key types and a conditional result type. It would also need two read paths. Two functions keep the types of getRelated() the same.
getLiveRelated() uses the text as the version, because a live entry has no digest. The default text is the text of data, and then the words of rendered.html. A loader can return the content of an entry as HTML in rendered.html (LiveDataEntry in dist/types/public/content.d.ts of Astro 7.3.8 and 6.0.0). This is the place of body in a content collection. getLiveRelated() removes the tags and the content of script and style elements. It decodes the numeric entities and six common named entities, and it changes each other named entity, such as ’, to a space. Without this step, the tag names and the entity names, such as strong, amp, and rsquo, relate entries that share no words. The step uses three regular expressions, not an HTML parser, to keep the package small. It keeps the content of the other elements, such as template. A > or a <script> in the text of an attribute can leave part of a tag in the text or remove the words up to the next </script>.
The result type is LiveDataEntry<LiveLoaderDataType<C>>. This is the entry type of getLiveCollection(C) in the generated types. LiveLoaderDataType is a type of the generated astro:content module without the export keyword. TypeScript exports all declarations of an ambient module, so a package can import it. Astro 6.0.0 has the same type (templates/content/types.d.ts:137 of the npm package). The placeholder types of astro/client (types/content.d.ts) have no LiveContentConfig and no LiveLoaderDataType. For the build, src/astro-live-module.d.ts adds general forms of both types. tsc does not copy this file to dist. In a project, the generated types replace the placeholder, and the import gets the real types. Without src/live.config.ts, LiveContentConfig is never, and getRelated() users see no change.
An instantiation expression, typeof getLiveCollection<C>, would need only the exported function. The placeholder declares getLiveCollection as (...args: any[]) => any, and TypeScript rejects type arguments for this type (TS2635). Thus the build cannot use it.
test/live-types/ is a separate TypeScript project for the type test. Its astro-content.d.ts has the generated helper types of Astro 7.3.8 and a live.config.ts with a loader type and a schema. test/tsconfig.json excludes this directory, because its declarations conflict with src/astro-live-module.d.ts. pnpm check runs both projects.
Astro documents live collections for pages that render on demand. The runtime of Astro 7.3.8 has no prerender check in getLiveCollection(). In the manual check, a prerendered page called getLiveRelated() at build time, also without an adapter.
The freshness window of getEmDashRelated() uses the same key. A freshness window, not a saved model describes it.
EmDash support is a site helper, not a plugin
EmDash runs sandboxed plugins with default limits of 50 ms CPU time and 10 subrequests for each invocation. The ResourceLimits type in emdash/src/plugins/sandbox/types.ts (emdash 1.2.0) documents these defaults. A related-content model must read the whole collection, and that work does not fit these limits. Also, a theme cannot read the storage of a plugin directly.
getEmDashRelated() runs in the pages of the site. There it reads the collection with getEmDashCollection(), which works on every runtime that EmDash supports.
getEmDashRelated() reads published entries in pages of 100. It follows nextCursor until EmDash returns no cursor. It does not use hasMore, because emdash 1.0.0 does not return it. emdash 1.0.1 added the hasMore field.
The default text leaves out the keys that EmDash adds to entry.data. SYSTEM_KEYS in src/emdash-text.ts is the list. The comment above SYSTEM_KEYS names the emdash 1.2.0 source files that the list comes from. From terms, the default text reads only the label of each term. EmDash options in the README shows the list to users.
Without this rule, the text contains the bylines and the ids, and posts by the same author look related. In the manual EmDash check, before this rule, the related list of the black-holes post showed the sourdough post.
getEmDashRelated() returns entries from the corpus, not the entry that the page gives. Its type parameter D is the type of data in the published entries, and the result type is EmDashEntry<D>[]. The entry parameter is string | EmDashEntry<D> | EmDashEntry<Partial<D>>. With EmDashEntry<Partial<D>>, a page can give only some of the keys of D. The EmDashEntry<D> member accepts no other values, but it changes inference. When TypeScript gets D only through Partial<D>, it removes the ? modifier from each key. The text option gets EmDashEntry<Partial<D>>, because it also reads the entry object of the page. Version 3.0.0 used the type of the entry object as the result type.
The model contains only published entries. rank() in src/emdash.ts selects the query. EmDash uses the slug as the entry id, with a locale prefix when i18n routing adds one. entryIdForRow() in the emdash 1.3.1 loader.ts makes this id. A draft or preview revision takes its id from the revision slug, if the revision has one (revisionEntry() in loader.ts). A draft stages a slug change as _slug in the revision, and the content row keeps the old slug until the draft is published (emdash-runtime.ts and createSlugChangeRedirect() in api/handlers/content.ts).
revisionEntry() copies the system columns of the content row to entry.data, also the id column. Thus a draft or a preview has the same data.id as the published entry of the same row, also with a new slug. In emdash 1.0.0, the preview branch of loadEntry() in src/loader.ts does the same with INCLUDE_IN_DATA. Each locale of an entry is a separate row with its own id. The rows of the translations share translationGroup, not id.
- If the page gave a string,
rank()callsfindRelated(id). If the id is not in the model,getEmDashRelated()throws an error. - If the page gave an entry object,
rank()callsfindRelatedToText()with the text of the object. It excludes the id of the object and each corpus entry with the samedata.id. These are the published entry itself, a preview with changes, a draft with a new slug, and a draft that was never published.
For an object with the published text, findRelatedToText() gives the same list as findRelated(id). astro-relatinator 3.1.0 calls findRelated(id) for an object whose id is in the model. Both functions tokenize the same text, and they sum the terms in the same order. These functions are in packages/relatinator/src/model.ts:
score()increateScorer()sums the terms in the order of the query.- For
findRelated(), the query is the indexed document.termCounter()keeps its terms in the order of their first occurrence. - For
findRelatedToText(),queryOf()makes the query. It keeps the order ofcountTerms(), which is also the order of the first occurrence.
The tests “ranks each published entry object as its id, with ties and limit %s” and “ranks each published entry object as its id, with close scores and limit %s” in test/emdash.test.ts check this, with limit 6 and 100. In the corpus with close scores, a different order of the terms in the sum changes the list. The cost is one text and one tokenization of the object for each call.
In emdash 1.3.1, the collection read of a preview request serves the draft of the previewed entry, and the read of an edit-mode request serves all drafts (draftRevisions in getEmDashCollectionUncached() in query.ts, and loadDraftRevisions() in loader.ts). A call in these requests does not use a window. Thus the corpus contains the draft text, and the object has the same text. The text of the object changes the list in these cases:
- With emdash 1.0.0. Its middleware also sets
previewandeditModein the request context (src/astro/middleware/request-context.ts). But its collection read does not serve drafts (getEmDashCollectionUncached()insrc/query.tsandloadCollection()insrc/loader.ts). OnlygetEmDashEntry()serves the draft of a preview, throughloadEntry()insrc/loader.ts. Thus the object of a preview has a different text than the corpus. - In a request that is not a preview or edit mode, when the object has a different text than the corpus. Examples are an object that the page changes, and an entry that changed after the read of a
maxAgewindow.
A freshness window, not a saved model
On EmDash, most of the time of a call with a cached model is the change check, not the model. The workerd measurement gives these values for 1,005 posts:
- A first call took 1,042 ms. The model build took 831 ms of this time.
- A later call took 274 ms. The read of the collection took 251 ms, and the text for the fingerprint took 18 ms.
findRelated()took 2 to 3 ms. - The same page without the call took 37 ms.
A saved model, for example in Workers KV, removes only the model build of a first call. A later call still reads the collection and makes the text of each entry, because the change check needs them. Thus a saved model does not make the 274 ms shorter. A saved model also needs a serialized format, a version check of that format, and a storage binding. The package does not have a saved model.
maxAge removes the change check for a number of seconds. In the second run of the measurement, a later call inside a 60-second window took 2 ms. The page took 12 ms, the same time as the page without the call. With maxAge 0, the later call took 215 ms.
These rules apply:
- The default is 0. A call with
maxAge0 reads the collection and makes the text of each entry, as before. Its output does not change. - The window state is the
recentReadsmap insrc/emdash.ts. For each cache key, it keeps the time of the last full read and the index of that read: the model and the entries. Thus the model, the result entries, and the entries thatfiltergets come from the same read.getCachedModel()does not change. - The key of the map is the key of the model cache, from
modelKey(). Thus one function makes both keys. A call for a different collection, locale,method, or text source does not use the window. - A call with
maxAgeabove 0 keeps its full read. A call withmaxAge0 updates the read only if the key already has one. Thus a site withoutmaxAgekeeps no entries in memory. - A call in a preview request or in edit mode does not use a window and does not keep its read. In emdash 1.3.1, the read of a preview request contains the draft of the previewed entry, and the read in edit mode contains all drafts. A window with such a read would show drafts on public pages. The call reads
previewandeditModefromgetRequestContext(). The test “does not start a window in edit mode” intest/emdash.test.tsshowed the leak before this rule: after an edit-mode read, a public call inside the window returned the list of the draft. Other tests in the same file check a preview or edit-mode call while a public window exists, withmaxAge0 and 60. - The window starts when the read starts. A call inside the window does not move it. At exactly
maxAgeseconds, the call reads the collection again. - In a request that is not a preview or edit mode, an entry object uses the model of the window and its own text, as without a window.
- A
maxAgevalue that is not a number from 0 causes aRangeError, as theratioofclassify()does.Infinityis valid. It keeps the first read for the life of the process or isolate. - The clock is
Date.now(). In workerd,Date.now()changes only at I/O, so it does not change while code runs. It changes between requests, so the window ends between requests. - Each process or isolate has its own window. The package does not share a window between isolates.
The window does not compare texts. Two text functions with the same source code have the same cache key. Inside a window, the second function gets the model of the first. Without a window, the fingerprint tells them apart.
getRelated() has no maxAge. The digest of each entry makes its change check fast. In the 3.1.0 benchmark, a later call took 0.65 ms for 1,005 posts.
Compiled JavaScript and type declarations
Version 2.x of astro-relatinator published its TypeScript source, integration.ts. In version 3.0, both packages publish dist/*.js and dist/*.d.ts files that tsc makes. The prepack script runs the build, so pnpm pack and pnpm publish always include new output.
JavaScript loads in every runtime and tool without a TypeScript step. Node.js does not strip types from files in node_modules. Node.js 24.13.1 stops with ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING. Also, the compiler options of a consumer do not apply to the source of the package.
relatinator 2.x used Vite library mode and dts-bundle-generator. Version 3.0 uses only tsc, which makes one output file for each source file.
One output file is an exception. In sandboxed: [], EmDash reads the entry file of a plugin with readFileSync() and gives the code to the sandbox runner as one module (emdash 1.3.1 generateSandboxedPluginsModule() in virtual-modules.ts). The Cloudflare runner loads this code as the sandbox-plugin.js module of a Worker Loader worker. That worker has no other module, so an import in the code fails. Thus, after tsc, scripts/bundle-sandbox.js bundles src/emdash-plugin-entry.ts with esbuild and writes over dist/emdash-plugin-entry.js. The bundle contains relatinator, wink-porter2-stemmer, and the other files of the package. Its format is ES modules, its platform is neutral, and its target is the target of tsconfig.json. tsc still makes dist/emdash-plugin-entry.d.ts. The official EmDash plugins also publish one file with no imports for their ./sandbox export, made by @emdash-cms/plugin-cli. The package does not use @emdash-cms/plugin-cli, because it builds the whole package with its own tools.
EmDash finds the entry file with require.resolve() from the site (resolveModulePathFromProject() in virtual-modules.ts). This call does not match the import condition of an exports map. Thus the ./emdash-plugin/sandbox export has a default condition.
TypeScript 6.0.3 in every package
The playground runs astro check (@astrojs/check 0.9.10). astro check uses the TypeScript JavaScript API, and TypeScript 7 does not have this API. All packages pin typescript to 6.0.3, so one compiler version checks the whole workspace.
Performance without output changes
Version 3.1 makes both libraries faster without a change to their outputs. The rule is strict. For each input and each option, relatinator 3.1 must give the same output as 3.0.0. The ids, their order, the scores, and the errors must be the same. Two differences are known. Both are for invalid method values, which TypeScript rejects. For the 9 names of Object.prototype methods, such as "constructor", createModel() now throws the same TypeError as for each other invalid value. Version 3.0.0 returned a model, and a query that weighed a term threw TypeError: weight is not a function. For "__defineGetter__" and "__defineSetter__", createModel() throws a TypeError with a different message. The 3.1.0 changelog describes both differences.
src/model.ts keeps the weights in an inverted index:
indexCorpus()gives each term a number, in the order of its first occurrence. Each document keeps its terms as these numbers, with the count of each term.buildPostings()puts the documents of all terms in oneInt32Arrayand their weights in oneFloat64Array. Theoffsetsarray gives the slots of each term. The function calculates each TF-IDF or BM25 weight one time, with the 3.0.0 formula.- A query reads only the slots of its terms. It multiplies each weight by the count of the term in the query. Version 3.0.0 calculated the weight of each query term for each document. Each document adds its terms in query order, as in 3.0.0. Thus the floating-point totals are the same.
- For an integer
limitfrom 0 to 64,topIndices()keeps a sorted list of the bestlimitdocuments. A new document goes after the documents with an equal score. Thus equal scores keep the input order, as the stable sort of 3.0.0 does. Each insert costs time in proportion tolimit, so larger limits use the 3.0.0 sort. TwoInfinityscores are equal in both paths. The sort comparator givesNaNfor them, and the sort uses+0for aNaNresult.
src/tokenize.ts stems each word one time:
tokenOf()keeps the stem of each word in a module-levelMap. A word that occurs again does not go through the stemmer again. The map holds at most 100,000 words, andtokenOf()clears it when it is full. The limit counts the words, not the bytes. In a measurement during the 3.1 work, each word used about 110 to 120 bytes, so a full map used about 14 MB. This value is typical, not a maximum. Very long words use more memory.- The keys of the map are copies of the words. A slice of a long text can keep the whole text in memory, but a copy does not.
tokenize()reads the text one code point at a time. ASCII letters and digits do not need a regular expression. Other code points use a regular expression with the\p{L}and\p{N}classes, as in 3.0.0.
astro-relatinator makes less temporary data on each call:
getCachedModel()compares the fingerprint item by item. One cached model for each key describes the compare. Versions 3.0.0 and 3.0.1 joined the fingerprint into one string on each call. In the benchmark with 1,005 EmDash posts, this string had 9.7 million characters, or about 19 MB.portableTextToPlainText()adds the text of each span and each block to one string. The default EmDash text copies the content keys and the term labels directly. Version 3.0.1 made temporary arrays in these steps.
Three checks enforce the rule:
test/equivalence.test.tscompares the outputs ofsrcwithrelatinator-reference. This development dependency isrelatinator3.0.0 from npm (npm:[email protected]). The test compares withisDeepStrictEqual(), so each score must be the same number. Automated checks describes the inputs.- The
relatinatorbenchmark makes a checksum of the outputs of each run. Its last line tells if the checksums of 3.1 and 3.0.0 agree. - Checks that ran one time during the 3.1 work compared more inputs. Manual checks during the 3.1 work lists them.
After these changes, tokenize() takes most of the build time. In the benchmark on 2026-10-16, tokenize() for 1,005 posts took 0.29 s, and createModel() took 0.34 s. Thus a faster build needs a faster tokenize().
Filters apply before the limit
Version 3.3 adds filter to both queries, and exclude to findRelated(). rank() in src/model.ts gets one function that keeps an id or not. It removes the query document, the ids in exclude, and the ids that filter does not keep. topIndices() calls it only for a document that has a score above 0 and that enters the top list. Thus a filtered list still has limit ids when enough documents match. Without exclude and filter, the function removes only the query document, as the Set of 3.0.0 did. The equivalence test with 3.0.0 still passes.
Scores and shared terms from the ranking code
Version 3.3 adds findRelatedScored(), findRelatedToTextScored(), and explainRelated(). They show why two documents are related. rank() in src/model.ts now returns the indices of the ranked documents, not their ids. Each query and its scored form use the same rank() call. The query changes the indices to ids. The scored form also reads the score of each index from the score array of the query. Thus the scored form has the same ids, the same order, and the same floating-point scores as the ranking.
explainRelated(id, otherId) reads the terms of document id. For each term that otherId also contains, it multiplies the count of the term in id by the weight of the term in otherId. The weight comes from the same function as the weight in the postings. These products are the summands of the score of otherId. explainRelated() sorts them as getTopTerms() does, so their sum can differ from the score in the last digits. test/model.test.ts compares the sum with the score with toBeCloseTo().
Version 3.0.0 does not have the three methods. The equivalence test ignores them in its check of the model keys, and its probes do not call them. The outputs of the other members do not change.
Topics from a fixed list, scored by their descriptions
classify() chooses topics from a list that a person writes. It does not make new tags. Issue #6 gives the reasons. In short, tags made from the words of a post were rare words or noise, and they repeated what related posts already do.
Each topic is one BM25 document with the tag and the description. classify() uses createScorer() from src/model.ts, so it has no second BM25 formula. The design differs from the first sketch in issue #6 in two ways. An eval on 2026-10-17 showed the need for both changes:
- No posts in the topic documents. The sketch added all posts with the topic to the topic document. Then the topic with the longest text won. In the eval, one topic had 13,881 tokens and was first for 15 of 37 posts, but only 6 posts had it. With posts in the documents, the descriptions changed 1 of 63 labels.
- A relative cut, not a minimum score. A BM25 score is a sum over the terms of the query, so it grows with the length of the text. In the eval, the best score went from 16 for a post with 130 characters to 1,883 for a post with 42,767 characters. With a fixed
minScorefrom 5 to 80, the precision stayed at 0.16 to 0.17.classify()divides each score by the best score and keeps the topics atratioor more.
The eval used the 37 posts of darko.io and 11 topics with 63 labels that a person approved. Two language models wrote the descriptions from the topic names only, without the posts. With ratio 0.5 and limit 3, the precision was 0.70 and 0.73, and the recall was 0.70 and 0.71. Without descriptions, the precision was 0.73 and the recall was 0.52. The first sketch, with the best 2 topics of each post, gave a precision of 0.58 and a recall of 0.68.
The eval also added a vote of the 3 nearest tagged posts to the description score. With the best 2 topics of each post, the vote found 3 to 5 more correct labels of 63. With ratio 0.5, it found 8 to 10 more, but the precision fell. Without the vote, the precision was 0.67 to 0.73. With the vote, it was 0.58 to 0.61. The vote also needs all tagged posts for each call, so classify() does not have it.
A second test on 2026-10-18 measured the effect of the topic list. Two language models, Claude and GPT, each read 38 posts without the 63 labels. The 38 posts were the 37 posts of darko.io and 1 post of blog.wts.sh. Each model made its own topic list with 13 or 14 topics and tagged the posts. Pair F1 compares two tag sets with different topic lists. It counts the pairs of posts that share a topic in both sets.
| Tag sets | Pair F1 |
|---|---|
| The 63 labels and Claude | 0.65 |
| The 63 labels and GPT | 0.67 |
| Claude and GPT | 0.79 |
classify() with the first topic list and GPT |
0.47 |
classify() with the Claude topic list and GPT |
0.71 |
classify() with the Claude topic list and Claude |
0.70 |
With the Claude topic list, classify() agreed with GPT as well as two taggers agree with each other. Against the Claude labels, the precision and the recall were both 0.79. The Claude descriptions named the projects, tools, and events of the posts, such as “BeerJS Skopje” and “Coolify and Hetzner”. The first descriptions had general words. The README section “Best practices for topic lists” comes from this result.
Two other methods did not help, and relatinator does not have them:
- Pseudo-relevance feedback. This method added the top terms of the posts that matched a topic best to its description. The mean F1 was 0.67 without it and 0.54 to 0.68 with it. The feedback posts were often wrong, and the top terms of a post were words of that post only.
- A part-of-speech filter. A filter with the compromise library kept only nouns. It made topics from the words of the posts better, from a mean best F1 of 0.68 to 0.73. But it adds 120 KB (gzipped) and works only with English.
Topics from a language model, as an option
relatinator/llm is a separate entry point. It asks a language model to choose topics from the same fixed list as classify(). It is experimental. Later tests can change its names, defaults, and scores, and a minor release can ship those changes. classify() stays the default, because it makes no network calls and runs in every runtime. relatinator/llm needs a chat endpoint and takes about 1 to 6 seconds for each post. Use it at build time, in a script, or in an editor panel. Do not use it in the request path of a page.
One interface for a local model and a hosted model. A site can call a local server, such as llama.cpp, or a hosted API with a key. ChatEndpoint covers both: a base URL, a model name, an optional key, extra headers, and extra body fields. TopicJudge is a function that gives a score for each topic. classifyWithLLM() takes any judge. The entry point has no dependencies and uses fetch only.
The score is log-odds, not a probability. Each judge gives the log-odds of “yes” over “no” for each topic. A score of 0 means that both answers are equally likely, and a positive score means that the topic applies. For most answers, the models gave a probability of 0 or 1, so a probability threshold had almost no effect. The log-odds still put these answers in order. The default rule is “score 0 or more, at most 2 topics”. On average, the 63 labels give 1.7 topics to each post.
Two judges. logprobJudge() follows the noul question of ruling. It asks one yes/no question for each topic and reads the logprobs of the answer letters A and B. It asks each question in both letter orders and uses the mean of the two margins. It needs an endpoint that returns top_logprobs. jsonJudge() asks for a JSON object with a probability for each topic, in one request. It works with reasoning models and with APIs that give no logprobs. For example, the free models of OpenCode Go gave no logprobs.
No BM25 shortlist. A cascade sent only the best 3 or 5 topics of classify() to the model. The cascade never scored higher than the model with all 11 topics.
Descriptions from the posts. describeTopics() writes a description for each tag from the titles and the summaries of the posts. It does not read the labels. For classify() and for the language model, these descriptions scored higher than descriptions from the tag names only.
The eval on 2026-11-02 used the 37 posts and the 63 labels of the eval on 2026-10-17. These labels are the gold labels of this eval. Each topic list has the same 11 tags with different descriptions:
- Clean names: a model wrote the descriptions from the tags only, two times.
- Clean corpus:
describeTopics()wrote the descriptions from the tags, titles, and summaries, two times. - Mine: the descriptions of 2026-10-17. They come from the same session as the labels, so they can leak the labels.
The F1 values below use the rule “score 0 or more, at most 2 topics”. classify() uses its defaults, ratio 0.5 and limit 3:
| Method | Clean corpus | Clean names | Mine |
|---|---|---|---|
classify() |
0.68, 0.69 | 0.57, 0.60 | 0.67 |
jsonJudge(), beam-free (OpenCode Go) |
0.85, 0.81 | 0.69, 0.71 | 0.84 |
logprobJudge(), Qwen3 4B Q4_K_M |
0.85, 0.82 | 0.72, 0.71 | 0.81 |
A second set of runs looked for the smallest model that works. All models ran in llama.cpp on an RTX 4070 laptop GPU with 8 GB. AUC is the chance that a correct post and topic pair scores higher than a wrong pair:
Model with logprobJudge() |
Size on disk | F1, clean corpus | AUC, clean corpus | Time for 37 posts |
|---|---|---|---|---|
| Qwen3 0.6B Q8_0 | 0.6 GB | 0.66, 0.67 | 0.86 | 29 to 35 s |
| Qwen3.5 0.8B Q8_0 | 0.8 GB | 0.63, 0.70 | 0.84 | 64 to 67 s |
| Qwen3 1.7B Q8_0 | 1.7 GB | 0.73, 0.75 | 0.95, 0.96 | 34 to 42 s |
| Qwen3.5 2B UD-Q8_K_XL | 2.7 GB | 0.76, 0.75 | 0.96, 0.95 | 95 to 116 s |
| Gemma 4 E2B UD-Q4_K_XL | 3.0 GB | 0.81, 0.78 | 0.97, 0.96 | 73 to 77 s |
| Qwen3 4B Instruct Q4_K_M | 2.5 GB | 0.85, 0.82 | 0.97, 0.96 | 38 to 66 s |
The results:
- A model under 1B does not score higher than
classify(). A model of about 2B parameters is the smallest useful size. Qwen3 4B is the best choice of the models in the test. - The descriptions still have the largest effect. Descriptions from the titles and summaries added 0.10 to 0.14 F1 to the 4B model.
- The best cut changes with the model. For Qwen3.5 2B, it was near 1. For Gemma 4 E2B, it was near 3. For Qwen3 4B, it was from -13 to 19 for the different topic lists.
minScoresets the cut. - Two runs of beam-free at temperature 0 gave the same result for 400 of 407 threshold decisions.
- In the
jsonJudge()runs, beam-free wrote the clean descriptions and also judged the posts. A model can prefer its own words. ThelogprobJudge()runs with Qwen3 4B used the same descriptions and gave about the same F1. No run used descriptions from a second writer model. - The eval has only 63 labels, so a difference of 0.03 F1 or less is noise.
To repeat the eval, run pnpm --filter relatinator eval -- with the paths of the posts, the labels, and the topic list. The posts and the labels are not in the repository. For a llama.cpp server, add --body '{"cache_prompt":true}'. For Qwen3.5 models, also set "chat_template_kwargs":{"enable_thinking":false}. Then the first token is the answer letter. On 2026-11-02, the script gave the same F1 as the spike scripts: 0.68 for classify() and 0.85 for Qwen3 4B on the first clean corpus list. jsonJudge() with beam-free gave 0.84, against 0.85 in the spike. The first jsonJudge() run stopped after 15 of 37 posts, because beam-free sent an empty reply. The spike scripts asked again up to 4 times, so the spike did not show this failure. jsonJudge() now asks again up to 3 times when a reply has no JSON object.
EmDash terms as topics, in an editor panel
The astro-relatinator/emdash-plugin plugin suggests taxonomy terms for a saved entry. It closes the item “EmDash terms are not topics yet” of issue #6. It does not change the site helper. EmDash support is a site helper, not a plugin still applies to related content.
The plugin is different from related content in three ways. It reads one entry and the terms of its taxonomies, not the whole collection. It writes only when a user selects terms. Its result is for the user, not for a page of the site.
The standard format. The descriptor sets format: "standard". The entry file default-exports { routes }, and EmDash calls each route with (routeCtx, ctx). emdash 1.3.1 accepts a standard plugin in plugins: [] and in sandboxed: []. A native plugin can run only in plugins: []. A standard plugin cannot get options from the descriptor. The options field of PluginDescriptor is for native plugins only. Thus the only option is collections, which goes into the descriptor itself.
The plugin runs in plugins: [] and in sandboxed: []. For sandboxed: [], the entry file must be one module with no imports. Compiled JavaScript and type declarations tells how the build makes it. In the sandbox, the bridge of the runner gives ctx.content and ctx.taxonomies to the route, and it checks the capabilities of the descriptor. taxonomies:write includes taxonomies:read (PLUGIN_CAPABILITY_IMPLICATIONS in emdash 1.3.1 plugins/types.ts).
The route permission. A route without a permission requires plugins:manage. The panel route sets content:edit_own, so the users with this permission can use it. Contributors have only content:edit_own_drafts and cannot use it. EmDash then checks that the user can edit the entry (requireOwnerPerm() in http-route-dispatch.ts). The permission is in two places. The entry route has it for plugins: [], and the descriptor routes list has it for sandboxed: [].
The route reads the entry from routeCtx.ui.entry. EmDash sets this value only for a request through the editor panel endpoint, after the checks. A direct call to the plugin route has no ui.entry, and the route returns status 400. The route also does not trust the submitted term ids. It adds only the ids that are terms of that taxonomy and that the entry does not have yet.
Tag = slug. Each term becomes one topic. The tag is the term slug. EmDash keeps the slug unique for each taxonomy and locale, but two terms can have the same label. classify() throws an error for two equal tags, so the label cannot be the tag. The description is the label and data.description, because the label is the best short description of a term. classify() runs one time for each taxonomy. Thus the same slug in two taxonomies does not give equal tags, and the relative cut compares only the terms of one taxonomy.
No labels of the assigned terms in the text. The default EmDash text contains the labels of the terms of the entry. EmDash support is a site helper, not a plugin gives the reason. For suggestions, these labels are wrong. Each assigned term matches its own label and can get the best score. The relative cut then removes the other terms, and the panel removes the assigned term. The result is no suggestion. topicText() removes terms from the data before it makes the default text. In emdash 1.3.1, ctx.content.get() does not put terms into the data. The rule keeps the text correct if a later EmDash version adds it.
The assigned terms stay in the classify() call. The panel removes them from the result. Thus the cut is the same as for an entry without terms, and an assigned term can use one of the 3 places of limit.
Matched words, not a percent. Each suggestion shows the term label and up to 3 words of the entry that matched the term, for example “supertokens: supertokens, authenticated, providers”. The first version of the panel showed the score as a percent of the best score in the taxonomy. A test on 2026-10-19 used a copy of darko.io with 37 posts and 80 tag terms with descriptions. The percent did not show a bad suggestion. The best term always has 100%. For a post about a related-post classifier, the panel showed “guest (100%)”. The matched words made good and bad suggestions clear:
- supertokens: supertokens, authenticated, providers (a post about authentication)
- guest: post, blog, published (the post about a related-post classifier)
The words also show which description to change. The words of “guest” are general words that most blog posts contain.
The words come from the same text and the same weights as the score. suggestTerms() builds a model of the topic documents of one taxonomy with createModel(). These are the documents of classify(), so each term has the weight that gave the score. The part of a term in the score is its count in the text times its weight in the topic document. The words are the 3 terms with the largest parts, and for equal parts, the first stem in alphabetical order. The text is the text of topicText(), without the labels of the assigned terms. Each stem shows as the first word of the text with that stem, in lower case. The words follow the split rule of tokenize(). tokenize() removes elisions before it splits, so “won’t” gives the stem “wo”. Thus the words split the text at each character that is not a letter, a digit, or an apostrophe. They split a piece at its apostrophes only when the piece gives more than one token. The word “won’t” then shows as “won’t”, not as “wo”. An ending such as “’s” goes away when the word without it gives the same stem, so “conference’s” shows as “conference”.
A first prototype added the text to the topic model as one more document and called explainRelated(). That document changes the weights of the terms. On the darko.io data, the two methods gave 92 suggestions. The prototype gave different words for 4 of them, and the same words in a different order for 9. The model of the topic documents has no document for the text, so it needs no id for the text. Thus the text cannot collide with a term slug.
A Block Kit checkbox option has only label and value in emdash 1.3.1 (CheckboxElement in @emdash-cms/blocks). Thus the words go into the label, after a colon. This change adds createModel() to the sandbox bundle. The bundle grew from 26306 to 31941 bytes.
Taxonomies only. The plugin reads the terms of the taxonomies of the collection. It does not read other fields. darko.io kept its tags in a multiSelect field. On that site, the panel showed “No terms” until the tags were terms of a taxonomy. The README tells sites to move the tags to a taxonomy and to read the terms in their templates.
Verification
This section lists the checks behind the claims in the READMEs. There are two types of checks:
- Automated checks are scripts in this repository. No CI runs them. Run them by hand before a release.
- Manual checks ran one time, during the 3.0 work on 2026-10-15, during the 3.1 work on 2026-10-16, and during the topic plugin work, the live collection work, the workerd measurement, the preview work, and the sandbox work on 2026-10-19. Only the workerd measurement has scripts in the repository, in
packages/astro-relatinator/bench/workerd. The procedures below repeat the consumer builds, the Cloudflare check, the EmDash check, the live collection check, the preview check, the workerd measurement, and the sandbox check. The negative control, the 2.x baseline, the 3.1 checks, and the topic plugin checks have no written procedure.
Automated checks
Run these commands from the repository root. The results are from 2026-10-16, with Node.js 24.13.1 and pnpm 11.24.0. A result that gives another date is from that date.
| Command | What it checks | Result on 2026-10-16 |
|---|---|---|
pnpm test |
Runs the Vitest tests. relatinator tests the tokenizer, the weights, the ranking, the scores and shared terms, classify(), relatinator/llm with a fake fetch, fixture posts, and the equivalence with 3.0.0. astro-relatinator tests the three helpers, the text rules and the exported text functions, the cache, the maxAge window, the types of getEmDashRelated() and getLiveRelated(), and the topic plugin. |
220 tests in relatinator on 2026-11-02 and 127 in astro-relatinator on 2026-10-19. test/classify.test.ts has 19 of them, and test/llm.test.ts has 17. |
pnpm --filter relatinator test equivalence |
Runs only test/equivalence.test.ts. It compares the results, the scores, the tie order, and the errors of src with relatinator 3.0.0. The inputs are synthetic corpora, the fixture posts, the playground posts, seven option sets, limit values next to 64, odd limit values, more than 100,000 different words, and strings with Unicode edge cases. |
63 tests. |
pnpm check |
tsc checks the source and the tests of both libraries, also the test/live-types project. astro check checks the playground. |
No errors. astro check reports 0 errors. |
pnpm --filter relatinator test:portability |
Builds relatinator and bundles dist/index.js and dist/llm.js for the neutral platform with esbuild. It fails if a bundle keeps an import or is larger than 60 KB. |
dist/index.js 10153 bytes (9.9 KB) and dist/llm.js 3681 bytes (3.6 KB), no imports, on 2026-11-02. |
pnpm --filter astro-relatinator test:sandbox |
Builds astro-relatinator. Then it bundles dist/emdash-plugin-entry.js again with esbuild, with each import as external, and fails if the file has an import, an export ... from, an import(), or a require(). It also fails if require.resolve("astro-relatinator/emdash-plugin/sandbox") gives another file, or if the file does not default-export the panel route. prepack runs it. |
31941 bytes, no imports, on 2026-10-19. On the tsc output without the bundle step, it failed with imports other modules: ./emdash-topics.js. |
pnpm build |
Builds both libraries and the playground. | The playground has 7 pages. |
pnpm -r --if-present run format:check |
Runs Prettier in both libraries. | No changes. Each package prints one deprecation warning. |
pnpm --filter relatinator bench --impl both --sizes 1005 |
Builds relatinator. Then it times the model build, the queries, and tokenize() of 3.1 and 3.0.0 on 1,005 synthetic posts, in 5 runs. Each run starts a new Node.js process. The last line tells if the output checksums of all runs agree. |
The checksums agree, and no call failed. The 3.1.0 changelog gives the times. |
pnpm --filter astro-relatinator bench |
Builds both libraries. Then it calls getEmDashRelated() and getRelated() on 1,005 synthetic posts in memory, in 5 runs. It times the first call, the later calls, and the text of all entries. --dist sets the directory of the helpers. The changelog tells how to time 3.0.1. |
No call failed. Each helper gave one checksum in all runs. The published 3.0.1 package gave the same checksums. The 3.1.0 changelog gives the times. |
The benchmark times depend on the machine and its load. For the results above, taskset -c 3 kept each benchmark on one CPU core.
All 330 tests pass on 2026-10-19. The type assertions in test/emdash-types.test.ts, test/public-entry-text.test.ts, and test/live-types/live-types.test.ts have an effect only in pnpm check, because Vitest does not check types. The astro-relatinator tests do not load Astro or EmDash. vitest.config.ts replaces astro:content with test/astro-content.ts and emdash with test/emdash.ts. These stubs return the entries that each test sets. The workspace sets autoInstallPeers: false, so pnpm does not install the optional emdash peer.
Playground related lists
The playground is the manual check for changes that affect Astro. Run pnpm build. Then compare the related lists in the build output with the expected related lists. The playground README has a command that prints the lists. On 2026-10-16, all six lists matched. Also compare the topics with the expected topics. On 2026-10-19, all six lists and all six topic lists matched.
Manual checks during the 3.0 work
These checks ran one time, on 2026-10-15.
| Check | Result |
|---|---|
| Consumer build on Astro 7.3.8 | The build passed. The pages showed the related lists. |
| Consumer build on Astro 6.4.8 | The build passed. The pages showed the related lists. |
| Cloudflare adapter 14.3.4 on Astro 7.3.8 | The build prerendered the pages in workerd. The pages showed the related lists. |
| EmDash 1.2.0 blog site | The related lists showed in astro dev and in the Node.js production build. |
| Negative control, package marked external | The build failed with ERR_UNSUPPORTED_ESM_URL_SCHEME ... 'astro:'. |
2.x code at commit c0067eb |
The natural and wink bundle was 3.98 MB. The playground build on Astro 5.3 failed with a stack error. |
To repeat the consumer builds:
- In
packages/relatinatorand inpackages/astro-relatinator, runpnpm pack. Theprepackscript builds each package.pnpm packchangesworkspace:^to the version range ofrelatinator, for example^3.1.0. - Make a new Astro project outside the workspace. Use
astro7.3.8 for the first build and 6.4.8 for the second build. - Install the two tarballs with one command, for example
npm install ./relatinator-3.1.0.tgz ./astro-relatinator-3.1.0.tgz. With npm, the localrelatinatortarball satisfies the^3.1.0dependency ofastro-relatinator. pnpm does not use the local tarball for this dependency. With pnpm, add anoverridesentry forrelatinatorthat points to the tarball. - Add a content collection and a page that calls
getRelated(). To compare with known results, copy the playground posts,src/content.config.ts, andsrc/pages/posts/[id].astro. - Run
astro build. Make sure that each post page has a related list.
To repeat the Cloudflare check:
- In the Astro 7.3.8 consumer project, add
@astrojs/cloudflare14.3.4 as the adapter. - Run
astro build. The adapter prerenders the pages in workerd. - Make sure that each prerendered post page has a related list.
To repeat the EmDash check:
- Run
npm create [email protected] site -- --template node:blog. - Install the two tarballs in the new site.
- In the post page of the template, call
getEmDashRelated()and show the result. Theastro-relatinatorREADME has an example. - Start
astro dev. Open/_emdash/api/setup/dev-bypass?redirect=/. This route works only in development. It completes the setup and adds the sample content of the template seed. - Open the post pages. Make sure that each page has a related list.
- Build the site for Node.js and start the built server. Open the post pages again.
Manual checks during the 3.1 work
These checks compared version 3.1 with relatinator 3.0.0 and astro-relatinator 3.0.1. They ran on 2026-10-16.
| Check | Result |
|---|---|
Real and generated posts. The check compares findRelated() with limit 5 and 50, getTopTerms(), and tokenize(). The corpora are the 37 published posts of a real EmDash blog and two generated corpora of 205 and 1,005 posts. The check also compares the default EmDash text of the 37 posts with 3.0.1. |
0 differences in 5,025 comparisons. |
Extreme options. The BM25 options include k1 and b values such as 0, negative values, 1e308, Infinity, and NaN. The limit values are each integer from 0 to 70 and odd values such as -0, 0.5, NaN, and "3". |
0 differences in 40,180 comparisons. |
Score bits. The check compares the internal total of each document in findRelated() and findRelatedToText() queries, bit for bit. |
0 differences in 232,250 totals. |
Tokenizer. The check compares tokenize() on 1,000,000 random strings with Unicode edge cases. A second run tests each Unicode code point in three positions next to word characters. |
0 differences in 1,000,000 and in 3,342,336 strings. |
| Text. The inputs are random EmDash and Astro entries, also entries with odd shapes. The check compares the outputs and the errors of the text functions with 3.0.1. | 0 differences in 1,000,000 comparisons. |
| Cache. The check makes 40,000 random changes to a collection. One half of the run also uses ids and texts with U+0000 and U+0001. The check compares each decision to build a new model with 3.0.1. | 0 differences. |
Manual check of the topic plugin
This check ran on 2026-10-19. It used npm create [email protected] with the node:blog template, emdash 1.3.1, Astro 7.4.0, and the packed tarballs of relatinator 3.2.0 and astro-relatinator. The config had plugins: [relatinatorTopics()]. After the dev-bypass setup, the check called the panel endpoint POST /_emdash/api/content/posts/{id}/plugin-extensions/relatinator-topics/panel/suggested-topics with the session cookie and the X-EmDash-Request: 1 header.
- The manifest listed the “Suggested topics” panel.
panel_loadgave the expected blocks for each of the 8 seeded posts. Three posts got suggestions, and five posts got “No suggestions”. The seed terms have no descriptions, so each response had the description hint.form_submitwith one suggested tag added that tag to the entry. The response had the toast “Added 1 term.” and the new blocks. The terms API of the entry then listed the new tag.- A request without the header got status 403. A request without the cookie got status 401. A direct call to
/_emdash/api/plugins/relatinator-topics/panelgot status 400.
A second check on the same day drove the admin UI in Chromium with Playwright:
- The “Suggested topics” panel showed in the sidebar of the post editor. It listed “Design (100%)” under Categories and “Tools (100%)” under Tags. A click on “Add selected terms” added the selected term and showed the toast.
- With
refresh: true, the Categories field of the editor did not show the new term. emdash 1.3.1handlePluginEntryRefreshin the admin reloads thecontent,revisions, andtranslationsqueries, but not theentry-termsqueries of that field. The field saves its whole list. A removal of the old term in the field then removed the new term too. The terms API showed no categories after that removal. - The panel now returns
navigateto the same entry instead ofrefresh. The admin then loads the page again withwindow.location.assign(). After the add, the Categories field showed both terms. A removal of the old term kept the new term. - An edit of the post title saved automatically within 3 seconds. The Save button then showed “Saved”.
- The browser console showed a React warning that a
formis inside aform. The admin puts the Block Kit form of the panel inside the editor form. The submit of the panel did not submit the editor form.
These two checks ran on the code that showed a percent. A third check on 2026-10-19 ran the matched words. It used a copy of darko.io with emdash 1.3.1, Astro 7.3.8, 37 published posts, and 80 tag terms with descriptions. The check packed the tarball of astro-relatinator from the working tree of this change. Earlier checks in the admin UI had changed the assigned terms of some posts, and the check used the terms as they were. panel_load gave status 200 for each of the 37 posts. 23 posts got “No new suggestions”, and 14 posts got 22 suggestions. Some results:
- self-hosting-with-coolify-and-hetzner: “No new suggestions”. Assigned terms: cloudflare-tunnels, coolify, devops, hetzner, infrastructure, self-hosting.
- build-you-a-related-post-classifier: “guest: post, blog, published”, “rest: post, get, api”, “shorts: post, note, quick”. Assigned terms: astro, nlp.
- inside-the-cfp: “year-in-review: reviewers, year, look”, “conference: talks, conference, speakers”. Assigned terms: cfp, community, conferences, whatthestack.
- find-your-tribe: “beerjs: community, network, organism”. Assigned terms: community, devrel.
- but-auth-is-hard: “supertokens: supertokens, authenticated, providers”. Assigned terms: auth, express, foss, node, oauth2, oidc, php.
- wts-the-third: “conference: conferences, speakers, events”, “cfp: conferences, speakers, cfp”. Assigned terms: community, conferences, whatthestack.
An earlier run in Chromium, on the tarball before the tokenizer rule of the matched words, showed the same labels for build-you-a-related-post-classifier.
The same check ran on a copy of darko.io with emdash 1.2.0 and plugins: [relatinatorTopics()]. astro dev started with no error and no warning about the plugin. The admin manifest listed the “Suggested topics” panel, and the editor showed it. panel_load gave status 200 and “No terms”, because this copy kept its tags in a multiSelect field. No check added a term with emdash 1.2.0.
Manual check of the topic plugin in the sandbox
This check ran on 2026-10-19. It used a copy of the site of the workerd measurement: the EmDash Cloudflare blog template with emdash 1.3.1, @emdash-cms/cloudflare 1.3.1, Astro 7.4.0, @astrojs/cloudflare 14.4.0, wrangler 4.151.1, and workerd 1.20261012.1, with a local D1 database. The database had the 7 posts and the draft of the template. The packed tarballs of relatinator 3.2.0 and astro-relatinator were from the working tree of this change. The Worker Loader binding is a binding of the Workers paid plans. The doc comment of sandbox() in @emdash-cms/cloudflare 1.3.1 (dist/index.d.mts) says: “Without that paid-plan binding, sandboxed plugins are disabled at build time.” The procedure:
- In
wrangler.jsonc, add"worker_loaders": [{ "binding": "LOADER" }]. - In
astro.config.mjs, addsandboxed: [relatinatorTopics()]andsandboxRunner: sandbox()to the EmDash options.sandbox()is from@emdash-cms/cloudflare. - Start
astro devand get a session cookie fromPOST /_emdash/api/auth/dev-bypass. This route works only in dev mode. - Call the panel endpoint
POST /_emdash/api/content/posts/{id}/plugin-extensions/relatinator-topics/panel/suggested-topicswith the cookie and theX-EmDash-Request: 1header. - Run
astro buildandastro preview, and call the endpoint again with the same cookie. Local workerd keeps the session between the two servers.
The results:
- In both servers, the log (
.astro/dev.logand.astro/preview.logof the site) showedEmDash: Loaded sandboxed plugin relatinator-topics:3.1.0 with capabilities: [content:read, taxonomies:write, taxonomies:read]. Inastro dev, the manifest listed the plugin with"sandboxed": trueand the “Suggested topics” panel. - In
astro dev,panel_loadgave status 200 for each of the 8 posts. Three posts got suggestions, four posts got “No new suggestions”, and the draft got “No suggestions”. Each response had the description hint, because the seed terms have no descriptions. - A second run in
astro devon the same day saved each response.panel_loadfornotes-on-simplicitysuggested the “Design” category.form_submitwith that category gave status 200, the toast “Added 1 term.”, andnavigateto the entry. The category terms of the entry (GET /_emdash/api/content/posts/{id}/terms/category) changed fromnotestodesignandnotes. - In
astro preview, the log showed that the plugin loaded. No output of the preview requests was saved, so this record does not give their results. - In
astro dev, a request without the header got status 403, and a request without the cookie got status 401. Inastro dev, a direct call to/_emdash/api/plugins/relatinator-topics/panelgot status 400. In the sandbox, the error code isROUTE_ERROR, and the message isPluginRouteError: Open this panel from the entry editor.
Two negative controls ran in the same site, with astro build and astro preview. With the exports map of the commit before this change, astro build failed with Package subpath './emdash-plugin/sandbox' is not defined by "exports", from resolveModulePathFromProject(). With the new exports map and the tsc output of the entry file, the build passed. The plugin loaded, but panel_load got status 400 with Failed to start Worker: Uncaught Error: No such module "emdash-topics.js". imported from "sandbox-plugin.js".
Manual check of live collections
This check ran on 2026-10-19. It used a new Astro 7.3.8 site with @astrojs/node 11.1.8 in standalone mode and the packed tarballs of relatinator 3.2.0 and astro-relatinator. src/live.config.ts had one live collection, products, with four entries from a loader in memory. The loader added a call count to the data of each entry. The pages called getLiveRelated("products", ..., { fields: ["title", "text"] }).
astro checkreported 0 errors. A test file withconst wrong: number = related[0]!.data.titleandgetLiveRelated("posts", "a")got two errors:Type 'string' is not assignable to type 'number'andArgument of type '"posts"' is not assignable to parameter of type '"products"'.astro build, thennode dist/server/entry.mjs. The on-demand page/products/telescopeshowed the related list “Star map”, “Moon lamp”./products/star-mapshowed “Telescope”. The call count of the loader was 1, 2, and 3 in three requests, so each request loaded the collection again.- The prerendered page
/static/telescope/showed “Star map”, “Moon lamp” in the build output. A second build without an adapter also prerendered this page with the same list.
To repeat the check:
- Pack the two libraries, as in step 1 of the consumer builds.
- Make a new Astro 7.3.8 project. Install the two tarballs and
@astrojs/node. Setadapter: node({ mode: "standalone" }). - Add
src/live.config.tswith a live collection and a loader. Add a page withexport const prerender = falsethat callsgetLiveRelated(). - Run
astro checkandastro build. Startnode dist/server/entry.mjsand open the page.
Manual check of previews and drafts
This check ran on 2026-10-19. It used the EmDash blog template with emdash 1.3.1, SQLite, @astrojs/node, and astro dev. The packed tarballs of relatinator 3.2.0 and astro-relatinator were installed. The post page called getEmDashRelated("posts", post, { limit: 5, maxAge }) with the entry from getEmDashEntry(). A maxAge query parameter set maxAge, with 0 as the default. The check used the “The Case for Static” post. A second run used the tarball of the commit before the preview work. Both runs used code from before the shared rule in publishedIndex(). Thus in these runs, a preview call could use a window and keep its read in the window.
The admin API made the preview URL and the drafts. POST /_emdash/api/content/posts/{id}/preview-url returns a URL with a signed _preview token. The [id] is the id of the content row. A state-changing request needs the X-EmDash-Request: 1 header. GET /_emdash/api/auth/dev-bypass signs in on a dev server. A PUT to the entry with new data saved a draft revision. The entry stayed published.
| Case | Commit before the preview work | Preview work |
|---|---|---|
| Public page, before and after the draft | Published list (P) | P |
Preview of a draft with new text, maxAge 0 |
Draft list (D) | D |
| Same preview, inside a window from a public read | P | D |
| Preview of a draft with a new slug, inside the window | The old slug is first | P, without the old slug |
| Public page inside the window, after a preview read | Not checked | Draft text and its title |
P was notes-on-simplicity, designing-with-constraints, a-weekend-with-a-side-project, small-tools-big-impact, building-for-the-long-term. The draft had the content of “Learning in Public”. D started with learning-in-public. The draft with the new slug had the published content. Its preview had the entry id the-case-for-static-sites and the data.id of the content row. The last row shows the leak of the old code. The shared rule removes this leak, and the check below ran on the final code.
A second check ran on 2026-10-19, on the final code. It used an EmDash blog site with emdash 1.3.1 and @astrojs/node, on the dev server. The tarballs were packed from the working tree of this change. The post page called getEmDashRelated("posts", post, { limit: 5, maxAge }). A maxAge query parameter set maxAge.
- A request opened the preview URL of
the-case-for-staticwithmaxAge=600. The title of the draft is “Static sites, learned in public”. The page showed this title. The related list waslearning-in-public,building-for-the-long-term,a-weekend-with-a-side-project,notes-on-simplicity,small-tools-big-impact. - Then two public requests opened
/posts/learning-in-public?maxAge=600, inside the same window. Each page showedbuilding-for-the-long-term,a-weekend-with-a-side-project,notes-on-simplicity,small-tools-big-impact,the-case-for-static. The title ofthe-case-for-staticwas the published title “The Case for Static”. The draft title occurred 0 times in the two responses.
To repeat the check:
- Pack the two libraries, as in step 1 of the consumer builds. Install the tarballs in an EmDash site.
- Call
getEmDashRelated()in the post page with the entry fromgetEmDashEntry(), and render the ids. - Save a draft of a published post, and make a preview URL. Open the public page and the preview URL with and without a
maxAge.
Workerd measurement of getEmDashRelated()
This measurement ran on 2026-10-19, in two runs. It times getEmDashRelated() in the post page of an EmDash site on the Cloudflare adapter. astro preview runs the built worker in local workerd, with a local D1 database.
Environment. The site came from npm create [email protected] with the cloudflare:blog template. The versions were emdash 1.3.1, @emdash-cms/cloudflare 1.3.1, Astro 7.4.0, @astrojs/cloudflare 14.4.0, wrangler 4.151.1, and workerd 1.20261012.1. Node.js was 24.13.1. The machine had an AMD Ryzen AI 9 HX 370. taskset -c 0-3,12-15 kept the server on 8 logical CPUs.
Data. The seed is the seed of the template with generated posts. The posts come from bench/corpus.js of relatinator, with 300 to 3,000 words, the seed 2026, and Portable Text content. Each size has a seeded D1 state: 7 posts (the template only), 200 posts, and 1,005 posts.
Method. For each run, size, and variant, the script copies the seeded state, starts the server, and sends one first request and 10 later requests. Each request is for a different post. Then it stops the server. The variant order rotates in each run, so each variant sees the same machine load. Each value is the median of 6 runs, with the range in parentheses. The later value of a run is the median of its 10 later requests. All times are in ms.
The page measures the call with Date.now(). In workerd, Date.now() changes only at I/O. The page waits for a 0 ms timer before each clock read, so the time includes the CPU work. EmDash gives the count and the time of the database queries in the Server-Timing header.
First run, maxAge 0. This run used astro-relatinator 3.1.0 and relatinator 3.2.0 from npm, in the post page of the template. The load average was 8 to 17 on 24 logical CPUs.
| Posts | First call | Later call | Page with the call, later request | Page without the call, later request |
|---|---|---|---|---|
| 7 | 13 (11 to 16) | 5 (4 to 5) | 32 (26 to 41) | 29 (23 to 32) |
| 200 | 274 (228 to 701) | 54 (47 to 135) | 87 (74 to 233) | 35 (28 to 36) |
| 1,005 | 1,042 (919 to 1,485) | 274 (222 to 353) | 310 (253 to 399) | 37 (29 to 57) |
At 1,005 posts, a later request with the call made 14 database queries, and a later request without the call made 3. Thus the call made 11 queries, one for each page of 100 entries. A first request with the call made 24 queries. The CPU time of the workerd process for a first request was 1,460 ms with the call and 200 ms without the call.
A split endpoint repeats the steps of a first call on each request, with a timer before each clock read. These are its times for 1,005 posts:
| Step | First request | Later request |
|---|---|---|
| Read of the collection, 11 pages | 311 (270 to 521) | 251 (216 to 426) |
| Text of each entry, for the fingerprint | 25 (18 to 31) | 18 (15 to 29) |
| Text of each entry, for the model | 27 (21 to 38) | 20 (17 to 27) |
| Model build | 831 (716 to 1,422) | 706 (572 to 1,192) |
findRelated() |
3 (2 to 4) | 2 (1 to 3) |
For 200 posts, the model build of a first request took 182 ms (172 to 250). In the same runs, bench/bench.mjs with the same dist and 1,005 posts in memory gave 467 ms (413 to 726) for a first call and 20 ms (17 to 23) for a later call.
Second run, maxAge 0 and 60. This run used the packed tarballs of this branch and the page bench/workerd/templates/post-page.astro. This page shows only the title and the related list, so its page times are shorter than the times of the first run. The load average was 5 to 6. The size was 1,005 posts.
| Variant | First call | Later call | Page, later request | Queries, later request | workerd CPU, later request |
|---|---|---|---|---|---|
maxAge 0 |
854 (823 to 873) | 215 (212 to 216) | 227 (223 to 228) | 13 | 270 |
maxAge 60 |
854 (832 to 878) | 2 (1 to 2) | 12 (12 to 13) | 2 | 10 |
| Without the call | Not applicable | Not applicable | 12 (11 to 13) | 2 | 10 |
With maxAge 60, the first call is the full read, so its time is the same as with maxAge 0. Inside the window, the page made only the 2 queries of getEmDashEntry().
Errors and outputs. No request failed in the two runs. The script counts a request as an error when the status is not 200, or when the related list does not have 3 items (0 items without the call). The second run sent 198 requests and had 0 errors. No check sent an invalid maxAge to workerd. The unit tests check the RangeError.
What limits each value.
- First call: the model build, 831 of 1,042 ms at 1,005 posts. Performance without output changes tells that
tokenize()takes most of the build time. - Later call with
maxAge0: the read of the collection, 251 of 274 ms at 1,005 posts. The text for the fingerprint took 18 ms. EmDash gave 163 ms of query time for the call (172 ms with the call, about 9 ms without it). The rest of the read time is outside the queries that EmDash times. - Later call inside the window:
findRelated()and the clock. The clock has a resolution of 1 ms, and the timer before a clock read adds about 1 ms. Without the call, the page measured 1 ms between the same two clock reads. - Page without the call:
getEmDashEntry()and the render.
Caveats.
- Local D1 is a local SQLite database. On Cloudflare, each of the 11 queries goes over the network. No check measured a call on Cloudflare.
- The CPU column is the user and system time of the workerd process from
/proc/<pid>/stat, in steps of 10 ms. It is not the CPU time that Cloudflare counts. - The posts are synthetic. Real posts can have more or less text.
- The two runs used different pages and different machine load. Compare values only inside one run.
To repeat the workerd measurement, run the commands in an empty work directory. <repo> is the path of this repository, and <bench> is <repo>/packages/astro-relatinator/bench/workerd.
- Run
npm create [email protected] site -- --template cloudflare:blog. Then runnpm installin the newsitedirectory. - In
<repo>/packages/relatinatorand in<repo>/packages/astro-relatinator, runpnpm pack. Install the two tarballs in the site with onenpm installcommand. - Copy
<bench>/templates/post-page.astrotosite/src/pages/posts/[slug].astro. Copy<bench>/templates/split.tstosite/src/pages/bench/split.ts. Do this before step 5, becauseseed-size.shbuilds the site. - Run
mkdir seeds states. Then make a seed for each size, for examplenode <bench>/gen-seed.mjs site/seed/seed.json 1005 seeds/seed-1005.json. The size is the number of published posts. The template has 7 published posts. - Seed a D1 state for each size, for example
<bench>/seed-size.sh site seeds/seed-1005.json states/state-1005. The script builds the site, starts it, runs the EmDash setup, and copies the D1 state. At its end, also after an error, the script stops the server and puts back the seed of the site. - Run
node <bench>/measure.mjs --site site --states states --runs 6 --sizes 1005 --variants related,window,baseline --cpus 0-3,12-15. The script writesresults.json.--max-agesets the window of thewindowvariant (default60). Thesplitvariant calls the split endpoint. Thenodevariant runsbench/bench.mjswith thedistof the site.--portsets the port (default4399). - Run
node <bench>/summarize.mjs results.json. It prints each value as the median, with the range in brackets. Inresults.jsonand in the summary, thecoldkeys are for the first request, and thewarmkeys are for the later requests.
The scripts need Linux, because they read /proc and use taskset and pgrep. measure.mjs stops the preview server of the site before each start. Each server start copies the state, so the measurement does not change the seeded states.
Known gaps and follow-ups
These items were open on 2026-10-16. The three classify() items were open on 2026-10-17. The topic plugin items, the live collection item, the workerd items, and the preview item were open on 2026-10-19.
- Astro 6 in the repository. All workspace packages use Astro 7.3.8. Only the manual consumer build used Astro 6.4.8.
- Lowest supported versions. No check covers the lowest versions of the supported ranges. All checks ran on Node.js 24.13.1, not on Node.js 22.12. The Astro checks used 6.4.8 and 7.3.8, not 6.0 and 7.0. The EmDash check used 1.2.0, and the preview checks used 1.3.1, not 1.0. For emdash 1.0.0, a code reading on 2026-10-19 covered the result type of
getEmDashCollection(), the request context, the collection read, and thedata.idof a preview. No check ran a site with emdash 1.0.0. - On-demand pages with
getRelated(). No check rendersgetRelated()in an on-demand page. The EmDash check covers on-demand rendering only throughgetEmDashRelated(), and the live collection check only throughgetLiveRelated(). - Live collections. The live collection check used Astro 7.3.8 and the Node.js adapter only. No check used Astro 6, the Cloudflare adapter,
astro dev, or a real API loader.getLiveRelated()does not give a loader filter togetLiveCollection(), so each call loads the whole collection. A loader that needs a filter fails. On darko.io with emdash 1.2.0, theemdashLoader()collection failed with “type filter is required”. The README tells EmDash sites to usegetEmDashRelated(). It does not usecacheHint. The result type depends onLiveLoaderDataType, which Astro does not document. If a later Astro version renames this type, the result type can becomeany, ortsccan report an error. - Browsers. No test runs
relatinatorin a browser. The portability check shows only that the bundle has no imports. - CI. The repository has no CI workflow. The automated checks run only when a maintainer runs them.
- Changesets. The root
package.jsonhas@changesets/cliand@changesets/changelog-githubindevDependencies. The repository has no.changesetdirectory, so Changesets is not set up. Maintainers edit the changelogs by hand. - Lint. Version 3.0 removed the ESLint and Stylelint configurations. No linter replaces them. Prettier and
tscare the only static checks. - Prettier configuration.
.prettierrcin both libraries setsjsxBracketSameLine, which Prettier 3 deprecates. Each Prettier run prints a warning. - Prettier coverage. Only the two libraries have
format:checkscripts. The root README,docs/, and the playground have no Prettier check. - Double build in
pnpm check. Theprecheckscript ofastro-relatinatorbuildsrelatinator. Then theprecheckscript of the playground buildsrelatinatorandastro-relatinatoragain. relatedIndex()trusts its callers. Whentextis set,versionOfmust return the text thattextreturns. All three callers do this, butrelatedIndex()does not enforce it.- Two meanings of
entries. Insrc/related-index.ts,Corpus.entriesis an array of entries.RelatedIndex.entries(ids)is a function that maps ids to entries. limitandfilterin the shared options.RelatedOptionscontainslimitandfilter, butrelatedIndex()does not read them.getRelated()andgetEmDashRelated()give them to the model.- Entry objects without
data.id. The exclusion of the published version of a draft with a new slug needsdata.id. A page that gives an object withoutdata.idcan get the published version in the list. - Warm EmDash calls. Without
maxAge, eachgetEmDashRelated()call reads every entry and makes the text of every entry, because the cache uses the text as the version. In the workerd measurement with 1,005 posts, the read took most of the time of a later call: 251 of 274 ms. The text took 18 ms.maxAgeskips both inside a window, but the lists can then be stale. No check measured a call on Cloudflare D1 over the network. - Cold EmDash calls. The first call in each isolate builds the model. In local workerd, the build took 182 ms for 200 posts and 831 ms for 1,005 posts. This is more than the 10 ms of CPU time for each request of the Workers Free plan. A saved model could remove the build. The package does not have one. A freshness window, not a saved model gives the reasons.
textfunctions in a window. Twotextfunctions with the same source code share one window. The window does not compare their texts.- Benchmark data. The automated benchmarks use the synthetic posts of
bench/corpus.js. The helper benchmark reads in-memory collections, not a database, and it does not run Astro. The workerd measurement reads a local D1 database in local workerd, with the same synthetic posts. No measurement used real posts or Cloudflare’s network. - 3.0 manual checks on the 3.1 code. The consumer builds, the Cloudflare check, and the EmDash check ran on the 3.0 code. They did not run on the 3.1 code.
classify()defaults. The defaultsratio0.5 andlimit3 come from the eval of 37 posts. The same posts chose them and measured them. No second corpus checks them. The test on 2026-10-18 added one post and two more taggers, but no new corpus. Its taggers were language models, not people.eval/classify.mjsrepeats the eval, but the posts and the labels are not in the repository.relatinator/llmdefaults. The rule “score 0 or more, at most 2 topics” comes from the same 37 posts. The limit of 2 came from the results: it scored about 0.05 F1 higher than 3. The best cut changes with the model, andrelatinator/llmdoes not calibrate it. No test used a second corpus, a language other than English, or a model with logprobs from a hosted API.relatinator/llmin the runtimes. No test runsrelatinator/llmin a browser, in Cloudflare Workers, or in the EmDash sandbox. No check measured Workers AI. The topic plugin does not userelatinator/llm.relatinator/llmerrors. OnlyjsonJudge()asks again, and only for a reply without a JSON object. A failed request, alogprobJudge()reply without logprobs, or adescribeTopics()reply without JSON throws an error. The error stops the whole call.logprobJudge()sends its questions one at a time. A server with more slots does not make it faster.- Posts that match no topic. Issue #6 asks for a list of the posts that match no topic. A relative cut gives each text with a matching term at least one topic. Thus
classify()cannot give this list. It needs a different signal. - Topics in
astro-relatinator. Issue #6 asks for topics for Astro content collections.astro-relatinatorexportsentryText(), and the README has a recipe that gives this text toclassify(). The package has no topic option and no topic cache, because oneclassify()call reads only the topic list. Only the manual playground check runs the recipe in an Astro page. The playground post page callsclassify()withentryText(), andastro checktype-checks it. Since the topic plugin, EmDash terms are topics in the entry editor. EmDash terms as topics, in an editor panel describes it. - The topic plugin in the sandbox. Only the sandbox check runs the plugin in
sandboxed: [], in local workerd. No check ran it on Cloudflare. No check measured the plugin against the default limits of 50 ms of CPU time and 10 subrequests for each invocation. A code reading did not find if local workerd enforces them, or if each call to the bridge counts as a subrequest. Apanel_loadmakes 3 bridge calls and 1 more for each taxonomy of the collection. An add makes 1 more call for each taxonomy with selected terms. No automated test runs the bundle in workerd.@emdash-cms/plugin-test0.2.10 needs Vitest 4,@cloudflare/vitest-plugin, and a build with@emdash-cms/plugin-cli. The package uses Vitest 5. No check used the sandbox runner of another platform. The default limits are insrc/plugins/sandbox/types.tsof emdash 1.3.1 (cpuMsandsubrequests). - The text of the topic panel. The panel reads the data that
ctx.content.get()returns. In emdash 1.3.1, this data comes from the stored columns of the entry (ContentRepository.findById()), not from a revision. A code reading did not find if an unpublished draft revision of a published entry changes these columns. No check covers an entry with a draft revision. - Unsaved changes in the topic panel. The panel reads the saved content of the entry, not the changes that are not saved in the editor.
- The matched words. The panel shows at most 3 words. Each word is the first word of the entry with that stem, so the panel can show a different form than the form that occurs most, for example “conferences” for the term “conference”. A word that gives more than one token splits at its apostrophes, for example “rock’n’roll”. If that word also has an elision, a part can give another stem than
tokenize(). The panel then shows the stem in place of a word. For the test word “o’won’t”, the panel shows “wo”. No check found such a word in real text. A long label can wrap in the narrow sidebar. Block Kit 1.3.1 has no description for a checkbox option, so the words are in the label. A screen reader reads them as a part of the label. - Taxonomies only. The plugin does not read
multiSelectfields. A site with tags in such a field must move them to a taxonomy. The plugin has no migration for this move. - emdash 1.2.0. The README asks for emdash 1.3.1 or later. With emdash 1.2.0, the panel loaded and
panel_loadworked, but no check added a term or checked the reload after an add. - The reload after an add. After the panel adds terms, the admin loads the entry page again. This reload loses the changes that are not saved. The emdash 1.3.1 admin has no
beforeunloadguard, so the browser does not warn the user. - Entries with a null locale. For an entry with a
nulllocale, the panel reads the taxonomies and the terms of all locales. Thus the panel can show a taxonomy group more than one time. This case is rare. Thelocalecolumns of the taxonomy tables are NOT NULL since migration 036 of emdash 1.3.1, and thelocalecolumns of the content tables are NOT NULL since migration 019. - The plugin version. The descriptor sets
versionby hand. A test intest/emdash-plugin.test.tsfails when it is not the version inpackage.json. - Native EmDash plugins. How a page can read the data of a native EmDash plugin is an open question upstream. Until EmDash answers it, the site helper is the supported design.