@farm.js/content 0.1.0-beta.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 +22 -0
- package/README.md +96 -0
- package/dist/assets.d.ts +28 -0
- package/dist/assets.d.ts.map +1 -0
- package/dist/assets.js +348 -0
- package/dist/config.d.ts +11 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +73 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +135 -0
- package/dist/loader.d.ts +13 -0
- package/dist/loader.d.ts.map +1 -0
- package/dist/loader.js +360 -0
- package/dist/markdown-assets.d.ts +7 -0
- package/dist/markdown-assets.d.ts.map +1 -0
- package/dist/markdown-assets.js +197 -0
- package/dist/runtime.d.ts +11 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +66 -0
- package/dist/server.d.ts +9 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +15 -0
- package/dist/types.d.ts +149 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/package.json +64 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Farm.js Team
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
package/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# `@farm.js/content`
|
|
2
|
+
|
|
3
|
+
Load local Markdown, MDX, JSON, and YAML as schema-validated, typed content collections in Farm.js.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add @farm.js/content zod
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Zod is optional. Any Standard Schema-compatible validator works, as does an object with `parse` or
|
|
12
|
+
`parseAsync`.
|
|
13
|
+
|
|
14
|
+
## Configure
|
|
15
|
+
|
|
16
|
+
Collections live directly in `farm.config.ts`. There is no required `content.config.ts` and no
|
|
17
|
+
virtual module to import from application code.
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { asset, collection, content, files } from "@farm.js/content";
|
|
21
|
+
import { defineConfig } from "@farm.js/core";
|
|
22
|
+
import { z } from "zod";
|
|
23
|
+
|
|
24
|
+
export default defineConfig({
|
|
25
|
+
plugins: [
|
|
26
|
+
content({
|
|
27
|
+
collections: {
|
|
28
|
+
posts: collection({
|
|
29
|
+
source: files("content/posts/**/*.{md,mdx}"),
|
|
30
|
+
schema: z.object({
|
|
31
|
+
title: z.string(),
|
|
32
|
+
publishedAt: z.coerce.date(),
|
|
33
|
+
tags: z.array(z.string()).default([]),
|
|
34
|
+
}),
|
|
35
|
+
assets: {
|
|
36
|
+
image: asset.image().optional(),
|
|
37
|
+
downloads: asset.files().default([]),
|
|
38
|
+
},
|
|
39
|
+
transform: ({ data, words }) => ({
|
|
40
|
+
...data,
|
|
41
|
+
readingMinutes: Math.max(1, Math.ceil(words / 220)),
|
|
42
|
+
}),
|
|
43
|
+
}),
|
|
44
|
+
},
|
|
45
|
+
}),
|
|
46
|
+
],
|
|
47
|
+
});
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
For a large setup, move the ordinary collection objects to any application file and import the
|
|
51
|
+
result into `farm.config.ts`. The filename is an application choice, not another framework config.
|
|
52
|
+
|
|
53
|
+
## Read on the server
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
import { getCollection, getEntry, getEntryOrThrow } from "@farm.js/content/server";
|
|
57
|
+
|
|
58
|
+
const posts = await getCollection("posts");
|
|
59
|
+
const optionalPost = await getEntry("posts", "guides/getting-started");
|
|
60
|
+
const post = await getEntryOrThrow("posts", "guides/getting-started");
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Asset fields are written as ordinary relative frontmatter paths but resolve to typed metadata under
|
|
64
|
+
`entry.data`. Keep those fields out of the Standard Schema object: `asset.image()` performs image
|
|
65
|
+
validation and produces `src`, `width`, and `height`, while `asset.file()` produces file metadata.
|
|
66
|
+
Plural helpers accept arrays, and declarations support `.optional()` and `.default(...)`.
|
|
67
|
+
|
|
68
|
+
The same `assets` option processes relative Markdown image and file destinations automatically.
|
|
69
|
+
Use `assets: true` when no typed frontmatter fields are needed. Farm emits content-hashed URLs,
|
|
70
|
+
tracks the source files for development reloads, and exposes discovered metadata through
|
|
71
|
+
`entry.bodyAssets`.
|
|
72
|
+
|
|
73
|
+
`src/farm.d.ts` infers the collection name and final `data` type from `farm.config.ts`, including
|
|
74
|
+
values added by `transform`. Run `farm generate` after adding the plugin if the development server
|
|
75
|
+
has not generated the declaration yet.
|
|
76
|
+
|
|
77
|
+
Entry IDs are project-route friendly. They are derived from the path below the source base, with
|
|
78
|
+
the file extension and trailing `index` removed. For example,
|
|
79
|
+
`content/posts/guides/index.mdx` becomes `guides`.
|
|
80
|
+
|
|
81
|
+
## Runtime contract
|
|
82
|
+
|
|
83
|
+
- Source files are parsed and validated during Farm configuration, not on each request.
|
|
84
|
+
- Development watches supported content files, regenerates the private module, and reloads the page.
|
|
85
|
+
- Managed assets are validated inside the project, emitted through Vite, and watched for changes.
|
|
86
|
+
- Production bundles the validated collection into the server output, so source files do not need
|
|
87
|
+
to exist on the deployed filesystem.
|
|
88
|
+
- Dates, bigints, arrays, plain objects, `undefined`, and primitive values keep their runtime types.
|
|
89
|
+
- Functions, symbols, class instances, circular values, invalid dates, and non-finite numbers fail
|
|
90
|
+
with an actionable build error instead of silently changing shape.
|
|
91
|
+
- `@farm.js/content/server` is server-only. Use it in Server Components, static path generation,
|
|
92
|
+
server queries, server functions, or API routes.
|
|
93
|
+
|
|
94
|
+
The plugin returns Markdown or MDX in `entry.body`. It stays unchanged unless assets are enabled;
|
|
95
|
+
then only managed Markdown destinations are replaced with emitted URLs. Rendering stays explicit so
|
|
96
|
+
applications can choose their renderer, component mapping, and HTML security policy.
|
package/dist/assets.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { ContentAssetField, ContentAssetFields, ContentFileAsset, ContentImageAsset } from "./types.js";
|
|
2
|
+
export interface ContentAssetImport {
|
|
3
|
+
readonly token: string;
|
|
4
|
+
readonly filePath: string;
|
|
5
|
+
}
|
|
6
|
+
export interface ContentAssetContext {
|
|
7
|
+
readonly root: string;
|
|
8
|
+
readonly collectionName: string;
|
|
9
|
+
readonly sourceFile: string;
|
|
10
|
+
readonly sourceFiles: Set<string>;
|
|
11
|
+
readonly imports: Map<string, ContentAssetImport>;
|
|
12
|
+
}
|
|
13
|
+
export declare const asset: Readonly<{
|
|
14
|
+
image: () => ContentAssetField<ContentImageAsset, string>;
|
|
15
|
+
images: () => ContentAssetField<readonly ContentImageAsset[], readonly string[]>;
|
|
16
|
+
file: () => ContentAssetField<ContentFileAsset, string>;
|
|
17
|
+
files: () => ContentAssetField<readonly ContentFileAsset[], readonly string[]>;
|
|
18
|
+
}>;
|
|
19
|
+
export declare function normalizeContentAssetFields(input: unknown): ContentAssetFields;
|
|
20
|
+
export declare function splitContentAssetInput(input: unknown, fields: ContentAssetFields): {
|
|
21
|
+
schemaInput: unknown;
|
|
22
|
+
assetInput: unknown;
|
|
23
|
+
};
|
|
24
|
+
export declare function resolveContentAssetFields(fields: ContentAssetFields, input: unknown, context: ContentAssetContext): Promise<Record<string, unknown>>;
|
|
25
|
+
export declare function mergeContentAssetData(data: unknown, assets: Record<string, unknown>, context: ContentAssetContext): Record<string, unknown>;
|
|
26
|
+
export declare function resolveContentAsset(reference: string, expectedKind: "image" | "file", context: ContentAssetContext, diagnosticPath: readonly string[]): Promise<ContentFileAsset | ContentImageAsset>;
|
|
27
|
+
export declare function isContentAssetField(value: unknown): value is ContentAssetField<any, any>;
|
|
28
|
+
//# sourceMappingURL=assets.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"assets.d.ts","sourceRoot":"","sources":["../src/assets.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,iBAAiB,EACjB,kBAAkB,EAClB,gBAAgB,EAChB,iBAAiB,EAClB,MAAM,YAAY,CAAC;AAEpB,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;CAC3B;AAED,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,WAAW,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC;IAClC,QAAQ,CAAC,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,kBAAkB,CAAC,CAAC;CACnD;AAyCD,eAAO,MAAM,KAAK;;;;;EAKhB,CAAC;AAEH,wBAAgB,2BAA2B,CAAC,KAAK,EAAE,OAAO,GAAG,kBAAkB,CA8B9E;AAED,wBAAgB,sBAAsB,CACpC,KAAK,EAAE,OAAO,EACd,MAAM,EAAE,kBAAkB,GACzB;IAAE,WAAW,EAAE,OAAO,CAAC;IAAC,UAAU,EAAE,OAAO,CAAA;CAAE,CAsC/C;AAED,wBAAsB,yBAAyB,CAC7C,MAAM,EAAE,kBAAkB,EAC1B,KAAK,EAAE,OAAO,EACd,OAAO,EAAE,mBAAmB,GAC3B,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAyBlC;AAED,wBAAgB,qBAAqB,CACnC,IAAI,EAAE,OAAO,EACb,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,OAAO,EAAE,mBAAmB,GAC3B,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CA0CzB;AAED,wBAAsB,mBAAmB,CACvC,SAAS,EAAE,MAAM,EACjB,YAAY,EAAE,OAAO,GAAG,MAAM,EAC9B,OAAO,EAAE,mBAAmB,EAC5B,cAAc,EAAE,SAAS,MAAM,EAAE,GAChC,OAAO,CAAC,gBAAgB,GAAG,iBAAiB,CAAC,CAsF/C;AAED,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,iBAAiB,CAAC,GAAG,EAAE,GAAG,CAAC,CAYxF"}
|
package/dist/assets.js
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { readFile, realpath, stat } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { imageSize } from "image-size";
|
|
5
|
+
const IMAGE_FORMATS = Object.freeze({
|
|
6
|
+
".avif": { detected: ["heif"], type: "image/avif" },
|
|
7
|
+
".bmp": { detected: ["bmp"], type: "image/bmp" },
|
|
8
|
+
".gif": { detected: ["gif"], type: "image/gif" },
|
|
9
|
+
".heic": { detected: ["heif"], type: "image/heic" },
|
|
10
|
+
".heif": { detected: ["heif"], type: "image/heif" },
|
|
11
|
+
".ico": { detected: ["ico"], type: "image/x-icon" },
|
|
12
|
+
".jpe": { detected: ["jpg"], type: "image/jpeg" },
|
|
13
|
+
".jpeg": { detected: ["jpg"], type: "image/jpeg" },
|
|
14
|
+
".jpg": { detected: ["jpg"], type: "image/jpeg" },
|
|
15
|
+
".png": { detected: ["png"], type: "image/png" },
|
|
16
|
+
".svg": { detected: ["svg"], type: "image/svg+xml" },
|
|
17
|
+
".tif": { detected: ["tiff"], type: "image/tiff" },
|
|
18
|
+
".tiff": { detected: ["tiff"], type: "image/tiff" },
|
|
19
|
+
".webp": { detected: ["webp"], type: "image/webp" },
|
|
20
|
+
});
|
|
21
|
+
const FILE_MIME_TYPES = Object.freeze({
|
|
22
|
+
".avif": "image/avif",
|
|
23
|
+
".css": "text/css",
|
|
24
|
+
".csv": "text/csv",
|
|
25
|
+
".gif": "image/gif",
|
|
26
|
+
".html": "text/html",
|
|
27
|
+
".jpeg": "image/jpeg",
|
|
28
|
+
".jpg": "image/jpeg",
|
|
29
|
+
".json": "application/json",
|
|
30
|
+
".pdf": "application/pdf",
|
|
31
|
+
".png": "image/png",
|
|
32
|
+
".svg": "image/svg+xml",
|
|
33
|
+
".txt": "text/plain",
|
|
34
|
+
".webp": "image/webp",
|
|
35
|
+
".xml": "application/xml",
|
|
36
|
+
".zip": "application/zip",
|
|
37
|
+
});
|
|
38
|
+
export const asset = Object.freeze({
|
|
39
|
+
image: () => createAssetField("image", false),
|
|
40
|
+
images: () => createAssetField("image", true),
|
|
41
|
+
file: () => createAssetField("file", false),
|
|
42
|
+
files: () => createAssetField("file", true),
|
|
43
|
+
});
|
|
44
|
+
export function normalizeContentAssetFields(input) {
|
|
45
|
+
const ancestors = new Set();
|
|
46
|
+
function visit(value, fieldPath) {
|
|
47
|
+
if (!isPlainObject(value)) {
|
|
48
|
+
throw new TypeError(`Content assets ${fieldPath || "definition"} must be an object`);
|
|
49
|
+
}
|
|
50
|
+
if (ancestors.has(value)) {
|
|
51
|
+
throw new TypeError("Content assets definition cannot contain circular references");
|
|
52
|
+
}
|
|
53
|
+
ancestors.add(value);
|
|
54
|
+
try {
|
|
55
|
+
const result = Object.create(null);
|
|
56
|
+
for (const key of Object.keys(value)) {
|
|
57
|
+
if (!key.trim())
|
|
58
|
+
throw new TypeError("Content asset field names cannot be empty");
|
|
59
|
+
const child = value[key];
|
|
60
|
+
result[key] = isContentAssetField(child)
|
|
61
|
+
? child
|
|
62
|
+
: visit(child, fieldPath ? `${fieldPath}.${key}` : key);
|
|
63
|
+
}
|
|
64
|
+
if (Object.keys(result).length === 0) {
|
|
65
|
+
throw new TypeError(`Content assets ${fieldPath || "definition"} cannot be empty`);
|
|
66
|
+
}
|
|
67
|
+
return Object.freeze(result);
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
ancestors.delete(value);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return visit(input, "");
|
|
74
|
+
}
|
|
75
|
+
export function splitContentAssetInput(input, fields) {
|
|
76
|
+
if (!isPlainObject(input)) {
|
|
77
|
+
throw new TypeError("frontmatter must be an object when typed assets are configured");
|
|
78
|
+
}
|
|
79
|
+
function split(value, declarations) {
|
|
80
|
+
const schema = copyRecord(value);
|
|
81
|
+
const assets = Object.create(null);
|
|
82
|
+
for (const [key, declaration] of Object.entries(declarations)) {
|
|
83
|
+
if (isContentAssetField(declaration)) {
|
|
84
|
+
assets[key] = value?.[key];
|
|
85
|
+
delete schema[key];
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const child = value?.[key];
|
|
89
|
+
if (child !== undefined && !isPlainObject(child)) {
|
|
90
|
+
assets[key] = child;
|
|
91
|
+
delete schema[key];
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const nested = split(child, declaration);
|
|
95
|
+
assets[key] = nested.assets;
|
|
96
|
+
if (child !== undefined) {
|
|
97
|
+
if (Object.keys(nested.schema).length > 0)
|
|
98
|
+
schema[key] = nested.schema;
|
|
99
|
+
else
|
|
100
|
+
delete schema[key];
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { schema, assets };
|
|
104
|
+
}
|
|
105
|
+
const splitValue = split(input, fields);
|
|
106
|
+
return { schemaInput: splitValue.schema, assetInput: splitValue.assets };
|
|
107
|
+
}
|
|
108
|
+
export async function resolveContentAssetFields(fields, input, context) {
|
|
109
|
+
if (!isPlainObject(input)) {
|
|
110
|
+
throw assetError(context, [], "Expected frontmatter asset values to be an object");
|
|
111
|
+
}
|
|
112
|
+
async function visit(declarations, values, fieldPath) {
|
|
113
|
+
const result = Object.create(null);
|
|
114
|
+
for (const [key, declaration] of Object.entries(declarations)) {
|
|
115
|
+
const nextPath = [...fieldPath, key];
|
|
116
|
+
const value = values[key];
|
|
117
|
+
if (isContentAssetField(declaration)) {
|
|
118
|
+
result[key] = await resolveDeclaredAsset(declaration, value, context, nextPath);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
if (!isPlainObject(value))
|
|
122
|
+
throw assetError(context, nextPath, "Expected an object");
|
|
123
|
+
result[key] = await visit(declaration, value, nextPath);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
return visit(fields, input, []);
|
|
129
|
+
}
|
|
130
|
+
export function mergeContentAssetData(data, assets, context) {
|
|
131
|
+
if (!isPlainObject(data)) {
|
|
132
|
+
throw assetError(context, [], "Schema output must be an object when typed assets are configured");
|
|
133
|
+
}
|
|
134
|
+
function merge(value, additions, fieldPath) {
|
|
135
|
+
const result = copyRecord(value);
|
|
136
|
+
for (const [key, addition] of Object.entries(additions)) {
|
|
137
|
+
const nextPath = [...fieldPath, key];
|
|
138
|
+
if (isPlainObject(addition) && !isResolvedAsset(addition)) {
|
|
139
|
+
const existing = result[key];
|
|
140
|
+
if (existing !== undefined && !isPlainObject(existing)) {
|
|
141
|
+
throw assetError(context, nextPath, "Conflicts with the schema output");
|
|
142
|
+
}
|
|
143
|
+
result[key] = merge(existing ?? {}, addition, nextPath);
|
|
144
|
+
}
|
|
145
|
+
else {
|
|
146
|
+
if (Object.prototype.hasOwnProperty.call(result, key)) {
|
|
147
|
+
throw assetError(context, nextPath, "Conflicts with the schema output; remove this asset field from schema");
|
|
148
|
+
}
|
|
149
|
+
result[key] = addition;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
return merge(data, assets, []);
|
|
155
|
+
}
|
|
156
|
+
export async function resolveContentAsset(reference, expectedKind, context, diagnosticPath) {
|
|
157
|
+
const parsed = parseRelativeAssetReference(reference, context, diagnosticPath);
|
|
158
|
+
const sourceDirectory = path.dirname(path.resolve(context.root, context.sourceFile));
|
|
159
|
+
const requestedPath = path.resolve(sourceDirectory, parsed.path);
|
|
160
|
+
assertInsideRoot(requestedPath, path.resolve(context.root), context, diagnosticPath);
|
|
161
|
+
context.sourceFiles.add(requestedPath);
|
|
162
|
+
let actualPath;
|
|
163
|
+
let fileStats;
|
|
164
|
+
try {
|
|
165
|
+
[actualPath, fileStats] = await Promise.all([realpath(requestedPath), stat(requestedPath)]);
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
throw assetError(context, diagnosticPath, `Asset ${JSON.stringify(reference)} does not exist`, error);
|
|
169
|
+
}
|
|
170
|
+
const actualRoot = await realpath(context.root);
|
|
171
|
+
assertInsideRoot(actualPath, actualRoot, context, diagnosticPath);
|
|
172
|
+
context.sourceFiles.add(actualPath);
|
|
173
|
+
if (!fileStats.isFile()) {
|
|
174
|
+
throw assetError(context, diagnosticPath, `Asset ${JSON.stringify(reference)} is not a file`);
|
|
175
|
+
}
|
|
176
|
+
const bytes = await readFile(actualPath);
|
|
177
|
+
const token = registerAssetImport(actualPath, actualRoot, context.imports);
|
|
178
|
+
const common = {
|
|
179
|
+
src: `${token}${parsed.suffix}`,
|
|
180
|
+
source: reference,
|
|
181
|
+
name: path.basename(actualPath),
|
|
182
|
+
bytes: bytes.byteLength,
|
|
183
|
+
};
|
|
184
|
+
if (expectedKind === "file") {
|
|
185
|
+
return Object.freeze({
|
|
186
|
+
kind: "file",
|
|
187
|
+
...common,
|
|
188
|
+
type: FILE_MIME_TYPES[path.extname(actualPath).toLowerCase()] ?? "application/octet-stream",
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
let dimensions;
|
|
192
|
+
try {
|
|
193
|
+
dimensions = imageSize(bytes);
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
throw assetError(context, diagnosticPath, `Asset ${JSON.stringify(reference)} is not a supported image with intrinsic dimensions`, error);
|
|
197
|
+
}
|
|
198
|
+
if (!dimensions.width || !dimensions.height || !dimensions.type) {
|
|
199
|
+
throw assetError(context, diagnosticPath, `Asset ${JSON.stringify(reference)} does not have readable intrinsic dimensions`);
|
|
200
|
+
}
|
|
201
|
+
const extension = path.extname(actualPath).toLowerCase();
|
|
202
|
+
const format = IMAGE_FORMATS[extension];
|
|
203
|
+
if (!format) {
|
|
204
|
+
throw assetError(context, diagnosticPath, `Asset ${JSON.stringify(reference)} uses an unsupported image extension ${JSON.stringify(extension || "(none)")}`);
|
|
205
|
+
}
|
|
206
|
+
if (!format.detected.includes(dimensions.type)) {
|
|
207
|
+
throw assetError(context, diagnosticPath, `Asset ${JSON.stringify(reference)} has extension ${JSON.stringify(extension)} but contains a ${dimensions.type} image`);
|
|
208
|
+
}
|
|
209
|
+
return Object.freeze({
|
|
210
|
+
kind: "image",
|
|
211
|
+
...common,
|
|
212
|
+
type: format.type,
|
|
213
|
+
width: dimensions.width,
|
|
214
|
+
height: dimensions.height,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
export function isContentAssetField(value) {
|
|
218
|
+
if (!value || typeof value !== "object" || !("__farmContentAsset" in value))
|
|
219
|
+
return false;
|
|
220
|
+
const field = value;
|
|
221
|
+
const definition = field.__farmContentAsset;
|
|
222
|
+
return Boolean(definition &&
|
|
223
|
+
(definition.kind === "image" || definition.kind === "file") &&
|
|
224
|
+
typeof definition.multiple === "boolean" &&
|
|
225
|
+
typeof definition.optional === "boolean" &&
|
|
226
|
+
typeof field.optional === "function" &&
|
|
227
|
+
typeof field.default === "function");
|
|
228
|
+
}
|
|
229
|
+
function createAssetField(kind, multiple, options = {}) {
|
|
230
|
+
const definition = Object.freeze({
|
|
231
|
+
kind,
|
|
232
|
+
multiple,
|
|
233
|
+
optional: options.optional === true,
|
|
234
|
+
...(options.defaultValue === undefined ? {} : { defaultValue: options.defaultValue }),
|
|
235
|
+
});
|
|
236
|
+
return Object.freeze({
|
|
237
|
+
__farmContentAsset: definition,
|
|
238
|
+
optional: () => createAssetField(kind, multiple, {
|
|
239
|
+
...options,
|
|
240
|
+
optional: true,
|
|
241
|
+
}),
|
|
242
|
+
default: (value) => {
|
|
243
|
+
assertAssetDefault(value, multiple);
|
|
244
|
+
const defaultValue = Array.isArray(value) ? Object.freeze([...value]) : value;
|
|
245
|
+
return createAssetField(kind, multiple, {
|
|
246
|
+
optional: false,
|
|
247
|
+
defaultValue: defaultValue,
|
|
248
|
+
});
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
async function resolveDeclaredAsset(field, rawValue, context, fieldPath) {
|
|
253
|
+
const definition = field.__farmContentAsset;
|
|
254
|
+
const value = rawValue === undefined ? definition.defaultValue : rawValue;
|
|
255
|
+
if (value === undefined) {
|
|
256
|
+
if (definition.optional)
|
|
257
|
+
return undefined;
|
|
258
|
+
throw assetError(context, fieldPath, "Required asset is missing");
|
|
259
|
+
}
|
|
260
|
+
if (definition.multiple) {
|
|
261
|
+
if (!Array.isArray(value) ||
|
|
262
|
+
value.some((entry) => typeof entry !== "string" || !entry.trim())) {
|
|
263
|
+
throw assetError(context, fieldPath, "Expected an array of relative asset paths");
|
|
264
|
+
}
|
|
265
|
+
return Promise.all(value.map((entry, index) => resolveContentAsset(entry, definition.kind, context, [...fieldPath, String(index)])));
|
|
266
|
+
}
|
|
267
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
268
|
+
throw assetError(context, fieldPath, "Expected a relative asset path");
|
|
269
|
+
}
|
|
270
|
+
return resolveContentAsset(value, definition.kind, context, fieldPath);
|
|
271
|
+
}
|
|
272
|
+
function parseRelativeAssetReference(reference, context, fieldPath) {
|
|
273
|
+
const value = reference.trim();
|
|
274
|
+
if (!value ||
|
|
275
|
+
value.startsWith("/") ||
|
|
276
|
+
value.startsWith("\\") ||
|
|
277
|
+
value.startsWith("#") ||
|
|
278
|
+
value.startsWith("?") ||
|
|
279
|
+
/^[A-Za-z][A-Za-z\d+.-]*:/.test(value)) {
|
|
280
|
+
throw assetError(context, fieldPath, `Expected a relative asset path, received ${JSON.stringify(reference)}`);
|
|
281
|
+
}
|
|
282
|
+
if (value.includes("\\")) {
|
|
283
|
+
throw assetError(context, fieldPath, "Asset paths must use forward slashes");
|
|
284
|
+
}
|
|
285
|
+
const suffixIndex = value.search(/[?#]/);
|
|
286
|
+
const rawPath = suffixIndex < 0 ? value : value.slice(0, suffixIndex);
|
|
287
|
+
const suffix = suffixIndex < 0 ? "" : value.slice(suffixIndex);
|
|
288
|
+
let decodedPath;
|
|
289
|
+
try {
|
|
290
|
+
decodedPath = decodeURIComponent(rawPath);
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
throw assetError(context, fieldPath, `Asset path ${JSON.stringify(reference)} has invalid encoding`, error);
|
|
294
|
+
}
|
|
295
|
+
if (!decodedPath || decodedPath.includes("\0")) {
|
|
296
|
+
throw assetError(context, fieldPath, `Asset path ${JSON.stringify(reference)} is invalid`);
|
|
297
|
+
}
|
|
298
|
+
return { path: decodedPath, suffix };
|
|
299
|
+
}
|
|
300
|
+
function registerAssetImport(filePath, root, imports) {
|
|
301
|
+
const normalized = path.relative(root, filePath).replace(/\\/g, "/");
|
|
302
|
+
const token = `__FARM_CONTENT_ASSET_${createHash("sha256")
|
|
303
|
+
.update(normalized)
|
|
304
|
+
.digest("hex")
|
|
305
|
+
.slice(0, 20)}__`;
|
|
306
|
+
if (!imports.has(token))
|
|
307
|
+
imports.set(token, Object.freeze({ token, filePath }));
|
|
308
|
+
return token;
|
|
309
|
+
}
|
|
310
|
+
function assertInsideRoot(filePath, root, context, fieldPath) {
|
|
311
|
+
const relative = path.relative(root, filePath);
|
|
312
|
+
if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
313
|
+
throw assetError(context, fieldPath, "Asset path cannot leave the Farm project root");
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
function assetError(context, fieldPath, message, cause) {
|
|
317
|
+
const location = fieldPath.length ? `${fieldPath.join(".")}: ` : "";
|
|
318
|
+
return new Error(`[farm:content] Collection ${JSON.stringify(context.collectionName)} rejected ${context.sourceFile}: ${location}${message}`, cause === undefined ? undefined : { cause });
|
|
319
|
+
}
|
|
320
|
+
function assertAssetDefault(value, multiple) {
|
|
321
|
+
if (multiple) {
|
|
322
|
+
if (!Array.isArray(value) ||
|
|
323
|
+
value.some((entry) => typeof entry !== "string" || !entry.trim())) {
|
|
324
|
+
throw new TypeError("Content asset list defaults must contain relative path strings");
|
|
325
|
+
}
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
329
|
+
throw new TypeError("Content asset defaults must be relative path strings");
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
function copyRecord(value) {
|
|
333
|
+
const result = Object.create(null);
|
|
334
|
+
if (!value)
|
|
335
|
+
return result;
|
|
336
|
+
for (const key of Object.keys(value))
|
|
337
|
+
result[key] = value[key];
|
|
338
|
+
return result;
|
|
339
|
+
}
|
|
340
|
+
function isPlainObject(value) {
|
|
341
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
342
|
+
return false;
|
|
343
|
+
const prototype = Object.getPrototypeOf(value);
|
|
344
|
+
return prototype === Object.prototype || prototype === null;
|
|
345
|
+
}
|
|
346
|
+
function isResolvedAsset(value) {
|
|
347
|
+
return (value.kind === "image" || value.kind === "file") && typeof value.src === "string";
|
|
348
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ContentAssetsInput, ContentCollection, ContentCollectionInput, ContentDataWithAssets, ContentFileSource, ContentSchema, InferContentSchema } from "./types.js";
|
|
2
|
+
export interface ContentFilesOptions {
|
|
3
|
+
/** Ignore globs, evaluated relative to the Farm project root. */
|
|
4
|
+
ignore?: string | readonly string[];
|
|
5
|
+
/** Directory used to derive entry IDs. Defaults to the common static directory of all globs. */
|
|
6
|
+
base?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function files(patterns: string | readonly string[], options?: ContentFilesOptions): ContentFileSource;
|
|
9
|
+
export declare function collection<TSchema extends ContentSchema<any>, const TAssets extends ContentAssetsInput = undefined, TTransformed = ContentDataWithAssets<InferContentSchema<TSchema>, TAssets>>(input: ContentCollectionInput<TSchema, TTransformed, TAssets>): ContentCollection<TTransformed, InferContentSchema<TSchema>>;
|
|
10
|
+
export declare function isSupportedContentFile(filePath: string): boolean;
|
|
11
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,kBAAkB,EAClB,iBAAiB,EACjB,sBAAsB,EACtB,qBAAqB,EACrB,iBAAiB,EACjB,aAAa,EACb,kBAAkB,EACnB,MAAM,YAAY,CAAC;AAIpB,MAAM,WAAW,mBAAmB;IAClC,iEAAiE;IACjE,MAAM,CAAC,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,CAAC;IACpC,gGAAgG;IAChG,IAAI,CAAC,EAAE,MAAM,CAAC;CACf;AAED,wBAAgB,KAAK,CACnB,QAAQ,EAAE,MAAM,GAAG,SAAS,MAAM,EAAE,EACpC,OAAO,GAAE,mBAAwB,GAChC,iBAAiB,CAWnB;AAED,wBAAgB,UAAU,CACxB,OAAO,SAAS,aAAa,CAAC,GAAG,CAAC,EAClC,KAAK,CAAC,OAAO,SAAS,kBAAkB,GAAG,SAAS,EACpD,YAAY,GAAG,qBAAqB,CAAC,kBAAkB,CAAC,OAAO,CAAC,EAAE,OAAO,CAAC,EAE1E,KAAK,EAAE,sBAAsB,CAAC,OAAO,EAAE,YAAY,EAAE,OAAO,CAAC,GAC5D,iBAAiB,CAAC,YAAY,EAAE,kBAAkB,CAAC,OAAO,CAAC,CAAC,CAgC9D;AAED,wBAAgB,sBAAsB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAEhE"}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { normalizeContentAssetFields } from "./assets.js";
|
|
3
|
+
const SUPPORTED_EXTENSIONS = new Set([".md", ".mdx", ".json", ".yaml", ".yml"]);
|
|
4
|
+
export function files(patterns, options = {}) {
|
|
5
|
+
const normalizedPatterns = normalizePatterns(patterns, "patterns");
|
|
6
|
+
const ignore = normalizePatterns(options.ignore ?? [], "ignore", true);
|
|
7
|
+
const base = options.base === undefined ? undefined : normalizeRelativePath(options.base, "base");
|
|
8
|
+
return Object.freeze({
|
|
9
|
+
kind: "files",
|
|
10
|
+
patterns: Object.freeze(normalizedPatterns),
|
|
11
|
+
ignore: Object.freeze(ignore),
|
|
12
|
+
...(base ? { base } : {}),
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
export function collection(input) {
|
|
16
|
+
if (!input || typeof input !== "object") {
|
|
17
|
+
throw new TypeError("Content collection must be an object");
|
|
18
|
+
}
|
|
19
|
+
if (input.source?.kind !== "files") {
|
|
20
|
+
throw new TypeError("Content collection source must come from files()");
|
|
21
|
+
}
|
|
22
|
+
if (!isContentSchema(input.schema)) {
|
|
23
|
+
throw new TypeError("Content collection schema must implement Standard Schema, parseAsync, or parse");
|
|
24
|
+
}
|
|
25
|
+
if (input.transform !== undefined && typeof input.transform !== "function") {
|
|
26
|
+
throw new TypeError("Content collection transform must be a function");
|
|
27
|
+
}
|
|
28
|
+
if (input.assets !== undefined && input.assets !== true && typeof input.assets !== "object") {
|
|
29
|
+
throw new TypeError("Content collection assets must be true or an asset declaration object");
|
|
30
|
+
}
|
|
31
|
+
const assets = input.assets === true
|
|
32
|
+
? true
|
|
33
|
+
: input.assets === undefined
|
|
34
|
+
? undefined
|
|
35
|
+
: normalizeContentAssetFields(input.assets);
|
|
36
|
+
return Object.freeze({
|
|
37
|
+
source: input.source,
|
|
38
|
+
schema: input.schema,
|
|
39
|
+
...(assets === undefined ? {} : { assets }),
|
|
40
|
+
...(input.transform ? { transform: input.transform } : {}),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
export function isSupportedContentFile(filePath) {
|
|
44
|
+
return SUPPORTED_EXTENSIONS.has(path.extname(filePath).toLowerCase());
|
|
45
|
+
}
|
|
46
|
+
function isContentSchema(value) {
|
|
47
|
+
if (!value || typeof value !== "object")
|
|
48
|
+
return false;
|
|
49
|
+
const schema = value;
|
|
50
|
+
return (typeof schema["~standard"]?.validate === "function" ||
|
|
51
|
+
typeof schema.parseAsync === "function" ||
|
|
52
|
+
typeof schema.parse === "function");
|
|
53
|
+
}
|
|
54
|
+
function normalizePatterns(value, label, allowEmpty = false) {
|
|
55
|
+
const values = (Array.isArray(value) ? value : [value]).map((pattern) => normalizeRelativePath(pattern, label));
|
|
56
|
+
if (!allowEmpty && values.length === 0) {
|
|
57
|
+
throw new TypeError(`Content files ${label} must include at least one glob`);
|
|
58
|
+
}
|
|
59
|
+
return [...new Set(values)];
|
|
60
|
+
}
|
|
61
|
+
function normalizeRelativePath(value, label) {
|
|
62
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
63
|
+
throw new TypeError(`Content files ${label} must contain non-empty paths`);
|
|
64
|
+
}
|
|
65
|
+
const normalized = value.trim().replace(/\\/g, "/").replace(/^\.\//, "");
|
|
66
|
+
if (path.posix.isAbsolute(normalized) || /^[A-Za-z]:\//.test(normalized)) {
|
|
67
|
+
throw new TypeError(`Content files ${label} must be relative to the Farm project root`);
|
|
68
|
+
}
|
|
69
|
+
if (normalized.split("/").includes("..")) {
|
|
70
|
+
throw new TypeError(`Content files ${label} cannot leave the Farm project root`);
|
|
71
|
+
}
|
|
72
|
+
return normalized;
|
|
73
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { asset } from "./assets.js";
|
|
2
|
+
import { collection, files } from "./config.js";
|
|
3
|
+
import type { ContentCollections, ContentOptions, ContentPlugin } from "./types.js";
|
|
4
|
+
export { asset, collection, files };
|
|
5
|
+
export type { ContentFilesOptions } from "./config.js";
|
|
6
|
+
export type { AppContentEntry, AppContentRegistry, ContentAssetField, ContentAssetFields, ContentAssetsInput, ContentAssetValue, ContentCollection, ContentCollectionInput, ContentCollectionName, ContentCollections, ContentEntry, ContentFileAsset, ContentFileSource, ContentImageAsset, ContentOptions, ContentPlugin, ContentSchema, ContentTransformContext, InferContentCollectionEntry, InferContentAssetFields, InferContentRegistry, InferContentSchema, } from "./types.js";
|
|
7
|
+
/** Load schema-validated local content as a typed server-only collection registry. */
|
|
8
|
+
export declare function content<const TCollections extends ContentCollections>(options: ContentOptions<TCollections>): ContentPlugin<TCollections>;
|
|
9
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AACpC,OAAO,EAAE,UAAU,EAAE,KAAK,EAA0B,MAAM,aAAa,CAAC;AAExE,OAAO,KAAK,EAAE,kBAAkB,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAIpF,OAAO,EAAE,KAAK,EAAE,UAAU,EAAE,KAAK,EAAE,CAAC;AACpC,YAAY,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AACvD,YAAY,EACV,eAAe,EACf,kBAAkB,EAClB,iBAAiB,EACjB,kBAAkB,EAClB,kBAAkB,EAClB,iBAAiB,EACjB,iBAAiB,EACjB,sBAAsB,EACtB,qBAAqB,EACrB,kBAAkB,EAClB,YAAY,EACZ,gBAAgB,EAChB,iBAAiB,EACjB,iBAAiB,EACjB,cAAc,EACd,aAAa,EACb,aAAa,EACb,uBAAuB,EACvB,2BAA2B,EAC3B,uBAAuB,EACvB,oBAAoB,EACpB,kBAAkB,GACnB,MAAM,YAAY,CAAC;AAEpB,sFAAsF;AACtF,wBAAgB,OAAO,CAAC,KAAK,CAAC,YAAY,SAAS,kBAAkB,EACnE,OAAO,EAAE,cAAc,CAAC,YAAY,CAAC,GACpC,aAAa,CAAC,YAAY,CAAC,CA4G7B"}
|