Repository navigation
Expand file tree
/
Copy pathnext.config.ts
More file actions
159 lines (153 loc) · 6.67 KB
/
Copy pathnext.config.ts
File metadata and controls
159 lines (153 loc) · 6.67 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
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
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
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
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
import type { NextConfig } from "next";
import {
OPTIMIZED_IMAGE_HOSTS,
WORKSPACE_LOGO_HOSTS,
} from "./src/lib/image-hosts";
import { LEGACY_REDIRECTS } from "./src/lib/redirects";
// Mintlify docs are hosted at assembly-ff8b9417.mintlify.site and proxied
// under /docs so they appear to live on this domain (Mintlify's
// subdirectory custom-domain setup: https://mintlify.com/docs/settings/custom-domain).
const MINTLIFY_SITE = "https://assembly-ff8b9417.mintlify.site";
const isProduction = process.env.VERCEL_ENV === "production";
// Third-party origins inventoried during the analytics migration from
// web-presence-js (Aug 2026). Deployed as Report-Only first — switch to the
// enforcing header once staging shows no violations.
const cspDirectives = [
"default-src 'self'",
// 'unsafe-inline' covers the theme-init, auth-init, and font-swap scripts
// in layout.tsx that run as dangerouslySetInnerHTML. Segment and GTM are
// loaded via next/script which adds its own <script> elements at runtime.
"script-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://*.googletagmanager.com https://*.google-analytics.com https://cdn.segment.com https://*.segment.io https://copilotplatforms.chilipiper.com",
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
// The workspace logo hosts are /powered-by's firm logos, listed so switching
// this header to enforcing doesn't blank every one. See WORKSPACE_LOGO_HOSTS.
`img-src 'self' data: blob: https://images.ctfassets.net https://storage.ghost.io https://images.unsplash.com https://www.googletagmanager.com https://*.google-analytics.com ${WORKSPACE_LOGO_HOSTS.map((host) => `https://${host}`).join(" ")}`,
"font-src 'self' data: https://fonts.gstatic.com",
"connect-src 'self' https://*.google-analytics.com https://*.googletagmanager.com https://cdn.segment.com https://api.segment.io https://*.customer.io https://copilotplatforms.chilipiper.com https://graphql.contentful.com https://storage.ghost.io",
"frame-src 'self' https://www.youtube.com https://copilotplatforms.chilipiper.com",
"media-src 'self' data:",
"object-src 'none'",
"base-uri 'self'",
"form-action 'self' https://copilotplatforms.chilipiper.com",
"frame-ancestors 'none'",
].join("; ");
const securityHeaders = [
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
{
key: "Permissions-Policy",
value: "camera=(), microphone=(), geolocation=()",
},
{ key: "Content-Security-Policy-Report-Only", value: cspDirectives },
];
const nextConfig: NextConfig = {
// The changelog's entries are read off disk at request time, and Next only
// ships the files it can see being imported. Naming the directory puts it in
// the bundle for the three routes that read it; without this /updates is a
// 500 on Vercel and works fine locally, which is the worst way to find out.
// The imagery goes with them on the two routes that render an entry: those
// read each screenshot's dimensions off the file so the browser knows the
// aspect ratio before the bytes arrive, and public/ is served from the CDN
// rather than mounted in the function.
outputFileTracingIncludes: {
"/updates": ["./src/content/updates/**", "./public/images/updates/**"],
"/updates/[slug]": [
"./src/content/updates/**",
"./public/images/updates/**",
],
"/sitemap-updates.xml": ["./src/content/updates/**"],
},
// Read at BUILD time and inlined, which is the whole point: it dates the
// hand-shipped pages in the sitemaps. A static page changes when the site is
// deployed and at no other moment, so "now" at build is its real lastmod —
// and reading the clock at request time would report a date on which nothing
// happened.
env: {
BUILD_TIME: new Date().toISOString(),
},
// Screenshots are text-dense, so the default quality (75) reads as blurry.
// Whitelist higher steps (Next 16 requires listing any non-default value).
images: {
qualities: [75, 90, 100],
// Template screenshots uploaded to Contentful are served from its CDN.
remotePatterns: OPTIMIZED_IMAGE_HOSTS.map((hostname) => ({
protocol: "https" as const,
hostname,
})),
},
// The embeds listing moved up from /embeds/directory to /embeds, and an
// embed's own page from /embeds/directory/{slug} to /embed/{slug}. Permanent,
// so anything already linking or indexing the old shape hands its ranking to
// the new one rather than dropping a 404.
async redirects() {
return [
{
source: "/embeds/directory",
destination: "/embeds",
permanent: true,
},
{
source: "/embeds/directory/:slug",
destination: "/embed/:slug",
permanent: true,
},
// The badge page was built at /built-on and moved to /powered-by, the
// path the growth-loops PRD and the product's badge link use. Only ever
// linked from staging, so temporary: a 308 would be cached hard for a
// path nobody outside the team has.
{
source: "/built-on",
destination: "/powered-by",
permanent: false,
},
// Everything the previous assembly.com site published. Listed last so the
// two rules above keep precedence if the table ever grows a row that
// overlaps them.
...LEGACY_REDIRECTS,
];
},
async rewrites() {
return [
{
source: "/docs",
destination: `${MINTLIFY_SITE}/docs`,
},
{
source: "/docs/:path*",
destination: `${MINTLIFY_SITE}/docs/:path*`,
},
];
},
async headers() {
return [
{
// The icons are immutable in practice and change only when the brand
// does. Out of public/ they otherwise carry max-age=0, so every
// navigation revalidates the favicon and the swap gets a window to be
// seen in. A day, not a year: a stale favicon is unusually annoying to
// flush, since browsers cache it outside the normal HTTP cache.
source: "/favicon.:ext(ico|svg)",
headers: [
{
key: "Cache-Control",
value: "public, max-age=86400, stale-while-revalidate=604800",
},
],
},
{
source: "/:path*",
headers: [
...securityHeaders,
// Keep preview/staging deployments out of the index so they can't
// compete with the production host. The production alias
// (VERCEL_ENV=production) is left indexable.
...(isProduction
? []
: [{ key: "X-Robots-Tag", value: "noindex, nofollow" }]),
],
},
];
},
};
export default nextConfig;