@content-collections/core 0.1.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.
Files changed (87) hide show
  1. package/.turbo/turbo-build.log +14 -0
  2. package/.turbo/turbo-test.log +47 -0
  3. package/.turbo/turbo-typecheck.log +4 -0
  4. package/LICENSE +21 -0
  5. package/coverage/.tmp/coverage-0.json +1 -0
  6. package/coverage/.tmp/coverage-1.json +1 -0
  7. package/coverage/.tmp/coverage-2.json +1 -0
  8. package/coverage/.tmp/coverage-3.json +1 -0
  9. package/coverage/.tmp/coverage-4.json +1 -0
  10. package/coverage/.tmp/coverage-5.json +1 -0
  11. package/coverage/.tmp/coverage-6.json +1 -0
  12. package/coverage/.tmp/coverage-7.json +1 -0
  13. package/coverage/.tmp/coverage-8.json +1 -0
  14. package/coverage/.tmp/coverage-9.json +1 -0
  15. package/coverage/base.css +224 -0
  16. package/coverage/block-navigation.js +87 -0
  17. package/coverage/builder.ts.html +424 -0
  18. package/coverage/clover.xml +960 -0
  19. package/coverage/collector.ts.html +427 -0
  20. package/coverage/config.ts.html +403 -0
  21. package/coverage/configurationReader.ts.html +409 -0
  22. package/coverage/coverage-final.json +11 -0
  23. package/coverage/events.ts.html +310 -0
  24. package/coverage/favicon.png +0 -0
  25. package/coverage/index.html +251 -0
  26. package/coverage/index.ts.html +106 -0
  27. package/coverage/prettify.css +1 -0
  28. package/coverage/prettify.js +2 -0
  29. package/coverage/sort-arrow-sprite.png +0 -0
  30. package/coverage/sorter.js +196 -0
  31. package/coverage/synchronizer.ts.html +394 -0
  32. package/coverage/transformer.ts.html +607 -0
  33. package/coverage/utils.ts.html +118 -0
  34. package/coverage/writer.ts.html +424 -0
  35. package/dist/index.cjs +416 -0
  36. package/dist/index.d.cts +59 -0
  37. package/dist/index.d.ts +146 -0
  38. package/dist/index.js +630 -0
  39. package/package.json +39 -0
  40. package/src/__tests__/.content-collections/cache/config.001.ts.js +19 -0
  41. package/src/__tests__/.content-collections/cache/config.002.mjs +16 -0
  42. package/src/__tests__/.content-collections/cache/config.002.ts.js +16 -0
  43. package/src/__tests__/.content-collections/cache/config.003.ts.js +24 -0
  44. package/src/__tests__/.content-collections/cache/config.004.mjs +47 -0
  45. package/src/__tests__/.content-collections/different-cache-dir/different.config.js +19 -0
  46. package/src/__tests__/.content-collections/generated-config.002/allPosts.json +13 -0
  47. package/src/__tests__/.content-collections/generated-config.002/index.d.ts +7 -0
  48. package/src/__tests__/.content-collections/generated-config.002/index.js +5 -0
  49. package/src/__tests__/.content-collections/generated-config.004/allAuthors.json +13 -0
  50. package/src/__tests__/.content-collections/generated-config.004/allPosts.json +13 -0
  51. package/src/__tests__/.content-collections/generated-config.004/index.d.ts +10 -0
  52. package/src/__tests__/.content-collections/generated-config.004/index.js +6 -0
  53. package/src/__tests__/collections/posts.ts +15 -0
  54. package/src/__tests__/config.001.ts +19 -0
  55. package/src/__tests__/config.002.ts +14 -0
  56. package/src/__tests__/config.003.ts +6 -0
  57. package/src/__tests__/config.004.ts +47 -0
  58. package/src/__tests__/invalid +1 -0
  59. package/src/__tests__/sources/authors/trillian.md +8 -0
  60. package/src/__tests__/sources/posts/first.md +6 -0
  61. package/src/__tests__/sources/test/001.md +5 -0
  62. package/src/__tests__/sources/test/002.md +5 -0
  63. package/src/__tests__/sources/test/broken-frontmatter +6 -0
  64. package/src/builder.test.ts +180 -0
  65. package/src/builder.ts +113 -0
  66. package/src/collector.test.ts +157 -0
  67. package/src/collector.ts +114 -0
  68. package/src/config.ts +106 -0
  69. package/src/configurationReader.test.ts +104 -0
  70. package/src/configurationReader.ts +108 -0
  71. package/src/events.test.ts +84 -0
  72. package/src/events.ts +75 -0
  73. package/src/index.ts +7 -0
  74. package/src/synchronizer.test.ts +192 -0
  75. package/src/synchronizer.ts +103 -0
  76. package/src/transformer.test.ts +431 -0
  77. package/src/transformer.ts +174 -0
  78. package/src/types.test.ts +137 -0
  79. package/src/types.ts +33 -0
  80. package/src/utils.test.ts +48 -0
  81. package/src/utils.ts +11 -0
  82. package/src/watcher.test.ts +200 -0
  83. package/src/watcher.ts +56 -0
  84. package/src/writer.test.ts +135 -0
  85. package/src/writer.ts +113 -0
  86. package/tsconfig.json +27 -0
  87. package/vite.config.ts +24 -0
@@ -0,0 +1,431 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { z } from "zod";
3
+ import {
4
+ TransformError,
5
+ ResolvedCollection,
6
+ createTransformer,
7
+ } from "./transformer";
8
+ import { CollectionFile } from "./types";
9
+ import { Meta, defineCollection } from "./config";
10
+ import { Events, createEmitter } from "./events";
11
+
12
+ const sampleOne: CollectionFile = {
13
+ data: {
14
+ name: "One",
15
+ content: "# One",
16
+ },
17
+ path: "001.md",
18
+ };
19
+
20
+ const sampleTwo: CollectionFile = {
21
+ data: {
22
+ name: "Two",
23
+ content: "# Two",
24
+ },
25
+ path: "002.md",
26
+ };
27
+
28
+ const sampleThree: CollectionFile = {
29
+ data: {
30
+ name: "Three",
31
+ content: "# Three",
32
+ },
33
+ path: "nested/003.md",
34
+ };
35
+
36
+ const sampleFour: CollectionFile = {
37
+ data: {
38
+ name: "Four",
39
+ content: "# Four",
40
+ },
41
+ path: "nested/index.md",
42
+ };
43
+
44
+ const firstPost: CollectionFile = {
45
+ data: {
46
+ title: "First post",
47
+ author: "trillian",
48
+ content: "# First post",
49
+ },
50
+ path: "first.md",
51
+ };
52
+
53
+ const authorTrillian: CollectionFile = {
54
+ data: {
55
+ ref: "trillian",
56
+ displayName: "Tricia Marie McMillan",
57
+ content: "# Trillian",
58
+ },
59
+ path: "trillian.md",
60
+ };
61
+
62
+ describe("transform", () => {
63
+ let emitter = createEmitter<Events>();
64
+
65
+ beforeEach(() => {
66
+ emitter = createEmitter<Events>();
67
+ });
68
+
69
+ function createSampleCollection(
70
+ ...files: Array<CollectionFile>
71
+ ): ResolvedCollection {
72
+ return {
73
+ name: "sample",
74
+ typeName: "Sample",
75
+ schema: {
76
+ name: z.string(),
77
+ },
78
+ directory: "tests",
79
+ include: "*.md",
80
+ files,
81
+ };
82
+ }
83
+
84
+ function createNestedSampleCollection(
85
+ ...files: Array<CollectionFile>
86
+ ): ResolvedCollection {
87
+ return {
88
+ name: "sample",
89
+ typeName: "Sample",
90
+ schema: {
91
+ name: z.string(),
92
+ },
93
+ directory: "tests",
94
+ include: "**/*.md",
95
+ files,
96
+ };
97
+ }
98
+
99
+ it("should create two document", async () => {
100
+ const [collection] = await createTransformer(emitter)([
101
+ createSampleCollection(sampleOne, sampleTwo),
102
+ ]);
103
+
104
+ expect(collection?.documents).toHaveLength(2);
105
+ });
106
+
107
+ it("should create document with meta", async () => {
108
+ const [collection] = await createTransformer(emitter)([
109
+ createNestedSampleCollection(sampleThree),
110
+ ]);
111
+
112
+ const meta: Meta = collection?.documents[0].document._meta;
113
+ if (!meta) {
114
+ throw new Error("No meta");
115
+ }
116
+
117
+ expect(meta.filePath).toBe("nested/003.md");
118
+ expect(meta.fileName).toBe("003.md");
119
+ expect(meta.directory).toBe("nested");
120
+ expect(meta.extension).toBe("md");
121
+ expect(meta.path).toBe("nested/003");
122
+ });
123
+
124
+ it("should create document with meta for index files", async () => {
125
+ const [collection] = await createTransformer(emitter)([
126
+ createNestedSampleCollection(sampleFour),
127
+ ]);
128
+
129
+ const meta: Meta = collection?.documents[0].document._meta;
130
+ if (!meta) {
131
+ throw new Error("No meta");
132
+ }
133
+
134
+ expect(meta.filePath).toBe("nested/index.md");
135
+ expect(meta.fileName).toBe("index.md");
136
+ expect(meta.directory).toBe("nested");
137
+ expect(meta.extension).toBe("md");
138
+ expect(meta.path).toBe("nested");
139
+ });
140
+
141
+ it("should parse documents data", async () => {
142
+ const [collection] = await createTransformer(emitter)([
143
+ createSampleCollection(sampleOne),
144
+ ]);
145
+
146
+ expect(collection?.documents[0].document.name).toBe("One");
147
+ });
148
+
149
+ it("should transform document", async () => {
150
+ const sample = defineCollection({
151
+ name: "sample",
152
+ schema: (z) => ({
153
+ name: z.string(),
154
+ }),
155
+ directory: "tests",
156
+ include: "*.md",
157
+ transform: (_, document) => {
158
+ return {
159
+ ...document,
160
+ test: "test",
161
+ upperName: document.name.toUpperCase(),
162
+ };
163
+ },
164
+ });
165
+
166
+ const [collection] = await createTransformer(emitter)([
167
+ {
168
+ ...sample,
169
+ files: [sampleOne],
170
+ },
171
+ ]);
172
+
173
+ expect(collection?.documents[0].document.test).toBe("test");
174
+ expect(collection?.documents[0].document.upperName).toBe("ONE");
175
+ });
176
+
177
+ it("should add the content to the document", async () => {
178
+ const sample = defineCollection({
179
+ name: "sample",
180
+ schema: (z) => ({
181
+ name: z.string(),
182
+ }),
183
+ directory: "tests",
184
+ include: "*.md",
185
+ });
186
+
187
+ const [collection] = await createTransformer(emitter)([
188
+ {
189
+ ...sample,
190
+ files: [sampleOne],
191
+ },
192
+ ]);
193
+
194
+ expect(collection?.documents[0]?.document.content.trim()).toBe("# One");
195
+ });
196
+
197
+ it("should collect with multiple collections", async () => {
198
+ const posts = defineCollection({
199
+ name: "posts",
200
+ schema: (z) => ({
201
+ title: z.string(),
202
+ author: z.string(),
203
+ }),
204
+ directory: "tests",
205
+ include: "*.md",
206
+ });
207
+
208
+ const authors = defineCollection({
209
+ name: "authors",
210
+ schema: (z) => ({
211
+ ref: z.string(),
212
+ displayName: z.string(),
213
+ }),
214
+ directory: "tests",
215
+ include: "*.md",
216
+ });
217
+
218
+ const collections = await createTransformer(emitter)([
219
+ {
220
+ ...posts,
221
+ files: [firstPost],
222
+ },
223
+ {
224
+ ...authors,
225
+ files: [authorTrillian],
226
+ },
227
+ ]);
228
+ expect(collections).toHaveLength(2);
229
+
230
+ expect(collections[0]?.documents).toHaveLength(1);
231
+ expect(collections[1]?.documents).toHaveLength(1);
232
+ });
233
+
234
+ it("should transform with collection references", async () => {
235
+ const authors = defineCollection({
236
+ name: "authors",
237
+ schema: (z) => ({
238
+ ref: z.string(),
239
+ displayName: z.string(),
240
+ }),
241
+ directory: "tests",
242
+ include: "*.md",
243
+ });
244
+
245
+ const posts = defineCollection({
246
+ name: "posts",
247
+ schema: (z) => ({
248
+ title: z.string(),
249
+ author: z.string(),
250
+ }),
251
+ directory: "tests",
252
+ include: "*.md",
253
+ transform: async (context, document) => {
254
+ const author = await context
255
+ .documents(authors)
256
+ .find((a) => a.ref === document.author);
257
+ return {
258
+ ...document,
259
+ author: author?.displayName,
260
+ };
261
+ },
262
+ });
263
+
264
+ const collections = await createTransformer(emitter)([
265
+ {
266
+ ...authors,
267
+ files: [authorTrillian],
268
+ },
269
+ {
270
+ ...posts,
271
+ files: [firstPost],
272
+ },
273
+ ]);
274
+ expect(collections[1]?.documents[0]?.document.author).toBe(
275
+ "Tricia Marie McMillan"
276
+ );
277
+ });
278
+
279
+ it("should throw if document validation fails", async () => {
280
+ const posts = defineCollection({
281
+ name: "posts",
282
+ schema: (z) => ({
283
+ name: z.string(),
284
+ }),
285
+ directory: "tests",
286
+ include: "*.md",
287
+ });
288
+
289
+ emitter.on("transformer:validation-error", (event) => {
290
+ throw event.error;
291
+ });
292
+
293
+ await expect(
294
+ createTransformer(emitter)([
295
+ {
296
+ ...posts,
297
+ files: [firstPost],
298
+ },
299
+ ])
300
+ ).rejects.toThrowError(/invalid_type/);
301
+ });
302
+
303
+ it("should capture validation error", async () => {
304
+ const posts = defineCollection({
305
+ name: "posts",
306
+ schema: (z) => ({
307
+ name: z.string(),
308
+ }),
309
+ directory: "tests",
310
+ include: "*.md",
311
+ });
312
+
313
+ const errors: Array<TransformError> = [];
314
+ emitter.on("transformer:validation-error", (event) =>
315
+ errors.push(event.error)
316
+ );
317
+ await createTransformer(emitter)([
318
+ {
319
+ ...posts,
320
+ files: [firstPost],
321
+ },
322
+ ]);
323
+ expect(errors[0]?.type).toBe("Validation");
324
+ });
325
+
326
+ it("should not hold invalid documents", async () => {
327
+ const posts = defineCollection({
328
+ name: "posts",
329
+ schema: (z) => ({
330
+ name: z.string(),
331
+ }),
332
+ directory: "tests",
333
+ include: "*.md",
334
+ });
335
+
336
+ const [collection] = await createTransformer(emitter)([
337
+ {
338
+ ...posts,
339
+ files: [firstPost],
340
+ },
341
+ ]);
342
+ expect(collection?.documents).toHaveLength(0);
343
+ });
344
+
345
+ it("should report an error if a collection is not registered", async () => {
346
+ const authors = defineCollection({
347
+ name: "authors",
348
+ schema: (z) => ({
349
+ ref: z.string(),
350
+ displayName: z.string(),
351
+ }),
352
+ directory: "tests",
353
+ include: "*.md",
354
+ });
355
+
356
+ const posts = defineCollection({
357
+ name: "posts",
358
+ schema: (z) => ({
359
+ title: z.string(),
360
+ }),
361
+ directory: "tests",
362
+ include: "*.md",
363
+ transform: async (context, document) => {
364
+ const allAuthors = await context.documents(authors);
365
+ return {
366
+ ...document,
367
+ allAuthors,
368
+ };
369
+ },
370
+ });
371
+
372
+ const errors: Array<TransformError> = [];
373
+ emitter.on("transformer:error", (event) => errors.push(event.error));
374
+ await createTransformer(emitter)([
375
+ {
376
+ ...posts,
377
+ files: [firstPost],
378
+ },
379
+ ]);
380
+ expect(errors[0]?.type).toBe("Configuration");
381
+ });
382
+
383
+ it("should report an transform error", async () => {
384
+ const posts = defineCollection({
385
+ name: "posts",
386
+ schema: (z) => ({
387
+ title: z.string(),
388
+ }),
389
+ directory: "tests",
390
+ include: "*.md",
391
+ transform: (doc) => {
392
+ throw new Error("Something went wrong");
393
+ return doc;
394
+ },
395
+ });
396
+
397
+ const errors: Array<TransformError> = [];
398
+ emitter.on("transformer:error", (event) => errors.push(event.error));
399
+
400
+ await createTransformer(emitter)([
401
+ {
402
+ ...posts,
403
+ files: [firstPost],
404
+ },
405
+ ]);
406
+ expect(errors[0]?.type).toBe("Transform");
407
+ });
408
+
409
+ it("should exclude documents with a transform error", async () => {
410
+ const posts = defineCollection({
411
+ name: "posts",
412
+ schema: (z) => ({
413
+ title: z.string(),
414
+ }),
415
+ directory: "tests",
416
+ include: "*.md",
417
+ transform: (doc) => {
418
+ throw new Error("Something went wrong");
419
+ return doc;
420
+ },
421
+ });
422
+
423
+ const [collection] = await createTransformer(emitter)([
424
+ {
425
+ ...posts,
426
+ files: [firstPost],
427
+ },
428
+ ]);
429
+ expect(collection?.documents).toHaveLength(0);
430
+ });
431
+ });
@@ -0,0 +1,174 @@
1
+ import { CollectionFile } from "./types";
2
+ import { AnyCollection, Context } from "./config";
3
+ import { isDefined } from "./utils";
4
+ import { Emitter } from "./events";
5
+ import { basename, dirname, extname } from "node:path";
6
+ import { z } from "zod";
7
+
8
+ export type TransformerEvents = {
9
+ "transformer:validation-error": {
10
+ collection: AnyCollection;
11
+ file: CollectionFile;
12
+ error: TransformError;
13
+ };
14
+ "transformer:error": {
15
+ collection: AnyCollection;
16
+ error: TransformError;
17
+ };
18
+ };
19
+
20
+ type ParsedFile = {
21
+ document: any;
22
+ };
23
+
24
+ export type ResolvedCollection = AnyCollection & {
25
+ files: Array<CollectionFile>;
26
+ };
27
+
28
+ export type TransformedCollection = AnyCollection & {
29
+ documents: Array<any>;
30
+ };
31
+
32
+ export type ErrorType = "Validation" | "Configuration" | "Transform";
33
+
34
+ export class TransformError extends Error {
35
+ type: ErrorType;
36
+ constructor(type: ErrorType, message: string) {
37
+ super(message);
38
+ this.type = type;
39
+ }
40
+ }
41
+
42
+ function createPath(path: string, ext: string) {
43
+ let p = path.slice(0, -ext.length);
44
+ if (p.endsWith("/index")) {
45
+ p = p.slice(0, -6);
46
+ }
47
+ return p;
48
+ }
49
+
50
+ export function createTransformer(emitter: Emitter) {
51
+ function createSchema(schema: z.ZodRawShape) {
52
+ return z.object({
53
+ content: z.string(),
54
+ ...schema,
55
+ });
56
+ }
57
+
58
+ async function parseFile(
59
+ collection: AnyCollection,
60
+ file: CollectionFile
61
+ ): Promise<ParsedFile | null> {
62
+ const { data, path } = file;
63
+
64
+ const schema = createSchema(collection.schema);
65
+
66
+ let parsedData = await schema.safeParseAsync(data);
67
+ if (!parsedData.success) {
68
+ emitter.emit("transformer:validation-error", {
69
+ collection,
70
+ file,
71
+ error: new TransformError("Validation", parsedData.error.message),
72
+ });
73
+ return null;
74
+ }
75
+
76
+ const ext = extname(path);
77
+
78
+ let extension = ext;
79
+ if (extension.startsWith(".")) {
80
+ extension = extension.slice(1);
81
+ }
82
+
83
+ const document = {
84
+ ...parsedData.data,
85
+ _meta: {
86
+ filePath: path,
87
+ fileName: basename(path),
88
+ directory: dirname(path),
89
+ extension,
90
+ path: createPath(path, ext),
91
+ },
92
+ };
93
+
94
+ return {
95
+ document,
96
+ };
97
+ }
98
+
99
+ async function parseCollection(
100
+ collection: ResolvedCollection
101
+ ): Promise<TransformedCollection> {
102
+ const promises = collection.files.map((file) =>
103
+ parseFile(collection, file)
104
+ );
105
+ return {
106
+ ...collection,
107
+ documents: (await Promise.all(promises)).filter(isDefined),
108
+ };
109
+ }
110
+
111
+ function createContext(
112
+ collections: Array<TransformedCollection>,
113
+ ): Context {
114
+ return {
115
+ documents: (collection) => {
116
+ const resolved = collections.find((c) => c.name === collection.name);
117
+ if (!resolved) {
118
+ throw new TransformError(
119
+ "Configuration",
120
+ `Collection ${collection.name} not found, do you have registered it in your configuration?`
121
+ );
122
+ }
123
+ return resolved.documents.map((doc) => doc.document);
124
+ },
125
+ };
126
+ }
127
+
128
+ async function transformCollection(
129
+ collections: Array<TransformedCollection>,
130
+ collection: TransformedCollection
131
+ ) {
132
+ if (collection.transform) {
133
+ const docs = [];
134
+ const context = createContext(collections);
135
+ for (const doc of collection.documents) {
136
+ try {
137
+ docs.push({
138
+ ...doc,
139
+ document: await collection.transform(context, doc.document),
140
+ });
141
+ } catch (error) {
142
+ if (error instanceof TransformError) {
143
+ emitter.emit("transformer:error", {
144
+ collection,
145
+ error,
146
+ });
147
+ } else {
148
+ emitter.emit("transformer:error", {
149
+ collection,
150
+ error: new TransformError("Transform", String(error)),
151
+ });
152
+ }
153
+ }
154
+ }
155
+ return docs;
156
+ }
157
+ return collection.documents;
158
+ }
159
+
160
+ return async (untransformedCollections: Array<ResolvedCollection>) => {
161
+ const promises = untransformedCollections.map((collection) =>
162
+ parseCollection(collection)
163
+ );
164
+ const collections = await Promise.all(promises);
165
+
166
+ for (const collection of collections) {
167
+ collection.documents = await transformCollection(collections, collection);
168
+ }
169
+
170
+ return collections;
171
+ };
172
+ }
173
+
174
+ export type Transformer = ReturnType<typeof createTransformer>;