create-feedbax 0.1.0 → 0.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/LICENSE +201 -21
- package/README.md +11 -1
- package/dist/cli.js +1261 -26
- package/dist/index.js +1402 -160
- package/package.json +40 -14
- package/template/.feedbax-template.sha256 +1 -0
- package/template/.gitignore.template +6 -0
- package/template/.npmignore.template +5 -0
- package/template/.prettierignore.template +4 -0
- package/template/LICENSE +201 -0
- package/template/components.json +25 -0
- package/template/eslint.config.mjs +25 -0
- package/template/package.json +70 -14
- package/template/public/feedbax-mark.svg +4 -0
- package/template/public/robots.txt +3 -0
- package/template/src/authorized-draft-post.test.tsx +212 -0
- package/template/src/authorized-draft-post.tsx +204 -0
- package/template/src/authorized-draft-query.ts +74 -0
- package/template/src/browser-post-state.test.ts +120 -0
- package/template/src/browser-post-state.ts +134 -0
- package/template/src/browser-vote-state.test.ts +14 -0
- package/template/src/browser-vote-state.ts +27 -0
- package/template/src/capability-authorization-error.ts +3 -0
- package/template/src/changelog-page.test.tsx +139 -0
- package/template/src/changelog-page.tsx +219 -0
- package/template/src/changelog-query.ts +13 -0
- package/template/src/changelog-runtime.ts +27 -0
- package/template/src/cloudflare-turnstile.ts +31 -0
- package/template/src/cloudflare-workers-test.ts +3 -0
- package/template/src/cloudflare-workers.d.ts +18 -0
- package/template/src/comment-creation.test.tsx +270 -0
- package/template/src/comment-profile.ts +5 -0
- package/template/src/comment-thread-page.ts +18 -0
- package/template/src/components/command-palette-context.tsx +71 -0
- package/template/src/components/command-palette.tsx +246 -0
- package/template/src/components/create-post-trigger.tsx +44 -0
- package/template/src/components/device-profile-provider.tsx +309 -0
- package/template/src/components/header.tsx +95 -0
- package/template/src/components/loader.tsx +10 -0
- package/template/src/components/profile-menu.tsx +89 -0
- package/template/src/components/ui/components/alert-dialog.tsx +185 -0
- package/template/src/components/ui/components/attachment.tsx +207 -0
- package/template/src/components/ui/components/avatar.tsx +107 -0
- package/template/src/components/ui/components/badge.tsx +52 -0
- package/template/src/components/ui/components/bubble.tsx +128 -0
- package/template/src/components/ui/components/button-group.tsx +87 -0
- package/template/src/components/ui/components/button.tsx +56 -0
- package/template/src/components/ui/components/card.tsx +103 -0
- package/template/src/components/ui/components/checkbox.tsx +26 -0
- package/template/src/components/ui/components/command.tsx +194 -0
- package/template/src/components/ui/components/dialog.tsx +154 -0
- package/template/src/components/ui/components/drawer.tsx +229 -0
- package/template/src/components/ui/components/dropdown-menu.tsx +270 -0
- package/template/src/components/ui/components/empty.tsx +101 -0
- package/template/src/components/ui/components/field.tsx +236 -0
- package/template/src/components/ui/components/input-group.tsx +155 -0
- package/template/src/components/ui/components/input.tsx +20 -0
- package/template/src/components/ui/components/item.tsx +201 -0
- package/template/src/components/ui/components/kbd.tsx +26 -0
- package/template/src/components/ui/components/label.tsx +18 -0
- package/template/src/components/ui/components/marker.tsx +71 -0
- package/template/src/components/ui/components/message-scroller.tsx +128 -0
- package/template/src/components/ui/components/message.tsx +92 -0
- package/template/src/components/ui/components/native-select.tsx +65 -0
- package/template/src/components/ui/components/popover.tsx +88 -0
- package/template/src/components/ui/components/portal-primitives.test.tsx +85 -0
- package/template/src/components/ui/components/radio-group.tsx +36 -0
- package/template/src/components/ui/components/scroll-area.tsx +52 -0
- package/template/src/components/ui/components/secondary-form-actions.test.tsx +23 -0
- package/template/src/components/ui/components/select.tsx +199 -0
- package/template/src/components/ui/components/separator.tsx +23 -0
- package/template/src/components/ui/components/sheet.tsx +135 -0
- package/template/src/components/ui/components/skeleton.tsx +13 -0
- package/template/src/components/ui/components/sonner.tsx +43 -0
- package/template/src/components/ui/components/spinner.tsx +16 -0
- package/template/src/components/ui/components/tabs.tsx +80 -0
- package/template/src/components/ui/components/textarea.tsx +18 -0
- package/template/src/components/ui/components/tooltip.tsx +64 -0
- package/template/src/components/ui/hooks/.gitkeep +0 -0
- package/template/src/components/ui/lib/utils.ts +6 -0
- package/template/src/components/ui/styles/globals.css +151 -0
- package/template/src/create-post-overlay.tsx +82 -0
- package/template/src/draft-post-list.tsx +39 -0
- package/template/src/feedback-cache-invalidation.test.ts +185 -0
- package/template/src/feedback-cache-invalidation.ts +97 -0
- package/template/src/feedback-runtime.ts +38 -0
- package/template/src/feedbax/changelog/index.test.ts +156 -0
- package/template/src/feedbax/changelog/index.ts +206 -0
- package/template/src/feedbax/changelog/notion-changelog.test.ts +395 -0
- package/template/src/feedbax/changelog/notion-changelog.ts +400 -0
- package/template/src/feedbax/config/index.test.ts +111 -0
- package/template/src/feedbax/config/index.ts +271 -0
- package/template/src/feedbax/feedback/feedback-storage.ts +43 -0
- package/template/src/feedbax/feedback/index.test.ts +900 -0
- package/template/src/feedbax/feedback/index.ts +942 -0
- package/template/src/feedbax/feedback/notion-comments.test.ts +171 -0
- package/template/src/feedbax/feedback/notion-comments.ts +231 -0
- package/template/src/feedbax/feedback/notion-data-source.test.ts +364 -0
- package/template/src/feedbax/feedback/notion-data-source.ts +458 -0
- package/template/src/feedbax/feedback/notion-feedback.test.ts +998 -0
- package/template/src/feedbax/feedback/notion-feedback.ts +651 -0
- package/template/src/feedbax/feedback/notion-request.ts +74 -0
- package/template/src/feedbax.test-fixture.ts +64 -0
- package/template/src/feedbax.ts +69 -0
- package/template/src/index.css +2138 -0
- package/template/src/lazy-create-post-overlay.tsx +6 -0
- package/template/src/lazy-post-detail-overlay.tsx +10 -0
- package/template/src/masked-post-link.tsx +27 -0
- package/template/src/overlay-post-detail.test.tsx +130 -0
- package/template/src/overlay-post-detail.tsx +92 -0
- package/template/src/portal-comments.test.ts +375 -0
- package/template/src/portal-comments.ts +384 -0
- package/template/src/portal-features.test.tsx +150 -0
- package/template/src/portal-feedback-form.tsx +515 -0
- package/template/src/portal-feedback-runtime.test.ts +32 -0
- package/template/src/portal-feedback-runtime.ts +55 -0
- package/template/src/portal-feedback-server-function.ts +93 -0
- package/template/src/portal-feedback-submission.test.ts +307 -0
- package/template/src/portal-feedback-submission.ts +214 -0
- package/template/src/portal-head.ts +58 -0
- package/template/src/portal-server-entry.ts +64 -0
- package/template/src/portal-voting.test.ts +106 -0
- package/template/src/portal-voting.ts +149 -0
- package/template/src/post-badges.tsx +27 -0
- package/template/src/post-creation-flow.test.tsx +230 -0
- package/template/src/post-creation-flow.tsx +88 -0
- package/template/src/post-detail-overlay.test.tsx +114 -0
- package/template/src/post-detail-overlay.tsx +67 -0
- package/template/src/post-feed-controls.test.tsx +239 -0
- package/template/src/post-feed-controls.tsx +298 -0
- package/template/src/post-presentation.ts +37 -0
- package/template/src/post-queries.test.ts +158 -0
- package/template/src/post-queries.ts +26 -0
- package/template/src/post-results.tsx +165 -0
- package/template/src/public-cache-policy.test.ts +141 -0
- package/template/src/public-cache-policy.ts +119 -0
- package/template/src/public-cache-tags.ts +7 -0
- package/template/src/public-changelog-page.ts +15 -0
- package/template/src/public-changelog-server-function.ts +27 -0
- package/template/src/public-date.ts +11 -0
- package/template/src/public-failure-state.test.tsx +69 -0
- package/template/src/public-feedback-index.tsx +124 -0
- package/template/src/public-feedback-page.test.tsx +290 -0
- package/template/src/public-feedback-page.ts +75 -0
- package/template/src/public-feedback-server-function.ts +22 -0
- package/template/src/public-portal-error.tsx +27 -0
- package/template/src/public-post-detail.tsx +682 -0
- package/template/src/public-post-page.test.tsx +173 -0
- package/template/src/public-post-page.ts +15 -0
- package/template/src/public-post-route.test.tsx +214 -0
- package/template/src/public-post-route.tsx +171 -0
- package/template/src/public-post-server-function.ts +48 -0
- package/template/src/public-post-unavailable.tsx +10 -0
- package/template/src/public-roadmap-page.test.tsx +139 -0
- package/template/src/public-roadmap-page.ts +24 -0
- package/template/src/public-roadmap-route.test.tsx +218 -0
- package/template/src/public-roadmap-route.tsx +50 -0
- package/template/src/public-roadmap-server-function.ts +44 -0
- package/template/src/public-roadmap-view.tsx +378 -0
- package/template/src/public-shell.test.tsx +518 -0
- package/template/src/roadmap-query.ts +21 -0
- package/template/src/root-branding.test.ts +53 -0
- package/template/src/routeTree.gen.ts +131 -0
- package/template/src/routed-post-creation-form.tsx +28 -0
- package/template/src/router.tsx +25 -0
- package/template/src/routes/__root.tsx +116 -0
- package/template/src/routes/changelog.tsx +39 -0
- package/template/src/routes/index.tsx +61 -0
- package/template/src/routes/p.$slug.tsx +16 -0
- package/template/src/routes/roadmap.tsx +11 -0
- package/template/src/routes/submit.tsx +28 -0
- package/template/src/safe-public-failure.ts +45 -0
- package/template/src/server.ts +111 -0
- package/template/src/similar-posts.tsx +45 -0
- package/template/src/submit-route.test.tsx +134 -0
- package/template/src/trusted-feedback-handler.test.ts +425 -0
- package/template/src/trusted-feedback-handler.ts +181 -0
- package/template/src/trusted-feedback-runtime.ts +17 -0
- package/template/src/turnstile-configuration.test.ts +37 -0
- package/template/src/turnstile-configuration.ts +19 -0
- package/template/src/use-media-query.ts +17 -0
- package/template/src/vote-toggle.test.tsx +171 -0
- package/template/src/vote-toggle.tsx +211 -0
- package/template/tsconfig.json +45 -0
- package/template/vite.config.ts +20 -0
- package/template/vitest.config.ts +4 -0
- package/template/vitest.setup.ts +3 -0
- package/template/wrangler.jsonc +35 -0
- package/dist/cli.d.ts +0 -3
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/index.d.ts +0 -21
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/template/.env.example +0 -4
- package/template/README.md +0 -7
- package/template/apps/portal/components.json +0 -20
- package/template/apps/portal/package.json +0 -31
- package/template/apps/portal/src/lib/utils.ts +0 -6
- package/template/apps/portal/src/routeTree.gen.ts +0 -59
- package/template/apps/portal/src/router.tsx +0 -10
- package/template/apps/portal/src/routes/__root.tsx +0 -31
- package/template/apps/portal/src/routes/index.tsx +0 -35
- package/template/apps/portal/src/styles.css +0 -91
- package/template/apps/portal/tsconfig.json +0 -15
- package/template/apps/portal/vite.config.ts +0 -13
- package/template/feedbax.config.ts +0 -13
- package/template/feedbax.jsonc +0 -11
- package/template/pnpm-workspace.yaml +0 -3
- package/template/turbo.json +0 -8
package/dist/index.js
CHANGED
|
@@ -1,173 +1,1415 @@
|
|
|
1
|
-
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { createHash as createHash2, randomBytes } from "node:crypto";
|
|
3
|
+
import { readFile as readFile2, readdir as readdir2, writeFile as writeFile2 } from "node:fs/promises";
|
|
4
|
+
|
|
5
|
+
// ../config/src/index.ts
|
|
6
|
+
var rootKeys = [
|
|
7
|
+
"product",
|
|
8
|
+
"theme",
|
|
9
|
+
"notion",
|
|
10
|
+
"changelog",
|
|
11
|
+
"features",
|
|
12
|
+
"submissions",
|
|
13
|
+
"turnstile"
|
|
14
|
+
];
|
|
15
|
+
var featureKeys = ["voting", "comments", "changelog"];
|
|
16
|
+
var feedbackPropertyKeys = [
|
|
17
|
+
"title",
|
|
18
|
+
"slug",
|
|
19
|
+
"description",
|
|
20
|
+
"type",
|
|
21
|
+
"status",
|
|
22
|
+
"published",
|
|
23
|
+
"voteCount",
|
|
24
|
+
"teamResponse",
|
|
25
|
+
"releaseNotesUrl",
|
|
26
|
+
"submitterName",
|
|
27
|
+
"submitterEmail",
|
|
28
|
+
"source",
|
|
29
|
+
"externalId",
|
|
30
|
+
"editTokenHash",
|
|
31
|
+
"createdAt",
|
|
32
|
+
"updatedAt"
|
|
33
|
+
];
|
|
34
|
+
var changelogPropertyKeys = [
|
|
35
|
+
"title",
|
|
36
|
+
"slug",
|
|
37
|
+
"date",
|
|
38
|
+
"summary",
|
|
39
|
+
"body",
|
|
40
|
+
"labels",
|
|
41
|
+
"image",
|
|
42
|
+
"published",
|
|
43
|
+
"createdAt",
|
|
44
|
+
"updatedAt"
|
|
45
|
+
];
|
|
46
|
+
function defineFeedbax(config) {
|
|
47
|
+
const value = object(config, "Feedbax configuration");
|
|
48
|
+
rejectUnknown(value, rootKeys, "Feedbax configuration");
|
|
49
|
+
const product = object(value.product, "product");
|
|
50
|
+
rejectUnknown(
|
|
51
|
+
product,
|
|
52
|
+
["name", "description", "logo", "siteUrl", "supportUrl", "privacyUrl"],
|
|
53
|
+
"product"
|
|
54
|
+
);
|
|
55
|
+
const validatedProduct = {
|
|
56
|
+
name: text(product.name, "product.name"),
|
|
57
|
+
description: text(product.description, "product.description"),
|
|
58
|
+
logo: publicUrl(product.logo, "product.logo", true),
|
|
59
|
+
...optionalPublicUrl(product, "siteUrl"),
|
|
60
|
+
...optionalPublicUrl(product, "supportUrl"),
|
|
61
|
+
...optionalPublicUrl(product, "privacyUrl")
|
|
62
|
+
};
|
|
63
|
+
const theme = object(value.theme, "theme");
|
|
64
|
+
rejectUnknown(theme, ["preset"], "theme");
|
|
65
|
+
const notion = storage(
|
|
66
|
+
value.notion,
|
|
67
|
+
feedbackPropertyKeys,
|
|
68
|
+
"notion"
|
|
69
|
+
);
|
|
70
|
+
const features = object(value.features, "features");
|
|
71
|
+
rejectUnknown(features, featureKeys, "features");
|
|
72
|
+
const validatedFeatures = Object.fromEntries(
|
|
73
|
+
featureKeys.map((key) => {
|
|
74
|
+
if (typeof features[key] !== "boolean")
|
|
75
|
+
throw new Error(`features.${key} must be true or false.`);
|
|
76
|
+
return [key, features[key]];
|
|
77
|
+
})
|
|
78
|
+
);
|
|
79
|
+
const changelog = value.changelog === void 0 ? void 0 : storage(
|
|
80
|
+
value.changelog,
|
|
81
|
+
changelogPropertyKeys,
|
|
82
|
+
"changelog"
|
|
83
|
+
);
|
|
84
|
+
if (validatedFeatures.changelog && !changelog)
|
|
85
|
+
throw new Error(
|
|
86
|
+
"Changelog is enabled but changelog storage is not configured. Add changelog identifiers and property mappings, or set features.changelog to false."
|
|
87
|
+
);
|
|
88
|
+
const submissions = object(value.submissions, "submissions");
|
|
89
|
+
rejectUnknown(
|
|
90
|
+
submissions,
|
|
91
|
+
["moderation", "maxTitleLength", "maxDescriptionLength"],
|
|
92
|
+
"submissions"
|
|
93
|
+
);
|
|
94
|
+
if (submissions.moderation !== "required")
|
|
95
|
+
throw new Error('submissions.moderation must be "required".');
|
|
96
|
+
const turnstile = object(value.turnstile, "turnstile");
|
|
97
|
+
rejectUnknown(turnstile, ["enabled", "siteKey"], "turnstile");
|
|
98
|
+
if (typeof turnstile.enabled !== "boolean")
|
|
99
|
+
throw new Error("turnstile.enabled must be true or false.");
|
|
100
|
+
const siteKey = turnstile.siteKey === void 0 ? void 0 : text(turnstile.siteKey, "turnstile.siteKey");
|
|
101
|
+
if (turnstile.enabled && !siteKey)
|
|
102
|
+
throw new Error("Turnstile is enabled but turnstile.siteKey is missing.");
|
|
103
|
+
return {
|
|
104
|
+
product: validatedProduct,
|
|
105
|
+
theme: { preset: text(theme.preset, "theme.preset") },
|
|
106
|
+
notion,
|
|
107
|
+
...changelog ? { changelog } : {},
|
|
108
|
+
features: validatedFeatures,
|
|
109
|
+
submissions: {
|
|
110
|
+
moderation: "required",
|
|
111
|
+
maxTitleLength: positiveInteger(
|
|
112
|
+
submissions.maxTitleLength,
|
|
113
|
+
"submissions.maxTitleLength"
|
|
114
|
+
),
|
|
115
|
+
maxDescriptionLength: positiveInteger(
|
|
116
|
+
submissions.maxDescriptionLength,
|
|
117
|
+
"submissions.maxDescriptionLength"
|
|
118
|
+
)
|
|
119
|
+
},
|
|
120
|
+
turnstile: { enabled: turnstile.enabled, ...siteKey ? { siteKey } : {} }
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function storage(value, keys, label) {
|
|
124
|
+
const input = object(value, label);
|
|
125
|
+
rejectUnknown(input, ["databaseId", "dataSourceId", "properties"], label);
|
|
126
|
+
const properties = object(input.properties, `${label}.properties`);
|
|
127
|
+
rejectUnknown(properties, keys, `${label}.properties`);
|
|
128
|
+
for (const key of keys) text(properties[key], `${label}.properties.${key}`);
|
|
129
|
+
return {
|
|
130
|
+
databaseId: text(input.databaseId, `${label}.databaseId`),
|
|
131
|
+
dataSourceId: text(input.dataSourceId, `${label}.dataSourceId`),
|
|
132
|
+
properties: { ...properties }
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
function object(value, label) {
|
|
136
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
137
|
+
throw new Error(`${label} must be an object.`);
|
|
138
|
+
return value;
|
|
139
|
+
}
|
|
140
|
+
function text(value, label) {
|
|
141
|
+
if (typeof value !== "string" || !value.trim())
|
|
142
|
+
throw new Error(`${label} must be a non-empty string.`);
|
|
143
|
+
return value.trim();
|
|
144
|
+
}
|
|
145
|
+
function positiveInteger(value, label) {
|
|
146
|
+
if (!Number.isInteger(value) || Number(value) <= 0)
|
|
147
|
+
throw new Error(`${label} must be a positive integer.`);
|
|
148
|
+
return Number(value);
|
|
149
|
+
}
|
|
150
|
+
function rejectUnknown(value, allowed, label) {
|
|
151
|
+
const key = Object.keys(value).find(
|
|
152
|
+
(candidate) => !allowed.includes(candidate)
|
|
153
|
+
);
|
|
154
|
+
if (key) throw new Error(`Unknown ${label} key "${key}".`);
|
|
155
|
+
}
|
|
156
|
+
function optionalPublicUrl(value, key) {
|
|
157
|
+
return value[key] === void 0 ? {} : { [key]: publicUrl(value[key], `product.${key}`, false) };
|
|
158
|
+
}
|
|
159
|
+
function publicUrl(value, label, allowRelative) {
|
|
160
|
+
const url = text(value, label);
|
|
161
|
+
if (allowRelative && url.startsWith("/") && !url.startsWith("//") && !url.includes("\\") && !Array.from(url).some((character) => character.charCodeAt(0) <= 32))
|
|
162
|
+
return url;
|
|
163
|
+
try {
|
|
164
|
+
if (new URL(url).protocol === "https:") return url;
|
|
165
|
+
} catch {
|
|
166
|
+
}
|
|
167
|
+
throw new Error(
|
|
168
|
+
`${label} must be ${allowRelative ? "root-relative or " : ""}an HTTPS URL.`
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// src/index.ts
|
|
173
|
+
import { basename, join as join2 } from "node:path";
|
|
174
|
+
|
|
175
|
+
// src/template.ts
|
|
176
|
+
import { createHash } from "node:crypto";
|
|
177
|
+
import { parse } from "jsonc-parser";
|
|
178
|
+
import {
|
|
179
|
+
cp,
|
|
180
|
+
mkdir,
|
|
181
|
+
readFile,
|
|
182
|
+
readdir,
|
|
183
|
+
rm,
|
|
184
|
+
stat,
|
|
185
|
+
writeFile
|
|
186
|
+
} from "node:fs/promises";
|
|
187
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
188
|
+
import { fileURLToPath } from "node:url";
|
|
189
|
+
var ignored = ["node_modules", "dist", ".wrangler", ".dev.vars", ".env"];
|
|
190
|
+
var packagedTemplateDirectory = resolve(
|
|
191
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
192
|
+
"../template"
|
|
193
|
+
);
|
|
194
|
+
async function assembleFlatTemplate(coreRoot, destination) {
|
|
195
|
+
const target = resolve(destination);
|
|
196
|
+
await rm(target, { recursive: true, force: true });
|
|
197
|
+
await mkdir(join(target, "src/feedbax"), { recursive: true });
|
|
198
|
+
await copy(join(coreRoot, "apps/portal/src"), join(target, "src"));
|
|
199
|
+
await copy(join(coreRoot, "apps/portal/public"), join(target, "public"));
|
|
200
|
+
await copy(
|
|
201
|
+
join(coreRoot, "packages/feedback/src"),
|
|
202
|
+
join(target, "src/feedbax/feedback")
|
|
203
|
+
);
|
|
204
|
+
await copy(
|
|
205
|
+
join(coreRoot, "packages/changelog/src"),
|
|
206
|
+
join(target, "src/feedbax/changelog")
|
|
207
|
+
);
|
|
208
|
+
await copy(
|
|
209
|
+
join(coreRoot, "packages/config/src"),
|
|
210
|
+
join(target, "src/feedbax/config")
|
|
211
|
+
);
|
|
212
|
+
await copy(
|
|
213
|
+
join(coreRoot, "packages/ui/src"),
|
|
214
|
+
join(target, "src/components/ui")
|
|
215
|
+
);
|
|
216
|
+
for (const name of ["vite.config.ts"])
|
|
217
|
+
await copy(join(coreRoot, "apps/portal", name), join(target, name));
|
|
218
|
+
const components = parse(
|
|
219
|
+
await readFile(join(coreRoot, "apps/portal/components.json"), "utf8")
|
|
220
|
+
);
|
|
221
|
+
components.tailwind.css = "src/components/ui/styles/globals.css";
|
|
222
|
+
await writeFile(
|
|
223
|
+
join(target, "components.json"),
|
|
224
|
+
JSON.stringify(components, null, 2) + "\n"
|
|
225
|
+
);
|
|
226
|
+
const wrangler = parse(
|
|
227
|
+
await readFile(join(coreRoot, "apps/portal/wrangler.jsonc"), "utf8")
|
|
228
|
+
);
|
|
229
|
+
delete wrangler.routes;
|
|
230
|
+
delete wrangler.vars;
|
|
231
|
+
delete wrangler.account_id;
|
|
232
|
+
wrangler.name = "feedbax-installation";
|
|
233
|
+
await writeFile(
|
|
234
|
+
join(target, "wrangler.jsonc"),
|
|
235
|
+
JSON.stringify(wrangler, null, 2) + "\n"
|
|
236
|
+
);
|
|
237
|
+
await copy(join(coreRoot, "LICENSE"), join(target, "LICENSE"));
|
|
238
|
+
await copy(
|
|
239
|
+
join(coreRoot, "eslint.config.mjs"),
|
|
240
|
+
join(target, "eslint.config.mjs")
|
|
241
|
+
);
|
|
242
|
+
await writeFile(
|
|
243
|
+
join(target, "package.json"),
|
|
244
|
+
JSON.stringify(await flatManifest(coreRoot), null, 2) + "\n"
|
|
245
|
+
);
|
|
246
|
+
await writeFile(
|
|
247
|
+
join(target, "tsconfig.json"),
|
|
248
|
+
JSON.stringify(flatTsconfig, null, 2) + "\n"
|
|
249
|
+
);
|
|
250
|
+
await writeFile(
|
|
251
|
+
join(target, "vitest.config.ts"),
|
|
252
|
+
'import { fileURLToPath } from "node:url";\nimport { defineConfig } from "vitest/config";\nconst src = fileURLToPath(new URL("./src/", import.meta.url));\nexport default defineConfig({ resolve: { alias: [{ find: "@feedbax/feedback", replacement: `${src}feedbax/feedback/index.ts` }, { find: "@feedbax/changelog", replacement: `${src}feedbax/changelog/index.ts` }, { find: "@feedbax/config", replacement: `${src}feedbax/config/index.ts` }, { find: "cloudflare:workers", replacement: `${src}cloudflare-workers-test.ts` }, { find: /^@feedbax\\/ui\\/(.*)$/, replacement: `${src}components/ui/$1` }] } });\n'
|
|
253
|
+
);
|
|
254
|
+
const feedbackKeys = [
|
|
255
|
+
"title",
|
|
256
|
+
"slug",
|
|
257
|
+
"description",
|
|
258
|
+
"type",
|
|
259
|
+
"status",
|
|
260
|
+
"published",
|
|
261
|
+
"voteCount",
|
|
262
|
+
"teamResponse",
|
|
263
|
+
"releaseNotesUrl",
|
|
264
|
+
"submitterName",
|
|
265
|
+
"submitterEmail",
|
|
266
|
+
"source",
|
|
267
|
+
"externalId",
|
|
268
|
+
"editTokenHash",
|
|
269
|
+
"createdAt",
|
|
270
|
+
"updatedAt"
|
|
271
|
+
];
|
|
272
|
+
const changelogKeys = [
|
|
273
|
+
"title",
|
|
274
|
+
"slug",
|
|
275
|
+
"date",
|
|
276
|
+
"summary",
|
|
277
|
+
"body",
|
|
278
|
+
"labels",
|
|
279
|
+
"image",
|
|
280
|
+
"published",
|
|
281
|
+
"createdAt",
|
|
282
|
+
"updatedAt"
|
|
283
|
+
];
|
|
284
|
+
const fixture = {
|
|
285
|
+
product: {
|
|
286
|
+
name: "Feedbax",
|
|
287
|
+
description: "Share feedback and follow product progress.",
|
|
288
|
+
logo: "/feedbax-mark.svg"
|
|
289
|
+
},
|
|
290
|
+
theme: { preset: "lyra-neutral" },
|
|
291
|
+
notion: {
|
|
292
|
+
databaseId: "test-database",
|
|
293
|
+
dataSourceId: "test-feedback",
|
|
294
|
+
properties: Object.fromEntries(
|
|
295
|
+
feedbackKeys.map((key) => [key, `test-${key}`])
|
|
296
|
+
)
|
|
297
|
+
},
|
|
298
|
+
changelog: {
|
|
299
|
+
databaseId: "test-database",
|
|
300
|
+
dataSourceId: "test-changelog",
|
|
301
|
+
properties: Object.fromEntries(
|
|
302
|
+
changelogKeys.map((key) => [key, `test-${key}`])
|
|
303
|
+
)
|
|
304
|
+
},
|
|
305
|
+
features: { voting: true, comments: true, changelog: true },
|
|
306
|
+
submissions: {
|
|
307
|
+
moderation: "required",
|
|
308
|
+
maxTitleLength: 120,
|
|
309
|
+
maxDescriptionLength: 5e3
|
|
310
|
+
},
|
|
311
|
+
turnstile: { enabled: false }
|
|
312
|
+
};
|
|
313
|
+
await writeFile(
|
|
314
|
+
join(target, "src/feedbax.test-fixture.ts"),
|
|
315
|
+
`import { defineFeedbax } from "@feedbax/config";
|
|
316
|
+
|
|
317
|
+
// prettier-ignore
|
|
318
|
+
export default defineFeedbax(${JSON.stringify(fixture, null, 2)});
|
|
319
|
+
`
|
|
320
|
+
);
|
|
321
|
+
await writeFile(
|
|
322
|
+
join(target, "vitest.setup.ts"),
|
|
323
|
+
'import { vi } from "vitest";\n\nvi.mock("./src/feedbax", () => import("./src/feedbax.test-fixture"));\n'
|
|
324
|
+
);
|
|
325
|
+
const testConfigPath = join(target, "vitest.config.ts");
|
|
326
|
+
await writeFile(
|
|
327
|
+
testConfigPath,
|
|
328
|
+
(await readFile(testConfigPath, "utf8")).replace(
|
|
329
|
+
"defineConfig({ resolve:",
|
|
330
|
+
'defineConfig({ test: { setupFiles: ["./vitest.setup.ts"] }, resolve:'
|
|
331
|
+
)
|
|
332
|
+
);
|
|
333
|
+
await writeFile(
|
|
334
|
+
join(target, ".gitignore.template"),
|
|
335
|
+
".dev.vars\n.env*\ndist/\n.wrangler/\n*.log\nnode_modules/\n"
|
|
336
|
+
);
|
|
337
|
+
await writeFile(
|
|
338
|
+
join(target, ".npmignore.template"),
|
|
339
|
+
".dev.vars\n.env*\ndist/\n.wrangler/\n*.log\n"
|
|
340
|
+
);
|
|
341
|
+
await writeFile(
|
|
342
|
+
join(target, ".prettierignore.template"),
|
|
343
|
+
"pnpm-lock.yaml\nsrc/routeTree.gen.ts\ntsconfig.json\nvitest.config.ts\n"
|
|
344
|
+
);
|
|
345
|
+
const checksum = await templateChecksum(target);
|
|
346
|
+
await writeFile(join(target, ".feedbax-template.sha256"), `${checksum}
|
|
347
|
+
`);
|
|
348
|
+
return checksum;
|
|
349
|
+
}
|
|
350
|
+
async function templateChecksum(root) {
|
|
351
|
+
const hash = createHash("sha256");
|
|
352
|
+
for (const file of await files(root)) {
|
|
353
|
+
const name = relative(root, file);
|
|
354
|
+
if (name === ".feedbax-template.sha256") continue;
|
|
355
|
+
hash.update(name).update("\0").update(await readFile(file)).update("\0");
|
|
356
|
+
}
|
|
357
|
+
return hash.digest("hex");
|
|
358
|
+
}
|
|
359
|
+
async function verifyPackagedTemplate(coreRoot, packaged = packagedTemplateDirectory) {
|
|
360
|
+
const temporary = `${packaged}.verify-${process.pid}`;
|
|
361
|
+
try {
|
|
362
|
+
const expected = await assembleFlatTemplate(coreRoot, temporary);
|
|
363
|
+
const actual = (await readFile(join(packaged, ".feedbax-template.sha256"), "utf8")).trim();
|
|
364
|
+
if (actual !== expected || await templateChecksum(packaged) !== actual)
|
|
365
|
+
throw new Error(
|
|
366
|
+
"Packaged create-feedbax template is out of sync. Run pnpm template:sync."
|
|
367
|
+
);
|
|
368
|
+
} finally {
|
|
369
|
+
await rm(temporary, { recursive: true, force: true });
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
async function copyPackagedTemplate(destination, packaged = packagedTemplateDirectory) {
|
|
373
|
+
const target = resolve(destination);
|
|
374
|
+
await mkdir(target, { recursive: true });
|
|
375
|
+
if ((await readdir(target)).length)
|
|
376
|
+
throw new Error(
|
|
377
|
+
`Refusing to generate into non-empty destination: ${target}`
|
|
378
|
+
);
|
|
379
|
+
await cp(packaged, target, { recursive: true });
|
|
380
|
+
await renameIgnoreTemplates(target);
|
|
381
|
+
}
|
|
382
|
+
async function renameIgnoreTemplates(target) {
|
|
383
|
+
const { rename } = await import("node:fs/promises");
|
|
384
|
+
for (const name of [".gitignore", ".npmignore", ".prettierignore"])
|
|
385
|
+
await rename(join(target, `${name}.template`), join(target, name));
|
|
386
|
+
}
|
|
387
|
+
async function flatManifest(coreRoot) {
|
|
388
|
+
const packageFiles = [
|
|
389
|
+
"apps/portal",
|
|
390
|
+
"packages/ui",
|
|
391
|
+
"packages/config",
|
|
392
|
+
"packages/feedback",
|
|
393
|
+
"packages/changelog"
|
|
394
|
+
];
|
|
395
|
+
const manifests = await Promise.all(
|
|
396
|
+
packageFiles.map(
|
|
397
|
+
async (path) => JSON.parse(await readFile(join(coreRoot, path, "package.json"), "utf8"))
|
|
398
|
+
)
|
|
399
|
+
);
|
|
400
|
+
const workspace = await readFile(
|
|
401
|
+
join(coreRoot, "pnpm-workspace.yaml"),
|
|
402
|
+
"utf8"
|
|
403
|
+
);
|
|
404
|
+
const catalog = Object.fromEntries(
|
|
405
|
+
[...workspace.matchAll(/^ {2}("?[^"\s:]+"?): (.+)$/gm)].map((match) => [
|
|
406
|
+
match[1].replaceAll('"', ""),
|
|
407
|
+
match[2]
|
|
408
|
+
])
|
|
409
|
+
);
|
|
410
|
+
const collect = (section) => Object.fromEntries(
|
|
411
|
+
manifests.flatMap((manifest) => Object.entries(manifest[section] ?? {})).filter(([name]) => !name.startsWith("@feedbax/")).map(([name, version]) => [
|
|
412
|
+
name,
|
|
413
|
+
version === "catalog:" ? catalog[name] : version
|
|
414
|
+
])
|
|
415
|
+
);
|
|
416
|
+
return {
|
|
417
|
+
name: "feedbax-installation",
|
|
418
|
+
version: JSON.parse(
|
|
419
|
+
await readFile(join(coreRoot, "packages/feedbax/package.json"), "utf8")
|
|
420
|
+
).version,
|
|
421
|
+
private: true,
|
|
422
|
+
type: "module",
|
|
423
|
+
scripts: {
|
|
424
|
+
dev: "vite dev",
|
|
425
|
+
build: "vite build",
|
|
426
|
+
test: "vitest run",
|
|
427
|
+
"type-check": "tsr generate && tsc --noEmit",
|
|
428
|
+
lint: "eslint .",
|
|
429
|
+
"format:check": "prettier --check .",
|
|
430
|
+
deploy: "feedbax deploy",
|
|
431
|
+
"deploy:dry-run": "wrangler deploy --dry-run"
|
|
432
|
+
},
|
|
433
|
+
dependencies: collect("dependencies"),
|
|
434
|
+
devDependencies: {
|
|
435
|
+
...collect("devDependencies"),
|
|
436
|
+
"@eslint/js": "10.0.1",
|
|
437
|
+
"@types/node": "22.20.1",
|
|
438
|
+
feedbax: JSON.parse(
|
|
439
|
+
await readFile(join(coreRoot, "packages/feedbax/package.json"), "utf8")
|
|
440
|
+
).version,
|
|
441
|
+
eslint: "10.8.1",
|
|
442
|
+
globals: "17.11.0",
|
|
443
|
+
prettier: "3.9.6",
|
|
444
|
+
"typescript-eslint": "8.67.0",
|
|
445
|
+
vitest: "4.1.11"
|
|
446
|
+
},
|
|
447
|
+
pnpm: { onlyBuiltDependencies: ["esbuild"] },
|
|
448
|
+
engines: { node: ">=22.14.0" },
|
|
449
|
+
packageManager: "pnpm@10.29.3",
|
|
450
|
+
license: "Apache-2.0"
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
var flatTsconfig = {
|
|
454
|
+
include: ["src/**/*.ts", "src/**/*.tsx"],
|
|
455
|
+
compilerOptions: {
|
|
456
|
+
target: "ES2022",
|
|
457
|
+
jsx: "react-jsx",
|
|
458
|
+
module: "ESNext",
|
|
459
|
+
lib: ["ES2022", "DOM", "DOM.Iterable"],
|
|
460
|
+
types: ["vite/client", "node"],
|
|
461
|
+
moduleResolution: "bundler",
|
|
462
|
+
allowImportingTsExtensions: true,
|
|
463
|
+
noEmit: true,
|
|
464
|
+
skipLibCheck: true,
|
|
465
|
+
strict: true,
|
|
466
|
+
paths: {
|
|
467
|
+
"@/*": ["./src/*"],
|
|
468
|
+
"@feedbax/feedback": ["./src/feedbax/feedback/index.ts"],
|
|
469
|
+
"@feedbax/changelog": ["./src/feedbax/changelog/index.ts"],
|
|
470
|
+
"@feedbax/config": ["./src/feedbax/config/index.ts"],
|
|
471
|
+
"@feedbax/ui/*": ["./src/components/ui/*"],
|
|
472
|
+
"@feedbax/ui/globals.css": ["./src/components/ui/styles/globals.css"]
|
|
473
|
+
}
|
|
474
|
+
}
|
|
20
475
|
};
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
476
|
+
async function copy(from, to) {
|
|
477
|
+
await cp(from, to, {
|
|
478
|
+
recursive: true,
|
|
479
|
+
filter: (source) => !ignored.some((entry) => source.includes(`/${entry}`))
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
async function files(root) {
|
|
483
|
+
const entries = await readdir(root);
|
|
484
|
+
const result = [];
|
|
485
|
+
for (const entry of entries.sort()) {
|
|
486
|
+
const path = join(root, entry);
|
|
487
|
+
if ((await stat(path)).isDirectory()) result.push(...await files(path));
|
|
488
|
+
else result.push(path);
|
|
489
|
+
}
|
|
490
|
+
return result;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ../feedback/src/notion-request.ts
|
|
494
|
+
var MAX_ATTEMPTS = 3;
|
|
495
|
+
var BASE_BACKOFF_MILLISECONDS = 250;
|
|
496
|
+
var MAX_BACKOFF_MILLISECONDS = 2e3;
|
|
497
|
+
async function requestNotion(input, init = {}, {
|
|
498
|
+
request,
|
|
499
|
+
retry = {},
|
|
500
|
+
operation
|
|
501
|
+
}) {
|
|
502
|
+
const sleep = retry.sleep ?? defaultSleep;
|
|
503
|
+
const random = retry.random ?? Math.random;
|
|
504
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
|
|
505
|
+
const response = await request(input, init);
|
|
506
|
+
if (!isRetryableNotionFailure(response.status, operation)) return response;
|
|
507
|
+
if (attempt === MAX_ATTEMPTS) {
|
|
508
|
+
throw new Error(
|
|
509
|
+
`Notion request failed after ${MAX_ATTEMPTS} attempts (${response.status}).`
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
await sleep(retryDelayMilliseconds(response, attempt, random));
|
|
513
|
+
}
|
|
514
|
+
throw new Error("Notion request retry loop ended unexpectedly.");
|
|
515
|
+
}
|
|
516
|
+
function isRetryableNotionFailure(status, operation) {
|
|
517
|
+
return status === 429 || status === 529 && operation === "idempotent";
|
|
518
|
+
}
|
|
519
|
+
function retryDelayMilliseconds(response, attempt, random) {
|
|
520
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
521
|
+
if (retryAfter) {
|
|
522
|
+
const seconds = Number(retryAfter);
|
|
523
|
+
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1e3;
|
|
524
|
+
}
|
|
525
|
+
return exponentialDelayMilliseconds(attempt, random);
|
|
526
|
+
}
|
|
527
|
+
function exponentialDelayMilliseconds(attempt, random) {
|
|
528
|
+
const ceiling = Math.min(
|
|
529
|
+
BASE_BACKOFF_MILLISECONDS * 2 ** (attempt - 1),
|
|
530
|
+
MAX_BACKOFF_MILLISECONDS
|
|
531
|
+
);
|
|
532
|
+
return Math.round(ceiling / 2 + random() * (ceiling / 2));
|
|
533
|
+
}
|
|
534
|
+
async function defaultSleep(milliseconds) {
|
|
535
|
+
await new Promise((resolve2) => setTimeout(resolve2, milliseconds));
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ../feedback/src/notion-data-source.ts
|
|
539
|
+
var NOTION_API_URL = "https://api.notion.com/v1";
|
|
540
|
+
var NOTION_API_VERSION = "2026-03-11";
|
|
541
|
+
var canonicalProperties = {
|
|
542
|
+
title: { name: "Title", type: "title", schema: { title: {} } },
|
|
543
|
+
slug: { name: "Slug", type: "rich_text", schema: { rich_text: {} } },
|
|
544
|
+
description: {
|
|
545
|
+
name: "Description",
|
|
546
|
+
type: "rich_text",
|
|
547
|
+
schema: { rich_text: {} }
|
|
548
|
+
},
|
|
549
|
+
type: {
|
|
550
|
+
name: "Type",
|
|
551
|
+
type: "select",
|
|
552
|
+
schema: {
|
|
553
|
+
select: {
|
|
554
|
+
options: [
|
|
555
|
+
{ name: "Feature Request" },
|
|
556
|
+
{ name: "Bug Report" },
|
|
557
|
+
{ name: "General Feedback" }
|
|
558
|
+
]
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
},
|
|
562
|
+
status: {
|
|
563
|
+
name: "Status",
|
|
564
|
+
type: "select",
|
|
565
|
+
schema: {
|
|
566
|
+
select: {
|
|
567
|
+
options: [
|
|
568
|
+
{ name: "New" },
|
|
569
|
+
{ name: "Reviewing" },
|
|
570
|
+
{ name: "Planned" },
|
|
571
|
+
{ name: "In Progress" },
|
|
572
|
+
{ name: "Shipped" },
|
|
573
|
+
{ name: "Closed" }
|
|
574
|
+
]
|
|
575
|
+
}
|
|
41
576
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
await ask('--package-manager', 'Package manager (pnpm, npm)', 'pnpm');
|
|
81
|
-
await ask('--preset', 'shadcn preset', 'feedbax-default');
|
|
82
|
-
return resolveCreateOptions(prompted);
|
|
83
|
-
}
|
|
84
|
-
async function assertEmpty(path) {
|
|
85
|
-
try {
|
|
86
|
-
if ((await readdir(path)).length > 0)
|
|
87
|
-
throw new Error(`Target directory is not empty: ${path}`);
|
|
577
|
+
},
|
|
578
|
+
published: {
|
|
579
|
+
name: "Published",
|
|
580
|
+
type: "checkbox",
|
|
581
|
+
schema: { checkbox: {} }
|
|
582
|
+
},
|
|
583
|
+
voteCount: {
|
|
584
|
+
name: "Vote Count",
|
|
585
|
+
type: "number",
|
|
586
|
+
schema: { number: { format: "number" } }
|
|
587
|
+
},
|
|
588
|
+
teamResponse: {
|
|
589
|
+
name: "Team Response",
|
|
590
|
+
type: "rich_text",
|
|
591
|
+
schema: { rich_text: {} }
|
|
592
|
+
},
|
|
593
|
+
releaseNotesUrl: {
|
|
594
|
+
name: "Release Notes URL",
|
|
595
|
+
type: "url",
|
|
596
|
+
schema: { url: {} }
|
|
597
|
+
},
|
|
598
|
+
submitterName: {
|
|
599
|
+
name: "Submitter Name",
|
|
600
|
+
type: "rich_text",
|
|
601
|
+
schema: { rich_text: {} }
|
|
602
|
+
},
|
|
603
|
+
submitterEmail: {
|
|
604
|
+
name: "Submitter Email",
|
|
605
|
+
type: "email",
|
|
606
|
+
schema: { email: {} }
|
|
607
|
+
},
|
|
608
|
+
source: {
|
|
609
|
+
name: "Source",
|
|
610
|
+
type: "select",
|
|
611
|
+
schema: {
|
|
612
|
+
select: {
|
|
613
|
+
options: [{ name: "Portal" }, { name: "API" }, { name: "Team" }]
|
|
614
|
+
}
|
|
88
615
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
616
|
+
},
|
|
617
|
+
externalId: {
|
|
618
|
+
name: "External ID",
|
|
619
|
+
type: "rich_text",
|
|
620
|
+
schema: { rich_text: {} }
|
|
621
|
+
},
|
|
622
|
+
editTokenHash: {
|
|
623
|
+
name: "Edit Token Hash",
|
|
624
|
+
type: "rich_text",
|
|
625
|
+
schema: { rich_text: {} }
|
|
626
|
+
},
|
|
627
|
+
createdAt: {
|
|
628
|
+
name: "Created At",
|
|
629
|
+
type: "created_time",
|
|
630
|
+
schema: { created_time: {} }
|
|
631
|
+
},
|
|
632
|
+
updatedAt: {
|
|
633
|
+
name: "Updated At",
|
|
634
|
+
type: "last_edited_time",
|
|
635
|
+
schema: { last_edited_time: {} }
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
var feedbackWorkflowProperties = {
|
|
639
|
+
Owner: { people: {} },
|
|
640
|
+
Priority: {
|
|
641
|
+
select: {
|
|
642
|
+
options: [
|
|
643
|
+
{ name: "Urgent" },
|
|
644
|
+
{ name: "High" },
|
|
645
|
+
{ name: "Medium" },
|
|
646
|
+
{ name: "Low" }
|
|
647
|
+
]
|
|
648
|
+
}
|
|
649
|
+
},
|
|
650
|
+
"Strategic Theme": { multi_select: { options: [] } },
|
|
651
|
+
"Participant Segment": { multi_select: { options: [] } },
|
|
652
|
+
Impact: {
|
|
653
|
+
select: {
|
|
654
|
+
options: [{ name: "High" }, { name: "Medium" }, { name: "Low" }]
|
|
92
655
|
}
|
|
656
|
+
},
|
|
657
|
+
Effort: {
|
|
658
|
+
select: {
|
|
659
|
+
options: [{ name: "Small" }, { name: "Medium" }, { name: "Large" }]
|
|
660
|
+
}
|
|
661
|
+
},
|
|
662
|
+
"Decision Note": { rich_text: {} },
|
|
663
|
+
"Needs Follow-up": { checkbox: {} }
|
|
664
|
+
};
|
|
665
|
+
async function createNotionFeedbackDataSource({
|
|
666
|
+
token,
|
|
667
|
+
parentPageId,
|
|
668
|
+
request = fetch,
|
|
669
|
+
retry
|
|
670
|
+
}) {
|
|
671
|
+
const response = await requestNotion(
|
|
672
|
+
`${NOTION_API_URL}/databases`,
|
|
673
|
+
{
|
|
674
|
+
method: "POST",
|
|
675
|
+
headers: notionHeaders(token),
|
|
676
|
+
body: JSON.stringify({
|
|
677
|
+
parent: { type: "page_id", page_id: parentPageId },
|
|
678
|
+
title: [{ type: "text", text: { content: "Feedback" } }],
|
|
679
|
+
initial_data_source: {
|
|
680
|
+
properties: {
|
|
681
|
+
...Object.fromEntries(
|
|
682
|
+
Object.values(canonicalProperties).map(({ name, schema }) => [
|
|
683
|
+
name,
|
|
684
|
+
schema
|
|
685
|
+
])
|
|
686
|
+
),
|
|
687
|
+
...feedbackWorkflowProperties
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
})
|
|
691
|
+
},
|
|
692
|
+
{ request, retry, operation: "create" }
|
|
693
|
+
);
|
|
694
|
+
const database = await readNotionResponse(response);
|
|
695
|
+
if (typeof database.id !== "string") {
|
|
696
|
+
throw new Error("Notion did not return the created Feedback database.");
|
|
697
|
+
}
|
|
698
|
+
const dataSourceId = firstDataSourceId(database);
|
|
699
|
+
const dataSourceResponse = await requestNotion(
|
|
700
|
+
`${NOTION_API_URL}/data_sources/${dataSourceId}`,
|
|
701
|
+
{ method: "GET", headers: notionHeaders(token) },
|
|
702
|
+
{ request, retry, operation: "idempotent" }
|
|
703
|
+
);
|
|
704
|
+
const dataSource = await readNotionResponse(dataSourceResponse);
|
|
705
|
+
return configurationFromCanonicalNames(dataSource, database.id);
|
|
93
706
|
}
|
|
94
|
-
async function
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
707
|
+
async function createFeedbackWorkflowViews({
|
|
708
|
+
token,
|
|
709
|
+
databaseId,
|
|
710
|
+
dataSourceId,
|
|
711
|
+
propertyIds,
|
|
712
|
+
request = fetch
|
|
713
|
+
}) {
|
|
714
|
+
const views = [
|
|
715
|
+
{
|
|
716
|
+
name: "Inbox",
|
|
717
|
+
type: "table",
|
|
718
|
+
filter: selectFilter(propertyIds.status, "New"),
|
|
719
|
+
sorts: [{ property: propertyIds.createdAt, direction: "descending" }]
|
|
720
|
+
},
|
|
721
|
+
{
|
|
722
|
+
name: "Reviewing",
|
|
723
|
+
type: "table",
|
|
724
|
+
filter: selectFilter(propertyIds.status, "Reviewing"),
|
|
725
|
+
sorts: [{ property: propertyIds.updatedAt, direction: "descending" }]
|
|
726
|
+
},
|
|
727
|
+
{
|
|
728
|
+
name: "Prioritization",
|
|
729
|
+
type: "table",
|
|
730
|
+
filter: {
|
|
731
|
+
or: [
|
|
732
|
+
selectFilter(propertyIds.status, "Reviewing"),
|
|
733
|
+
selectFilter(propertyIds.status, "Planned")
|
|
734
|
+
]
|
|
735
|
+
},
|
|
736
|
+
sorts: [
|
|
737
|
+
{ property: "Priority", direction: "ascending" },
|
|
738
|
+
{ property: propertyIds.voteCount, direction: "descending" }
|
|
739
|
+
]
|
|
740
|
+
},
|
|
741
|
+
{
|
|
742
|
+
name: "Follow-up",
|
|
743
|
+
type: "table",
|
|
744
|
+
filter: { property: "Needs Follow-up", checkbox: { equals: true } }
|
|
745
|
+
},
|
|
746
|
+
{
|
|
747
|
+
name: "Roadmap",
|
|
748
|
+
type: "board",
|
|
749
|
+
filter: {
|
|
750
|
+
or: ["Planned", "In Progress", "Shipped"].map(
|
|
751
|
+
(status) => selectFilter(propertyIds.status, status)
|
|
752
|
+
)
|
|
753
|
+
},
|
|
754
|
+
configuration: {
|
|
755
|
+
type: "board",
|
|
756
|
+
group_by: {
|
|
757
|
+
type: "select",
|
|
758
|
+
property_id: propertyIds.status,
|
|
759
|
+
group_by: "option",
|
|
760
|
+
sort: { type: "manual" }
|
|
104
761
|
}
|
|
762
|
+
}
|
|
763
|
+
},
|
|
764
|
+
{
|
|
765
|
+
name: "Shipped",
|
|
766
|
+
type: "table",
|
|
767
|
+
filter: selectFilter(propertyIds.status, "Shipped"),
|
|
768
|
+
sorts: [{ property: propertyIds.updatedAt, direction: "descending" }]
|
|
105
769
|
}
|
|
770
|
+
];
|
|
771
|
+
for (const view of views) {
|
|
772
|
+
const response = await requestNotion(
|
|
773
|
+
`${NOTION_API_URL}/views`,
|
|
774
|
+
{
|
|
775
|
+
method: "POST",
|
|
776
|
+
headers: notionHeaders(token),
|
|
777
|
+
body: JSON.stringify({
|
|
778
|
+
database_id: databaseId,
|
|
779
|
+
data_source_id: dataSourceId,
|
|
780
|
+
...view
|
|
781
|
+
})
|
|
782
|
+
},
|
|
783
|
+
{ request, operation: "create" }
|
|
784
|
+
);
|
|
785
|
+
await readNotionResponse(response);
|
|
786
|
+
}
|
|
106
787
|
}
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
788
|
+
function selectFilter(property, value) {
|
|
789
|
+
return { property, select: { equals: value } };
|
|
790
|
+
}
|
|
791
|
+
async function validateNotionFeedbackDataSource({
|
|
792
|
+
token,
|
|
793
|
+
dataSourceId,
|
|
794
|
+
propertyIds,
|
|
795
|
+
request = fetch,
|
|
796
|
+
retry
|
|
797
|
+
}) {
|
|
798
|
+
const response = await requestNotion(
|
|
799
|
+
`${NOTION_API_URL}/data_sources/${dataSourceId}`,
|
|
800
|
+
{
|
|
801
|
+
method: "GET",
|
|
802
|
+
headers: notionHeaders(token)
|
|
803
|
+
},
|
|
804
|
+
{ request, retry, operation: "idempotent" }
|
|
805
|
+
);
|
|
806
|
+
const dataSource = await readNotionResponse(response);
|
|
807
|
+
const properties = dataSource.properties;
|
|
808
|
+
if (typeof dataSource.id !== "string" || !isPropertyMap(properties)) {
|
|
809
|
+
throw new Error(
|
|
810
|
+
"Notion returned an invalid Feedback Data Source response."
|
|
811
|
+
);
|
|
812
|
+
}
|
|
813
|
+
const propertiesById = new Map(
|
|
814
|
+
Object.values(properties).filter(
|
|
815
|
+
(property) => Boolean(property) && typeof property.id === "string"
|
|
816
|
+
).map((property) => [property.id, property])
|
|
817
|
+
);
|
|
818
|
+
const repairs = [];
|
|
819
|
+
for (const [key, { name, type }] of Object.entries(canonicalProperties)) {
|
|
820
|
+
const configuredId = propertyIds[key];
|
|
821
|
+
const property = propertiesById.get(configuredId);
|
|
822
|
+
if (!property) {
|
|
823
|
+
repairs.push(
|
|
824
|
+
`Restore the "${name}" property with ID "${configuredId}" and type "${type}", then update the configured property ID if Notion assigns a new one.`
|
|
825
|
+
);
|
|
826
|
+
} else if (property.type !== type) {
|
|
827
|
+
repairs.push(
|
|
828
|
+
`Change the property configured as "${name}" (ID "${configuredId}") from "${String(property.type)}" to "${type}", or restore a ${type} property and configure its ID.`
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
if (repairs.length > 0) {
|
|
833
|
+
throw new Error(repairs.join("\n"));
|
|
834
|
+
}
|
|
835
|
+
return {
|
|
836
|
+
databaseId: databaseIdFromDataSource(dataSource),
|
|
837
|
+
dataSourceId,
|
|
838
|
+
propertyIds: { ...propertyIds }
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
function notionHeaders(token) {
|
|
842
|
+
return {
|
|
843
|
+
Authorization: `Bearer ${token}`,
|
|
844
|
+
"Content-Type": "application/json",
|
|
845
|
+
"Notion-Version": NOTION_API_VERSION
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
async function readNotionResponse(response) {
|
|
849
|
+
const body = await response.json();
|
|
850
|
+
if (!response.ok) {
|
|
851
|
+
throw new Error(
|
|
852
|
+
`Notion rejected the Feedback Data Source request (${response.status}).`
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
if (!body || typeof body !== "object") {
|
|
856
|
+
throw new Error(
|
|
857
|
+
"Notion returned an invalid Feedback Data Source response."
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
return body;
|
|
861
|
+
}
|
|
862
|
+
function firstDataSourceId(database) {
|
|
863
|
+
const dataSources = Reflect.get(database, "data_sources");
|
|
864
|
+
const firstDataSource = Array.isArray(dataSources) ? dataSources[0] : void 0;
|
|
865
|
+
const id = firstDataSource && typeof firstDataSource === "object" ? Reflect.get(firstDataSource, "id") : void 0;
|
|
866
|
+
if (typeof id !== "string") {
|
|
867
|
+
throw new Error("Notion did not return the created Feedback Data Source.");
|
|
868
|
+
}
|
|
869
|
+
return id;
|
|
870
|
+
}
|
|
871
|
+
function configurationFromCanonicalNames(dataSource, databaseId) {
|
|
872
|
+
const properties = dataSource.properties;
|
|
873
|
+
if (typeof dataSource.id !== "string" || !isPropertyMap(properties)) {
|
|
874
|
+
throw new Error(
|
|
875
|
+
"Notion returned an invalid Feedback Data Source response."
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
return {
|
|
879
|
+
databaseId,
|
|
880
|
+
dataSourceId: dataSource.id,
|
|
881
|
+
propertyIds: Object.fromEntries(
|
|
882
|
+
Object.entries(canonicalProperties).map(([key, { name }]) => {
|
|
883
|
+
const property = properties[name];
|
|
884
|
+
if (!property || typeof property.id !== "string") {
|
|
885
|
+
throw new Error(
|
|
886
|
+
`Notion did not return the required ${name} property.`
|
|
887
|
+
);
|
|
133
888
|
}
|
|
889
|
+
return [key, property.id];
|
|
890
|
+
})
|
|
891
|
+
)
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
function databaseIdFromDataSource(dataSource) {
|
|
895
|
+
const parent = dataSource.parent;
|
|
896
|
+
const databaseId = parent && typeof parent === "object" ? Reflect.get(parent, "database_id") : void 0;
|
|
897
|
+
if (typeof databaseId !== "string") {
|
|
898
|
+
throw new Error(
|
|
899
|
+
"Notion did not return the Feedback Data Source parent database."
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
return databaseId;
|
|
903
|
+
}
|
|
904
|
+
function isPropertyMap(value) {
|
|
905
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// ../feedback/src/index.ts
|
|
909
|
+
var postCreationQueue = Promise.resolve();
|
|
910
|
+
|
|
911
|
+
// ../changelog/src/notion-changelog.ts
|
|
912
|
+
var NOTION_API_URL2 = "https://api.notion.com/v1";
|
|
913
|
+
var NOTION_API_VERSION2 = "2026-03-11";
|
|
914
|
+
var canonicalProperties2 = {
|
|
915
|
+
title: { name: "Title", type: "title", schema: { title: {} } },
|
|
916
|
+
slug: { name: "Slug", type: "rich_text", schema: { rich_text: {} } },
|
|
917
|
+
date: { name: "Date", type: "date", schema: { date: {} } },
|
|
918
|
+
summary: {
|
|
919
|
+
name: "Summary",
|
|
920
|
+
type: "rich_text",
|
|
921
|
+
schema: { rich_text: {} }
|
|
922
|
+
},
|
|
923
|
+
body: { name: "Body", type: "rich_text", schema: { rich_text: {} } },
|
|
924
|
+
labels: {
|
|
925
|
+
name: "Labels",
|
|
926
|
+
type: "multi_select",
|
|
927
|
+
schema: { multi_select: { options: [] } }
|
|
928
|
+
},
|
|
929
|
+
image: { name: "Image", type: "files", schema: { files: {} } },
|
|
930
|
+
published: {
|
|
931
|
+
name: "Published",
|
|
932
|
+
type: "checkbox",
|
|
933
|
+
schema: { checkbox: {} }
|
|
934
|
+
},
|
|
935
|
+
createdAt: {
|
|
936
|
+
name: "Created At",
|
|
937
|
+
type: "created_time",
|
|
938
|
+
schema: { created_time: {} }
|
|
939
|
+
},
|
|
940
|
+
updatedAt: {
|
|
941
|
+
name: "Updated At",
|
|
942
|
+
type: "last_edited_time",
|
|
943
|
+
schema: { last_edited_time: {} }
|
|
944
|
+
}
|
|
945
|
+
};
|
|
946
|
+
async function createNotionChangelogDataSource({
|
|
947
|
+
token,
|
|
948
|
+
databaseId,
|
|
949
|
+
request = fetch
|
|
950
|
+
}) {
|
|
951
|
+
const response = await request(`${NOTION_API_URL2}/data_sources`, {
|
|
952
|
+
method: "POST",
|
|
953
|
+
headers: notionHeaders2(token),
|
|
954
|
+
body: JSON.stringify({
|
|
955
|
+
parent: { database_id: databaseId },
|
|
956
|
+
title: [{ type: "text", text: { content: "Changelog" } }],
|
|
957
|
+
properties: Object.fromEntries(
|
|
958
|
+
Object.values(canonicalProperties2).map(({ name, schema }) => [
|
|
959
|
+
name,
|
|
960
|
+
schema
|
|
961
|
+
])
|
|
962
|
+
)
|
|
963
|
+
})
|
|
964
|
+
});
|
|
965
|
+
return configurationFromResponse(
|
|
966
|
+
await readObject(response, "Changelog Data Source"),
|
|
967
|
+
databaseId
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
async function validateNotionChangelogDataSource({
|
|
971
|
+
token,
|
|
972
|
+
dataSourceId,
|
|
973
|
+
propertyIds,
|
|
974
|
+
request = fetch
|
|
975
|
+
}) {
|
|
976
|
+
const response = await request(
|
|
977
|
+
`${NOTION_API_URL2}/data_sources/${dataSourceId}`,
|
|
978
|
+
{ headers: notionHeaders2(token) }
|
|
979
|
+
);
|
|
980
|
+
const dataSource = await readObject(response, "Changelog Data Source");
|
|
981
|
+
const properties = recordAt(dataSource, "properties");
|
|
982
|
+
const propertiesById = new Map(
|
|
983
|
+
Object.values(properties).flatMap((property) => {
|
|
984
|
+
if (!isRecord(property) || typeof property.id !== "string") return [];
|
|
985
|
+
return [[property.id, property]];
|
|
986
|
+
})
|
|
987
|
+
);
|
|
988
|
+
const repairs = [];
|
|
989
|
+
for (const [key, { name, type }] of Object.entries(canonicalProperties2)) {
|
|
990
|
+
const configuredId = propertyIds[key];
|
|
991
|
+
const property = propertiesById.get(configuredId);
|
|
992
|
+
if (!property) {
|
|
993
|
+
repairs.push(
|
|
994
|
+
`Restore the "${name}" property with ID "${configuredId}" and type "${type}", then update the configured property ID if Notion assigns a new one.`
|
|
995
|
+
);
|
|
996
|
+
} else if (property.type !== type) {
|
|
997
|
+
repairs.push(
|
|
998
|
+
`Change the property configured as "${name}" (ID "${configuredId}") from "${String(property.type)}" to "${type}", or restore a ${type} property and configure its ID.`
|
|
999
|
+
);
|
|
134
1000
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
1001
|
+
}
|
|
1002
|
+
if (repairs.length) throw new Error(repairs.join("\n"));
|
|
1003
|
+
return {
|
|
1004
|
+
databaseId: databaseIdFromResponse(dataSource),
|
|
1005
|
+
dataSourceId,
|
|
1006
|
+
propertyIds: { ...propertyIds }
|
|
1007
|
+
};
|
|
1008
|
+
}
|
|
1009
|
+
function configurationFromResponse(dataSource, databaseId) {
|
|
1010
|
+
const properties = recordAt(dataSource, "properties");
|
|
1011
|
+
return {
|
|
1012
|
+
databaseId,
|
|
1013
|
+
dataSourceId: requiredString(dataSource.id),
|
|
1014
|
+
propertyIds: Object.fromEntries(
|
|
1015
|
+
Object.entries(canonicalProperties2).map(([key, { name }]) => [
|
|
1016
|
+
key,
|
|
1017
|
+
requiredString(recordAt(properties, name).id)
|
|
1018
|
+
])
|
|
1019
|
+
)
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
function databaseIdFromResponse(dataSource) {
|
|
1023
|
+
return requiredString(recordAt(dataSource, "parent").database_id);
|
|
1024
|
+
}
|
|
1025
|
+
function notionHeaders2(token) {
|
|
1026
|
+
return {
|
|
1027
|
+
Authorization: `Bearer ${token}`,
|
|
1028
|
+
"Content-Type": "application/json",
|
|
1029
|
+
"Notion-Version": NOTION_API_VERSION2
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
async function readObject(response, subject) {
|
|
1033
|
+
const body = await response.json();
|
|
1034
|
+
if (!response.ok) {
|
|
1035
|
+
throw new Error(
|
|
1036
|
+
`Notion rejected the ${subject} request (${response.status}).`
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
if (!isRecord(body))
|
|
1040
|
+
throw new Error(`Notion returned an invalid ${subject} response.`);
|
|
1041
|
+
return body;
|
|
1042
|
+
}
|
|
1043
|
+
function recordAt(value, key) {
|
|
1044
|
+
return record(value[key]);
|
|
1045
|
+
}
|
|
1046
|
+
function record(value) {
|
|
1047
|
+
if (!isRecord(value))
|
|
1048
|
+
throw new Error("Notion returned an invalid Changelog response.");
|
|
1049
|
+
return value;
|
|
1050
|
+
}
|
|
1051
|
+
function requiredString(value) {
|
|
1052
|
+
if (typeof value !== "string")
|
|
1053
|
+
throw new Error("Notion returned an invalid Changelog response.");
|
|
1054
|
+
return value;
|
|
1055
|
+
}
|
|
1056
|
+
function isRecord(value) {
|
|
1057
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
// src/index.ts
|
|
1061
|
+
async function provisionGuidedInstallation({
|
|
1062
|
+
destination,
|
|
1063
|
+
product,
|
|
1064
|
+
themePreset,
|
|
1065
|
+
workerName,
|
|
1066
|
+
commentProbePageId,
|
|
1067
|
+
features,
|
|
1068
|
+
feedbackSelection,
|
|
1069
|
+
changelogSelection,
|
|
1070
|
+
notionToken,
|
|
1071
|
+
turnstile,
|
|
1072
|
+
preview,
|
|
1073
|
+
request = fetch
|
|
1074
|
+
}) {
|
|
1075
|
+
try {
|
|
1076
|
+
if ((await readdir2(destination)).length)
|
|
1077
|
+
throw new Error(
|
|
1078
|
+
`Refusing to generate into non-empty destination: ${destination}`
|
|
1079
|
+
);
|
|
1080
|
+
} catch (error) {
|
|
1081
|
+
if (error.code !== "ENOENT") throw error;
|
|
1082
|
+
}
|
|
1083
|
+
if (product.siteUrl && new URL(product.siteUrl).protocol !== "https:")
|
|
1084
|
+
throw new Error("product.siteUrl must be an HTTPS URL.");
|
|
1085
|
+
const targetName = workerName ?? basename(destination).toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
1086
|
+
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(targetName))
|
|
1087
|
+
throw new Error(
|
|
1088
|
+
"Worker name must contain 1\u201363 lowercase letters, numbers, or hyphens."
|
|
1089
|
+
);
|
|
1090
|
+
const approved = await preview(
|
|
1091
|
+
feedbackSelection.kind === "create" ? `Create the canonical Feedback Data Source inside Notion page ${feedbackSelection.parentPageId}.` : `Validate existing Feedback Data Source ${feedbackSelection.configuration.dataSourceId} without changing it.`
|
|
1092
|
+
);
|
|
1093
|
+
if (!approved)
|
|
1094
|
+
throw new Error(
|
|
1095
|
+
"Feedback setup was not approved. No local files were created."
|
|
1096
|
+
);
|
|
1097
|
+
const feedback = feedbackSelection.kind === "create" ? await createNotionFeedbackDataSource({
|
|
1098
|
+
token: notionToken,
|
|
1099
|
+
parentPageId: feedbackSelection.parentPageId,
|
|
1100
|
+
request
|
|
1101
|
+
}) : await validateNotionFeedbackDataSource({
|
|
1102
|
+
token: notionToken,
|
|
1103
|
+
...feedbackSelection.configuration,
|
|
1104
|
+
request
|
|
1105
|
+
});
|
|
1106
|
+
if (feedbackSelection.kind === "create") {
|
|
1107
|
+
if (!await preview(
|
|
1108
|
+
"Create six Notion team views: Inbox, Reviewing, Prioritization, Follow-up, Roadmap, and Shipped."
|
|
1109
|
+
))
|
|
1110
|
+
throw new Error("Workflow view creation was not approved.");
|
|
1111
|
+
await createFeedbackWorkflowViews({
|
|
1112
|
+
token: notionToken,
|
|
1113
|
+
...feedback,
|
|
1114
|
+
request
|
|
150
1115
|
});
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
1116
|
+
}
|
|
1117
|
+
await verifySelectedCommentCapabilities(features, {
|
|
1118
|
+
token: notionToken,
|
|
1119
|
+
pageId: commentProbePageId ?? (feedbackSelection.kind === "create" ? feedbackSelection.parentPageId : feedback.databaseId),
|
|
1120
|
+
request,
|
|
1121
|
+
command: "setup"
|
|
1122
|
+
});
|
|
1123
|
+
const changelog = await configureChangelogStorage({
|
|
1124
|
+
features,
|
|
1125
|
+
selection: changelogSelection ?? (features.changelog ? { kind: "create", databaseId: feedback.databaseId } : void 0),
|
|
1126
|
+
token: notionToken,
|
|
1127
|
+
request,
|
|
1128
|
+
preview
|
|
1129
|
+
});
|
|
1130
|
+
await copyPackagedTemplate(destination);
|
|
1131
|
+
const wranglerPath = join2(destination, "wrangler.jsonc");
|
|
1132
|
+
const wrangler = JSON.parse(await readFile2(wranglerPath, "utf8"));
|
|
1133
|
+
await writeFile2(
|
|
1134
|
+
wranglerPath,
|
|
1135
|
+
JSON.stringify({ ...wrangler, name: targetName }, null, 2) + "\n"
|
|
1136
|
+
);
|
|
1137
|
+
const apiKey = randomBytes(32).toString("base64url");
|
|
1138
|
+
const signingSecret = randomBytes(32).toString("base64url");
|
|
1139
|
+
const values = {
|
|
1140
|
+
NOTION_TOKEN: notionToken,
|
|
1141
|
+
NOTION_FEEDBACK_DATABASE_ID: feedback.databaseId,
|
|
1142
|
+
NOTION_FEEDBACK_DATA_SOURCE_ID: feedback.dataSourceId,
|
|
1143
|
+
...prefixProperties("NOTION_FEEDBACK", feedback.propertyIds),
|
|
1144
|
+
FEEDBAX_API_KEY_HASH: createHash2("sha256").update(apiKey).digest("base64url"),
|
|
1145
|
+
PARTICIPATION_SIGNING_SECRET: signingSecret,
|
|
1146
|
+
FEEDBAX_VOTING_ENABLED: String(features.voting),
|
|
1147
|
+
FEEDBAX_COMMENTS_ENABLED: String(features.comments),
|
|
1148
|
+
FEEDBAX_CHANGELOG_ENABLED: String(features.changelog),
|
|
1149
|
+
...turnstile.siteKey ? { VITE_TURNSTILE_SITE_KEY: turnstile.siteKey } : {},
|
|
1150
|
+
...turnstile.secretKey ? { TURNSTILE_SECRET_KEY: turnstile.secretKey } : {},
|
|
1151
|
+
...changelog ? {
|
|
1152
|
+
NOTION_CHANGELOG_DATABASE_ID: changelog.databaseId,
|
|
1153
|
+
NOTION_CHANGELOG_DATA_SOURCE_ID: changelog.dataSourceId,
|
|
1154
|
+
...prefixProperties("NOTION_CHANGELOG", changelog.propertyIds)
|
|
1155
|
+
} : {}
|
|
1156
|
+
};
|
|
1157
|
+
await writeFile2(
|
|
1158
|
+
join2(destination, ".dev.vars"),
|
|
1159
|
+
Object.entries(values).map(([key, value]) => `${key}=${JSON.stringify(value)}`).join("\n") + "\n",
|
|
1160
|
+
{ mode: 384 }
|
|
1161
|
+
);
|
|
1162
|
+
const configuration = defineFeedbax({
|
|
1163
|
+
product: { ...product, logo: "/feedbax-mark.svg" },
|
|
1164
|
+
theme: { preset: themePreset },
|
|
1165
|
+
notion: {
|
|
1166
|
+
databaseId: feedback.databaseId,
|
|
1167
|
+
dataSourceId: feedback.dataSourceId,
|
|
1168
|
+
properties: feedback.propertyIds
|
|
1169
|
+
},
|
|
1170
|
+
...changelog ? {
|
|
1171
|
+
changelog: {
|
|
1172
|
+
databaseId: changelog.databaseId,
|
|
1173
|
+
dataSourceId: changelog.dataSourceId,
|
|
1174
|
+
properties: changelog.propertyIds
|
|
1175
|
+
}
|
|
1176
|
+
} : {},
|
|
1177
|
+
features,
|
|
1178
|
+
submissions: {
|
|
1179
|
+
moderation: "required",
|
|
1180
|
+
maxTitleLength: 160,
|
|
1181
|
+
maxDescriptionLength: 5e3
|
|
1182
|
+
},
|
|
1183
|
+
turnstile: {
|
|
1184
|
+
enabled: Boolean(turnstile.siteKey),
|
|
1185
|
+
...turnstile.siteKey ? { siteKey: turnstile.siteKey } : {}
|
|
158
1186
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
1187
|
+
});
|
|
1188
|
+
await writeFile2(
|
|
1189
|
+
join2(destination, "src/feedbax.ts"),
|
|
1190
|
+
`import { defineFeedbax } from "@feedbax/config";
|
|
1191
|
+
|
|
1192
|
+
export default defineFeedbax(${JSON.stringify(configuration, null, 2)});
|
|
1193
|
+
`
|
|
1194
|
+
);
|
|
1195
|
+
const probePage = commentProbePageId ?? (feedbackSelection.kind === "create" ? feedbackSelection.parentPageId : feedback.databaseId);
|
|
1196
|
+
await writeFile2(
|
|
1197
|
+
join2(destination, ".dev.vars"),
|
|
1198
|
+
`NOTION_COMMENT_CAPABILITY_PROBE_PAGE_ID=${JSON.stringify(probePage)}
|
|
1199
|
+
`,
|
|
1200
|
+
{ flag: "a", mode: 384 }
|
|
1201
|
+
);
|
|
1202
|
+
return { apiKey, feedback, changelog };
|
|
1203
|
+
}
|
|
1204
|
+
function prefixProperties(prefix, properties) {
|
|
1205
|
+
return Object.fromEntries(
|
|
1206
|
+
Object.entries(properties).map(([key, value]) => [
|
|
1207
|
+
`${prefix}_${key.replace(/[A-Z]/g, (letter) => `_${letter}`).toUpperCase()}_PROPERTY_ID`,
|
|
1208
|
+
value
|
|
1209
|
+
])
|
|
1210
|
+
);
|
|
1211
|
+
}
|
|
1212
|
+
var featureChoices = [
|
|
1213
|
+
{ key: "voting", label: "Voting", initialValue: true },
|
|
1214
|
+
{ key: "comments", label: "Comments", initialValue: true },
|
|
1215
|
+
{ key: "changelog", label: "Changelog", initialValue: true }
|
|
1216
|
+
];
|
|
1217
|
+
async function collectFeatureSelection(prompter) {
|
|
1218
|
+
const selections = [];
|
|
1219
|
+
for (const { key, label, initialValue } of featureChoices) {
|
|
1220
|
+
selections.push([
|
|
1221
|
+
key,
|
|
1222
|
+
await prompter.confirm({
|
|
1223
|
+
message: `Enable ${label}?`,
|
|
1224
|
+
initialValue
|
|
1225
|
+
})
|
|
1226
|
+
]);
|
|
1227
|
+
}
|
|
1228
|
+
return Object.fromEntries(selections);
|
|
1229
|
+
}
|
|
1230
|
+
function renderFeatureConfiguration(features) {
|
|
1231
|
+
return `features: {
|
|
1232
|
+
voting: ${features.voting},
|
|
1233
|
+
comments: ${features.comments},
|
|
1234
|
+
changelog: ${features.changelog},
|
|
1235
|
+
},`;
|
|
1236
|
+
}
|
|
1237
|
+
function renderChangelogConfiguration(configuration) {
|
|
1238
|
+
const properties = Object.entries(configuration.propertyIds).map(([key, value]) => ` ${key}: ${JSON.stringify(value)},`).join("\n");
|
|
1239
|
+
return `changelog: {
|
|
1240
|
+
databaseId: ${JSON.stringify(configuration.databaseId)},
|
|
1241
|
+
dataSourceId: ${JSON.stringify(configuration.dataSourceId)},
|
|
1242
|
+
propertyIds: {
|
|
1243
|
+
${properties}
|
|
1244
|
+
},
|
|
1245
|
+
},`;
|
|
1246
|
+
}
|
|
1247
|
+
async function verifySelectedCommentCapabilities(features, {
|
|
1248
|
+
token,
|
|
1249
|
+
pageId,
|
|
1250
|
+
request = fetch,
|
|
1251
|
+
command = "setup"
|
|
1252
|
+
}) {
|
|
1253
|
+
if (!features.comments) return;
|
|
1254
|
+
const headers = {
|
|
1255
|
+
Authorization: `Bearer ${token}`,
|
|
1256
|
+
"Notion-Version": "2026-03-11"
|
|
1257
|
+
};
|
|
1258
|
+
const readResponse = await request(
|
|
1259
|
+
`https://api.notion.com/v1/comments?block_id=${encodeURIComponent(pageId)}&page_size=1`,
|
|
1260
|
+
{ headers }
|
|
1261
|
+
);
|
|
1262
|
+
if (!readResponse.ok) {
|
|
1263
|
+
if (readResponse.status === 403)
|
|
1264
|
+
assertEnabledCommentCapabilities({
|
|
1265
|
+
commentsEnabled: true,
|
|
1266
|
+
capabilities: { readComments: false, insertComments: true },
|
|
1267
|
+
command
|
|
1268
|
+
});
|
|
1269
|
+
throw new Error(
|
|
1270
|
+
`Notion Comment read check failed (HTTP ${readResponse.status}). Verify a shared page ID and connection access.`
|
|
1271
|
+
);
|
|
1272
|
+
}
|
|
1273
|
+
if (command === "doctor") {
|
|
1274
|
+
assertEnabledCommentCapabilities({
|
|
1275
|
+
commentsEnabled: true,
|
|
1276
|
+
capabilities: {
|
|
1277
|
+
readComments: readResponse.status !== 403,
|
|
1278
|
+
// Setup proves Insert comments before enabling the capability. Doctor
|
|
1279
|
+
// deliberately avoids a write-method probe and verifies the live read path.
|
|
1280
|
+
insertComments: true
|
|
1281
|
+
},
|
|
1282
|
+
command
|
|
1283
|
+
});
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
const insertResponse = await request("https://api.notion.com/v1/comments", {
|
|
1287
|
+
method: "POST",
|
|
1288
|
+
headers: { ...headers, "Content-Type": "application/json" },
|
|
1289
|
+
body: "{}"
|
|
1290
|
+
});
|
|
1291
|
+
if (insertResponse.status !== 400 && insertResponse.status !== 403)
|
|
1292
|
+
throw new Error(
|
|
1293
|
+
`Notion Comment insert capability check failed (HTTP ${insertResponse.status}).`
|
|
1294
|
+
);
|
|
1295
|
+
const capabilities = {
|
|
1296
|
+
readComments: readResponse.status !== 403,
|
|
1297
|
+
insertComments: insertResponse.status !== 403
|
|
1298
|
+
};
|
|
1299
|
+
assertEnabledCommentCapabilities({
|
|
1300
|
+
commentsEnabled: true,
|
|
1301
|
+
capabilities,
|
|
1302
|
+
command
|
|
1303
|
+
});
|
|
1304
|
+
}
|
|
1305
|
+
function assertEnabledCommentCapabilities({
|
|
1306
|
+
commentsEnabled,
|
|
1307
|
+
capabilities,
|
|
1308
|
+
command = "setup"
|
|
1309
|
+
}) {
|
|
1310
|
+
if (!commentsEnabled) return;
|
|
1311
|
+
const missing = [
|
|
1312
|
+
...capabilities.readComments ? [] : ["Read comments"],
|
|
1313
|
+
...capabilities.insertComments ? [] : ["Insert comments"]
|
|
1314
|
+
];
|
|
1315
|
+
if (missing.length === 0) return;
|
|
1316
|
+
const retry = command === "doctor" ? "rerun doctor. No changes were made." : "retry setup.";
|
|
1317
|
+
throw new Error(
|
|
1318
|
+
`Enable ${missing.join(" and ")} in the Notion connection settings, then ${retry}`
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
1321
|
+
async function configureChangelogStorage({
|
|
1322
|
+
features,
|
|
1323
|
+
selection,
|
|
1324
|
+
token,
|
|
1325
|
+
request = fetch,
|
|
1326
|
+
preview
|
|
1327
|
+
}) {
|
|
1328
|
+
if (!features.changelog) return;
|
|
1329
|
+
if (!selection) {
|
|
1330
|
+
throw new Error(
|
|
1331
|
+
"Choose an existing compatible Changelog Data Source or create one beside Feedback."
|
|
1332
|
+
);
|
|
1333
|
+
}
|
|
1334
|
+
if (selection.kind === "existing") {
|
|
1335
|
+
return validateNotionChangelogDataSource({
|
|
1336
|
+
token,
|
|
1337
|
+
dataSourceId: selection.dataSourceId,
|
|
1338
|
+
propertyIds: selection.propertyIds,
|
|
1339
|
+
request
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
const approved = await preview(
|
|
1343
|
+
`Create an empty Changelog Data Source beside Feedback in Feedbax Database ${selection.databaseId}. No sample entries will be published.`
|
|
1344
|
+
);
|
|
1345
|
+
if (!approved) throw new Error("Changelog creation was not approved.");
|
|
1346
|
+
return createNotionChangelogDataSource({
|
|
1347
|
+
token,
|
|
1348
|
+
databaseId: selection.databaseId,
|
|
1349
|
+
request
|
|
1350
|
+
});
|
|
1351
|
+
}
|
|
1352
|
+
async function doctorChangelogStorage({
|
|
1353
|
+
enabled,
|
|
1354
|
+
configuration,
|
|
1355
|
+
token,
|
|
1356
|
+
request = fetch
|
|
1357
|
+
}) {
|
|
1358
|
+
if (!enabled) return;
|
|
1359
|
+
if (!configuration) {
|
|
1360
|
+
throw new Error(
|
|
1361
|
+
"Changelog is enabled without identifiers and property mappings. Create or select a compatible Changelog Data Source, configure it, then rerun doctor. No changes were made."
|
|
1362
|
+
);
|
|
1363
|
+
}
|
|
1364
|
+
await validateNotionChangelogDataSource({
|
|
1365
|
+
token,
|
|
1366
|
+
dataSourceId: configuration.dataSourceId,
|
|
1367
|
+
propertyIds: configuration.propertyIds,
|
|
1368
|
+
request
|
|
1369
|
+
});
|
|
1370
|
+
}
|
|
1371
|
+
async function doctorInstallation({
|
|
1372
|
+
features,
|
|
1373
|
+
feedback,
|
|
1374
|
+
changelog,
|
|
1375
|
+
commentProbePageId,
|
|
1376
|
+
token,
|
|
1377
|
+
request = fetch
|
|
1378
|
+
}) {
|
|
1379
|
+
await validateNotionFeedbackDataSource({ token, ...feedback, request });
|
|
1380
|
+
if (features.comments) {
|
|
1381
|
+
if (!commentProbePageId) {
|
|
1382
|
+
throw new Error(
|
|
1383
|
+
"Comments are enabled but no Comment capability probe page is configured. Configure one, then rerun doctor. No changes were made."
|
|
1384
|
+
);
|
|
162
1385
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
1386
|
+
await verifySelectedCommentCapabilities(features, {
|
|
1387
|
+
token,
|
|
1388
|
+
pageId: commentProbePageId,
|
|
1389
|
+
request,
|
|
1390
|
+
command: "doctor"
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
await doctorChangelogStorage({
|
|
1394
|
+
enabled: features.changelog,
|
|
1395
|
+
configuration: changelog,
|
|
1396
|
+
token,
|
|
1397
|
+
request
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
1400
|
+
export {
|
|
1401
|
+
assembleFlatTemplate,
|
|
1402
|
+
assertEnabledCommentCapabilities,
|
|
1403
|
+
collectFeatureSelection,
|
|
1404
|
+
configureChangelogStorage,
|
|
1405
|
+
copyPackagedTemplate,
|
|
1406
|
+
doctorChangelogStorage,
|
|
1407
|
+
doctorInstallation,
|
|
1408
|
+
featureChoices,
|
|
1409
|
+
provisionGuidedInstallation,
|
|
1410
|
+
renderChangelogConfiguration,
|
|
1411
|
+
renderFeatureConfiguration,
|
|
1412
|
+
templateChecksum,
|
|
1413
|
+
verifyPackagedTemplate,
|
|
1414
|
+
verifySelectedCommentCapabilities
|
|
1415
|
+
};
|