fix(sandbox): Fix broken scripts on sandbox build (#1824)

This commit is contained in:
Santiago Puppo
2026-07-24 14:46:18 -07:00
committed by GitHub
parent 53e543bbf8
commit 20f118e95c
7 changed files with 148 additions and 16 deletions
+2 -2
View File
@@ -20,7 +20,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 22
node-version-file: '.nvmrc'
cache: pnpm
- name: Cache turbo build setup
@@ -65,7 +65,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 22
node-version-file: '.nvmrc'
cache: pnpm
- name: Cache turbo build setup
+2 -2
View File
@@ -20,7 +20,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 22
node-version-file: '.nvmrc'
cache: pnpm
- name: Install dependencies
@@ -50,7 +50,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 22
node-version-file: '.nvmrc'
cache: pnpm
- name: Install dependencies
+1 -1
View File
@@ -1 +1 @@
22.19.0
24.14.0
+1 -1
View File
@@ -1 +1 @@
node-version=22.19.0
node-version=24.14.0
+2 -2
View File
@@ -1,11 +1,12 @@
import { existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import { defineConfig, normalizePath, type Plugin } from 'vite';
import { mirrorTemplatesToSrc } from './scripts/shared';
const htmlCdnDir = resolve(__dirname, '../../packages/html/cdn');
const htmlCdnI18nRegistry = normalizePath(resolve(htmlCdnDir, 'i18n.dev.js'));
const cdnSandboxMainSrc = resolve(__dirname, 'src/cdn/main.ts');
@@ -108,7 +109,6 @@ function sandboxTemplateSyncPlugin(): Plugin {
return {
name: 'sandbox-template-sync',
async buildStart() {
const { mirrorTemplatesToSrc } = await import(pathToFileURL(resolve(__dirname, 'scripts/shared.ts')).href);
await mirrorTemplatesToSrc();
},
};
+48 -5
View File
@@ -16,9 +16,48 @@ type DurationFormatConstructor = new (
options?: { style?: TimeFormatOptions['style']; hoursDisplay?: 'auto' | 'always' }
) => { format: (duration: DurationRecord) => string };
const DurationFormat = (Intl as typeof Intl & { DurationFormat: DurationFormatConstructor }).DurationFormat;
const DurationFormat = (Intl as typeof Intl & { DurationFormat?: DurationFormatConstructor }).DurationFormat;
const durationFormatters = new Map<string, InstanceType<typeof DurationFormat>>();
type DurationFormatter = { format: (duration: DurationRecord) => string };
const durationFormatters = new Map<string, DurationFormatter>();
/**
* `Intl.DurationFormat` is unavailable on Node < 23 (SSR/prerender) and pre-2024 evergreen
* browsers, so degrade gracefully per the documented browser-support fallback policy.
* Digital output stays exact; localized phrase styles fall back to English.
*/
function createFallbackFormatter(
style: NonNullable<TimeFormatOptions['style']>,
hoursDisplay?: 'auto' | 'always'
): DurationFormatter {
if (style === 'digital') {
const pad = (value: number): string => String(value).padStart(2, '0');
return {
format: (duration) => {
const body = `${pad(duration.minutes ?? 0)}:${pad(duration.seconds ?? 0)}`;
const showHours = hoursDisplay === 'always' || duration.hours !== undefined;
return showHours ? `${duration.hours ?? 0}:${body}` : body;
},
};
}
const units: Array<[keyof DurationRecord, string]> = [
['hours', 'hour'],
['minutes', 'minute'],
['seconds', 'second'],
];
return {
format: (duration) =>
units
.filter(([unit]) => duration[unit] !== undefined)
.map(([unit, label]) => {
const value = duration[unit] ?? 0;
return `${value} ${label}${value === 1 ? '' : 's'}`;
})
.join(', '),
};
}
function localeCacheKey(locale?: string | string[]): string {
if (locale === undefined) return '';
@@ -35,12 +74,16 @@ function getDurationFormatter(
locale?: string | string[],
style: NonNullable<TimeFormatOptions['style']> = 'long',
hoursDisplay?: 'auto' | 'always'
): InstanceType<typeof DurationFormat> {
): DurationFormatter {
const key = `${localeCacheKey(locale)}:${style}:${hoursDisplay ?? ''}`;
let formatter = durationFormatters.get(key);
if (!formatter) {
const options = hoursDisplay === undefined ? { style } : { style, hoursDisplay };
formatter = new DurationFormat(locale, options);
if (DurationFormat) {
const options = hoursDisplay === undefined ? { style } : { style, hoursDisplay };
formatter = new DurationFormat(locale, options);
} else {
formatter = createFallbackFormatter(style, hoursDisplay);
}
durationFormatters.set(key, formatter);
}
return formatter;
+92 -3
View File
@@ -1,7 +1,29 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import { formatTime, formatTimeAsPhrase, secondsToIsoDuration } from '../format';
const hasDurationFormat = typeof (Intl as { DurationFormat?: unknown }).DurationFormat === 'function';
type FormatModule = typeof import('../format');
/**
* Re-import the module with `Intl.DurationFormat` removed so the module-level capture
* resolves to `undefined` and `getDurationFormatter` uses `createFallbackFormatter`.
* Runs the fallback path deterministically regardless of the Node version.
*/
async function loadWithFallback(): Promise<FormatModule> {
const intl = Intl as { DurationFormat?: unknown };
const original = intl.DurationFormat;
intl.DurationFormat = undefined;
vi.resetModules();
try {
return await import('../format');
} finally {
if (original === undefined) delete intl.DurationFormat;
else intl.DurationFormat = original;
}
}
describe('formatTime', () => {
it('formats seconds only', () => {
expect(formatTime(0)).toBe('0:00');
@@ -82,7 +104,7 @@ describe('formatTimeAsPhrase', () => {
expect(formatted).not.toMatch(/remaining$/i);
});
it('uses Intl.DurationFormat', () => {
it.runIf(hasDurationFormat)('uses Intl.DurationFormat', () => {
const en = formatTimeAsPhrase(125, { locale: 'en' });
const de = formatTimeAsPhrase(125, { locale: 'de' });
expect(en.length).toBeGreaterThan(0);
@@ -95,9 +117,15 @@ describe('formatTimeAsPhrase', () => {
expect(formatTimeAsPhrase(Infinity)).toBe('');
});
it('throws when Intl.DurationFormat rejects the locale', () => {
it.runIf(hasDurationFormat)('throws when Intl.DurationFormat rejects the locale', () => {
expect(() => formatTimeAsPhrase(90, { locale: 'not-a-valid-bcp47-tag!!!' })).toThrow(RangeError);
});
it.skipIf(hasDurationFormat)('falls back to an English phrase without Intl.DurationFormat', () => {
expect(formatTimeAsPhrase(125)).toBe('2 minutes, 5 seconds');
expect(formatTimeAsPhrase(3661)).toBe('1 hour, 1 minute, 1 second');
expect(formatTimeAsPhrase(-30)).toMatch(/remaining$/i);
});
});
describe('secondsToIsoDuration', () => {
@@ -126,3 +154,64 @@ describe('secondsToIsoDuration', () => {
expect(secondsToIsoDuration(Infinity)).toBe('PT0S');
});
});
describe('createFallbackFormatter', () => {
describe('digital style (via formatTime)', () => {
it('pads minutes and seconds', async () => {
const { formatTime: format } = await loadWithFallback();
expect(format(5)).toBe('0:05');
expect(format(65)).toBe('1:05');
expect(format(600)).toBe('10:00');
});
it('shows hours when present', async () => {
const { formatTime: format } = await loadWithFallback();
expect(format(3661)).toBe('1:01:01');
expect(format(36000)).toBe('10:00:00');
});
it('forces hours display when guided by an hours-long duration', async () => {
const { formatTime: format } = await loadWithFallback();
expect(format(35, 3600)).toBe('0:00:35');
});
});
describe('text style (via formatTimeAsPhrase)', () => {
it('joins present units with a comma', async () => {
const { formatTimeAsPhrase: format } = await loadWithFallback();
expect(format(3661)).toBe('1 hour, 1 minute, 1 second');
expect(format(125)).toBe('2 minutes, 5 seconds');
});
it('pluralizes based on the unit value', async () => {
const { formatTimeAsPhrase: format } = await loadWithFallback();
expect(format(1)).toBe('1 second');
expect(format(2)).toBe('2 seconds');
expect(format(3600)).toBe('1 hour');
expect(format(7200)).toBe('2 hours');
});
it('omits units that are absent from the record', async () => {
const { formatTimeAsPhrase: format } = await loadWithFallback();
expect(format(30)).toBe('30 seconds');
expect(format(120)).toBe('2 minutes');
expect(format(0)).toBe('0 seconds');
});
it('ignores non-digital style variants (all render as long-form text)', async () => {
const { formatTimeAsPhrase: format } = await loadWithFallback();
expect(format(125, { style: 'short' })).toBe('2 minutes, 5 seconds');
expect(format(125, { style: 'narrow' })).toBe('2 minutes, 5 seconds');
});
it('wraps negative durations in the English remaining phrase', async () => {
const { formatTimeAsPhrase: format } = await loadWithFallback();
expect(format(-30)).toBe('30 seconds remaining');
});
it('applies formatRemaining for negative durations', async () => {
const { formatTimeAsPhrase: format } = await loadWithFallback();
expect(format(-30, { formatRemaining: (duration) => `${duration} left` })).toBe('30 seconds left');
});
});
});