featuredrop 1.4.0 → 2.2.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/README.md +287 -760
- package/dist/adapters.cjs +1757 -0
- package/dist/adapters.cjs.map +1 -0
- package/dist/adapters.d.cts +744 -0
- package/dist/adapters.d.ts +744 -0
- package/dist/adapters.js +1745 -0
- package/dist/adapters.js.map +1 -0
- package/dist/admin.cjs +148 -32
- package/dist/admin.cjs.map +1 -1
- package/dist/admin.d.cts +14 -3
- package/dist/admin.d.ts +14 -3
- package/dist/admin.js +148 -32
- package/dist/admin.js.map +1 -1
- package/dist/bridges.cjs +111 -13
- package/dist/bridges.cjs.map +1 -1
- package/dist/bridges.d.cts +12 -5
- package/dist/bridges.d.ts +12 -5
- package/dist/bridges.js +111 -13
- package/dist/bridges.js.map +1 -1
- package/dist/ci.cjs +34 -0
- package/dist/ci.cjs.map +1 -1
- package/dist/ci.d.cts +5 -1
- package/dist/ci.d.ts +5 -1
- package/dist/ci.js +34 -1
- package/dist/ci.js.map +1 -1
- package/dist/cms.cjs +835 -0
- package/dist/cms.cjs.map +1 -0
- package/dist/cms.d.cts +236 -0
- package/dist/cms.d.ts +236 -0
- package/dist/cms.js +829 -0
- package/dist/cms.js.map +1 -0
- package/dist/flags.cjs +27 -7
- package/dist/flags.cjs.map +1 -1
- package/dist/flags.d.cts +14 -0
- package/dist/flags.d.ts +14 -0
- package/dist/flags.js +27 -7
- package/dist/flags.js.map +1 -1
- package/dist/index.cjs +52 -4481
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1 -1340
- package/dist/index.d.ts +1 -1340
- package/dist/index.js +53 -4388
- package/dist/index.js.map +1 -1
- package/dist/markdown.cjs +257 -0
- package/dist/markdown.cjs.map +1 -0
- package/dist/markdown.d.cts +9 -0
- package/dist/markdown.d.ts +9 -0
- package/dist/markdown.js +234 -0
- package/dist/markdown.js.map +1 -0
- package/dist/renderer.cjs +503 -0
- package/dist/renderer.cjs.map +1 -0
- package/dist/renderer.d.cts +250 -0
- package/dist/renderer.d.ts +250 -0
- package/dist/renderer.js +501 -0
- package/dist/renderer.js.map +1 -0
- package/dist/rss.cjs +291 -0
- package/dist/rss.cjs.map +1 -0
- package/dist/rss.d.cts +158 -0
- package/dist/rss.d.ts +158 -0
- package/dist/rss.js +268 -0
- package/dist/rss.js.map +1 -0
- package/package.json +72 -6
package/dist/cms.cjs
ADDED
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var promises = require('fs/promises');
|
|
4
|
+
var path = require('path');
|
|
5
|
+
var zod = require('zod');
|
|
6
|
+
|
|
7
|
+
// src/changelog-as-code.ts
|
|
8
|
+
|
|
9
|
+
// src/dependencies.ts
|
|
10
|
+
function getDirectDependencies(feature) {
|
|
11
|
+
const dependsOn = feature.dependsOn;
|
|
12
|
+
if (!dependsOn) return [];
|
|
13
|
+
const seen = dependsOn.seen ?? [];
|
|
14
|
+
const clicked = dependsOn.clicked ?? [];
|
|
15
|
+
const dismissed = dependsOn.dismissed ?? [];
|
|
16
|
+
const unique = /* @__PURE__ */ new Set();
|
|
17
|
+
for (const id of [...seen, ...clicked, ...dismissed]) {
|
|
18
|
+
if (id) unique.add(id);
|
|
19
|
+
}
|
|
20
|
+
return Array.from(unique);
|
|
21
|
+
}
|
|
22
|
+
function hasDependencyCycle(manifest) {
|
|
23
|
+
const ids = new Set(manifest.map((feature) => feature.id));
|
|
24
|
+
const outgoing = /* @__PURE__ */ new Map();
|
|
25
|
+
const indegree = /* @__PURE__ */ new Map();
|
|
26
|
+
for (const feature of manifest) {
|
|
27
|
+
outgoing.set(feature.id, /* @__PURE__ */ new Set());
|
|
28
|
+
indegree.set(feature.id, 0);
|
|
29
|
+
}
|
|
30
|
+
for (const feature of manifest) {
|
|
31
|
+
for (const dependencyId of getDirectDependencies(feature)) {
|
|
32
|
+
if (!ids.has(dependencyId)) continue;
|
|
33
|
+
const edges = outgoing.get(dependencyId);
|
|
34
|
+
if (!edges || edges.has(feature.id)) continue;
|
|
35
|
+
edges.add(feature.id);
|
|
36
|
+
indegree.set(feature.id, (indegree.get(feature.id) ?? 0) + 1);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
const queue = [];
|
|
40
|
+
for (const feature of manifest) {
|
|
41
|
+
if ((indegree.get(feature.id) ?? 0) === 0) queue.push(feature.id);
|
|
42
|
+
}
|
|
43
|
+
let visited = 0;
|
|
44
|
+
while (queue.length > 0) {
|
|
45
|
+
const id = queue.shift();
|
|
46
|
+
if (!id) continue;
|
|
47
|
+
visited += 1;
|
|
48
|
+
const edges = outgoing.get(id);
|
|
49
|
+
if (!edges) continue;
|
|
50
|
+
for (const nextId of edges) {
|
|
51
|
+
const nextDegree = (indegree.get(nextId) ?? 0) - 1;
|
|
52
|
+
indegree.set(nextId, nextDegree);
|
|
53
|
+
if (nextDegree === 0) queue.push(nextId);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return visited !== manifest.length;
|
|
57
|
+
}
|
|
58
|
+
function isRecord(value) {
|
|
59
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
60
|
+
}
|
|
61
|
+
function isValidDate(value) {
|
|
62
|
+
return Number.isFinite(new Date(value).getTime());
|
|
63
|
+
}
|
|
64
|
+
var nonEmptyString = zod.z.string().trim().min(1, "must be a non-empty string");
|
|
65
|
+
var isoDateString = nonEmptyString.refine(isValidDate, {
|
|
66
|
+
message: "must be a valid date",
|
|
67
|
+
params: { featuredropCode: "invalid_date" }
|
|
68
|
+
});
|
|
69
|
+
var dependsOnSchema = zod.z.object({
|
|
70
|
+
seen: zod.z.array(zod.z.string()).optional(),
|
|
71
|
+
clicked: zod.z.array(zod.z.string()).optional(),
|
|
72
|
+
dismissed: zod.z.array(zod.z.string()).optional()
|
|
73
|
+
}).optional();
|
|
74
|
+
var ctaSchema = zod.z.object({
|
|
75
|
+
label: nonEmptyString,
|
|
76
|
+
url: nonEmptyString
|
|
77
|
+
}).optional();
|
|
78
|
+
var featureEntrySchema = zod.z.object({
|
|
79
|
+
id: nonEmptyString,
|
|
80
|
+
label: nonEmptyString,
|
|
81
|
+
releasedAt: isoDateString,
|
|
82
|
+
showNewUntil: isoDateString,
|
|
83
|
+
description: zod.z.string().optional(),
|
|
84
|
+
flagKey: zod.z.string().optional(),
|
|
85
|
+
product: zod.z.string().optional(),
|
|
86
|
+
url: zod.z.string().optional(),
|
|
87
|
+
image: zod.z.string().optional(),
|
|
88
|
+
type: zod.z.enum(["feature", "improvement", "fix", "breaking"]).optional(),
|
|
89
|
+
priority: zod.z.enum(["critical", "normal", "low"]).optional(),
|
|
90
|
+
cta: ctaSchema,
|
|
91
|
+
meta: zod.z.record(zod.z.unknown()).optional(),
|
|
92
|
+
dependsOn: dependsOnSchema
|
|
93
|
+
}).passthrough();
|
|
94
|
+
zod.z.array(featureEntrySchema);
|
|
95
|
+
function toIssuePath(path) {
|
|
96
|
+
if (path.length === 0) return "$";
|
|
97
|
+
let output = "";
|
|
98
|
+
for (const part of path) {
|
|
99
|
+
if (typeof part === "number") output += `[${part}]`;
|
|
100
|
+
else output += output ? `.${part}` : part;
|
|
101
|
+
}
|
|
102
|
+
return output;
|
|
103
|
+
}
|
|
104
|
+
function mapZodIssue(issue) {
|
|
105
|
+
const codeParam = issue.params?.featuredropCode;
|
|
106
|
+
if (codeParam === "invalid_date") {
|
|
107
|
+
return {
|
|
108
|
+
path: toIssuePath(issue.path),
|
|
109
|
+
message: issue.message,
|
|
110
|
+
code: "invalid_date"
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (issue.code === "invalid_type") {
|
|
114
|
+
return {
|
|
115
|
+
path: toIssuePath(issue.path),
|
|
116
|
+
message: issue.message,
|
|
117
|
+
code: issue.received === "undefined" ? "missing_required" : "invalid_type"
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
path: toIssuePath(issue.path),
|
|
122
|
+
message: issue.message,
|
|
123
|
+
code: "invalid_value"
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
var UNSAFE_META_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
127
|
+
function isSafeUrl(value) {
|
|
128
|
+
const normalized = value.trim();
|
|
129
|
+
if (!normalized) return false;
|
|
130
|
+
if (/^(\/|\.\/|\.\.\/|\?|#)/.test(normalized)) return true;
|
|
131
|
+
if (/^https?:\/\//i.test(normalized)) return true;
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
function findUnsafeMetaPath(value, path = "meta") {
|
|
135
|
+
if (Array.isArray(value)) {
|
|
136
|
+
for (let index = 0; index < value.length; index++) {
|
|
137
|
+
const nested = findUnsafeMetaPath(value[index], `${path}[${index}]`);
|
|
138
|
+
if (nested) return nested;
|
|
139
|
+
}
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
if (!isRecord(value)) return null;
|
|
143
|
+
for (const [key, nestedValue] of Object.entries(value)) {
|
|
144
|
+
if (UNSAFE_META_KEYS.has(key)) {
|
|
145
|
+
return `${path}.${key}`;
|
|
146
|
+
}
|
|
147
|
+
const nested = findUnsafeMetaPath(nestedValue, `${path}.${key}`);
|
|
148
|
+
if (nested) return nested;
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
function validateFeatureEntry(raw, index) {
|
|
153
|
+
if (!isRecord(raw)) {
|
|
154
|
+
return {
|
|
155
|
+
issues: [
|
|
156
|
+
{
|
|
157
|
+
path: `[${index}]`,
|
|
158
|
+
message: "Feature entry must be an object",
|
|
159
|
+
code: "invalid_type"
|
|
160
|
+
}
|
|
161
|
+
]
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const parsed = featureEntrySchema.safeParse(raw);
|
|
165
|
+
if (!parsed.success) {
|
|
166
|
+
return {
|
|
167
|
+
issues: parsed.error.issues.map((issue) => ({
|
|
168
|
+
...mapZodIssue(issue),
|
|
169
|
+
path: `[${index}]${issue.path.length > 0 ? `.${toIssuePath(issue.path)}` : ""}`
|
|
170
|
+
}))
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return {
|
|
174
|
+
issues: [],
|
|
175
|
+
entry: parsed.data
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
function validateManifest(data) {
|
|
179
|
+
const errors = [];
|
|
180
|
+
if (!Array.isArray(data)) {
|
|
181
|
+
return {
|
|
182
|
+
valid: false,
|
|
183
|
+
errors: [
|
|
184
|
+
{
|
|
185
|
+
path: "$",
|
|
186
|
+
message: "Manifest must be an array",
|
|
187
|
+
code: "invalid_type"
|
|
188
|
+
}
|
|
189
|
+
]
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const entries = [];
|
|
193
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
194
|
+
data.forEach((item, index) => {
|
|
195
|
+
const result = validateFeatureEntry(item, index);
|
|
196
|
+
errors.push(...result.issues);
|
|
197
|
+
if (!result.entry) return;
|
|
198
|
+
if (seenIds.has(result.entry.id)) {
|
|
199
|
+
errors.push({
|
|
200
|
+
path: `[${index}].id`,
|
|
201
|
+
message: `Duplicate feature id "${result.entry.id}"`,
|
|
202
|
+
code: "duplicate_id"
|
|
203
|
+
});
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
seenIds.add(result.entry.id);
|
|
207
|
+
entries.push(result.entry);
|
|
208
|
+
});
|
|
209
|
+
if (entries.length > 0 && hasDependencyCycle(entries)) {
|
|
210
|
+
errors.push({
|
|
211
|
+
path: "$",
|
|
212
|
+
message: "Circular dependsOn relationship detected",
|
|
213
|
+
code: "circular_dependency"
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
for (let index = 0; index < entries.length; index++) {
|
|
217
|
+
const entry = entries[index];
|
|
218
|
+
if (new Date(entry.showNewUntil).getTime() <= new Date(entry.releasedAt).getTime()) {
|
|
219
|
+
errors.push({
|
|
220
|
+
path: `[${index}].showNewUntil`,
|
|
221
|
+
message: "showNewUntil must be after releasedAt",
|
|
222
|
+
code: "invalid_value"
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
if (entry.url && !isSafeUrl(entry.url)) {
|
|
226
|
+
errors.push({
|
|
227
|
+
path: `[${index}].url`,
|
|
228
|
+
message: "url must be http, https, or relative",
|
|
229
|
+
code: "invalid_value"
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
if (entry.image && !isSafeUrl(entry.image)) {
|
|
233
|
+
errors.push({
|
|
234
|
+
path: `[${index}].image`,
|
|
235
|
+
message: "image must be http, https, or relative",
|
|
236
|
+
code: "invalid_value"
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
if (entry.cta?.url && !isSafeUrl(entry.cta.url)) {
|
|
240
|
+
errors.push({
|
|
241
|
+
path: `[${index}].cta.url`,
|
|
242
|
+
message: "cta.url must be http, https, or relative",
|
|
243
|
+
code: "invalid_value"
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
const unsafeMetaPath = findUnsafeMetaPath(entry.meta);
|
|
247
|
+
if (unsafeMetaPath) {
|
|
248
|
+
errors.push({
|
|
249
|
+
path: `[${index}].${unsafeMetaPath}`,
|
|
250
|
+
message: `meta contains unsafe key "${unsafeMetaPath.split(".").pop()}"`,
|
|
251
|
+
code: "invalid_value"
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
valid: errors.length === 0,
|
|
257
|
+
errors
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// src/changelog-as-code.ts
|
|
262
|
+
function parseScalar(raw) {
|
|
263
|
+
const value = raw.trim();
|
|
264
|
+
if (!value) return "";
|
|
265
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
266
|
+
return value.slice(1, -1);
|
|
267
|
+
}
|
|
268
|
+
if (value === "true") return true;
|
|
269
|
+
if (value === "false") return false;
|
|
270
|
+
if (value === "null") return null;
|
|
271
|
+
if (/^-?\d+(\.\d+)?$/.test(value)) return Number(value);
|
|
272
|
+
if (value.startsWith("[") && value.endsWith("]")) {
|
|
273
|
+
const inner = value.slice(1, -1).trim();
|
|
274
|
+
if (!inner) return [];
|
|
275
|
+
return inner.split(",").map((part) => String(parseScalar(part.trim())));
|
|
276
|
+
}
|
|
277
|
+
return value;
|
|
278
|
+
}
|
|
279
|
+
function parseFrontmatter(raw) {
|
|
280
|
+
const lines = raw.split(/\r?\n/);
|
|
281
|
+
const root = {};
|
|
282
|
+
const stack = [
|
|
283
|
+
{ indent: -1, value: root }
|
|
284
|
+
];
|
|
285
|
+
const isArrayContext = (idx) => {
|
|
286
|
+
for (let i = idx + 1; i < lines.length; i++) {
|
|
287
|
+
const line = lines[i];
|
|
288
|
+
if (!line.trim()) continue;
|
|
289
|
+
const indent = line.length - line.trimStart().length;
|
|
290
|
+
if (indent <= lines[idx].length - lines[idx].trimStart().length) return false;
|
|
291
|
+
return line.trimStart().startsWith("- ");
|
|
292
|
+
}
|
|
293
|
+
return false;
|
|
294
|
+
};
|
|
295
|
+
for (let i = 0; i < lines.length; i++) {
|
|
296
|
+
const line = lines[i];
|
|
297
|
+
if (!line.trim() || line.trimStart().startsWith("#")) continue;
|
|
298
|
+
const indent = line.length - line.trimStart().length;
|
|
299
|
+
const trimmed = line.trim();
|
|
300
|
+
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
|
|
301
|
+
stack.pop();
|
|
302
|
+
}
|
|
303
|
+
const current = stack[stack.length - 1].value;
|
|
304
|
+
if (trimmed.startsWith("- ")) {
|
|
305
|
+
if (!Array.isArray(current)) {
|
|
306
|
+
throw new Error(`Invalid frontmatter list at line ${i + 1}`);
|
|
307
|
+
}
|
|
308
|
+
const item = trimmed.slice(2).trim();
|
|
309
|
+
current.push(parseScalar(item));
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
const colon = trimmed.indexOf(":");
|
|
313
|
+
if (colon === -1) {
|
|
314
|
+
throw new Error(`Invalid frontmatter line ${i + 1}: ${trimmed}`);
|
|
315
|
+
}
|
|
316
|
+
const key = trimmed.slice(0, colon).trim();
|
|
317
|
+
const rest = trimmed.slice(colon + 1).trim();
|
|
318
|
+
if (Array.isArray(current)) {
|
|
319
|
+
throw new Error(`Unexpected key in list at line ${i + 1}`);
|
|
320
|
+
}
|
|
321
|
+
if (!rest) {
|
|
322
|
+
const container = isArrayContext(i) ? [] : {};
|
|
323
|
+
current[key] = container;
|
|
324
|
+
stack.push({ indent, value: container });
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
current[key] = parseScalar(rest);
|
|
328
|
+
}
|
|
329
|
+
return root;
|
|
330
|
+
}
|
|
331
|
+
function splitFrontmatter(markdown) {
|
|
332
|
+
const normalized = markdown.replace(/\r\n/g, "\n");
|
|
333
|
+
if (!normalized.startsWith("---\n")) {
|
|
334
|
+
return { frontmatter: {}, body: normalized.trim() };
|
|
335
|
+
}
|
|
336
|
+
const end = normalized.indexOf("\n---\n", 4);
|
|
337
|
+
if (end === -1) {
|
|
338
|
+
throw new Error("Frontmatter block is not closed with ---");
|
|
339
|
+
}
|
|
340
|
+
const fmRaw = normalized.slice(4, end);
|
|
341
|
+
const body = normalized.slice(end + 5).trim();
|
|
342
|
+
return {
|
|
343
|
+
frontmatter: parseFrontmatter(fmRaw),
|
|
344
|
+
body
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
function asString(value, field, source) {
|
|
348
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
349
|
+
throw new Error(`${source}: "${field}" must be a non-empty string`);
|
|
350
|
+
}
|
|
351
|
+
return value;
|
|
352
|
+
}
|
|
353
|
+
function asOptionalObject(value, field, source) {
|
|
354
|
+
if (value === void 0) return void 0;
|
|
355
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
356
|
+
throw new Error(`${source}: "${field}" must be an object`);
|
|
357
|
+
}
|
|
358
|
+
return value;
|
|
359
|
+
}
|
|
360
|
+
function parseFeatureFile(markdown, source = "feature.md") {
|
|
361
|
+
const { frontmatter, body } = splitFrontmatter(markdown);
|
|
362
|
+
const entry = {
|
|
363
|
+
id: asString(frontmatter.id, "id", source),
|
|
364
|
+
label: asString(frontmatter.label, "label", source),
|
|
365
|
+
releasedAt: asString(frontmatter.releasedAt, "releasedAt", source),
|
|
366
|
+
showNewUntil: asString(frontmatter.showNewUntil, "showNewUntil", source),
|
|
367
|
+
description: body || void 0
|
|
368
|
+
};
|
|
369
|
+
if (frontmatter.sidebarKey !== void 0) entry.sidebarKey = asString(frontmatter.sidebarKey, "sidebarKey", source);
|
|
370
|
+
if (frontmatter.category !== void 0) entry.category = asString(frontmatter.category, "category", source);
|
|
371
|
+
if (frontmatter.product !== void 0) entry.product = asString(frontmatter.product, "product", source);
|
|
372
|
+
if (frontmatter.url !== void 0) entry.url = asString(frontmatter.url, "url", source);
|
|
373
|
+
if (frontmatter.flagKey !== void 0) entry.flagKey = asString(frontmatter.flagKey, "flagKey", source);
|
|
374
|
+
if (frontmatter.image !== void 0) entry.image = asString(frontmatter.image, "image", source);
|
|
375
|
+
if (frontmatter.publishAt !== void 0) entry.publishAt = asString(frontmatter.publishAt, "publishAt", source);
|
|
376
|
+
if (frontmatter.version !== void 0) {
|
|
377
|
+
if (typeof frontmatter.version === "string" || typeof frontmatter.version === "object") {
|
|
378
|
+
entry.version = frontmatter.version;
|
|
379
|
+
} else {
|
|
380
|
+
throw new Error(`${source}: "version" must be a string or object`);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (frontmatter.type !== void 0) {
|
|
384
|
+
const type = asString(frontmatter.type, "type", source);
|
|
385
|
+
if (!["feature", "improvement", "fix", "breaking"].includes(type)) {
|
|
386
|
+
throw new Error(`${source}: invalid "type" value "${type}"`);
|
|
387
|
+
}
|
|
388
|
+
entry.type = type;
|
|
389
|
+
}
|
|
390
|
+
if (frontmatter.priority !== void 0) {
|
|
391
|
+
const priority = asString(frontmatter.priority, "priority", source);
|
|
392
|
+
if (!["critical", "normal", "low"].includes(priority)) {
|
|
393
|
+
throw new Error(`${source}: invalid "priority" value "${priority}"`);
|
|
394
|
+
}
|
|
395
|
+
entry.priority = priority;
|
|
396
|
+
}
|
|
397
|
+
const cta = asOptionalObject(frontmatter.cta, "cta", source);
|
|
398
|
+
if (cta) {
|
|
399
|
+
entry.cta = {
|
|
400
|
+
label: asString(cta.label, "cta.label", source),
|
|
401
|
+
url: asString(cta.url, "cta.url", source)
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
const audience = asOptionalObject(frontmatter.audience, "audience", source);
|
|
405
|
+
if (audience) {
|
|
406
|
+
const parsedAudience = {};
|
|
407
|
+
for (const field of ["plan", "role", "region"]) {
|
|
408
|
+
const value = audience[field];
|
|
409
|
+
if (value !== void 0) {
|
|
410
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
|
|
411
|
+
throw new Error(`${source}: "audience.${field}" must be string[]`);
|
|
412
|
+
}
|
|
413
|
+
parsedAudience[field] = value;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (audience.custom !== void 0) {
|
|
417
|
+
if (!audience.custom || typeof audience.custom !== "object" || Array.isArray(audience.custom)) {
|
|
418
|
+
throw new Error(`${source}: "audience.custom" must be an object`);
|
|
419
|
+
}
|
|
420
|
+
parsedAudience.custom = audience.custom;
|
|
421
|
+
}
|
|
422
|
+
entry.audience = parsedAudience;
|
|
423
|
+
}
|
|
424
|
+
return entry;
|
|
425
|
+
}
|
|
426
|
+
function normalizePattern(pattern) {
|
|
427
|
+
const normalized = pattern.replaceAll("\\", "/");
|
|
428
|
+
if (normalized.endsWith("/**/*.md")) {
|
|
429
|
+
return {
|
|
430
|
+
baseDir: normalized.slice(0, -"/**/*.md".length),
|
|
431
|
+
ext: ".md"
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
throw new Error(`Unsupported pattern "${pattern}". Use "features/**/*.md" style patterns.`);
|
|
435
|
+
}
|
|
436
|
+
async function collectFiles(dir, ext) {
|
|
437
|
+
const out = [];
|
|
438
|
+
async function walk(current) {
|
|
439
|
+
let entries;
|
|
440
|
+
try {
|
|
441
|
+
entries = await promises.readdir(current, { withFileTypes: true });
|
|
442
|
+
} catch {
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
for (const entry of entries) {
|
|
446
|
+
const fullPath = path.join(current, entry.name);
|
|
447
|
+
if (entry.isDirectory()) {
|
|
448
|
+
await walk(fullPath);
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
if (entry.isFile() && entry.name.endsWith(ext)) {
|
|
452
|
+
out.push(fullPath);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
await walk(dir);
|
|
457
|
+
return out.sort();
|
|
458
|
+
}
|
|
459
|
+
async function buildManifestFromPattern(options = {}) {
|
|
460
|
+
const cwd = options.cwd ?? process.cwd();
|
|
461
|
+
const pattern = options.pattern ?? "features/**/*.md";
|
|
462
|
+
const { baseDir, ext } = normalizePattern(pattern);
|
|
463
|
+
const baseAbs = path.join(cwd, baseDir);
|
|
464
|
+
const stats = await promises.stat(baseAbs).catch(() => null);
|
|
465
|
+
if (!stats || !stats.isDirectory()) {
|
|
466
|
+
throw new Error(`Pattern base directory does not exist: ${baseDir}`);
|
|
467
|
+
}
|
|
468
|
+
const files = await collectFiles(baseAbs, ext);
|
|
469
|
+
const entries = [];
|
|
470
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
471
|
+
for (const file of files) {
|
|
472
|
+
const content = await promises.readFile(file, "utf8");
|
|
473
|
+
const source = path.relative(cwd, file).split(path.sep).join("/");
|
|
474
|
+
const entry = parseFeatureFile(content, source);
|
|
475
|
+
if (seenIds.has(entry.id)) {
|
|
476
|
+
throw new Error(`Duplicate feature id "${entry.id}" found at ${source}`);
|
|
477
|
+
}
|
|
478
|
+
seenIds.add(entry.id);
|
|
479
|
+
entries.push(entry);
|
|
480
|
+
}
|
|
481
|
+
if (options.outFile) {
|
|
482
|
+
const outPath = path.join(cwd, options.outFile);
|
|
483
|
+
await promises.writeFile(outPath, `${JSON.stringify(entries, null, 2)}
|
|
484
|
+
`, "utf8");
|
|
485
|
+
}
|
|
486
|
+
return entries;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// src/cms.ts
|
|
490
|
+
var DEFAULT_FIELDS = {
|
|
491
|
+
id: "id",
|
|
492
|
+
label: "label",
|
|
493
|
+
releasedAt: "releasedAt",
|
|
494
|
+
showNewUntil: "showNewUntil",
|
|
495
|
+
description: "description",
|
|
496
|
+
sidebarKey: "sidebarKey",
|
|
497
|
+
category: "category",
|
|
498
|
+
product: "product",
|
|
499
|
+
flagKey: "flagKey",
|
|
500
|
+
url: "url",
|
|
501
|
+
image: "image",
|
|
502
|
+
publishAt: "publishAt",
|
|
503
|
+
type: "type",
|
|
504
|
+
priority: "priority",
|
|
505
|
+
ctaLabel: "cta.label",
|
|
506
|
+
ctaUrl: "cta.url"
|
|
507
|
+
};
|
|
508
|
+
function getByPath(record, path) {
|
|
509
|
+
const parts = path.split(".").filter(Boolean);
|
|
510
|
+
let cursor = record;
|
|
511
|
+
for (const part of parts) {
|
|
512
|
+
if (!cursor || typeof cursor !== "object") return void 0;
|
|
513
|
+
cursor = cursor[part];
|
|
514
|
+
}
|
|
515
|
+
return cursor;
|
|
516
|
+
}
|
|
517
|
+
function normalizeLocalizedValue(value) {
|
|
518
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
519
|
+
const objectValue = value;
|
|
520
|
+
const keys = Object.keys(objectValue);
|
|
521
|
+
if (keys.length === 1) {
|
|
522
|
+
const nested = objectValue[keys[0]];
|
|
523
|
+
if (typeof nested === "string" || typeof nested === "number" || typeof nested === "boolean" || nested == null) {
|
|
524
|
+
return nested;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
return value;
|
|
529
|
+
}
|
|
530
|
+
function resolveField(record, resolver) {
|
|
531
|
+
if (!resolver) return void 0;
|
|
532
|
+
if (typeof resolver === "function") return resolver(record);
|
|
533
|
+
return normalizeLocalizedValue(getByPath(record, resolver));
|
|
534
|
+
}
|
|
535
|
+
function asString2(value) {
|
|
536
|
+
if (typeof value === "string") {
|
|
537
|
+
const trimmed = value.trim();
|
|
538
|
+
return trimmed ? trimmed : void 0;
|
|
539
|
+
}
|
|
540
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
541
|
+
return String(value);
|
|
542
|
+
}
|
|
543
|
+
return void 0;
|
|
544
|
+
}
|
|
545
|
+
function normalizeFieldMapping(defaults, overrides) {
|
|
546
|
+
return {
|
|
547
|
+
...defaults,
|
|
548
|
+
...overrides
|
|
549
|
+
};
|
|
550
|
+
}
|
|
551
|
+
function validateMappedEntries(entries, options) {
|
|
552
|
+
const strictValidation = options?.strictValidation ?? false;
|
|
553
|
+
const validEntries = [];
|
|
554
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
555
|
+
const errors = [];
|
|
556
|
+
for (const entry of entries) {
|
|
557
|
+
if (seenIds.has(entry.id)) {
|
|
558
|
+
errors.push(`${entry.id}: duplicate id`);
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
seenIds.add(entry.id);
|
|
562
|
+
const validation = validateManifest([entry]);
|
|
563
|
+
if (validation.valid) {
|
|
564
|
+
validEntries.push(entry);
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
const reason = validation.errors.map((error) => `${error.path} ${error.message}`).join("; ");
|
|
568
|
+
errors.push(`${entry.id}: ${reason}`);
|
|
569
|
+
}
|
|
570
|
+
if (errors.length > 0) {
|
|
571
|
+
if (strictValidation) {
|
|
572
|
+
throw new Error(`[featuredrop] CMS mapping validation failed: ${errors.join(" | ")}`);
|
|
573
|
+
}
|
|
574
|
+
if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
|
|
575
|
+
console.warn(`[featuredrop] Skipped ${errors.length} invalid CMS entries.`);
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return validEntries;
|
|
579
|
+
}
|
|
580
|
+
function mapRecordToFeatureEntry(record, mapping) {
|
|
581
|
+
const id = asString2(resolveField(record, mapping.id));
|
|
582
|
+
const label = asString2(resolveField(record, mapping.label));
|
|
583
|
+
const releasedAt = asString2(resolveField(record, mapping.releasedAt));
|
|
584
|
+
const showNewUntil = asString2(resolveField(record, mapping.showNewUntil));
|
|
585
|
+
if (!id || !label || !releasedAt || !showNewUntil) return null;
|
|
586
|
+
const entry = {
|
|
587
|
+
id,
|
|
588
|
+
label,
|
|
589
|
+
releasedAt,
|
|
590
|
+
showNewUntil
|
|
591
|
+
};
|
|
592
|
+
const description = asString2(resolveField(record, mapping.description));
|
|
593
|
+
if (description) entry.description = description;
|
|
594
|
+
const sidebarKey = asString2(resolveField(record, mapping.sidebarKey));
|
|
595
|
+
if (sidebarKey) entry.sidebarKey = sidebarKey;
|
|
596
|
+
const category = asString2(resolveField(record, mapping.category));
|
|
597
|
+
if (category) entry.category = category;
|
|
598
|
+
const product = asString2(resolveField(record, mapping.product));
|
|
599
|
+
if (product) entry.product = product;
|
|
600
|
+
const flagKey = asString2(resolveField(record, mapping.flagKey));
|
|
601
|
+
if (flagKey) entry.flagKey = flagKey;
|
|
602
|
+
const url = asString2(resolveField(record, mapping.url));
|
|
603
|
+
if (url) entry.url = url;
|
|
604
|
+
const image = asString2(resolveField(record, mapping.image));
|
|
605
|
+
if (image) entry.image = image;
|
|
606
|
+
const publishAt = asString2(resolveField(record, mapping.publishAt));
|
|
607
|
+
if (publishAt) entry.publishAt = publishAt;
|
|
608
|
+
const type = asString2(resolveField(record, mapping.type));
|
|
609
|
+
if (type && ["feature", "improvement", "fix", "breaking"].includes(type)) {
|
|
610
|
+
entry.type = type;
|
|
611
|
+
}
|
|
612
|
+
const priority = asString2(resolveField(record, mapping.priority));
|
|
613
|
+
if (priority && ["critical", "normal", "low"].includes(priority)) {
|
|
614
|
+
entry.priority = priority;
|
|
615
|
+
}
|
|
616
|
+
const ctaLabel = asString2(resolveField(record, mapping.ctaLabel));
|
|
617
|
+
const ctaUrl = asString2(resolveField(record, mapping.ctaUrl));
|
|
618
|
+
if (ctaLabel && ctaUrl) {
|
|
619
|
+
entry.cta = { label: ctaLabel, url: ctaUrl };
|
|
620
|
+
}
|
|
621
|
+
return entry;
|
|
622
|
+
}
|
|
623
|
+
async function fetchJson(input, init) {
|
|
624
|
+
const response = await fetch(input, init);
|
|
625
|
+
if (!response.ok) {
|
|
626
|
+
throw new Error(`[featuredrop] CMS request failed (${response.status}) for ${input}`);
|
|
627
|
+
}
|
|
628
|
+
return response.json();
|
|
629
|
+
}
|
|
630
|
+
var ContentfulAdapter = class {
|
|
631
|
+
options;
|
|
632
|
+
constructor(options) {
|
|
633
|
+
this.options = options;
|
|
634
|
+
}
|
|
635
|
+
async load() {
|
|
636
|
+
const environment = this.options.environment ?? "master";
|
|
637
|
+
const params = new URLSearchParams({
|
|
638
|
+
content_type: this.options.contentType,
|
|
639
|
+
limit: String(this.options.limit ?? 1e3)
|
|
640
|
+
});
|
|
641
|
+
if (this.options.locale) {
|
|
642
|
+
params.set("locale", this.options.locale);
|
|
643
|
+
}
|
|
644
|
+
const url = `https://cdn.contentful.com/spaces/${encodeURIComponent(this.options.spaceId)}/environments/${encodeURIComponent(environment)}/entries?${params.toString()}`;
|
|
645
|
+
const payload = await fetchJson(url, {
|
|
646
|
+
headers: {
|
|
647
|
+
Authorization: `Bearer ${this.options.accessToken}`
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
const mapping = normalizeFieldMapping(
|
|
651
|
+
{
|
|
652
|
+
...DEFAULT_FIELDS,
|
|
653
|
+
id: "sys.id",
|
|
654
|
+
label: "fields.label",
|
|
655
|
+
description: "fields.description",
|
|
656
|
+
releasedAt: "fields.releasedAt",
|
|
657
|
+
showNewUntil: "fields.showNewUntil",
|
|
658
|
+
sidebarKey: "fields.sidebarKey",
|
|
659
|
+
category: "fields.category",
|
|
660
|
+
product: "fields.product",
|
|
661
|
+
flagKey: "fields.flagKey",
|
|
662
|
+
url: "fields.url",
|
|
663
|
+
image: "fields.image",
|
|
664
|
+
publishAt: "fields.publishAt",
|
|
665
|
+
type: "fields.type",
|
|
666
|
+
priority: "fields.priority",
|
|
667
|
+
ctaLabel: "fields.ctaLabel",
|
|
668
|
+
ctaUrl: "fields.ctaUrl"
|
|
669
|
+
},
|
|
670
|
+
this.options.fieldMapping
|
|
671
|
+
);
|
|
672
|
+
const entries = (payload.items ?? []).map((item) => mapRecordToFeatureEntry(item, mapping)).filter((entry) => entry !== null);
|
|
673
|
+
return validateMappedEntries(entries, this.options);
|
|
674
|
+
}
|
|
675
|
+
};
|
|
676
|
+
var SanityAdapter = class {
|
|
677
|
+
options;
|
|
678
|
+
constructor(options) {
|
|
679
|
+
this.options = options;
|
|
680
|
+
}
|
|
681
|
+
async load() {
|
|
682
|
+
const version = this.options.apiVersion ?? "v2023-10-01";
|
|
683
|
+
const queryParam = encodeURIComponent(this.options.query);
|
|
684
|
+
const url = `https://${encodeURIComponent(this.options.projectId)}.api.sanity.io/${version}/data/query/${encodeURIComponent(this.options.dataset)}?query=${queryParam}`;
|
|
685
|
+
const headers = {};
|
|
686
|
+
if (this.options.token) {
|
|
687
|
+
headers.Authorization = `Bearer ${this.options.token}`;
|
|
688
|
+
}
|
|
689
|
+
const payload = await fetchJson(url, {
|
|
690
|
+
headers
|
|
691
|
+
});
|
|
692
|
+
const mapping = normalizeFieldMapping(
|
|
693
|
+
{
|
|
694
|
+
...DEFAULT_FIELDS,
|
|
695
|
+
id: "_id"
|
|
696
|
+
},
|
|
697
|
+
this.options.fieldMapping
|
|
698
|
+
);
|
|
699
|
+
const entries = (payload.result ?? []).map((item) => mapRecordToFeatureEntry(item, mapping)).filter((entry) => entry !== null);
|
|
700
|
+
return validateMappedEntries(entries, this.options);
|
|
701
|
+
}
|
|
702
|
+
};
|
|
703
|
+
var StrapiAdapter = class {
|
|
704
|
+
options;
|
|
705
|
+
constructor(options) {
|
|
706
|
+
this.options = options;
|
|
707
|
+
}
|
|
708
|
+
async load() {
|
|
709
|
+
const endpoint = this.options.endpoint ?? "/api/features";
|
|
710
|
+
const base = this.options.baseUrl.replace(/\/+$/, "");
|
|
711
|
+
const query = this.options.query ? `?${this.options.query}` : "";
|
|
712
|
+
const url = `${base}${endpoint}${query}`;
|
|
713
|
+
const headers = {};
|
|
714
|
+
if (this.options.token) {
|
|
715
|
+
headers.Authorization = `Bearer ${this.options.token}`;
|
|
716
|
+
}
|
|
717
|
+
const payload = await fetchJson(url, { headers });
|
|
718
|
+
const mapping = normalizeFieldMapping(DEFAULT_FIELDS, this.options.fieldMapping);
|
|
719
|
+
const entries = (payload.data ?? []).map((item) => {
|
|
720
|
+
if (!item || typeof item !== "object") return null;
|
|
721
|
+
const record = item;
|
|
722
|
+
const attributes = record.attributes;
|
|
723
|
+
if (attributes && typeof attributes === "object") {
|
|
724
|
+
return mapRecordToFeatureEntry(
|
|
725
|
+
{ id: record.id, ...attributes },
|
|
726
|
+
mapping
|
|
727
|
+
);
|
|
728
|
+
}
|
|
729
|
+
return mapRecordToFeatureEntry(record, mapping);
|
|
730
|
+
}).filter((entry) => entry !== null);
|
|
731
|
+
return validateMappedEntries(entries, this.options);
|
|
732
|
+
}
|
|
733
|
+
};
|
|
734
|
+
function notionPropertyToValue(property) {
|
|
735
|
+
if (!property || typeof property !== "object") return void 0;
|
|
736
|
+
const typed = property;
|
|
737
|
+
const type = typed.type;
|
|
738
|
+
if (typeof type !== "string") return void 0;
|
|
739
|
+
const value = typed[type];
|
|
740
|
+
if (type === "title" || type === "rich_text") {
|
|
741
|
+
if (!Array.isArray(value)) return void 0;
|
|
742
|
+
return value.map((item) => {
|
|
743
|
+
if (!item || typeof item !== "object") return "";
|
|
744
|
+
return asString2(item.plain_text) ?? "";
|
|
745
|
+
}).join("").trim();
|
|
746
|
+
}
|
|
747
|
+
if (type === "select") {
|
|
748
|
+
if (!value || typeof value !== "object") return void 0;
|
|
749
|
+
return asString2(value.name);
|
|
750
|
+
}
|
|
751
|
+
if (type === "multi_select") {
|
|
752
|
+
if (!Array.isArray(value)) return void 0;
|
|
753
|
+
return value.map((item) => item && typeof item === "object" ? asString2(item.name) : void 0).filter((item) => Boolean(item));
|
|
754
|
+
}
|
|
755
|
+
if (type === "date") {
|
|
756
|
+
if (!value || typeof value !== "object") return void 0;
|
|
757
|
+
return asString2(value.start);
|
|
758
|
+
}
|
|
759
|
+
if (type === "number" || type === "url" || type === "email" || type === "phone_number") {
|
|
760
|
+
return value;
|
|
761
|
+
}
|
|
762
|
+
if (type === "checkbox") {
|
|
763
|
+
return value === true ? "true" : value === false ? "false" : void 0;
|
|
764
|
+
}
|
|
765
|
+
return void 0;
|
|
766
|
+
}
|
|
767
|
+
function flattenNotionPage(page) {
|
|
768
|
+
if (!page || typeof page !== "object") return {};
|
|
769
|
+
const record = page;
|
|
770
|
+
const properties = record.properties;
|
|
771
|
+
const flattened = {
|
|
772
|
+
id: record.id
|
|
773
|
+
};
|
|
774
|
+
if (properties && typeof properties === "object") {
|
|
775
|
+
Object.entries(properties).forEach(([key, value]) => {
|
|
776
|
+
flattened[key] = notionPropertyToValue(value);
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
return flattened;
|
|
780
|
+
}
|
|
781
|
+
var NotionAdapter = class {
|
|
782
|
+
options;
|
|
783
|
+
constructor(options) {
|
|
784
|
+
this.options = options;
|
|
785
|
+
}
|
|
786
|
+
async load() {
|
|
787
|
+
const body = {};
|
|
788
|
+
if (this.options.filter) body.filter = this.options.filter;
|
|
789
|
+
if (this.options.sorts) body.sorts = this.options.sorts;
|
|
790
|
+
const payload = await fetchJson(
|
|
791
|
+
`https://api.notion.com/v1/databases/${encodeURIComponent(this.options.databaseId)}/query`,
|
|
792
|
+
{
|
|
793
|
+
method: "POST",
|
|
794
|
+
headers: {
|
|
795
|
+
"Content-Type": "application/json",
|
|
796
|
+
Authorization: `Bearer ${this.options.token}`,
|
|
797
|
+
"Notion-Version": this.options.notionVersion ?? "2022-06-28"
|
|
798
|
+
},
|
|
799
|
+
body: JSON.stringify(body)
|
|
800
|
+
}
|
|
801
|
+
);
|
|
802
|
+
const mapping = normalizeFieldMapping(DEFAULT_FIELDS, this.options.fieldMapping);
|
|
803
|
+
const entries = (payload.results ?? []).map((page) => mapRecordToFeatureEntry(flattenNotionPage(page), mapping)).filter((entry) => entry !== null);
|
|
804
|
+
return validateMappedEntries(entries, this.options);
|
|
805
|
+
}
|
|
806
|
+
};
|
|
807
|
+
var MarkdownAdapter = class {
|
|
808
|
+
options;
|
|
809
|
+
constructor(options = {}) {
|
|
810
|
+
this.options = options;
|
|
811
|
+
}
|
|
812
|
+
async load() {
|
|
813
|
+
if (this.options.pattern) {
|
|
814
|
+
const entries2 = await buildManifestFromPattern({
|
|
815
|
+
cwd: this.options.cwd,
|
|
816
|
+
pattern: this.options.pattern
|
|
817
|
+
});
|
|
818
|
+
return validateMappedEntries(entries2, this.options);
|
|
819
|
+
}
|
|
820
|
+
const entries = this.options.entries ?? [];
|
|
821
|
+
const mapped = entries.map((entry, index) => {
|
|
822
|
+
const source = entry.source ?? `feature-${index + 1}.md`;
|
|
823
|
+
return parseFeatureFile(entry.markdown, source);
|
|
824
|
+
});
|
|
825
|
+
return validateMappedEntries(mapped, this.options);
|
|
826
|
+
}
|
|
827
|
+
};
|
|
828
|
+
|
|
829
|
+
exports.ContentfulAdapter = ContentfulAdapter;
|
|
830
|
+
exports.MarkdownAdapter = MarkdownAdapter;
|
|
831
|
+
exports.NotionAdapter = NotionAdapter;
|
|
832
|
+
exports.SanityAdapter = SanityAdapter;
|
|
833
|
+
exports.StrapiAdapter = StrapiAdapter;
|
|
834
|
+
//# sourceMappingURL=cms.cjs.map
|
|
835
|
+
//# sourceMappingURL=cms.cjs.map
|