Skip to content

Commit 1f2e7a2

Browse files
authored
feat: add Current Date resource, system.pathname, and Time component custom formatting (#5434)
Closes #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
1 parent 2f1fb25 commit 1f2e7a2

File tree

47 files changed

+232
-10
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+232
-10
lines changed

apps/builder/app/builder/features/pages/page-utils.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ registerContainers();
3737
const initialSystem = {
3838
origin: "https://undefined.wstd.work",
3939
params: {},
40+
pathname: "/",
4041
search: {},
4142
};
4243

@@ -586,6 +587,7 @@ test("page root scope should provide page system variable value", () => {
586587
$ws$dataSource$systemId: {
587588
origin: "https://undefined.wstd.work",
588589
params: {},
590+
pathname: "/",
589591
search: {},
590592
},
591593
},
@@ -594,6 +596,7 @@ test("page root scope should provide page system variable value", () => {
594596
"systemId",
595597
{
596598
params: {},
599+
pathname: "/",
597600
search: {},
598601
origin: "https://undefined.wstd.work",
599602
},
@@ -608,6 +611,7 @@ test("page root scope should provide page system variable value", () => {
608611
scope: {
609612
$ws$dataSource$systemId: {
610613
params: { slug: "my-post" },
614+
pathname: "/",
611615
search: {},
612616
origin: "https://undefined.wstd.work",
613617
},
@@ -617,6 +621,7 @@ test("page root scope should provide page system variable value", () => {
617621
"systemId",
618622
{
619623
params: { slug: "my-post" },
624+
pathname: "/",
620625
search: {},
621626
origin: "https://undefined.wstd.work",
622627
},

apps/builder/app/builder/features/settings-panel/resource-panel.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@ import {
2525
SYSTEM_VARIABLE_ID,
2626
systemParameter,
2727
} from "@webstudio-is/sdk";
28-
import { serializeValue, sitemapResourceUrl } from "@webstudio-is/sdk/runtime";
28+
import {
29+
serializeValue,
30+
sitemapResourceUrl,
31+
currentDateResourceUrl,
32+
} from "@webstudio-is/sdk/runtime";
2933
import {
3034
Box,
3135
Flex,
@@ -989,6 +993,12 @@ export const SystemResourceForm = forwardRef<
989993
value: JSON.stringify(sitemapResourceUrl),
990994
description: "Resource that loads the sitemap data of the current site.",
991995
},
996+
{
997+
label: "Current Date",
998+
value: JSON.stringify(currentDateResourceUrl),
999+
description:
1000+
"Provides current date information (year, month, day) normalized to midnight UTC. Time components are set to 00:00:00 to prevent React hydration errors.",
1001+
},
9921002
];
9931003

9941004
const [localResource, setLocalResource] = useState(() => {

apps/builder/app/routes/rest.resources-loader.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { type ActionFunctionArgs, data } from "@remix-run/server-runtime";
33
import { ResourceRequest } from "@webstudio-is/sdk";
44
import { isLocalResource, loadResource } from "@webstudio-is/sdk/runtime";
55
import { loader as siteMapLoader } from "../shared/$resources/sitemap.xml.server";
6+
import { loader as currentDateLoader } from "../shared/$resources/current-date.server";
67
import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
78
import { checkCsrf } from "~/services/csrf-session.server";
89
import { getResourceKey } from "~/shared/resources";
@@ -21,6 +22,10 @@ export const action = async ({ request }: ActionFunctionArgs) => {
2122
return siteMapLoader({ request });
2223
}
2324

25+
if (isLocalResource(input, "current-date")) {
26+
return currentDateLoader({ request });
27+
}
28+
2429
return fetch(input, init);
2530
};
2631

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { json } from "@remix-run/server-runtime";
2+
import { parseBuilderUrl } from "@webstudio-is/http-client";
3+
import { isBuilder } from "../router-utils";
4+
5+
/**
6+
* System Resource that provides current date information.
7+
* This prevents React hydration errors when displaying dynamic dates
8+
* (e.g., copyright years in footers) by ensuring server and client
9+
* render the same date.
10+
*
11+
* All values are normalized to midnight UTC (00:00:00.000Z) to ensure
12+
* consistency throughout the entire day, preventing hydration mismatches.
13+
*/
14+
export const loader = async ({ request }: { request: Request }) => {
15+
if (isBuilder(request) === false) {
16+
throw new Error("Only builder requests are allowed");
17+
}
18+
19+
const { projectId } = parseBuilderUrl(request.url);
20+
21+
if (projectId === undefined) {
22+
throw new Error("projectId is required");
23+
}
24+
25+
const now = new Date();
26+
27+
// Normalize to midnight UTC to prevent hydration mismatches
28+
const startOfDay = new Date(
29+
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
30+
);
31+
32+
return json({
33+
iso: startOfDay.toISOString(),
34+
year: startOfDay.getUTCFullYear(),
35+
month: startOfDay.getUTCMonth() + 1, // 1-12 instead of 0-11
36+
day: startOfDay.getUTCDate(),
37+
timestamp: startOfDay.getTime(),
38+
});
39+
};

apps/builder/app/shared/nano-states/props.test.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { $resourcesCache, getResourceKey } from "../resources";
3636
const initialSystem = {
3737
origin: "https://undefined.wstd.work",
3838
params: {},
39+
pathname: "/",
3940
search: {},
4041
};
4142

@@ -917,6 +918,7 @@ test("provide page system variable value", () => {
917918
?.get(systemId)
918919
).toEqual({
919920
params: { slug: "my-post" },
921+
pathname: "/",
920922
search: {},
921923
origin: "https://undefined.wstd.work",
922924
});
@@ -948,6 +950,7 @@ test("provide global system variable value", () => {
948950
});
949951
const updatedSystem = {
950952
params: { slug: "my-post" },
953+
pathname: "/",
951954
search: {},
952955
origin: "https://undefined.wstd.work",
953956
};

apps/builder/app/shared/system.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,20 @@ export const $currentSystem = computed(
3434
const system: System = {
3535
search: {},
3636
params: {},
37+
pathname: "/",
3738
origin,
3839
};
3940
if (page === undefined) {
4041
return system;
4142
}
4243
const systemData = systemByPage.get(page.id);
4344
const extractedParams = extractParams(page.path, page.history?.[0]);
45+
const params = { ...extractedParams, ...systemData?.params };
46+
const pathname = compilePath(page.path, params) || "/";
4447
return {
4548
search: { ...system.search, ...systemData?.search },
46-
params: { ...extractedParams, ...systemData?.params },
49+
params,
50+
pathname,
4751
origin,
4852
};
4953
}

fixtures/react-router-cloudflare/app/routes/[another-page]._index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export const loader = async (arg: LoaderFunctionArgs) => {
7070
params,
7171
search: Object.fromEntries(url.searchParams),
7272
origin: url.origin,
73+
pathname: url.pathname,
7374
};
7475

7576
const resources = await loadResources(
@@ -203,6 +204,7 @@ export const action = async ({
203204
params: {},
204205
search: {},
205206
origin: url.origin,
207+
pathname: url.pathname,
206208
};
207209

208210
const resourceName = formData.get(formIdFieldName);

fixtures/react-router-cloudflare/app/routes/_index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export const loader = async (arg: LoaderFunctionArgs) => {
7070
params,
7171
search: Object.fromEntries(url.searchParams),
7272
origin: url.origin,
73+
pathname: url.pathname,
7374
};
7475

7576
const resources = await loadResources(
@@ -203,6 +204,7 @@ export const action = async ({
203204
params: {},
204205
search: {},
205206
origin: url.origin,
207+
pathname: url.pathname,
206208
};
207209

208210
const resourceName = formData.get(formIdFieldName);

fixtures/react-router-docker/app/routes/[another-page]._index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export const loader = async (arg: LoaderFunctionArgs) => {
7070
params,
7171
search: Object.fromEntries(url.searchParams),
7272
origin: url.origin,
73+
pathname: url.pathname,
7374
};
7475

7576
const resources = await loadResources(
@@ -203,6 +204,7 @@ export const action = async ({
203204
params: {},
204205
search: {},
205206
origin: url.origin,
207+
pathname: url.pathname,
206208
};
207209

208210
const resourceName = formData.get(formIdFieldName);

fixtures/react-router-docker/app/routes/_index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export const loader = async (arg: LoaderFunctionArgs) => {
7070
params,
7171
search: Object.fromEntries(url.searchParams),
7272
origin: url.origin,
73+
pathname: url.pathname,
7374
};
7475

7576
const resources = await loadResources(
@@ -203,6 +204,7 @@ export const action = async ({
203204
params: {},
204205
search: {},
205206
origin: url.origin,
207+
pathname: url.pathname,
206208
};
207209

208210
const resourceName = formData.get(formIdFieldName);

0 commit comments

Comments
 (0)