mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-30 00:27:43 +09:00
Closes https://github.com/webstudio-is/webstudio/issues/3510 ## Summary This PR adds a **Current Date** system resource, extends the `system` object with a `pathname` property, and enhances the **Time component** with custom date formatting capabilities to provide more flexibility and prevent hydration errors. ### Key Features 1. **Current Date System Resource** - Provides normalized date/time information that prevents React hydration errors 2. **System Pathname** - Adds `pathname` property directly to the `system` object for easy access in expressions 3. **Time Component Custom Formatting** - Adds a `format` prop with template tokens for flexible date/time display ## Motivation These changes solve important use cases: - **Current Date**: Prevents React hydration errors when displaying dynamic dates (e.g., copyright years in footers). Previously, using `new Date()` in expressions would cause server/client mismatch. - **System Pathname**: Provides easy access to the current page path in expressions without needing to parse the URL manually. - **Custom Time Formatting**: Allows users to format dates with simple templates (e.g., `YYYY-MM-DD`) without being limited to `Intl.DateTimeFormat` options, and provides a simple way to exclude specific date or time components to avoid hydration issues. ## Changes ### Current Date Resource - Created [`apps/builder/app/shared/$resources/current-date.server.ts`](apps/builder/app/shared/$resources/current-date.server.ts) - Provides date fields: `year`, `month`, `day`, `iso`, `timestamp` - All values normalized to midnight UTC (00:00:00.000Z) to prevent hydration errors - Registered in [`rest.resources-loader.ts`](apps/builder/app/routes/rest.resources-loader.ts) - Added to SystemResourceForm dropdown in UI ### System Pathname - Added `pathname` property to [`System`](packages/sdk/src/schema/pages.ts) type - Updated [`system.ts`](apps/builder/app/shared/system.ts) to compute and include pathname - Normalized empty pathname to `'/'` for consistency - Available in expressions as `system.pathname` ### Time Component Enhancements - Added [`formatDate()`](packages/sdk-components-react/src/time.tsx:353) function supporting template tokens: - `YYYY`, `YY` - year (4-digit, 2-digit) - `MM`, `M` - month (zero-padded, no padding) - `DD`, `D` - day (zero-padded, no padding) - `HH`, `H` - hours (zero-padded, no padding) - `mm`, `m` - minutes (zero-padded, no padding) - `ss`, `s` - seconds (zero-padded, no padding) - Added `format` prop to [`Time`](packages/sdk-components-react/src/time.tsx:386) component that overrides `dateStyle`/`timeStyle` when provided - Updated component logic to check for `format` prop first before falling back to `Intl.DateTimeFormat` - Added comprehensive tests for date formatting in [`time.test.ts`](packages/sdk-components-react/src/time.test.ts) - Updated component metadata in [`time.ws.ts`](packages/sdk-components-react/src/time.ws.ts) to include `format` in initialProps - Example: `format="YYYY-MM-DD HH:mm:ss"` displays as "2025-11-03 18:47:25" ### Template & Fixture Updates - Updated all CLI templates (defaults, react-router, SSG) to include `pathname` in system objects - Updated all fixture files (47 files total) to include `pathname` in system objects - All 694 tests passing - All typecheck passing ## Usage ### Current Date Resource Users can now add the Current Date resource from the Data Variables panel: 1. Click + next to Data Variables 2. Select "System Resource" 3. Choose "Current Date" from the dropdown 4. Access in expressions: - `date.year` - for copyright year - `date.month` - current month (1-12) - `date.day` - current day (1-31) - `date.iso` - ISO 8601 string - `date.timestamp` - Unix timestamp ### System Pathname Access the current page path directly: - `system.pathname` - current page path (e.g., `/about`, `/blog/post-1`) ### Time Component Custom Format Use the `format` prop for custom date/time display: ```jsx <Time datetime="2025-11-03T18:47:25Z" format="YYYY-MM-DD" /> // Displays: 2025-11-03 <Time datetime="2025-11-03T18:47:25Z" format="YYYY-MM-DD HH:mm:ss" /> // Displays: 2025-11-03 18:47:25 <Time datetime="2025-11-03T18:47:25Z" format="M/D/YYYY" /> // Displays: 11/3/2025 ``` Technical Notes - Current Date values are normalized to midnight UTC to ensure consistency throughout the day and prevent hydration mismatches - The pathname property is computed from the request URL and normalized to '/' when empty - Custom format templates in the Time component are processed before falling back to Intl.DateTimeFormat - All changes are backward compatible
95 lines
2.9 KiB
TypeScript
95 lines
2.9 KiB
TypeScript
import { atom, computed } from "nanostores";
|
|
import { findPageByIdOrPath, type Page, type System } from "@webstudio-is/sdk";
|
|
import {
|
|
compilePathnamePattern,
|
|
matchPathnamePattern,
|
|
tokenizePathnamePattern,
|
|
} from "~/builder/shared/url-pattern";
|
|
import { $selectedPage } from "./awareness";
|
|
import { $pages, $publishedOrigin } from "./nano-states";
|
|
import { serverSyncStore } from "./sync";
|
|
|
|
export const $systemDataByPage = atom(
|
|
new Map<Page["id"], Pick<System, "search" | "params">>()
|
|
);
|
|
|
|
const extractParams = (pattern: string, path?: string) => {
|
|
const params: System["params"] = {};
|
|
const tokens = tokenizePathnamePattern(pattern);
|
|
// try to match the first item in history to let user
|
|
// see the page without manually entering params
|
|
// or selecting them in address bar
|
|
const matchedParams = path ? matchPathnamePattern(pattern, path) : undefined;
|
|
for (const token of tokens) {
|
|
if (token.type === "param") {
|
|
params[token.name] = matchedParams?.[token.name] ?? undefined;
|
|
}
|
|
}
|
|
return params;
|
|
};
|
|
|
|
export const $currentSystem = computed(
|
|
[$publishedOrigin, $selectedPage, $systemDataByPage],
|
|
(origin, page, systemByPage) => {
|
|
const system: System = {
|
|
search: {},
|
|
params: {},
|
|
pathname: "/",
|
|
origin,
|
|
};
|
|
if (page === undefined) {
|
|
return system;
|
|
}
|
|
const systemData = systemByPage.get(page.id);
|
|
const extractedParams = extractParams(page.path, page.history?.[0]);
|
|
const params = { ...extractedParams, ...systemData?.params };
|
|
const pathname = compilePath(page.path, params) || "/";
|
|
return {
|
|
search: { ...system.search, ...systemData?.search },
|
|
params,
|
|
pathname,
|
|
origin,
|
|
};
|
|
}
|
|
);
|
|
|
|
const compilePath = (pattern: string, params: System["params"]) => {
|
|
const tokens = tokenizePathnamePattern(pattern);
|
|
return compilePathnamePattern(tokens, params);
|
|
};
|
|
|
|
/**
|
|
* put new path into the beginning of history
|
|
* and drop paths in the end when exceeded 20
|
|
*/
|
|
const savePathInHistory = (pageId: string, path: string) => {
|
|
serverSyncStore.createTransaction([$pages], (pages) => {
|
|
if (pages === undefined) {
|
|
return;
|
|
}
|
|
const page = findPageByIdOrPath(pageId, pages);
|
|
if (page === undefined) {
|
|
return;
|
|
}
|
|
const history = Array.from(page.history ?? []);
|
|
history.unshift(path);
|
|
page.history = Array.from(new Set(history)).slice(0, 20);
|
|
});
|
|
};
|
|
|
|
export const updateCurrentSystem = (
|
|
update: Partial<Pick<System, "search" | "params">>
|
|
) => {
|
|
const page = $selectedPage.get();
|
|
if (page === undefined) {
|
|
return;
|
|
}
|
|
const systemDataByPage = new Map($systemDataByPage.get());
|
|
const systemData = systemDataByPage.get(page.id);
|
|
const search = update.search ?? systemData?.search ?? {};
|
|
const params = update.params ?? systemData?.params ?? {};
|
|
systemDataByPage.set(page.id, { search, params });
|
|
$systemDataByPage.set(systemDataByPage);
|
|
savePathInHistory(page.id, compilePath(page.path, params));
|
|
};
|