@swiftbrowser/web 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/README.md +600 -0
- package/app/index.html +125 -0
- package/index.html +56 -0
- package/node/dist/node/apps.d.ts +58 -0
- package/node/dist/node/apps.d.ts.map +1 -0
- package/node/dist/node/apps.js +60 -0
- package/node/dist/node/apps.js.map +1 -0
- package/node/dist/node/config.d.ts +42 -0
- package/node/dist/node/config.d.ts.map +1 -0
- package/node/dist/node/config.js +99 -0
- package/node/dist/node/config.js.map +1 -0
- package/node/dist/node/examples.d.ts +39 -0
- package/node/dist/node/examples.d.ts.map +1 -0
- package/node/dist/node/examples.js +116 -0
- package/node/dist/node/examples.js.map +1 -0
- package/node/dist/node/index.d.ts +47 -0
- package/node/dist/node/index.d.ts.map +1 -0
- package/node/dist/node/index.js +52 -0
- package/node/dist/node/index.js.map +1 -0
- package/node/dist/plugins/app-manifests.d.ts +61 -0
- package/node/dist/plugins/app-manifests.d.ts.map +1 -0
- package/node/dist/plugins/app-manifests.js +168 -0
- package/node/dist/plugins/app-manifests.js.map +1 -0
- package/node/dist/plugins/asset-catalogs.d.ts +119 -0
- package/node/dist/plugins/asset-catalogs.d.ts.map +1 -0
- package/node/dist/plugins/asset-catalogs.js +488 -0
- package/node/dist/plugins/asset-catalogs.js.map +1 -0
- package/node/dist/plugins/bundle-resources.d.ts +49 -0
- package/node/dist/plugins/bundle-resources.d.ts.map +1 -0
- package/node/dist/plugins/bundle-resources.js +172 -0
- package/node/dist/plugins/bundle-resources.js.map +1 -0
- package/node/dist/plugins/swift-wasm.d.ts +33 -0
- package/node/dist/plugins/swift-wasm.d.ts.map +1 -0
- package/node/dist/plugins/swift-wasm.js +241 -0
- package/node/dist/plugins/swift-wasm.js.map +1 -0
- package/node/dist/src/assets.d.ts +60 -0
- package/node/dist/src/assets.d.ts.map +1 -0
- package/node/dist/src/assets.js +54 -0
- package/node/dist/src/assets.js.map +1 -0
- package/node/dist/src/devEvents.d.ts +31 -0
- package/node/dist/src/devEvents.d.ts.map +1 -0
- package/node/dist/src/devEvents.js +9 -0
- package/node/dist/src/devEvents.js.map +1 -0
- package/package.json +56 -0
- package/plugins/app-manifests.ts +192 -0
- package/plugins/asset-catalogs.ts +558 -0
- package/plugins/bundle-resources.ts +195 -0
- package/plugins/swift-wasm.ts +259 -0
- package/public/_headers +23 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/icon.svg +14 -0
- package/public/manifest.webmanifest +14 -0
- package/src/animation.ts +521 -0
- package/src/assets.ts +86 -0
- package/src/chartDom.ts +481 -0
- package/src/devEvents.ts +35 -0
- package/src/deviceMode.ts +80 -0
- package/src/ghost.ts +25 -0
- package/src/interaction.ts +1113 -0
- package/src/landing.css +261 -0
- package/src/landing.ts +141 -0
- package/src/layout/chart.ts +477 -0
- package/src/layout/controls.ts +116 -0
- package/src/layout/engine.ts +3581 -0
- package/src/layout/index.ts +91 -0
- package/src/layout/measure.ts +86 -0
- package/src/layout/text.ts +124 -0
- package/src/layout/types.ts +153 -0
- package/src/layout/typography.ts +188 -0
- package/src/layoutDom.ts +549 -0
- package/src/layoutEvents.ts +110 -0
- package/src/locale.ts +64 -0
- package/src/main.ts +554 -0
- package/src/mock.ts +752 -0
- package/src/nodeQueries.ts +60 -0
- package/src/protocol.ts +1421 -0
- package/src/renderer.ts +2882 -0
- package/src/styles.css +2834 -0
- package/src/styles.ts +373 -0
- package/src/swipe.ts +110 -0
- package/src/symbols.ts +595 -0
- package/src/typography.ts +76 -0
- package/src/vite-env.d.ts +26 -0
- package/src/wasm.ts +400 -0
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serves every registered app's Xcode asset catalogs (the `*.xcassets`
|
|
3
|
+
* directories of its `AppSpec`) as one JSON manifest at `/catalog/<App>.json`
|
|
4
|
+
* plus the image files at `/catalog/<App>/<name>.<ext>` (dev server and
|
|
5
|
+
* `vite build`).
|
|
6
|
+
*
|
|
7
|
+
* The Swift side refers to catalog images and colors by name only
|
|
8
|
+
* (`Image("dough/plain-full")`, `Color("dough/plain-bg")`). The page fetches
|
|
9
|
+
* the manifest before loading the app (src/main.ts) so the layout engine
|
|
10
|
+
* knows every image's intrinsic size and the renderer can resolve the URL and
|
|
11
|
+
* the light/dark color values. See `AssetCatalog` in src/assets.ts.
|
|
12
|
+
*
|
|
13
|
+
* Catalog rules implemented here:
|
|
14
|
+
* - a folder whose `Contents.json` has `properties.provides-namespace: true`
|
|
15
|
+
* prefixes everything inside with `<folder>/` (nested folders chain);
|
|
16
|
+
* - `<name>.imageset`: the `2x` entry is preferred (then `1x`, `3x`, then the
|
|
17
|
+
* first entry with a file); points = pixels ÷ scale;
|
|
18
|
+
* - `<name>.symbolset`: an SVG drawn as a template (`kind: "symbol"`);
|
|
19
|
+
* - `<name>.colorset`: the entry without appearances is `light`, the one with
|
|
20
|
+
* `luminosity: dark` is `dark`; idiom and contrast variants are ignored.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import type { Plugin } from 'vite';
|
|
26
|
+
import { APP_NAME, type AppSpec } from '../node/apps.js';
|
|
27
|
+
import {
|
|
28
|
+
CATALOG_DIR,
|
|
29
|
+
assetFileName,
|
|
30
|
+
assetFilePath,
|
|
31
|
+
type AssetCatalog,
|
|
32
|
+
type AssetColor,
|
|
33
|
+
type AssetImage,
|
|
34
|
+
} from '../src/assets.js';
|
|
35
|
+
|
|
36
|
+
export { CATALOG_DIR, catalogPath, assetFilePath } from '../src/assets.js';
|
|
37
|
+
export type { AssetCatalog } from '../src/assets.js';
|
|
38
|
+
|
|
39
|
+
/** One file the manifest refers to. */
|
|
40
|
+
export interface CatalogFile {
|
|
41
|
+
/** Published path without a leading slash (`catalog/App/dough__plain-full.png`). */
|
|
42
|
+
fileName: string;
|
|
43
|
+
/** Absolute path of the source file on disk. */
|
|
44
|
+
source: string;
|
|
45
|
+
contentType: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface BuiltCatalog {
|
|
49
|
+
manifest: AssetCatalog;
|
|
50
|
+
/** Keyed by `fileName`. */
|
|
51
|
+
files: Map<string, CatalogFile>;
|
|
52
|
+
/** Entries that were skipped and why (the plugin logs them). */
|
|
53
|
+
warnings: string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const CONTENT_TYPES: Record<string, string> = {
|
|
57
|
+
png: 'image/png',
|
|
58
|
+
jpg: 'image/jpeg',
|
|
59
|
+
jpeg: 'image/jpeg',
|
|
60
|
+
webp: 'image/webp',
|
|
61
|
+
svg: 'image/svg+xml',
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Contents.json shapes (only the fields read here)
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
interface FolderContents {
|
|
69
|
+
properties?: { 'provides-namespace'?: boolean };
|
|
70
|
+
}
|
|
71
|
+
interface ImageSetContents {
|
|
72
|
+
images?: { filename?: string; idiom?: string; scale?: string }[];
|
|
73
|
+
}
|
|
74
|
+
interface SymbolSetContents {
|
|
75
|
+
symbols?: { filename?: string; idiom?: string }[];
|
|
76
|
+
}
|
|
77
|
+
interface ColorSetContents {
|
|
78
|
+
colors?: {
|
|
79
|
+
idiom?: string;
|
|
80
|
+
appearances?: { appearance?: string; value?: string }[];
|
|
81
|
+
color?: {
|
|
82
|
+
'color-space'?: string;
|
|
83
|
+
components?: { red?: string; green?: string; blue?: string; alpha?: string };
|
|
84
|
+
};
|
|
85
|
+
}[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readJson<T>(file: string): T | null {
|
|
89
|
+
try {
|
|
90
|
+
return JSON.parse(readFileSync(file, 'utf8')) as T;
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Color components
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* One colorset component as a 0...1 value. Xcode writes hex (`"0xC2"`),
|
|
102
|
+
* floats (`"0.439"`, `"1.000"`) or 0...255 integers (`"255"`). A bare `"0"`
|
|
103
|
+
* or `"1"` has no decimal point but is taken as 0 / 1 (full), as Xcode never
|
|
104
|
+
* writes 1/255 that way.
|
|
105
|
+
*/
|
|
106
|
+
export function parseComponent(value: string | undefined, fallback = 1): number {
|
|
107
|
+
if (typeof value !== 'string') return fallback;
|
|
108
|
+
const text = value.trim();
|
|
109
|
+
if (/^0x[0-9a-f]+$/i.test(text)) return clamp01(parseInt(text.slice(2), 16) / 255);
|
|
110
|
+
if (/^-?\d+$/.test(text)) {
|
|
111
|
+
const n = parseInt(text, 10);
|
|
112
|
+
return clamp01(n > 1 ? n / 255 : n);
|
|
113
|
+
}
|
|
114
|
+
const n = Number(text);
|
|
115
|
+
return Number.isFinite(n) ? clamp01(n) : fallback;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function clamp01(v: number): number {
|
|
119
|
+
return Math.min(1, Math.max(0, v));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** CSS `rgba(r, g, b, a)` for colorset components (r/g/b 0...255, a 0...1). */
|
|
123
|
+
export function componentsToCSS(components: {
|
|
124
|
+
red?: string;
|
|
125
|
+
green?: string;
|
|
126
|
+
blue?: string;
|
|
127
|
+
alpha?: string;
|
|
128
|
+
}): string {
|
|
129
|
+
const channel = (v: string | undefined): number => Math.round(parseComponent(v, 0) * 255);
|
|
130
|
+
const alpha = Math.round(parseComponent(components.alpha, 1) * 1000) / 1000;
|
|
131
|
+
return `rgba(${channel(components.red)}, ${channel(components.green)}, ${channel(components.blue)}, ${alpha})`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The `light` / `dark` CSS colors of a colorset, or null when it has no usable entry. */
|
|
135
|
+
export function parseColorSet(contents: ColorSetContents): AssetColor | null {
|
|
136
|
+
let light: string | undefined;
|
|
137
|
+
let dark: string | undefined;
|
|
138
|
+
for (const entry of contents.colors ?? []) {
|
|
139
|
+
const components = entry.color?.components;
|
|
140
|
+
if (!components) continue;
|
|
141
|
+
const appearances = entry.appearances ?? [];
|
|
142
|
+
// Contrast ("high") variants are not rendered; skip entries that carry one.
|
|
143
|
+
if (appearances.some((a) => a.appearance === 'contrast')) continue;
|
|
144
|
+
const luminosity = appearances.find((a) => a.appearance === 'luminosity')?.value;
|
|
145
|
+
const css = componentsToCSS(components);
|
|
146
|
+
if (luminosity === 'dark') dark ??= css;
|
|
147
|
+
else if (luminosity === undefined) light ??= css;
|
|
148
|
+
}
|
|
149
|
+
if (light === undefined) {
|
|
150
|
+
if (dark === undefined) return null;
|
|
151
|
+
light = dark;
|
|
152
|
+
}
|
|
153
|
+
return dark === undefined ? { light } : { light, dark };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// Image dimensions
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
export interface PixelSize {
|
|
161
|
+
width: number;
|
|
162
|
+
height: number;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Pixel size of a PNG (IHDR), JPEG (the first SOF segment), WebP (VP8,
|
|
167
|
+
* VP8L or VP8X chunk) or SVG (`width`/`height` attributes, else `viewBox`);
|
|
168
|
+
* null for anything else.
|
|
169
|
+
*/
|
|
170
|
+
export function readImageSize(data: Buffer, ext: string): PixelSize | null {
|
|
171
|
+
switch (ext.toLowerCase()) {
|
|
172
|
+
case 'png':
|
|
173
|
+
return pngSize(data);
|
|
174
|
+
case 'jpg':
|
|
175
|
+
case 'jpeg':
|
|
176
|
+
return jpegSize(data);
|
|
177
|
+
case 'webp':
|
|
178
|
+
return webpSize(data);
|
|
179
|
+
case 'svg':
|
|
180
|
+
return svgSize(data.toString('utf8'));
|
|
181
|
+
default:
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
187
|
+
|
|
188
|
+
function pngSize(data: Buffer): PixelSize | null {
|
|
189
|
+
if (data.length < 24 || !data.subarray(0, 8).equals(PNG_SIGNATURE)) return null;
|
|
190
|
+
if (data.toString('latin1', 12, 16) !== 'IHDR') return null;
|
|
191
|
+
return { width: data.readUInt32BE(16), height: data.readUInt32BE(20) };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** RIFF/WEBP: the first chunk is VP8 (lossy), VP8L (lossless) or VP8X (extended). */
|
|
195
|
+
function webpSize(data: Buffer): PixelSize | null {
|
|
196
|
+
if (data.length < 30 || data.toString('latin1', 0, 4) !== 'RIFF' || data.toString('latin1', 8, 12) !== 'WEBP') return null;
|
|
197
|
+
const chunk = data.toString('latin1', 12, 16);
|
|
198
|
+
if (chunk === 'VP8X') {
|
|
199
|
+
// 24-bit little-endian canvas width/height minus one at bytes 24 and 27.
|
|
200
|
+
const width = 1 + (data[24]! | (data[25]! << 8) | (data[26]! << 16));
|
|
201
|
+
const height = 1 + (data[27]! | (data[28]! << 8) | (data[29]! << 16));
|
|
202
|
+
return { width, height };
|
|
203
|
+
}
|
|
204
|
+
if (chunk === 'VP8L') {
|
|
205
|
+
if (data[20] !== 0x2f) return null;
|
|
206
|
+
const bits = data.readUInt32LE(21);
|
|
207
|
+
return { width: 1 + (bits & 0x3fff), height: 1 + ((bits >>> 14) & 0x3fff) };
|
|
208
|
+
}
|
|
209
|
+
if (chunk === 'VP8 ') {
|
|
210
|
+
// Key frame: 3-byte frame tag, start code 9d 01 2a, then 14-bit width and height.
|
|
211
|
+
if (data[23] !== 0x9d || data[24] !== 0x01 || data[25] !== 0x2a) return null;
|
|
212
|
+
return { width: data.readUInt16LE(26) & 0x3fff, height: data.readUInt16LE(28) & 0x3fff };
|
|
213
|
+
}
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function jpegSize(data: Buffer): PixelSize | null {
|
|
218
|
+
if (data.length < 4 || data[0] !== 0xff || data[1] !== 0xd8) return null;
|
|
219
|
+
let offset = 2;
|
|
220
|
+
while (offset + 9 <= data.length) {
|
|
221
|
+
if (data[offset] !== 0xff) return null;
|
|
222
|
+
const marker = data[offset + 1] as number;
|
|
223
|
+
if (marker === 0xff) {
|
|
224
|
+
offset += 1; // padding
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7) || marker === 0x01) {
|
|
228
|
+
offset += 2; // standalone markers without a length
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const length = data.readUInt16BE(offset + 2);
|
|
232
|
+
// SOF0...SOF15 except DHT (C4), JPG (C8) and DAC (CC) hold the frame size.
|
|
233
|
+
const isSOF = marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
|
|
234
|
+
if (isSOF) {
|
|
235
|
+
return { height: data.readUInt16BE(offset + 5), width: data.readUInt16BE(offset + 7) };
|
|
236
|
+
}
|
|
237
|
+
if (marker === 0xda || marker === 0xd9) return null; // scan data / end: no SOF found
|
|
238
|
+
offset += 2 + length;
|
|
239
|
+
}
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** A CSS length from an SVG attribute, in px (pt is 4/3 px; other units are taken as px). */
|
|
244
|
+
function svgLength(value: string | undefined): number | null {
|
|
245
|
+
if (!value) return null;
|
|
246
|
+
const match = /^\s*([0-9]*\.?[0-9]+)\s*([a-z%]*)\s*$/i.exec(value);
|
|
247
|
+
if (!match) return null;
|
|
248
|
+
const n = Number(match[1]);
|
|
249
|
+
if (!Number.isFinite(n) || n <= 0) return null;
|
|
250
|
+
if (match[2] === '%') return null;
|
|
251
|
+
return match[2]?.toLowerCase() === 'pt' ? (n * 4) / 3 : n;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function svgSize(text: string): PixelSize | null {
|
|
255
|
+
const open = /<svg\b[^>]*>/i.exec(text);
|
|
256
|
+
if (!open) return null;
|
|
257
|
+
const tag = open[0];
|
|
258
|
+
const attr = (name: string): string | undefined =>
|
|
259
|
+
new RegExp(`\\s${name}\\s*=\\s*["']([^"']*)["']`, 'i').exec(tag)?.[1];
|
|
260
|
+
const width = svgLength(attr('width'));
|
|
261
|
+
const height = svgLength(attr('height'));
|
|
262
|
+
if (width !== null && height !== null) return { width, height };
|
|
263
|
+
const viewBox = attr('viewBox');
|
|
264
|
+
if (viewBox) {
|
|
265
|
+
const parts = viewBox.trim().split(/[\s,]+/).map(Number);
|
|
266
|
+
const vw = parts[2];
|
|
267
|
+
const vh = parts[3];
|
|
268
|
+
if (parts.length === 4 && vw !== undefined && vh !== undefined && vw > 0 && vh > 0) {
|
|
269
|
+
// One explicit dimension with a viewBox: derive the other from the aspect ratio.
|
|
270
|
+
if (width !== null) return { width, height: (width * vh) / vw };
|
|
271
|
+
if (height !== null) return { width: (height * vw) / vh, height };
|
|
272
|
+
return { width: vw, height: vh };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return null;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ---------------------------------------------------------------------------
|
|
279
|
+
// Catalog scanning
|
|
280
|
+
// ---------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
const SCALE_PREFERENCE = ['2x', '1x', '3x'];
|
|
283
|
+
|
|
284
|
+
/** The image entry to publish for an imageset, with its numeric scale. */
|
|
285
|
+
export function pickImage(contents: ImageSetContents): { filename: string; scale: number } | null {
|
|
286
|
+
const images = (contents.images ?? []).filter(
|
|
287
|
+
(image): image is { filename: string; idiom?: string; scale?: string } => typeof image.filename === 'string',
|
|
288
|
+
);
|
|
289
|
+
for (const scale of SCALE_PREFERENCE) {
|
|
290
|
+
const match = images.find((image) => image.scale === scale);
|
|
291
|
+
if (match) return { filename: match.filename, scale: parseInt(scale, 10) };
|
|
292
|
+
}
|
|
293
|
+
const first = images[0];
|
|
294
|
+
if (!first) return null;
|
|
295
|
+
const scale = first.scale ? parseInt(first.scale, 10) : 1;
|
|
296
|
+
return { filename: first.filename, scale: Number.isFinite(scale) && scale > 0 ? scale : 1 };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
interface Scan {
|
|
300
|
+
app: string;
|
|
301
|
+
manifest: AssetCatalog;
|
|
302
|
+
files: Map<string, CatalogFile>;
|
|
303
|
+
warnings: string[];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function extensionOf(file: string): string {
|
|
307
|
+
return path.extname(file).slice(1).toLowerCase();
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function addFile(scan: Scan, name: string, source: string, relativeToCatalog: string): AssetImage | null {
|
|
311
|
+
const ext = extensionOf(source);
|
|
312
|
+
const contentType = CONTENT_TYPES[ext];
|
|
313
|
+
if (!contentType) {
|
|
314
|
+
scan.warnings.push(`${relativeToCatalog}: unsupported image type .${ext}, skipped`);
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
const fileName = `${CATALOG_DIR}/${scan.app}/${assetFileName(name, ext)}`;
|
|
318
|
+
scan.files.set(fileName, { fileName, source, contentType });
|
|
319
|
+
return { src: assetFilePath(scan.app, name, ext), width: 0, height: 0, scale: 1, kind: 'image' };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function scanImageSet(scan: Scan, dir: string, name: string, label: string): void {
|
|
323
|
+
const contents = readJson<ImageSetContents>(path.join(dir, 'Contents.json'));
|
|
324
|
+
const picked = contents ? pickImage(contents) : null;
|
|
325
|
+
if (!picked) {
|
|
326
|
+
scan.warnings.push(`${label}: no image file listed in Contents.json, skipped`);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
const source = path.join(dir, picked.filename);
|
|
330
|
+
if (!existsSync(source)) {
|
|
331
|
+
scan.warnings.push(`${label}: ${picked.filename} is missing, skipped`);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
const entry = addFile(scan, name, source, label);
|
|
335
|
+
if (!entry) return;
|
|
336
|
+
const size = readImageSize(readFileSync(source), extensionOf(source));
|
|
337
|
+
if (!size) {
|
|
338
|
+
scan.warnings.push(`${label}: could not read the size of ${picked.filename}, skipped`);
|
|
339
|
+
scan.files.delete(`${CATALOG_DIR}/${scan.app}/${assetFileName(name, extensionOf(source))}`);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
entry.width = size.width / picked.scale;
|
|
343
|
+
entry.height = size.height / picked.scale;
|
|
344
|
+
entry.scale = picked.scale;
|
|
345
|
+
scan.manifest.images[name] = entry;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function scanSymbolSet(scan: Scan, dir: string, name: string, label: string): void {
|
|
349
|
+
const contents = readJson<SymbolSetContents>(path.join(dir, 'Contents.json'));
|
|
350
|
+
const filename = contents?.symbols?.find((s) => typeof s.filename === 'string')?.filename;
|
|
351
|
+
if (!filename) {
|
|
352
|
+
scan.warnings.push(`${label}: no symbol file listed in Contents.json, skipped`);
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const source = path.join(dir, filename);
|
|
356
|
+
if (!existsSync(source)) {
|
|
357
|
+
scan.warnings.push(`${label}: ${filename} is missing, skipped`);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (extensionOf(source) !== 'svg') {
|
|
361
|
+
scan.warnings.push(`${label}: a symbolset must hold an SVG, skipped`);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const entry = addFile(scan, name, source, label);
|
|
365
|
+
if (!entry) return;
|
|
366
|
+
entry.kind = 'symbol';
|
|
367
|
+
// The layout engine sizes symbols from the font, so the size is informative only.
|
|
368
|
+
const size = readImageSize(readFileSync(source), 'svg');
|
|
369
|
+
if (size) {
|
|
370
|
+
entry.width = size.width;
|
|
371
|
+
entry.height = size.height;
|
|
372
|
+
}
|
|
373
|
+
scan.manifest.images[name] = entry;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function scanColorSet(scan: Scan, dir: string, name: string, label: string): void {
|
|
377
|
+
const contents = readJson<ColorSetContents>(path.join(dir, 'Contents.json'));
|
|
378
|
+
const color = contents ? parseColorSet(contents) : null;
|
|
379
|
+
if (!color) {
|
|
380
|
+
scan.warnings.push(`${label}: no color components in Contents.json, skipped`);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
scan.manifest.colors[name] = color;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Walk one directory of a catalog; `prefix` is the namespace so far (`dough/`). */
|
|
387
|
+
function scanDirectory(scan: Scan, dir: string, prefix: string, label: string): void {
|
|
388
|
+
const entries = readdirSync(dir, { withFileTypes: true })
|
|
389
|
+
.filter((entry) => entry.isDirectory() && !entry.name.startsWith('.'))
|
|
390
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
391
|
+
for (const entry of entries) {
|
|
392
|
+
const full = path.join(dir, entry.name);
|
|
393
|
+
const ext = path.extname(entry.name);
|
|
394
|
+
const base = path.basename(entry.name, ext);
|
|
395
|
+
const name = prefix + base;
|
|
396
|
+
const entryLabel = `${label}/${entry.name}`;
|
|
397
|
+
switch (ext) {
|
|
398
|
+
case '.imageset':
|
|
399
|
+
scanImageSet(scan, full, name, entryLabel);
|
|
400
|
+
break;
|
|
401
|
+
case '.symbolset':
|
|
402
|
+
scanSymbolSet(scan, full, name, entryLabel);
|
|
403
|
+
break;
|
|
404
|
+
case '.colorset':
|
|
405
|
+
scanColorSet(scan, full, name, entryLabel);
|
|
406
|
+
break;
|
|
407
|
+
case '':
|
|
408
|
+
case undefined: {
|
|
409
|
+
// A plain folder: a namespace when its Contents.json says so.
|
|
410
|
+
const contents = readJson<FolderContents>(path.join(full, 'Contents.json'));
|
|
411
|
+
const namespaced = contents?.properties?.['provides-namespace'] === true;
|
|
412
|
+
scanDirectory(scan, full, namespaced ? `${name}/` : prefix, entryLabel);
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
default:
|
|
416
|
+
// App icons, launch images, data sets, ...: not addressable from SwiftUI views.
|
|
417
|
+
break;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Every `*.xcassets` directory under `dir` (recursive, skipping hidden directories). */
|
|
423
|
+
export function findAssetCatalogs(dir: string): string[] {
|
|
424
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) return [];
|
|
425
|
+
const found: string[] = [];
|
|
426
|
+
const walk = (current: string): void => {
|
|
427
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
428
|
+
if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
|
|
429
|
+
const full = path.join(current, entry.name);
|
|
430
|
+
if (entry.name.endsWith('.xcassets')) found.push(full);
|
|
431
|
+
else if (entry.name !== 'node_modules') walk(full);
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
walk(dir);
|
|
435
|
+
return found.sort();
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Build the manifest of `app` from the given `.xcassets` directories (later
|
|
440
|
+
* catalogs do not override earlier names: the first definition wins, with a
|
|
441
|
+
* warning for the duplicate).
|
|
442
|
+
*/
|
|
443
|
+
export function buildCatalog(app: string, catalogDirs: readonly string[]): BuiltCatalog {
|
|
444
|
+
if (!APP_NAME.test(app)) throw new Error(`Invalid app name: ${JSON.stringify(app)}`);
|
|
445
|
+
const scan: Scan = { app, manifest: { images: {}, colors: {} }, files: new Map(), warnings: [] };
|
|
446
|
+
for (const dir of catalogDirs) {
|
|
447
|
+
const partial: Scan = { app, manifest: { images: {}, colors: {} }, files: new Map(), warnings: scan.warnings };
|
|
448
|
+
scanDirectory(partial, dir, '', path.basename(dir));
|
|
449
|
+
for (const [name, image] of Object.entries(partial.manifest.images)) {
|
|
450
|
+
if (name in scan.manifest.images) {
|
|
451
|
+
scan.warnings.push(`${path.basename(dir)}: image "${name}" is already defined by another catalog, skipped`);
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
scan.manifest.images[name] = image;
|
|
455
|
+
}
|
|
456
|
+
for (const [name, color] of Object.entries(partial.manifest.colors)) {
|
|
457
|
+
if (name in scan.manifest.colors) {
|
|
458
|
+
scan.warnings.push(`${path.basename(dir)}: color "${name}" is already defined by another catalog, skipped`);
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
scan.manifest.colors[name] = color;
|
|
462
|
+
}
|
|
463
|
+
for (const [fileName, file] of partial.files) {
|
|
464
|
+
if (!scan.files.has(fileName)) scan.files.set(fileName, file);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
// Drop files whose image was skipped or shadowed.
|
|
468
|
+
const referenced = new Set(Object.values(scan.manifest.images).map((image) => image.src.slice(1)));
|
|
469
|
+
for (const fileName of Array.from(scan.files.keys())) {
|
|
470
|
+
if (!referenced.has(fileName)) scan.files.delete(fileName);
|
|
471
|
+
}
|
|
472
|
+
return { manifest: scan.manifest, files: scan.files, warnings: scan.warnings };
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/** The manifest of the app at `<examplesDir>/<App>` (all catalogs under it). */
|
|
476
|
+
export function buildAppCatalog(examplesDir: string, app: string): BuiltCatalog {
|
|
477
|
+
return buildCatalog(app, findAssetCatalogs(path.join(examplesDir, app)));
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export function catalogJson(catalog: AssetCatalog): string {
|
|
481
|
+
return JSON.stringify(catalog, null, 2) + '\n';
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** The app named by a manifest request URL (`/catalog/<App>.json`), or null. */
|
|
485
|
+
export function catalogRequestApp(url: string): string | null {
|
|
486
|
+
const match = new RegExp(`^/${CATALOG_DIR}/([A-Za-z0-9_-]+)\\.json(?:\\?|$)`).exec(url);
|
|
487
|
+
return match?.[1] ?? null;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** The app and published file name of an asset file request, or null. */
|
|
491
|
+
export function catalogRequestFile(url: string): { app: string; fileName: string } | null {
|
|
492
|
+
const match = new RegExp(`^/${CATALOG_DIR}/([A-Za-z0-9_-]+)/([A-Za-z0-9_.-]+)(?:\\?|$)`).exec(url);
|
|
493
|
+
if (!match?.[1] || !match[2] || match[2].startsWith('.')) return null;
|
|
494
|
+
return { app: match[1], fileName: `${CATALOG_DIR}/${match[1]}/${match[2]}` };
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** The fields of an {@link AppSpec} the catalog plugin reads. */
|
|
498
|
+
export type CatalogSpec = Pick<AppSpec, 'name' | 'catalogs'>;
|
|
499
|
+
|
|
500
|
+
export function assetCatalogs(specs: readonly CatalogSpec[]): Plugin {
|
|
501
|
+
const byName = new Map(specs.map((spec) => [spec.name, spec]));
|
|
502
|
+
|
|
503
|
+
return {
|
|
504
|
+
name: 'swiftbrowser:asset-catalogs',
|
|
505
|
+
configureServer(server) {
|
|
506
|
+
const warned = new Set<string>();
|
|
507
|
+
// Rebuilt on every manifest request so edits to a catalog show up on
|
|
508
|
+
// reload; scanning a few directories is cheap.
|
|
509
|
+
const build = (spec: CatalogSpec): BuiltCatalog => {
|
|
510
|
+
const built = buildCatalog(spec.name, spec.catalogs);
|
|
511
|
+
for (const warning of built.warnings) {
|
|
512
|
+
if (warned.has(warning)) continue;
|
|
513
|
+
warned.add(warning);
|
|
514
|
+
server.config.logger.warn(`[asset-catalogs] ${spec.name}: ${warning}`, { timestamp: true });
|
|
515
|
+
}
|
|
516
|
+
return built;
|
|
517
|
+
};
|
|
518
|
+
server.middlewares.use((req, res, next) => {
|
|
519
|
+
const url = req.url ?? '';
|
|
520
|
+
const app = catalogRequestApp(url);
|
|
521
|
+
if (app !== null) {
|
|
522
|
+
const spec = byName.get(app);
|
|
523
|
+
if (!spec) {
|
|
524
|
+
res.statusCode = 404;
|
|
525
|
+
res.end();
|
|
526
|
+
return;
|
|
527
|
+
}
|
|
528
|
+
res.setHeader('Content-Type', 'application/json');
|
|
529
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
530
|
+
res.end(catalogJson(build(spec).manifest));
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
const file = catalogRequestFile(url);
|
|
534
|
+
if (file === null) return next();
|
|
535
|
+
const spec = byName.get(file.app);
|
|
536
|
+
const entry = spec ? build(spec).files.get(file.fileName) : undefined;
|
|
537
|
+
if (!entry) {
|
|
538
|
+
res.statusCode = 404;
|
|
539
|
+
res.end();
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
res.setHeader('Content-Type', entry.contentType);
|
|
543
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
544
|
+
res.end(readFileSync(entry.source));
|
|
545
|
+
});
|
|
546
|
+
},
|
|
547
|
+
generateBundle() {
|
|
548
|
+
for (const spec of specs) {
|
|
549
|
+
const built = buildCatalog(spec.name, spec.catalogs);
|
|
550
|
+
for (const warning of built.warnings) this.warn(`${spec.name}: ${warning}`);
|
|
551
|
+
this.emitFile({ type: 'asset', fileName: `${CATALOG_DIR}/${spec.name}.json`, source: catalogJson(built.manifest) });
|
|
552
|
+
for (const file of built.files.values()) {
|
|
553
|
+
this.emitFile({ type: 'asset', fileName: file.fileName, source: readFileSync(file.source) });
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
},
|
|
557
|
+
};
|
|
558
|
+
}
|