@bygghammar/cms-platform 0.90.0-m7.9 → 0.91.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/CHANGELOG.md +241 -0
- package/README.md +23 -1
- package/docs/GOOGLE-SHEETS.md +145 -0
- package/docs/LAYOUT-BLOCKS.md +125 -0
- package/docs/MEDIA-FOLDERS.md +90 -0
- package/docs/NEW-SITE.md +35 -0
- package/docs/PAGES.md +452 -0
- package/docs/SCHEDULING.md +130 -0
- package/docs/TYPOGRAPHY.md +86 -0
- package/migrations/20260904090000_c54603f8-a155-40f8-a86c-54434aa5c0ae.sql +33 -0
- package/migrations/20260904140000_9f2a7c41-06d5-4d6b-8b4e-1c7a5d3e9b20.sql +383 -0
- package/migrations/20260904160000_0d565000-2839-4738-aacc-537d7090398f.sql +751 -0
- package/package.json +5 -1
- package/src/backoffice/DomainsView.tsx +152 -25
- package/src/backoffice/FormEditorView.tsx +10 -0
- package/src/backoffice/FormSettingsView.tsx +8 -0
- package/src/backoffice/MediaLibraryView.tsx +239 -21
- package/src/backoffice/NavigationView.tsx +43 -0
- package/src/backoffice/PageBuilderView.tsx +193 -36
- package/src/backoffice/PageEditorView.tsx +293 -57
- package/src/backoffice/PagesListView.tsx +294 -19
- package/src/backoffice/ScheduleFields.tsx +151 -0
- package/src/backoffice/SectionFormView.tsx +58 -0
- package/src/backoffice/StatusBadge.tsx +174 -0
- package/src/backoffice/TrackingView.tsx +247 -208
- package/src/backoffice/TypographyPanel.tsx +308 -0
- package/src/backoffice/forms/GoogleSheetsHelp.tsx +220 -0
- package/src/backoffice/inspectors/blocks.tsx +149 -0
- package/src/backoffice/inspectors/fields.tsx +81 -0
- package/src/backoffice/inspectors/index.tsx +3 -0
- package/src/backoffice/media/ConfirmDialog.tsx +64 -4
- package/src/backoffice/routes/host.ts +31 -0
- package/src/backoffice/routes/index.tsx +140 -15
- package/src/backoffice/routes/media-forms.tsx +5 -0
- package/src/backoffice/seo/PageSeoPanelView.tsx +31 -6
- package/src/backoffice/seo/page-seo-form.ts +11 -1
- package/src/backoffice/shell/domain-guard.ts +27 -14
- package/src/backoffice/shell/index.ts +5 -0
- package/src/backoffice/shell/use-resolved-config.tsx +67 -0
- package/src/backoffice/tracking-drafts.ts +150 -0
- package/src/blocks/block-children.tsx +40 -0
- package/src/blocks/block-tree.ts +378 -0
- package/src/blocks/components/IconGridBlock.tsx +76 -51
- package/src/blocks/components/LayoutBlock.tsx +100 -0
- package/src/blocks/countdown-time.ts +17 -87
- package/src/blocks/registry.tsx +21 -0
- package/src/blocks/types.ts +40 -0
- package/src/cms/page-publication.ts +377 -0
- package/src/cms/page-status.ts +152 -0
- package/src/cms/timezone.ts +181 -0
- package/src/cms/use-publication-boundary.ts +84 -0
- package/src/cms/use-schedule-clock.ts +43 -0
- package/src/cms/visibility-schedule.ts +267 -0
- package/src/config/modules.ts +34 -10
- package/src/config/site-config.ts +32 -0
- package/src/config/typography.ts +143 -0
- package/src/http/cache-policy.ts +49 -2
- package/src/media/folders.ts +183 -0
- package/src/media/use-media-upload.ts +21 -4
- package/src/navigation/index.ts +34 -2
- package/src/platform/contract.ts +52 -1
- package/src/runtime/server-entry.ts +8 -2
- package/src/server/content-sections-core.ts +16 -0
- package/src/server/domains-core.ts +124 -107
- package/src/server/forms-delivery-core.ts +61 -10
- package/src/server/forms-submissions-core.ts +47 -0
- package/src/server/google-service-account.ts +325 -0
- package/src/server/google-sheets-core.ts +463 -0
- package/src/server/http/sitemap.ts +32 -8
- package/src/server/injections-core.ts +230 -49
- package/src/server/media-library-core.ts +27 -6
- package/src/server/pages-core.ts +680 -184
- package/src/server/published-cache.ts +25 -4
- package/src/server/resolve-globals.ts +6 -1
- package/src/server/schema-drift.ts +4 -0
- package/src/server/schemas.ts +15 -0
- package/src/site/PageRenderer.tsx +45 -12
- package/src/site/SiteFooter.tsx +44 -3
- package/src/site/SiteHeader.tsx +23 -2
- package/src/site/google-fonts.ts +191 -0
- package/src/site/host.ts +172 -0
- package/src/site/locale-domains.ts +500 -0
- package/src/site/routes/public-page.tsx +12 -1
- package/src/site/routes.ts +206 -38
- package/src/site/site-url.ts +80 -17
- package/src/site/typography.tsx +75 -0
- package/src/tracking/injections.ts +52 -9
- package/src/ui/MobileCarousel.tsx +289 -0
|
@@ -14,6 +14,8 @@ export type { SiteFeatureFlags } from "./modules";
|
|
|
14
14
|
export { DEFAULT_FEATURE_FLAGS } from "./modules";
|
|
15
15
|
import type { SiteFeatureFlags } from "./modules";
|
|
16
16
|
import { CMS_MODULE_KEYS, DEFAULT_FEATURE_FLAGS } from "./modules";
|
|
17
|
+
import { DEFAULT_TIMEZONE, isValidTimezone } from "../cms/timezone";
|
|
18
|
+
import { EMPTY_TYPOGRAPHY, parseSiteTypography, type SiteTypography } from "./typography";
|
|
17
19
|
|
|
18
20
|
/** Official company / contact details. Locale-neutral by design. */
|
|
19
21
|
export type SiteCompany = {
|
|
@@ -87,6 +89,13 @@ export type SiteConfigInput = {
|
|
|
87
89
|
backofficePath?: string;
|
|
88
90
|
/** UI language of the backoffice itself. */
|
|
89
91
|
backofficeLocale?: string;
|
|
92
|
+
/**
|
|
93
|
+
* IANA timezone the site's editors think in, e.g. "Europe/Stockholm".
|
|
94
|
+
*
|
|
95
|
+
* Scheduled content is stored in UTC and displayed in this zone, so a
|
|
96
|
+
* publish time typed as "10 Sep 18:00" means 18:00 where the site is run.
|
|
97
|
+
*/
|
|
98
|
+
timezone?: string;
|
|
90
99
|
company?: Partial<SiteCompany>;
|
|
91
100
|
/** Logo artwork defaults; the backoffice may override these. */
|
|
92
101
|
branding?: Partial<SiteBranding>;
|
|
@@ -104,7 +113,11 @@ export type SiteConfig = {
|
|
|
104
113
|
defaultLocale: string;
|
|
105
114
|
backofficePath: string;
|
|
106
115
|
backofficeLocale: string;
|
|
116
|
+
/** IANA timezone used to display and enter scheduled times. */
|
|
117
|
+
timezone: string;
|
|
107
118
|
company: SiteCompany;
|
|
119
|
+
/** Menu and footer typography. Defaults keep the theme fonts. */
|
|
120
|
+
typography: SiteTypography;
|
|
108
121
|
branding: SiteBranding;
|
|
109
122
|
brand: SiteBrandTokens;
|
|
110
123
|
features: SiteFeatureFlags;
|
|
@@ -130,6 +143,8 @@ export function defineSiteConfig(input: SiteConfigInput): SiteConfig {
|
|
|
130
143
|
if (!backofficePath.startsWith("/") || backofficePath.endsWith("/")) {
|
|
131
144
|
fail(`backofficePath "${backofficePath}" must start with "/" and not end with one`);
|
|
132
145
|
}
|
|
146
|
+
const timezone = input.timezone?.trim() || DEFAULT_TIMEZONE;
|
|
147
|
+
if (!isValidTimezone(timezone)) fail(`timezone "${timezone}" is not a known IANA timezone`);
|
|
133
148
|
return {
|
|
134
149
|
key: input.key,
|
|
135
150
|
name: input.name.trim(),
|
|
@@ -138,7 +153,9 @@ export function defineSiteConfig(input: SiteConfigInput): SiteConfig {
|
|
|
138
153
|
defaultLocale: input.defaultLocale,
|
|
139
154
|
backofficePath,
|
|
140
155
|
backofficeLocale: input.backofficeLocale ?? "en",
|
|
156
|
+
timezone,
|
|
141
157
|
company: { ...EMPTY_COMPANY, ...(input.company ?? {}) },
|
|
158
|
+
typography: EMPTY_TYPOGRAPHY,
|
|
142
159
|
branding: { ...EMPTY_BRANDING, ...(input.branding ?? {}) },
|
|
143
160
|
brand: { ...(input.brand ?? {}) },
|
|
144
161
|
features: { ...DEFAULT_FEATURE_FLAGS, ...(input.features ?? {}) },
|
|
@@ -179,6 +196,7 @@ export const OVERRIDABLE_SETTING_KEYS = [
|
|
|
179
196
|
"branding",
|
|
180
197
|
"brand",
|
|
181
198
|
"features",
|
|
199
|
+
"typography",
|
|
182
200
|
] as const satisfies readonly (keyof SiteConfig)[];
|
|
183
201
|
|
|
184
202
|
export type OverridableSettingKey = (typeof OVERRIDABLE_SETTING_KEYS)[number];
|
|
@@ -190,6 +208,7 @@ export type SiteSettingOverrides = Partial<{
|
|
|
190
208
|
branding: Partial<SiteBranding>;
|
|
191
209
|
brand: SiteBrandTokens;
|
|
192
210
|
features: Partial<SiteFeatureFlags>;
|
|
211
|
+
typography: SiteTypography;
|
|
193
212
|
}>;
|
|
194
213
|
|
|
195
214
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
@@ -238,6 +257,7 @@ export function parseSiteSettingOverrides(raw: unknown): SiteSettingOverrides {
|
|
|
238
257
|
}
|
|
239
258
|
out.features = features;
|
|
240
259
|
}
|
|
260
|
+
if (isRecord(raw["typography"])) out.typography = parseSiteTypography(raw["typography"]);
|
|
241
261
|
return out;
|
|
242
262
|
}
|
|
243
263
|
|
|
@@ -251,6 +271,7 @@ export function resolveSiteConfig(base: SiteConfig, overrides: unknown): SiteCon
|
|
|
251
271
|
branding: { ...base.branding, ...(parsed.branding ?? {}) },
|
|
252
272
|
brand: { ...base.brand, ...(parsed.brand ?? {}) },
|
|
253
273
|
features: { ...base.features, ...(parsed.features ?? {}) },
|
|
274
|
+
typography: { ...base.typography, ...(parsed.typography ?? {}) },
|
|
254
275
|
};
|
|
255
276
|
}
|
|
256
277
|
|
|
@@ -273,6 +294,17 @@ export function registerSiteConfig(config: SiteConfig): SiteConfig {
|
|
|
273
294
|
return config;
|
|
274
295
|
}
|
|
275
296
|
|
|
297
|
+
/**
|
|
298
|
+
* The site's timezone, or the platform default when no config is registered.
|
|
299
|
+
*
|
|
300
|
+
* Scheduling controls need a zone but must not be the thing that crashes a
|
|
301
|
+
* screen: an unregistered config is a startup bug, not a reason for a date
|
|
302
|
+
* field to throw while an editor is typing in it.
|
|
303
|
+
*/
|
|
304
|
+
export function siteTimezone(): string {
|
|
305
|
+
return active?.timezone || DEFAULT_TIMEZONE;
|
|
306
|
+
}
|
|
307
|
+
|
|
276
308
|
/** Read the registered site config. Throws when a site forgot to register. */
|
|
277
309
|
export function siteConfig(): SiteConfig {
|
|
278
310
|
if (!active) {
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Menu and footer typography.
|
|
3
|
+
*
|
|
4
|
+
* Two independent choices — the top menu and the footer — each either keep the
|
|
5
|
+
* site's theme font (the default, and what every existing site already renders)
|
|
6
|
+
* or name a Google font family.
|
|
7
|
+
*
|
|
8
|
+
* Backwards compatibility is the point of the `"theme"` source: a site with no
|
|
9
|
+
* stored typography, or with a half-filled row, resolves to *no* font-family
|
|
10
|
+
* declaration at all, so the menu and footer keep inheriting exactly the fonts
|
|
11
|
+
* they inherit today. Upgrading changes nothing until an editor chooses a font.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Where a font comes from. `theme` means "leave the theme alone". */
|
|
15
|
+
export type FontSource = "theme" | "google";
|
|
16
|
+
|
|
17
|
+
/** Theme font tokens from `theme.css`, used as the fallback stack. */
|
|
18
|
+
export type ThemeFontToken = "sans" | "serif";
|
|
19
|
+
|
|
20
|
+
export type FontChoice = {
|
|
21
|
+
/** Defaults to `"theme"` — anything unrecognised is treated as `"theme"`. */
|
|
22
|
+
source?: FontSource;
|
|
23
|
+
/** Google font family, e.g. "DM Serif Display". Required when source is google. */
|
|
24
|
+
family?: string;
|
|
25
|
+
/** Weights to request. Defaults to 400 and 700. */
|
|
26
|
+
weights?: number[];
|
|
27
|
+
/** Theme token appended as the fallback stack behind a Google family. */
|
|
28
|
+
fallback?: ThemeFontToken;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type TypographySlot = "menu" | "footer";
|
|
32
|
+
|
|
33
|
+
/** Slots a site can restyle, and the theme token each one falls back to. */
|
|
34
|
+
export const TYPOGRAPHY_SLOTS: Record<TypographySlot, { label: string; fallback: ThemeFontToken }> =
|
|
35
|
+
{
|
|
36
|
+
menu: { label: "Menu", fallback: "sans" },
|
|
37
|
+
footer: { label: "Footer", fallback: "serif" },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type SiteTypography = Partial<Record<TypographySlot, FontChoice>>;
|
|
41
|
+
|
|
42
|
+
/** No overrides: every slot keeps the theme font. */
|
|
43
|
+
export const EMPTY_TYPOGRAPHY: SiteTypography = {};
|
|
44
|
+
|
|
45
|
+
/** The choice a slot starts from — explicitly "whatever the theme already does". */
|
|
46
|
+
export const THEME_FONT_CHOICE: FontChoice = { source: "theme" };
|
|
47
|
+
|
|
48
|
+
/** Weights requested when a font choice does not name any. */
|
|
49
|
+
export const DEFAULT_FONT_WEIGHTS = [400, 700] as const;
|
|
50
|
+
|
|
51
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
52
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A Google family name safe to put in a stylesheet URL and a CSS declaration.
|
|
57
|
+
* Letters, digits, spaces and a few punctuation marks only — no quotes, no
|
|
58
|
+
* semicolons, nothing that could break out of the `font-family` value.
|
|
59
|
+
*/
|
|
60
|
+
export function sanitizeFontFamily(raw: unknown): string {
|
|
61
|
+
if (typeof raw !== "string") return "";
|
|
62
|
+
return raw
|
|
63
|
+
.replace(/[^A-Za-z0-9 +\-_]/g, " ")
|
|
64
|
+
.replace(/\s+/g, " ")
|
|
65
|
+
.trim()
|
|
66
|
+
.slice(0, 60);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sanitizeWeights(raw: unknown): number[] | undefined {
|
|
70
|
+
if (!Array.isArray(raw)) return undefined;
|
|
71
|
+
const weights = Array.from(
|
|
72
|
+
new Set(
|
|
73
|
+
raw
|
|
74
|
+
.map((w) => (typeof w === "number" ? w : Number.parseInt(String(w), 10)))
|
|
75
|
+
.filter((w) => Number.isFinite(w) && w >= 100 && w <= 900 && w % 100 === 0),
|
|
76
|
+
),
|
|
77
|
+
).sort((a, b) => a - b);
|
|
78
|
+
return weights.length ? weights : undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Narrow one untrusted font choice. Anything unusable becomes the theme font. */
|
|
82
|
+
export function parseFontChoice(raw: unknown): FontChoice {
|
|
83
|
+
if (!isRecord(raw)) return { ...THEME_FONT_CHOICE };
|
|
84
|
+
const family = sanitizeFontFamily(raw["family"]);
|
|
85
|
+
const source: FontSource = raw["source"] === "google" && family ? "google" : "theme";
|
|
86
|
+
const out: FontChoice = { source };
|
|
87
|
+
if (family) out.family = family;
|
|
88
|
+
const weights = sanitizeWeights(raw["weights"]);
|
|
89
|
+
if (weights) out.weights = weights;
|
|
90
|
+
if (raw["fallback"] === "sans" || raw["fallback"] === "serif") {
|
|
91
|
+
out.fallback = raw["fallback"];
|
|
92
|
+
}
|
|
93
|
+
return out;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Narrow an untrusted typography payload from `site_settings`. */
|
|
97
|
+
export function parseSiteTypography(raw: unknown): SiteTypography {
|
|
98
|
+
if (!isRecord(raw)) return {};
|
|
99
|
+
const out: SiteTypography = {};
|
|
100
|
+
for (const slot of Object.keys(TYPOGRAPHY_SLOTS) as TypographySlot[]) {
|
|
101
|
+
if (raw[slot] !== undefined) out[slot] = parseFontChoice(raw[slot]);
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type ResolvedFont = {
|
|
107
|
+
/**
|
|
108
|
+
* The `font-family` value to apply, or `undefined` when the theme font should
|
|
109
|
+
* be left exactly as it is. `undefined` is the backwards-compatible answer.
|
|
110
|
+
*/
|
|
111
|
+
fontFamily?: string;
|
|
112
|
+
/** Google family to load, when one is configured. */
|
|
113
|
+
googleFamily?: string;
|
|
114
|
+
/** Weights to load with it. */
|
|
115
|
+
weights: number[];
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** Resolve one slot's choice into what the renderer and the loader need. */
|
|
119
|
+
export function resolveFontChoice(
|
|
120
|
+
choice: FontChoice | null | undefined,
|
|
121
|
+
slot: TypographySlot,
|
|
122
|
+
): ResolvedFont {
|
|
123
|
+
const weights = [...DEFAULT_FONT_WEIGHTS];
|
|
124
|
+
if (!choice || choice.source !== "google") return { weights };
|
|
125
|
+
const family = sanitizeFontFamily(choice.family);
|
|
126
|
+
if (!family) return { weights };
|
|
127
|
+
const fallback = choice.fallback ?? TYPOGRAPHY_SLOTS[slot].fallback;
|
|
128
|
+
return {
|
|
129
|
+
fontFamily: `"${family}", var(--font-${fallback})`,
|
|
130
|
+
googleFamily: family,
|
|
131
|
+
weights: choice.weights?.length ? [...choice.weights] : weights,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export type ResolvedTypography = Record<TypographySlot, ResolvedFont>;
|
|
136
|
+
|
|
137
|
+
/** Resolve every slot at once. */
|
|
138
|
+
export function resolveTypography(typography: SiteTypography | undefined): ResolvedTypography {
|
|
139
|
+
return {
|
|
140
|
+
menu: resolveFontChoice(typography?.menu, "menu"),
|
|
141
|
+
footer: resolveFontChoice(typography?.footer, "footer"),
|
|
142
|
+
};
|
|
143
|
+
}
|
package/src/http/cache-policy.ts
CHANGED
|
@@ -62,6 +62,44 @@ export function isVersionedMediaUrl(search: string | URLSearchParams): boolean {
|
|
|
62
62
|
return typeof token === "string" && token.length > 0;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/** Sitemaps and feeds: generated from data that changes on a schedule. */
|
|
66
|
+
export function isFeedPath(pathname: string): boolean {
|
|
67
|
+
return /^\/sitemap.*\.xml$/.test(pathname) || pathname === "/rss.xml";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The longest a feed is cached when nothing at all is scheduled to change. */
|
|
71
|
+
const FEED_MAX_AGE = 3600;
|
|
72
|
+
|
|
73
|
+
/** Still cacheable, but every hit revalidates — an ETag turns that into a 304. */
|
|
74
|
+
const FEED_REVALIDATE: CachePolicy = {
|
|
75
|
+
cacheControl: "public, max-age=0, must-revalidate",
|
|
76
|
+
expiresSeconds: 0,
|
|
77
|
+
kind: "feed",
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Caching for one sitemap or feed response, capped by the next publication
|
|
82
|
+
* boundary.
|
|
83
|
+
*
|
|
84
|
+
* `revalidateAt` is the instant the body stops being true — the earliest
|
|
85
|
+
* `publish_at` still to come or `unpublish_at` still to pass across every page
|
|
86
|
+
* in it, which `listPublishedRoutesCore` hands back with the keys. A cache may
|
|
87
|
+
* shorten that but never outlive it: a scheduled page missing from the sitemap
|
|
88
|
+
* for an hour after it went live is the same defect as publishing it an hour
|
|
89
|
+
* late. Null means nothing is scheduled, so the ordinary hour applies; a
|
|
90
|
+
* boundary already reached means revalidate now.
|
|
91
|
+
*/
|
|
92
|
+
export function feedCachePolicy(
|
|
93
|
+
revalidateAt?: number | null,
|
|
94
|
+
now: number = Date.now(),
|
|
95
|
+
): CachePolicy {
|
|
96
|
+
const budget =
|
|
97
|
+
revalidateAt == null ? FEED_MAX_AGE : Math.floor((revalidateAt - now) / 1000);
|
|
98
|
+
const maxAge = Math.max(0, Math.min(FEED_MAX_AGE, budget));
|
|
99
|
+
if (maxAge <= 0) return { ...FEED_REVALIDATE };
|
|
100
|
+
return { cacheControl: `public, max-age=${maxAge}`, expiresSeconds: maxAge, kind: "feed" };
|
|
101
|
+
}
|
|
102
|
+
|
|
65
103
|
/** Resolve the caching policy for one request path (+ query string). */
|
|
66
104
|
export function cachePolicyFor(pathname: string, search = ""): CachePolicy {
|
|
67
105
|
if (NO_STORE_PREFIXES.some((p) => pathname === p || pathname.startsWith(p))) {
|
|
@@ -95,8 +133,17 @@ export function cachePolicyFor(pathname: string, search = ""): CachePolicy {
|
|
|
95
133
|
};
|
|
96
134
|
}
|
|
97
135
|
|
|
98
|
-
if (
|
|
99
|
-
|
|
136
|
+
if (isFeedPath(pathname)) {
|
|
137
|
+
/*
|
|
138
|
+
* Deliberately not an hour. A sitemap is generated from publication
|
|
139
|
+
* windows, so its contents change on a schedule: an hour of caching can
|
|
140
|
+
* keep a page that went live at noon out of the sitemap until one, and keep
|
|
141
|
+
* a page that expired at noon in it just as long. The path alone cannot say
|
|
142
|
+
* when the next boundary is — only the handler that read the publications
|
|
143
|
+
* knows that, and it says so by calling `feedCachePolicy`. Everything else
|
|
144
|
+
* revalidates, which still costs one cheap 304 rather than a full body.
|
|
145
|
+
*/
|
|
146
|
+
return { ...FEED_REVALIDATE };
|
|
100
147
|
}
|
|
101
148
|
|
|
102
149
|
// Dynamic, server-rendered HTML: always revalidate, never immutable.
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media folders.
|
|
3
|
+
*
|
|
4
|
+
* Storage paths are plain `folder/name.ext` strings — no new table, no new
|
|
5
|
+
* storage API. Uploads default into a `YYYY-MM` folder so the bucket root does
|
|
6
|
+
* not grow without bound; a batch may instead be pinned to a named folder such
|
|
7
|
+
* as `2027-01-Cruice_1_Gallery`.
|
|
8
|
+
*
|
|
9
|
+
* Existing files keep their existing paths: nothing here moves or rewrites
|
|
10
|
+
* anything already uploaded, and `mediaUrl()` has always accepted a prefixed
|
|
11
|
+
* path, so old URLs keep resolving byte for byte.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { instantToZonedLocal } from "../cms/timezone";
|
|
15
|
+
|
|
16
|
+
/** Folders are shallow on purpose — deep trees make the browser unusable. */
|
|
17
|
+
export const MEDIA_FOLDER_MAX_SEGMENTS = 3;
|
|
18
|
+
export const MEDIA_FOLDER_MAX_SEGMENT_LENGTH = 64;
|
|
19
|
+
export const MEDIA_FOLDER_MAX_LENGTH = 160;
|
|
20
|
+
|
|
21
|
+
/** How a batch decides where its files land. */
|
|
22
|
+
export type MediaFolderMode = "auto" | "custom";
|
|
23
|
+
|
|
24
|
+
/** Thrown for folder input that cannot be made safe. */
|
|
25
|
+
export class MediaFolderError extends Error {
|
|
26
|
+
constructor(message: string) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "MediaFolderError";
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The automatic folder for an upload: `YYYY-MM` in the site timezone, so a
|
|
34
|
+
* batch uploaded at 00:30 on the first of the month lands where the editor
|
|
35
|
+
* expects it rather than in the previous month's UTC folder.
|
|
36
|
+
*/
|
|
37
|
+
export function monthFolder(now: Date | number = Date.now(), timezone?: string | null): string {
|
|
38
|
+
const instant = typeof now === "number" ? now : now.getTime();
|
|
39
|
+
if (!Number.isFinite(instant)) return "unknown";
|
|
40
|
+
return instantToZonedLocal(instant, timezone).slice(0, 7);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** True for a `YYYY-MM` folder — the ones the library groups by date. */
|
|
44
|
+
export function isMonthFolder(folder: string): boolean {
|
|
45
|
+
return /^\d{4}-(?:0[1-9]|1[0-2])$/.test(folder);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* One path segment, made safe for any object store.
|
|
50
|
+
*
|
|
51
|
+
* Case is preserved (`Cruice_1_Gallery` stays readable) but everything that
|
|
52
|
+
* could escape the bucket or confuse a storage backend is removed: separators,
|
|
53
|
+
* control characters, leading/trailing dots and whitespace, and the `.`/`..`
|
|
54
|
+
* segments that path traversal relies on. Returns `""` when nothing usable is
|
|
55
|
+
* left, which callers treat as invalid rather than as "the bucket root".
|
|
56
|
+
*/
|
|
57
|
+
export function sanitizeFolderSegment(input: string): string {
|
|
58
|
+
const cleaned = input
|
|
59
|
+
.normalize("NFD")
|
|
60
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
61
|
+
// Whitespace becomes a separator before the remaining control characters
|
|
62
|
+
// are dropped, so a pasted line break separates words instead of gluing
|
|
63
|
+
// them together.
|
|
64
|
+
.replace(/\s+/g, "-")
|
|
65
|
+
.replace(/[\u0000-\u001f\u007f]/g, "")
|
|
66
|
+
.replace(/[^A-Za-z0-9._-]/g, "-")
|
|
67
|
+
.replace(/-{2,}/g, "-")
|
|
68
|
+
.replace(/^[.\-]+/, "")
|
|
69
|
+
.replace(/[.\-]+$/, "")
|
|
70
|
+
.slice(0, MEDIA_FOLDER_MAX_SEGMENT_LENGTH);
|
|
71
|
+
if (cleaned === "." || cleaned === "..") return "";
|
|
72
|
+
return cleaned;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Sanitize a whole folder path. Returns `""` when the input is empty or has no
|
|
77
|
+
* usable segment left — callers decide whether that means "use the automatic
|
|
78
|
+
* folder" or "reject". Never returns a leading `/`, a `..` segment, a drive
|
|
79
|
+
* letter, a protocol, or more than {@link MEDIA_FOLDER_MAX_SEGMENTS} levels.
|
|
80
|
+
*/
|
|
81
|
+
export function sanitizeMediaFolder(input: string | null | undefined): string {
|
|
82
|
+
if (typeof input !== "string") return "";
|
|
83
|
+
const segments = input
|
|
84
|
+
.replace(/\\/g, "/")
|
|
85
|
+
.split("/")
|
|
86
|
+
.map(sanitizeFolderSegment)
|
|
87
|
+
.filter(Boolean)
|
|
88
|
+
.slice(0, MEDIA_FOLDER_MAX_SEGMENTS);
|
|
89
|
+
return segments.join("/").slice(0, MEDIA_FOLDER_MAX_LENGTH).replace(/\/+$/, "");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Sanitize a folder the editor typed, refusing input that cannot be honoured.
|
|
94
|
+
* Silently uploading somewhere other than the folder that was asked for is
|
|
95
|
+
* worse than an error message.
|
|
96
|
+
*/
|
|
97
|
+
export function assertMediaFolder(input: string | null | undefined): string {
|
|
98
|
+
const raw = typeof input === "string" ? input.trim() : "";
|
|
99
|
+
if (!raw) return "";
|
|
100
|
+
// A `..` is refused rather than rewritten. "../../gallery" sanitizes to the
|
|
101
|
+
// perfectly safe folder "gallery", but uploading there is not what was asked
|
|
102
|
+
// for, and a traversal attempt deserves an answer rather than a shrug.
|
|
103
|
+
if (/(?:^|\/)\.\.(?:\/|$)/.test(raw.replace(/\\/g, "/"))) {
|
|
104
|
+
throw new MediaFolderError(
|
|
105
|
+
`"${raw}" is not a usable folder name: a folder path cannot contain "..".`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
const safe = sanitizeMediaFolder(raw);
|
|
109
|
+
if (!safe) {
|
|
110
|
+
throw new MediaFolderError(
|
|
111
|
+
`"${raw}" is not a usable folder name. Use letters, numbers, dashes, dots or underscores.`,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return safe;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Everything before the last `/`, or `""` for a file at the bucket root. */
|
|
118
|
+
export function mediaFolderOf(path: string): string {
|
|
119
|
+
const at = path.lastIndexOf("/");
|
|
120
|
+
return at === -1 ? "" : path.slice(0, at);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Everything after the last `/`. */
|
|
124
|
+
export function mediaBaseName(path: string): string {
|
|
125
|
+
const at = path.lastIndexOf("/");
|
|
126
|
+
return at === -1 ? path : path.slice(at + 1);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Join an already sanitized folder and a file name into a storage path. */
|
|
130
|
+
export function joinMediaPath(folder: string, name: string): string {
|
|
131
|
+
const dir = folder.replace(/^\/+|\/+$/g, "");
|
|
132
|
+
const file = name.replace(/^\/+/, "");
|
|
133
|
+
return dir ? `${dir}/${file}` : file;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Label for the folder pickers — the root has to be called something. */
|
|
137
|
+
export const ROOT_FOLDER_LABEL = "Library root";
|
|
138
|
+
|
|
139
|
+
export function folderLabel(folder: string): string {
|
|
140
|
+
return folder === "" ? ROOT_FOLDER_LABEL : folder;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export type MediaFolderSummary = {
|
|
144
|
+
/** `""` for the bucket root. */
|
|
145
|
+
folder: string;
|
|
146
|
+
label: string;
|
|
147
|
+
count: number;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Folder list for the browser: month folders newest first, then named folders
|
|
152
|
+
* alphabetically, then the root. Cheap enough to run on every render — one
|
|
153
|
+
* pass over paths the host already loaded.
|
|
154
|
+
*/
|
|
155
|
+
export function summarizeMediaFolders(paths: Iterable<string>): MediaFolderSummary[] {
|
|
156
|
+
const counts = new Map<string, number>();
|
|
157
|
+
for (const path of paths) {
|
|
158
|
+
const folder = mediaFolderOf(path);
|
|
159
|
+
counts.set(folder, (counts.get(folder) ?? 0) + 1);
|
|
160
|
+
}
|
|
161
|
+
return [...counts.entries()]
|
|
162
|
+
.map(([folder, count]) => ({ folder, label: folderLabel(folder), count }))
|
|
163
|
+
.sort((a, b) => {
|
|
164
|
+
if (a.folder === b.folder) return 0;
|
|
165
|
+
if (a.folder === "") return 1;
|
|
166
|
+
if (b.folder === "") return -1;
|
|
167
|
+
const aMonth = isMonthFolder(a.folder);
|
|
168
|
+
const bMonth = isMonthFolder(b.folder);
|
|
169
|
+
if (aMonth !== bMonth) return aMonth ? -1 : 1;
|
|
170
|
+
return aMonth ? b.folder.localeCompare(a.folder) : a.folder.localeCompare(b.folder);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** True when `path` sits in `folder` itself, not in one of its subfolders. */
|
|
175
|
+
export function isInFolder(path: string, folder: string): boolean {
|
|
176
|
+
return mediaFolderOf(path) === folder;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** True when `path` sits in `folder` or anywhere below it. */
|
|
180
|
+
export function isUnderFolder(path: string, folder: string): boolean {
|
|
181
|
+
if (folder === "") return true;
|
|
182
|
+
return path === folder || path.startsWith(`${folder}/`);
|
|
183
|
+
}
|
|
@@ -4,6 +4,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
|
|
4
4
|
import { optimizeForUpload } from "./optimize";
|
|
5
5
|
import { pickVideoPoster } from "./pick-video-poster";
|
|
6
6
|
import { cleanFileName, splitExt, uniqueFileName } from "./filename";
|
|
7
|
+
import { mediaBaseName } from "./folders";
|
|
7
8
|
|
|
8
9
|
export type UploadProgress = { label: string; percent: number } | null;
|
|
9
10
|
|
|
@@ -14,9 +15,15 @@ export type UploadExistingItem = { name: string };
|
|
|
14
15
|
export type UploadBundleFn = (input: {
|
|
15
16
|
data: {
|
|
16
17
|
files: Array<{ name: string; contentType: string; bytes: number[] }>;
|
|
18
|
+
/** Destination folder; omitted means the automatic `YYYY-MM` folder. */
|
|
19
|
+
folder?: string;
|
|
20
|
+
timezone?: string;
|
|
17
21
|
};
|
|
18
22
|
}) => Promise<{ urls: string[] }>;
|
|
19
23
|
|
|
24
|
+
/** Where a batch should land. Omitted folder = the automatic month folder. */
|
|
25
|
+
export type UploadOptions = { folder?: string; timezone?: string };
|
|
26
|
+
|
|
20
27
|
/**
|
|
21
28
|
* Shared browser-side media upload (optimize → storage → cache invalidate)
|
|
22
29
|
* with a coarse percentage so callers can render a progress bar.
|
|
@@ -26,6 +33,7 @@ export type UploadBundleFn = (input: {
|
|
|
26
33
|
export function useMediaUploadWith(
|
|
27
34
|
uploadBundle: UploadBundleFn,
|
|
28
35
|
existing: UploadExistingItem[] = [],
|
|
36
|
+
options: UploadOptions = {},
|
|
29
37
|
) {
|
|
30
38
|
const queryClient = useQueryClient();
|
|
31
39
|
const [progress, setProgress] = useState<UploadProgress>(null);
|
|
@@ -37,10 +45,13 @@ export function useMediaUploadWith(
|
|
|
37
45
|
if (!arr.length) return [];
|
|
38
46
|
setUploading(true);
|
|
39
47
|
const urls: string[] = [];
|
|
48
|
+
// File names are compared without their folder: the same name in two
|
|
49
|
+
// folders is fine, and the server de-duplicates within the destination.
|
|
40
50
|
const taken = new Set<string>(
|
|
41
51
|
existing.flatMap((i) => {
|
|
42
|
-
const
|
|
43
|
-
|
|
52
|
+
const name = mediaBaseName(i.name);
|
|
53
|
+
const { base } = splitExt(name);
|
|
54
|
+
return [name, base, base.replace(/-(?:480|768|1200|1920)$/, "")];
|
|
44
55
|
}),
|
|
45
56
|
);
|
|
46
57
|
try {
|
|
@@ -72,7 +83,13 @@ export function useMediaUploadWith(
|
|
|
72
83
|
bytes: Array.from(new Uint8Array(await part.blob.arrayBuffer())),
|
|
73
84
|
})),
|
|
74
85
|
);
|
|
75
|
-
const { urls: uploaded } = await uploadBundle({
|
|
86
|
+
const { urls: uploaded } = await uploadBundle({
|
|
87
|
+
data: {
|
|
88
|
+
files: payload,
|
|
89
|
+
...(options.folder ? { folder: options.folder } : {}),
|
|
90
|
+
...(options.timezone ? { timezone: options.timezone } : {}),
|
|
91
|
+
},
|
|
92
|
+
});
|
|
76
93
|
urls.push(uploaded[0]!);
|
|
77
94
|
step(1, "Uploading");
|
|
78
95
|
}
|
|
@@ -83,7 +100,7 @@ export function useMediaUploadWith(
|
|
|
83
100
|
setProgress(null);
|
|
84
101
|
}
|
|
85
102
|
},
|
|
86
|
-
[existing, queryClient, uploadBundle],
|
|
103
|
+
[existing, options.folder, options.timezone, queryClient, uploadBundle],
|
|
87
104
|
);
|
|
88
105
|
|
|
89
106
|
return { upload, uploading, progress };
|
package/src/navigation/index.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ComponentType } from "react";
|
|
2
2
|
|
|
3
3
|
import { hasText, resolvedHref } from "../blocks/optional-content";
|
|
4
|
+
import { isVisibleAt, type VisibilitySchedule } from "../cms/visibility-schedule";
|
|
4
5
|
import type { Locale } from "../i18n/locales";
|
|
5
6
|
import type { LocalizedString } from "../i18n/types";
|
|
6
7
|
|
|
@@ -8,6 +9,11 @@ export type NavigationItem = {
|
|
|
8
9
|
label: LocalizedString;
|
|
9
10
|
href: string;
|
|
10
11
|
children?: NavigationItem[];
|
|
12
|
+
/**
|
|
13
|
+
* Optional visibility window. Absent means "always visible", so every menu
|
|
14
|
+
* written before scheduling existed behaves exactly as it did.
|
|
15
|
+
*/
|
|
16
|
+
schedule?: VisibilitySchedule;
|
|
11
17
|
};
|
|
12
18
|
|
|
13
19
|
export type BackofficeNavigationItem<TIconProps = { className?: string }> = {
|
|
@@ -52,19 +58,45 @@ export function navigationPanelId(href: string): string {
|
|
|
52
58
|
* only in Swedish still shows in English through the usual fallback, while one
|
|
53
59
|
* deliberately blanked for English is absent there.
|
|
54
60
|
*
|
|
61
|
+
* An entry may also carry a visibility window (`schedule`). Outside that window
|
|
62
|
+
* it is dropped here, which is the only place menu visibility is decided — so a
|
|
63
|
+
* hidden entry is absent from the desktop bar, the mobile panel and any other
|
|
64
|
+
* consumer at the same instant. `now` is a parameter rather than a call to
|
|
65
|
+
* `Date.now()` inside, so callers control the clock and every boundary is
|
|
66
|
+
* testable; a caller that renders over time re-renders on the next boundary
|
|
67
|
+
* (see `useScheduleClock`) rather than trusting a cached list.
|
|
68
|
+
*
|
|
55
69
|
* Order is preserved exactly, so what the editor arranged is what renders.
|
|
56
70
|
*/
|
|
57
71
|
export function renderableNavigation(
|
|
58
72
|
items: readonly NavigationItem[] | undefined,
|
|
59
73
|
locale: Locale,
|
|
74
|
+
now: number | Date = Date.now(),
|
|
60
75
|
): NavigationItem[] {
|
|
61
76
|
return (items ?? [])
|
|
62
|
-
.filter(
|
|
77
|
+
.filter(
|
|
78
|
+
(item) =>
|
|
79
|
+
hasText(item.label, locale) &&
|
|
80
|
+
resolvedHref(item.href) !== "" &&
|
|
81
|
+
isVisibleAt(item.schedule, now),
|
|
82
|
+
)
|
|
63
83
|
.map((item) => {
|
|
64
84
|
const href = resolvedHref(item.href);
|
|
65
|
-
const children = renderableNavigation(item.children, locale);
|
|
85
|
+
const children = renderableNavigation(item.children, locale, now);
|
|
66
86
|
return children.length > 0
|
|
67
87
|
? { ...item, href, children }
|
|
68
88
|
: { ...item, href, children: undefined };
|
|
69
89
|
});
|
|
70
90
|
}
|
|
91
|
+
|
|
92
|
+
/** Every schedule in a navigation tree, for cache and re-render planning. */
|
|
93
|
+
export function navigationSchedules(
|
|
94
|
+
items: readonly NavigationItem[] | undefined,
|
|
95
|
+
): VisibilitySchedule[] {
|
|
96
|
+
const out: VisibilitySchedule[] = [];
|
|
97
|
+
for (const item of items ?? []) {
|
|
98
|
+
if (item.schedule) out.push(item.schedule);
|
|
99
|
+
out.push(...navigationSchedules(item.children));
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|