mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
fix(sandbox): Fix broken scripts on sandbox build (#1824)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -1 +1 @@
|
||||
22.19.0
|
||||
24.14.0
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user