@wix/himalaya-cli 0.1177.0 → 0.1179.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/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/state/src/index.d.ts +7 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/stdlib/wix-sdk/rich-content/src/index.d.ts +8 -0
- package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/wix-rich-content.mjs +24 -0
- package/dist/cli.mjs +16 -16
- package/dist/preview-assets.generated.bin +0 -0
- package/guides/sdk-reference.json +7 -1
- package/package.json +2 -2
- package/templates/beauty-salon-owner/tier5-src/_shared/owner-editor-value.ts +57 -39
- package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-post-form/index.ts +23 -13
- package/templates/beauty-salon-owner/tier5-src/himi-owner-lite-product-form/index.ts +20 -8
- package/templates/community-club-owner/tier5-src/_shared/owner-editor-value.ts +57 -39
- package/templates/community-club-owner/tier5-src/himi-owner-lite-event-form/index.ts +21 -10
- package/templates/community-club-owner/tier5-src/himi-owner-lite-post-form/index.ts +23 -13
- package/templates/community-club-owner/tier5-src/himi-owner-lite-product-form/index.ts +20 -8
- package/templates/events-meetups-owner/tier5-src/_shared/owner-editor-value.ts +57 -39
- package/templates/events-meetups-owner/tier5-src/himi-owner-lite-event-form/index.ts +21 -10
- package/templates/events-meetups-owner/tier5-src/himi-owner-lite-post-form/index.ts +23 -13
- package/templates/gym-fitness-owner/tier5-src/_shared/owner-editor-value.ts +57 -39
- package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-event-form/index.ts +21 -10
- package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-post-form/index.ts +23 -13
- package/templates/gym-fitness-owner/tier5-src/himi-owner-lite-product-form/index.ts +20 -8
- package/templates/nonprofit-cause-owner/tier5-src/_shared/owner-editor-value.ts +57 -39
- package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-event-form/index.ts +21 -10
- package/templates/nonprofit-cause-owner/tier5-src/himi-owner-lite-post-form/index.ts +23 -13
- package/templates/owner-lite/tier5-src/_shared/owner-editor-value.ts +57 -39
- package/templates/owner-lite/tier5-src/himi-owner-lite-event-form/index.ts +21 -10
- package/templates/owner-lite/tier5-src/himi-owner-lite-faq-question-form/index.ts +16 -10
- package/templates/owner-lite/tier5-src/himi-owner-lite-post-form/index.ts +23 -13
- package/templates/owner-lite/tier5-src/himi-owner-lite-product-form/index.ts +20 -8
- package/templates/podcast-creator-owner/tier5-src/_shared/owner-editor-value.ts +57 -39
- package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-event-form/index.ts +21 -10
- package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-post-form/index.ts +23 -13
- package/templates/podcast-creator-owner/tier5-src/himi-owner-lite-product-form/index.ts +20 -8
- package/templates/portfolio-creative-owner/tier5-src/_shared/owner-editor-value.ts +57 -39
- package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-post-form/index.ts +23 -13
- package/templates/portfolio-creative-owner/tier5-src/himi-owner-lite-product-form/index.ts +20 -8
- package/templates/restaurant-owner/tier5-src/_shared/owner-editor-value.ts +57 -39
- package/templates/restaurant-owner/tier5-src/himi-owner-lite-event-form/index.ts +21 -10
- package/templates/restaurant-owner/tier5-src/himi-owner-lite-post-form/index.ts +23 -13
- package/templates/restaurant-owner/tier5-src/himi-owner-lite-product-form/index.ts +20 -8
- package/templates/retail-store-owner/tier5-src/_shared/owner-editor-value.ts +57 -39
- package/templates/retail-store-owner/tier5-src/himi-owner-lite-post-form/index.ts +23 -13
- package/templates/retail-store-owner/tier5-src/himi-owner-lite-product-form/index.ts +20 -8
- package/templates/yoga-studio-owner/tier5-src/_shared/owner-editor-value.ts +57 -39
- package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-event-form/index.ts +21 -10
- package/templates/yoga-studio-owner/tier5-src/himi-owner-lite-post-form/index.ts +23 -13
package/dist/authoring-runtime/node_modules/@wix/himalaya/dist/_types/core/sdk/state/src/index.d.ts
CHANGED
|
@@ -8,6 +8,13 @@ export interface ServiceDefinition<S> {
|
|
|
8
8
|
* (native side) updates the service's state and notifies subscribers.
|
|
9
9
|
*/
|
|
10
10
|
actions?: Record<string, (state: S, ...args: never[]) => S | undefined | Promise<S | undefined>>;
|
|
11
|
+
/**
|
|
12
|
+
* Keys a cold boot takes from `state`, never from the persisted snapshot (cold-boot
|
|
13
|
+
* hydration). For UI state that must describe THIS run — an editor's one-shot seed, a
|
|
14
|
+
* "still loading" gate, an open sheet — where last run's value would be wrong, not just stale.
|
|
15
|
+
* Runtimes that honour it set `himalaya.hydrationOptOut`; an older one ignores the option.
|
|
16
|
+
*/
|
|
17
|
+
notHydrated?: readonly (keyof S & string)[];
|
|
11
18
|
}
|
|
12
19
|
export interface ServiceHandle<S> {
|
|
13
20
|
/** Snapshot of the current state. Cheap; safe to call from anywhere. */
|
|
@@ -38,6 +38,14 @@ export declare const RICH_CONTENT_LIMITS: {
|
|
|
38
38
|
readonly maxNodes: 5000;
|
|
39
39
|
readonly maxDepth: 16;
|
|
40
40
|
};
|
|
41
|
+
/** Which of the rich-content limits (`RICH_CONTENT_LIMITS`) a document's JSON string breaks — checked as every viewer does, in its order:
|
|
42
|
+
* the raw string's UTF-8 bytes (before parsing), then nesting depth (top-level nodes are depth 1), then the node
|
|
43
|
+
* count (every object in a `nodes` array). `null` when within all three. A worker runs it on the string it is
|
|
44
|
+
* about to WRITE: a body past a limit renders as every viewer's fallback, so it must not be saved. The document's
|
|
45
|
+
* shape is not checked here (a string that does not parse is within the limits) — `parseRicosJson` does that.
|
|
46
|
+
* Pass `parsed` (the string's own parse) when the caller has it, so the document is parsed once; pass `null`
|
|
47
|
+
* to check the bytes alone, before deciding to parse. */
|
|
48
|
+
export declare function richContentOverLimits(json: string, parsed?: unknown): "too_large" | "too_deep" | "too_many_nodes" | null;
|
|
41
49
|
/** Reads a Ricos document from a JSON string or an already-parsed object (page messages arrive as either,
|
|
42
50
|
* depending on the platform). Returns null for empty input, invalid JSON, or anything without a `nodes` array. */
|
|
43
51
|
export declare function parseRicosJson(value: unknown): RicosDocument | null;
|
|
@@ -121,6 +121,29 @@ function ricosVideoUrl(src) {
|
|
|
121
121
|
|
|
122
122
|
// ../../stdlib/wix-sdk/rich-content/src/index.ts
|
|
123
123
|
var RICH_CONTENT_LIMITS = { maxBytes: 262144, maxNodes: 5e3, maxDepth: 16 };
|
|
124
|
+
function richContentOverLimits(json, parsed) {
|
|
125
|
+
if (utf8Length(json) > RICH_CONTENT_LIMITS.maxBytes) return "too_large";
|
|
126
|
+
let doc = parsed;
|
|
127
|
+
if (doc === void 0) {
|
|
128
|
+
try {
|
|
129
|
+
doc = JSON.parse(json);
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const nodes = isObj(doc) && Array.isArray(doc.nodes) ? doc.nodes : null;
|
|
135
|
+
if (!nodes) return null;
|
|
136
|
+
if (deeperThan(nodes, 1, RICH_CONTENT_LIMITS.maxDepth)) return "too_deep";
|
|
137
|
+
return countNodes(nodes) > RICH_CONTENT_LIMITS.maxNodes ? "too_many_nodes" : null;
|
|
138
|
+
}
|
|
139
|
+
function deeperThan(nodes, depth, max) {
|
|
140
|
+
for (const n of nodes) {
|
|
141
|
+
if (!isObj(n)) continue;
|
|
142
|
+
if (depth > max) return true;
|
|
143
|
+
if (Array.isArray(n.nodes) && deeperThan(n.nodes, depth + 1, max)) return true;
|
|
144
|
+
}
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
124
147
|
var isObj = (v) => !!v && typeof v === "object" && !Array.isArray(v);
|
|
125
148
|
function parseRicosJson(value) {
|
|
126
149
|
let v = value;
|
|
@@ -340,6 +363,7 @@ export {
|
|
|
340
363
|
isPlainRicos,
|
|
341
364
|
parseRicosJson,
|
|
342
365
|
plainTextToRicos,
|
|
366
|
+
richContentOverLimits,
|
|
343
367
|
ricosToPlainText
|
|
344
368
|
};
|
|
345
369
|
//# sourceMappingURL=wix-rich-content.mjs.map
|
package/dist/cli.mjs
CHANGED
|
@@ -31775,7 +31775,7 @@ function RichContentEditorView({ props, ctx, wrap, nodeId, renderChild, ...dom }
|
|
|
31775
31775
|
}
|
|
31776
31776
|
setLink(null);
|
|
31777
31777
|
};
|
|
31778
|
-
const root = { position: "relative", display: "flex", flexDirection: "column", width: "100%", ...fill ? { flex: "1 1 auto"
|
|
31778
|
+
const root = { position: "relative", display: "flex", flexDirection: "column", width: "100%", ...fill ? { flex: "1 1 auto" } : {}, minHeight, ...wrap };
|
|
31779
31779
|
if (!live3) {
|
|
31780
31780
|
const { paddingTop: _t, paddingBottom: _b, paddingLeft: _l, paddingRight: _r, ...rootNoSides } = root;
|
|
31781
31781
|
const own3 = { ...rootNoSides, border: `1px solid ${C("border", "#e3e3e3")}`, borderRadius: 8, padding: 12, boxSizing: "border-box" };
|
|
@@ -60291,21 +60291,21 @@ var init_preview_assets_generated = __esm({
|
|
|
60291
60291
|
"../../packages/serve/src/preview-assets.generated.ts"() {
|
|
60292
60292
|
init_preview_assets_format();
|
|
60293
60293
|
PREVIEW_ASSET_MANIFEST = [
|
|
60294
|
-
{ key: "assets/index-
|
|
60295
|
-
{ key: "fonts/Roboto-LICENSE.txt", contentType: "application/octet-stream", offset:
|
|
60296
|
-
{ key: "fonts/Roboto-Variable.ttf", contentType: "font/ttf", offset:
|
|
60297
|
-
{ key: "fonts/WixMadeforDisplay-Bold.ttf", contentType: "font/ttf", offset:
|
|
60298
|
-
{ key: "fonts/WixMadeforDisplay-ExtraBold.ttf", contentType: "font/ttf", offset:
|
|
60299
|
-
{ key: "fonts/WixMadeforDisplay-Medium.ttf", contentType: "font/ttf", offset:
|
|
60300
|
-
{ key: "fonts/WixMadeforDisplay-Regular.ttf", contentType: "font/ttf", offset:
|
|
60301
|
-
{ key: "fonts/WixMadeforDisplay-SemiBold.ttf", contentType: "font/ttf", offset:
|
|
60302
|
-
{ key: "fonts/WixMadeforText-Bold.ttf", contentType: "font/ttf", offset:
|
|
60303
|
-
{ key: "fonts/WixMadeforText-ExtraBold.ttf", contentType: "font/ttf", offset:
|
|
60304
|
-
{ key: "fonts/WixMadeforText-Medium.ttf", contentType: "font/ttf", offset:
|
|
60305
|
-
{ key: "fonts/WixMadeforText-Regular.ttf", contentType: "font/ttf", offset:
|
|
60306
|
-
{ key: "fonts/WixMadeforText-SemiBold.ttf", contentType: "font/ttf", offset:
|
|
60307
|
-
{ key: "index.html", contentType: "text/html; charset=utf-8", offset:
|
|
60308
|
-
{ key: "t5-frame.html", contentType: "text/html; charset=utf-8", offset:
|
|
60294
|
+
{ key: "assets/index-B7qO1NPG.js", contentType: "application/javascript; charset=utf-8", offset: 8, length: 1133532, sha256: "b950705c43efae2ae266278ca6b108a89b7de1c88172890d9b862808cfcf9d83" },
|
|
60295
|
+
{ key: "fonts/Roboto-LICENSE.txt", contentType: "application/octet-stream", offset: 1133540, length: 4610, sha256: "994062dd7af4186a739de55dd9e151c63b6301f8e2cbafc608d35c2e87da0cd2" },
|
|
60296
|
+
{ key: "fonts/Roboto-Variable.ttf", contentType: "font/ttf", offset: 1138150, length: 488584, sha256: "d7598e12c5dbef095ff8272cfc55da0250bd07fbdecbac8a530b9b277872a134" },
|
|
60297
|
+
{ key: "fonts/WixMadeforDisplay-Bold.ttf", contentType: "font/ttf", offset: 1626734, length: 90924, sha256: "ec4c7d63772d980d6e147b2df6215fd2737e4b04cebe0cf7e11dc695fb63cdcf" },
|
|
60298
|
+
{ key: "fonts/WixMadeforDisplay-ExtraBold.ttf", contentType: "font/ttf", offset: 1717658, length: 90984, sha256: "bf4c16a2774edd38a921edf3099469866cd74c094fd596f3fa4e81f54a04dfaa" },
|
|
60299
|
+
{ key: "fonts/WixMadeforDisplay-Medium.ttf", contentType: "font/ttf", offset: 1808642, length: 90980, sha256: "368faca44fb9f7207e4af784e94750b1ee59c1098b8689e0f4a918926f244fb5" },
|
|
60300
|
+
{ key: "fonts/WixMadeforDisplay-Regular.ttf", contentType: "font/ttf", offset: 1899622, length: 90872, sha256: "97ed8f1a6772abe26e3d8af5cecfc4b02faa94497d585b0398b5053b739a70c1" },
|
|
60301
|
+
{ key: "fonts/WixMadeforDisplay-SemiBold.ttf", contentType: "font/ttf", offset: 1990494, length: 90980, sha256: "642feab8a48ddf6c7a89d7c2a7c0edfa0e6e773f671287e460e222703563191e" },
|
|
60302
|
+
{ key: "fonts/WixMadeforText-Bold.ttf", contentType: "font/ttf", offset: 2081474, length: 108636, sha256: "37c179b4fefb63ea9637322b84583a2df53aa226c150294eea1675c14b1b8e29" },
|
|
60303
|
+
{ key: "fonts/WixMadeforText-ExtraBold.ttf", contentType: "font/ttf", offset: 2190110, length: 109084, sha256: "62e2fe4718129f36ecdb0ae4de385bd6b81ecc876146447167c6c140fad699a9" },
|
|
60304
|
+
{ key: "fonts/WixMadeforText-Medium.ttf", contentType: "font/ttf", offset: 2299194, length: 109048, sha256: "4ecf270136af928612c76fe4ebade91d8156975e5ea4c0cf9225501dd1788e02" },
|
|
60305
|
+
{ key: "fonts/WixMadeforText-Regular.ttf", contentType: "font/ttf", offset: 2408242, length: 107700, sha256: "dee56f09fd3efc127c1ea95557edc74110c1460a46287da36e0af25efd341ef5" },
|
|
60306
|
+
{ key: "fonts/WixMadeforText-SemiBold.ttf", contentType: "font/ttf", offset: 2515942, length: 88108, sha256: "dced97a77624bc5f8b1ae65850360908297f4588e09feb6f22792d29dc7e765b" },
|
|
60307
|
+
{ key: "index.html", contentType: "text/html; charset=utf-8", offset: 2604050, length: 4947, sha256: "dca3aa5b2c45744b1c376ca6462b1c64093e59076238eccc4b9fcb30025fe850" },
|
|
60308
|
+
{ key: "t5-frame.html", contentType: "text/html; charset=utf-8", offset: 2608997, length: 17344, sha256: "d057298d75e7373e5e2c4d11b7d1bcbea88e03af555ba63d97641c78509c58e4" }
|
|
60309
60309
|
];
|
|
60310
60310
|
}
|
|
60311
60311
|
});
|
|
Binary file
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
"name": "ServiceDefinition",
|
|
9
9
|
"kind": "interface",
|
|
10
10
|
"jsdoc": "",
|
|
11
|
-
"decl": "export interface ServiceDefinition<S> {\n /** Initial state for the service. Must be JSON-serializable. */\n state: S;\n\n /**\n * Actions: pure functions of `(state, ...args) → newState` (or async). The\n * platform wraps each action so calling it from JS or from `__invokeAction`\n * (native side) updates the service's state and notifies subscribers.\n */\n actions?: Record<\n string,\n // `never[]`, not `unknown[]` — same reason as defineService's constraint below:\n // parameters are contravariant, so `unknown[]` rejects every handler that narrows\n // its payload. Leaving this one loose would reintroduce the same TS2344 for anyone\n // who annotates a `ServiceDefinition<S>` explicitly instead of inferring it.\n (state: S, ...args: never[]) => S | undefined | Promise<S | undefined>\n >;\n}"
|
|
11
|
+
"decl": "export interface ServiceDefinition<S> {\n /** Initial state for the service. Must be JSON-serializable. */\n state: S;\n\n /**\n * Actions: pure functions of `(state, ...args) → newState` (or async). The\n * platform wraps each action so calling it from JS or from `__invokeAction`\n * (native side) updates the service's state and notifies subscribers.\n */\n actions?: Record<\n string,\n // `never[]`, not `unknown[]` — same reason as defineService's constraint below:\n // parameters are contravariant, so `unknown[]` rejects every handler that narrows\n // its payload. Leaving this one loose would reintroduce the same TS2344 for anyone\n // who annotates a `ServiceDefinition<S>` explicitly instead of inferring it.\n (state: S, ...args: never[]) => S | undefined | Promise<S | undefined>\n >;\n\n /**\n * Keys a cold boot takes from `state`, never from the persisted snapshot (cold-boot\n * hydration). For UI state that must describe THIS run — an editor's one-shot seed, a\n * \"still loading\" gate, an open sheet — where last run's value would be wrong, not just stale.\n * Runtimes that honour it set `himalaya.hydrationOptOut`; an older one ignores the option.\n */\n notHydrated?: readonly (keyof S & string)[];\n}"
|
|
12
12
|
},
|
|
13
13
|
{
|
|
14
14
|
"name": "ServiceHandle",
|
|
@@ -21599,6 +21599,12 @@
|
|
|
21599
21599
|
"jsdoc": "/** The RichContent viewer's ceilings; every renderer rejects a document past any of them. Kept equal to the platform limits file by a test. */",
|
|
21600
21600
|
"decl": "export const RICH_CONTENT_LIMITS = { maxBytes: 262144, maxNodes: 5000, maxDepth: 16 } as const;"
|
|
21601
21601
|
},
|
|
21602
|
+
{
|
|
21603
|
+
"name": "richContentOverLimits",
|
|
21604
|
+
"kind": "function",
|
|
21605
|
+
"jsdoc": "/** Which of the rich-content limits (`RICH_CONTENT_LIMITS`) a document's JSON string breaks — checked as every viewer does, in its order:\n * the raw string's UTF-8 bytes (before parsing), then nesting depth (top-level nodes are depth 1), then the node\n * count (every object in a `nodes` array). `null` when within all three. A worker runs it on the string it is\n * about to WRITE: a body past a limit renders as every viewer's fallback, so it must not be saved. The document's\n * shape is not checked here (a string that does not parse is within the limits) — `parseRicosJson` does that.\n * Pass `parsed` (the string's own parse) when the caller has it, so the document is parsed once; pass `null`\n * to check the bytes alone, before deciding to parse. */",
|
|
21606
|
+
"decl": "export function richContentOverLimits(json: string, parsed?: unknown): \"too_large\" | \"too_deep\" | \"too_many_nodes\" | null;"
|
|
21607
|
+
},
|
|
21602
21608
|
{
|
|
21603
21609
|
"name": "parseRicosJson",
|
|
21604
21610
|
"kind": "function",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wix/himalaya-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.1179.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "himi — the Himalaya authoring CLI. Build a Tier-5 content package on your own machine, push it to the production serve as a draft, preview it on web + Himi Player, and read worker logs back — the remote dev loop. Wraps @himalaya/serve-cli for transport.",
|
|
6
6
|
"keywords": [
|
|
@@ -67,5 +67,5 @@
|
|
|
67
67
|
"artifactId": "himalaya-cli"
|
|
68
68
|
}
|
|
69
69
|
},
|
|
70
|
-
"falconPackageHash": "
|
|
70
|
+
"falconPackageHash": "73904175073399d3fbbcc0f3bf365e9dfe2b2ce4bef46062df2f0a88"
|
|
71
71
|
}
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// drops nodes the owner never touched).
|
|
9
9
|
// Inside the platform source this import is a relative path, so node test runs resolve it; an app's
|
|
10
10
|
// copy imports "@himalaya/wix-rich-content".
|
|
11
|
-
import { parseRicosJson, isPlainRicos,
|
|
11
|
+
import { parseRicosJson, isPlainRicos, richContentOverLimits, type RicosDocument } from "@himalaya/wix-rich-content";
|
|
12
12
|
|
|
13
13
|
export type EditorValue =
|
|
14
14
|
| { kind: "absent" } // leave the stored body untouched
|
|
@@ -17,34 +17,6 @@ export type EditorValue =
|
|
|
17
17
|
| { kind: "too_large" } // past the rich-content limits: save the rest, say why
|
|
18
18
|
| { kind: "invalid" }; // not a Ricos document: refuse, write nothing
|
|
19
19
|
|
|
20
|
-
function utf8Bytes(s: string): number {
|
|
21
|
-
let n = 0;
|
|
22
|
-
for (let i = 0; i < s.length; i++) {
|
|
23
|
-
const c = s.charCodeAt(i);
|
|
24
|
-
if (c < 0x80) n += 1;
|
|
25
|
-
else if (c < 0x800) n += 2;
|
|
26
|
-
else if (c >= 0xd800 && c <= 0xdbff && i + 1 < s.length) { n += 4; i++; }
|
|
27
|
-
else n += 3;
|
|
28
|
-
}
|
|
29
|
-
return n;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** Every viewer renders a body past the rich-content limits as its fallback, so it is never written.
|
|
33
|
-
* The viewer's order: bytes (before parsing), then depth, then the node count. */
|
|
34
|
-
function overLimits(doc: RicosDocument): boolean {
|
|
35
|
-
let count = 0;
|
|
36
|
-
const work: Array<[unknown, number]> = doc.nodes.map((n) => [n, 1] as [unknown, number]);
|
|
37
|
-
while (work.length) {
|
|
38
|
-
const [n, depth] = work.pop()!;
|
|
39
|
-
if (!n || typeof n !== "object" || Array.isArray(n)) continue;
|
|
40
|
-
if (depth > RICH_CONTENT_LIMITS.maxDepth) return true;
|
|
41
|
-
if (++count > RICH_CONTENT_LIMITS.maxNodes) return true;
|
|
42
|
-
const kids = (n as { nodes?: unknown }).nodes;
|
|
43
|
-
if (Array.isArray(kids)) for (const k of kids) work.push([k, depth + 1]);
|
|
44
|
-
}
|
|
45
|
-
return false;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
20
|
const TEXT_ONLY = new Set(["PARAGRAPH", "TEXT", "HEADING", "BULLETED_LIST", "ORDERED_LIST", "LIST_ITEM", "BLOCKQUOTE", "CODE_BLOCK"]);
|
|
49
21
|
/** No visible text and no non-text node (an image, a divider, an embed…): an empty body. */
|
|
50
22
|
export function isEmptyDocument(doc: RicosDocument): boolean {
|
|
@@ -55,10 +27,12 @@ export function isEmptyDocument(doc: RicosDocument): boolean {
|
|
|
55
27
|
|
|
56
28
|
/** A Ricos JSON string checked as every viewer would: the limits first, then the shape. */
|
|
57
29
|
function checkedDocument(raw: string): EditorValue {
|
|
58
|
-
|
|
30
|
+
// Every viewer renders a body past the limits as its fallback, so it is never written. One parse
|
|
31
|
+
// feeds both checks; a string past the byte limit is refused before it is parsed at all.
|
|
32
|
+
if (richContentOverLimits(raw, null) === "too_large") return { kind: "too_large" };
|
|
59
33
|
const doc = parseRicosJson(raw);
|
|
60
34
|
if (!doc) return { kind: "invalid" };
|
|
61
|
-
return
|
|
35
|
+
return richContentOverLimits(raw, doc) ? { kind: "too_large" } : { kind: "doc", doc, json: raw };
|
|
62
36
|
}
|
|
63
37
|
|
|
64
38
|
/** `raw` is `values[name]`; `seededJson` is the string the form published as the editor's seed. */
|
|
@@ -108,15 +82,59 @@ export function cachedRichVerdict(doc: RicosDocument | null, knownPlain: boolean
|
|
|
108
82
|
return knownPlain ? "false" : "true";
|
|
109
83
|
}
|
|
110
84
|
|
|
111
|
-
/**
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
|
|
85
|
+
/** The keys of one editor field that describe THIS run, never the last one: the seed (`<key>Json`),
|
|
86
|
+
* the load gate (`<key>Pending`), the edit flag (`<key>Dirty`) and its counter (`<key>Edits`). A form
|
|
87
|
+
* lists them in `defineService(name, { notHydrated })`, so a cold boot takes their DECLARED values: the
|
|
88
|
+
* editor seeds once, and a persisted seed would be painted as its content and saved back over a newer
|
|
89
|
+
* record. */
|
|
90
|
+
export function editorRunKeys<K extends string>(key: K): [`${K}Json`, `${K}Pending`, `${K}Dirty`, `${K}Edits`] {
|
|
91
|
+
return [`${key}Json`, `${key}Pending`, `${key}Dirty`, `${key}Edits`];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The same keys on a runtime that ignores `notHydrated` (a binary from before #3644, still served new
|
|
95
|
+
* bundles over the air): reset them in place, before any action runs. A no-op where the runtime
|
|
96
|
+
* honours the option (`himalaya.hydrationOptOut`). */
|
|
97
|
+
export function resetHydratedEditorOnOlderRuntime(svc: unknown, key: string, pendingAtBoot: boolean, extra: Record<string, unknown> = {}): void {
|
|
98
|
+
if ((globalThis as { himalaya?: { hydrationOptOut?: unknown } }).himalaya?.hydrationOptOut === true) return;
|
|
116
99
|
const booted = (svc as { getState?: () => Record<string, unknown> | undefined }).getState?.();
|
|
117
100
|
if (!booted || typeof booted !== "object") return;
|
|
118
|
-
if (booted[
|
|
119
|
-
booted[
|
|
120
|
-
booted[
|
|
101
|
+
if (booted[`${key}Json`]) booted[`${key}Json`] = "";
|
|
102
|
+
booted[`${key}Pending`] = pendingAtBoot;
|
|
103
|
+
booted[`${key}Dirty`] = false;
|
|
104
|
+
booted[`${key}Edits`] = 0;
|
|
121
105
|
Object.assign(booted, extra);
|
|
122
106
|
}
|
|
107
|
+
|
|
108
|
+
/** What an awaited save leaves the field as, read from the LIVE state when it lands. An on_change that
|
|
109
|
+
* arrived while the save was in flight (`<key>Edits` moved) is an edit the save did not write: the
|
|
110
|
+
* field stays dirty and the save's confirmation is stale. Otherwise a save that wrote the field
|
|
111
|
+
* re-bases it as saved. `live` is the service's state now (none, or one that never moved: the start state). */
|
|
112
|
+
export function editorAfterSave(key: string, start: Record<string, unknown>, live: Record<string, unknown> | undefined, wrote: boolean):
|
|
113
|
+
{ dirty: boolean; edits: number; editedDuringSave: boolean } {
|
|
114
|
+
const atStart = Number(start[`${key}Edits`] ?? 0);
|
|
115
|
+
const now = Number(live?.[`${key}Edits`] ?? atStart);
|
|
116
|
+
// Only a counter that MOVED ON counts; one behind the start is a host that does not apply results.
|
|
117
|
+
const editedDuringSave = now > atStart;
|
|
118
|
+
return editedDuringSave
|
|
119
|
+
? { dirty: true, edits: now, editedDuringSave }
|
|
120
|
+
: { dirty: wrote ? false : Boolean(start[`${key}Dirty`]), edits: atStart, editedDuringSave };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The live state of a service handle, when the runtime gives one. */
|
|
124
|
+
export function liveState(svc: unknown): Record<string, unknown> | undefined {
|
|
125
|
+
const st = (svc as { getState?: () => unknown } | undefined)?.getState?.();
|
|
126
|
+
return st && typeof st === "object" ? st as Record<string, unknown> : undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** An awaited action's result, laid over the LIVE state: only the keys the action CHANGED (against the
|
|
130
|
+
* state it started from) are written, so anything the owner did while it awaited — an edit, a field in
|
|
131
|
+
* the details sheet, closing the sheet — survives a save that lands after it, success or failure. A
|
|
132
|
+
* key both changed goes to the action. Where the host gives no live state, the result as it is. */
|
|
133
|
+
export function settleOverLive<S extends object>(svc: unknown, start: S, result: S): S {
|
|
134
|
+
const now = liveState(svc) as S | undefined;
|
|
135
|
+
if (!now || now === start || now === result) return result;
|
|
136
|
+
const out = { ...now } as Record<string, unknown>;
|
|
137
|
+
const r = result as Record<string, unknown>, s0 = start as Record<string, unknown>;
|
|
138
|
+
for (const k of Object.keys(r)) if (!Object.is(r[k], s0[k])) out[k] = r[k];
|
|
139
|
+
return out as S;
|
|
140
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// screen (himiOwnerLitePost.publish). Bound by buildForm ($service:himiOwnerLitePostForm.*).
|
|
5
5
|
|
|
6
6
|
import { postBodyText, plainPostBody, postCoverUrl } from "../_shared/owner-post-content.js";
|
|
7
|
-
import { editorSubmitValue, queuedDocument, editorChanged,
|
|
7
|
+
import { editorSubmitValue, queuedDocument, editorChanged, editorRunKeys, resetHydratedEditorOnOlderRuntime, editorAfterSave, liveState, settleOverLive } from "../_shared/owner-editor-value.js";
|
|
8
8
|
import { ricosUploadService } from "@himalaya/wix-rich-content-editor";
|
|
9
9
|
import { wixCall } from "@himalaya/wix-site-media";
|
|
10
10
|
import { wixMediaUrl, WIX_DETAIL_IMAGE } from "../_shared/himi-store.js";
|
|
@@ -46,6 +46,8 @@ interface PostFormState {
|
|
|
46
46
|
* the body was loaded or saved, and (edit mode) the editor is hidden until this run's load lands. */
|
|
47
47
|
bodyDirty: boolean;
|
|
48
48
|
bodyPending: boolean;
|
|
49
|
+
/** Counts the editor's on_change events, so a save can tell an edit made while it was in flight. */
|
|
50
|
+
bodyEdits: number;
|
|
49
51
|
coverUrl: string;
|
|
50
52
|
originalCoverUrl: string;
|
|
51
53
|
coverPreview: string;
|
|
@@ -120,6 +122,7 @@ function shape(s: Partial<PostFormState>): PostFormState {
|
|
|
120
122
|
detailsOpen: s.detailsOpen ?? false,
|
|
121
123
|
bodyDirty: s.bodyDirty ?? false,
|
|
122
124
|
bodyPending: s.bodyPending ?? false,
|
|
125
|
+
bodyEdits: s.bodyEdits ?? 0,
|
|
123
126
|
coverUrl: s.coverUrl ?? "",
|
|
124
127
|
originalCoverUrl: s.originalCoverUrl ?? "",
|
|
125
128
|
coverPreview: wixMediaUrl(s.coverUrl ?? "", WIX_DETAIL_IMAGE),
|
|
@@ -150,9 +153,8 @@ function demoRefusal(s: Parameters<typeof shape>[0]): ReturnType<typeof shape> {
|
|
|
150
153
|
|
|
151
154
|
// [rico] #3371 — the RichContentEditor's image tool (the editor's upload contract). The component picks
|
|
152
155
|
// and PUTs the bytes; this service mints the signed Wix Media URL with the worker's own wixApi
|
|
153
|
-
// (@himalaya/wix-site-media rides the `wix_api` grant), so no token reaches the page.
|
|
154
|
-
//
|
|
155
|
-
// LAST defineService as its service.
|
|
156
|
+
// (@himalaya/wix-site-media rides the `wix_api` grant), so no token reaches the page. Any order:
|
|
157
|
+
// the worker test harnesses find each service by name.
|
|
156
158
|
ricosUploadService("himiOwnerLitePostFormUploads");
|
|
157
159
|
|
|
158
160
|
const svc = defineService<PostFormState, {
|
|
@@ -165,6 +167,9 @@ const svc = defineService<PostFormState, {
|
|
|
165
167
|
submit: (s: PostFormState, values?: Record<string, unknown>) => Promise<PostFormState>;
|
|
166
168
|
}>("himiOwnerLitePostForm", {
|
|
167
169
|
state: shape({ mode: startMode, id: initialInput.id ?? "", bodyPending: startMode === "edit" }),
|
|
170
|
+
// [rico] #3644 — this run's editor state and UI: a cold boot takes these from `state`, never from
|
|
171
|
+
// the persisted snapshot (the editor seeds once; last run's sheet is not this run's).
|
|
172
|
+
notHydrated: [...editorRunKeys("body"), "detailsOpen"],
|
|
168
173
|
actions: {
|
|
169
174
|
load: async (s, arg) => {
|
|
170
175
|
stashCopy(arg); // absorb the descriptor's onAppear copy-pack param
|
|
@@ -236,8 +241,9 @@ const svc = defineService<PostFormState, {
|
|
|
236
241
|
detailsOpen: (s) => shape({ ...s, detailsOpen: true }),
|
|
237
242
|
detailsClose: (s) => shape({ ...s, detailsOpen: false }),
|
|
238
243
|
// The editor's `on_change` carries `args.dirty` only; the document stays in the form bag.
|
|
239
|
-
bodyChanged: (s) => shape({ ...s, bodyDirty: true, ...editorChanged(s) }),
|
|
240
|
-
|
|
244
|
+
bodyChanged: (s) => shape({ ...s, bodyDirty: true, bodyEdits: s.bodyEdits + 1, ...editorChanged(s) }),
|
|
245
|
+
// Laid over the live state ([rico] #3644): what the owner did while the save awaited survives it.
|
|
246
|
+
submit: async (s, values): Promise<PostFormState> => settleOverLive(svc, s, await (async (): Promise<PostFormState> => {
|
|
241
247
|
if (isOwnerPreviewDemo() || s.demoNote) return demoRefusal(s);
|
|
242
248
|
const v = values ?? {};
|
|
243
249
|
const title = String(v.title ?? s.title).trim();
|
|
@@ -292,14 +298,19 @@ const svc = defineService<PostFormState, {
|
|
|
292
298
|
let kept: WixRichContent | null = bodyEdited ? richContent : (replay ? null : s.richContent);
|
|
293
299
|
const localRow = (id: string): DbEntity => ({ id, name: title, title, excerpt, body: postBodyText(kept), coverUrl, ...(kept ? { richContent: JSON.stringify(kept) } : {}), isFeatured: isFeatured ? "true" : "false", postStatus: "Draft", postStatusRaw: "DRAFT" });
|
|
294
300
|
const localId = editing ? targetId : `local-${Date.now()}`;
|
|
295
|
-
const savedState = (id: string, savedMessage: string) =>
|
|
301
|
+
const savedState = (id: string, savedMessage: string) => {
|
|
302
|
+
// An edit typed while this save was in flight stays unsaved work ([rico] #3644).
|
|
303
|
+
const after = editorAfterSave("body", s as never, liveState(svc), bodyEdited);
|
|
304
|
+
return shape({
|
|
296
305
|
...s, mode: "edit", id, heading: copyParam("ownerPostForm.headingEdit", "Edit post"),
|
|
297
306
|
title, excerpt, body: postBodyText(kept), richContent: kept, bodyJson: bodyEdited ? bodyRaw : s.bodyJson,
|
|
298
|
-
bodyDirty:
|
|
307
|
+
bodyDirty: after.dirty, bodyEdits: after.edits,
|
|
299
308
|
coverUrl, originalCoverUrl: coverUrl, isFeatured: isFeatured ? "true" : "false",
|
|
300
309
|
status: "saved", savedMessage, savedId: id,
|
|
310
|
+
...(after.editedDuringSave ? { status: "idle" as const, savedMessage: "" } : {}),
|
|
301
311
|
error: bodyTooLarge ? copyParam("ownerPostForm.bodyTooLarge", "The post body is too long to save, so it was left as it was. Your other changes were saved.") : "",
|
|
302
|
-
|
|
312
|
+
});
|
|
313
|
+
};
|
|
303
314
|
try {
|
|
304
315
|
// A replay worker starts without the loaded document. Entity upserts replace whole rows, so
|
|
305
316
|
// retain the cached rich body before writing back.
|
|
@@ -349,12 +360,11 @@ const svc = defineService<PostFormState, {
|
|
|
349
360
|
}
|
|
350
361
|
return shape({ ...s, status: "error", error: ownerLoadError(e), savedMessage: "" });
|
|
351
362
|
}
|
|
352
|
-
},
|
|
363
|
+
})()),
|
|
353
364
|
},
|
|
354
365
|
});
|
|
355
366
|
|
|
356
|
-
//
|
|
357
|
-
|
|
358
|
-
resetHydratedEditor(svc, { json: "bodyJson", pending: "bodyPending", dirty: "bodyDirty" }, startMode === "edit", { detailsOpen: false });
|
|
367
|
+
// The same reset on a runtime from before `notHydrated` existed (an older binary on a new bundle).
|
|
368
|
+
resetHydratedEditorOnOlderRuntime(svc, "body", startMode === "edit", { detailsOpen: false });
|
|
359
369
|
|
|
360
370
|
export { svc };
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
import { get as dbGet, upsert as dbUpsert, remove as dbRemove, enqueue as dbEnqueue, type DbEntity } from "../_shared/owner-local-db.js";
|
|
19
19
|
import { htmlIsTrivial, htmlPreviewText } from "../_shared/owner-rich-guard.js";
|
|
20
20
|
import { postBodyText, plainPostBody } from "../_shared/owner-post-content.js";
|
|
21
|
-
import { editorSubmitValue, queuedDocument, editorChanged, cachedRichVerdict,
|
|
21
|
+
import { editorSubmitValue, queuedDocument, editorChanged, cachedRichVerdict, editorRunKeys, resetHydratedEditorOnOlderRuntime, editorAfterSave, liveState, settleOverLive } from "../_shared/owner-editor-value.js";
|
|
22
22
|
import { parseRicosJson } from "@himalaya/wix-rich-content";
|
|
23
23
|
import { ricosUploadService } from "@himalaya/wix-rich-content-editor";
|
|
24
24
|
import { ownerLoadError, isBridgeUnavailable, isOwnerPreviewDemo, shouldQueueOwnerWrite } from "../_shared/owner-load-error.js";
|
|
@@ -69,6 +69,8 @@ interface ProductFormState {
|
|
|
69
69
|
* hidden until this run's load lands. */
|
|
70
70
|
descriptionDirty: boolean;
|
|
71
71
|
descriptionPending: boolean;
|
|
72
|
+
/** Counts the editor's on_change events, so a save can tell an edit made while it was in flight. */
|
|
73
|
+
descriptionEdits: number;
|
|
72
74
|
/** Derived from the mode: "locked" shows the preview + `descriptionNote`, and refuses an edit. */
|
|
73
75
|
descriptionProtected: boolean;
|
|
74
76
|
descriptionEditable: boolean;
|
|
@@ -154,6 +156,7 @@ function shape(s: Partial<ProductFormState>): ProductFormState {
|
|
|
154
156
|
descriptionEditorHidden: dMode !== "editor",
|
|
155
157
|
descriptionDirty: s.descriptionDirty ?? false,
|
|
156
158
|
descriptionPending: s.descriptionPending ?? false,
|
|
159
|
+
descriptionEdits: s.descriptionEdits ?? 0,
|
|
157
160
|
descriptionProtected: dMode === "locked",
|
|
158
161
|
descriptionEditable: dMode !== "locked",
|
|
159
162
|
descriptionNote: dMode === "locked" ? copyParam("ownerProductForm.richDescriptionNote", "This description has formatting or media. Edit it in your site dashboard to keep that content intact; name, price and SKU can be changed here.") : "",
|
|
@@ -183,8 +186,8 @@ function demoRefusal(s: Parameters<typeof shape>[0]): ReturnType<typeof shape> {
|
|
|
183
186
|
|
|
184
187
|
// The RichContentEditor's image tool (the editor's upload contract). The component picks and PUTs
|
|
185
188
|
// the bytes; this service mints the signed Wix Media URL with the worker's own wixApi, so no token
|
|
186
|
-
// reaches the page.
|
|
187
|
-
//
|
|
189
|
+
// reaches the page. Any order: the
|
|
190
|
+
// worker test harnesses find each service by name.
|
|
188
191
|
ricosUploadService("himiOwnerLiteProductFormUploads");
|
|
189
192
|
|
|
190
193
|
const svc = defineService<ProductFormState, {
|
|
@@ -194,6 +197,8 @@ const svc = defineService<ProductFormState, {
|
|
|
194
197
|
remove: (s: ProductFormState) => Promise<ProductFormState>;
|
|
195
198
|
}>("himiOwnerLiteProductForm", {
|
|
196
199
|
state: shape({ mode: startMode, id: initialInput.id ?? "", descriptionPending: startMode === "edit" }),
|
|
200
|
+
// [rico] #3644 — this run's editor state: a cold boot takes it from `state`, never the snapshot.
|
|
201
|
+
notHydrated: editorRunKeys("description"),
|
|
197
202
|
actions: {
|
|
198
203
|
load: async (s, arg) => {
|
|
199
204
|
stashCopy(arg); // absorb the descriptor's onAppear copy-pack param
|
|
@@ -279,8 +284,9 @@ const svc = defineService<ProductFormState, {
|
|
|
279
284
|
}
|
|
280
285
|
},
|
|
281
286
|
// The editor's on_change carries `args.dirty` only; the document stays in the form bag.
|
|
282
|
-
descriptionChanged: (s) => shape({ ...s, descriptionDirty: true, ...editorChanged(s) }),
|
|
283
|
-
|
|
287
|
+
descriptionChanged: (s) => shape({ ...s, descriptionDirty: true, descriptionEdits: s.descriptionEdits + 1, ...editorChanged(s) }),
|
|
288
|
+
// Laid over the live state ([rico] #3644): what the owner did while the save awaited survives it.
|
|
289
|
+
submit: async (s, values): Promise<ProductFormState> => settleOverLive(svc, s, await (async (): Promise<ProductFormState> => {
|
|
284
290
|
if (isOwnerPreviewDemo() || s.demoNote) return demoRefusal(s);
|
|
285
291
|
const v = values ?? {};
|
|
286
292
|
const name = String(v.name ?? s.name).trim();
|
|
@@ -352,9 +358,13 @@ const svc = defineService<ProductFormState, {
|
|
|
352
358
|
const savedDescription = {
|
|
353
359
|
description, originalDescription: descriptionEdited || wrote ? description : s.originalDescription,
|
|
354
360
|
...(docRaw ? { descriptionJson: docRaw } : {}),
|
|
355
|
-
descriptionDirty: wrote ? false : s.descriptionDirty,
|
|
356
361
|
error: tooLarge ? copyParam("ownerProductForm.descriptionTooLarge", "The description is too long to save, so it was left as it was. Your other changes were saved.") : "",
|
|
357
362
|
};
|
|
363
|
+
// Read when the save lands: an edit typed while it was in flight stays unsaved work ([rico] #3644).
|
|
364
|
+
const afterSaved = () => {
|
|
365
|
+
const a = editorAfterSave("description", s as never, liveState(svc), wrote);
|
|
366
|
+
return { descriptionDirty: a.dirty, descriptionEdits: a.edits, ...(a.editedDuringSave ? { status: "idle" as const, savedMessage: "" } : {}) };
|
|
367
|
+
};
|
|
358
368
|
const localId = s.mode === "edit" && s.id ? s.id : `local-${Date.now()}`;
|
|
359
369
|
await cachePut(localRow(localId));
|
|
360
370
|
try {
|
|
@@ -380,6 +390,7 @@ const svc = defineService<ProductFormState, {
|
|
|
380
390
|
...savedDescription,
|
|
381
391
|
savedMessage: copyParam("ownerProductForm.savedMessage", "Product saved."),
|
|
382
392
|
savedId: id,
|
|
393
|
+
...afterSaved(),
|
|
383
394
|
});
|
|
384
395
|
} catch (e) {
|
|
385
396
|
if (shouldQueueOwnerWrite(e)) {
|
|
@@ -398,11 +409,12 @@ const svc = defineService<ProductFormState, {
|
|
|
398
409
|
visible: visible ? "true" : "false", status: "saved",
|
|
399
410
|
savedMessage: copyParam("ownerProductForm.savedOffline", "Saved offline — will sync when you reconnect."),
|
|
400
411
|
savedId: localId,
|
|
412
|
+
...afterSaved(),
|
|
401
413
|
});
|
|
402
414
|
}
|
|
403
415
|
return shape({ ...s, status: "error", error: ownerLoadError(e), savedMessage: "" });
|
|
404
416
|
}
|
|
405
|
-
},
|
|
417
|
+
})()),
|
|
406
418
|
remove: async (s) => {
|
|
407
419
|
if (isOwnerPreviewDemo() || s.demoNote) return demoRefusal(s);
|
|
408
420
|
if (!s.id) return shape({ ...s, status: "error", error: copyParam("ownerProductForm.errorNoDelete", "No product to delete."), savedMessage: "" });
|
|
@@ -424,6 +436,6 @@ const svc = defineService<ProductFormState, {
|
|
|
424
436
|
});
|
|
425
437
|
|
|
426
438
|
// Adopter contract: the editor seeds once, so a cold boot must not seed it with the last run's document.
|
|
427
|
-
|
|
439
|
+
resetHydratedEditorOnOlderRuntime(svc, "description", startMode === "edit");
|
|
428
440
|
|
|
429
441
|
export { svc };
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// drops nodes the owner never touched).
|
|
9
9
|
// Inside the platform source this import is a relative path, so node test runs resolve it; an app's
|
|
10
10
|
// copy imports "@himalaya/wix-rich-content".
|
|
11
|
-
import { parseRicosJson, isPlainRicos,
|
|
11
|
+
import { parseRicosJson, isPlainRicos, richContentOverLimits, type RicosDocument } from "@himalaya/wix-rich-content";
|
|
12
12
|
|
|
13
13
|
export type EditorValue =
|
|
14
14
|
| { kind: "absent" } // leave the stored body untouched
|
|
@@ -17,34 +17,6 @@ export type EditorValue =
|
|
|
17
17
|
| { kind: "too_large" } // past the rich-content limits: save the rest, say why
|
|
18
18
|
| { kind: "invalid" }; // not a Ricos document: refuse, write nothing
|
|
19
19
|
|
|
20
|
-
function utf8Bytes(s: string): number {
|
|
21
|
-
let n = 0;
|
|
22
|
-
for (let i = 0; i < s.length; i++) {
|
|
23
|
-
const c = s.charCodeAt(i);
|
|
24
|
-
if (c < 0x80) n += 1;
|
|
25
|
-
else if (c < 0x800) n += 2;
|
|
26
|
-
else if (c >= 0xd800 && c <= 0xdbff && i + 1 < s.length) { n += 4; i++; }
|
|
27
|
-
else n += 3;
|
|
28
|
-
}
|
|
29
|
-
return n;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** Every viewer renders a body past the rich-content limits as its fallback, so it is never written.
|
|
33
|
-
* The viewer's order: bytes (before parsing), then depth, then the node count. */
|
|
34
|
-
function overLimits(doc: RicosDocument): boolean {
|
|
35
|
-
let count = 0;
|
|
36
|
-
const work: Array<[unknown, number]> = doc.nodes.map((n) => [n, 1] as [unknown, number]);
|
|
37
|
-
while (work.length) {
|
|
38
|
-
const [n, depth] = work.pop()!;
|
|
39
|
-
if (!n || typeof n !== "object" || Array.isArray(n)) continue;
|
|
40
|
-
if (depth > RICH_CONTENT_LIMITS.maxDepth) return true;
|
|
41
|
-
if (++count > RICH_CONTENT_LIMITS.maxNodes) return true;
|
|
42
|
-
const kids = (n as { nodes?: unknown }).nodes;
|
|
43
|
-
if (Array.isArray(kids)) for (const k of kids) work.push([k, depth + 1]);
|
|
44
|
-
}
|
|
45
|
-
return false;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
20
|
const TEXT_ONLY = new Set(["PARAGRAPH", "TEXT", "HEADING", "BULLETED_LIST", "ORDERED_LIST", "LIST_ITEM", "BLOCKQUOTE", "CODE_BLOCK"]);
|
|
49
21
|
/** No visible text and no non-text node (an image, a divider, an embed…): an empty body. */
|
|
50
22
|
export function isEmptyDocument(doc: RicosDocument): boolean {
|
|
@@ -55,10 +27,12 @@ export function isEmptyDocument(doc: RicosDocument): boolean {
|
|
|
55
27
|
|
|
56
28
|
/** A Ricos JSON string checked as every viewer would: the limits first, then the shape. */
|
|
57
29
|
function checkedDocument(raw: string): EditorValue {
|
|
58
|
-
|
|
30
|
+
// Every viewer renders a body past the limits as its fallback, so it is never written. One parse
|
|
31
|
+
// feeds both checks; a string past the byte limit is refused before it is parsed at all.
|
|
32
|
+
if (richContentOverLimits(raw, null) === "too_large") return { kind: "too_large" };
|
|
59
33
|
const doc = parseRicosJson(raw);
|
|
60
34
|
if (!doc) return { kind: "invalid" };
|
|
61
|
-
return
|
|
35
|
+
return richContentOverLimits(raw, doc) ? { kind: "too_large" } : { kind: "doc", doc, json: raw };
|
|
62
36
|
}
|
|
63
37
|
|
|
64
38
|
/** `raw` is `values[name]`; `seededJson` is the string the form published as the editor's seed. */
|
|
@@ -108,15 +82,59 @@ export function cachedRichVerdict(doc: RicosDocument | null, knownPlain: boolean
|
|
|
108
82
|
return knownPlain ? "false" : "true";
|
|
109
83
|
}
|
|
110
84
|
|
|
111
|
-
/**
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
|
|
85
|
+
/** The keys of one editor field that describe THIS run, never the last one: the seed (`<key>Json`),
|
|
86
|
+
* the load gate (`<key>Pending`), the edit flag (`<key>Dirty`) and its counter (`<key>Edits`). A form
|
|
87
|
+
* lists them in `defineService(name, { notHydrated })`, so a cold boot takes their DECLARED values: the
|
|
88
|
+
* editor seeds once, and a persisted seed would be painted as its content and saved back over a newer
|
|
89
|
+
* record. */
|
|
90
|
+
export function editorRunKeys<K extends string>(key: K): [`${K}Json`, `${K}Pending`, `${K}Dirty`, `${K}Edits`] {
|
|
91
|
+
return [`${key}Json`, `${key}Pending`, `${key}Dirty`, `${key}Edits`];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The same keys on a runtime that ignores `notHydrated` (a binary from before #3644, still served new
|
|
95
|
+
* bundles over the air): reset them in place, before any action runs. A no-op where the runtime
|
|
96
|
+
* honours the option (`himalaya.hydrationOptOut`). */
|
|
97
|
+
export function resetHydratedEditorOnOlderRuntime(svc: unknown, key: string, pendingAtBoot: boolean, extra: Record<string, unknown> = {}): void {
|
|
98
|
+
if ((globalThis as { himalaya?: { hydrationOptOut?: unknown } }).himalaya?.hydrationOptOut === true) return;
|
|
116
99
|
const booted = (svc as { getState?: () => Record<string, unknown> | undefined }).getState?.();
|
|
117
100
|
if (!booted || typeof booted !== "object") return;
|
|
118
|
-
if (booted[
|
|
119
|
-
booted[
|
|
120
|
-
booted[
|
|
101
|
+
if (booted[`${key}Json`]) booted[`${key}Json`] = "";
|
|
102
|
+
booted[`${key}Pending`] = pendingAtBoot;
|
|
103
|
+
booted[`${key}Dirty`] = false;
|
|
104
|
+
booted[`${key}Edits`] = 0;
|
|
121
105
|
Object.assign(booted, extra);
|
|
122
106
|
}
|
|
107
|
+
|
|
108
|
+
/** What an awaited save leaves the field as, read from the LIVE state when it lands. An on_change that
|
|
109
|
+
* arrived while the save was in flight (`<key>Edits` moved) is an edit the save did not write: the
|
|
110
|
+
* field stays dirty and the save's confirmation is stale. Otherwise a save that wrote the field
|
|
111
|
+
* re-bases it as saved. `live` is the service's state now (none, or one that never moved: the start state). */
|
|
112
|
+
export function editorAfterSave(key: string, start: Record<string, unknown>, live: Record<string, unknown> | undefined, wrote: boolean):
|
|
113
|
+
{ dirty: boolean; edits: number; editedDuringSave: boolean } {
|
|
114
|
+
const atStart = Number(start[`${key}Edits`] ?? 0);
|
|
115
|
+
const now = Number(live?.[`${key}Edits`] ?? atStart);
|
|
116
|
+
// Only a counter that MOVED ON counts; one behind the start is a host that does not apply results.
|
|
117
|
+
const editedDuringSave = now > atStart;
|
|
118
|
+
return editedDuringSave
|
|
119
|
+
? { dirty: true, edits: now, editedDuringSave }
|
|
120
|
+
: { dirty: wrote ? false : Boolean(start[`${key}Dirty`]), edits: atStart, editedDuringSave };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** The live state of a service handle, when the runtime gives one. */
|
|
124
|
+
export function liveState(svc: unknown): Record<string, unknown> | undefined {
|
|
125
|
+
const st = (svc as { getState?: () => unknown } | undefined)?.getState?.();
|
|
126
|
+
return st && typeof st === "object" ? st as Record<string, unknown> : undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** An awaited action's result, laid over the LIVE state: only the keys the action CHANGED (against the
|
|
130
|
+
* state it started from) are written, so anything the owner did while it awaited — an edit, a field in
|
|
131
|
+
* the details sheet, closing the sheet — survives a save that lands after it, success or failure. A
|
|
132
|
+
* key both changed goes to the action. Where the host gives no live state, the result as it is. */
|
|
133
|
+
export function settleOverLive<S extends object>(svc: unknown, start: S, result: S): S {
|
|
134
|
+
const now = liveState(svc) as S | undefined;
|
|
135
|
+
if (!now || now === start || now === result) return result;
|
|
136
|
+
const out = { ...now } as Record<string, unknown>;
|
|
137
|
+
const r = result as Record<string, unknown>, s0 = start as Record<string, unknown>;
|
|
138
|
+
for (const k of Object.keys(r)) if (!Object.is(r[k], s0[k])) out[k] = r[k];
|
|
139
|
+
return out as S;
|
|
140
|
+
}
|