@usegraft/content-migrations 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.
- package/LICENSE +21 -0
- package/dist/index.d.ts +56 -0
- package/dist/index.js +119 -0
- package/package.json +41 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anderson Joseph
|
|
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.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { AnyCollection, DocumentData } from '@usegraft/core';
|
|
2
|
+
|
|
3
|
+
/** What the transform sees per document: the old, pre-migration shape. */
|
|
4
|
+
interface ContentMigrationDoc {
|
|
5
|
+
slug: string;
|
|
6
|
+
/** `<collection>/<file>.mdx`, relative to the content root. */
|
|
7
|
+
sourcePath: string;
|
|
8
|
+
/** Raw frontmatter as authored (minus `slug`) — the OLD shape, so untyped. */
|
|
9
|
+
data: Record<string, unknown>;
|
|
10
|
+
/** The MDX body as authored. */
|
|
11
|
+
body: string;
|
|
12
|
+
}
|
|
13
|
+
/** What the transform returns: the new frontmatter (and optionally a new body). */
|
|
14
|
+
interface ContentMigrationChange<TData> {
|
|
15
|
+
data: TData;
|
|
16
|
+
/** Omit to keep the body as-is. */
|
|
17
|
+
body?: string;
|
|
18
|
+
}
|
|
19
|
+
interface ContentMigrationOptions<TCollection extends AnyCollection> {
|
|
20
|
+
collection: TCollection;
|
|
21
|
+
/** One line for `graft migrate` listings: what this migration does and why. */
|
|
22
|
+
description: string;
|
|
23
|
+
transform: (doc: ContentMigrationDoc) => ContentMigrationChange<DocumentData<TCollection>> | Promise<ContentMigrationChange<DocumentData<TCollection>>>;
|
|
24
|
+
}
|
|
25
|
+
interface ContentMigration<TCollection extends AnyCollection = AnyCollection> {
|
|
26
|
+
readonly kind: "content";
|
|
27
|
+
readonly collection: TCollection;
|
|
28
|
+
readonly description: string;
|
|
29
|
+
readonly transform: ContentMigrationOptions<TCollection>["transform"];
|
|
30
|
+
}
|
|
31
|
+
type AnyContentMigration = ContentMigration<AnyCollection>;
|
|
32
|
+
declare function defineContentMigration<TCollection extends AnyCollection>(options: ContentMigrationOptions<TCollection>): ContentMigration<TCollection>;
|
|
33
|
+
|
|
34
|
+
interface RunContentMigrationOptions {
|
|
35
|
+
/** Absolute path to the content root (files live at <contentDir>/<collection>/…). */
|
|
36
|
+
contentDir: string;
|
|
37
|
+
migration: AnyContentMigration;
|
|
38
|
+
/** Write the transformed files. Defaults to false — report only. */
|
|
39
|
+
apply?: boolean;
|
|
40
|
+
}
|
|
41
|
+
interface ContentMigrationFileResult {
|
|
42
|
+
sourcePath: string;
|
|
43
|
+
slug: string;
|
|
44
|
+
changed: boolean;
|
|
45
|
+
}
|
|
46
|
+
interface ContentMigrationReport {
|
|
47
|
+
collection: string;
|
|
48
|
+
files: ContentMigrationFileResult[];
|
|
49
|
+
changed: number;
|
|
50
|
+
unchanged: number;
|
|
51
|
+
/** True when the changed files were written (apply mode). */
|
|
52
|
+
applied: boolean;
|
|
53
|
+
}
|
|
54
|
+
declare function runContentMigration(options: RunContentMigrationOptions): Promise<ContentMigrationReport>;
|
|
55
|
+
|
|
56
|
+
export { type AnyContentMigration, type ContentMigration, type ContentMigrationChange, type ContentMigrationDoc, type ContentMigrationFileResult, type ContentMigrationOptions, type ContentMigrationReport, type RunContentMigrationOptions, defineContentMigration, runContentMigration };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// src/define.ts
|
|
2
|
+
import { GraftError } from "@usegraft/contracts";
|
|
3
|
+
function defineContentMigration(options) {
|
|
4
|
+
if (options.collection.authority === "db-authoritative") {
|
|
5
|
+
throw new GraftError({
|
|
6
|
+
code: "AUTHORITY_MISMATCH",
|
|
7
|
+
message: `Content migrations transform files, but collection "${options.collection.name}" is db-authoritative \u2014 its records are Postgres rows.`,
|
|
8
|
+
fix: `Use defineDataMigration from "@usegraft/core" for db-authoritative collections; defineContentMigration is only for collections whose documents are files.`,
|
|
9
|
+
details: { collection: options.collection.name, authority: options.collection.authority }
|
|
10
|
+
});
|
|
11
|
+
}
|
|
12
|
+
return {
|
|
13
|
+
kind: "content",
|
|
14
|
+
collection: options.collection,
|
|
15
|
+
description: options.description,
|
|
16
|
+
transform: options.transform
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// src/run.ts
|
|
21
|
+
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs";
|
|
22
|
+
import { basename, join, sep } from "path";
|
|
23
|
+
import { GraftError as GraftError2 } from "@usegraft/contracts";
|
|
24
|
+
import matter from "gray-matter";
|
|
25
|
+
async function runContentMigration(options) {
|
|
26
|
+
const { contentDir, migration } = options;
|
|
27
|
+
const collection = migration.collection;
|
|
28
|
+
if (!existsSync(contentDir) || !statSync(contentDir).isDirectory()) {
|
|
29
|
+
throw new GraftError2({
|
|
30
|
+
code: "CONTENT_DIR_NOT_FOUND",
|
|
31
|
+
message: `Content directory not found: ${contentDir}`,
|
|
32
|
+
fix: `Run from the project root (contentDir comes from graft.config.ts), or create the directory.`,
|
|
33
|
+
details: { contentDir }
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
const dir = join(contentDir, collection.name);
|
|
37
|
+
const files = existsSync(dir) && statSync(dir).isDirectory() ? walk(dir).map((file) => ({
|
|
38
|
+
file,
|
|
39
|
+
sourcePath: `${collection.name}/${file.slice(dir.length + 1).split(sep).join("/")}`
|
|
40
|
+
})) : [];
|
|
41
|
+
const results = [];
|
|
42
|
+
const writes = [];
|
|
43
|
+
const failures = [];
|
|
44
|
+
for (const { file, sourcePath } of files) {
|
|
45
|
+
const raw = readFileSync(file, "utf8");
|
|
46
|
+
let parsed;
|
|
47
|
+
try {
|
|
48
|
+
parsed = matter(raw);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
failures.push({
|
|
51
|
+
sourcePath,
|
|
52
|
+
reason: `frontmatter is not parseable YAML (${error instanceof Error ? error.message : String(error)}) \u2014 fix the file before migrating`
|
|
53
|
+
});
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const frontmatter = parsed.data;
|
|
57
|
+
const explicitSlug = typeof frontmatter.slug === "string" && frontmatter.slug.length > 0 ? frontmatter.slug : void 0;
|
|
58
|
+
const slug = explicitSlug ?? basename(sourcePath).replace(/\.mdx?$/, "");
|
|
59
|
+
const { slug: _slug, ...data } = frontmatter;
|
|
60
|
+
let next;
|
|
61
|
+
try {
|
|
62
|
+
next = await migration.transform({ slug, sourcePath, data, body: parsed.content });
|
|
63
|
+
} catch (error) {
|
|
64
|
+
failures.push({
|
|
65
|
+
sourcePath,
|
|
66
|
+
reason: `transform threw: ${error instanceof Error ? error.message : String(error)}`
|
|
67
|
+
});
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const validated = collection.schema.safeParse(next.data);
|
|
71
|
+
if (!validated.success) {
|
|
72
|
+
const issues = validated.error.issues.map((issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`).join("; ");
|
|
73
|
+
failures.push({ sourcePath, reason: `transformed data fails the schema \u2014 ${issues}` });
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const nextBody = next.body ?? parsed.content;
|
|
77
|
+
const changed2 = JSON.stringify(next.data) !== JSON.stringify(data) || nextBody !== parsed.content;
|
|
78
|
+
results.push({ sourcePath, slug, changed: changed2 });
|
|
79
|
+
if (changed2) {
|
|
80
|
+
const nextFrontmatter = {
|
|
81
|
+
...explicitSlug !== void 0 && { slug: explicitSlug },
|
|
82
|
+
...next.data
|
|
83
|
+
};
|
|
84
|
+
writes.push({ file, raw: matter.stringify(nextBody, nextFrontmatter) });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (failures.length > 0) {
|
|
88
|
+
throw new GraftError2({
|
|
89
|
+
code: "MIGRATION_FAILED",
|
|
90
|
+
message: `Content migration for "${collection.name}" failed on ${failures.length} of ${files.length} file(s); nothing was written.`,
|
|
91
|
+
fix: `Fix the transform (or the listed files) so every output satisfies the current "${collection.name}" schema, then re-run. Failures: ${failures.map((f) => `${f.sourcePath}: ${f.reason}`).join(" | ")}`,
|
|
92
|
+
details: { collection: collection.name, failures }
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (options.apply) {
|
|
96
|
+
for (const write of writes) writeFileSync(write.file, write.raw);
|
|
97
|
+
}
|
|
98
|
+
const changed = results.filter((r) => r.changed).length;
|
|
99
|
+
return {
|
|
100
|
+
collection: collection.name,
|
|
101
|
+
files: results,
|
|
102
|
+
changed,
|
|
103
|
+
unchanged: results.length - changed,
|
|
104
|
+
applied: options.apply === true
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function walk(dir) {
|
|
108
|
+
const out = [];
|
|
109
|
+
for (const name of readdirSync(dir)) {
|
|
110
|
+
const full = join(dir, name);
|
|
111
|
+
if (statSync(full).isDirectory()) out.push(...walk(full));
|
|
112
|
+
else if (/\.mdx?$/.test(name)) out.push(full);
|
|
113
|
+
}
|
|
114
|
+
return out.sort();
|
|
115
|
+
}
|
|
116
|
+
export {
|
|
117
|
+
defineContentMigration,
|
|
118
|
+
runContentMigration
|
|
119
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@usegraft/content-migrations",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/AndersonDesign1/graft.git",
|
|
8
|
+
"directory": "packages/content-migrations"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"module": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"import": "./dist/index.js"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"gray-matter": "^4.0.3",
|
|
28
|
+
"@usegraft/core": "0.1.0",
|
|
29
|
+
"@usegraft/contracts": "0.1.0"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=22.16"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
36
|
+
"dev": "tsup src/index.ts --format esm --watch",
|
|
37
|
+
"typecheck": "tsc --noEmit",
|
|
38
|
+
"test": "vitest run --passWithNoTests",
|
|
39
|
+
"lint": "oxlint ."
|
|
40
|
+
}
|
|
41
|
+
}
|