@tandem-language-exchange/content-store 1.0.11 → 1.0.12
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/dist/chunk-DPWIBUHQ.js +85 -0
- package/dist/chunk-DPWIBUHQ.js.map +1 -0
- package/dist/chunk-QH4EH2NU.js +272 -0
- package/dist/chunk-QH4EH2NU.js.map +1 -0
- package/dist/chunk-R6THP5E4.js +105 -0
- package/dist/chunk-R6THP5E4.js.map +1 -0
- package/package.json +3 -2
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/shared/s3.ts
|
|
4
|
+
import {
|
|
5
|
+
S3Client,
|
|
6
|
+
PutObjectCommand,
|
|
7
|
+
CopyObjectCommand,
|
|
8
|
+
GetObjectCommand
|
|
9
|
+
} from "@aws-sdk/client-s3";
|
|
10
|
+
var ContentStore = class {
|
|
11
|
+
client;
|
|
12
|
+
bucket;
|
|
13
|
+
constructor(cfg) {
|
|
14
|
+
this.client = new S3Client({
|
|
15
|
+
region: cfg.region,
|
|
16
|
+
credentials: {
|
|
17
|
+
accessKeyId: cfg.accessKeyId,
|
|
18
|
+
secretAccessKey: cfg.secretAccessKey
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
this.bucket = cfg.bucket;
|
|
22
|
+
}
|
|
23
|
+
/** content-{cms}-{contentType}-{timestamp}.json */
|
|
24
|
+
buildVersionedKey(cms, contentType, timestamp) {
|
|
25
|
+
return `content-${cms}-${contentType}-${timestamp}.json`;
|
|
26
|
+
}
|
|
27
|
+
/** content-{cms}-{contentType}.json (always points at the latest version) */
|
|
28
|
+
buildLatestKey(cms, contentType) {
|
|
29
|
+
return `content-${cms}-${contentType}.json`;
|
|
30
|
+
}
|
|
31
|
+
async upload(key, data) {
|
|
32
|
+
await this.client.send(
|
|
33
|
+
new PutObjectCommand({
|
|
34
|
+
Bucket: this.bucket,
|
|
35
|
+
Key: key,
|
|
36
|
+
Body: JSON.stringify(data, null, 2),
|
|
37
|
+
ContentType: "application/json"
|
|
38
|
+
})
|
|
39
|
+
);
|
|
40
|
+
return key;
|
|
41
|
+
}
|
|
42
|
+
async download(key) {
|
|
43
|
+
const response = await this.client.send(
|
|
44
|
+
new GetObjectCommand({ Bucket: this.bucket, Key: key })
|
|
45
|
+
);
|
|
46
|
+
const body = await response.Body?.transformToString();
|
|
47
|
+
if (!body) throw new Error(`Empty response for key: ${key}`);
|
|
48
|
+
return JSON.parse(body);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Copies a versioned object to the "latest" key so that it always reflects
|
|
52
|
+
* the most recent sync while older timestamped versions are retained.
|
|
53
|
+
*/
|
|
54
|
+
async copyToLatest(sourceKey, cms, contentType) {
|
|
55
|
+
const latestKey = this.buildLatestKey(cms, contentType);
|
|
56
|
+
await this.client.send(
|
|
57
|
+
new CopyObjectCommand({
|
|
58
|
+
Bucket: this.bucket,
|
|
59
|
+
CopySource: `${this.bucket}/${sourceKey}`,
|
|
60
|
+
Key: latestKey,
|
|
61
|
+
ContentType: "application/json"
|
|
62
|
+
})
|
|
63
|
+
);
|
|
64
|
+
return latestKey;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// src/shared/config.ts
|
|
69
|
+
import dotenv from "dotenv";
|
|
70
|
+
dotenv.config({ path: ".env.local" });
|
|
71
|
+
dotenv.config();
|
|
72
|
+
var config = {
|
|
73
|
+
s3: {
|
|
74
|
+
bucket: process.env.CONTENT_STORE_S3_BUCKET ?? "",
|
|
75
|
+
region: process.env.CONTENT_STORE_S3_REGION ?? "eu-central-1",
|
|
76
|
+
accessKeyId: process.env.AWS_ACCESS_KEY ?? "",
|
|
77
|
+
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? ""
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export {
|
|
82
|
+
config,
|
|
83
|
+
ContentStore
|
|
84
|
+
};
|
|
85
|
+
//# sourceMappingURL=chunk-DPWIBUHQ.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/shared/s3.ts","../src/shared/config.ts"],"sourcesContent":["import {\n S3Client,\n PutObjectCommand,\n CopyObjectCommand,\n GetObjectCommand,\n} from '@aws-sdk/client-s3';\nimport type { S3Config } from './types';\n\nexport class ContentStore {\n private client: S3Client;\n private bucket: string;\n\n constructor(cfg: S3Config) {\n this.client = new S3Client({\n region: cfg.region,\n credentials: {\n accessKeyId: cfg.accessKeyId,\n secretAccessKey: cfg.secretAccessKey,\n },\n });\n this.bucket = cfg.bucket;\n }\n\n /** content-{cms}-{contentType}-{timestamp}.json */\n buildVersionedKey(cms: string, contentType: string, timestamp: number): string {\n return `content-${cms}-${contentType}-${timestamp}.json`;\n }\n\n /** content-{cms}-{contentType}.json (always points at the latest version) */\n buildLatestKey(cms: string, contentType: string): string {\n return `content-${cms}-${contentType}.json`;\n }\n\n async upload(key: string, data: unknown): Promise<string> {\n await this.client.send(\n new PutObjectCommand({\n Bucket: this.bucket,\n Key: key,\n Body: JSON.stringify(data, null, 2),\n ContentType: 'application/json',\n }),\n );\n return key;\n }\n\n async download(key: string): Promise<unknown> {\n const response = await this.client.send(\n new GetObjectCommand({ Bucket: this.bucket, Key: key }),\n );\n const body = await response.Body?.transformToString();\n if (!body) throw new Error(`Empty response for key: ${key}`);\n return JSON.parse(body);\n }\n\n /**\n * Copies a versioned object to the \"latest\" key so that it always reflects\n * the most recent sync while older timestamped versions are retained.\n */\n async copyToLatest(\n sourceKey: string,\n cms: string,\n contentType: string,\n ): Promise<string> {\n const latestKey = this.buildLatestKey(cms, contentType);\n await this.client.send(\n new CopyObjectCommand({\n Bucket: this.bucket,\n CopySource: `${this.bucket}/${sourceKey}`,\n Key: latestKey,\n ContentType: 'application/json',\n }),\n );\n return latestKey;\n }\n}\n","import dotenv from 'dotenv';\nimport type { S3Config, CMSProvider } from './types';\n\ndotenv.config({ path: '.env.local' });\ndotenv.config();\n\nexport type { CMSProvider, S3Config };\n\nexport interface SharedConfig {\n s3: S3Config;\n}\n\nexport const config: SharedConfig = {\n s3: {\n bucket: process.env.CONTENT_STORE_S3_BUCKET ?? '',\n region: process.env.CONTENT_STORE_S3_REGION ?? 'eu-central-1',\n accessKeyId: process.env.AWS_ACCESS_KEY ?? '',\n secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY ?? '',\n }\n};\n"],"mappings":";;;AAAA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAGA,IAAM,eAAN,MAAmB;AAAA,EAChB;AAAA,EACA;AAAA,EAER,YAAY,KAAe;AACzB,SAAK,SAAS,IAAI,SAAS;AAAA,MACzB,QAAQ,IAAI;AAAA,MACZ,aAAa;AAAA,QACX,aAAa,IAAI;AAAA,QACjB,iBAAiB,IAAI;AAAA,MACvB;AAAA,IACF,CAAC;AACD,SAAK,SAAS,IAAI;AAAA,EACpB;AAAA;AAAA,EAGA,kBAAkB,KAAa,aAAqB,WAA2B;AAC7E,WAAO,WAAW,GAAG,IAAI,WAAW,IAAI,SAAS;AAAA,EACnD;AAAA;AAAA,EAGA,eAAe,KAAa,aAA6B;AACvD,WAAO,WAAW,GAAG,IAAI,WAAW;AAAA,EACtC;AAAA,EAEA,MAAM,OAAO,KAAa,MAAgC;AACxD,UAAM,KAAK,OAAO;AAAA,MAChB,IAAI,iBAAiB;AAAA,QACnB,QAAQ,KAAK;AAAA,QACb,KAAK;AAAA,QACL,MAAM,KAAK,UAAU,MAAM,MAAM,CAAC;AAAA,QAClC,aAAa;AAAA,MACf,CAAC;AAAA,IACH;AACA,WAAO;AAAA,EACT;AAAA,EAEA,MAAM,SAAS,KAA+B;AAC5C,UAAM,WAAW,MAAM,KAAK,OAAO;AAAA,MACjC,IAAI,iBAAiB,EAAE,QAAQ,KAAK,QAAQ,KAAK,IAAI,CAAC;AAAA,IACxD;AACA,UAAM,OAAO,MAAM,SAAS,MAAM,kBAAkB;AACpD,QAAI,CAAC,KAAM,OAAM,IAAI,MAAM,2BAA2B,GAAG,EAAE;AAC3D,WAAO,KAAK,MAAM,IAAI;AAAA,EACxB;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAM,aACJ,WACA,KACA,aACiB;AACjB,UAAM,YAAY,KAAK,eAAe,KAAK,WAAW;AACtD,UAAM,KAAK,OAAO;AAAA,MAChB,IAAI,kBAAkB;AAAA,QACpB,QAAQ,KAAK;AAAA,QACb,YAAY,GAAG,KAAK,MAAM,IAAI,SAAS;AAAA,QACvC,KAAK;AAAA,QACL,aAAa;AAAA,MACf,CAAC;AAAA,IACH;AACA,WAAO;AAAA,EACT;AACF;;;AC1EA,OAAO,YAAY;AAGnB,OAAO,OAAO,EAAE,MAAM,aAAa,CAAC;AACpC,OAAO,OAAO;AAQP,IAAM,SAAuB;AAAA,EAChC,IAAI;AAAA,IACA,QAAQ,QAAQ,IAAI,2BAA2B;AAAA,IAC/C,QAAQ,QAAQ,IAAI,2BAA2B;AAAA,IAC/C,aAAa,QAAQ,IAAI,kBAAkB;AAAA,IAC3C,iBAAiB,QAAQ,IAAI,yBAAyB;AAAA,EAC1D;AACJ;","names":[]}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
ContentStore,
|
|
4
|
+
config
|
|
5
|
+
} from "./chunk-DPWIBUHQ.js";
|
|
6
|
+
|
|
7
|
+
// src/server/config.ts
|
|
8
|
+
import dotenv from "dotenv";
|
|
9
|
+
dotenv.config({ path: ".env.local" });
|
|
10
|
+
dotenv.config();
|
|
11
|
+
var config2 = {
|
|
12
|
+
...config,
|
|
13
|
+
contentful: {
|
|
14
|
+
spaceId: process.env.CONTENTFUL_SPACE_ID ?? "",
|
|
15
|
+
accessToken: process.env.CONTENTFUL_WEBSITE_TOKEN ?? "",
|
|
16
|
+
host: process.env.CONTENTFUL_HOST ?? "preview.contentful.com",
|
|
17
|
+
batchSize: 1e3,
|
|
18
|
+
maxDepth: 4,
|
|
19
|
+
contentTypes: [
|
|
20
|
+
"gridLayout",
|
|
21
|
+
"iconWithText",
|
|
22
|
+
"page"
|
|
23
|
+
// Add Contentful content type IDs here to limit sync scope.
|
|
24
|
+
// Leave empty to sync all content types in the space.
|
|
25
|
+
]
|
|
26
|
+
},
|
|
27
|
+
sanity: {
|
|
28
|
+
projectId: process.env.SANITY_PROJECT_ID ?? "",
|
|
29
|
+
dataset: process.env.SANITY_DATASET ?? "main",
|
|
30
|
+
token: process.env.SANITY_API_TOKEN ?? "",
|
|
31
|
+
apiVersion: "2024-01-01"
|
|
32
|
+
},
|
|
33
|
+
retry: {
|
|
34
|
+
maxRetries: parseInt(process.env.RETRY_MAX_RETRIES ?? "5", 10),
|
|
35
|
+
baseDelayMs: parseInt(process.env.RETRY_BASE_DELAY_MS ?? "1000", 10),
|
|
36
|
+
maxDelayMs: parseInt(process.env.RETRY_MAX_DELAY_MS ?? "60000", 10)
|
|
37
|
+
},
|
|
38
|
+
api: {
|
|
39
|
+
port: parseInt(process.env.PORT ?? "3010"),
|
|
40
|
+
apiToken: process.env.CONTENT_STORE_API_TOKEN ?? ""
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// src/server/adapters/contentful.ts
|
|
45
|
+
import {
|
|
46
|
+
createClient
|
|
47
|
+
} from "contentful";
|
|
48
|
+
|
|
49
|
+
// src/server/sync/retry.ts
|
|
50
|
+
function rateLimitDelayMs(err) {
|
|
51
|
+
const e = err;
|
|
52
|
+
if (e?.status === 429 || e?.statusCode === 429) {
|
|
53
|
+
const reset = e?.headers?.["x-contentful-ratelimit-reset"];
|
|
54
|
+
return reset ? parseFloat(reset) * 1e3 : 0;
|
|
55
|
+
}
|
|
56
|
+
const resp = e?.response;
|
|
57
|
+
if (resp?.status === 429) {
|
|
58
|
+
const headers = resp?.headers;
|
|
59
|
+
const reset = headers?.["x-contentful-ratelimit-reset"];
|
|
60
|
+
return reset ? parseFloat(reset) * 1e3 : 0;
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
function computeDelay(attempt, baseDelayMs, maxDelayMs) {
|
|
65
|
+
const exponential = baseDelayMs * Math.pow(2, attempt);
|
|
66
|
+
const jitter = Math.random() * baseDelayMs;
|
|
67
|
+
return Math.min(exponential + jitter, maxDelayMs);
|
|
68
|
+
}
|
|
69
|
+
async function withRetry(fn, { maxRetries, baseDelayMs, maxDelayMs }) {
|
|
70
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
71
|
+
try {
|
|
72
|
+
return await fn();
|
|
73
|
+
} catch (err) {
|
|
74
|
+
if (attempt === maxRetries) throw err;
|
|
75
|
+
const rlDelay = rateLimitDelayMs(err);
|
|
76
|
+
let delay;
|
|
77
|
+
if (rlDelay !== null) {
|
|
78
|
+
delay = rlDelay > 0 ? rlDelay : computeDelay(attempt, baseDelayMs, maxDelayMs);
|
|
79
|
+
console.warn(
|
|
80
|
+
` Rate limited (attempt ${attempt + 1}/${maxRetries}). Waiting ${Math.round(delay)}ms\u2026`
|
|
81
|
+
);
|
|
82
|
+
} else {
|
|
83
|
+
delay = computeDelay(attempt, baseDelayMs, maxDelayMs);
|
|
84
|
+
console.warn(
|
|
85
|
+
` Request failed (attempt ${attempt + 1}/${maxRetries}): ${err.message}. Retrying in ${Math.round(delay)}ms\u2026`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
throw new Error("withRetry: unreachable");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// src/server/adapters/contentful.ts
|
|
95
|
+
function stripEnvelope(value, maxDepth, depth = 0, seen = /* @__PURE__ */ new WeakSet()) {
|
|
96
|
+
if (value === null || typeof value !== "object") return value;
|
|
97
|
+
const obj = value;
|
|
98
|
+
if (seen.has(obj)) return void 0;
|
|
99
|
+
seen.add(obj);
|
|
100
|
+
if (Array.isArray(value)) {
|
|
101
|
+
return value.map((item) => stripEnvelope(item, maxDepth, depth, seen));
|
|
102
|
+
}
|
|
103
|
+
const isEnvelope = "sys" in obj && "fields" in obj && typeof obj.fields === "object";
|
|
104
|
+
if (isEnvelope) {
|
|
105
|
+
if (depth >= maxDepth) return void 0;
|
|
106
|
+
const fields = obj.fields;
|
|
107
|
+
const result2 = {};
|
|
108
|
+
for (const [k, v] of Object.entries(fields)) {
|
|
109
|
+
result2[k] = stripEnvelope(v, maxDepth, depth + 1, seen);
|
|
110
|
+
}
|
|
111
|
+
return result2;
|
|
112
|
+
}
|
|
113
|
+
const result = {};
|
|
114
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
115
|
+
result[k] = stripEnvelope(v, maxDepth, depth, seen);
|
|
116
|
+
}
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
var ContentfulAdapter = class {
|
|
120
|
+
name = "contentful";
|
|
121
|
+
client;
|
|
122
|
+
batchSize;
|
|
123
|
+
maxDepth;
|
|
124
|
+
allowedTypes;
|
|
125
|
+
retryConfig;
|
|
126
|
+
constructor(cfg, retryConfig) {
|
|
127
|
+
this.client = createClient({
|
|
128
|
+
space: cfg.spaceId,
|
|
129
|
+
accessToken: cfg.accessToken,
|
|
130
|
+
host: cfg.host
|
|
131
|
+
});
|
|
132
|
+
this.batchSize = cfg.batchSize;
|
|
133
|
+
this.maxDepth = cfg.maxDepth;
|
|
134
|
+
this.allowedTypes = cfg.contentTypes;
|
|
135
|
+
this.retryConfig = retryConfig;
|
|
136
|
+
}
|
|
137
|
+
async getContentTypes() {
|
|
138
|
+
const response = await withRetry(
|
|
139
|
+
() => this.client.getContentTypes(),
|
|
140
|
+
this.retryConfig
|
|
141
|
+
);
|
|
142
|
+
const allTypes = response.items.map((ct) => ct.sys.id);
|
|
143
|
+
if (this.allowedTypes.length > 0) {
|
|
144
|
+
return allTypes.filter((t) => this.allowedTypes.includes(t));
|
|
145
|
+
}
|
|
146
|
+
return allTypes;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Fetches every entry for a content type using batched pagination.
|
|
150
|
+
* Contentful caps `getEntries` at 1 000 items per call, so we page through
|
|
151
|
+
* with `skip` until all items are collected.
|
|
152
|
+
*/
|
|
153
|
+
async fetchAll(contentType) {
|
|
154
|
+
const allItems = [];
|
|
155
|
+
let skip = 0;
|
|
156
|
+
let total = 0;
|
|
157
|
+
do {
|
|
158
|
+
const response = await withRetry(
|
|
159
|
+
() => this.client.getEntries({
|
|
160
|
+
content_type: contentType,
|
|
161
|
+
limit: this.batchSize,
|
|
162
|
+
skip,
|
|
163
|
+
include: 2
|
|
164
|
+
}),
|
|
165
|
+
this.retryConfig
|
|
166
|
+
);
|
|
167
|
+
total = response.total;
|
|
168
|
+
allItems.push(...response.items);
|
|
169
|
+
skip += response.items.length;
|
|
170
|
+
if (total > this.batchSize) {
|
|
171
|
+
console.log(
|
|
172
|
+
` [contentful] ${contentType}: fetched ${allItems.length}/${total}`
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
} while (skip < total);
|
|
176
|
+
return { contentType, items: allItems.map((item) => stripEnvelope(item, this.maxDepth)), total };
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// src/server/adapters/sanity.ts
|
|
181
|
+
import { createClient as createClient2 } from "@sanity/client";
|
|
182
|
+
var SanityAdapter = class {
|
|
183
|
+
name = "sanity";
|
|
184
|
+
client;
|
|
185
|
+
retryConfig;
|
|
186
|
+
constructor(cfg, retryConfig) {
|
|
187
|
+
this.client = createClient2({
|
|
188
|
+
projectId: cfg.projectId,
|
|
189
|
+
dataset: cfg.dataset,
|
|
190
|
+
token: cfg.token,
|
|
191
|
+
apiVersion: cfg.apiVersion,
|
|
192
|
+
useCdn: false
|
|
193
|
+
});
|
|
194
|
+
this.retryConfig = retryConfig;
|
|
195
|
+
}
|
|
196
|
+
async getContentTypes() {
|
|
197
|
+
const types = await withRetry(
|
|
198
|
+
() => this.client.fetch(`array::unique(*[]._type)`),
|
|
199
|
+
this.retryConfig
|
|
200
|
+
);
|
|
201
|
+
return types.filter(
|
|
202
|
+
(t) => !t.startsWith("system.") && !t.startsWith("sanity.")
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
async fetchAll(contentType) {
|
|
206
|
+
const items = await withRetry(
|
|
207
|
+
() => this.client.fetch(`*[_type == $type]`, { type: contentType }),
|
|
208
|
+
this.retryConfig
|
|
209
|
+
);
|
|
210
|
+
console.log(` [sanity] ${contentType}: fetched ${items.length} items`);
|
|
211
|
+
return { contentType, items, total: items.length };
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// src/server/adapters/index.ts
|
|
216
|
+
function createAdapter(cms) {
|
|
217
|
+
switch (cms) {
|
|
218
|
+
case "contentful":
|
|
219
|
+
return new ContentfulAdapter(config2.contentful, config2.retry);
|
|
220
|
+
case "sanity":
|
|
221
|
+
return new SanityAdapter(config2.sanity, config2.retry);
|
|
222
|
+
default:
|
|
223
|
+
throw new Error(`Unknown CMS provider: ${cms}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// src/server/sync/engine.ts
|
|
228
|
+
async function runSync(cms, contentTypes) {
|
|
229
|
+
const adapter = createAdapter(cms);
|
|
230
|
+
const store = new ContentStore(config2.s3);
|
|
231
|
+
const timestamp = Math.floor(Date.now() / 1e3);
|
|
232
|
+
console.log(`
|
|
233
|
+
Starting sync from ${cms} at ${new Date(timestamp * 1e3).toISOString()}`);
|
|
234
|
+
const typesToSync = contentTypes && contentTypes.length > 0 ? contentTypes : await adapter.getContentTypes();
|
|
235
|
+
console.log(`Content types to sync: ${typesToSync.join(", ")}
|
|
236
|
+
`);
|
|
237
|
+
const entries = [];
|
|
238
|
+
const errors = [];
|
|
239
|
+
for (const contentType of typesToSync) {
|
|
240
|
+
try {
|
|
241
|
+
const result = await adapter.fetchAll(contentType);
|
|
242
|
+
const versionedKey = store.buildVersionedKey(cms, contentType, timestamp);
|
|
243
|
+
await store.upload(versionedKey, result.items);
|
|
244
|
+
const latestKey = await store.copyToLatest(versionedKey, cms, contentType);
|
|
245
|
+
entries.push({
|
|
246
|
+
contentType,
|
|
247
|
+
itemCount: result.total,
|
|
248
|
+
versionedKey,
|
|
249
|
+
latestKey
|
|
250
|
+
});
|
|
251
|
+
console.log(
|
|
252
|
+
` + ${contentType}: ${result.total} items -> ${versionedKey}`
|
|
253
|
+
);
|
|
254
|
+
} catch (err) {
|
|
255
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
256
|
+
errors.push({ contentType, error: message });
|
|
257
|
+
console.error(` x ${contentType}: ${message}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
console.log(
|
|
261
|
+
`
|
|
262
|
+
Sync complete: ${entries.length} succeeded, ${errors.length} failed
|
|
263
|
+
`
|
|
264
|
+
);
|
|
265
|
+
return { cms, timestamp, entries, errors };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export {
|
|
269
|
+
config2 as config,
|
|
270
|
+
runSync
|
|
271
|
+
};
|
|
272
|
+
//# sourceMappingURL=chunk-QH4EH2NU.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/server/config.ts","../src/server/adapters/contentful.ts","../src/server/sync/retry.ts","../src/server/adapters/sanity.ts","../src/server/adapters/index.ts","../src/server/sync/engine.ts"],"sourcesContent":["import dotenv from 'dotenv';\nimport type { S3Config, CMSProvider } from '../shared/types';\nimport {SharedConfig, config as sharedConfig} from '../shared/config';\n\ndotenv.config({ path: '.env.local' });\ndotenv.config();\n\nexport type { CMSProvider, S3Config };\n\nexport interface ContentfulConfig {\n spaceId: string;\n accessToken: string;\n host: string;\n batchSize: number;\n /** Content types to sync. When empty, all content types in the space are synced. */\n contentTypes: string[];\n /** Max nesting depth when unwrapping resolved entries. Deeper references are dropped. */\n maxDepth: number;\n}\n\nexport interface SanityConfig {\n projectId: string;\n dataset: string;\n token: string;\n apiVersion: string;\n}\n\nexport interface RetryConfig {\n maxRetries: number;\n baseDelayMs: number;\n maxDelayMs: number;\n}\n\nexport interface RestApiConfig {\n port: number;\n apiToken: string;\n}\n\nexport interface ServerConfig {\n contentful: ContentfulConfig;\n sanity: SanityConfig;\n retry: RetryConfig;\n api: RestApiConfig;\n}\n\nexport const config: ServerConfig & SharedConfig = {\n ...sharedConfig,\n contentful: {\n spaceId: process.env.CONTENTFUL_SPACE_ID ?? '',\n accessToken: process.env.CONTENTFUL_WEBSITE_TOKEN ?? '',\n host: process.env.CONTENTFUL_HOST ?? 'preview.contentful.com',\n batchSize: 1000,\n maxDepth: 4,\n contentTypes: [\n 'gridLayout','iconWithText','page'\n // Add Contentful content type IDs here to limit sync scope.\n // Leave empty to sync all content types in the space.\n ],\n },\n\n sanity: {\n projectId: process.env.SANITY_PROJECT_ID ?? '',\n dataset: process.env.SANITY_DATASET ?? 'main',\n token: process.env.SANITY_API_TOKEN ?? '',\n apiVersion: '2024-01-01',\n },\n\n retry: {\n maxRetries: parseInt(process.env.RETRY_MAX_RETRIES ?? '5', 10),\n baseDelayMs: parseInt(process.env.RETRY_BASE_DELAY_MS ?? '1000', 10),\n maxDelayMs: parseInt(process.env.RETRY_MAX_DELAY_MS ?? '60000', 10),\n },\n\n api: {\n port: parseInt(process.env.PORT ?? '3010'),\n apiToken: process.env.CONTENT_STORE_API_TOKEN ?? '',\n },\n};\n","import {\n createClient,\n type ContentfulClientApi,\n type ContentTypeCollection,\n type EntryCollection,\n type EntrySkeletonType,\n} from 'contentful';\nimport type { ContentfulConfig, RetryConfig } from '../config';\nimport type { CMSAdapter, FetchResult } from './types';\nimport { withRetry } from '../sync/retry';\n\n/**\n * Recursively unwraps Contentful's { metadata, sys, fields } envelope.\n * `depth` tracks how many entry/asset envelopes deep we are — anything\n * beyond `maxDepth` is dropped to avoid blowing the call stack on\n * circular or extremely deep reference chains.\n */\nfunction stripEnvelope(\n value: unknown,\n maxDepth: number,\n depth = 0,\n seen = new WeakSet<object>(),\n): unknown {\n if (value === null || typeof value !== 'object') return value;\n\n const obj = value as Record<string, unknown>;\n\n if (seen.has(obj)) return undefined;\n seen.add(obj);\n\n if (Array.isArray(value)) {\n return value.map((item) => stripEnvelope(item, maxDepth, depth, seen));\n }\n\n const isEnvelope = 'sys' in obj && 'fields' in obj && typeof obj.fields === 'object';\n\n if (isEnvelope) {\n if (depth >= maxDepth) return undefined;\n\n const fields = obj.fields as Record<string, unknown>;\n const result: Record<string, unknown> = {};\n for (const [k, v] of Object.entries(fields)) {\n result[k] = stripEnvelope(v, maxDepth, depth + 1, seen);\n }\n return result;\n }\n\n const result: Record<string, unknown> = {};\n for (const [k, v] of Object.entries(obj)) {\n result[k] = stripEnvelope(v, maxDepth, depth, seen);\n }\n return result;\n}\n\nexport class ContentfulAdapter implements CMSAdapter {\n readonly name = 'contentful';\n private client: ContentfulClientApi<undefined>;\n private batchSize: number;\n private maxDepth: number;\n private allowedTypes: string[];\n private retryConfig: RetryConfig;\n\n constructor(cfg: ContentfulConfig, retryConfig: RetryConfig) {\n this.client = createClient({\n space: cfg.spaceId,\n accessToken: cfg.accessToken,\n host: cfg.host,\n });\n this.batchSize = cfg.batchSize;\n this.maxDepth = cfg.maxDepth;\n this.allowedTypes = cfg.contentTypes;\n this.retryConfig = retryConfig;\n }\n\n async getContentTypes(): Promise<string[]> {\n const response = await withRetry<ContentTypeCollection>(\n () => this.client.getContentTypes(),\n this.retryConfig,\n );\n\n const allTypes = response.items.map((ct) => ct.sys.id);\n\n if (this.allowedTypes.length > 0) {\n return allTypes.filter((t) => this.allowedTypes.includes(t));\n }\n return allTypes;\n }\n\n /**\n * Fetches every entry for a content type using batched pagination.\n * Contentful caps `getEntries` at 1 000 items per call, so we page through\n * with `skip` until all items are collected.\n */\n async fetchAll(contentType: string): Promise<FetchResult> {\n const allItems: unknown[] = [];\n let skip = 0;\n let total = 0;\n\n do {\n const response = await withRetry<EntryCollection<EntrySkeletonType>>(\n () =>\n this.client.getEntries({\n content_type: contentType,\n limit: this.batchSize,\n skip,\n include: 2,\n }),\n this.retryConfig,\n );\n\n total = response.total;\n allItems.push(...response.items);\n skip += response.items.length;\n\n if (total > this.batchSize) {\n console.log(\n ` [contentful] ${contentType}: fetched ${allItems.length}/${total}`,\n );\n }\n } while (skip < total);\n\n return { contentType, items: allItems.map((item) => stripEnvelope(item, this.maxDepth)), total };\n }\n}\n","import type { RetryConfig } from '../config';\n\n/**\n * Inspects an error to determine if it represents an API rate-limit (HTTP 429).\n * Returns the suggested wait time in ms when available, otherwise `0` to signal\n * that the caller should fall back to computed backoff. Returns `null` when the\n * error is *not* a rate-limit error.\n */\nfunction rateLimitDelayMs(err: unknown): number | null {\n const e = err as Record<string, unknown>;\n\n if (e?.status === 429 || e?.statusCode === 429) {\n const reset = (e?.headers as Record<string, string> | undefined)?.[\n 'x-contentful-ratelimit-reset'\n ];\n return reset ? parseFloat(reset) * 1000 : 0;\n }\n\n const resp = e?.response as Record<string, unknown> | undefined;\n if (resp?.status === 429) {\n const headers = resp?.headers as Record<string, string> | undefined;\n const reset = headers?.['x-contentful-ratelimit-reset'];\n return reset ? parseFloat(reset) * 1000 : 0;\n }\n\n return null;\n}\n\nfunction computeDelay(\n attempt: number,\n baseDelayMs: number,\n maxDelayMs: number,\n): number {\n const exponential = baseDelayMs * Math.pow(2, attempt);\n const jitter = Math.random() * baseDelayMs;\n return Math.min(exponential + jitter, maxDelayMs);\n}\n\n/**\n * Executes `fn` with automatic retry + exponential backoff.\n * Rate-limit (429) responses are handled specially: if the API provides a\n * Retry-After / reset header, that value is respected instead of computed backoff.\n */\nexport async function withRetry<T>(\n fn: () => Promise<T>,\n { maxRetries, baseDelayMs, maxDelayMs }: RetryConfig,\n): Promise<T> {\n for (let attempt = 0; attempt <= maxRetries; attempt++) {\n try {\n return await fn();\n } catch (err) {\n if (attempt === maxRetries) throw err;\n\n const rlDelay = rateLimitDelayMs(err);\n let delay: number;\n\n if (rlDelay !== null) {\n delay =\n rlDelay > 0 ? rlDelay : computeDelay(attempt, baseDelayMs, maxDelayMs);\n console.warn(\n ` Rate limited (attempt ${attempt + 1}/${maxRetries}). ` +\n `Waiting ${Math.round(delay)}ms…`,\n );\n } else {\n delay = computeDelay(attempt, baseDelayMs, maxDelayMs);\n console.warn(\n ` Request failed (attempt ${attempt + 1}/${maxRetries}): ` +\n `${(err as Error).message}. Retrying in ${Math.round(delay)}ms…`,\n );\n }\n\n await new Promise((resolve) => setTimeout(resolve, delay));\n }\n }\n\n throw new Error('withRetry: unreachable');\n}\n","import { createClient, type SanityClient } from '@sanity/client';\nimport type { SanityConfig, RetryConfig } from '../config';\nimport type { CMSAdapter, FetchResult } from './types';\nimport { withRetry } from '../sync/retry';\n\nexport class SanityAdapter implements CMSAdapter {\n readonly name = 'sanity';\n private client: SanityClient;\n private retryConfig: RetryConfig;\n\n constructor(cfg: SanityConfig, retryConfig: RetryConfig) {\n this.client = createClient({\n projectId: cfg.projectId,\n dataset: cfg.dataset,\n token: cfg.token,\n apiVersion: cfg.apiVersion,\n useCdn: false,\n });\n this.retryConfig = retryConfig;\n }\n\n async getContentTypes(): Promise<string[]> {\n const types: string[] = await withRetry(\n () => this.client.fetch(`array::unique(*[]._type)`),\n this.retryConfig,\n );\n return types.filter(\n (t) => !t.startsWith('system.') && !t.startsWith('sanity.'),\n );\n }\n\n async fetchAll(contentType: string): Promise<FetchResult> {\n const items: unknown[] = await withRetry(\n () => this.client.fetch(`*[_type == $type]`, { type: contentType }),\n this.retryConfig,\n );\n\n console.log(` [sanity] ${contentType}: fetched ${items.length} items`);\n return { contentType, items, total: items.length };\n }\n}\n","import { config, type CMSProvider } from '../config';\nimport type { CMSAdapter } from './types';\nimport { ContentfulAdapter } from './contentful';\nimport { SanityAdapter } from './sanity';\n\nexport function createAdapter(cms: CMSProvider): CMSAdapter {\n switch (cms) {\n case 'contentful':\n return new ContentfulAdapter(config.contentful, config.retry);\n case 'sanity':\n return new SanityAdapter(config.sanity, config.retry);\n default:\n throw new Error(`Unknown CMS provider: ${cms as string}`);\n }\n}\n\nexport type { CMSAdapter, FetchResult } from './types';\n","import type { CMSProvider } from '../config';\nimport { config } from '../config';\nimport { createAdapter } from '../adapters';\nimport { ContentStore } from '../../shared/s3';\n\nexport interface SyncResultEntry {\n contentType: string;\n itemCount: number;\n versionedKey: string;\n latestKey: string;\n}\n\nexport interface SyncResult {\n cms: CMSProvider;\n timestamp: number;\n entries: SyncResultEntry[];\n errors: Array<{ contentType: string; error: string }>;\n}\n\nexport async function runSync(\n cms: CMSProvider,\n contentTypes?: string[],\n): Promise<SyncResult> {\n const adapter = createAdapter(cms);\n const store = new ContentStore(config.s3);\n const timestamp = Math.floor(Date.now() / 1000);\n\n console.log(`\\nStarting sync from ${cms} at ${new Date(timestamp * 1000).toISOString()}`);\n\n const typesToSync =\n contentTypes && contentTypes.length > 0\n ? contentTypes\n : await adapter.getContentTypes();\n\n console.log(`Content types to sync: ${typesToSync.join(', ')}\\n`);\n\n const entries: SyncResultEntry[] = [];\n const errors: Array<{ contentType: string; error: string }> = [];\n\n for (const contentType of typesToSync) {\n try {\n const result = await adapter.fetchAll(contentType);\n\n const versionedKey = store.buildVersionedKey(cms, contentType, timestamp);\n await store.upload(versionedKey, result.items);\n\n const latestKey = await store.copyToLatest(versionedKey, cms, contentType);\n\n entries.push({\n contentType,\n itemCount: result.total,\n versionedKey,\n latestKey,\n });\n\n console.log(\n ` + ${contentType}: ${result.total} items -> ${versionedKey}`,\n );\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err);\n errors.push({ contentType, error: message });\n console.error(` x ${contentType}: ${message}`);\n }\n }\n\n console.log(\n `\\nSync complete: ${entries.length} succeeded, ${errors.length} failed\\n`,\n );\n\n return { cms, timestamp, entries, errors };\n}\n"],"mappings":"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","names":["config","result","createClient","createClient","config","config"]}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
config
|
|
4
|
+
} from "./chunk-DPWIBUHQ.js";
|
|
5
|
+
|
|
6
|
+
// src/client/config.ts
|
|
7
|
+
import dotenv from "dotenv";
|
|
8
|
+
dotenv.config({ path: ".env.local" });
|
|
9
|
+
dotenv.config();
|
|
10
|
+
var config2 = {
|
|
11
|
+
...config
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// src/shared/bundles.ts
|
|
15
|
+
import fs from "fs/promises";
|
|
16
|
+
import path from "path";
|
|
17
|
+
function trimDepth(value, remaining) {
|
|
18
|
+
if (value === null || value === void 0 || typeof value !== "object") {
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
if (Array.isArray(value)) {
|
|
22
|
+
return value.map((item) => trimDepth(item, remaining));
|
|
23
|
+
}
|
|
24
|
+
const obj = value;
|
|
25
|
+
const result = {};
|
|
26
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
27
|
+
if (v === null || v === void 0 || typeof v !== "object") {
|
|
28
|
+
result[k] = v;
|
|
29
|
+
} else if (remaining <= 1) {
|
|
30
|
+
result[k] = null;
|
|
31
|
+
} else if (Array.isArray(v)) {
|
|
32
|
+
result[k] = v.map((item) => {
|
|
33
|
+
if (item !== null && typeof item === "object" && !Array.isArray(item)) {
|
|
34
|
+
return trimDepth(item, remaining - 1);
|
|
35
|
+
}
|
|
36
|
+
return item;
|
|
37
|
+
});
|
|
38
|
+
} else {
|
|
39
|
+
result[k] = trimDepth(v, remaining - 1);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
async function fetchBundles(store, outputDir, options) {
|
|
45
|
+
const { cms, contentTypes } = options;
|
|
46
|
+
await fs.mkdir(outputDir, { recursive: true });
|
|
47
|
+
const result = {};
|
|
48
|
+
await Promise.all(
|
|
49
|
+
contentTypes.map(async (contentType) => {
|
|
50
|
+
const key = store.buildLatestKey(cms, contentType);
|
|
51
|
+
const data = await store.download(key);
|
|
52
|
+
const filePath = path.resolve(
|
|
53
|
+
outputDir,
|
|
54
|
+
`content-${cms}-${contentType}.json`
|
|
55
|
+
);
|
|
56
|
+
await fs.writeFile(filePath, JSON.stringify(data, null, 2), "utf-8");
|
|
57
|
+
result[contentType] = filePath;
|
|
58
|
+
})
|
|
59
|
+
);
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
async function queryBundle(outputDir, cms, contentType, options = {}) {
|
|
63
|
+
const filePath = path.resolve(
|
|
64
|
+
outputDir,
|
|
65
|
+
`content-${cms}-${contentType}.json`
|
|
66
|
+
);
|
|
67
|
+
const raw = await fs.readFile(filePath, "utf-8");
|
|
68
|
+
let items = JSON.parse(raw);
|
|
69
|
+
if (options.fields) {
|
|
70
|
+
const filters = options.fields;
|
|
71
|
+
items = items.filter(
|
|
72
|
+
(item) => Object.entries(filters).every(([key, expected]) => {
|
|
73
|
+
const actual = item[key];
|
|
74
|
+
if (Array.isArray(expected)) return expected.includes(actual);
|
|
75
|
+
return actual === expected;
|
|
76
|
+
})
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (options.limit !== void 0 && options.limit > 0) {
|
|
80
|
+
items = items.slice(0, options.limit);
|
|
81
|
+
}
|
|
82
|
+
if (options.include !== void 0) {
|
|
83
|
+
items = items.map(
|
|
84
|
+
(item) => trimDepth(item, options.include)
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
if (options.select?.length) {
|
|
88
|
+
const keys = options.select;
|
|
89
|
+
items = items.map((item) => {
|
|
90
|
+
const picked = {};
|
|
91
|
+
for (const k of keys) {
|
|
92
|
+
if (k in item) picked[k] = item[k];
|
|
93
|
+
}
|
|
94
|
+
return picked;
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return items;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export {
|
|
101
|
+
config2 as config,
|
|
102
|
+
fetchBundles,
|
|
103
|
+
queryBundle
|
|
104
|
+
};
|
|
105
|
+
//# sourceMappingURL=chunk-R6THP5E4.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/client/config.ts","../src/shared/bundles.ts"],"sourcesContent":["import dotenv from 'dotenv';\nimport type { S3Config, CMSProvider } from '../shared/types';\nimport {SharedConfig, config as sharedConfig} from '../shared/config';\n\ndotenv.config({ path: '.env.local' });\ndotenv.config();\n\nexport type { CMSProvider, S3Config };\n\nexport interface ClientConfig {\n}\n\nexport const config: ClientConfig & SharedConfig = {\n ...sharedConfig\n};\n","import fs from 'node:fs/promises';\nimport path from 'node:path';\nimport type { CMSProvider } from './types';\nimport { ContentStore } from './s3';\n\nexport interface FetchBundlesOptions {\n cms: CMSProvider;\n contentTypes: string[];\n}\n\nexport interface QueryOptions {\n /** Filter items by matching top-level property values (exact equality, or array for IN). */\n fields?: Record<string, unknown>;\n /** Properties to include in each result object. Omit to return all properties. */\n select?: string[];\n /** Maximum number of items to return. */\n limit?: number;\n /**\n * How many levels deep to return.\n * - 1 = the item's own scalar properties only; nested objects/refs are nulled.\n * - 2 = the item including its direct references; refs inside those are nulled.\n * - 3 = three levels deep, and so on.\n * - Omit to return the full depth.\n */\n include?: number;\n}\n\n/**\n * Trims nested object depth.\n *\n * `remaining` represents how many levels the current object is allowed.\n * - Scalar properties are always kept.\n * - Nested objects / arrays-of-objects consume one level. When `remaining`\n * drops to 1 they are replaced with `null` (no budget left for refs).\n */\nexport function trimDepth(value: unknown, remaining: number): unknown {\n if (value === null || value === undefined || typeof value !== 'object') {\n return value;\n }\n\n if (Array.isArray(value)) {\n return value.map((item) => trimDepth(item, remaining));\n }\n\n const obj = value as Record<string, unknown>;\n const result: Record<string, unknown> = {};\n\n for (const [k, v] of Object.entries(obj)) {\n if (v === null || v === undefined || typeof v !== 'object') {\n result[k] = v;\n } else if (remaining <= 1) {\n result[k] = null;\n } else if (Array.isArray(v)) {\n result[k] = v.map((item) => {\n if (item !== null && typeof item === 'object' && !Array.isArray(item)) {\n return trimDepth(item, remaining - 1);\n }\n return item;\n });\n } else {\n result[k] = trimDepth(v, remaining - 1);\n }\n }\n\n return result;\n}\n\n/**\n * Downloads the latest bundles from S3 and writes them as JSON files to `outputDir`.\n *\n * @returns A map of contentType to absolute file path.\n */\nexport async function fetchBundles(\n store: ContentStore,\n outputDir: string,\n options: FetchBundlesOptions,\n): Promise<Record<string, string>> {\n const { cms, contentTypes } = options;\n await fs.mkdir(outputDir, { recursive: true });\n\n const result: Record<string, string> = {};\n\n await Promise.all(\n contentTypes.map(async (contentType) => {\n const key = store.buildLatestKey(cms, contentType);\n const data = await store.download(key);\n const filePath = path.resolve(\n outputDir,\n `content-${cms}-${contentType}.json`,\n );\n await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf-8');\n result[contentType] = filePath;\n }),\n );\n\n return result;\n}\n\n/**\n * Queries a previously fetched bundle from the local filesystem.\n */\nexport async function queryBundle(\n outputDir: string,\n cms: CMSProvider,\n contentType: string,\n options: QueryOptions = {},\n): Promise<unknown[]> {\n const filePath = path.resolve(\n outputDir,\n `content-${cms}-${contentType}.json`,\n );\n const raw = await fs.readFile(filePath, 'utf-8');\n let items = JSON.parse(raw) as Record<string, unknown>[];\n\n if (options.fields) {\n const filters = options.fields;\n items = items.filter((item) =>\n Object.entries(filters).every(([key, expected]) => {\n const actual = item[key];\n if (Array.isArray(expected)) return expected.includes(actual);\n return actual === expected;\n }),\n );\n }\n\n if (options.limit !== undefined && options.limit > 0) {\n items = items.slice(0, options.limit);\n }\n\n if (options.include !== undefined) {\n items = items.map(\n (item) => trimDepth(item, options.include!) as Record<string, unknown>,\n );\n }\n\n if (options.select?.length) {\n const keys = options.select;\n items = items.map((item) => {\n const picked: Record<string, unknown> = {};\n for (const k of keys) {\n if (k in item) picked[k] = item[k];\n }\n return picked;\n });\n }\n\n return items;\n}\n"],"mappings":"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","names":["config"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tandem-language-exchange/content-store",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.12",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
"dist/index.*",
|
|
15
15
|
"dist/sdk/",
|
|
16
16
|
"dist/client/",
|
|
17
|
-
"dist/shared/"
|
|
17
|
+
"dist/shared/",
|
|
18
|
+
"dist/chunk-*"
|
|
18
19
|
],
|
|
19
20
|
"bin": {
|
|
20
21
|
"fetch-content-bundles": "dist/client/fetch-bundles.js"
|