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
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
applyPublicCachePolicy,
|
|
5
|
+
canonicalPublicRequest,
|
|
6
|
+
} from "./public-cache-policy";
|
|
7
|
+
|
|
8
|
+
describe("public portal cache policy", () => {
|
|
9
|
+
it("marks only successful public projections with the agreed browser and edge freshness", () => {
|
|
10
|
+
const response = applyPublicCachePolicy(
|
|
11
|
+
new Request("https://feedback.example.com/roadmap"),
|
|
12
|
+
Response.json({ lanes: [] }),
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
expect(response.headers.get("cache-control")).toBe("public, max-age=30");
|
|
16
|
+
expect(response.headers.get("cloudflare-cdn-cache-control")).toBe(
|
|
17
|
+
"public, max-age=120, stale-while-revalidate=600, stale-if-error=86400",
|
|
18
|
+
);
|
|
19
|
+
expect(response.headers.get("cache-tag")).toBe(
|
|
20
|
+
"feedbax-public,feedbax-roadmap",
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("caches Changelog briefly without stale reuse of expiring image URLs", () => {
|
|
25
|
+
const response = applyPublicCachePolicy(
|
|
26
|
+
new Request("https://feedback.example.com/changelog"),
|
|
27
|
+
new Response("timeline"),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
expect(response.headers.get("cache-control")).toBe("public, max-age=30");
|
|
31
|
+
expect(response.headers.get("cloudflare-cdn-cache-control")).toBe(
|
|
32
|
+
"public, max-age=30",
|
|
33
|
+
);
|
|
34
|
+
expect(response.headers.get("cloudflare-cdn-cache-control")).not.toContain(
|
|
35
|
+
"stale-if-error",
|
|
36
|
+
);
|
|
37
|
+
expect(response.headers.get("cache-tag")).toBe(
|
|
38
|
+
"feedbax-public,feedbax-changelog",
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("keeps mutations, private pages, and failed public reads out of the cache", () => {
|
|
43
|
+
const cases = [
|
|
44
|
+
[
|
|
45
|
+
new Request("https://feedback.example.com/submit"),
|
|
46
|
+
new Response("form"),
|
|
47
|
+
],
|
|
48
|
+
[
|
|
49
|
+
new Request("https://feedback.example.com/roadmap", {
|
|
50
|
+
method: "POST",
|
|
51
|
+
}),
|
|
52
|
+
new Response("updated"),
|
|
53
|
+
],
|
|
54
|
+
[
|
|
55
|
+
new Request("https://feedback.example.com/roadmap"),
|
|
56
|
+
new Response("unavailable", { status: 503 }),
|
|
57
|
+
],
|
|
58
|
+
] as const;
|
|
59
|
+
|
|
60
|
+
for (const [request, source] of cases) {
|
|
61
|
+
const response = applyPublicCachePolicy(request, source);
|
|
62
|
+
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
63
|
+
expect(response.headers.has("cache-tag")).toBe(false);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("canonicalizes equivalent feedback filters before the cache sees the public projection", () => {
|
|
68
|
+
const first = canonicalPublicRequest(
|
|
69
|
+
new Request(
|
|
70
|
+
"https://feedback.example.com/?status=New&type=Bug%20Report&ignored=value",
|
|
71
|
+
),
|
|
72
|
+
);
|
|
73
|
+
const second = canonicalPublicRequest(
|
|
74
|
+
new Request("https://feedback.example.com/?type=Bug%20Report&status=New"),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
expect(first?.url).toBe(
|
|
78
|
+
"https://feedback.example.com/?types=%5B%22Bug+Report%22%5D&statuses=%5B%22New%22%5D&pageSize=25&sort=top&schema=1",
|
|
79
|
+
);
|
|
80
|
+
expect(second?.url).toBe(first?.url);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("does not canonicalize non-public routes", () => {
|
|
84
|
+
const request = new Request(
|
|
85
|
+
"https://feedback.example.com/submit?status=New&type=Bug%20Report",
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
expect(canonicalPublicRequest(request)).toBeUndefined();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("versions detail and roadmap identities with their fixed projection policy", () => {
|
|
92
|
+
expect(
|
|
93
|
+
canonicalPublicRequest(
|
|
94
|
+
new Request("https://feedback.example.com/roadmap"),
|
|
95
|
+
)?.url,
|
|
96
|
+
).toBe(
|
|
97
|
+
"https://feedback.example.com/roadmap?sort=updated-at-desc&schema=1",
|
|
98
|
+
);
|
|
99
|
+
expect(
|
|
100
|
+
canonicalPublicRequest(new Request("https://feedback.example.com/p/slug"))
|
|
101
|
+
?.url,
|
|
102
|
+
).toBe("https://feedback.example.com/p/slug?schema=1");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("feed URL normalization", () => {
|
|
107
|
+
it("preserves New ordering instead of redirecting back to Top", () => {
|
|
108
|
+
expect(
|
|
109
|
+
canonicalPublicRequest(
|
|
110
|
+
new Request(
|
|
111
|
+
"https://feedback.example.com/?pageSize=25&sort=new&schema=1",
|
|
112
|
+
),
|
|
113
|
+
),
|
|
114
|
+
).toBeUndefined();
|
|
115
|
+
});
|
|
116
|
+
it("uses New ordering with voting disabled and reaches a stable canonical URL", () => {
|
|
117
|
+
const features = { voting: false, comments: true, changelog: true };
|
|
118
|
+
const first = canonicalPublicRequest(
|
|
119
|
+
new Request("https://feedback.example.com/"),
|
|
120
|
+
features,
|
|
121
|
+
);
|
|
122
|
+
expect(new URL(first!.url).searchParams.get("sort")).toBe("new");
|
|
123
|
+
expect(canonicalPublicRequest(first!, features)).toBeUndefined();
|
|
124
|
+
});
|
|
125
|
+
it("preserves URL search and multiple Type/Status filters", () => {
|
|
126
|
+
const first = canonicalPublicRequest(
|
|
127
|
+
new Request(
|
|
128
|
+
"https://feedback.example.com/?types=%5B%22Bug%20Report%22,%22Feature%20Request%22%5D&statuses=%5B%22Planned%22%5D&search=keyboard&sort=new",
|
|
129
|
+
),
|
|
130
|
+
);
|
|
131
|
+
const url = new URL(first!.url);
|
|
132
|
+
expect(JSON.parse(url.searchParams.get("types")!)).toEqual([
|
|
133
|
+
"Bug Report",
|
|
134
|
+
"Feature Request",
|
|
135
|
+
]);
|
|
136
|
+
expect(JSON.parse(url.searchParams.get("statuses")!)).toEqual(["Planned"]);
|
|
137
|
+
expect(url.searchParams.get("search")).toBe("keyboard");
|
|
138
|
+
expect(url.searchParams.get("sort")).toBe("new");
|
|
139
|
+
expect(canonicalPublicRequest(first!)).toBeUndefined();
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defaultParseSearch,
|
|
3
|
+
defaultStringifySearch,
|
|
4
|
+
} from "@tanstack/react-router";
|
|
5
|
+
import type { PortalFeatures } from "@feedbax/config";
|
|
6
|
+
import { publicPostSearch } from "./public-feedback-page";
|
|
7
|
+
import {
|
|
8
|
+
publicChangelogCacheTag,
|
|
9
|
+
publicFeedbackCacheTag,
|
|
10
|
+
publicPostCacheTag,
|
|
11
|
+
publicRoadmapCacheTag,
|
|
12
|
+
} from "./public-cache-tags";
|
|
13
|
+
|
|
14
|
+
const BROWSER_CACHE_CONTROL = "public, max-age=30";
|
|
15
|
+
const EDGE_CACHE_CONTROL =
|
|
16
|
+
"public, max-age=120, stale-while-revalidate=600, stale-if-error=86400";
|
|
17
|
+
const CHANGELOG_EDGE_CACHE_CONTROL = "public, max-age=30";
|
|
18
|
+
const PUBLIC_PROJECTION_SCHEMA_VERSION = "1";
|
|
19
|
+
const PUBLIC_FEEDBACK_PAGE_SIZE = "25";
|
|
20
|
+
const PUBLIC_ROADMAP_SORT = "updated-at-desc";
|
|
21
|
+
|
|
22
|
+
export function canonicalPublicRequest(
|
|
23
|
+
request: Request,
|
|
24
|
+
features?: PortalFeatures,
|
|
25
|
+
): Request | undefined {
|
|
26
|
+
if (request.method !== "GET") return undefined;
|
|
27
|
+
const source = new URL(request.url);
|
|
28
|
+
if (!isPublicPath(source.pathname)) return undefined;
|
|
29
|
+
|
|
30
|
+
const canonical = new URL(source.origin + source.pathname);
|
|
31
|
+
if (source.pathname === "/") {
|
|
32
|
+
const parsed: Record<string, unknown> = defaultParseSearch(source.search);
|
|
33
|
+
const query = publicPostSearch(
|
|
34
|
+
{
|
|
35
|
+
...parsed,
|
|
36
|
+
types: parsed.types ?? parsed.type,
|
|
37
|
+
statuses: parsed.statuses ?? parsed.status,
|
|
38
|
+
},
|
|
39
|
+
features,
|
|
40
|
+
);
|
|
41
|
+
const { cursor, search, types, statuses, sort } = query;
|
|
42
|
+
canonical.search = defaultStringifySearch({
|
|
43
|
+
...(cursor ? { cursor } : {}),
|
|
44
|
+
...(search ? { search } : {}),
|
|
45
|
+
...(types?.length ? { types: [...types].sort() } : {}),
|
|
46
|
+
...(statuses?.length ? { statuses: [...statuses].sort() } : {}),
|
|
47
|
+
pageSize: Number(PUBLIC_FEEDBACK_PAGE_SIZE),
|
|
48
|
+
sort,
|
|
49
|
+
schema: Number(PUBLIC_PROJECTION_SCHEMA_VERSION),
|
|
50
|
+
});
|
|
51
|
+
} else if (source.pathname === "/roadmap") {
|
|
52
|
+
canonical.searchParams.set("sort", PUBLIC_ROADMAP_SORT);
|
|
53
|
+
}
|
|
54
|
+
canonical.searchParams.set("schema", PUBLIC_PROJECTION_SCHEMA_VERSION);
|
|
55
|
+
|
|
56
|
+
if (canonical.href === source.href) return undefined;
|
|
57
|
+
return new Request(canonical, request);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function applyPublicCachePolicy(
|
|
61
|
+
request: Request,
|
|
62
|
+
source: Response,
|
|
63
|
+
): Response {
|
|
64
|
+
const response = new Response(source.body, source);
|
|
65
|
+
if (!isSafePublicProjection(request, response)) {
|
|
66
|
+
response.headers.set("Cache-Control", "no-store");
|
|
67
|
+
response.headers.delete("Cloudflare-CDN-Cache-Control");
|
|
68
|
+
response.headers.delete("Cache-Tag");
|
|
69
|
+
return response;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
response.headers.set("Cache-Control", BROWSER_CACHE_CONTROL);
|
|
73
|
+
response.headers.set(
|
|
74
|
+
"Cloudflare-CDN-Cache-Control",
|
|
75
|
+
new URL(request.url).pathname === "/changelog"
|
|
76
|
+
? CHANGELOG_EDGE_CACHE_CONTROL
|
|
77
|
+
: EDGE_CACHE_CONTROL,
|
|
78
|
+
);
|
|
79
|
+
response.headers.set("Cache-Tag", publicCacheTags(new URL(request.url)));
|
|
80
|
+
return response;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isSafePublicProjection(request: Request, response: Response): boolean {
|
|
84
|
+
if (request.method !== "GET" || response.status !== 200) return false;
|
|
85
|
+
if (response.headers.has("Set-Cookie")) return false;
|
|
86
|
+
const { pathname } = new URL(request.url);
|
|
87
|
+
return isPublicPath(pathname);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isPublicPath(pathname: string): boolean {
|
|
91
|
+
return (
|
|
92
|
+
pathname === "/" ||
|
|
93
|
+
pathname === "/roadmap" ||
|
|
94
|
+
pathname === "/changelog" ||
|
|
95
|
+
/^\/p\/[^/]+$/.test(pathname)
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function publicCacheTags(url: URL): string {
|
|
100
|
+
if (url.pathname === "/changelog") {
|
|
101
|
+
return `feedbax-public,${publicChangelogCacheTag}`;
|
|
102
|
+
}
|
|
103
|
+
if (url.pathname === "/roadmap") {
|
|
104
|
+
return `feedbax-public,${publicRoadmapCacheTag}`;
|
|
105
|
+
}
|
|
106
|
+
const slug = /^\/p\/([^/]+)$/.exec(url.pathname)?.[1];
|
|
107
|
+
if (slug) {
|
|
108
|
+
return `feedbax-public,${publicFeedbackCacheTag},${publicPostCacheTag(decodePathSegment(slug))}`;
|
|
109
|
+
}
|
|
110
|
+
return `feedbax-public,${publicFeedbackCacheTag}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function decodePathSegment(value: string): string {
|
|
114
|
+
try {
|
|
115
|
+
return decodeURIComponent(value);
|
|
116
|
+
} catch {
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const publicFeedbackCacheTag = "feedbax-feedback";
|
|
2
|
+
export const publicRoadmapCacheTag = "feedbax-roadmap";
|
|
3
|
+
export const publicChangelogCacheTag = "feedbax-changelog";
|
|
4
|
+
|
|
5
|
+
export function publicPostCacheTag(slug: string): string {
|
|
6
|
+
return `feedbax-post-${slug.replace(/[^A-Za-z0-9_.:-]/g, "-")}`;
|
|
7
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ChangelogModule,
|
|
3
|
+
ChangelogPage,
|
|
4
|
+
ChangelogQuery,
|
|
5
|
+
} from "@feedbax/changelog";
|
|
6
|
+
|
|
7
|
+
export function loadPublicChangelogPage({
|
|
8
|
+
changelog,
|
|
9
|
+
query = {},
|
|
10
|
+
}: {
|
|
11
|
+
changelog: Pick<ChangelogModule, "listPublishedEntries">;
|
|
12
|
+
query?: ChangelogQuery;
|
|
13
|
+
}): Promise<ChangelogPage> {
|
|
14
|
+
return changelog.listPublishedEntries(query);
|
|
15
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createServerFn } from "@tanstack/react-start";
|
|
2
|
+
import { loadPublicChangelogPage } from "./public-changelog-page";
|
|
3
|
+
import { runSafePublicRead } from "./safe-public-failure";
|
|
4
|
+
|
|
5
|
+
export const getPublicChangelogPage = createServerFn({ method: "GET" })
|
|
6
|
+
.validator((input: unknown) => {
|
|
7
|
+
if (input === undefined) return {};
|
|
8
|
+
if (!input || typeof input !== "object")
|
|
9
|
+
throw new Error("Changelog request is invalid.");
|
|
10
|
+
const cursor = Reflect.get(input, "cursor");
|
|
11
|
+
const label = Reflect.get(input, "label");
|
|
12
|
+
if (cursor !== undefined && typeof cursor !== "string")
|
|
13
|
+
throw new Error("Changelog request is invalid.");
|
|
14
|
+
if (label !== undefined && typeof label !== "string")
|
|
15
|
+
throw new Error("Changelog request is invalid.");
|
|
16
|
+
return { ...(cursor ? { cursor } : {}), ...(label ? { label } : {}) };
|
|
17
|
+
})
|
|
18
|
+
.handler(({ data }) =>
|
|
19
|
+
runSafePublicRead(async () => {
|
|
20
|
+
const { createConfiguredChangelogModule } =
|
|
21
|
+
await import("./changelog-runtime");
|
|
22
|
+
return loadPublicChangelogPage({
|
|
23
|
+
changelog: createConfiguredChangelogModule(),
|
|
24
|
+
query: data,
|
|
25
|
+
});
|
|
26
|
+
}),
|
|
27
|
+
);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { renderToStaticMarkup } from "react-dom/server";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { PublicPortalError } from "./public-portal-error";
|
|
5
|
+
import {
|
|
6
|
+
ActionablePortalFailure,
|
|
7
|
+
runSafePortalMutation,
|
|
8
|
+
runSafePublicRead,
|
|
9
|
+
} from "./safe-public-failure";
|
|
10
|
+
|
|
11
|
+
describe("public portal failure state", () => {
|
|
12
|
+
it("replaces private upstream diagnostics at the public read boundary", async () => {
|
|
13
|
+
const privateFailure = new Error(
|
|
14
|
+
"Notion token secret-token and property private-field",
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
const failure = await runSafePublicRead(async () =>
|
|
18
|
+
Promise.reject(privateFailure),
|
|
19
|
+
).then(
|
|
20
|
+
() => undefined,
|
|
21
|
+
(error: unknown) => error,
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
expect(failure).toBeInstanceOf(Error);
|
|
25
|
+
expect((failure as Error).message).toBe(
|
|
26
|
+
"Public feedback is temporarily unavailable.",
|
|
27
|
+
);
|
|
28
|
+
expect((failure as Error).message).not.toContain("secret-token");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("renders an explicit retry state without serializing private failure details", () => {
|
|
32
|
+
const privateFailure = new Error(
|
|
33
|
+
"Notion token secret-token, property private-field, Browser Capability private-capability",
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const html = renderToStaticMarkup(
|
|
37
|
+
<PublicPortalError error={privateFailure} reset={() => undefined} />,
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
expect(html).toContain("Posts are temporarily unavailable");
|
|
41
|
+
expect(html).toContain("Try again");
|
|
42
|
+
expect(html).not.toContain("secret-token");
|
|
43
|
+
expect(html).not.toContain("private-field");
|
|
44
|
+
expect(html).not.toContain("private-capability");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("preserves actionable portal failures while replacing private mutation diagnostics", async () => {
|
|
48
|
+
await expect(
|
|
49
|
+
runSafePortalMutation(async () => {
|
|
50
|
+
throw new ActionablePortalFailure(
|
|
51
|
+
"Feedback submission rate limit exceeded.",
|
|
52
|
+
);
|
|
53
|
+
}),
|
|
54
|
+
).rejects.toThrow("Feedback submission rate limit exceeded.");
|
|
55
|
+
|
|
56
|
+
const failure = await runSafePortalMutation(async () => {
|
|
57
|
+
throw new Error("Notion private diagnostic secret-token");
|
|
58
|
+
}).then(
|
|
59
|
+
() => undefined,
|
|
60
|
+
(error: unknown) => error,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
expect(failure).toBeInstanceOf(Error);
|
|
64
|
+
expect((failure as Error).message).toBe(
|
|
65
|
+
"Public feedback mutation is temporarily unavailable.",
|
|
66
|
+
);
|
|
67
|
+
expect((failure as Error).message).not.toContain("secret-token");
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import type { PublicPostQuery, PublicPostPage } from "@feedbax/feedback";
|
|
2
|
+
import type { PortalFeatures } from "@feedbax/config";
|
|
3
|
+
import { Skeleton } from "@feedbax/ui/components/skeleton";
|
|
4
|
+
import { useAuthorizedDraftPosts } from "./draft-post-list";
|
|
5
|
+
import {
|
|
6
|
+
BoardNavigation,
|
|
7
|
+
PostFeedControls,
|
|
8
|
+
StatusNavigation,
|
|
9
|
+
} from "./post-feed-controls";
|
|
10
|
+
import { PostResults } from "./post-results";
|
|
11
|
+
import feedbax from "./feedbax";
|
|
12
|
+
|
|
13
|
+
export function PublicPostIndex({
|
|
14
|
+
page,
|
|
15
|
+
search,
|
|
16
|
+
loadMore,
|
|
17
|
+
loadingMore,
|
|
18
|
+
loadMoreError,
|
|
19
|
+
onSearchChange,
|
|
20
|
+
pending = false,
|
|
21
|
+
maskPostLinks = false,
|
|
22
|
+
features = feedbax.features,
|
|
23
|
+
}: {
|
|
24
|
+
page: PublicPostPage;
|
|
25
|
+
search: PublicPostQuery;
|
|
26
|
+
loadMore?: () => void;
|
|
27
|
+
loadingMore?: boolean;
|
|
28
|
+
loadMoreError?: boolean;
|
|
29
|
+
onSearchChange?: (search: PublicPostQuery) => void;
|
|
30
|
+
pending?: boolean;
|
|
31
|
+
maskPostLinks?: boolean;
|
|
32
|
+
features?: PortalFeatures;
|
|
33
|
+
}) {
|
|
34
|
+
const drafts = useAuthorizedDraftPosts(search);
|
|
35
|
+
return (
|
|
36
|
+
<main className="mx-auto w-full max-w-[96rem] px-6 py-10 lg:px-10">
|
|
37
|
+
<div className="grid gap-12 lg:grid-cols-[minmax(0,1fr)_22rem]">
|
|
38
|
+
<div className="min-w-0">
|
|
39
|
+
<section aria-labelledby="feedback-heading">
|
|
40
|
+
<h1
|
|
41
|
+
id="feedback-heading"
|
|
42
|
+
className="text-3xl font-semibold tracking-tight"
|
|
43
|
+
>
|
|
44
|
+
Feedback
|
|
45
|
+
</h1>
|
|
46
|
+
<p className="mt-2 text-base text-muted-foreground">
|
|
47
|
+
{features.voting
|
|
48
|
+
? "Share ideas and vote on what matters."
|
|
49
|
+
: "Share ideas and follow public progress."}
|
|
50
|
+
</p>
|
|
51
|
+
</section>
|
|
52
|
+
<div className="mt-8 flex flex-col gap-3 lg:flex-row lg:items-center">
|
|
53
|
+
<PostFeedControls
|
|
54
|
+
contextualCreatePost={maskPostLinks}
|
|
55
|
+
search={search}
|
|
56
|
+
onSearchChange={onSearchChange}
|
|
57
|
+
pending={pending}
|
|
58
|
+
features={features}
|
|
59
|
+
/>
|
|
60
|
+
</div>
|
|
61
|
+
<section className="mt-8" aria-live="polite">
|
|
62
|
+
<div className="mb-4">
|
|
63
|
+
<h2 className="text-lg font-medium">Posts</h2>
|
|
64
|
+
</div>
|
|
65
|
+
<PostResults
|
|
66
|
+
drafts={drafts}
|
|
67
|
+
initialItems={page.items}
|
|
68
|
+
nextCursor={page.nextCursor}
|
|
69
|
+
loadMore={loadMore}
|
|
70
|
+
loadingMore={loadingMore}
|
|
71
|
+
loadMoreError={loadMoreError}
|
|
72
|
+
maskPostLinks={maskPostLinks}
|
|
73
|
+
features={features}
|
|
74
|
+
/>
|
|
75
|
+
</section>
|
|
76
|
+
</div>
|
|
77
|
+
<aside
|
|
78
|
+
className="hidden border-l pl-8 pt-14 lg:block"
|
|
79
|
+
aria-label="Feedback navigation"
|
|
80
|
+
>
|
|
81
|
+
<BoardNavigation search={search} onSearchChange={onSearchChange} />
|
|
82
|
+
<div className="mt-8">
|
|
83
|
+
<StatusNavigation search={search} onSearchChange={onSearchChange} />
|
|
84
|
+
</div>
|
|
85
|
+
</aside>
|
|
86
|
+
</div>
|
|
87
|
+
</main>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function PublicPostFeedSkeleton() {
|
|
92
|
+
return (
|
|
93
|
+
<main
|
|
94
|
+
className="feedback-index feedback-feed-skeleton"
|
|
95
|
+
aria-label="Loading Posts"
|
|
96
|
+
aria-busy="true"
|
|
97
|
+
>
|
|
98
|
+
<section className="feedback-intro" aria-hidden="true">
|
|
99
|
+
<Skeleton className="feedback-skeleton-heading" />
|
|
100
|
+
<Skeleton className="feedback-skeleton-deck" />
|
|
101
|
+
</section>
|
|
102
|
+
<div className="post-controls" aria-hidden="true">
|
|
103
|
+
<Skeleton className="feedback-skeleton-control" />
|
|
104
|
+
<Skeleton className="feedback-skeleton-control" />
|
|
105
|
+
<Skeleton className="feedback-skeleton-control" />
|
|
106
|
+
</div>
|
|
107
|
+
<section className="feedback-results" aria-hidden="true">
|
|
108
|
+
<Skeleton className="feedback-skeleton-section-heading" />
|
|
109
|
+
<ol className="feedback-list">
|
|
110
|
+
{Array.from({ length: 3 }, (_, index) => (
|
|
111
|
+
<li key={index} data-post-skeleton-row>
|
|
112
|
+
<div className="feedback-skeleton-row">
|
|
113
|
+
<Skeleton className="feedback-skeleton-meta" />
|
|
114
|
+
<Skeleton className="feedback-skeleton-title" />
|
|
115
|
+
<Skeleton className="feedback-skeleton-copy" />
|
|
116
|
+
</div>
|
|
117
|
+
</li>
|
|
118
|
+
))}
|
|
119
|
+
</ol>
|
|
120
|
+
</section>
|
|
121
|
+
<span className="sr-only">Loading Posts…</span>
|
|
122
|
+
</main>
|
|
123
|
+
);
|
|
124
|
+
}
|