featuredrop 1.1.0 → 1.3.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 +547 -4
- package/dist/admin.cjs +212 -0
- package/dist/admin.cjs.map +1 -0
- package/dist/admin.d.cts +176 -0
- package/dist/admin.d.ts +176 -0
- package/dist/admin.js +207 -0
- package/dist/admin.js.map +1 -0
- package/dist/angular.cjs +296 -0
- package/dist/angular.cjs.map +1 -0
- package/dist/angular.d.cts +233 -0
- package/dist/angular.d.ts +233 -0
- package/dist/angular.js +293 -0
- package/dist/angular.js.map +1 -0
- package/dist/bridges.cjs +401 -0
- package/dist/bridges.cjs.map +1 -0
- package/dist/bridges.d.cts +194 -0
- package/dist/bridges.d.ts +194 -0
- package/dist/bridges.js +394 -0
- package/dist/bridges.js.map +1 -0
- package/dist/ci.cjs +328 -0
- package/dist/ci.cjs.map +1 -0
- package/dist/ci.d.cts +176 -0
- package/dist/ci.d.ts +176 -0
- package/dist/ci.js +324 -0
- package/dist/ci.js.map +1 -0
- package/dist/featuredrop.cjs +1377 -0
- package/dist/featuredrop.cjs.map +1 -0
- package/dist/flags.cjs +51 -0
- package/dist/flags.cjs.map +1 -0
- package/dist/flags.d.cts +48 -0
- package/dist/flags.d.ts +48 -0
- package/dist/flags.js +47 -0
- package/dist/flags.js.map +1 -0
- package/dist/index.cjs +4734 -70
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1516 -9
- package/dist/index.d.ts +1516 -9
- package/dist/index.js +4660 -71
- package/dist/index.js.map +1 -1
- package/dist/preact.cjs +7790 -0
- package/dist/preact.cjs.map +1 -0
- package/dist/preact.d.cts +1213 -0
- package/dist/preact.d.ts +1213 -0
- package/dist/preact.js +7760 -0
- package/dist/preact.js.map +1 -0
- package/dist/react.cjs +6678 -159
- package/dist/react.cjs.map +1 -1
- package/dist/react.d.cts +852 -112
- package/dist/react.d.ts +852 -112
- package/dist/react.js +6657 -156
- package/dist/react.js.map +1 -1
- package/dist/schema.cjs +292 -0
- package/dist/schema.cjs.map +1 -0
- package/dist/schema.d.cts +345 -0
- package/dist/schema.d.ts +345 -0
- package/dist/schema.js +286 -0
- package/dist/schema.js.map +1 -0
- package/dist/solid.cjs +383 -0
- package/dist/solid.cjs.map +1 -0
- package/dist/solid.d.cts +246 -0
- package/dist/solid.d.ts +246 -0
- package/dist/solid.js +376 -0
- package/dist/solid.js.map +1 -0
- package/dist/svelte.cjs +339 -0
- package/dist/svelte.cjs.map +1 -0
- package/dist/svelte.js +334 -0
- package/dist/svelte.js.map +1 -0
- package/dist/testing.cjs +1543 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +361 -0
- package/dist/testing.d.ts +361 -0
- package/dist/testing.js +1536 -0
- package/dist/testing.js.map +1 -0
- package/dist/vue.cjs +1094 -0
- package/dist/vue.cjs.map +1 -0
- package/dist/vue.js +1082 -0
- package/dist/vue.js.map +1 -0
- package/dist/web-components.cjs +493 -0
- package/dist/web-components.cjs.map +1 -0
- package/dist/web-components.d.cts +215 -0
- package/dist/web-components.d.ts +215 -0
- package/dist/web-components.js +487 -0
- package/dist/web-components.js.map +1 -0
- package/package.json +184 -3
|
@@ -0,0 +1,1377 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
var promises$1 = require('readline/promises');
|
|
5
|
+
var promises = require('fs/promises');
|
|
6
|
+
var path = require('path');
|
|
7
|
+
var zod = require('zod');
|
|
8
|
+
var module$1 = require('module');
|
|
9
|
+
|
|
10
|
+
var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
|
|
11
|
+
// src/dependencies.ts
|
|
12
|
+
function getDirectDependencies(feature) {
|
|
13
|
+
const dependsOn = feature.dependsOn;
|
|
14
|
+
if (!dependsOn) return [];
|
|
15
|
+
const seen = dependsOn.seen ?? [];
|
|
16
|
+
const clicked = dependsOn.clicked ?? [];
|
|
17
|
+
const dismissed = dependsOn.dismissed ?? [];
|
|
18
|
+
const unique = /* @__PURE__ */ new Set();
|
|
19
|
+
for (const id of [...seen, ...clicked, ...dismissed]) {
|
|
20
|
+
if (id) unique.add(id);
|
|
21
|
+
}
|
|
22
|
+
return Array.from(unique);
|
|
23
|
+
}
|
|
24
|
+
function hasDependencyCycle(manifest) {
|
|
25
|
+
const ids = new Set(manifest.map((feature) => feature.id));
|
|
26
|
+
const outgoing = /* @__PURE__ */ new Map();
|
|
27
|
+
const indegree = /* @__PURE__ */ new Map();
|
|
28
|
+
for (const feature of manifest) {
|
|
29
|
+
outgoing.set(feature.id, /* @__PURE__ */ new Set());
|
|
30
|
+
indegree.set(feature.id, 0);
|
|
31
|
+
}
|
|
32
|
+
for (const feature of manifest) {
|
|
33
|
+
for (const dependencyId of getDirectDependencies(feature)) {
|
|
34
|
+
if (!ids.has(dependencyId)) continue;
|
|
35
|
+
const edges = outgoing.get(dependencyId);
|
|
36
|
+
if (!edges || edges.has(feature.id)) continue;
|
|
37
|
+
edges.add(feature.id);
|
|
38
|
+
indegree.set(feature.id, (indegree.get(feature.id) ?? 0) + 1);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const queue = [];
|
|
42
|
+
for (const feature of manifest) {
|
|
43
|
+
if ((indegree.get(feature.id) ?? 0) === 0) queue.push(feature.id);
|
|
44
|
+
}
|
|
45
|
+
let visited = 0;
|
|
46
|
+
while (queue.length > 0) {
|
|
47
|
+
const id = queue.shift();
|
|
48
|
+
if (!id) continue;
|
|
49
|
+
visited += 1;
|
|
50
|
+
const edges = outgoing.get(id);
|
|
51
|
+
if (!edges) continue;
|
|
52
|
+
for (const nextId of edges) {
|
|
53
|
+
const nextDegree = (indegree.get(nextId) ?? 0) - 1;
|
|
54
|
+
indegree.set(nextId, nextDegree);
|
|
55
|
+
if (nextDegree === 0) queue.push(nextId);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return visited !== manifest.length;
|
|
59
|
+
}
|
|
60
|
+
function isRecord(value) {
|
|
61
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
62
|
+
}
|
|
63
|
+
function isValidDate(value) {
|
|
64
|
+
return Number.isFinite(new Date(value).getTime());
|
|
65
|
+
}
|
|
66
|
+
var nonEmptyString = zod.z.string().trim().min(1, "must be a non-empty string");
|
|
67
|
+
var isoDateString = nonEmptyString.refine(isValidDate, {
|
|
68
|
+
message: "must be a valid date",
|
|
69
|
+
params: { featuredropCode: "invalid_date" }
|
|
70
|
+
});
|
|
71
|
+
var dependsOnSchema = zod.z.object({
|
|
72
|
+
seen: zod.z.array(zod.z.string()).optional(),
|
|
73
|
+
clicked: zod.z.array(zod.z.string()).optional(),
|
|
74
|
+
dismissed: zod.z.array(zod.z.string()).optional()
|
|
75
|
+
}).optional();
|
|
76
|
+
var ctaSchema = zod.z.object({
|
|
77
|
+
label: nonEmptyString,
|
|
78
|
+
url: nonEmptyString
|
|
79
|
+
}).optional();
|
|
80
|
+
var featureEntrySchema = zod.z.object({
|
|
81
|
+
id: nonEmptyString,
|
|
82
|
+
label: nonEmptyString,
|
|
83
|
+
releasedAt: isoDateString,
|
|
84
|
+
showNewUntil: isoDateString,
|
|
85
|
+
description: zod.z.string().optional(),
|
|
86
|
+
flagKey: zod.z.string().optional(),
|
|
87
|
+
product: zod.z.string().optional(),
|
|
88
|
+
url: zod.z.string().optional(),
|
|
89
|
+
image: zod.z.string().optional(),
|
|
90
|
+
type: zod.z.enum(["feature", "improvement", "fix", "breaking"]).optional(),
|
|
91
|
+
priority: zod.z.enum(["critical", "normal", "low"]).optional(),
|
|
92
|
+
cta: ctaSchema,
|
|
93
|
+
meta: zod.z.record(zod.z.unknown()).optional(),
|
|
94
|
+
dependsOn: dependsOnSchema
|
|
95
|
+
}).passthrough();
|
|
96
|
+
zod.z.array(featureEntrySchema);
|
|
97
|
+
function toIssuePath(path) {
|
|
98
|
+
if (path.length === 0) return "$";
|
|
99
|
+
let output = "";
|
|
100
|
+
for (const part of path) {
|
|
101
|
+
if (typeof part === "number") output += `[${part}]`;
|
|
102
|
+
else output += output ? `.${part}` : part;
|
|
103
|
+
}
|
|
104
|
+
return output;
|
|
105
|
+
}
|
|
106
|
+
function mapZodIssue(issue) {
|
|
107
|
+
const codeParam = issue.params?.featuredropCode;
|
|
108
|
+
if (codeParam === "invalid_date") {
|
|
109
|
+
return {
|
|
110
|
+
path: toIssuePath(issue.path),
|
|
111
|
+
message: issue.message,
|
|
112
|
+
code: "invalid_date"
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (issue.code === "invalid_type") {
|
|
116
|
+
return {
|
|
117
|
+
path: toIssuePath(issue.path),
|
|
118
|
+
message: issue.message,
|
|
119
|
+
code: issue.received === "undefined" ? "missing_required" : "invalid_type"
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
path: toIssuePath(issue.path),
|
|
124
|
+
message: issue.message,
|
|
125
|
+
code: "invalid_value"
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
var UNSAFE_META_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
129
|
+
function isSafeUrl(value) {
|
|
130
|
+
const normalized = value.trim();
|
|
131
|
+
if (!normalized) return false;
|
|
132
|
+
if (/^(\/|\.\/|\.\.\/|\?|#)/.test(normalized)) return true;
|
|
133
|
+
if (/^https?:\/\//i.test(normalized)) return true;
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
function findUnsafeMetaPath(value, path = "meta") {
|
|
137
|
+
if (Array.isArray(value)) {
|
|
138
|
+
for (let index = 0; index < value.length; index++) {
|
|
139
|
+
const nested = findUnsafeMetaPath(value[index], `${path}[${index}]`);
|
|
140
|
+
if (nested) return nested;
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
if (!isRecord(value)) return null;
|
|
145
|
+
for (const [key, nestedValue] of Object.entries(value)) {
|
|
146
|
+
if (UNSAFE_META_KEYS.has(key)) {
|
|
147
|
+
return `${path}.${key}`;
|
|
148
|
+
}
|
|
149
|
+
const nested = findUnsafeMetaPath(nestedValue, `${path}.${key}`);
|
|
150
|
+
if (nested) return nested;
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
function validateFeatureEntry(raw, index) {
|
|
155
|
+
if (!isRecord(raw)) {
|
|
156
|
+
return {
|
|
157
|
+
issues: [
|
|
158
|
+
{
|
|
159
|
+
path: `[${index}]`,
|
|
160
|
+
message: "Feature entry must be an object",
|
|
161
|
+
code: "invalid_type"
|
|
162
|
+
}
|
|
163
|
+
]
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
const parsed = featureEntrySchema.safeParse(raw);
|
|
167
|
+
if (!parsed.success) {
|
|
168
|
+
return {
|
|
169
|
+
issues: parsed.error.issues.map((issue) => ({
|
|
170
|
+
...mapZodIssue(issue),
|
|
171
|
+
path: `[${index}]${issue.path.length > 0 ? `.${toIssuePath(issue.path)}` : ""}`
|
|
172
|
+
}))
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
return {
|
|
176
|
+
issues: [],
|
|
177
|
+
entry: parsed.data
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
function validateManifest(data) {
|
|
181
|
+
const errors = [];
|
|
182
|
+
if (!Array.isArray(data)) {
|
|
183
|
+
return {
|
|
184
|
+
valid: false,
|
|
185
|
+
errors: [
|
|
186
|
+
{
|
|
187
|
+
path: "$",
|
|
188
|
+
message: "Manifest must be an array",
|
|
189
|
+
code: "invalid_type"
|
|
190
|
+
}
|
|
191
|
+
]
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
const entries = [];
|
|
195
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
196
|
+
data.forEach((item, index) => {
|
|
197
|
+
const result = validateFeatureEntry(item, index);
|
|
198
|
+
errors.push(...result.issues);
|
|
199
|
+
if (!result.entry) return;
|
|
200
|
+
if (seenIds.has(result.entry.id)) {
|
|
201
|
+
errors.push({
|
|
202
|
+
path: `[${index}].id`,
|
|
203
|
+
message: `Duplicate feature id "${result.entry.id}"`,
|
|
204
|
+
code: "duplicate_id"
|
|
205
|
+
});
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
seenIds.add(result.entry.id);
|
|
209
|
+
entries.push(result.entry);
|
|
210
|
+
});
|
|
211
|
+
if (entries.length > 0 && hasDependencyCycle(entries)) {
|
|
212
|
+
errors.push({
|
|
213
|
+
path: "$",
|
|
214
|
+
message: "Circular dependsOn relationship detected",
|
|
215
|
+
code: "circular_dependency"
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
for (let index = 0; index < entries.length; index++) {
|
|
219
|
+
const entry = entries[index];
|
|
220
|
+
if (new Date(entry.showNewUntil).getTime() <= new Date(entry.releasedAt).getTime()) {
|
|
221
|
+
errors.push({
|
|
222
|
+
path: `[${index}].showNewUntil`,
|
|
223
|
+
message: "showNewUntil must be after releasedAt",
|
|
224
|
+
code: "invalid_value"
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
if (entry.url && !isSafeUrl(entry.url)) {
|
|
228
|
+
errors.push({
|
|
229
|
+
path: `[${index}].url`,
|
|
230
|
+
message: "url must be http, https, or relative",
|
|
231
|
+
code: "invalid_value"
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
if (entry.image && !isSafeUrl(entry.image)) {
|
|
235
|
+
errors.push({
|
|
236
|
+
path: `[${index}].image`,
|
|
237
|
+
message: "image must be http, https, or relative",
|
|
238
|
+
code: "invalid_value"
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
if (entry.cta?.url && !isSafeUrl(entry.cta.url)) {
|
|
242
|
+
errors.push({
|
|
243
|
+
path: `[${index}].cta.url`,
|
|
244
|
+
message: "cta.url must be http, https, or relative",
|
|
245
|
+
code: "invalid_value"
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
const unsafeMetaPath = findUnsafeMetaPath(entry.meta);
|
|
249
|
+
if (unsafeMetaPath) {
|
|
250
|
+
errors.push({
|
|
251
|
+
path: `[${index}].${unsafeMetaPath}`,
|
|
252
|
+
message: `meta contains unsafe key "${unsafeMetaPath.split(".").pop()}"`,
|
|
253
|
+
code: "invalid_value"
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return {
|
|
258
|
+
valid: errors.length === 0,
|
|
259
|
+
errors
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// src/changelog-as-code.ts
|
|
264
|
+
function parseScalar(raw) {
|
|
265
|
+
const value = raw.trim();
|
|
266
|
+
if (!value) return "";
|
|
267
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
268
|
+
return value.slice(1, -1);
|
|
269
|
+
}
|
|
270
|
+
if (value === "true") return true;
|
|
271
|
+
if (value === "false") return false;
|
|
272
|
+
if (value === "null") return null;
|
|
273
|
+
if (/^-?\d+(\.\d+)?$/.test(value)) return Number(value);
|
|
274
|
+
if (value.startsWith("[") && value.endsWith("]")) {
|
|
275
|
+
const inner = value.slice(1, -1).trim();
|
|
276
|
+
if (!inner) return [];
|
|
277
|
+
return inner.split(",").map((part) => String(parseScalar(part.trim())));
|
|
278
|
+
}
|
|
279
|
+
return value;
|
|
280
|
+
}
|
|
281
|
+
function parseFrontmatter(raw) {
|
|
282
|
+
const lines = raw.split(/\r?\n/);
|
|
283
|
+
const root = {};
|
|
284
|
+
const stack = [
|
|
285
|
+
{ indent: -1, value: root }
|
|
286
|
+
];
|
|
287
|
+
const isArrayContext = (idx) => {
|
|
288
|
+
for (let i = idx + 1; i < lines.length; i++) {
|
|
289
|
+
const line = lines[i];
|
|
290
|
+
if (!line.trim()) continue;
|
|
291
|
+
const indent = line.length - line.trimStart().length;
|
|
292
|
+
if (indent <= lines[idx].length - lines[idx].trimStart().length) return false;
|
|
293
|
+
return line.trimStart().startsWith("- ");
|
|
294
|
+
}
|
|
295
|
+
return false;
|
|
296
|
+
};
|
|
297
|
+
for (let i = 0; i < lines.length; i++) {
|
|
298
|
+
const line = lines[i];
|
|
299
|
+
if (!line.trim() || line.trimStart().startsWith("#")) continue;
|
|
300
|
+
const indent = line.length - line.trimStart().length;
|
|
301
|
+
const trimmed = line.trim();
|
|
302
|
+
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
|
|
303
|
+
stack.pop();
|
|
304
|
+
}
|
|
305
|
+
const current = stack[stack.length - 1].value;
|
|
306
|
+
if (trimmed.startsWith("- ")) {
|
|
307
|
+
if (!Array.isArray(current)) {
|
|
308
|
+
throw new Error(`Invalid frontmatter list at line ${i + 1}`);
|
|
309
|
+
}
|
|
310
|
+
const item = trimmed.slice(2).trim();
|
|
311
|
+
current.push(parseScalar(item));
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
const colon = trimmed.indexOf(":");
|
|
315
|
+
if (colon === -1) {
|
|
316
|
+
throw new Error(`Invalid frontmatter line ${i + 1}: ${trimmed}`);
|
|
317
|
+
}
|
|
318
|
+
const key = trimmed.slice(0, colon).trim();
|
|
319
|
+
const rest = trimmed.slice(colon + 1).trim();
|
|
320
|
+
if (Array.isArray(current)) {
|
|
321
|
+
throw new Error(`Unexpected key in list at line ${i + 1}`);
|
|
322
|
+
}
|
|
323
|
+
if (!rest) {
|
|
324
|
+
const container = isArrayContext(i) ? [] : {};
|
|
325
|
+
current[key] = container;
|
|
326
|
+
stack.push({ indent, value: container });
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
current[key] = parseScalar(rest);
|
|
330
|
+
}
|
|
331
|
+
return root;
|
|
332
|
+
}
|
|
333
|
+
function splitFrontmatter(markdown) {
|
|
334
|
+
const normalized = markdown.replace(/\r\n/g, "\n");
|
|
335
|
+
if (!normalized.startsWith("---\n")) {
|
|
336
|
+
return { frontmatter: {}, body: normalized.trim() };
|
|
337
|
+
}
|
|
338
|
+
const end = normalized.indexOf("\n---\n", 4);
|
|
339
|
+
if (end === -1) {
|
|
340
|
+
throw new Error("Frontmatter block is not closed with ---");
|
|
341
|
+
}
|
|
342
|
+
const fmRaw = normalized.slice(4, end);
|
|
343
|
+
const body = normalized.slice(end + 5).trim();
|
|
344
|
+
return {
|
|
345
|
+
frontmatter: parseFrontmatter(fmRaw),
|
|
346
|
+
body
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
function asString(value, field, source) {
|
|
350
|
+
if (typeof value !== "string" || !value.trim()) {
|
|
351
|
+
throw new Error(`${source}: "${field}" must be a non-empty string`);
|
|
352
|
+
}
|
|
353
|
+
return value;
|
|
354
|
+
}
|
|
355
|
+
function asOptionalObject(value, field, source) {
|
|
356
|
+
if (value === void 0) return void 0;
|
|
357
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
358
|
+
throw new Error(`${source}: "${field}" must be an object`);
|
|
359
|
+
}
|
|
360
|
+
return value;
|
|
361
|
+
}
|
|
362
|
+
function parseFeatureFile(markdown, source = "feature.md") {
|
|
363
|
+
const { frontmatter, body } = splitFrontmatter(markdown);
|
|
364
|
+
const entry = {
|
|
365
|
+
id: asString(frontmatter.id, "id", source),
|
|
366
|
+
label: asString(frontmatter.label, "label", source),
|
|
367
|
+
releasedAt: asString(frontmatter.releasedAt, "releasedAt", source),
|
|
368
|
+
showNewUntil: asString(frontmatter.showNewUntil, "showNewUntil", source),
|
|
369
|
+
description: body || void 0
|
|
370
|
+
};
|
|
371
|
+
if (frontmatter.sidebarKey !== void 0) entry.sidebarKey = asString(frontmatter.sidebarKey, "sidebarKey", source);
|
|
372
|
+
if (frontmatter.category !== void 0) entry.category = asString(frontmatter.category, "category", source);
|
|
373
|
+
if (frontmatter.product !== void 0) entry.product = asString(frontmatter.product, "product", source);
|
|
374
|
+
if (frontmatter.url !== void 0) entry.url = asString(frontmatter.url, "url", source);
|
|
375
|
+
if (frontmatter.flagKey !== void 0) entry.flagKey = asString(frontmatter.flagKey, "flagKey", source);
|
|
376
|
+
if (frontmatter.image !== void 0) entry.image = asString(frontmatter.image, "image", source);
|
|
377
|
+
if (frontmatter.publishAt !== void 0) entry.publishAt = asString(frontmatter.publishAt, "publishAt", source);
|
|
378
|
+
if (frontmatter.version !== void 0) {
|
|
379
|
+
if (typeof frontmatter.version === "string" || typeof frontmatter.version === "object") {
|
|
380
|
+
entry.version = frontmatter.version;
|
|
381
|
+
} else {
|
|
382
|
+
throw new Error(`${source}: "version" must be a string or object`);
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
if (frontmatter.type !== void 0) {
|
|
386
|
+
const type = asString(frontmatter.type, "type", source);
|
|
387
|
+
if (!["feature", "improvement", "fix", "breaking"].includes(type)) {
|
|
388
|
+
throw new Error(`${source}: invalid "type" value "${type}"`);
|
|
389
|
+
}
|
|
390
|
+
entry.type = type;
|
|
391
|
+
}
|
|
392
|
+
if (frontmatter.priority !== void 0) {
|
|
393
|
+
const priority = asString(frontmatter.priority, "priority", source);
|
|
394
|
+
if (!["critical", "normal", "low"].includes(priority)) {
|
|
395
|
+
throw new Error(`${source}: invalid "priority" value "${priority}"`);
|
|
396
|
+
}
|
|
397
|
+
entry.priority = priority;
|
|
398
|
+
}
|
|
399
|
+
const cta = asOptionalObject(frontmatter.cta, "cta", source);
|
|
400
|
+
if (cta) {
|
|
401
|
+
entry.cta = {
|
|
402
|
+
label: asString(cta.label, "cta.label", source),
|
|
403
|
+
url: asString(cta.url, "cta.url", source)
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
const audience = asOptionalObject(frontmatter.audience, "audience", source);
|
|
407
|
+
if (audience) {
|
|
408
|
+
const parsedAudience = {};
|
|
409
|
+
for (const field of ["plan", "role", "region"]) {
|
|
410
|
+
const value = audience[field];
|
|
411
|
+
if (value !== void 0) {
|
|
412
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
|
|
413
|
+
throw new Error(`${source}: "audience.${field}" must be string[]`);
|
|
414
|
+
}
|
|
415
|
+
parsedAudience[field] = value;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if (audience.custom !== void 0) {
|
|
419
|
+
if (!audience.custom || typeof audience.custom !== "object" || Array.isArray(audience.custom)) {
|
|
420
|
+
throw new Error(`${source}: "audience.custom" must be an object`);
|
|
421
|
+
}
|
|
422
|
+
parsedAudience.custom = audience.custom;
|
|
423
|
+
}
|
|
424
|
+
entry.audience = parsedAudience;
|
|
425
|
+
}
|
|
426
|
+
return entry;
|
|
427
|
+
}
|
|
428
|
+
function normalizePattern(pattern) {
|
|
429
|
+
const normalized = pattern.replaceAll("\\", "/");
|
|
430
|
+
if (normalized.endsWith("/**/*.md")) {
|
|
431
|
+
return {
|
|
432
|
+
baseDir: normalized.slice(0, -"/**/*.md".length),
|
|
433
|
+
ext: ".md"
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
throw new Error(`Unsupported pattern "${pattern}". Use "features/**/*.md" style patterns.`);
|
|
437
|
+
}
|
|
438
|
+
async function collectFiles(dir, ext) {
|
|
439
|
+
const out = [];
|
|
440
|
+
async function walk(current) {
|
|
441
|
+
let entries;
|
|
442
|
+
try {
|
|
443
|
+
entries = await promises.readdir(current, { withFileTypes: true });
|
|
444
|
+
} catch {
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
for (const entry of entries) {
|
|
448
|
+
const fullPath = path.join(current, entry.name);
|
|
449
|
+
if (entry.isDirectory()) {
|
|
450
|
+
await walk(fullPath);
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
if (entry.isFile() && entry.name.endsWith(ext)) {
|
|
454
|
+
out.push(fullPath);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
await walk(dir);
|
|
459
|
+
return out.sort();
|
|
460
|
+
}
|
|
461
|
+
async function buildManifestFromPattern(options = {}) {
|
|
462
|
+
const cwd = options.cwd ?? process.cwd();
|
|
463
|
+
const pattern = options.pattern ?? "features/**/*.md";
|
|
464
|
+
const { baseDir, ext } = normalizePattern(pattern);
|
|
465
|
+
const baseAbs = path.join(cwd, baseDir);
|
|
466
|
+
const stats = await promises.stat(baseAbs).catch(() => null);
|
|
467
|
+
if (!stats || !stats.isDirectory()) {
|
|
468
|
+
throw new Error(`Pattern base directory does not exist: ${baseDir}`);
|
|
469
|
+
}
|
|
470
|
+
const files = await collectFiles(baseAbs, ext);
|
|
471
|
+
const entries = [];
|
|
472
|
+
const seenIds = /* @__PURE__ */ new Set();
|
|
473
|
+
for (const file of files) {
|
|
474
|
+
const content = await promises.readFile(file, "utf8");
|
|
475
|
+
const source = path.relative(cwd, file).split(path.sep).join("/");
|
|
476
|
+
const entry = parseFeatureFile(content, source);
|
|
477
|
+
if (seenIds.has(entry.id)) {
|
|
478
|
+
throw new Error(`Duplicate feature id "${entry.id}" found at ${source}`);
|
|
479
|
+
}
|
|
480
|
+
seenIds.add(entry.id);
|
|
481
|
+
entries.push(entry);
|
|
482
|
+
}
|
|
483
|
+
if (options.outFile) {
|
|
484
|
+
const outPath = path.join(cwd, options.outFile);
|
|
485
|
+
await promises.writeFile(outPath, `${JSON.stringify(entries, null, 2)}
|
|
486
|
+
`, "utf8");
|
|
487
|
+
}
|
|
488
|
+
return entries;
|
|
489
|
+
}
|
|
490
|
+
async function validateFeatureFiles(options = {}) {
|
|
491
|
+
const entries = await buildManifestFromPattern(options);
|
|
492
|
+
const result = validateManifest(entries);
|
|
493
|
+
if (!result.valid) {
|
|
494
|
+
const message = result.errors.map((issue) => `${issue.path}: ${issue.message}`).join("; ");
|
|
495
|
+
throw new Error(`Manifest validation failed: ${message}`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// src/cli-utils.ts
|
|
500
|
+
function computeManifestStats(entries) {
|
|
501
|
+
const byType = {};
|
|
502
|
+
const byCategory = {};
|
|
503
|
+
for (const entry of entries) {
|
|
504
|
+
const type = entry.type ?? "feature";
|
|
505
|
+
byType[type] = (byType[type] ?? 0) + 1;
|
|
506
|
+
if (entry.category) {
|
|
507
|
+
byCategory[entry.category] = (byCategory[entry.category] ?? 0) + 1;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
const sortedByDate = [...entries].sort(
|
|
511
|
+
(a, b) => new Date(b.releasedAt).getTime() - new Date(a.releasedAt).getTime()
|
|
512
|
+
);
|
|
513
|
+
return {
|
|
514
|
+
total: entries.length,
|
|
515
|
+
byType,
|
|
516
|
+
byCategory,
|
|
517
|
+
newestRelease: sortedByDate[0]?.releasedAt ?? null,
|
|
518
|
+
oldestRelease: sortedByDate[sortedByDate.length - 1]?.releasedAt ?? null
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
function generateMarkdownChangelog(entries) {
|
|
522
|
+
const sorted = [...entries].sort(
|
|
523
|
+
(a, b) => new Date(b.releasedAt).getTime() - new Date(a.releasedAt).getTime()
|
|
524
|
+
);
|
|
525
|
+
const sections = sorted.map((entry) => {
|
|
526
|
+
const lines = [
|
|
527
|
+
`## ${entry.label}`,
|
|
528
|
+
"",
|
|
529
|
+
`- **ID**: \`${entry.id}\``,
|
|
530
|
+
`- **Released**: ${entry.releasedAt}`
|
|
531
|
+
];
|
|
532
|
+
if (entry.type) lines.push(`- **Type**: ${entry.type}`);
|
|
533
|
+
if (entry.category) lines.push(`- **Category**: ${entry.category}`);
|
|
534
|
+
if (entry.showNewUntil) lines.push(`- **Show new until**: ${entry.showNewUntil}`);
|
|
535
|
+
if (entry.cta) lines.push(`- **CTA**: [${entry.cta.label}](${entry.cta.url})`);
|
|
536
|
+
if (entry.description) {
|
|
537
|
+
lines.push("", entry.description.trim());
|
|
538
|
+
}
|
|
539
|
+
return lines.join("\n");
|
|
540
|
+
});
|
|
541
|
+
return `# Generated Changelog
|
|
542
|
+
|
|
543
|
+
${sections.join("\n\n---\n\n")}
|
|
544
|
+
`;
|
|
545
|
+
}
|
|
546
|
+
function isIsoWithTimezone(value) {
|
|
547
|
+
if (!value.includes("T")) return false;
|
|
548
|
+
if (!(value.endsWith("Z") || /[+-]\d{2}:\d{2}$/.test(value))) return false;
|
|
549
|
+
return Number.isFinite(new Date(value).getTime());
|
|
550
|
+
}
|
|
551
|
+
function runDoctor(entries, now = /* @__PURE__ */ new Date()) {
|
|
552
|
+
const checks = [];
|
|
553
|
+
const warnings = [];
|
|
554
|
+
const errors = [];
|
|
555
|
+
checks.push(`Manifest entries loaded: ${entries.length}`);
|
|
556
|
+
const ids = /* @__PURE__ */ new Set();
|
|
557
|
+
let duplicateCount = 0;
|
|
558
|
+
for (const entry of entries) {
|
|
559
|
+
if (ids.has(entry.id)) duplicateCount += 1;
|
|
560
|
+
ids.add(entry.id);
|
|
561
|
+
}
|
|
562
|
+
if (duplicateCount > 0) {
|
|
563
|
+
errors.push(`${duplicateCount} duplicate feature id(s) found`);
|
|
564
|
+
} else {
|
|
565
|
+
checks.push("No duplicate IDs");
|
|
566
|
+
}
|
|
567
|
+
let invalidDateCount = 0;
|
|
568
|
+
let reversedDateCount = 0;
|
|
569
|
+
let expiredCount = 0;
|
|
570
|
+
let scheduledCount = 0;
|
|
571
|
+
let missingDescriptionCount = 0;
|
|
572
|
+
for (const entry of entries) {
|
|
573
|
+
if (!entry.description?.trim()) missingDescriptionCount += 1;
|
|
574
|
+
if (!isIsoWithTimezone(entry.releasedAt) || !isIsoWithTimezone(entry.showNewUntil)) {
|
|
575
|
+
invalidDateCount += 1;
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
const released = new Date(entry.releasedAt).getTime();
|
|
579
|
+
const showUntil = new Date(entry.showNewUntil).getTime();
|
|
580
|
+
if (showUntil <= released) reversedDateCount += 1;
|
|
581
|
+
if (showUntil < now.getTime()) expiredCount += 1;
|
|
582
|
+
if (entry.publishAt) {
|
|
583
|
+
const publishMs = new Date(entry.publishAt).getTime();
|
|
584
|
+
if (Number.isFinite(publishMs) && publishMs > now.getTime()) scheduledCount += 1;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
if (invalidDateCount > 0) {
|
|
588
|
+
errors.push(`${invalidDateCount} entries have invalid ISO 8601 dates with timezone`);
|
|
589
|
+
} else {
|
|
590
|
+
checks.push("All dates are valid ISO 8601 with timezone");
|
|
591
|
+
}
|
|
592
|
+
if (reversedDateCount > 0) {
|
|
593
|
+
errors.push(`${reversedDateCount} entries have showNewUntil before/at releasedAt`);
|
|
594
|
+
}
|
|
595
|
+
if (expiredCount > 0) warnings.push(`${expiredCount} entries have showNewUntil in the past`);
|
|
596
|
+
if (scheduledCount > 0) warnings.push(`${scheduledCount} entries have publishAt in the future`);
|
|
597
|
+
if (missingDescriptionCount > 0) {
|
|
598
|
+
errors.push(`${missingDescriptionCount} entries have no description`);
|
|
599
|
+
} else {
|
|
600
|
+
checks.push("All entries have descriptions");
|
|
601
|
+
}
|
|
602
|
+
if (hasDependencyCycle(entries)) {
|
|
603
|
+
errors.push("Circular dependsOn relationship detected");
|
|
604
|
+
} else {
|
|
605
|
+
checks.push("No circular dependencies in dependsOn chains");
|
|
606
|
+
}
|
|
607
|
+
return { checks, warnings, errors };
|
|
608
|
+
}
|
|
609
|
+
function isRecord2(value) {
|
|
610
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
611
|
+
}
|
|
612
|
+
async function pathExists(path) {
|
|
613
|
+
const result = await promises.stat(path).catch(() => null);
|
|
614
|
+
return !!result;
|
|
615
|
+
}
|
|
616
|
+
function ensureIsoDate(value, field) {
|
|
617
|
+
const parsed = new Date(value).getTime();
|
|
618
|
+
if (!Number.isFinite(parsed)) {
|
|
619
|
+
throw new Error(`"${field}" must be a valid ISO date string`);
|
|
620
|
+
}
|
|
621
|
+
return new Date(parsed).toISOString();
|
|
622
|
+
}
|
|
623
|
+
function withDays(date, days) {
|
|
624
|
+
return new Date(date.getTime() + days * 24 * 60 * 60 * 1e3).toISOString();
|
|
625
|
+
}
|
|
626
|
+
function toSlug(value) {
|
|
627
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-");
|
|
628
|
+
}
|
|
629
|
+
function getPathDate(dateIso) {
|
|
630
|
+
return dateIso.slice(0, 10);
|
|
631
|
+
}
|
|
632
|
+
function getFrontmatterValue(value) {
|
|
633
|
+
return value.replace(/\n/g, " ").trim();
|
|
634
|
+
}
|
|
635
|
+
function slugifyFeatureId(label) {
|
|
636
|
+
const slug = toSlug(label);
|
|
637
|
+
return slug || "feature";
|
|
638
|
+
}
|
|
639
|
+
function createFeatureEntry(options, now = /* @__PURE__ */ new Date()) {
|
|
640
|
+
const releasedAt = options.releasedAt ? ensureIsoDate(options.releasedAt, "releasedAt") : now.toISOString();
|
|
641
|
+
const showNewUntil = options.showNewUntil ? ensureIsoDate(options.showNewUntil, "showNewUntil") : withDays(new Date(releasedAt), options.showDays ?? 14);
|
|
642
|
+
const id = options.id ? slugifyFeatureId(options.id) : slugifyFeatureId(options.label);
|
|
643
|
+
const entry = {
|
|
644
|
+
id,
|
|
645
|
+
label: options.label.trim(),
|
|
646
|
+
releasedAt,
|
|
647
|
+
showNewUntil,
|
|
648
|
+
type: options.type ?? "feature"
|
|
649
|
+
};
|
|
650
|
+
if (options.description?.trim()) entry.description = options.description.trim();
|
|
651
|
+
if (options.category?.trim()) entry.category = options.category.trim();
|
|
652
|
+
if (options.url?.trim()) entry.url = options.url.trim();
|
|
653
|
+
return entry;
|
|
654
|
+
}
|
|
655
|
+
function renderFeatureMarkdown(entry) {
|
|
656
|
+
const lines = [
|
|
657
|
+
"---",
|
|
658
|
+
`id: ${getFrontmatterValue(entry.id)}`,
|
|
659
|
+
`label: ${getFrontmatterValue(entry.label)}`,
|
|
660
|
+
`releasedAt: ${entry.releasedAt}`,
|
|
661
|
+
`showNewUntil: ${entry.showNewUntil}`
|
|
662
|
+
];
|
|
663
|
+
if (entry.type) lines.push(`type: ${entry.type}`);
|
|
664
|
+
if (entry.category) lines.push(`category: ${getFrontmatterValue(entry.category)}`);
|
|
665
|
+
if (entry.url) lines.push(`url: ${getFrontmatterValue(entry.url)}`);
|
|
666
|
+
lines.push("---", "");
|
|
667
|
+
if (entry.description) {
|
|
668
|
+
lines.push(entry.description.trim(), "");
|
|
669
|
+
} else {
|
|
670
|
+
lines.push("Describe the feature here.", "");
|
|
671
|
+
}
|
|
672
|
+
return `${lines.join("\n")}`;
|
|
673
|
+
}
|
|
674
|
+
function getNextAvailablePath(existingNames, baseName) {
|
|
675
|
+
if (!existingNames.has(baseName)) return baseName;
|
|
676
|
+
const ext = baseName.endsWith(".md") ? ".md" : "";
|
|
677
|
+
const withoutExt = ext ? baseName.slice(0, -ext.length) : baseName;
|
|
678
|
+
let index = 2;
|
|
679
|
+
while (existingNames.has(`${withoutExt}-${index}${ext}`)) {
|
|
680
|
+
index += 1;
|
|
681
|
+
}
|
|
682
|
+
return `${withoutExt}-${index}${ext}`;
|
|
683
|
+
}
|
|
684
|
+
async function detectProjectFormat(cwd, explicit) {
|
|
685
|
+
if (explicit) return explicit;
|
|
686
|
+
if (await pathExists(path.join(cwd, "features"))) return "markdown";
|
|
687
|
+
if (await pathExists(path.join(cwd, "features.json"))) return "json";
|
|
688
|
+
return "markdown";
|
|
689
|
+
}
|
|
690
|
+
async function initFeaturedropProject(options = {}) {
|
|
691
|
+
const cwd = options.cwd ?? process.cwd();
|
|
692
|
+
const format = options.format ?? "markdown";
|
|
693
|
+
const now = options.now ?? /* @__PURE__ */ new Date();
|
|
694
|
+
const force = options.force ?? false;
|
|
695
|
+
const created = [];
|
|
696
|
+
if (format === "markdown") {
|
|
697
|
+
const featuresDir = path.join(cwd, "features");
|
|
698
|
+
if (await pathExists(featuresDir)) {
|
|
699
|
+
const existing = await promises.readdir(featuresDir).catch(() => []);
|
|
700
|
+
if (existing.length > 0 && !force) {
|
|
701
|
+
throw new Error("features/ already exists and is not empty (use --force to overwrite sample files)");
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
await promises.mkdir(featuresDir, { recursive: true });
|
|
705
|
+
const sample2 = createFeatureEntry(
|
|
706
|
+
{
|
|
707
|
+
id: "welcome-featuredrop",
|
|
708
|
+
label: "Welcome to featuredrop",
|
|
709
|
+
description: "Update this file with your first product announcement.",
|
|
710
|
+
category: "onboarding",
|
|
711
|
+
type: "feature",
|
|
712
|
+
releasedAt: now.toISOString(),
|
|
713
|
+
showDays: 30
|
|
714
|
+
},
|
|
715
|
+
now
|
|
716
|
+
);
|
|
717
|
+
const sampleName = `${getPathDate(sample2.releasedAt)}-${sample2.id}.md`;
|
|
718
|
+
const samplePath = path.join(featuresDir, sampleName);
|
|
719
|
+
await promises.writeFile(samplePath, renderFeatureMarkdown(sample2), "utf8");
|
|
720
|
+
created.push("features/");
|
|
721
|
+
created.push(`features/${sampleName}`);
|
|
722
|
+
return { format, created };
|
|
723
|
+
}
|
|
724
|
+
const manifestPath = path.join(cwd, "features.json");
|
|
725
|
+
if (await pathExists(manifestPath)) {
|
|
726
|
+
if (!force) {
|
|
727
|
+
throw new Error("features.json already exists (use --force to overwrite)");
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
const sample = createFeatureEntry(
|
|
731
|
+
{
|
|
732
|
+
id: "welcome-featuredrop",
|
|
733
|
+
label: "Welcome to featuredrop",
|
|
734
|
+
description: "Replace this sample entry with your own release notes.",
|
|
735
|
+
category: "onboarding",
|
|
736
|
+
type: "feature",
|
|
737
|
+
releasedAt: now.toISOString(),
|
|
738
|
+
showDays: 30
|
|
739
|
+
},
|
|
740
|
+
now
|
|
741
|
+
);
|
|
742
|
+
await promises.writeFile(manifestPath, `${JSON.stringify([sample], null, 2)}
|
|
743
|
+
`, "utf8");
|
|
744
|
+
created.push("features.json");
|
|
745
|
+
return { format, created };
|
|
746
|
+
}
|
|
747
|
+
async function ensureUniqueIdForMarkdown(cwd, id) {
|
|
748
|
+
const featuresDir = path.join(cwd, "features");
|
|
749
|
+
if (!await pathExists(featuresDir)) return;
|
|
750
|
+
const entries = await buildManifestFromPattern({ cwd, pattern: "features/**/*.md" }).catch(() => []);
|
|
751
|
+
if (entries.some((entry) => entry.id === id)) {
|
|
752
|
+
throw new Error(`Feature id "${id}" already exists`);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
async function addFeatureToMarkdown(cwd, entry) {
|
|
756
|
+
await promises.mkdir(path.join(cwd, "features"), { recursive: true });
|
|
757
|
+
await ensureUniqueIdForMarkdown(cwd, entry.id);
|
|
758
|
+
const existingFiles = new Set(
|
|
759
|
+
(await promises.readdir(path.join(cwd, "features")).catch(() => [])).filter((name) => name.endsWith(".md"))
|
|
760
|
+
);
|
|
761
|
+
const baseName = `${getPathDate(entry.releasedAt)}-${entry.id}.md`;
|
|
762
|
+
const fileName = getNextAvailablePath(existingFiles, baseName);
|
|
763
|
+
const relPath = `features/${fileName}`;
|
|
764
|
+
await promises.writeFile(path.join(cwd, relPath), renderFeatureMarkdown(entry), "utf8");
|
|
765
|
+
return relPath;
|
|
766
|
+
}
|
|
767
|
+
async function addFeatureToJson(cwd, entry) {
|
|
768
|
+
const manifestPath = path.join(cwd, "features.json");
|
|
769
|
+
const raw = await promises.readFile(manifestPath, "utf8").catch(() => "[]");
|
|
770
|
+
let parsed;
|
|
771
|
+
try {
|
|
772
|
+
parsed = JSON.parse(raw);
|
|
773
|
+
} catch {
|
|
774
|
+
throw new Error("features.json is not valid JSON");
|
|
775
|
+
}
|
|
776
|
+
if (!Array.isArray(parsed)) {
|
|
777
|
+
throw new Error("features.json must contain an array of feature entries");
|
|
778
|
+
}
|
|
779
|
+
const existing = parsed;
|
|
780
|
+
if (existing.some((item) => item.id === entry.id)) {
|
|
781
|
+
throw new Error(`Feature id "${entry.id}" already exists`);
|
|
782
|
+
}
|
|
783
|
+
const next = [...existing, entry];
|
|
784
|
+
const validation = validateManifest(next);
|
|
785
|
+
if (!validation.valid) {
|
|
786
|
+
throw new Error(`features.json validation failed: ${validation.errors[0]?.message ?? "unknown error"}`);
|
|
787
|
+
}
|
|
788
|
+
await promises.writeFile(manifestPath, `${JSON.stringify(next, null, 2)}
|
|
789
|
+
`, "utf8");
|
|
790
|
+
return "features.json";
|
|
791
|
+
}
|
|
792
|
+
async function addFeatureEntry(options) {
|
|
793
|
+
const cwd = options.cwd ?? process.cwd();
|
|
794
|
+
const format = await detectProjectFormat(cwd, options.format);
|
|
795
|
+
const entry = createFeatureEntry(options);
|
|
796
|
+
const path = format === "markdown" ? await addFeatureToMarkdown(cwd, entry) : await addFeatureToJson(cwd, entry);
|
|
797
|
+
return { format, path, entry };
|
|
798
|
+
}
|
|
799
|
+
function pickString(obj, keys) {
|
|
800
|
+
for (const key of keys) {
|
|
801
|
+
const value = obj[key];
|
|
802
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
803
|
+
}
|
|
804
|
+
return void 0;
|
|
805
|
+
}
|
|
806
|
+
function getMigrationItems(payload) {
|
|
807
|
+
if (Array.isArray(payload)) return payload;
|
|
808
|
+
if (!isRecord2(payload)) throw new Error("Migration payload must be an array or object");
|
|
809
|
+
for (const key of ["posts", "items", "announcements", "entries"]) {
|
|
810
|
+
const value = payload[key];
|
|
811
|
+
if (Array.isArray(value)) return value;
|
|
812
|
+
}
|
|
813
|
+
throw new Error("Could not find entries array in migration payload");
|
|
814
|
+
}
|
|
815
|
+
function normalizeDate(raw, fallback) {
|
|
816
|
+
if (!raw) return fallback.toISOString();
|
|
817
|
+
const parsed = new Date(raw).getTime();
|
|
818
|
+
if (!Number.isFinite(parsed)) return fallback.toISOString();
|
|
819
|
+
return new Date(parsed).toISOString();
|
|
820
|
+
}
|
|
821
|
+
function buildFallbackId(prefix, index) {
|
|
822
|
+
return `${prefix}-entry-${index + 1}`;
|
|
823
|
+
}
|
|
824
|
+
var MIGRATION_PROFILES = {
|
|
825
|
+
beamer: {
|
|
826
|
+
fallbackPrefix: "beamer",
|
|
827
|
+
labelKeys: ["title", "name", "headline"],
|
|
828
|
+
idKeys: ["id", "uid", "slug", "postId", "post_id"],
|
|
829
|
+
dateKeys: ["publishedAt", "published_at", "published", "createdAt", "created_at", "date"],
|
|
830
|
+
categoryKeys: ["category", "type", "segment"],
|
|
831
|
+
urlKeys: ["url", "link", "permalink"],
|
|
832
|
+
descriptionKeys: ["description", "content", "body", "html"],
|
|
833
|
+
showUntilKeys: ["showNewUntil", "show_new_until", "newUntil", "new_until"]
|
|
834
|
+
},
|
|
835
|
+
headway: {
|
|
836
|
+
fallbackPrefix: "headway",
|
|
837
|
+
labelKeys: ["title", "name", "headline"],
|
|
838
|
+
idKeys: ["id", "slug", "entryId", "entry_id"],
|
|
839
|
+
dateKeys: ["publishedAt", "published_at", "createdAt", "created_at", "date"],
|
|
840
|
+
categoryKeys: ["category", "tag", "tags"],
|
|
841
|
+
urlKeys: ["url", "link", "permalink"],
|
|
842
|
+
descriptionKeys: ["description", "summary", "content", "body"],
|
|
843
|
+
showUntilKeys: ["showNewUntil", "new_until"]
|
|
844
|
+
},
|
|
845
|
+
announcekit: {
|
|
846
|
+
fallbackPrefix: "announcekit",
|
|
847
|
+
labelKeys: ["title", "subject", "name"],
|
|
848
|
+
idKeys: ["id", "post_id", "postId", "slug"],
|
|
849
|
+
dateKeys: ["published_at", "publishedAt", "created_at", "createdAt", "date"],
|
|
850
|
+
categoryKeys: ["category", "segment", "tab", "label"],
|
|
851
|
+
urlKeys: ["url", "link", "permalink"],
|
|
852
|
+
descriptionKeys: ["description", "content", "html", "body"],
|
|
853
|
+
showUntilKeys: ["show_new_until", "showNewUntil", "new_until"]
|
|
854
|
+
},
|
|
855
|
+
canny: {
|
|
856
|
+
fallbackPrefix: "canny",
|
|
857
|
+
labelKeys: ["title", "name"],
|
|
858
|
+
idKeys: ["id", "postId", "post_id", "slug"],
|
|
859
|
+
dateKeys: ["createdAt", "created_at", "publishedAt", "published_at", "date"],
|
|
860
|
+
categoryKeys: ["category", "type", "boardName"],
|
|
861
|
+
urlKeys: ["url", "link", "permalink"],
|
|
862
|
+
descriptionKeys: ["details", "description", "content", "body"],
|
|
863
|
+
showUntilKeys: ["showNewUntil", "new_until"]
|
|
864
|
+
},
|
|
865
|
+
launchnotes: {
|
|
866
|
+
fallbackPrefix: "launchnotes",
|
|
867
|
+
labelKeys: ["title", "headline", "name"],
|
|
868
|
+
idKeys: ["id", "slug", "noteId", "note_id"],
|
|
869
|
+
dateKeys: ["publishedAt", "published_at", "createdAt", "created_at", "date"],
|
|
870
|
+
categoryKeys: ["category", "segment", "channel"],
|
|
871
|
+
urlKeys: ["url", "link", "permalink"],
|
|
872
|
+
descriptionKeys: ["description", "body", "content", "summary"],
|
|
873
|
+
showUntilKeys: ["showNewUntil", "new_until"]
|
|
874
|
+
}
|
|
875
|
+
};
|
|
876
|
+
function migrateFromSourcePayload(source, payload, now = /* @__PURE__ */ new Date()) {
|
|
877
|
+
const profile = MIGRATION_PROFILES[source];
|
|
878
|
+
const items = getMigrationItems(payload);
|
|
879
|
+
const usedIds = /* @__PURE__ */ new Set();
|
|
880
|
+
return items.map((raw, index) => {
|
|
881
|
+
if (!isRecord2(raw)) return null;
|
|
882
|
+
const release = normalizeDate(
|
|
883
|
+
pickString(raw, profile.dateKeys),
|
|
884
|
+
now
|
|
885
|
+
);
|
|
886
|
+
const label = pickString(raw, profile.labelKeys) ?? `Update ${index + 1}`;
|
|
887
|
+
const idSeed = pickString(raw, profile.idKeys) ?? label;
|
|
888
|
+
let id = slugifyFeatureId(idSeed);
|
|
889
|
+
if (!id) id = buildFallbackId(profile.fallbackPrefix, index);
|
|
890
|
+
while (usedIds.has(id)) {
|
|
891
|
+
id = `${id}-${index + 1}`;
|
|
892
|
+
}
|
|
893
|
+
usedIds.add(id);
|
|
894
|
+
const category = pickString(raw, profile.categoryKeys);
|
|
895
|
+
const url = pickString(raw, profile.urlKeys);
|
|
896
|
+
const description = pickString(raw, profile.descriptionKeys);
|
|
897
|
+
const explicitShowUntil = normalizeDate(
|
|
898
|
+
pickString(raw, profile.showUntilKeys),
|
|
899
|
+
new Date(withDays(new Date(release), 30))
|
|
900
|
+
);
|
|
901
|
+
const entry = {
|
|
902
|
+
id,
|
|
903
|
+
label,
|
|
904
|
+
releasedAt: release,
|
|
905
|
+
showNewUntil: explicitShowUntil,
|
|
906
|
+
type: "feature"
|
|
907
|
+
};
|
|
908
|
+
if (description) entry.description = description;
|
|
909
|
+
if (category) entry.category = category;
|
|
910
|
+
if (url) entry.url = url;
|
|
911
|
+
return entry;
|
|
912
|
+
}).filter((value) => !!value);
|
|
913
|
+
}
|
|
914
|
+
async function migrateManifest(options) {
|
|
915
|
+
const cwd = options.cwd ?? process.cwd();
|
|
916
|
+
const outFile = options.outFile ?? "featuredrop.manifest.json";
|
|
917
|
+
const inputPath = path.join(cwd, options.inputFile);
|
|
918
|
+
const raw = await promises.readFile(inputPath, "utf8");
|
|
919
|
+
const payload = JSON.parse(raw);
|
|
920
|
+
const entries = migrateFromSourcePayload(options.from, payload, options.now ?? /* @__PURE__ */ new Date());
|
|
921
|
+
await promises.writeFile(path.join(cwd, outFile), `${JSON.stringify(entries, null, 2)}
|
|
922
|
+
`, "utf8");
|
|
923
|
+
return { outFile: path.basename(outFile), entries };
|
|
924
|
+
}
|
|
925
|
+
var dynamicRequire = module$1.createRequire((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('featuredrop.cjs', document.baseURI).href)));
|
|
926
|
+
var cachedMarked = null;
|
|
927
|
+
var cachedShiki = null;
|
|
928
|
+
function optionalRequire(name) {
|
|
929
|
+
try {
|
|
930
|
+
return dynamicRequire(name);
|
|
931
|
+
} catch (error) {
|
|
932
|
+
if (error && typeof error === "object" && "code" in error && error.code === "MODULE_NOT_FOUND") {
|
|
933
|
+
return null;
|
|
934
|
+
}
|
|
935
|
+
return null;
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
function getMarked() {
|
|
939
|
+
if (cachedMarked !== null) return cachedMarked || null;
|
|
940
|
+
cachedMarked = optionalRequire("marked") ?? false;
|
|
941
|
+
return cachedMarked || null;
|
|
942
|
+
}
|
|
943
|
+
function getShiki() {
|
|
944
|
+
if (cachedShiki !== null) return cachedShiki || null;
|
|
945
|
+
cachedShiki = optionalRequire("shiki") ?? false;
|
|
946
|
+
return cachedShiki || null;
|
|
947
|
+
}
|
|
948
|
+
function escapeHtml(value) {
|
|
949
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
950
|
+
}
|
|
951
|
+
function sanitizeUrl(url) {
|
|
952
|
+
if (!url) return null;
|
|
953
|
+
const trimmed = url.trim();
|
|
954
|
+
if (!trimmed) return null;
|
|
955
|
+
const lower = trimmed.toLowerCase();
|
|
956
|
+
if (lower.startsWith("javascript:")) return null;
|
|
957
|
+
if (lower.startsWith("data:")) return null;
|
|
958
|
+
if (lower.startsWith("vbscript:")) return null;
|
|
959
|
+
if (/['"<>\s]/.test(trimmed)) return null;
|
|
960
|
+
return trimmed;
|
|
961
|
+
}
|
|
962
|
+
function sanitizeHtml(html) {
|
|
963
|
+
return html.replace(/<script[\s\S]*?>[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?>[\s\S]*?<\/style>/gi, "").replace(/\s+on[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, "").replace(/\s+(?:href|src|xlink:href)\s*=\s*("|')(?:javascript:|data:)[^"']*\1/gi, "");
|
|
964
|
+
}
|
|
965
|
+
function decodeAllowedEntities(html) {
|
|
966
|
+
const allowTags = [
|
|
967
|
+
"p",
|
|
968
|
+
"strong",
|
|
969
|
+
"em",
|
|
970
|
+
"a",
|
|
971
|
+
"code",
|
|
972
|
+
"pre",
|
|
973
|
+
"img",
|
|
974
|
+
"ul",
|
|
975
|
+
"ol",
|
|
976
|
+
"li",
|
|
977
|
+
"blockquote",
|
|
978
|
+
"h1",
|
|
979
|
+
"h2",
|
|
980
|
+
"h3",
|
|
981
|
+
"h4",
|
|
982
|
+
"h5",
|
|
983
|
+
"h6",
|
|
984
|
+
"br"
|
|
985
|
+
];
|
|
986
|
+
return html.replace(/<(\/?)([a-z0-9]+)([^>]*)>/gi, (match, slash, tag, rest) => {
|
|
987
|
+
if (!allowTags.includes(tag.toLowerCase())) return match;
|
|
988
|
+
const decodedRest = rest.replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
989
|
+
return `<${slash}${tag}${decodedRest}>`;
|
|
990
|
+
});
|
|
991
|
+
}
|
|
992
|
+
function renderCodeBlock(code, language) {
|
|
993
|
+
const shiki = getShiki();
|
|
994
|
+
if (shiki?.codeToHtml) {
|
|
995
|
+
try {
|
|
996
|
+
const rendered = shiki.codeToHtml(code, { lang: language || "text", theme: "github-dark" });
|
|
997
|
+
if (typeof rendered === "string") return rendered;
|
|
998
|
+
} catch {
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
const langAttr = language ? ` class="language-${escapeHtml(language)}"` : "";
|
|
1002
|
+
return `<pre><code${langAttr}>${escapeHtml(code)}</code></pre>`;
|
|
1003
|
+
}
|
|
1004
|
+
function inlineMarkdown(text) {
|
|
1005
|
+
let result = escapeHtml(text);
|
|
1006
|
+
const codeSpans = [];
|
|
1007
|
+
result = result.replace(/`([^`]+)`/g, (_match, code) => {
|
|
1008
|
+
const idx = codeSpans.length;
|
|
1009
|
+
codeSpans.push(`<code>${escapeHtml(code)}</code>`);
|
|
1010
|
+
return `\xA7\xA7CODE${idx}\xA7\xA7`;
|
|
1011
|
+
});
|
|
1012
|
+
result = result.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_match, alt, url) => {
|
|
1013
|
+
const safeUrl = sanitizeUrl(url);
|
|
1014
|
+
const safeAlt = escapeHtml(alt ?? "");
|
|
1015
|
+
if (!safeUrl) return safeAlt;
|
|
1016
|
+
return `<img src="${escapeHtml(safeUrl)}" alt="${safeAlt}" />`;
|
|
1017
|
+
});
|
|
1018
|
+
result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, label, url) => {
|
|
1019
|
+
const safeUrl = sanitizeUrl(url);
|
|
1020
|
+
const safeLabel = escapeHtml(label ?? "");
|
|
1021
|
+
if (!safeUrl) return safeLabel;
|
|
1022
|
+
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer">${safeLabel}</a>`;
|
|
1023
|
+
});
|
|
1024
|
+
result = result.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
1025
|
+
result = result.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
|
1026
|
+
result = result.replace(/§§CODE(\d+)§§/g, (_m, idx) => codeSpans[Number(idx)] ?? "");
|
|
1027
|
+
return result;
|
|
1028
|
+
}
|
|
1029
|
+
function fallbackParse(markdown) {
|
|
1030
|
+
const lines = markdown.split(/\r?\n/);
|
|
1031
|
+
const blocks = [];
|
|
1032
|
+
let listBuffer = null;
|
|
1033
|
+
let quoteBuffer = null;
|
|
1034
|
+
let inCodeBlock = false;
|
|
1035
|
+
let codeLang;
|
|
1036
|
+
let codeLines = [];
|
|
1037
|
+
const flushList = () => {
|
|
1038
|
+
if (!listBuffer) return;
|
|
1039
|
+
blocks.push(`<ul>${listBuffer.map((item) => `<li>${item}</li>`).join("")}</ul>`);
|
|
1040
|
+
listBuffer = null;
|
|
1041
|
+
};
|
|
1042
|
+
const flushQuote = () => {
|
|
1043
|
+
if (!quoteBuffer) return;
|
|
1044
|
+
const content = quoteBuffer.map((line) => inlineMarkdown(line.trim())).join("<br>");
|
|
1045
|
+
blocks.push(`<blockquote>${content}</blockquote>`);
|
|
1046
|
+
quoteBuffer = null;
|
|
1047
|
+
};
|
|
1048
|
+
const flushCode = () => {
|
|
1049
|
+
if (!inCodeBlock) return;
|
|
1050
|
+
blocks.push(renderCodeBlock(codeLines.join("\n"), codeLang));
|
|
1051
|
+
codeLines = [];
|
|
1052
|
+
codeLang = void 0;
|
|
1053
|
+
inCodeBlock = false;
|
|
1054
|
+
};
|
|
1055
|
+
for (const rawLine of lines) {
|
|
1056
|
+
const line = rawLine.replace(/\s+$/, "");
|
|
1057
|
+
const codeFence = line.match(/^```(.*)$/);
|
|
1058
|
+
if (codeFence) {
|
|
1059
|
+
if (inCodeBlock) {
|
|
1060
|
+
flushCode();
|
|
1061
|
+
} else {
|
|
1062
|
+
flushList();
|
|
1063
|
+
flushQuote();
|
|
1064
|
+
inCodeBlock = true;
|
|
1065
|
+
codeLang = codeFence[1]?.trim() || void 0;
|
|
1066
|
+
codeLines = [];
|
|
1067
|
+
}
|
|
1068
|
+
continue;
|
|
1069
|
+
}
|
|
1070
|
+
if (inCodeBlock) {
|
|
1071
|
+
codeLines.push(rawLine);
|
|
1072
|
+
continue;
|
|
1073
|
+
}
|
|
1074
|
+
const listMatch = line.match(/^\s*[-*+]\s+(.*)$/);
|
|
1075
|
+
if (listMatch) {
|
|
1076
|
+
flushQuote();
|
|
1077
|
+
listBuffer = listBuffer ?? [];
|
|
1078
|
+
listBuffer.push(inlineMarkdown(listMatch[1].trim()));
|
|
1079
|
+
continue;
|
|
1080
|
+
}
|
|
1081
|
+
if (listBuffer) flushList();
|
|
1082
|
+
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
|
|
1083
|
+
if (headingMatch) {
|
|
1084
|
+
flushQuote();
|
|
1085
|
+
const level = headingMatch[1].length;
|
|
1086
|
+
const content = inlineMarkdown(headingMatch[2].trim());
|
|
1087
|
+
blocks.push(`<h${level}>${content}</h${level}>`);
|
|
1088
|
+
continue;
|
|
1089
|
+
}
|
|
1090
|
+
const quoteMatch = line.match(/^>\s?(.*)$/);
|
|
1091
|
+
if (quoteMatch) {
|
|
1092
|
+
quoteBuffer = quoteBuffer ?? [];
|
|
1093
|
+
quoteBuffer.push(quoteMatch[1]);
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1096
|
+
if (quoteBuffer) flushQuote();
|
|
1097
|
+
if (!line.trim()) {
|
|
1098
|
+
continue;
|
|
1099
|
+
}
|
|
1100
|
+
blocks.push(`<p>${inlineMarkdown(line.trim())}</p>`);
|
|
1101
|
+
}
|
|
1102
|
+
flushList();
|
|
1103
|
+
flushQuote();
|
|
1104
|
+
flushCode();
|
|
1105
|
+
return blocks.join("\n");
|
|
1106
|
+
}
|
|
1107
|
+
function renderWithMarked(markdown, marked) {
|
|
1108
|
+
if (!marked.parse) return null;
|
|
1109
|
+
const renderer = marked.Renderer ? new marked.Renderer() : void 0;
|
|
1110
|
+
if (renderer) {
|
|
1111
|
+
renderer.link = (href, _title, text) => {
|
|
1112
|
+
const safeUrl = sanitizeUrl(href);
|
|
1113
|
+
if (!safeUrl) return escapeHtml(text);
|
|
1114
|
+
return `<a href="${escapeHtml(safeUrl)}" target="_blank" rel="noopener noreferrer">${text}</a>`;
|
|
1115
|
+
};
|
|
1116
|
+
renderer.image = (href, _title, text) => {
|
|
1117
|
+
const safeUrl = sanitizeUrl(href);
|
|
1118
|
+
const safeAlt = escapeHtml(text ?? "");
|
|
1119
|
+
if (!safeUrl) return safeAlt;
|
|
1120
|
+
return `<img src="${escapeHtml(safeUrl)}" alt="${safeAlt}" />`;
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
const output = marked.parse(markdown, renderer ? { renderer } : void 0);
|
|
1124
|
+
if (typeof output === "string") return output;
|
|
1125
|
+
return output ? String(output) : null;
|
|
1126
|
+
}
|
|
1127
|
+
function parseDescription(markdown) {
|
|
1128
|
+
if (!markdown) return "";
|
|
1129
|
+
const marked = getMarked();
|
|
1130
|
+
if (marked) {
|
|
1131
|
+
try {
|
|
1132
|
+
const rendered = renderWithMarked(markdown, marked);
|
|
1133
|
+
if (rendered) {
|
|
1134
|
+
const sanitized2 = sanitizeHtml(rendered);
|
|
1135
|
+
const decoded2 = decodeAllowedEntities(sanitized2);
|
|
1136
|
+
return sanitizeHtml(decoded2);
|
|
1137
|
+
}
|
|
1138
|
+
} catch {
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
if (/<[^>]+>/.test(markdown)) {
|
|
1142
|
+
const sanitized2 = sanitizeHtml(markdown);
|
|
1143
|
+
const decoded2 = decodeAllowedEntities(sanitized2);
|
|
1144
|
+
return sanitizeHtml(decoded2);
|
|
1145
|
+
}
|
|
1146
|
+
const fallback = fallbackParse(markdown);
|
|
1147
|
+
const sanitized = sanitizeHtml(fallback);
|
|
1148
|
+
const decoded = decodeAllowedEntities(sanitized);
|
|
1149
|
+
return sanitizeHtml(decoded);
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
// src/rss.ts
|
|
1153
|
+
function escape(str) {
|
|
1154
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
1155
|
+
}
|
|
1156
|
+
function generateRSS(manifest, options) {
|
|
1157
|
+
const title = escape(options?.title ?? "Featuredrop Changelog");
|
|
1158
|
+
const link = escape(options?.link ?? "");
|
|
1159
|
+
const desc = escape(options?.description ?? "Product updates");
|
|
1160
|
+
const items = manifest.slice().sort((a, b) => new Date(b.releasedAt).getTime() - new Date(a.releasedAt).getTime()).map((item) => {
|
|
1161
|
+
const descriptionHtml = item.description ? parseDescription(item.description) : "";
|
|
1162
|
+
const itemLink = item.url ? escape(item.url) : "";
|
|
1163
|
+
return [
|
|
1164
|
+
"<item>",
|
|
1165
|
+
`<title>${escape(item.label)}</title>`,
|
|
1166
|
+
itemLink ? `<link>${itemLink}</link>` : "",
|
|
1167
|
+
`<guid isPermaLink="false">${escape(item.id)}</guid>`,
|
|
1168
|
+
`<pubDate>${new Date(item.releasedAt).toUTCString()}</pubDate>`,
|
|
1169
|
+
`<description><![CDATA[${descriptionHtml}]]></description>`,
|
|
1170
|
+
"</item>"
|
|
1171
|
+
].join("");
|
|
1172
|
+
}).join("");
|
|
1173
|
+
return [
|
|
1174
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
1175
|
+
'<rss version="2.0">',
|
|
1176
|
+
"<channel>",
|
|
1177
|
+
`<title>${title}</title>`,
|
|
1178
|
+
link ? `<link>${link}</link>` : "",
|
|
1179
|
+
`<description>${desc}</description>`,
|
|
1180
|
+
items,
|
|
1181
|
+
"</channel>",
|
|
1182
|
+
"</rss>"
|
|
1183
|
+
].join("");
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
// src/cli.ts
|
|
1187
|
+
function parseArgs(argv) {
|
|
1188
|
+
const [commandRaw, ...rest] = argv;
|
|
1189
|
+
const allowed = /* @__PURE__ */ new Set([
|
|
1190
|
+
"init",
|
|
1191
|
+
"add",
|
|
1192
|
+
"migrate",
|
|
1193
|
+
"build",
|
|
1194
|
+
"validate",
|
|
1195
|
+
"stats",
|
|
1196
|
+
"doctor",
|
|
1197
|
+
"generate-rss",
|
|
1198
|
+
"generate-changelog"
|
|
1199
|
+
]);
|
|
1200
|
+
const command = allowed.has(commandRaw) ? commandRaw : "help";
|
|
1201
|
+
const parsed = { command };
|
|
1202
|
+
for (let i = 0; i < rest.length; i++) {
|
|
1203
|
+
const arg = rest[i];
|
|
1204
|
+
if (arg === "--pattern") parsed.pattern = rest[++i];
|
|
1205
|
+
else if (arg === "--out") parsed.outFile = rest[++i];
|
|
1206
|
+
else if (arg === "--cwd") parsed.cwd = rest[++i];
|
|
1207
|
+
else if (arg === "--title") parsed.title = rest[++i];
|
|
1208
|
+
else if (arg === "--link") parsed.link = rest[++i];
|
|
1209
|
+
else if (arg === "--description") parsed.description = rest[++i];
|
|
1210
|
+
else if (arg === "--input") parsed.inputFile = rest[++i];
|
|
1211
|
+
else if (arg === "--from") parsed.from = rest[++i];
|
|
1212
|
+
else if (arg === "--format") parsed.format = rest[++i];
|
|
1213
|
+
else if (arg === "--force") parsed.force = true;
|
|
1214
|
+
else if (arg === "--id") parsed.id = rest[++i];
|
|
1215
|
+
else if (arg === "--label") parsed.label = rest[++i];
|
|
1216
|
+
else if (arg === "--type") parsed.type = rest[++i];
|
|
1217
|
+
else if (arg === "--category") parsed.category = rest[++i];
|
|
1218
|
+
else if (arg === "--url") parsed.url = rest[++i];
|
|
1219
|
+
else if (arg === "--releasedAt") parsed.releasedAt = rest[++i];
|
|
1220
|
+
else if (arg === "--showNewUntil") parsed.showNewUntil = rest[++i];
|
|
1221
|
+
else if (arg === "--show-days") parsed.showDays = Number(rest[++i]);
|
|
1222
|
+
}
|
|
1223
|
+
return parsed;
|
|
1224
|
+
}
|
|
1225
|
+
function printHelp() {
|
|
1226
|
+
console.log("featuredrop CLI");
|
|
1227
|
+
console.log("");
|
|
1228
|
+
console.log("Usage:");
|
|
1229
|
+
console.log(" featuredrop init [--format markdown|json] [--force] [--cwd .]");
|
|
1230
|
+
console.log(" featuredrop add [--label ...] [--id ...] [--description ...] [--type feature|improvement|fix|breaking] [--category ...] [--url ...] [--releasedAt ...] [--showNewUntil ...] [--show-days 14] [--format markdown|json] [--cwd .]");
|
|
1231
|
+
console.log(" featuredrop migrate --from beamer|headway|announcekit|canny|launchnotes [--input export.json] [--out featuredrop.manifest.json] [--cwd .]");
|
|
1232
|
+
console.log(" featuredrop build [--pattern features/**/*.md] [--out featuredrop.manifest.json] [--cwd .]");
|
|
1233
|
+
console.log(" featuredrop validate [--pattern features/**/*.md] [--cwd .]");
|
|
1234
|
+
console.log(" featuredrop stats [--pattern features/**/*.md] [--cwd .]");
|
|
1235
|
+
console.log(" featuredrop doctor [--pattern features/**/*.md] [--cwd .]");
|
|
1236
|
+
console.log(" featuredrop generate-rss [--pattern features/**/*.md] [--out featuredrop.rss.xml] [--title ...] [--link ...] [--description ...] [--cwd .]");
|
|
1237
|
+
console.log(" featuredrop generate-changelog [--pattern features/**/*.md] [--out CHANGELOG.generated.md] [--cwd .]");
|
|
1238
|
+
}
|
|
1239
|
+
async function promptForLabelIfNeeded(label) {
|
|
1240
|
+
if (label?.trim()) return label.trim();
|
|
1241
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
1242
|
+
throw new Error("Missing --label. Provide --label in non-interactive mode.");
|
|
1243
|
+
}
|
|
1244
|
+
const rl = promises$1.createInterface({
|
|
1245
|
+
input: process.stdin,
|
|
1246
|
+
output: process.stdout
|
|
1247
|
+
});
|
|
1248
|
+
try {
|
|
1249
|
+
const value = (await rl.question("Feature label: ")).trim();
|
|
1250
|
+
if (!value) throw new Error("Feature label is required");
|
|
1251
|
+
return value;
|
|
1252
|
+
} finally {
|
|
1253
|
+
rl.close();
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
async function run() {
|
|
1257
|
+
const args = parseArgs(process.argv.slice(2));
|
|
1258
|
+
if (args.command === "help") {
|
|
1259
|
+
printHelp();
|
|
1260
|
+
process.exitCode = 1;
|
|
1261
|
+
return;
|
|
1262
|
+
}
|
|
1263
|
+
try {
|
|
1264
|
+
if (args.command === "init") {
|
|
1265
|
+
const result = await initFeaturedropProject({
|
|
1266
|
+
cwd: args.cwd,
|
|
1267
|
+
format: args.format,
|
|
1268
|
+
force: args.force
|
|
1269
|
+
});
|
|
1270
|
+
console.log(`Initialized featuredrop project (${result.format})`);
|
|
1271
|
+
for (const path of result.created) {
|
|
1272
|
+
console.log(`- ${path}`);
|
|
1273
|
+
}
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
if (args.command === "add") {
|
|
1277
|
+
const label = await promptForLabelIfNeeded(args.label);
|
|
1278
|
+
const result = await addFeatureEntry({
|
|
1279
|
+
cwd: args.cwd,
|
|
1280
|
+
format: args.format,
|
|
1281
|
+
id: args.id,
|
|
1282
|
+
label,
|
|
1283
|
+
description: args.description,
|
|
1284
|
+
type: args.type,
|
|
1285
|
+
category: args.category,
|
|
1286
|
+
url: args.url,
|
|
1287
|
+
releasedAt: args.releasedAt,
|
|
1288
|
+
showNewUntil: args.showNewUntil,
|
|
1289
|
+
showDays: args.showDays
|
|
1290
|
+
});
|
|
1291
|
+
console.log(`Added feature "${result.entry.id}" -> ${result.path}`);
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
if (args.command === "migrate") {
|
|
1295
|
+
const from = args.from;
|
|
1296
|
+
if (!from) {
|
|
1297
|
+
throw new Error('Missing required "--from" (beamer|headway|announcekit|canny|launchnotes)');
|
|
1298
|
+
}
|
|
1299
|
+
const inputFile = args.inputFile ?? `${from}-export.json`;
|
|
1300
|
+
const result = await migrateManifest({
|
|
1301
|
+
cwd: args.cwd,
|
|
1302
|
+
from,
|
|
1303
|
+
inputFile,
|
|
1304
|
+
outFile: args.outFile
|
|
1305
|
+
});
|
|
1306
|
+
console.log(`Migrated ${result.entries.length} entries from ${from} -> ${result.outFile}`);
|
|
1307
|
+
return;
|
|
1308
|
+
}
|
|
1309
|
+
if (args.command === "build") {
|
|
1310
|
+
const out = args.outFile ?? "featuredrop.manifest.json";
|
|
1311
|
+
const entries2 = await buildManifestFromPattern({
|
|
1312
|
+
pattern: args.pattern,
|
|
1313
|
+
outFile: out,
|
|
1314
|
+
cwd: args.cwd
|
|
1315
|
+
});
|
|
1316
|
+
console.log(`Built ${entries2.length} feature entries -> ${out}`);
|
|
1317
|
+
return;
|
|
1318
|
+
}
|
|
1319
|
+
if (args.command === "validate") {
|
|
1320
|
+
await validateFeatureFiles({
|
|
1321
|
+
pattern: args.pattern,
|
|
1322
|
+
cwd: args.cwd
|
|
1323
|
+
});
|
|
1324
|
+
console.log("Feature files valid");
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
const entries = await buildManifestFromPattern({
|
|
1328
|
+
pattern: args.pattern,
|
|
1329
|
+
cwd: args.cwd
|
|
1330
|
+
});
|
|
1331
|
+
if (args.command === "stats") {
|
|
1332
|
+
const stats = computeManifestStats(entries);
|
|
1333
|
+
console.log(`Total entries: ${stats.total}`);
|
|
1334
|
+
console.log(`By type: ${Object.entries(stats.byType).map(([k, v]) => `${k}=${v}`).join(", ") || "none"}`);
|
|
1335
|
+
console.log(`By category: ${Object.entries(stats.byCategory).map(([k, v]) => `${k}=${v}`).join(", ") || "none"}`);
|
|
1336
|
+
if (stats.newestRelease) console.log(`Newest release: ${stats.newestRelease}`);
|
|
1337
|
+
if (stats.oldestRelease) console.log(`Oldest release: ${stats.oldestRelease}`);
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
if (args.command === "doctor") {
|
|
1341
|
+
const report = runDoctor(entries);
|
|
1342
|
+
for (const check of report.checks) console.log(`\u2713 ${check}`);
|
|
1343
|
+
for (const warning of report.warnings) console.log(`\u26A0 ${warning}`);
|
|
1344
|
+
for (const error of report.errors) console.log(`\u2717 ${error}`);
|
|
1345
|
+
console.log("");
|
|
1346
|
+
console.log(`${report.warnings.length} warning(s), ${report.errors.length} error(s).`);
|
|
1347
|
+
if (report.errors.length > 0) process.exitCode = 1;
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
if (args.command === "generate-rss") {
|
|
1351
|
+
const out = args.outFile ?? "featuredrop.rss.xml";
|
|
1352
|
+
const xml = generateRSS(entries, {
|
|
1353
|
+
title: args.title,
|
|
1354
|
+
link: args.link,
|
|
1355
|
+
description: args.description
|
|
1356
|
+
});
|
|
1357
|
+
await promises.writeFile(path.join(args.cwd ?? process.cwd(), out), `${xml}
|
|
1358
|
+
`, "utf8");
|
|
1359
|
+
console.log(`Generated RSS feed -> ${out}`);
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
if (args.command === "generate-changelog") {
|
|
1363
|
+
const out = args.outFile ?? "CHANGELOG.generated.md";
|
|
1364
|
+
const markdown = generateMarkdownChangelog(entries);
|
|
1365
|
+
await promises.writeFile(path.join(args.cwd ?? process.cwd(), out), markdown, "utf8");
|
|
1366
|
+
console.log(`Generated markdown changelog -> ${out}`);
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
} catch (error) {
|
|
1370
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1371
|
+
console.error(`featuredrop: ${message}`);
|
|
1372
|
+
process.exitCode = 1;
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
void run();
|
|
1376
|
+
//# sourceMappingURL=featuredrop.cjs.map
|
|
1377
|
+
//# sourceMappingURL=featuredrop.cjs.map
|