@smallpen/local-package 0.1.0-alpha.1 → 0.1.0-alpha.11
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/assets/fonts/SourceSansPro-OFL.txt +46 -0
- package/assets/fonts/WorkSans-OFL.txt +46 -0
- package/package.json +4 -4
- package/runtime/fonts/OFL.txt +46 -0
- package/runtime/fonts/sourcesanspro-black.ttf +0 -0
- package/runtime/fonts/sourcesanspro-blackitalic.ttf +0 -0
- package/runtime/fonts/sourcesanspro-bold.ttf +0 -0
- package/runtime/fonts/sourcesanspro-bolditalic.ttf +0 -0
- package/runtime/fonts/sourcesanspro-extralight.ttf +0 -0
- package/runtime/fonts/sourcesanspro-extralightitalic.ttf +0 -0
- package/runtime/fonts/sourcesanspro-italic.ttf +0 -0
- package/runtime/fonts/sourcesanspro-light.ttf +0 -0
- package/runtime/fonts/sourcesanspro-lightitalic.ttf +0 -0
- package/runtime/fonts/sourcesanspro-regular.ttf +0 -0
- package/runtime/fonts/sourcesanspro-semibold.ttf +0 -0
- package/runtime/fonts/sourcesanspro-semibolditalic.ttf +0 -0
- package/runtime/manifest.json +7 -0
- package/runtime/render-wasm.js +15 -0
- package/runtime/render-wasm.wasm +0 -0
- package/runtime/renderer.mjs +169652 -0
- package/src/draft-import.mjs +1 -11
- package/src/font-convert.mjs +23 -1
- package/src/fs-utils.mjs +95 -0
- package/src/index.mjs +19 -3
- package/src/initialize.mjs +363 -136
- package/src/kiwi-runtime.mjs +38 -36
- package/src/library-network.mjs +189 -0
- package/src/local-package-backend.mjs +215 -24
- package/src/local-package.mjs +589 -177
- package/src/local-workspace-session.mjs +278 -96
- package/src/migrate-themes.mjs +116 -0
- package/src/remote-library.mjs +167 -28
- package/src/render-fonts.mjs +172 -0
- package/src/render-shared.mjs +346 -0
- package/src/render.mjs +71 -3557
- package/src/workspace.mjs +178 -17
- package/src/webp-decode.mjs +0 -73
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { watch } from "node:fs";
|
|
3
|
+
import { lstat } from "node:fs/promises";
|
|
3
4
|
import { basename, dirname, join, resolve } from "node:path";
|
|
4
5
|
|
|
5
6
|
import {
|
|
6
7
|
canonicalJSON,
|
|
8
|
+
loadPackageFromValues,
|
|
7
9
|
SmallPenError,
|
|
8
10
|
SMALLPEN_RUNTIME_CAPABILITIES,
|
|
9
11
|
} from "@smallpen/core";
|
|
@@ -19,6 +21,63 @@ import {
|
|
|
19
21
|
} from "./local-package.mjs";
|
|
20
22
|
|
|
21
23
|
const MAX_CHANGE_HISTORY = 200;
|
|
24
|
+
const DEFAULT_TOKEN_ENTRY = "tokens/tokens.json";
|
|
25
|
+
// File systems with coarse timestamps (HFS+, FAT) can hide a same-size edit
|
|
26
|
+
// made in the same timestamp granule as the fingerprint. A fingerprint that
|
|
27
|
+
// contains a timestamp this recent is never trusted to skip a reload.
|
|
28
|
+
const FINGERPRINT_SETTLE_MS = 2000;
|
|
29
|
+
|
|
30
|
+
// A cheap stat-only fingerprint of the Package directory, its manifest, its
|
|
31
|
+
// entries, its blob directory, and its blobs. Commits replace the whole
|
|
32
|
+
// directory, and external edits change a file's size or timestamps, so an
|
|
33
|
+
// unchanged fingerprint means a full reload would read the same content.
|
|
34
|
+
async function packageFingerprint(packagePath, entries) {
|
|
35
|
+
const parts = [];
|
|
36
|
+
let newest = 0n;
|
|
37
|
+
for (const path of [
|
|
38
|
+
packagePath,
|
|
39
|
+
join(packagePath, "manifest.json"),
|
|
40
|
+
join(packagePath, "blobs"),
|
|
41
|
+
...entries.map((entry) => join(packagePath, entry)),
|
|
42
|
+
]) {
|
|
43
|
+
try {
|
|
44
|
+
const info = await lstat(path, { bigint: true });
|
|
45
|
+
parts.push(`${info.ino}:${info.size}:${info.mtimeNs}:${info.ctimeNs}`);
|
|
46
|
+
for (const time of [info.mtimeMs, info.ctimeMs]) {
|
|
47
|
+
if (time > newest) newest = time;
|
|
48
|
+
}
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (error?.code !== "ENOENT" && error?.code !== "ENOTDIR") return undefined;
|
|
51
|
+
parts.push("-");
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (Date.now() - Number(newest) < FINGERPRINT_SETTLE_MS) return undefined;
|
|
55
|
+
return parts.join("|");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Blobs are listed too: an in-place blob edit changes no entry or directory.
|
|
59
|
+
function diskEntries(snapshot) {
|
|
60
|
+
return [
|
|
61
|
+
...Object.values(snapshot.manifest.entries).flat(),
|
|
62
|
+
...[...snapshot.blobs.keys()].sort(),
|
|
63
|
+
];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Projects an in-memory default Token library into a Package that has none,
|
|
67
|
+
// so opening never writes. The first commit made against the projected
|
|
68
|
+
// revision persists the library together with the edit.
|
|
69
|
+
async function projectDefaultTokenLibrary(snapshot, library) {
|
|
70
|
+
if (!library || snapshot.manifest.entries.tokens.length > 0) return snapshot;
|
|
71
|
+
const manifest = structuredClone(snapshot.manifest);
|
|
72
|
+
manifest.entries.tokens = [DEFAULT_TOKEN_ENTRY];
|
|
73
|
+
const values = new Map([["manifest.json", manifest]]);
|
|
74
|
+
for (const [entry, value] of Object.entries(snapshot.entries)) {
|
|
75
|
+
values.set(entry, structuredClone(value));
|
|
76
|
+
}
|
|
77
|
+
values.set(DEFAULT_TOKEN_ENTRY, structuredClone(library));
|
|
78
|
+
for (const [blob, bytes] of snapshot.blobs) values.set(blob, bytes);
|
|
79
|
+
return loadPackageFromValues(snapshot.locator, values);
|
|
80
|
+
}
|
|
22
81
|
|
|
23
82
|
function isRecord(value) {
|
|
24
83
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
@@ -103,9 +162,13 @@ export class LocalPackageBackend {
|
|
|
103
162
|
#changeSequence = 0;
|
|
104
163
|
#confirmedBatches = new Map();
|
|
105
164
|
#commitQueue = Promise.resolve();
|
|
165
|
+
#defaultTokenLibrary;
|
|
166
|
+
#diskEntries = [];
|
|
167
|
+
#fingerprint;
|
|
106
168
|
#listeners = new Set();
|
|
107
169
|
#packagePath;
|
|
108
170
|
#pollTimer;
|
|
171
|
+
#projection;
|
|
109
172
|
#refreshPromise;
|
|
110
173
|
#redoStack = [];
|
|
111
174
|
#timer;
|
|
@@ -116,6 +179,15 @@ export class LocalPackageBackend {
|
|
|
116
179
|
snapshot;
|
|
117
180
|
status = { readOnly: true, state: "closed" };
|
|
118
181
|
|
|
182
|
+
// `defaultTokenLibrary`: when set, a Package without Token entries is
|
|
183
|
+
// served with this library projected in memory (see
|
|
184
|
+
// projectDefaultTokenLibrary); nothing is written until the first commit.
|
|
185
|
+
constructor({ defaultTokenLibrary } = {}) {
|
|
186
|
+
this.#defaultTokenLibrary = defaultTokenLibrary
|
|
187
|
+
? structuredClone(defaultTokenLibrary)
|
|
188
|
+
: undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
119
191
|
capabilities() {
|
|
120
192
|
return structuredClone(SMALLPEN_RUNTIME_CAPABILITIES);
|
|
121
193
|
}
|
|
@@ -127,13 +199,51 @@ export class LocalPackageBackend {
|
|
|
127
199
|
this.#undoStack = [];
|
|
128
200
|
this.#changeSequence = 0;
|
|
129
201
|
this.changes = [];
|
|
130
|
-
|
|
131
|
-
this.#packagePath =
|
|
202
|
+
const disk = await openPackage(resolve(locator));
|
|
203
|
+
this.#packagePath = disk.locator;
|
|
204
|
+
this.snapshot = await this.#project(disk);
|
|
205
|
+
this.#fingerprint = undefined;
|
|
132
206
|
this.#markReady();
|
|
133
207
|
this.#startWatchers();
|
|
134
208
|
return this.snapshot;
|
|
135
209
|
}
|
|
136
210
|
|
|
211
|
+
async #project(disk) {
|
|
212
|
+
const snapshot = await projectDefaultTokenLibrary(
|
|
213
|
+
disk,
|
|
214
|
+
this.#defaultTokenLibrary,
|
|
215
|
+
);
|
|
216
|
+
this.#projection =
|
|
217
|
+
snapshot === disk
|
|
218
|
+
? undefined
|
|
219
|
+
: { diskRevision: disk.revision };
|
|
220
|
+
this.#diskEntries = diskEntries(disk);
|
|
221
|
+
return snapshot;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// A batch prepared against the projected revision is rebased onto the
|
|
225
|
+
// Package on disk with the projected default Token library prepended, so
|
|
226
|
+
// the commit persists exactly the state the client edited.
|
|
227
|
+
#diskBatch(batch) {
|
|
228
|
+
if (!this.#projection || batch?.baseRevision !== this.snapshot?.revision) {
|
|
229
|
+
return batch;
|
|
230
|
+
}
|
|
231
|
+
return {
|
|
232
|
+
...batch,
|
|
233
|
+
baseRevision: this.#projection.diskRevision,
|
|
234
|
+
operations: Array.isArray(batch.operations)
|
|
235
|
+
? [
|
|
236
|
+
{
|
|
237
|
+
entry: DEFAULT_TOKEN_ENTRY,
|
|
238
|
+
library: structuredClone(this.#defaultTokenLibrary),
|
|
239
|
+
type: "replace-token-library",
|
|
240
|
+
},
|
|
241
|
+
...batch.operations,
|
|
242
|
+
]
|
|
243
|
+
: batch.operations,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
|
|
137
247
|
commit(batch, options = {}) {
|
|
138
248
|
return this.#commit(batch, options.historyMode ?? "normal");
|
|
139
249
|
}
|
|
@@ -187,34 +297,47 @@ export class LocalPackageBackend {
|
|
|
187
297
|
}
|
|
188
298
|
|
|
189
299
|
importMedia(media) {
|
|
190
|
-
return this.#write(async () => {
|
|
191
|
-
const imported = await importPackageMedia(this.#packagePath, media);
|
|
300
|
+
return this.#write(async (options) => {
|
|
301
|
+
const imported = await importPackageMedia(this.#packagePath, media, options);
|
|
192
302
|
return { result: imported.result, value: imported };
|
|
193
303
|
});
|
|
194
304
|
}
|
|
195
305
|
|
|
196
306
|
importFontVariant(font) {
|
|
197
|
-
return this.#write(async () => {
|
|
198
|
-
const imported = await importPackageFontVariant(
|
|
307
|
+
return this.#write(async (options) => {
|
|
308
|
+
const imported = await importPackageFontVariant(
|
|
309
|
+
this.#packagePath,
|
|
310
|
+
font,
|
|
311
|
+
options,
|
|
312
|
+
);
|
|
199
313
|
return { result: imported.result, value: imported };
|
|
200
314
|
});
|
|
201
315
|
}
|
|
202
316
|
|
|
203
317
|
updateFontFamily(fontId, family) {
|
|
204
|
-
return this.#write(async () => ({
|
|
205
|
-
result: await updatePackageFontFamily(
|
|
318
|
+
return this.#write(async (options) => ({
|
|
319
|
+
result: await updatePackageFontFamily(
|
|
320
|
+
this.#packagePath,
|
|
321
|
+
fontId,
|
|
322
|
+
family,
|
|
323
|
+
options,
|
|
324
|
+
),
|
|
206
325
|
}));
|
|
207
326
|
}
|
|
208
327
|
|
|
209
328
|
deleteFontFamily(fontId) {
|
|
210
|
-
return this.#write(async () => ({
|
|
211
|
-
result: await deletePackageFontFamily(this.#packagePath, fontId),
|
|
329
|
+
return this.#write(async (options) => ({
|
|
330
|
+
result: await deletePackageFontFamily(this.#packagePath, fontId, options),
|
|
212
331
|
}));
|
|
213
332
|
}
|
|
214
333
|
|
|
215
334
|
deleteFontVariant(fontVariantId) {
|
|
216
|
-
return this.#write(async () => ({
|
|
217
|
-
result: await deletePackageFontVariant(
|
|
335
|
+
return this.#write(async (options) => ({
|
|
336
|
+
result: await deletePackageFontVariant(
|
|
337
|
+
this.#packagePath,
|
|
338
|
+
fontVariantId,
|
|
339
|
+
options,
|
|
340
|
+
),
|
|
218
341
|
}));
|
|
219
342
|
}
|
|
220
343
|
|
|
@@ -236,7 +359,22 @@ export class LocalPackageBackend {
|
|
|
236
359
|
async #performRefresh() {
|
|
237
360
|
if (!this.#packagePath) return undefined;
|
|
238
361
|
try {
|
|
239
|
-
|
|
362
|
+
// Stat before reading: a change that lands during the read alters the
|
|
363
|
+
// next fingerprint, so it is never hidden behind this one.
|
|
364
|
+
const entries = this.#diskEntries;
|
|
365
|
+
const fingerprint = await packageFingerprint(this.#packagePath, entries);
|
|
366
|
+
if (
|
|
367
|
+
fingerprint !== undefined &&
|
|
368
|
+
fingerprint === this.#fingerprint &&
|
|
369
|
+
this.status.state === "ready"
|
|
370
|
+
) {
|
|
371
|
+
return this.snapshot;
|
|
372
|
+
}
|
|
373
|
+
const next = await this.#project(await openPackage(this.#packagePath));
|
|
374
|
+
this.#fingerprint =
|
|
375
|
+
canonicalJSON(entries) === canonicalJSON(this.#diskEntries)
|
|
376
|
+
? fingerprint
|
|
377
|
+
: undefined;
|
|
240
378
|
const recovered = this.status.state === "repair";
|
|
241
379
|
if (next.revision === this.snapshot?.revision) {
|
|
242
380
|
this.#markReady();
|
|
@@ -248,11 +386,22 @@ export class LocalPackageBackend {
|
|
|
248
386
|
}
|
|
249
387
|
const before = this.snapshot;
|
|
250
388
|
const change = externalChange(before, next);
|
|
389
|
+
// Every local inverse batch targets a revision that no longer exists,
|
|
390
|
+
// so Undo and Redo could only fail with stale_revision from here on.
|
|
391
|
+
const historyCleared =
|
|
392
|
+
this.#undoStack.length > 0 || this.#redoStack.length > 0;
|
|
393
|
+
this.#redoStack = [];
|
|
394
|
+
this.#undoStack = [];
|
|
251
395
|
this.snapshot = next;
|
|
252
396
|
this.#markReady();
|
|
253
397
|
this.#recordChange(change);
|
|
254
398
|
this.#startWatchers();
|
|
255
|
-
this.#emit({
|
|
399
|
+
this.#emit({
|
|
400
|
+
change,
|
|
401
|
+
historyCleared,
|
|
402
|
+
snapshot: next,
|
|
403
|
+
type: "external-revision",
|
|
404
|
+
});
|
|
256
405
|
return next;
|
|
257
406
|
} catch (error) {
|
|
258
407
|
const alreadyRepair = this.status.state === "repair";
|
|
@@ -268,6 +417,7 @@ export class LocalPackageBackend {
|
|
|
268
417
|
if (!alreadyRepair) {
|
|
269
418
|
this.#emit({ error, snapshot: this.snapshot, status: this.status, type: "invalid-external-state" });
|
|
270
419
|
}
|
|
420
|
+
this.#fingerprint = undefined;
|
|
271
421
|
return undefined;
|
|
272
422
|
}
|
|
273
423
|
}
|
|
@@ -282,6 +432,9 @@ export class LocalPackageBackend {
|
|
|
282
432
|
this.#timer = undefined;
|
|
283
433
|
this.#closeWatchers();
|
|
284
434
|
this.#packagePath = undefined;
|
|
435
|
+
this.#diskEntries = [];
|
|
436
|
+
this.#fingerprint = undefined;
|
|
437
|
+
this.#projection = undefined;
|
|
285
438
|
this.snapshot = undefined;
|
|
286
439
|
this.status = { readOnly: true, state: "closed" };
|
|
287
440
|
this.#confirmedBatches.clear();
|
|
@@ -312,11 +465,15 @@ export class LocalPackageBackend {
|
|
|
312
465
|
this.#startWatchers();
|
|
313
466
|
return { ...confirmed.result, duplicate: true };
|
|
314
467
|
}
|
|
315
|
-
const result = await
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
this.#startWatchers();
|
|
468
|
+
const result = await this.#applyCommitted((options) =>
|
|
469
|
+
applyOperationBatch(this.#packagePath, this.#diskBatch(batch), options),
|
|
470
|
+
);
|
|
319
471
|
this.#confirmedBatches.set(batch.batchId, { identity, result });
|
|
472
|
+
if (result.alreadyApplied) {
|
|
473
|
+
// Replayed from the persistent ledger: nothing was written now, so
|
|
474
|
+
// its (possibly stale) inverse must not enter local history.
|
|
475
|
+
return result;
|
|
476
|
+
}
|
|
320
477
|
if (historyMode === "normal") {
|
|
321
478
|
this.#undoStack.push(result.inverseBatch);
|
|
322
479
|
this.#redoStack = [];
|
|
@@ -352,10 +509,13 @@ export class LocalPackageBackend {
|
|
|
352
509
|
this.#assertWritable();
|
|
353
510
|
this.#closeWatchers();
|
|
354
511
|
try {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
512
|
+
let value;
|
|
513
|
+
const result = await this.#applyCommitted(async (options) => {
|
|
514
|
+
const written = await operation(options);
|
|
515
|
+
value = written.value;
|
|
516
|
+
return written.result;
|
|
517
|
+
});
|
|
518
|
+
if (result.alreadyApplied) return value ?? result;
|
|
359
519
|
this.#undoStack.push(result.inverseBatch);
|
|
360
520
|
this.#redoStack = [];
|
|
361
521
|
this.#recordChange({
|
|
@@ -379,6 +539,32 @@ export class LocalPackageBackend {
|
|
|
379
539
|
return pending;
|
|
380
540
|
}
|
|
381
541
|
|
|
542
|
+
// Runs one write and adopts the Package state read under its write lock.
|
|
543
|
+
// Reopening after the lock is released could absorb an external revision
|
|
544
|
+
// that landed in between without reporting it; instead the fingerprint is
|
|
545
|
+
// dropped so the next refresh compares the disk against this snapshot and
|
|
546
|
+
// emits an external-revision event for anything newer.
|
|
547
|
+
async #applyCommitted(write) {
|
|
548
|
+
let committed;
|
|
549
|
+
const result = await write({
|
|
550
|
+
onCommitted: (snapshot) => {
|
|
551
|
+
committed = snapshot;
|
|
552
|
+
},
|
|
553
|
+
});
|
|
554
|
+
this.snapshot = await this.#project(
|
|
555
|
+
committed ?? (await openPackage(this.#packagePath)),
|
|
556
|
+
);
|
|
557
|
+
this.#fingerprint = undefined;
|
|
558
|
+
this.#markReady();
|
|
559
|
+
this.#startWatchers();
|
|
560
|
+
this.#scheduleRefresh();
|
|
561
|
+
// Clients use the reported revision as their next base revision; while a
|
|
562
|
+
// default Token library is projected that is the projected revision.
|
|
563
|
+
return this.#projection
|
|
564
|
+
? { ...result, revision: this.snapshot.revision }
|
|
565
|
+
: result;
|
|
566
|
+
}
|
|
567
|
+
|
|
382
568
|
#assertWritable() {
|
|
383
569
|
if (!this.#packagePath) {
|
|
384
570
|
throw new SmallPenError("package_not_open", "Local Package Backend is not open");
|
|
@@ -406,9 +592,14 @@ export class LocalPackageBackend {
|
|
|
406
592
|
if (this.changes.length > MAX_CHANGE_HISTORY) this.changes.shift();
|
|
407
593
|
}
|
|
408
594
|
|
|
595
|
+
// A pending refresh is never pushed back: a steady stream of external
|
|
596
|
+
// writes (or watcher events) must not postpone it indefinitely.
|
|
409
597
|
#scheduleRefresh() {
|
|
410
|
-
|
|
411
|
-
this.#timer = setTimeout(() =>
|
|
598
|
+
if (this.#timer) return;
|
|
599
|
+
this.#timer = setTimeout(() => {
|
|
600
|
+
this.#timer = undefined;
|
|
601
|
+
void this.refresh();
|
|
602
|
+
}, 120);
|
|
412
603
|
}
|
|
413
604
|
|
|
414
605
|
#startWatchers() {
|