astro-relatinator 2.1.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +9 -0
- package/README.md +233 -51
- package/dist/emdash-text.d.ts +7 -0
- package/dist/emdash-text.js +45 -0
- package/dist/emdash.d.ts +17 -0
- package/dist/emdash.js +46 -0
- package/dist/entry-text.d.ts +18 -0
- package/dist/entry-text.js +36 -0
- package/dist/get-related.d.ts +9 -0
- package/dist/get-related.js +25 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/integration.d.ts +6 -0
- package/dist/integration.js +16 -0
- package/dist/model-cache.d.ts +7 -0
- package/dist/model-cache.js +14 -0
- package/dist/portable-text.d.ts +8 -0
- package/dist/portable-text.js +29 -0
- package/dist/related-index.d.ts +36 -0
- package/dist/related-index.js +26 -0
- package/package.json +46 -19
- package/integration.ts +0 -170
package/LICENSE.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2022 Darko Bozhinovski
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,88 +1,270 @@
|
|
|
1
|
-
|
|
1
|
+
# astro-relatinator
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Find related entries in your Astro content collections.
|
|
4
|
+
|
|
5
|
+
This package wraps [relatinator](https://github.com/DBozhinovski/relatinator/tree/master/packages/relatinator). It scores entries with BM25 (default) or TF-IDF.
|
|
6
|
+
|
|
7
|
+
## Compatibility
|
|
8
|
+
|
|
9
|
+
- Astro 6 and Astro 7. The `astro` peer dependency is `^6.0.0 || ^7.0.0`.
|
|
10
|
+
- Node.js 22.12 or later.
|
|
11
|
+
- Static output and on-demand rendering.
|
|
12
|
+
- The Cloudflare adapter, also when the adapter prerenders pages in workerd.
|
|
13
|
+
- EmDash 1.x (1.0 or later), for `astro-relatinator/emdash` only. The optional `emdash` peer dependency is `^1.0.0`.
|
|
14
|
+
- ES modules only, with TypeScript type declarations.
|
|
15
|
+
|
|
16
|
+
For the checks behind this list, read [Verification](https://github.com/DBozhinovski/relatinator/blob/master/docs/architecture.md#verification).
|
|
4
17
|
|
|
5
18
|
## Installation
|
|
6
19
|
|
|
7
|
-
|
|
20
|
+
```sh
|
|
21
|
+
npx astro add astro-relatinator
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
You can also install the package without `astro add`:
|
|
8
25
|
|
|
26
|
+
```sh
|
|
27
|
+
npm install astro-relatinator
|
|
9
28
|
```
|
|
10
|
-
|
|
29
|
+
|
|
30
|
+
The integration is optional. `getRelated()` works without an entry in `integrations`. [How it works](#how-it-works) gives the reason. The `astro add` command adds `relatinator()` to your config. You can keep it or remove it.
|
|
31
|
+
|
|
32
|
+
```js
|
|
33
|
+
// astro.config.mjs
|
|
34
|
+
import { defineConfig } from "astro/config";
|
|
35
|
+
import relatinator from "astro-relatinator";
|
|
36
|
+
|
|
37
|
+
export default defineConfig({
|
|
38
|
+
integrations: [relatinator()],
|
|
39
|
+
});
|
|
11
40
|
```
|
|
12
41
|
|
|
13
|
-
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
Call `getRelated()` in the frontmatter of a page. Give it the collection name and an entry (or the id of an entry). It returns the related entries of the same collection, most related first.
|
|
45
|
+
|
|
46
|
+
```astro
|
|
47
|
+
---
|
|
48
|
+
// src/pages/posts/[id].astro
|
|
49
|
+
import { getCollection, render } from "astro:content";
|
|
50
|
+
import { getRelated } from "astro-relatinator";
|
|
51
|
+
|
|
52
|
+
export async function getStaticPaths() {
|
|
53
|
+
const posts = await getCollection("posts");
|
|
54
|
+
return posts.map((post) => ({ params: { id: post.id }, props: { post } }));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const { post } = Astro.props;
|
|
58
|
+
const { Content } = await render(post);
|
|
59
|
+
const related = await getRelated("posts", post, { limit: 3 });
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
<article>
|
|
63
|
+
<h1>{post.data.title}</h1>
|
|
64
|
+
<Content />
|
|
65
|
+
</article>
|
|
14
66
|
|
|
67
|
+
<h2>Related</h2>
|
|
68
|
+
<ul>
|
|
69
|
+
{related.map((entry) => <li><a href={`/posts/${entry.id}/`}>{entry.data.title}</a></li>)}
|
|
70
|
+
</ul>
|
|
15
71
|
```
|
|
72
|
+
|
|
73
|
+
The result has the same type as `getCollection("posts")`, so TypeScript knows the type of `entry.data`.
|
|
74
|
+
|
|
75
|
+
`getRelated()` throws an error if the collection has no entry with the given id.
|
|
76
|
+
|
|
77
|
+
## Options
|
|
78
|
+
|
|
79
|
+
| Option | Type | Default | Description |
|
|
80
|
+
| -------- | --------------------- | ---------- | --------------------------------------------------------------------------- |
|
|
81
|
+
| `limit` | `number` | `5` | The maximum number of related entries. |
|
|
82
|
+
| `method` | `"bm25"` or `"tfidf"` | `"bm25"` | The scoring method. |
|
|
83
|
+
| `fields` | `readonly string[]` | all fields | The `data` keys to read. The body is always read. |
|
|
84
|
+
| `text` | `(entry) => string` | none | A function that returns the full text of an entry. It replaces the default. |
|
|
85
|
+
|
|
86
|
+
The default text of an entry has two parts:
|
|
87
|
+
|
|
88
|
+
1. All string and number values in `entry.data`, also in arrays and nested objects. `getRelated()` does not read dates, booleans, or `null`. When you set `fields`, it reads only those keys.
|
|
89
|
+
2. The body of the entry (`entry.body`), when the entry has one.
|
|
90
|
+
|
|
91
|
+
Examples:
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
// Only read the title and tags, plus the body.
|
|
95
|
+
await getRelated("posts", post, { fields: ["title", "tags"] });
|
|
96
|
+
|
|
97
|
+
// Use your own text for each entry.
|
|
98
|
+
await getRelated("posts", post, {
|
|
99
|
+
text: (entry) => `${entry.data.title} ${entry.data.tags.join(" ")}`,
|
|
100
|
+
});
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## How it works
|
|
104
|
+
|
|
105
|
+
`getRelated()` reads the collection with `getCollection()` each time that a page calls it. It does not read files when Astro starts, and it does not watch files.
|
|
106
|
+
|
|
107
|
+
### The model cache
|
|
108
|
+
|
|
109
|
+
`getRelated()` builds one model for each combination of collection, `method`, and `fields` (or `text`). Later calls use the same model while the collection does not change. Thus a build with many pages builds each model one time.
|
|
110
|
+
|
|
111
|
+
To find changes, `getRelated()` compares the id and the `digest` of each entry. Entries from the built-in loaders have a digest. If an entry has no digest, it compares the text of the entry.
|
|
112
|
+
|
|
113
|
+
When you use `text`, it compares the text that your function returns. Your function runs for each entry on each call.
|
|
114
|
+
|
|
115
|
+
In `astro dev`, the related entries update when you edit a content file. You do not need to restart the server.
|
|
116
|
+
|
|
117
|
+
### Why the integration is optional
|
|
118
|
+
|
|
119
|
+
`getRelated()` imports `astro:content`, and only Vite can resolve this module. Astro processes `astro-relatinator` with Vite because the package has an `astro` peer dependency, the `astro` keyword, and a name that starts with `astro-`. Thus `getRelated()` works with or without the integration. For the details, read [The integration is optional](https://github.com/DBozhinovski/relatinator/blob/master/docs/architecture.md#the-integration-is-optional).
|
|
120
|
+
|
|
121
|
+
## Limitations
|
|
122
|
+
|
|
123
|
+
- With on-demand rendering, each Node.js process or Cloudflare Workers isolate builds its own model on the first call. The cache is in memory. A new process or isolate starts with an empty cache.
|
|
124
|
+
- Each call reads the whole collection. With `text`, and on EmDash, each call also makes the text of every entry to find changes. The time for a call and for a model build grows with the size of the collection.
|
|
125
|
+
- The cache keeps a model for each set of options that your pages use. It does not remove models.
|
|
126
|
+
- The related entries come from the collection that you give. `getRelated()` does not compare entries across collections.
|
|
127
|
+
- `getEmDashRelated()` ranks only published entries. [Which entries are compared](#which-entries-are-compared) tells how it uses a draft or a preview.
|
|
128
|
+
- The tokenizer is for English text. [How scoring works](https://github.com/DBozhinovski/relatinator/tree/master/packages/relatinator#how-scoring-works) gives the steps.
|
|
129
|
+
|
|
130
|
+
## EmDash
|
|
131
|
+
|
|
132
|
+
[EmDash](https://github.com/emdash-cms/emdash) keeps content in a database, not in content collections. For EmDash sites, use `getEmDashRelated()` from `astro-relatinator/emdash`. It uses the same scoring and the same model cache as `getRelated()`.
|
|
133
|
+
|
|
134
|
+
It needs `emdash` 1.x (1.0 or later). Install the package next to `emdash`:
|
|
135
|
+
|
|
136
|
+
```sh
|
|
16
137
|
npm install astro-relatinator
|
|
17
138
|
```
|
|
18
139
|
|
|
19
|
-
|
|
140
|
+
You do not need the integration in `astro.config.mjs`.
|
|
141
|
+
|
|
142
|
+
### Usage
|
|
20
143
|
|
|
21
|
-
|
|
144
|
+
Give `getEmDashRelated()` the collection slug and an entry (or the id of an entry). It returns the related entries, most related first.
|
|
22
145
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
146
|
+
```astro
|
|
147
|
+
---
|
|
148
|
+
// src/pages/posts/[slug].astro
|
|
149
|
+
import { getEmDashEntry } from "emdash";
|
|
150
|
+
import { getEmDashRelated } from "astro-relatinator/emdash";
|
|
151
|
+
|
|
152
|
+
const { entry: post } = await getEmDashEntry("posts", Astro.params.slug);
|
|
153
|
+
if (!post) return Astro.redirect("/404");
|
|
27
154
|
|
|
28
|
-
|
|
155
|
+
const related = await getEmDashRelated("posts", post, { limit: 3 });
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
<ul>
|
|
159
|
+
{related.map((entry) => <li><a href={`/posts/${entry.id}`}>{entry.data.title}</a></li>)}
|
|
160
|
+
</ul>
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
The result has the same type as the entry that you give. When you give an id, the type of `entry.data` is `Record<string, unknown>`. To set a different type, use `getEmDashRelated<typeof post>("posts", "my-slug")`.
|
|
164
|
+
|
|
165
|
+
### EmDash options
|
|
166
|
+
|
|
167
|
+
The options are the same as for `getRelated()`: `limit` (default `5`), `method` (default `"bm25"`), `fields`, and `text`.
|
|
168
|
+
|
|
169
|
+
The default text of an entry has two parts:
|
|
170
|
+
|
|
171
|
+
1. The string and number values of the content fields in `entry.data`. A Portable Text value becomes plain text. The plain text contains the text of each block, without keys, styles, and marks. It does not contain other block types, such as images.
|
|
172
|
+
2. The labels of the taxonomy terms of the entry, such as tag and category names.
|
|
173
|
+
|
|
174
|
+
The default text does not include the values that EmDash adds to each entry. These are `id`, `slug`, `status`, `authorId`, `primaryBylineId`, `createdAt`, `updatedAt`, `publishedAt`, `scheduledAt`, `draftRevisionId`, `liveRevisionId`, `locale`, `translationGroup`, `byline`, `bylines`, and `seo`. The default text also leaves out term ids and term slugs.
|
|
175
|
+
|
|
176
|
+
When you set `fields`, the default text reads only those keys. It reads all of the values in those keys, including EmDash system values and all of the properties of terms. Portable Text in those keys still becomes plain text. When you set `text`, your function gives all of the text.
|
|
29
177
|
|
|
30
178
|
```ts
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
// ...
|
|
35
|
-
relatinatorIntegration({
|
|
36
|
-
paths: ["./src/content/posts"], // path yo your content files
|
|
37
|
-
schema: ["title", "descriptions", "tags"], // frontmatter fields to use for training
|
|
38
|
-
similarityMethod: "bm25", // use BM25 for training
|
|
39
|
-
debug: true, // enable debug mode
|
|
40
|
-
}),
|
|
41
|
-
// ...
|
|
42
|
-
],
|
|
179
|
+
// Only read the title, the excerpt, and the content.
|
|
180
|
+
await getEmDashRelated("posts", post, {
|
|
181
|
+
fields: ["title", "excerpt", "content"],
|
|
43
182
|
});
|
|
44
183
|
```
|
|
45
184
|
|
|
46
|
-
|
|
185
|
+
To convert Portable Text in your own `text` function, use `portableTextToPlainText()`:
|
|
47
186
|
|
|
48
|
-
|
|
187
|
+
```ts
|
|
188
|
+
import {
|
|
189
|
+
getEmDashRelated,
|
|
190
|
+
portableTextToPlainText,
|
|
191
|
+
} from "astro-relatinator/emdash";
|
|
49
192
|
|
|
50
|
-
|
|
193
|
+
await getEmDashRelated("posts", post, {
|
|
194
|
+
text: (entry) =>
|
|
195
|
+
`${entry.data.title} ${portableTextToPlainText(entry.data.content)}`,
|
|
196
|
+
});
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### Which entries are compared
|
|
200
|
+
|
|
201
|
+
The model contains only the published entries of the collection. `getEmDashRelated()` reads all pages of the collection with `getEmDashCollection()`.
|
|
202
|
+
|
|
203
|
+
EmDash uses the slug of an entry as its id. Thus a preview of a published entry has the id of the published entry, unless the draft changes the slug.
|
|
204
|
+
|
|
205
|
+
- If you give the id of an entry that is not published, `getEmDashRelated()` throws an error.
|
|
206
|
+
- If you give an entry object whose id is in the published entries, `getEmDashRelated()` uses the published text of that id. It does not read the text of the object. For a preview of a published entry, the related entries come from the published text, not from the preview text.
|
|
207
|
+
- If you give an entry object whose id is not in the published entries, `getEmDashRelated()` uses the text of the object. An example is a draft of an entry that was never published. The result does not contain that id.
|
|
208
|
+
- If EmDash returns an error, `getEmDashRelated()` throws an error that names the collection.
|
|
209
|
+
|
|
210
|
+
### Caching on servers and Workers
|
|
51
211
|
|
|
52
|
-
|
|
212
|
+
EmDash sites render on request. `getEmDashRelated()` reads the collection on each call. It keeps the model until an entry changes. To find changes, it compares the id and the text of each entry. [Limitations](#limitations) tells how the cache works on Node.js servers and on Cloudflare Workers.
|
|
213
|
+
|
|
214
|
+
## Migrating from 2.x
|
|
215
|
+
|
|
216
|
+
Version 3.0 removes the old API. Make these changes:
|
|
217
|
+
|
|
218
|
+
1. Remove all options from `relatinator()` in your Astro config. In a type-checked config (`astro.config.ts`, or a file with `// @ts-check`), the 2.x options cause a type error. In a config without type checks, version 3.0 ignores the options and logs a warning. You can also remove the integration.
|
|
219
|
+
2. Remove `BM25Utils` and `TfIdfUtils` from your pages. Call `getRelated()` instead.
|
|
220
|
+
3. Use entry ids, not titles. Version 2.x used the `title` frontmatter field as the id. `getRelated()` uses `entry.id` and returns entries.
|
|
221
|
+
4. Replace `schema` with `fields`. Replace `similarityMethod` with `method`. The `paths` option has no replacement, because `getRelated()` reads the collection with `getCollection()`.
|
|
222
|
+
5. Note the new default method. Version 2.x used TF-IDF by default. Version 3.0 uses BM25. Set `method: "tfidf"` to keep the old method.
|
|
223
|
+
|
|
224
|
+
In version 2.x, `findRelated` returns the `title` frontmatter field of each related entry, not the entry id. Thus a lookup by `post.id` finds no entries. This 2.x example finds the entries by title:
|
|
53
225
|
|
|
54
226
|
```astro
|
|
55
227
|
---
|
|
56
228
|
import { getCollection } from "astro:content";
|
|
57
|
-
// Import the utils corresponding to your configured similarityMethod
|
|
58
|
-
// (BM25Utils in this example, matching the config example above)
|
|
59
229
|
import { BM25Utils } from "astro-relatinator";
|
|
60
230
|
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
231
|
+
const posts = await getCollection("posts");
|
|
232
|
+
const post = posts[0];
|
|
233
|
+
// 2.x returns frontmatter titles, not entry ids.
|
|
234
|
+
const titles = await BM25Utils.findRelated(`${post.data.title} ${post.body}`, post.data.title, 3);
|
|
235
|
+
const related = titles.map((title) => posts.find((p) => p.data.title === title));
|
|
236
|
+
---
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
In version 3.0, one call replaces this code:
|
|
64
240
|
|
|
65
|
-
|
|
66
|
-
|
|
241
|
+
```astro
|
|
242
|
+
---
|
|
243
|
+
import { getCollection } from "astro:content";
|
|
244
|
+
import { getRelated } from "astro-relatinator";
|
|
67
245
|
|
|
246
|
+
const posts = await getCollection("posts");
|
|
247
|
+
const related = await getRelated("posts", posts[0], { limit: 3 });
|
|
68
248
|
---
|
|
249
|
+
```
|
|
69
250
|
|
|
70
|
-
|
|
71
|
-
<!-- render your related posts here -->
|
|
72
|
-
{ related.map((id) => {
|
|
73
|
-
const post = blogEntries.find((post) => post.id === id);
|
|
74
|
-
if (!post) return null; // Handle case where post might not be found
|
|
75
|
-
return (
|
|
76
|
-
<a href={`/posts/${post.slug}/`}> {/* Adjust href based on your routing */}
|
|
77
|
-
<h2>{post.data.title}</h2>
|
|
78
|
-
<p>{post.data.description}</p>
|
|
79
|
-
</a>
|
|
80
|
-
);
|
|
81
|
-
}) }
|
|
82
|
-
</div>
|
|
251
|
+
## Development
|
|
83
252
|
|
|
84
|
-
|
|
253
|
+
This package is part of a pnpm workspace. For the workspace commands, read [Development](https://github.com/DBozhinovski/relatinator#development) in the repository README. For the design decisions and the verification, read [docs/architecture.md](https://github.com/DBozhinovski/relatinator/blob/master/docs/architecture.md).
|
|
254
|
+
|
|
255
|
+
Run these commands from the repository root:
|
|
256
|
+
|
|
257
|
+
| Command | What it does |
|
|
258
|
+
| ---------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
|
259
|
+
| `pnpm --filter astro-relatinator build` | Deletes `dist` and compiles `src` with `tsc`. |
|
|
260
|
+
| `pnpm --filter astro-relatinator test` | Builds `relatinator` in `pretest`. Then it runs the Vitest tests. The tests replace `astro:content` and `emdash` with stubs. |
|
|
261
|
+
| `pnpm --filter astro-relatinator check` | Builds `relatinator` in `precheck`. Then it type-checks `src` and `test` with `tsc`. |
|
|
262
|
+
| `pnpm --filter astro-relatinator format:check` | Checks the format with Prettier. `format` writes the changes. |
|
|
263
|
+
|
|
264
|
+
The tests do not run Astro. To test a change in a real Astro site, build the playground and compare its [expected related lists](https://github.com/DBozhinovski/relatinator/tree/master/packages/playground#expected-related-lists).
|
|
265
|
+
|
|
266
|
+
The `prepack` script runs `build`. Thus `pnpm pack` and `pnpm publish` always include a new build.
|
|
85
267
|
|
|
86
|
-
|
|
268
|
+
## License
|
|
87
269
|
|
|
88
|
-
|
|
270
|
+
MIT
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type ContentEntry } from "./entry-text.js";
|
|
2
|
+
/**
|
|
3
|
+
* Default text for an EmDash entry. Portable Text becomes plain text.
|
|
4
|
+
* Without `fields`, it reads the content fields and the labels of the
|
|
5
|
+
* taxonomy terms, but not the values that EmDash adds to every entry.
|
|
6
|
+
*/
|
|
7
|
+
export declare function emdashEntryText(entry: ContentEntry, fields?: readonly string[]): string;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { entryText } from "./entry-text.js";
|
|
2
|
+
import { portableTextValue } from "./portable-text.js";
|
|
3
|
+
// Values that EmDash adds to `entry.data` next to the content fields
|
|
4
|
+
// (emdash 1.2.0: loader.ts INCLUDE_IN_DATA and `slug`, `seo`; query.ts
|
|
5
|
+
// hydrates `byline`, `bylines`, and `terms`).
|
|
6
|
+
const SYSTEM_KEYS = new Set([
|
|
7
|
+
"id",
|
|
8
|
+
"slug",
|
|
9
|
+
"status",
|
|
10
|
+
"authorId",
|
|
11
|
+
"primaryBylineId",
|
|
12
|
+
"createdAt",
|
|
13
|
+
"updatedAt",
|
|
14
|
+
"publishedAt",
|
|
15
|
+
"scheduledAt",
|
|
16
|
+
"draftRevisionId",
|
|
17
|
+
"liveRevisionId",
|
|
18
|
+
"locale",
|
|
19
|
+
"translationGroup",
|
|
20
|
+
"byline",
|
|
21
|
+
"bylines",
|
|
22
|
+
"seo",
|
|
23
|
+
"terms",
|
|
24
|
+
]);
|
|
25
|
+
function termLabels(terms) {
|
|
26
|
+
if (typeof terms !== "object" || terms === null)
|
|
27
|
+
return [];
|
|
28
|
+
return Object.values(terms)
|
|
29
|
+
.flat()
|
|
30
|
+
.map((term) => term?.label)
|
|
31
|
+
.filter((label) => typeof label === "string");
|
|
32
|
+
}
|
|
33
|
+
function contentData(data) {
|
|
34
|
+
const content = Object.fromEntries(Object.entries(data).filter(([key]) => !SYSTEM_KEYS.has(key)));
|
|
35
|
+
return { ...content, terms: termLabels(data.terms) };
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Default text for an EmDash entry. Portable Text becomes plain text.
|
|
39
|
+
* Without `fields`, it reads the content fields and the labels of the
|
|
40
|
+
* taxonomy terms, but not the values that EmDash adds to every entry.
|
|
41
|
+
*/
|
|
42
|
+
export function emdashEntryText(entry, fields) {
|
|
43
|
+
const data = fields ? entry.data : contentData(entry.data);
|
|
44
|
+
return entryText({ id: entry.id, data }, fields, portableTextValue);
|
|
45
|
+
}
|
package/dist/emdash.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ContentEntry } from "./entry-text.js";
|
|
2
|
+
import { portableTextToPlainText } from "./portable-text.js";
|
|
3
|
+
import { type RelatedOptions } from "./related-index.js";
|
|
4
|
+
export { portableTextToPlainText };
|
|
5
|
+
/** The entry type when `getEmDashRelated` gets an id. */
|
|
6
|
+
export interface EmDashEntry {
|
|
7
|
+
readonly id: string;
|
|
8
|
+
readonly data: Record<string, unknown>;
|
|
9
|
+
}
|
|
10
|
+
export type GetEmDashRelatedOptions<E> = RelatedOptions<E>;
|
|
11
|
+
/**
|
|
12
|
+
* Finds the published entries of an EmDash `collection` that are most
|
|
13
|
+
* related to `entry`, in rank order. The model is built once and reused
|
|
14
|
+
* until an entry changes.
|
|
15
|
+
*/
|
|
16
|
+
export declare function getEmDashRelated<E extends ContentEntry>(collection: string, entry: E, options?: GetEmDashRelatedOptions<E>): Promise<E[]>;
|
|
17
|
+
export declare function getEmDashRelated<E extends ContentEntry = EmDashEntry>(collection: string, entry: string, options?: GetEmDashRelatedOptions<E>): Promise<E[]>;
|
package/dist/emdash.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { emdashEntryText } from "./emdash-text.js";
|
|
2
|
+
import { portableTextToPlainText } from "./portable-text.js";
|
|
3
|
+
import { relatedIndex } from "./related-index.js";
|
|
4
|
+
export { portableTextToPlainText };
|
|
5
|
+
const PAGE_SIZE = 100;
|
|
6
|
+
async function getPublishedEntries(collection) {
|
|
7
|
+
// Imported on call so that astro.config can import this package.
|
|
8
|
+
const { getEmDashCollection } = await import("emdash");
|
|
9
|
+
const entries = [];
|
|
10
|
+
let cursor;
|
|
11
|
+
do {
|
|
12
|
+
const page = await getEmDashCollection(collection, {
|
|
13
|
+
status: "published",
|
|
14
|
+
limit: PAGE_SIZE,
|
|
15
|
+
cursor,
|
|
16
|
+
});
|
|
17
|
+
if (page.error) {
|
|
18
|
+
throw new Error(`astro-relatinator: cannot read EmDash collection "${collection}": ${page.error.message}`, { cause: page.error });
|
|
19
|
+
}
|
|
20
|
+
entries.push(...page.entries);
|
|
21
|
+
cursor = page.nextCursor;
|
|
22
|
+
} while (cursor);
|
|
23
|
+
return entries;
|
|
24
|
+
}
|
|
25
|
+
export async function getEmDashRelated(collection, entry, options = {}) {
|
|
26
|
+
const { text, fields } = options;
|
|
27
|
+
const textOf = text ?? ((e) => emdashEntryText(e, fields));
|
|
28
|
+
const index = relatedIndex({
|
|
29
|
+
key: `emdash\u0000${collection}`,
|
|
30
|
+
entries: await getPublishedEntries(collection),
|
|
31
|
+
textOf,
|
|
32
|
+
// EmDash keeps `updatedAt` when only the terms or bylines of an
|
|
33
|
+
// entry change, so the version is the text.
|
|
34
|
+
versionOf: textOf,
|
|
35
|
+
}, options);
|
|
36
|
+
return index.entries(rank(index.model, collection, entry, textOf, options.limit));
|
|
37
|
+
}
|
|
38
|
+
function rank(model, collection, entry, textOf, limit) {
|
|
39
|
+
const id = typeof entry === "string" ? entry : entry.id;
|
|
40
|
+
if (model.has(id))
|
|
41
|
+
return model.findRelated(id, { limit });
|
|
42
|
+
if (typeof entry === "string") {
|
|
43
|
+
throw new Error(`astro-relatinator: no published entry "${id}" in EmDash collection "${collection}"`);
|
|
44
|
+
}
|
|
45
|
+
return model.findRelatedToText(textOf(entry), { limit, exclude: [id] });
|
|
46
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** The parts of an Astro or EmDash entry that relatinator reads. */
|
|
2
|
+
export interface ContentEntry {
|
|
3
|
+
readonly id: string;
|
|
4
|
+
readonly data: object;
|
|
5
|
+
readonly body?: string;
|
|
6
|
+
readonly digest?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Returns the text of a value that needs special handling, such as
|
|
10
|
+
* Portable Text, or `undefined` to read the value as plain data.
|
|
11
|
+
*/
|
|
12
|
+
type ValueText = (value: unknown) => string | undefined;
|
|
13
|
+
/**
|
|
14
|
+
* Default text for an entry: the string and number values of its data
|
|
15
|
+
* (restricted to `fields` when given), followed by its body.
|
|
16
|
+
*/
|
|
17
|
+
export declare function entryText(entry: ContentEntry, fields?: readonly string[], valueText?: ValueText): string;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
function isPlainObject(value) {
|
|
2
|
+
if (typeof value !== "object" || value === null)
|
|
3
|
+
return false;
|
|
4
|
+
const prototype = Object.getPrototypeOf(value);
|
|
5
|
+
return prototype === Object.prototype || prototype === null;
|
|
6
|
+
}
|
|
7
|
+
function collectText(value, out, valueText) {
|
|
8
|
+
const special = valueText?.(value);
|
|
9
|
+
if (special !== undefined) {
|
|
10
|
+
out.push(special);
|
|
11
|
+
}
|
|
12
|
+
else if (typeof value === "string" || typeof value === "number") {
|
|
13
|
+
out.push(String(value));
|
|
14
|
+
}
|
|
15
|
+
else if (Array.isArray(value)) {
|
|
16
|
+
for (const item of value)
|
|
17
|
+
collectText(item, out, valueText);
|
|
18
|
+
}
|
|
19
|
+
else if (isPlainObject(value)) {
|
|
20
|
+
for (const item of Object.values(value))
|
|
21
|
+
collectText(item, out, valueText);
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Default text for an entry: the string and number values of its data
|
|
27
|
+
* (restricted to `fields` when given), followed by its body.
|
|
28
|
+
*/
|
|
29
|
+
export function entryText(entry, fields, valueText) {
|
|
30
|
+
const data = isPlainObject(entry.data) ? entry.data : {};
|
|
31
|
+
const values = fields ? fields.map((field) => data[field]) : data;
|
|
32
|
+
const parts = collectText(values, [], valueText);
|
|
33
|
+
if (entry.body)
|
|
34
|
+
parts.push(entry.body);
|
|
35
|
+
return parts.join(" ");
|
|
36
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { CollectionEntry, CollectionKey } from "astro:content";
|
|
2
|
+
import { type RelatedOptions } from "./related-index.js";
|
|
3
|
+
export type GetRelatedOptions<E> = RelatedOptions<E>;
|
|
4
|
+
/**
|
|
5
|
+
* Finds the entries of `collection` that are most related to `entry`,
|
|
6
|
+
* in rank order. The model is built once and reused until an entry
|
|
7
|
+
* changes.
|
|
8
|
+
*/
|
|
9
|
+
export declare function getRelated<C extends CollectionKey>(collection: C, entry: CollectionEntry<C> | string, options?: GetRelatedOptions<CollectionEntry<C>>): Promise<CollectionEntry<C>[]>;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { entryText } from "./entry-text.js";
|
|
2
|
+
import { relatedIndex } from "./related-index.js";
|
|
3
|
+
/**
|
|
4
|
+
* Finds the entries of `collection` that are most related to `entry`,
|
|
5
|
+
* in rank order. The model is built once and reused until an entry
|
|
6
|
+
* changes.
|
|
7
|
+
*/
|
|
8
|
+
export async function getRelated(collection, entry, options = {}) {
|
|
9
|
+
const { text, fields } = options;
|
|
10
|
+
// Imported on call so that astro.config can import the integration.
|
|
11
|
+
const { getCollection } = await import("astro:content");
|
|
12
|
+
const entries = await getCollection(collection);
|
|
13
|
+
const textOf = text ?? ((e) => entryText(e, fields));
|
|
14
|
+
const index = relatedIndex({
|
|
15
|
+
key: String(collection),
|
|
16
|
+
entries,
|
|
17
|
+
textOf,
|
|
18
|
+
versionOf: (e) => (!text && e.digest ? e.digest : textOf(e)),
|
|
19
|
+
}, options);
|
|
20
|
+
const id = typeof entry === "string" ? entry : entry.id;
|
|
21
|
+
if (!index.model.has(id)) {
|
|
22
|
+
throw new Error(`astro-relatinator: no entry "${id}" in collection "${String(collection)}"`);
|
|
23
|
+
}
|
|
24
|
+
return index.entries(index.model.findRelated(id, { limit: options.limit }));
|
|
25
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const LEGACY_OPTIONS_WARNING = "Integration options are ignored since astro-relatinator 3.0. Call getRelated() in your pages instead. See https://github.com/DBozhinovski/relatinator/tree/master/packages/astro-relatinator#migrating-from-2x";
|
|
2
|
+
/**
|
|
3
|
+
* Registers astro-relatinator with Astro. It takes no options. A 2.x
|
|
4
|
+
* options object only produces a warning.
|
|
5
|
+
*/
|
|
6
|
+
export default function relatinator(legacyOptions) {
|
|
7
|
+
return {
|
|
8
|
+
name: "astro-relatinator",
|
|
9
|
+
hooks: {
|
|
10
|
+
"astro:config:setup": ({ logger }) => {
|
|
11
|
+
if (legacyOptions !== undefined)
|
|
12
|
+
logger.warn(LEGACY_OPTIONS_WARNING);
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { RelatinatorModel } from "relatinator";
|
|
2
|
+
/**
|
|
3
|
+
* Returns the model stored under `key` while its fingerprint matches.
|
|
4
|
+
* Otherwise builds a new model and replaces the entry, so the cache
|
|
5
|
+
* holds at most one model per key.
|
|
6
|
+
*/
|
|
7
|
+
export declare function getCachedModel(key: string, fingerprint: string, build: () => RelatinatorModel): RelatinatorModel;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const cache = new Map();
|
|
2
|
+
/**
|
|
3
|
+
* Returns the model stored under `key` while its fingerprint matches.
|
|
4
|
+
* Otherwise builds a new model and replaces the entry, so the cache
|
|
5
|
+
* holds at most one model per key.
|
|
6
|
+
*/
|
|
7
|
+
export function getCachedModel(key, fingerprint, build) {
|
|
8
|
+
const cached = cache.get(key);
|
|
9
|
+
if (cached?.fingerprint === fingerprint)
|
|
10
|
+
return cached.model;
|
|
11
|
+
const model = build();
|
|
12
|
+
cache.set(key, { fingerprint, model });
|
|
13
|
+
return model;
|
|
14
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Converts Portable Text to plain text: the span text of each `block`,
|
|
3
|
+
* one block per line. Other block types are skipped. A string is
|
|
4
|
+
* returned as it is.
|
|
5
|
+
*/
|
|
6
|
+
export declare function portableTextToPlainText(value: unknown): string;
|
|
7
|
+
/** The plain text of a Portable Text value, or `undefined` for other values. */
|
|
8
|
+
export declare function portableTextValue(value: unknown): string | undefined;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
function blockText({ _type, children }) {
|
|
2
|
+
if (_type !== "block" || !Array.isArray(children))
|
|
3
|
+
return undefined;
|
|
4
|
+
return children
|
|
5
|
+
.map((span) => typeof span?.text === "string" ? span.text : "")
|
|
6
|
+
.join("");
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Converts Portable Text to plain text: the span text of each `block`,
|
|
10
|
+
* one block per line. Other block types are skipped. A string is
|
|
11
|
+
* returned as it is.
|
|
12
|
+
*/
|
|
13
|
+
export function portableTextToPlainText(value) {
|
|
14
|
+
if (typeof value === "string")
|
|
15
|
+
return value;
|
|
16
|
+
if (!Array.isArray(value))
|
|
17
|
+
return "";
|
|
18
|
+
return value
|
|
19
|
+
.map((block) => blockText(block ?? {}))
|
|
20
|
+
.filter((text) => text !== undefined)
|
|
21
|
+
.join("\n");
|
|
22
|
+
}
|
|
23
|
+
/** The plain text of a Portable Text value, or `undefined` for other values. */
|
|
24
|
+
export function portableTextValue(value) {
|
|
25
|
+
const isPortableText = Array.isArray(value) &&
|
|
26
|
+
value.length > 0 &&
|
|
27
|
+
value.every((item) => typeof item?._type === "string");
|
|
28
|
+
return isPortableText ? portableTextToPlainText(value) : undefined;
|
|
29
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type RelatinatorModel, type SimilarityMethod } from "relatinator";
|
|
2
|
+
import type { ContentEntry } from "./entry-text.js";
|
|
3
|
+
export interface RelatedOptions<E> {
|
|
4
|
+
/** Maximum number of related entries. Default: 5. */
|
|
5
|
+
limit?: number;
|
|
6
|
+
/** Scoring method. Default: "bm25". */
|
|
7
|
+
method?: SimilarityMethod;
|
|
8
|
+
/**
|
|
9
|
+
* Data keys to read. Default: all data. The body of an Astro entry is
|
|
10
|
+
* always read.
|
|
11
|
+
*/
|
|
12
|
+
fields?: readonly string[];
|
|
13
|
+
/** Replaces the default text of each entry. */
|
|
14
|
+
text?: (entry: E) => string;
|
|
15
|
+
}
|
|
16
|
+
/** The entries to rank, and how to read them. */
|
|
17
|
+
export interface Corpus<E> {
|
|
18
|
+
/** Names the corpus in the model cache. */
|
|
19
|
+
readonly key: string;
|
|
20
|
+
readonly entries: readonly E[];
|
|
21
|
+
readonly textOf: (entry: E) => string;
|
|
22
|
+
/** A value that changes when the text of the entry changes. */
|
|
23
|
+
readonly versionOf: (entry: E) => string;
|
|
24
|
+
}
|
|
25
|
+
export interface RelatedIndex<E> {
|
|
26
|
+
readonly model: RelatinatorModel;
|
|
27
|
+
/** The entries of the corpus with the given ids, in the same order. */
|
|
28
|
+
entries(ids: readonly string[]): E[];
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The model of a corpus and a lookup from ids back to its entries. The
|
|
32
|
+
* model is built once and reused until the version of an entry changes.
|
|
33
|
+
* The fingerprint tells apart `text` functions that share their source
|
|
34
|
+
* code.
|
|
35
|
+
*/
|
|
36
|
+
export declare function relatedIndex<E extends ContentEntry>({ key, entries, textOf, versionOf }: Corpus<E>, { method, fields, text }: RelatedOptions<E>): RelatedIndex<E>;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createModel, } from "relatinator";
|
|
2
|
+
import { getCachedModel } from "./model-cache.js";
|
|
3
|
+
/**
|
|
4
|
+
* The model of a corpus and a lookup from ids back to its entries. The
|
|
5
|
+
* model is built once and reused until the version of an entry changes.
|
|
6
|
+
* The fingerprint tells apart `text` functions that share their source
|
|
7
|
+
* code.
|
|
8
|
+
*/
|
|
9
|
+
export function relatedIndex({ key, entries, textOf, versionOf }, { method = "bm25", fields, text }) {
|
|
10
|
+
const source = text ? `text:${text}` : `fields:${JSON.stringify(fields)}`;
|
|
11
|
+
const fingerprint = entries
|
|
12
|
+
.map((entry) => `${entry.id}\u0000${versionOf(entry)}`)
|
|
13
|
+
.join("\u0001");
|
|
14
|
+
const model = getCachedModel(`${key}\u0000${method}\u0000${source}`, fingerprint, () => createModel(entries.map((entry) => ({ id: entry.id, content: textOf(entry) })), { method }));
|
|
15
|
+
const byId = new Map(entries.map((entry) => [entry.id, entry]));
|
|
16
|
+
return {
|
|
17
|
+
model,
|
|
18
|
+
entries: (ids) => ids.map((id) => {
|
|
19
|
+
const entry = byId.get(id);
|
|
20
|
+
if (!entry) {
|
|
21
|
+
throw new Error(`astro-relatinator: internal error: ranked id "${id}" is not in the corpus`);
|
|
22
|
+
}
|
|
23
|
+
return entry;
|
|
24
|
+
}),
|
|
25
|
+
};
|
|
26
|
+
}
|
package/package.json
CHANGED
|
@@ -1,44 +1,71 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "astro-relatinator",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "Find related
|
|
3
|
+
"version": "3.0.0",
|
|
4
|
+
"description": "Find related entries in your Astro content collections",
|
|
5
5
|
"author": "Darko Bozhinovski <dbozhinovski@gmail.com>",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"keywords": [
|
|
8
8
|
"astro",
|
|
9
|
+
"astro-integration",
|
|
10
|
+
"emdash",
|
|
9
11
|
"related-content",
|
|
10
12
|
"tfidf",
|
|
13
|
+
"bm25",
|
|
11
14
|
"relatinator",
|
|
12
15
|
"related",
|
|
13
16
|
"words",
|
|
14
17
|
"posts",
|
|
15
18
|
"articles",
|
|
16
|
-
"static"
|
|
17
|
-
"astro-integration"
|
|
19
|
+
"static"
|
|
18
20
|
],
|
|
19
|
-
"files": [
|
|
20
|
-
"integration.ts"
|
|
21
|
-
],
|
|
22
|
-
"exports": {
|
|
23
|
-
".": "./integration.ts"
|
|
24
|
-
},
|
|
25
21
|
"repository": {
|
|
26
22
|
"type": "git",
|
|
27
23
|
"url": "https://github.com/DBozhinovski/relatinator"
|
|
28
24
|
},
|
|
29
25
|
"type": "module",
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"import": "./dist/index.js"
|
|
30
|
+
},
|
|
31
|
+
"./emdash": {
|
|
32
|
+
"types": "./dist/emdash.d.ts",
|
|
33
|
+
"import": "./dist/emdash.js"
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"dist"
|
|
38
|
+
],
|
|
39
|
+
"sideEffects": false,
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=22.12"
|
|
35
42
|
},
|
|
36
43
|
"dependencies": {
|
|
37
|
-
"
|
|
38
|
-
|
|
39
|
-
|
|
44
|
+
"relatinator": "^3.0.0"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@types/node": "^26.6.5",
|
|
48
|
+
"astro": "7.3.8",
|
|
49
|
+
"prettier": "^3.9.10",
|
|
50
|
+
"typescript": "6.0.3",
|
|
51
|
+
"vitest": "^5.0.4"
|
|
40
52
|
},
|
|
41
53
|
"peerDependencies": {
|
|
42
|
-
"astro": "
|
|
54
|
+
"astro": "^6.0.0 || ^7.0.0",
|
|
55
|
+
"emdash": "^1.0.0"
|
|
56
|
+
},
|
|
57
|
+
"peerDependenciesMeta": {
|
|
58
|
+
"emdash": {
|
|
59
|
+
"optional": true
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
"scripts": {
|
|
63
|
+
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
|
|
64
|
+
"pretest": "pnpm --filter \"astro-relatinator^...\" run build",
|
|
65
|
+
"test": "vitest run",
|
|
66
|
+
"precheck": "pnpm --filter \"astro-relatinator^...\" run build",
|
|
67
|
+
"check": "tsc --noEmit && tsc -p test",
|
|
68
|
+
"format": "prettier --write .",
|
|
69
|
+
"format:check": "prettier --check ."
|
|
43
70
|
}
|
|
44
71
|
}
|
package/integration.ts
DELETED
|
@@ -1,170 +0,0 @@
|
|
|
1
|
-
import type { AstroIntegration } from "astro";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { TfIdfUtils, BM25Utils } from "relatinator";
|
|
4
|
-
import { glob } from "glob";
|
|
5
|
-
import matter from "gray-matter";
|
|
6
|
-
import fs from "node:fs/promises";
|
|
7
|
-
import { RelatinatorDocument } from "relatinator";
|
|
8
|
-
|
|
9
|
-
// Helper to write debug info to a file
|
|
10
|
-
async function writeDebug(message: string) {
|
|
11
|
-
console.log("[astro-relatinator]", message);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
async function trainModel(
|
|
15
|
-
similarityMethod: "tfidf" | "bm25",
|
|
16
|
-
paths: string[],
|
|
17
|
-
schema: string[],
|
|
18
|
-
debug = false
|
|
19
|
-
) {
|
|
20
|
-
const documents: RelatinatorDocument[] = [];
|
|
21
|
-
|
|
22
|
-
if (debug) {
|
|
23
|
-
await writeDebug("[trainModel] Training TF-IDF on existing data...");
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
for (const path of paths) {
|
|
27
|
-
const markdownFiles = await glob(`${path}/**/*.{md,mdx}`);
|
|
28
|
-
|
|
29
|
-
if (debug) {
|
|
30
|
-
await writeDebug(
|
|
31
|
-
`[trainModel] Found ${markdownFiles.length} files in ${path}`
|
|
32
|
-
);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
for (const file of markdownFiles) {
|
|
36
|
-
const content = await fs.readFile(file, "utf-8");
|
|
37
|
-
const parsed = matter(content);
|
|
38
|
-
|
|
39
|
-
// Use the document's title as the ID
|
|
40
|
-
const id = parsed.data.title;
|
|
41
|
-
|
|
42
|
-
let documentContent = "";
|
|
43
|
-
for (const key of schema) {
|
|
44
|
-
if (parsed.data[key]) {
|
|
45
|
-
documentContent += " " + stringifyData(parsed.data[key]);
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
documentContent += " " + parsed.content;
|
|
49
|
-
|
|
50
|
-
if (debug) {
|
|
51
|
-
await writeDebug(`[trainModel] Adding document with ID: ${id}`);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
documents.push({
|
|
55
|
-
id,
|
|
56
|
-
content: documentContent,
|
|
57
|
-
});
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
if (debug) {
|
|
62
|
-
await writeDebug(
|
|
63
|
-
`[trainModel] Training with documents: ${documents
|
|
64
|
-
.map((d) => d.id)
|
|
65
|
-
.join(", ")}`
|
|
66
|
-
);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// Train the existing instance
|
|
70
|
-
if (similarityMethod === "tfidf") {
|
|
71
|
-
TfIdfUtils.train(documents, debug);
|
|
72
|
-
} else {
|
|
73
|
-
BM25Utils.train(documents, debug);
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const stringifyData = (data: any): string => {
|
|
78
|
-
if (typeof data === "object") {
|
|
79
|
-
if (Array.isArray(data)) {
|
|
80
|
-
return data.map(stringifyData).join(" ");
|
|
81
|
-
} else {
|
|
82
|
-
return Object.values(data).map(stringifyData).join(" ");
|
|
83
|
-
}
|
|
84
|
-
} else {
|
|
85
|
-
return `${data}`;
|
|
86
|
-
}
|
|
87
|
-
};
|
|
88
|
-
|
|
89
|
-
const relatinatorIntegration = ({
|
|
90
|
-
paths,
|
|
91
|
-
schema,
|
|
92
|
-
similarityMethod = "tfidf",
|
|
93
|
-
debug = false,
|
|
94
|
-
}: {
|
|
95
|
-
paths: string[];
|
|
96
|
-
schema: string[];
|
|
97
|
-
similarityMethod?: "tfidf" | "bm25";
|
|
98
|
-
debug?: boolean;
|
|
99
|
-
}): AstroIntegration => {
|
|
100
|
-
return {
|
|
101
|
-
name: "astro-relatinator",
|
|
102
|
-
hooks: {
|
|
103
|
-
"astro:server:setup": async ({ server, logger }) => {
|
|
104
|
-
let debounceTimeout: NodeJS.Timeout | null = null;
|
|
105
|
-
const debounceMs = 500; // Debounce delay in milliseconds
|
|
106
|
-
logger.info("relatinator server:setup");
|
|
107
|
-
|
|
108
|
-
logger.info(
|
|
109
|
-
`Training Relatinator using ${similarityMethod} on existing data...`
|
|
110
|
-
);
|
|
111
|
-
if (similarityMethod === "tfidf") {
|
|
112
|
-
await TfIdfUtils.getInstance();
|
|
113
|
-
} else {
|
|
114
|
-
await BM25Utils.getInstance();
|
|
115
|
-
}
|
|
116
|
-
await trainModel(similarityMethod, paths, schema, debug);
|
|
117
|
-
logger.info("Initial training done.");
|
|
118
|
-
|
|
119
|
-
server.watcher.on("all", (eventName, filePath) => {
|
|
120
|
-
if (!["add", "change"].includes(eventName)) {
|
|
121
|
-
return;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
if (path.extname(filePath).includes("md")) {
|
|
125
|
-
// Clear existing timer if there is one
|
|
126
|
-
if (debounceTimeout) {
|
|
127
|
-
clearTimeout(debounceTimeout);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Set a new timer
|
|
131
|
-
debounceTimeout = setTimeout(async () => {
|
|
132
|
-
logger.info(
|
|
133
|
-
`Debounced: Markdown file changed: ${filePath}; Re-training ${similarityMethod}...`
|
|
134
|
-
);
|
|
135
|
-
try {
|
|
136
|
-
// Reset the instance before re-training
|
|
137
|
-
if (similarityMethod === "tfidf") {
|
|
138
|
-
await TfIdfUtils.resetInstance();
|
|
139
|
-
} else {
|
|
140
|
-
await BM25Utils.resetInstance();
|
|
141
|
-
}
|
|
142
|
-
await trainModel(similarityMethod, paths, schema, debug);
|
|
143
|
-
logger.info("Debounced re-training complete.");
|
|
144
|
-
} catch (error) {
|
|
145
|
-
logger.error(`Error during debounced re-training: ${error}`);
|
|
146
|
-
}
|
|
147
|
-
}, debounceMs);
|
|
148
|
-
}
|
|
149
|
-
});
|
|
150
|
-
},
|
|
151
|
-
"astro:build:setup": async ({ logger }) => {
|
|
152
|
-
logger.info("relatinator build:setup");
|
|
153
|
-
|
|
154
|
-
logger.info(
|
|
155
|
-
`Training Relatinator using ${similarityMethod} on existing data...`
|
|
156
|
-
);
|
|
157
|
-
if (similarityMethod === "tfidf") {
|
|
158
|
-
await TfIdfUtils.getInstance();
|
|
159
|
-
} else {
|
|
160
|
-
await BM25Utils.getInstance();
|
|
161
|
-
}
|
|
162
|
-
await trainModel(similarityMethod, paths, schema, debug);
|
|
163
|
-
logger.info(`Training done.`);
|
|
164
|
-
},
|
|
165
|
-
},
|
|
166
|
-
};
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
export { TfIdfUtils, BM25Utils };
|
|
170
|
-
export default relatinatorIntegration;
|