@nysds/playground 0.1.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/LICENSE +25 -0
- package/README.md +364 -0
- package/bin/cli.mjs +223 -0
- package/bin/cli.test.mjs +36 -0
- package/decks/customizing-components.json +97 -0
- package/dist/assets/index-LO1tomgR.css +6 -0
- package/dist/assets/index-bBtGCGL_.js +5190 -0
- package/dist/assets/internal/typescript.js +193739 -0
- package/dist/assets/nys-icon.library-Bi_7DKlD-YSs5zqZy-DXISxj9N.js +609 -0
- package/dist/assets/nys-icon.library-CwuPZJAc-TryaOS7Z.js +600 -0
- package/dist/assets/playground-typescript-worker-BcTrPYfY.js +87 -0
- package/dist/assets/playground-typescript-worker.js +87 -0
- package/dist/favicon.svg +10 -0
- package/dist/index.html +769 -0
- package/dist/nysds-logo.svg +21 -0
- package/dist/nysds-symbol.svg +7 -0
- package/index.html +768 -0
- package/package.json +59 -0
- package/presets/00-welcome.json +8 -0
- package/presets/01-button.json +7 -0
- package/presets/02-alert.json +7 -0
- package/presets/03-badge-and-avatar.json +7 -0
- package/presets/04-text-input.json +7 -0
- package/presets/05-select-radio-checkbox.json +7 -0
- package/presets/06-form-validation.json +7 -0
- package/presets/07-card.json +7 -0
- package/presets/08-accordion.json +7 -0
- package/presets/09-tabs.json +7 -0
- package/presets/10-modal.json +7 -0
- package/presets/11-stepper.json +7 -0
- package/presets/12-table-and-pagination.json +7 -0
- package/presets/13-tooltip-and-dropdown.json +7 -0
- package/presets/14-navigation.json +7 -0
- package/presets/15-page-structure.json +7 -0
- package/presets/16-themes.json +7 -0
- package/presets/17-utility-classes.json +7 -0
- package/presets/README.md +118 -0
- package/public/favicon.svg +10 -0
- package/public/nysds-logo.svg +21 -0
- package/public/nysds-symbol.svg +7 -0
- package/src/app.css +1087 -0
- package/src/debounce.ts +86 -0
- package/src/deck-model.ts +299 -0
- package/src/deck-store.ts +144 -0
- package/src/decks.test.ts +288 -0
- package/src/editor-panes.ts +190 -0
- package/src/editors.ts +92 -0
- package/src/home.ts +225 -0
- package/src/icon-names.ts +117 -0
- package/src/icons.test.ts +58 -0
- package/src/keys.ts +162 -0
- package/src/main.ts +1456 -0
- package/src/playground.config.ts +74 -0
- package/src/playground.ts +261 -0
- package/src/present.ts +398 -0
- package/src/preset-schema.ts +228 -0
- package/src/route.test.ts +56 -0
- package/src/routing.ts +60 -0
- package/src/settings.ts +211 -0
- package/src/starters.ts +86 -0
- package/src/state.test.ts +544 -0
- package/src/state.ts +237 -0
- package/src/theme.ts +82 -0
- package/src/version-catalog.ts +42 -0
- package/src/versions.ts +88 -0
- package/src/wrapper.ts +84 -0
- package/tsconfig.json +25 -0
- package/vite.config.ts +62 -0
package/src/state.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads and writes the playground state in the URL.
|
|
3
|
+
*
|
|
4
|
+
* Two hash forms exist. `#code=` carries a compressed snapshot of the editors
|
|
5
|
+
* and is what a Share link produces. `#preset=` carries a preset id and stays
|
|
6
|
+
* readable, so it is used while a preset is loaded and unmodified.
|
|
7
|
+
*/
|
|
8
|
+
// lz-string ships CommonJS, so import the namespace as a default and read the
|
|
9
|
+
// two functions off it. Named imports do not survive Node's CJS interop.
|
|
10
|
+
import LZString from 'lz-string';
|
|
11
|
+
|
|
12
|
+
const {compressToEncodedURIComponent, decompressFromEncodedURIComponent} = LZString;
|
|
13
|
+
|
|
14
|
+
/** Everything the playground needs to restore a session. */
|
|
15
|
+
export interface PlaygroundState {
|
|
16
|
+
/** The design system version the preview loads. */
|
|
17
|
+
version: string;
|
|
18
|
+
/** The user's HTML. */
|
|
19
|
+
html: string;
|
|
20
|
+
/** The user's CSS. */
|
|
21
|
+
css: string;
|
|
22
|
+
/** The user's JavaScript. */
|
|
23
|
+
js: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The compact object that goes into the hash. Short keys keep links short. */
|
|
27
|
+
interface EncodedState {
|
|
28
|
+
v: string;
|
|
29
|
+
h: string;
|
|
30
|
+
c: string;
|
|
31
|
+
j: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What the current URL asks the playground to load. */
|
|
35
|
+
export type LocationState =
|
|
36
|
+
| {kind: 'code'; state: PlaygroundState}
|
|
37
|
+
| {kind: 'preset'; id: string}
|
|
38
|
+
| {kind: 'default'};
|
|
39
|
+
|
|
40
|
+
/** The query parameter that turns on presentation mode. */
|
|
41
|
+
const PRESENT_PARAM = 'present';
|
|
42
|
+
|
|
43
|
+
/** The query parameter that selects a deck. */
|
|
44
|
+
const DECK_PARAM = 'deck';
|
|
45
|
+
|
|
46
|
+
/** Compresses a state snapshot into the value of a `#code=` hash. */
|
|
47
|
+
export function encodeState(state: PlaygroundState): string {
|
|
48
|
+
const payload: EncodedState = {
|
|
49
|
+
v: state.version,
|
|
50
|
+
h: state.html,
|
|
51
|
+
c: state.css,
|
|
52
|
+
j: state.js,
|
|
53
|
+
};
|
|
54
|
+
return compressToEncodedURIComponent(JSON.stringify(payload));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Expands a `#code=` hash value. Returns `null` when the value is unusable. */
|
|
58
|
+
export function decodeState(encoded: string): PlaygroundState | null {
|
|
59
|
+
let json: string | null;
|
|
60
|
+
try {
|
|
61
|
+
json = decompressFromEncodedURIComponent(encoded);
|
|
62
|
+
} catch {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
if (!json) {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
let parsed: unknown;
|
|
69
|
+
try {
|
|
70
|
+
parsed = JSON.parse(json);
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const candidate = parsed as Partial<EncodedState>;
|
|
78
|
+
if (
|
|
79
|
+
typeof candidate.v !== 'string' ||
|
|
80
|
+
typeof candidate.h !== 'string' ||
|
|
81
|
+
typeof candidate.c !== 'string' ||
|
|
82
|
+
typeof candidate.j !== 'string'
|
|
83
|
+
) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
return {version: candidate.v, html: candidate.h, css: candidate.c, js: candidate.j};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Parses a hash string such as `#code=abc` into a {@link LocationState}. */
|
|
90
|
+
export function parseHash(hash: string): LocationState {
|
|
91
|
+
const value = hash.startsWith('#') ? hash.slice(1) : hash;
|
|
92
|
+
if (value.startsWith('code=')) {
|
|
93
|
+
const state = decodeState(value.slice('code='.length));
|
|
94
|
+
if (state) {
|
|
95
|
+
return {kind: 'code', state};
|
|
96
|
+
}
|
|
97
|
+
return {kind: 'default'};
|
|
98
|
+
}
|
|
99
|
+
if (value.startsWith('preset=')) {
|
|
100
|
+
const id = decodeURIComponent(value.slice('preset='.length));
|
|
101
|
+
if (id) {
|
|
102
|
+
return {kind: 'preset', id};
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return {kind: 'default'};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Reads the current browser location. */
|
|
109
|
+
export function readLocation(): LocationState {
|
|
110
|
+
return parseHash(window.location.hash);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Returns the deck id the URL asks for, or `null` for the library deck.
|
|
115
|
+
*/
|
|
116
|
+
export function readDeckId(search: string = window.location.search): string | null {
|
|
117
|
+
return new URLSearchParams(search).get(DECK_PARAM);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Builds a URL for a deck and slide, keeping presentation mode.
|
|
122
|
+
*
|
|
123
|
+
* The library deck drops the `deck` parameter so its links stay short.
|
|
124
|
+
*/
|
|
125
|
+
export function deckUrl(
|
|
126
|
+
deckId: string,
|
|
127
|
+
presetId: string,
|
|
128
|
+
libraryDeckId: string,
|
|
129
|
+
href: string = window.location.href,
|
|
130
|
+
): string {
|
|
131
|
+
const url = new URL(href);
|
|
132
|
+
if (deckId === libraryDeckId) {
|
|
133
|
+
url.searchParams.delete(DECK_PARAM);
|
|
134
|
+
} else {
|
|
135
|
+
url.searchParams.set(DECK_PARAM, deckId);
|
|
136
|
+
}
|
|
137
|
+
url.hash = presetId ? `#preset=${encodeURIComponent(presetId)}` : '';
|
|
138
|
+
return url.toString();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Reports whether the current URL requests presentation mode. */
|
|
142
|
+
export function isPresentMode(search: string = window.location.search): boolean {
|
|
143
|
+
return new URLSearchParams(search).get(PRESENT_PARAM) === '1';
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Returns the current URL with presentation mode turned on or off.
|
|
148
|
+
*
|
|
149
|
+
* The hash is preserved, so leaving presentation mode keeps you on the preset
|
|
150
|
+
* you were showing.
|
|
151
|
+
*/
|
|
152
|
+
export function presentUrl(on: boolean, href: string = window.location.href): string {
|
|
153
|
+
const url = new URL(href);
|
|
154
|
+
if (on) {
|
|
155
|
+
url.searchParams.set(PRESENT_PARAM, '1');
|
|
156
|
+
} else {
|
|
157
|
+
url.searchParams.delete(PRESENT_PARAM);
|
|
158
|
+
}
|
|
159
|
+
return url.toString();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Replaces the hash without adding a history entry.
|
|
164
|
+
*
|
|
165
|
+
* Query parameters stay untouched so `?present=1` survives every update.
|
|
166
|
+
*/
|
|
167
|
+
export function replaceHash(hash: string): void {
|
|
168
|
+
const url = new URL(window.location.href);
|
|
169
|
+
url.hash = hash;
|
|
170
|
+
window.history.replaceState(null, '', url.toString());
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Writes a `#code=` hash for the given state. */
|
|
174
|
+
export function writeCodeHash(state: PlaygroundState): void {
|
|
175
|
+
replaceHash(`#code=${encodeState(state)}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Writes a `#preset=` hash for the given preset id. */
|
|
179
|
+
export function writePresetHash(id: string): void {
|
|
180
|
+
replaceHash(`#preset=${encodeURIComponent(id)}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Returns a function that runs `fn` after `delay` milliseconds of quiet.
|
|
185
|
+
*
|
|
186
|
+
* The playground uses it to keep edits from writing to the URL on every
|
|
187
|
+
* keystroke.
|
|
188
|
+
*/
|
|
189
|
+
export function debounce<T extends unknown[]>(
|
|
190
|
+
fn: (...args: T) => void,
|
|
191
|
+
delay: number,
|
|
192
|
+
): {(...args: T): void; flush(): void; cancel(): void} {
|
|
193
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
194
|
+
let pending: T | undefined;
|
|
195
|
+
const run = (...args: T): void => {
|
|
196
|
+
pending = args;
|
|
197
|
+
if (timer !== undefined) {
|
|
198
|
+
clearTimeout(timer);
|
|
199
|
+
}
|
|
200
|
+
timer = setTimeout(() => {
|
|
201
|
+
timer = undefined;
|
|
202
|
+
const args = pending;
|
|
203
|
+
pending = undefined;
|
|
204
|
+
if (args) {
|
|
205
|
+
fn(...args);
|
|
206
|
+
}
|
|
207
|
+
}, delay);
|
|
208
|
+
};
|
|
209
|
+
run.flush = (): void => {
|
|
210
|
+
if (timer !== undefined) {
|
|
211
|
+
clearTimeout(timer);
|
|
212
|
+
timer = undefined;
|
|
213
|
+
}
|
|
214
|
+
const args = pending;
|
|
215
|
+
pending = undefined;
|
|
216
|
+
if (args) {
|
|
217
|
+
fn(...args);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
run.cancel = (): void => {
|
|
221
|
+
if (timer !== undefined) {
|
|
222
|
+
clearTimeout(timer);
|
|
223
|
+
timer = undefined;
|
|
224
|
+
}
|
|
225
|
+
pending = undefined;
|
|
226
|
+
};
|
|
227
|
+
return run;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** Turns a title into a filename-safe slug. */
|
|
231
|
+
export function slugify(title: string): string {
|
|
232
|
+
const slug = title
|
|
233
|
+
.toLowerCase()
|
|
234
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
235
|
+
.replace(/^-+|-+$/g, '');
|
|
236
|
+
return slug || 'playground';
|
|
237
|
+
}
|
package/src/theme.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Light and dark editor themes.
|
|
3
|
+
*
|
|
4
|
+
* The preview always shows the design system as it is. Only the code editors
|
|
5
|
+
* change. Light is the default because projectors wash out dark backgrounds.
|
|
6
|
+
* Dark suits screen recordings, where a dark pane frames the light preview.
|
|
7
|
+
*/
|
|
8
|
+
import lightTheme from 'playground-elements/themes/eclipse.css.js';
|
|
9
|
+
import darkTheme from 'playground-elements/themes/material-darker.css.js';
|
|
10
|
+
|
|
11
|
+
import {DEFAULTS, STORAGE_KEYS, readKey, writeKey} from './settings.ts';
|
|
12
|
+
|
|
13
|
+
/** The two editor themes the playground offers. */
|
|
14
|
+
export type EditorTheme = 'light' | 'dark';
|
|
15
|
+
|
|
16
|
+
/** The query parameter that picks the editor theme, as in `?theme=dark`. */
|
|
17
|
+
export const THEME_PARAM = 'theme';
|
|
18
|
+
|
|
19
|
+
/** The CodeMirror theme class each choice maps to. */
|
|
20
|
+
const THEME_CLASSES: Record<EditorTheme, string> = {
|
|
21
|
+
light: 'playground-theme-eclipse',
|
|
22
|
+
dark: 'playground-theme-material-darker',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Reads `?theme=` from a query string. Returns `null` when it isn't set. */
|
|
26
|
+
export function readThemeParam(search: string): EditorTheme | null {
|
|
27
|
+
const value = new URLSearchParams(search).get(THEME_PARAM);
|
|
28
|
+
return value === 'dark' || value === 'light' ? value : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Returns the other theme. */
|
|
32
|
+
export function otherTheme(theme: EditorTheme): EditorTheme {
|
|
33
|
+
return theme === 'dark' ? 'light' : 'dark';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Returns the URL with `?theme=` set to the given theme. */
|
|
37
|
+
export function themeUrl(theme: EditorTheme, href: string): string {
|
|
38
|
+
const url = new URL(href);
|
|
39
|
+
url.searchParams.set(THEME_PARAM, theme);
|
|
40
|
+
return url.toString();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Picks the theme for this visit: the URL wins, then the remembered choice,
|
|
45
|
+
* then dark.
|
|
46
|
+
*
|
|
47
|
+
* A theme chosen before is kept, so changing the default does not recolour
|
|
48
|
+
* anyone's editors on them.
|
|
49
|
+
*/
|
|
50
|
+
export function initialTheme(search: string = window.location.search): EditorTheme {
|
|
51
|
+
const fromUrl = readThemeParam(search);
|
|
52
|
+
if (fromUrl) {
|
|
53
|
+
return fromUrl;
|
|
54
|
+
}
|
|
55
|
+
const stored = readKey(STORAGE_KEYS.theme);
|
|
56
|
+
if (stored === 'dark' || stored === 'light') {
|
|
57
|
+
return stored;
|
|
58
|
+
}
|
|
59
|
+
return DEFAULTS.theme;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let sheetsAdopted = false;
|
|
63
|
+
|
|
64
|
+
/** Applies a theme to the document so it reaches the editors. */
|
|
65
|
+
export function applyEditorTheme(theme: EditorTheme): void {
|
|
66
|
+
if (!sheetsAdopted) {
|
|
67
|
+
const sheets = [lightTheme.styleSheet, darkTheme.styleSheet].filter(
|
|
68
|
+
(sheet): sheet is CSSStyleSheet => sheet !== undefined,
|
|
69
|
+
);
|
|
70
|
+
document.adoptedStyleSheets = [...document.adoptedStyleSheets, ...sheets];
|
|
71
|
+
sheetsAdopted = true;
|
|
72
|
+
}
|
|
73
|
+
for (const [name, className] of Object.entries(THEME_CLASSES)) {
|
|
74
|
+
document.body.classList.toggle(className, name === theme);
|
|
75
|
+
}
|
|
76
|
+
document.body.dataset.editorTheme = theme;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Remembers the theme for the next visit. */
|
|
80
|
+
export function writeTheme(theme: EditorTheme): void {
|
|
81
|
+
writeKey(STORAGE_KEYS.theme, theme);
|
|
82
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for the design system version list.
|
|
3
|
+
*
|
|
4
|
+
* This module imports nothing, so `src/state.test.ts` can load it directly
|
|
5
|
+
* under `node --test`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The versions the CDN can serve for both packages. */
|
|
9
|
+
export interface VersionCatalog {
|
|
10
|
+
/** Versions available in both packages, newest first. */
|
|
11
|
+
all: string[];
|
|
12
|
+
/** Stable versions only, newest first. */
|
|
13
|
+
stable: string[];
|
|
14
|
+
/** The newest stable version, used to resolve `latest`. */
|
|
15
|
+
latest: string;
|
|
16
|
+
/** Whether the list came from the fallback because the API was unreachable. */
|
|
17
|
+
usedFallback: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Reports whether a version string is a prerelease, such as `2.0.0-next.1`. */
|
|
21
|
+
export function isPrerelease(version: string): boolean {
|
|
22
|
+
return version.includes('-');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Picks the version to load for a requested version string.
|
|
27
|
+
*
|
|
28
|
+
* `latest` and an empty string resolve to the newest stable release. A version
|
|
29
|
+
* the CDN does not list also falls back to the newest stable, which keeps a
|
|
30
|
+
* mistyped or unpublished version in a shared link from breaking the preview.
|
|
31
|
+
* That check is skipped when the catalog came from the built-in fallback list,
|
|
32
|
+
* because that list names only a handful of releases.
|
|
33
|
+
*/
|
|
34
|
+
export function chooseVersion(requested: string, catalog: VersionCatalog): string {
|
|
35
|
+
if (!requested || requested === 'latest') {
|
|
36
|
+
return catalog.latest || requested;
|
|
37
|
+
}
|
|
38
|
+
if (!catalog.usedFallback && !catalog.all.includes(requested)) {
|
|
39
|
+
return catalog.latest || requested;
|
|
40
|
+
}
|
|
41
|
+
return requested;
|
|
42
|
+
}
|
package/src/versions.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetches the design system versions the CDN can serve.
|
|
3
|
+
*
|
|
4
|
+
* Only versions that exist in both packages are offered, because the preview
|
|
5
|
+
* loads the component bundle and the stylesheet at the same version.
|
|
6
|
+
*/
|
|
7
|
+
import {PLAYGROUND_CONFIG} from './playground.config';
|
|
8
|
+
import type {VersionCatalog} from './version-catalog';
|
|
9
|
+
import {chooseVersion, isPrerelease} from './version-catalog';
|
|
10
|
+
|
|
11
|
+
export type {VersionCatalog};
|
|
12
|
+
export {chooseVersion, isPrerelease};
|
|
13
|
+
|
|
14
|
+
/** The subset of the jsDelivr response the playground reads. */
|
|
15
|
+
interface JsdelivrPackage {
|
|
16
|
+
tags?: Record<string, string>;
|
|
17
|
+
versions?: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let cached: Promise<VersionCatalog> | undefined;
|
|
21
|
+
|
|
22
|
+
/** Fetches the published versions of one npm package. */
|
|
23
|
+
async function fetchVersions(name: string): Promise<JsdelivrPackage> {
|
|
24
|
+
const response = await fetch(`${PLAYGROUND_CONFIG.versionsApi}${name}`);
|
|
25
|
+
if (!response.ok) {
|
|
26
|
+
throw new Error(`The versions API answered ${response.status} for ${name}.`);
|
|
27
|
+
}
|
|
28
|
+
return (await response.json()) as JsdelivrPackage;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Builds the fallback catalog from the configured list. */
|
|
32
|
+
function fallbackCatalog(): VersionCatalog {
|
|
33
|
+
const all = [...PLAYGROUND_CONFIG.fallbackVersions];
|
|
34
|
+
const stable = all.filter((version) => !isPrerelease(version));
|
|
35
|
+
return {
|
|
36
|
+
all,
|
|
37
|
+
stable,
|
|
38
|
+
latest: stable[0] ?? all[0] ?? '',
|
|
39
|
+
usedFallback: true,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Returns the version catalog, fetching it at most once per page load.
|
|
45
|
+
*
|
|
46
|
+
* When the API is unreachable, the configured fallback list is returned
|
|
47
|
+
* instead so the playground still works offline.
|
|
48
|
+
*/
|
|
49
|
+
export function loadVersions(): Promise<VersionCatalog> {
|
|
50
|
+
if (!cached) {
|
|
51
|
+
cached = buildCatalog().catch((error: unknown) => {
|
|
52
|
+
console.warn('Could not load the version list from the CDN.', error);
|
|
53
|
+
return fallbackCatalog();
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return cached;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function buildCatalog(): Promise<VersionCatalog> {
|
|
60
|
+
const [components, styles] = await Promise.all([
|
|
61
|
+
fetchVersions(PLAYGROUND_CONFIG.packages.components.name),
|
|
62
|
+
fetchVersions(PLAYGROUND_CONFIG.packages.styles.name),
|
|
63
|
+
]);
|
|
64
|
+
const stylesVersions = new Set(styles.versions ?? []);
|
|
65
|
+
const all = (components.versions ?? []).filter((version) => stylesVersions.has(version));
|
|
66
|
+
if (all.length === 0) {
|
|
67
|
+
throw new Error('The two packages share no published versions.');
|
|
68
|
+
}
|
|
69
|
+
const stable = all.filter((version) => !isPrerelease(version));
|
|
70
|
+
const taggedLatest = components.tags?.latest;
|
|
71
|
+
const latest =
|
|
72
|
+
taggedLatest && all.includes(taggedLatest) ? taggedLatest : (stable[0] ?? all[0]);
|
|
73
|
+
return {all, stable, latest: latest ?? '', usedFallback: false};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Turns `latest` into a concrete version number, fetching the catalog first.
|
|
78
|
+
*/
|
|
79
|
+
export async function resolveVersion(version: string): Promise<string> {
|
|
80
|
+
const catalog = await loadVersions();
|
|
81
|
+
const chosen = chooseVersion(version, catalog);
|
|
82
|
+
if (chosen !== version) {
|
|
83
|
+
if (version && version !== 'latest') {
|
|
84
|
+
console.warn(`The CDN does not list version ${version}. Loading ${chosen} instead.`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return chosen || PLAYGROUND_CONFIG.fallbackVersions[0] || 'latest';
|
|
88
|
+
}
|
package/src/wrapper.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Builds the preview document that wraps the user's markup.
|
|
3
|
+
*
|
|
4
|
+
* The wrapper is a file of its own, hidden from the editors, so the HTML pane
|
|
5
|
+
* holds nothing but the user's snippet. That keeps select-all and copy honest
|
|
6
|
+
* and the line numbers correct.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** The options that vary between wrappers. */
|
|
10
|
+
export interface WrapperOptions {
|
|
11
|
+
/** The absolute URL of the design system stylesheet. */
|
|
12
|
+
stylesHref: string;
|
|
13
|
+
/** The absolute URL of the design system component bundle. */
|
|
14
|
+
componentsSrc: string;
|
|
15
|
+
/** Extra markup to append to the `<head>`. */
|
|
16
|
+
extraHeadHtml?: string;
|
|
17
|
+
/**
|
|
18
|
+
* CSS that applies to every slide in a deck.
|
|
19
|
+
*
|
|
20
|
+
* It goes into the head as a `<style>`, so it styles the preview without
|
|
21
|
+
* taking up room in the CSS tab.
|
|
22
|
+
*/
|
|
23
|
+
baseCss?: string;
|
|
24
|
+
/** The `<title>` of the preview document. */
|
|
25
|
+
title?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Wraps the user's markup in a complete HTML document.
|
|
30
|
+
*
|
|
31
|
+
* The result is never shown in an editor, so it is written for the browser
|
|
32
|
+
* rather than for reading.
|
|
33
|
+
*/
|
|
34
|
+
export function wrapUserHtml(userHtml: string, options: WrapperOptions): string {
|
|
35
|
+
const title = options.title ?? 'Preview';
|
|
36
|
+
const head = [
|
|
37
|
+
'<meta charset="utf-8">',
|
|
38
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
39
|
+
`<title>${escapeHtmlText(title)}</title>`,
|
|
40
|
+
`<link rel="stylesheet" href="${options.stylesHref}">`,
|
|
41
|
+
// The design system reset removes the body margin. Put the browser default
|
|
42
|
+
// back so a lone button doesn't sit against the edge. Deck base CSS and the
|
|
43
|
+
// CSS tab load later, so either can set `body { margin: 0 }` to remove it.
|
|
44
|
+
'<style>body{margin:8px}</style>',
|
|
45
|
+
options.extraHeadHtml ?? '',
|
|
46
|
+
options.baseCss ? `<style>${options.baseCss}</style>` : '',
|
|
47
|
+
'<link rel="stylesheet" href="./styles.css">',
|
|
48
|
+
`<script type="module" src="${options.componentsSrc}"></script>`,
|
|
49
|
+
].join('');
|
|
50
|
+
return [
|
|
51
|
+
'<!doctype html>',
|
|
52
|
+
'<html lang="en">',
|
|
53
|
+
`<head>${head}</head>`,
|
|
54
|
+
'<body>',
|
|
55
|
+
userHtml,
|
|
56
|
+
'<script type="module" src="./script.js"></script>',
|
|
57
|
+
'</body>',
|
|
58
|
+
'</html>',
|
|
59
|
+
'',
|
|
60
|
+
].join('\n');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Rewrites relative `href` and `src` values in head markup to absolute URLs.
|
|
65
|
+
*
|
|
66
|
+
* The preview runs on a different origin from the playground, so a link
|
|
67
|
+
* written relative to the playground page, such as `./fonts/nysds-fonts.css`,
|
|
68
|
+
* has to be resolved against the page before it goes into the preview.
|
|
69
|
+
*/
|
|
70
|
+
export function resolveHeadUrls(html: string, baseUrl: string): string {
|
|
71
|
+
return html.replace(
|
|
72
|
+
/\b(href|src)=(["'])(\.{1,2}\/[^"']*)\2/g,
|
|
73
|
+
(_match, attr: string, quote: string, value: string) =>
|
|
74
|
+
`${attr}=${quote}${new URL(value, baseUrl).href}${quote}`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Escapes text that goes into an HTML text node. */
|
|
79
|
+
function escapeHtmlText(value: string): string {
|
|
80
|
+
return value
|
|
81
|
+
.replace(/&/g, '&')
|
|
82
|
+
.replace(/</g, '<')
|
|
83
|
+
.replace(/>/g, '>');
|
|
84
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
7
|
+
"types": ["vite/client"],
|
|
8
|
+
"strict": true,
|
|
9
|
+
"noEmit": true,
|
|
10
|
+
"resolveJsonModule": true,
|
|
11
|
+
"allowImportingTsExtensions": true,
|
|
12
|
+
"isolatedModules": true,
|
|
13
|
+
"verbatimModuleSyntax": true,
|
|
14
|
+
"skipLibCheck": true,
|
|
15
|
+
"esModuleInterop": true,
|
|
16
|
+
"allowSyntheticDefaultImports": true,
|
|
17
|
+
"noUnusedLocals": true,
|
|
18
|
+
"noUnusedParameters": true,
|
|
19
|
+
"noFallthroughCasesInSwitch": true,
|
|
20
|
+
"forceConsistentCasingInFileNames": true,
|
|
21
|
+
"useDefineForClassFields": true,
|
|
22
|
+
"experimentalDecorators": false
|
|
23
|
+
},
|
|
24
|
+
"include": ["src", "vite.config.ts"]
|
|
25
|
+
}
|
package/vite.config.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import {createRequire} from 'node:module';
|
|
2
|
+
import {readFileSync} from 'node:fs';
|
|
3
|
+
import {defineConfig, type Plugin} from 'vite';
|
|
4
|
+
|
|
5
|
+
const require = createRequire(import.meta.url);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Files that `playground-elements` loads at runtime through a relative
|
|
9
|
+
* `new URL(..., import.meta.url)` reference rather than a static import.
|
|
10
|
+
*
|
|
11
|
+
* `playground-project.js` starts the TypeScript worker with
|
|
12
|
+
* `new URL('./playground-typescript-worker.js', import.meta.url)`, and that
|
|
13
|
+
* worker imports `./internal/typescript.js`. Rollup may or may not rewrite the
|
|
14
|
+
* `new URL` call into a hashed asset, so copy both files next to the emitted
|
|
15
|
+
* chunks. The relative lookup then resolves either way.
|
|
16
|
+
*/
|
|
17
|
+
const RUNTIME_WORKER_FILES = [
|
|
18
|
+
{
|
|
19
|
+
from: 'playground-elements/playground-typescript-worker.js',
|
|
20
|
+
to: 'assets/playground-typescript-worker.js',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
from: 'playground-elements/internal/typescript.js',
|
|
24
|
+
to: 'assets/internal/typescript.js',
|
|
25
|
+
},
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/** Copies the playground TypeScript worker and its dependency into `dist`. */
|
|
29
|
+
function copyPlaygroundWorker(): Plugin {
|
|
30
|
+
return {
|
|
31
|
+
name: 'copy-playground-worker',
|
|
32
|
+
apply: 'build',
|
|
33
|
+
generateBundle() {
|
|
34
|
+
for (const file of RUNTIME_WORKER_FILES) {
|
|
35
|
+
this.emitFile({
|
|
36
|
+
type: 'asset',
|
|
37
|
+
fileName: file.to,
|
|
38
|
+
source: readFileSync(require.resolve(file.from)),
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export default defineConfig({
|
|
46
|
+
// Relative asset URLs keep the build portable to GitHub Pages project sites.
|
|
47
|
+
base: './',
|
|
48
|
+
plugins: [copyPlaygroundWorker()],
|
|
49
|
+
build: {
|
|
50
|
+
target: 'es2022',
|
|
51
|
+
},
|
|
52
|
+
optimizeDeps: {
|
|
53
|
+
// Serve playground-elements unbundled in dev so its relative worker URL
|
|
54
|
+
// resolves against /node_modules/playground-elements/.
|
|
55
|
+
exclude: ['playground-elements'],
|
|
56
|
+
},
|
|
57
|
+
// The preview iframe runs on unpkg.com and loads the fonts from this
|
|
58
|
+
// server. Font requests always need CORS, and Vite only allows localhost
|
|
59
|
+
// origins by default.
|
|
60
|
+
server: {cors: true},
|
|
61
|
+
preview: {cors: true},
|
|
62
|
+
});
|