@mosetta/editor 0.0.0-stage → 0.0.28
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/CHANGELOG.md +185 -0
- package/LICENSE +21 -0
- package/README.md +10 -2
- package/mosetta.json +6 -0
- package/package.json +35 -3
- package/prefabs/workbench/prefab.json +11 -0
- package/prefabs/workbench/versions/1.json +120 -0
- package/prefabs/workbench/versions/2.json +13 -0
- package/prefabs/workbench/versions/3.json +19 -0
- package/prefabs/workbench/versions/4.json +29 -0
- package/prefabs/workbench/versions/5.json +29 -0
- package/tiles/canvas/canvas.component.tsx +2038 -0
- package/tiles/canvas/canvas.css +973 -0
- package/tiles/canvas/canvasTools.ts +66 -0
- package/tiles/canvas/chromeNodes.ts +35 -0
- package/tiles/canvas/ghosts.ts +33 -0
- package/tiles/canvas/insertPopover.tsx +56 -0
- package/tiles/canvas/popoverPlacement.ts +104 -0
- package/tiles/canvas/popoverSize.ts +87 -0
- package/tiles/canvas/providerChrome.tsx +191 -0
- package/tiles/canvas/scrollWatch.ts +43 -0
- package/tiles/canvas/settingsPopover.tsx +257 -0
- package/tiles/canvas/settingsTabs.ts +78 -0
- package/tiles/canvas/toolPanel.tsx +86 -0
- package/tiles/content/autoBind.ts +61 -0
- package/tiles/content/blockPage.css +390 -0
- package/tiles/content/blockPage.tsx +379 -0
- package/tiles/content/blockPicker.css +215 -0
- package/tiles/content/blockPicker.tsx +501 -0
- package/tiles/content/blockUpgrades.ts +145 -0
- package/tiles/content/blockVariants.ts +185 -0
- package/tiles/content/blockVocabulary.ts +77 -0
- package/tiles/content/catalogVersions.ts +106 -0
- package/tiles/content/componentPreview.css +82 -0
- package/tiles/content/componentPreview.tsx +249 -0
- package/tiles/content/content.server.ts +593 -0
- package/tiles/content/contentCommon.ts +133 -0
- package/tiles/content/disconnected.ts +56 -0
- package/tiles/content/distributions.css +256 -0
- package/tiles/content/distributions.tsx +271 -0
- package/tiles/content/editingAutosave.ts +125 -0
- package/tiles/content/editingClipboard.ts +185 -0
- package/tiles/content/editingConflicts.ts +223 -0
- package/tiles/content/editingHotkeys.ts +59 -0
- package/tiles/content/editingNotices.ts +105 -0
- package/tiles/content/formRoot.ts +18 -0
- package/tiles/content/fuzzySearch.ts +121 -0
- package/tiles/content/mediaUpload.ts +55 -0
- package/tiles/content/moduleSiblings.ts +9 -0
- package/tiles/content/mountedTree.ts +136 -0
- package/tiles/content/nodeSettings.css +483 -0
- package/tiles/content/nodeSettings.tsx +963 -0
- package/tiles/content/pasteText.ts +131 -0
- package/tiles/content/prefabFields.tsx +158 -0
- package/tiles/content/prefabParts.ts +105 -0
- package/tiles/content/previewEnvironment.ts +46 -0
- package/tiles/content/projectConfig.ts +46 -0
- package/tiles/content/projectPrefabs.ts +57 -0
- package/tiles/content/publishModal.css +109 -0
- package/tiles/content/publishModal.tsx +92 -0
- package/tiles/content/refsEditing.ts +412 -0
- package/tiles/content/scaledStage.ts +63 -0
- package/tiles/content/searchField.css +32 -0
- package/tiles/content/searchField.tsx +32 -0
- package/tiles/content/settingsDraft.ts +178 -0
- package/tiles/content/settingsIssues.tsx +124 -0
- package/tiles/content/themeScope.ts +72 -0
- package/tiles/content/themeVariables.ts +47 -0
- package/tiles/content/tooltipPlacement.ts +17 -0
- package/tiles/content/tooltips.tsx +131 -0
- package/tiles/content/treeMounter.ts +33 -0
- package/tiles/content/updatesModal.css +124 -0
- package/tiles/content/updatesModal.tsx +110 -0
- package/tiles/content/useCtrlHeld.ts +24 -0
- package/tiles/content/useEditingSession.ts +115 -0
- package/tiles/content/useMountedTree.ts +47 -0
- package/tiles/content/workbenchScale.css +27 -0
- package/tiles/hostFallback/hostFallback.component.tsx +32 -0
- package/tiles/hostFallback/hostFallback.css +12 -0
- package/tiles/hostFallback/hostFallback.editor.tsx +10 -0
- package/tiles/page/page.component.tsx +7 -0
- package/tiles/page/page.script.tsx +5 -0
- package/tiles/pageModules/pageModules.component.tsx +740 -0
- package/tiles/pageModules/pageModules.css +383 -0
- package/tiles/pageModules/pageModules.icon.svg +1 -0
- package/tiles/pageModules/servedPage.ts +465 -0
- package/tiles/pagesPanel/pagesPanel.component.tsx +1645 -0
- package/tiles/pagesPanel/pagesPanel.css +713 -0
- package/tiles/pagesPanel/pagesPanel.editor.tsx +23 -0
- package/tiles/pagesPanel/pagesSearch.ts +194 -0
- package/tiles/pagesPanel/routeTree.ts +98 -0
- package/tiles/previewBridge/landing.ts +66 -0
- package/tiles/previewBridge/previewAgent.ts +457 -0
- package/tiles/previewBridge/previewBridge.component.tsx +137 -0
- package/tiles/previewBridge/previewBridge.editor.tsx +10 -0
- package/tiles/previewBridge/previewBridgeCommon.ts +172 -0
- package/tiles/previewBridge/standIn.ts +22 -0
- package/tiles/treeView/treeView.component.tsx +514 -0
- package/tiles/treeView/treeView.css +303 -0
- package/tiles/treeView/treeView.editor.tsx +12 -0
- package/tiles/workbench/assistant.css +401 -0
- package/tiles/workbench/assistant.tsx +516 -0
- package/tiles/workbench/assistantChat.ts +165 -0
- package/tiles/workbench/dictation.ts +354 -0
- package/tiles/workbench/projectInfo.ts +72 -0
- package/tiles/workbench/recent.ts +67 -0
- package/tiles/workbench/workbench.component.tsx +476 -0
- package/tiles/workbench/workbench.css +376 -0
- package/tiles/workbench/workbenchAddress.ts +202 -0
- package/tiles/workbench/workspace.ts +775 -0
- package/tiles/workbenchEditor/workbenchEditor.component.tsx +461 -0
- package/tiles/workbenchEditor/workbenchEditor.css +370 -0
- package/tiles/workbenchHome/workbenchHome.component.tsx +196 -0
- package/tiles/workbenchHome/workbenchHome.css +271 -0
- package/tsconfig.json +3 -0
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
// THE PAGE AS A READER GETS IT (ADR-0062) — plain logic over what the server answers for a
|
|
2
|
+
// document's clean preview: the files its HTML makes the browser fetch (scripts, styles, images),
|
|
3
|
+
// the modules they belong to, what each module ASKED for against what the page LOADS, and the
|
|
4
|
+
// sums. The server already settled the page's framework (one copy of each package, ADR-0061), so
|
|
5
|
+
// nothing here re-computes that: an edge whose asked version is not among the loaded ones, while
|
|
6
|
+
// another version of the same package is, was taken over by a rule.
|
|
7
|
+
import { compareVersions } from '@mosetta/content';
|
|
8
|
+
|
|
9
|
+
export type TFileKind = 'js' | 'css' | 'image' | 'font' | 'other';
|
|
10
|
+
|
|
11
|
+
/** a file the page makes the browser fetch */
|
|
12
|
+
export interface IServedFile {
|
|
13
|
+
/** absolute, as the page names it */
|
|
14
|
+
readonly url: string;
|
|
15
|
+
readonly kind: TFileKind;
|
|
16
|
+
/** the module whose folder holds it (`…/modules/<id>/…`), or null for media and the like */
|
|
17
|
+
readonly moduleId: string | null;
|
|
18
|
+
/** an image the browser fetches only when it comes near the screen */
|
|
19
|
+
readonly lazy?: boolean;
|
|
20
|
+
/** a font the styles declare and the page's text never asks for — the browser never fetches it */
|
|
21
|
+
readonly unused?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** a catalog entry, as much of it as is read here */
|
|
25
|
+
export interface ICatalogItem {
|
|
26
|
+
readonly id: string;
|
|
27
|
+
readonly reference: {
|
|
28
|
+
readonly type?: string;
|
|
29
|
+
readonly namespace?: string;
|
|
30
|
+
readonly name?: string;
|
|
31
|
+
readonly version?: string;
|
|
32
|
+
readonly specifier?: string;
|
|
33
|
+
};
|
|
34
|
+
readonly dependencies?: readonly string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const EXTENSION_KINDS: Readonly<Record<string, TFileKind>> = {
|
|
38
|
+
js: 'js', mjs: 'js', css: 'css',
|
|
39
|
+
png: 'image', jpg: 'image', jpeg: 'image', gif: 'image', webp: 'image', avif: 'image', svg: 'image', ico: 'image',
|
|
40
|
+
woff: 'font', woff2: 'font', ttf: 'font', otf: 'font', eot: 'font',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const AS_KINDS: Readonly<Record<string, TFileKind>> = { script: 'js', style: 'css', image: 'image', font: 'font' };
|
|
44
|
+
|
|
45
|
+
function kindOf(url: URL, as?: string): TFileKind {
|
|
46
|
+
const extension = /\.([a-z0-9]+)$/i.exec(url.pathname)?.[1]?.toLowerCase();
|
|
47
|
+
return (extension ? EXTENSION_KINDS[extension] : undefined) ?? (as ? AS_KINDS[as] : undefined) ?? 'other';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** the module a file's folder names: `…/modules/<id>/<file>` */
|
|
51
|
+
function moduleIdOf(url: URL): string | null {
|
|
52
|
+
const match = /\/modules\/([^/]+)\/[^/]+$/.exec(url.pathname);
|
|
53
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const decodeEntities = (value: string): string => value
|
|
57
|
+
.replace(/"/g, '"').replace(/'/g, '\'').replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
|
58
|
+
|
|
59
|
+
function attributesOf(tag: string): Record<string, string> {
|
|
60
|
+
const attributes: Record<string, string> = {};
|
|
61
|
+
for (const match of tag.matchAll(/([a-zA-Z][\w:-]*)\s*=\s*("([^"]*)"|'([^']*)')/g)) {
|
|
62
|
+
attributes[match[1].toLowerCase()] = decodeEntities(match[3] ?? match[4] ?? '');
|
|
63
|
+
}
|
|
64
|
+
return attributes;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** one key per file: the same file under another cache-busting query is the same file */
|
|
68
|
+
const fileKey = (url: URL): string => `${url.origin}${url.pathname}`;
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The files a page's HTML makes the browser fetch — preloads, stylesheets, scripts, images (the
|
|
72
|
+
* address the browser falls back to, `src`) — each once, and the page's own document (the
|
|
73
|
+
* `pageContent` the server put in, its version rules resolved) when there is one.
|
|
74
|
+
*/
|
|
75
|
+
export function readServedPage(html: string, pageUrl: string): { files: IServedFile[]; doc: Record<string, unknown> | null } {
|
|
76
|
+
const files = new Map<string, IServedFile>();
|
|
77
|
+
const add = (href: string | undefined, as?: string, lazy?: boolean) => {
|
|
78
|
+
if (!href || href.startsWith('data:')) return;
|
|
79
|
+
let url: URL;
|
|
80
|
+
try {
|
|
81
|
+
url = new URL(href, pageUrl);
|
|
82
|
+
} catch {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const key = fileKey(url);
|
|
86
|
+
if (files.has(key)) return;
|
|
87
|
+
files.set(key, { url: url.href, kind: kindOf(url, as), moduleId: moduleIdOf(url), ...(lazy ? { lazy } : {}) });
|
|
88
|
+
};
|
|
89
|
+
for (const [tag] of html.matchAll(/<link\b[^>]*>/gi)) {
|
|
90
|
+
const a = attributesOf(tag);
|
|
91
|
+
const rel = (a.rel ?? '').toLowerCase().split(/\s+/);
|
|
92
|
+
if (rel.includes('preload') || rel.includes('modulepreload') || rel.includes('stylesheet')) add(a.href, a.as ?? (rel.includes('stylesheet') ? 'style' : undefined));
|
|
93
|
+
}
|
|
94
|
+
for (const [tag] of html.matchAll(/<script\b[^>]*>/gi)) add(attributesOf(tag).src, 'script');
|
|
95
|
+
for (const [tag] of html.matchAll(/<img\b[^>]*>/gi)) {
|
|
96
|
+
const a = attributesOf(tag);
|
|
97
|
+
add(a.src, 'image', a.loading === 'lazy');
|
|
98
|
+
}
|
|
99
|
+
const content = /<script\b[^>]*\bid="pageContent"[^>]*>([\s\S]*?)<\/script>/i.exec(html)?.[1];
|
|
100
|
+
let doc: Record<string, unknown> | null = null;
|
|
101
|
+
if (content) {
|
|
102
|
+
try {
|
|
103
|
+
doc = JSON.parse(content) as Record<string, unknown>;
|
|
104
|
+
} catch {
|
|
105
|
+
doc = null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { files: [...files.values()], doc };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** the files a stylesheet fetches in its turn — fonts and images of its `url(…)`s */
|
|
112
|
+
export function readStylesheet(css: string, cssUrl: string): IServedFile[] {
|
|
113
|
+
const files = new Map<string, IServedFile>();
|
|
114
|
+
for (const match of css.matchAll(/url\(\s*(?:"([^"]*)"|'([^']*)'|([^)'"]*))\s*\)/gi)) {
|
|
115
|
+
const href = (match[1] ?? match[2] ?? match[3] ?? '').trim();
|
|
116
|
+
if (!href || href.startsWith('data:') || href.startsWith('#')) continue;
|
|
117
|
+
let url: URL;
|
|
118
|
+
try {
|
|
119
|
+
url = new URL(href, cssUrl);
|
|
120
|
+
} catch {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (!files.has(fileKey(url))) files.set(fileKey(url), { url: url.href, kind: kindOf(url), moduleId: moduleIdOf(url) });
|
|
124
|
+
}
|
|
125
|
+
return [...files.values()];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** a font face a stylesheet declares: what it is for, and its files in the order a browser tries them */
|
|
129
|
+
export interface IFontFace {
|
|
130
|
+
readonly family: string;
|
|
131
|
+
readonly weight: string;
|
|
132
|
+
readonly style: string;
|
|
133
|
+
/** canonical: `0-ff,131` */
|
|
134
|
+
readonly unicodeRange: string;
|
|
135
|
+
readonly urls: readonly string[];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** a face the page's document has loaded (`document.fonts`, status loaded) */
|
|
139
|
+
export interface ILoadedFont {
|
|
140
|
+
readonly family: string;
|
|
141
|
+
readonly weight: string;
|
|
142
|
+
readonly style: string;
|
|
143
|
+
readonly unicodeRange: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const FULL_RANGE = 'U+0-10FFFF';
|
|
147
|
+
|
|
148
|
+
/** `U+0000-00FF, U+0131, U+4??` and `U+0-FF,U+131` alike → `0-ff,131,400-4ff` */
|
|
149
|
+
function canonicalRange(range: string): string {
|
|
150
|
+
return (range.trim() || FULL_RANGE).split(',').map((token) => {
|
|
151
|
+
const body = token.trim().replace(/^u\+/i, '');
|
|
152
|
+
if (body.includes('?')) {
|
|
153
|
+
return `${parseInt(body.replace(/\?/g, '0'), 16).toString(16)}-${parseInt(body.replace(/\?/g, 'f'), 16).toString(16)}`;
|
|
154
|
+
}
|
|
155
|
+
const [from, to] = body.split('-').map((hex) => parseInt(hex, 16).toString(16));
|
|
156
|
+
return to && to !== from ? `${from}-${to}` : from;
|
|
157
|
+
}).join(',');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const canonicalWeight = (weight: string): string => ({ normal: '400', bold: '700' }[weight.trim().toLowerCase()] ?? weight.trim().toLowerCase());
|
|
161
|
+
const canonicalFamily = (family: string): string => family.trim().replace(/^["']|["']$/g, '').toLowerCase();
|
|
162
|
+
const faceKey = (face: ILoadedFont): string => [canonicalFamily(face.family), canonicalWeight(face.weight), (face.style || 'normal').trim().toLowerCase(), canonicalRange(face.unicodeRange)].join('|');
|
|
163
|
+
|
|
164
|
+
/** the font faces a stylesheet declares */
|
|
165
|
+
export function readFontFaces(css: string, cssUrl: string): IFontFace[] {
|
|
166
|
+
const faces: IFontFace[] = [];
|
|
167
|
+
for (const [, body] of css.matchAll(/@font-face\s*\{([^}]*)\}/gi)) {
|
|
168
|
+
const descriptor = (name: string): string => new RegExp(`(?:^|;)\\s*${name}\\s*:\\s*([^;]*)`, 'i').exec(body)?.[1]?.trim() ?? '';
|
|
169
|
+
const urls = readStylesheet(descriptor('src'), cssUrl).filter((file) => file.kind === 'font').map((file) => file.url);
|
|
170
|
+
if (!urls.length) continue;
|
|
171
|
+
faces.push({
|
|
172
|
+
family: descriptor('font-family'),
|
|
173
|
+
weight: descriptor('font-weight') || '400',
|
|
174
|
+
style: descriptor('font-style') || 'normal',
|
|
175
|
+
unicodeRange: canonicalRange(descriptor('unicode-range')),
|
|
176
|
+
urls,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return faces;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* The fonts the page's styles declare, against the faces its preview loaded: a browser fetches a
|
|
184
|
+
* face its text needs — the first format of it it can read — and never the others, nor the faces
|
|
185
|
+
* (weights, character subsets) the text does not ask for. With no word from the preview every
|
|
186
|
+
* face counts once: at most.
|
|
187
|
+
*/
|
|
188
|
+
export function markUnusedFonts(files: readonly IServedFile[], faces: readonly IFontFace[], loaded: readonly ILoadedFont[]): IServedFile[] {
|
|
189
|
+
const loadedKeys = new Set(loaded.map(faceKey));
|
|
190
|
+
const fetched = new Set(faces
|
|
191
|
+
.filter((face) => !loadedKeys.size || loadedKeys.has(faceKey(face)))
|
|
192
|
+
.map((face) => fileKey(new URL(face.urls[0]))));
|
|
193
|
+
return files.map((file) => (file.kind === 'font' && !fetched.has(fileKey(new URL(file.url))) ? { ...file, unused: true } : file));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ---- the modules -------------------------------------------------------------------------------
|
|
197
|
+
|
|
198
|
+
const PACKAGE_PREFIX = '_ns_package_t_package_n_';
|
|
199
|
+
const MODULE_ID_RE = /^_ns_(.+?)_t_([a-z][A-Za-z0-9]*)_n_(.+)_v_([A-Za-z0-9_]+)$/;
|
|
200
|
+
|
|
201
|
+
/** a package id's normalized specifier and version, or null for a block's module */
|
|
202
|
+
function packagePartsOf(id: string): { specifier: string; version: string } | null {
|
|
203
|
+
if (!id.startsWith(PACKAGE_PREFIX)) return null;
|
|
204
|
+
const body = id.slice(PACKAGE_PREFIX.length);
|
|
205
|
+
const at = body.lastIndexOf('_v_');
|
|
206
|
+
return at < 0 ? null : { specifier: body.slice(0, at), version: body.slice(at + 3) };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const dotted = (version: string): string => version.replace(/_/g, '.');
|
|
210
|
+
|
|
211
|
+
/** an edge of a module: what it asked for, and what the page gives it */
|
|
212
|
+
export interface IDependencyEdge {
|
|
213
|
+
readonly id: string;
|
|
214
|
+
/** the package as people spell it (`@mosetta/react`) */
|
|
215
|
+
readonly label: string;
|
|
216
|
+
readonly asked: string;
|
|
217
|
+
/** the version the page loads, or null when it loads none (not fetched up front) */
|
|
218
|
+
readonly given: string | null;
|
|
219
|
+
/** another version is loaded in place of the asked one — a rule took the edge over */
|
|
220
|
+
readonly crossed: boolean;
|
|
221
|
+
/** …and it is OLDER than the one asked for */
|
|
222
|
+
readonly lower: boolean;
|
|
223
|
+
/** the module the page loads for it — what opening the edge shows; null when it loads none */
|
|
224
|
+
readonly loadedId: string | null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** a module the page fetches */
|
|
228
|
+
export interface IModuleRow {
|
|
229
|
+
readonly id: string;
|
|
230
|
+
readonly kind: 'block' | 'package';
|
|
231
|
+
/** a block's name (with its facet when not the component), a package entry's specifier */
|
|
232
|
+
readonly title: string;
|
|
233
|
+
readonly version: string;
|
|
234
|
+
/** what of it the page fetches */
|
|
235
|
+
readonly files: readonly IServedFile[];
|
|
236
|
+
readonly dependencies: readonly IDependencyEdge[];
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** the modules of one package: a distribution's blocks (`mosetta-ui@0.0.1`), a package's entries (`react@19.2.0`) */
|
|
240
|
+
export interface IPackageGroup {
|
|
241
|
+
readonly key: string;
|
|
242
|
+
readonly kind: 'distribution' | 'package';
|
|
243
|
+
readonly name: string;
|
|
244
|
+
readonly version: string;
|
|
245
|
+
readonly modules: readonly IModuleRow[];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export interface IPageModules {
|
|
249
|
+
/** the distributions first, then the packages — each as the page first names it */
|
|
250
|
+
readonly groups: readonly IPackageGroup[];
|
|
251
|
+
/** every module of the page, by id — where an edge leads */
|
|
252
|
+
readonly byId: ReadonlyMap<string, IModuleRow>;
|
|
253
|
+
/** files of no module: the loader, the page's images, fonts and images its styles name */
|
|
254
|
+
readonly assets: readonly IServedFile[];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** the package an entry belongs to: `@scope/name` of `@scope/name/sub`, `name` of `name/sub` */
|
|
258
|
+
export function packageNameOf(specifier: string): string {
|
|
259
|
+
const parts = specifier.split('/');
|
|
260
|
+
return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** the modules of the page, by package — what it fetches, with each module's edges */
|
|
264
|
+
export function pageModules(files: readonly IServedFile[], catalog: ReadonlyMap<string, ICatalogItem>): IPageModules {
|
|
265
|
+
const byModule = new Map<string, IServedFile[]>();
|
|
266
|
+
const assets: IServedFile[] = [];
|
|
267
|
+
for (const file of files) {
|
|
268
|
+
// the loader (system.js) sits in the root script's folder but is no module of it
|
|
269
|
+
if (file.moduleId && !/\/system(\.min)?\.js$/.test(new URL(file.url).pathname)) {
|
|
270
|
+
byModule.set(file.moduleId, [...(byModule.get(file.moduleId) ?? []), file]);
|
|
271
|
+
} else {
|
|
272
|
+
assets.push(file);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// the loaded version of each package entry, by its normalized specifier
|
|
276
|
+
const loaded = new Map<string, string>();
|
|
277
|
+
for (const id of byModule.keys()) {
|
|
278
|
+
const parts = packagePartsOf(id);
|
|
279
|
+
if (parts) loaded.set(parts.specifier, parts.version);
|
|
280
|
+
}
|
|
281
|
+
const labelOf = (id: string): string => catalog.get(id)?.reference.specifier ?? packagePartsOf(id)?.specifier ?? id;
|
|
282
|
+
const edgesOf = (id: string): IDependencyEdge[] => (catalog.get(id)?.dependencies ?? []).flatMap((dep) => {
|
|
283
|
+
const parts = packagePartsOf(dep);
|
|
284
|
+
if (!parts) return [];
|
|
285
|
+
const given = loaded.get(parts.specifier) ?? null;
|
|
286
|
+
const crossed = given !== null && given !== parts.version;
|
|
287
|
+
return [{
|
|
288
|
+
id: dep,
|
|
289
|
+
label: labelOf(dep),
|
|
290
|
+
asked: dotted(parts.version),
|
|
291
|
+
given: given === null ? null : dotted(given),
|
|
292
|
+
crossed,
|
|
293
|
+
lower: crossed && compareVersions(given, parts.version) < 0,
|
|
294
|
+
loadedId: given === null ? null : `${PACKAGE_PREFIX}${parts.specifier}_v_${given}`,
|
|
295
|
+
}];
|
|
296
|
+
});
|
|
297
|
+
const groups = new Map<string, { key: string; kind: 'distribution' | 'package'; name: string; version: string; modules: IModuleRow[] }>();
|
|
298
|
+
const byId = new Map<string, IModuleRow>();
|
|
299
|
+
for (const [id, own] of byModule) {
|
|
300
|
+
const parts = packagePartsOf(id);
|
|
301
|
+
const match = MODULE_ID_RE.exec(id);
|
|
302
|
+
const version = dotted(parts?.version ?? match?.[4] ?? '');
|
|
303
|
+
const title = parts ? labelOf(id) : match ? (match[2] === 'component' ? match[3] : `${match[3]} · ${match[2]}`) : id;
|
|
304
|
+
const row: IModuleRow = { id, kind: parts ? 'package' : 'block', title, version, files: own, dependencies: edgesOf(id) };
|
|
305
|
+
byId.set(id, row);
|
|
306
|
+
const name = parts ? packageNameOf(title) : match?.[1] ?? id;
|
|
307
|
+
const key = `${name}@${version}`;
|
|
308
|
+
const group = groups.get(key) ?? { key, kind: parts ? 'package' as const : 'distribution' as const, name, version, modules: [] };
|
|
309
|
+
group.modules.push(row);
|
|
310
|
+
groups.set(key, group);
|
|
311
|
+
}
|
|
312
|
+
const all = [...groups.values()];
|
|
313
|
+
return { groups: [...all.filter((g) => g.kind === 'distribution'), ...all.filter((g) => g.kind === 'package')], byId, assets };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ---- the sums ----------------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
/** what a file weighs: as fetched, and gzipped when it is text (null: not measured) */
|
|
319
|
+
export interface IFileSize {
|
|
320
|
+
readonly bytes: number;
|
|
321
|
+
readonly gzip: number | null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export interface IWeight {
|
|
325
|
+
readonly count: number;
|
|
326
|
+
readonly bytes: number;
|
|
327
|
+
/** gzipped text, binary as it is; null while any part of it is not measured */
|
|
328
|
+
readonly gzip: number | null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** a file's kind compresses as text — images other than svg and fonts are compressed already */
|
|
332
|
+
export const isText = (file: IServedFile): boolean => file.kind === 'js' || file.kind === 'css' || /\.svg$/i.test(new URL(file.url).pathname);
|
|
333
|
+
|
|
334
|
+
export function weightOf(files: readonly IServedFile[], sizes: ReadonlyMap<string, IFileSize>): IWeight {
|
|
335
|
+
let bytes = 0;
|
|
336
|
+
let gzip: number | null = 0;
|
|
337
|
+
let count = 0;
|
|
338
|
+
for (const file of files) {
|
|
339
|
+
const size = sizes.get(file.url);
|
|
340
|
+
if (!size) continue;
|
|
341
|
+
count += 1;
|
|
342
|
+
bytes += size.bytes;
|
|
343
|
+
const packed = isText(file) ? size.gzip : size.bytes;
|
|
344
|
+
gzip = gzip === null || packed === null ? null : gzip + packed;
|
|
345
|
+
}
|
|
346
|
+
return { count, bytes, gzip };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export const addWeights = (a: IWeight, b: IWeight): IWeight => ({
|
|
350
|
+
count: a.count + b.count,
|
|
351
|
+
bytes: a.bytes + b.bytes,
|
|
352
|
+
gzip: a.gzip === null || b.gzip === null ? null : a.gzip + b.gzip,
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
export function formatBytes(bytes: number): string {
|
|
356
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
357
|
+
const kb = bytes / 1024;
|
|
358
|
+
if (kb < 1024) return `${kb < 100 ? kb.toFixed(1) : Math.round(kb)} KB`;
|
|
359
|
+
const mb = kb / 1024;
|
|
360
|
+
return `${mb < 100 ? mb.toFixed(1) : Math.round(mb)} MB`;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ---- the order and the bars -------------------------------------------------------------------
|
|
364
|
+
|
|
365
|
+
/** how the rows of the tree are ordered — by name or weight, either way; null: as the page names them */
|
|
366
|
+
export interface IOrder {
|
|
367
|
+
readonly by: 'name' | 'size' | 'gzip';
|
|
368
|
+
readonly descending: boolean;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** the next order a click on a column gives: its own way first (names A→Z, weights heaviest first), then the other */
|
|
372
|
+
export function nextOrder(order: IOrder | null, by: IOrder['by']): IOrder {
|
|
373
|
+
if (order?.by === by) return { by, descending: !order.descending };
|
|
374
|
+
return { by, descending: by !== 'name' };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** the rows in an order; null keeps them as they are */
|
|
378
|
+
export function ordered<T>(rows: readonly T[], order: IOrder | null, nameOf: (row: T) => string, weightOf: (row: T) => IWeight): T[] {
|
|
379
|
+
if (!order) return [...rows];
|
|
380
|
+
const sign = order.descending ? -1 : 1;
|
|
381
|
+
const key = (row: T): number => {
|
|
382
|
+
const weight = weightOf(row);
|
|
383
|
+
return order.by === 'gzip' ? weight.gzip ?? weight.bytes : weight.bytes;
|
|
384
|
+
};
|
|
385
|
+
return [...rows].sort((a, b) => sign * (order.by === 'name'
|
|
386
|
+
? nameOf(a).localeCompare(nameOf(b), undefined, { numeric: true, sensitivity: 'base' })
|
|
387
|
+
: key(a) - key(b)));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* The tool's state as the address keeps it: the order (`order:size-down`) and the key of every
|
|
392
|
+
* open row, comma-joined — `order:size-down,packages,mosetta-ui@0.0.1,mosetta-ui@0.0.1/theme`.
|
|
393
|
+
* Nothing open and no order: null.
|
|
394
|
+
*/
|
|
395
|
+
export function writeToolState(order: IOrder | null, opened: Iterable<string>): string | null {
|
|
396
|
+
const parts = [...(order ? [`order:${order.by}-${order.descending ? 'down' : 'up'}`] : []), ...[...opened].sort()];
|
|
397
|
+
return parts.length ? parts.join(',') : null;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export function readToolState(state: string | null): { order: IOrder | null; opened: Set<string> } {
|
|
401
|
+
let order: IOrder | null = null;
|
|
402
|
+
const opened = new Set<string>();
|
|
403
|
+
for (const part of (state ?? '').split(',')) {
|
|
404
|
+
const said = /^order:(name|size|gzip)-(down|up)$/.exec(part);
|
|
405
|
+
if (said) order = { by: said[1] as IOrder['by'], descending: said[2] === 'down' };
|
|
406
|
+
else if (part) opened.add(part);
|
|
407
|
+
}
|
|
408
|
+
return { order, opened };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* The colour of a row's bar — the share of the page it weighs: green for a crumb, through yellow,
|
|
413
|
+
* to red for a third of the page and more.
|
|
414
|
+
*/
|
|
415
|
+
export function shareHue(share: number): number {
|
|
416
|
+
const heavy = Math.min(1, Math.max(0, share) / 0.33);
|
|
417
|
+
return Math.round(120 * (1 - heavy));
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ---- the rules ---------------------------------------------------------------------------------
|
|
421
|
+
|
|
422
|
+
/** a rule of the page's `settings.versions.shared` */
|
|
423
|
+
export interface IVersionRule {
|
|
424
|
+
readonly singleton?: boolean;
|
|
425
|
+
readonly version?: string;
|
|
426
|
+
readonly pin?: string;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* The rule every page has without saying so — the server's DEFAULT_SHARED, copied (the unit test
|
|
431
|
+
* keeps the two equal; moving it into a shared package would release the framework for a line).
|
|
432
|
+
*/
|
|
433
|
+
export const BUILT_IN_RULES: Readonly<Record<string, IVersionRule>> = { '@mosetta/*': { singleton: true, version: 'highest' } };
|
|
434
|
+
|
|
435
|
+
/** a rule as people read it */
|
|
436
|
+
export function describeRule(rule: IVersionRule): string {
|
|
437
|
+
const version = rule.pin ?? rule.version;
|
|
438
|
+
if (version === 'highest') return 'one copy, the newest any block asks for';
|
|
439
|
+
if (version) return `one copy, exactly ${version}`;
|
|
440
|
+
return rule.singleton ? 'one copy' : 'no constraint';
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** the page's own rules, from its root's settings */
|
|
444
|
+
export function pageRulesOf(settings: Record<string, unknown> | undefined): Record<string, IVersionRule> {
|
|
445
|
+
const shared = (settings?.versions as { shared?: unknown } | undefined)?.shared;
|
|
446
|
+
return shared && typeof shared === 'object' ? shared as Record<string, IVersionRule> : {};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** the root's settings patch that sets (or, with null, removes) one rule */
|
|
450
|
+
export function withRule(settings: Record<string, unknown> | undefined, key: string, rule: IVersionRule | null): Record<string, unknown> {
|
|
451
|
+
const versions = (settings?.versions && typeof settings.versions === 'object' ? settings.versions : {}) as Record<string, unknown>;
|
|
452
|
+
const shared = { ...pageRulesOf(settings) };
|
|
453
|
+
if (rule) shared[key] = rule;
|
|
454
|
+
else delete shared[key];
|
|
455
|
+
return { versions: { ...versions, shared } };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** the versions of a package the catalog holds, the newest first */
|
|
459
|
+
export function versionsOf(catalog: Iterable<ICatalogItem>, specifier: string): string[] {
|
|
460
|
+
const found = new Set<string>();
|
|
461
|
+
for (const item of catalog) {
|
|
462
|
+
if (item.reference.specifier === specifier && item.reference.version) found.add(item.reference.version);
|
|
463
|
+
}
|
|
464
|
+
return [...found].sort((a, b) => compareVersions(b, a));
|
|
465
|
+
}
|