@caelo-cms/shared 0.10.61 → 0.10.62
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/auto-update.d.ts +15 -0
- package/dist/auto-update.d.ts.map +1 -1
- package/dist/auto-update.js +27 -0
- package/dist/auto-update.js.map +1 -1
- package/dist/cloud-shell-install.d.ts +42 -10
- package/dist/cloud-shell-install.d.ts.map +1 -1
- package/dist/cloud-shell-install.js +59 -0
- package/dist/cloud-shell-install.js.map +1 -1
- package/dist/css-scope-scan.d.ts +29 -0
- package/dist/css-scope-scan.d.ts.map +1 -0
- package/dist/css-scope-scan.js +251 -0
- package/dist/css-scope-scan.js.map +1 -0
- package/dist/font-assets.d.ts +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/interactions.d.ts +65 -13
- package/dist/interactions.d.ts.map +1 -1
- package/dist/interactions.js +132 -33
- package/dist/interactions.js.map +1 -1
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +15 -19
- package/dist/preview-compose.js.map +1 -1
- package/dist/release-manifest.d.ts +3 -0
- package/dist/release-manifest.d.ts.map +1 -1
- package/dist/release-manifest.js +11 -0
- package/dist/release-manifest.js.map +1 -1
- package/dist/static-security-headers.d.ts +50 -0
- package/dist/static-security-headers.d.ts.map +1 -0
- package/dist/static-security-headers.js +58 -0
- package/dist/static-security-headers.js.map +1 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/auto-update.test.ts +25 -0
- package/src/auto-update.ts +36 -0
- package/src/cloud-shell-install.test.ts +36 -1
- package/src/cloud-shell-install.ts +64 -0
- package/src/css-scope-scan.test.ts +117 -0
- package/src/css-scope-scan.ts +263 -0
- package/src/index.ts +2 -0
- package/src/interactions.test.ts +61 -0
- package/src/interactions.ts +181 -26
- package/src/preview-compose.test.ts +87 -1
- package/src/preview-compose.ts +16 -21
- package/src/release-manifest.ts +12 -0
- package/src/static-security-headers.test.ts +60 -0
- package/src/static-security-headers.ts +64 -0
- package/src/version.ts +1 -1
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security response headers for uploaded media and the published static
|
|
3
|
+
* site — the one definition the admin's media routes, the staging preview,
|
|
4
|
+
* every static publisher and every provider stack's edge config derive
|
|
5
|
+
* from (#863, #891).
|
|
6
|
+
*
|
|
7
|
+
* An SVG opened directly (`https://<site>/_assets/logo.<hash>.svg`) is a
|
|
8
|
+
* document, not an image: it runs script in the origin it is served from.
|
|
9
|
+
* On the public site that origin also serves `/api/*` (the gateway behind
|
|
10
|
+
* the same load balancer, CLAUDE.md §11.B), on the admin it holds the
|
|
11
|
+
* operator's session. `sanitizeSvg` strips script at upload; this CSP is
|
|
12
|
+
* the line behind it should a construct ever get past the sanitizer: no
|
|
13
|
+
* script, no plugins, no network, and `sandbox` gives the document an
|
|
14
|
+
* opaque origin. Inline styles and `data:` rasters / fonts stay allowed so
|
|
15
|
+
* an SVG renders exactly as before — as a document and as an `<img>`.
|
|
16
|
+
* Browsers apply a CSP only to documents (and workers), so the header
|
|
17
|
+
* changes nothing for an SVG loaded through `<img>` or CSS.
|
|
18
|
+
*
|
|
19
|
+
* Only SVG gets the CSP: raster images, video and fonts cannot run script,
|
|
20
|
+
* and `sandbox` would stop Chrome's built-in viewer from opening a PDF.
|
|
21
|
+
* `nosniff` goes on everything so a browser never reinterprets bytes as a
|
|
22
|
+
* scriptable type the content-type did not declare.
|
|
23
|
+
*/
|
|
24
|
+
/** The CSP an SVG document is served with (see the file comment). */
|
|
25
|
+
export declare const SVG_DOCUMENT_CSP = "default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:; sandbox";
|
|
26
|
+
/** `X-Content-Type-Options` value every media / static response carries. */
|
|
27
|
+
export declare const NOSNIFF = "nosniff";
|
|
28
|
+
/** The MIME type the CSP is keyed on. */
|
|
29
|
+
export declare const SVG_CONTENT_TYPE = "image/svg+xml";
|
|
30
|
+
/**
|
|
31
|
+
* Regex (source string, unanchored at the start) matching a URL path that
|
|
32
|
+
* ends in `.svg`, any case. Only syntax shared by ECMAScript and RE2 (no
|
|
33
|
+
* inline flags), so the same string serves Firebase Hosting's `regex`
|
|
34
|
+
* header matcher and `RegExp` here. The static generator names every file
|
|
35
|
+
* by extension (`contentTypeFor` in admin-core), so the extension is what
|
|
36
|
+
* decides the served `image/svg+xml`.
|
|
37
|
+
*/
|
|
38
|
+
export declare const SVG_PATH_PATTERN = "\\.[sS][vV][gG]$";
|
|
39
|
+
/** True when `contentType` is SVG (case and parameters such as `; charset=` ignored). */
|
|
40
|
+
export declare function isSvgContentType(contentType: string): boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Security headers for one media / static-file response.
|
|
43
|
+
*
|
|
44
|
+
* @param contentType - the `content-type` the response is served with.
|
|
45
|
+
* @returns lower-case header entries to spread into the response's headers.
|
|
46
|
+
* @example
|
|
47
|
+
* new Response(body, { headers: { "Content-Type": ct, ...mediaSecurityHeaders(ct) } });
|
|
48
|
+
*/
|
|
49
|
+
export declare function mediaSecurityHeaders(contentType: string): Record<string, string>;
|
|
50
|
+
//# sourceMappingURL=static-security-headers.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"static-security-headers.d.ts","sourceRoot":"","sources":["../src/static-security-headers.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,qEAAqE;AACrE,eAAO,MAAM,gBAAgB,0FAC4D,CAAC;AAE1F,4EAA4E;AAC5E,eAAO,MAAM,OAAO,YAAY,CAAC;AAEjC,yCAAyC;AACzC,eAAO,MAAM,gBAAgB,kBAAkB,CAAC;AAEhD;;;;;;;GAOG;AACH,eAAO,MAAM,gBAAgB,qBAAqB,CAAC;AAEnD,yFAAyF;AACzF,wBAAgB,gBAAgB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAE7D;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAIhF"}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
/**
|
|
3
|
+
* Security response headers for uploaded media and the published static
|
|
4
|
+
* site — the one definition the admin's media routes, the staging preview,
|
|
5
|
+
* every static publisher and every provider stack's edge config derive
|
|
6
|
+
* from (#863, #891).
|
|
7
|
+
*
|
|
8
|
+
* An SVG opened directly (`https://<site>/_assets/logo.<hash>.svg`) is a
|
|
9
|
+
* document, not an image: it runs script in the origin it is served from.
|
|
10
|
+
* On the public site that origin also serves `/api/*` (the gateway behind
|
|
11
|
+
* the same load balancer, CLAUDE.md §11.B), on the admin it holds the
|
|
12
|
+
* operator's session. `sanitizeSvg` strips script at upload; this CSP is
|
|
13
|
+
* the line behind it should a construct ever get past the sanitizer: no
|
|
14
|
+
* script, no plugins, no network, and `sandbox` gives the document an
|
|
15
|
+
* opaque origin. Inline styles and `data:` rasters / fonts stay allowed so
|
|
16
|
+
* an SVG renders exactly as before — as a document and as an `<img>`.
|
|
17
|
+
* Browsers apply a CSP only to documents (and workers), so the header
|
|
18
|
+
* changes nothing for an SVG loaded through `<img>` or CSS.
|
|
19
|
+
*
|
|
20
|
+
* Only SVG gets the CSP: raster images, video and fonts cannot run script,
|
|
21
|
+
* and `sandbox` would stop Chrome's built-in viewer from opening a PDF.
|
|
22
|
+
* `nosniff` goes on everything so a browser never reinterprets bytes as a
|
|
23
|
+
* scriptable type the content-type did not declare.
|
|
24
|
+
*/
|
|
25
|
+
/** The CSP an SVG document is served with (see the file comment). */
|
|
26
|
+
export const SVG_DOCUMENT_CSP = "default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:; sandbox";
|
|
27
|
+
/** `X-Content-Type-Options` value every media / static response carries. */
|
|
28
|
+
export const NOSNIFF = "nosniff";
|
|
29
|
+
/** The MIME type the CSP is keyed on. */
|
|
30
|
+
export const SVG_CONTENT_TYPE = "image/svg+xml";
|
|
31
|
+
/**
|
|
32
|
+
* Regex (source string, unanchored at the start) matching a URL path that
|
|
33
|
+
* ends in `.svg`, any case. Only syntax shared by ECMAScript and RE2 (no
|
|
34
|
+
* inline flags), so the same string serves Firebase Hosting's `regex`
|
|
35
|
+
* header matcher and `RegExp` here. The static generator names every file
|
|
36
|
+
* by extension (`contentTypeFor` in admin-core), so the extension is what
|
|
37
|
+
* decides the served `image/svg+xml`.
|
|
38
|
+
*/
|
|
39
|
+
export const SVG_PATH_PATTERN = "\\.[sS][vV][gG]$";
|
|
40
|
+
/** True when `contentType` is SVG (case and parameters such as `; charset=` ignored). */
|
|
41
|
+
export function isSvgContentType(contentType) {
|
|
42
|
+
return (contentType.split(";")[0] ?? "").trim().toLowerCase() === SVG_CONTENT_TYPE;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Security headers for one media / static-file response.
|
|
46
|
+
*
|
|
47
|
+
* @param contentType - the `content-type` the response is served with.
|
|
48
|
+
* @returns lower-case header entries to spread into the response's headers.
|
|
49
|
+
* @example
|
|
50
|
+
* new Response(body, { headers: { "Content-Type": ct, ...mediaSecurityHeaders(ct) } });
|
|
51
|
+
*/
|
|
52
|
+
export function mediaSecurityHeaders(contentType) {
|
|
53
|
+
const headers = { "x-content-type-options": NOSNIFF };
|
|
54
|
+
if (isSvgContentType(contentType))
|
|
55
|
+
headers["content-security-policy"] = SVG_DOCUMENT_CSP;
|
|
56
|
+
return headers;
|
|
57
|
+
}
|
|
58
|
+
//# sourceMappingURL=static-security-headers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"static-security-headers.js","sourceRoot":"","sources":["../src/static-security-headers.ts"],"names":[],"mappings":"AAAA,mCAAmC;AAEnC;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,qEAAqE;AACrE,MAAM,CAAC,MAAM,gBAAgB,GAC3B,uFAAuF,CAAC;AAE1F,4EAA4E;AAC5E,MAAM,CAAC,MAAM,OAAO,GAAG,SAAS,CAAC;AAEjC,yCAAyC;AACzC,MAAM,CAAC,MAAM,gBAAgB,GAAG,eAAe,CAAC;AAEhD;;;;;;;GAOG;AACH,MAAM,CAAC,MAAM,gBAAgB,GAAG,kBAAkB,CAAC;AAEnD,yFAAyF;AACzF,MAAM,UAAU,gBAAgB,CAAC,WAAmB;IAClD,OAAO,CAAC,WAAW,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC,WAAW,EAAE,KAAK,gBAAgB,CAAC;AACrF,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,oBAAoB,CAAC,WAAmB;IACtD,MAAM,OAAO,GAA2B,EAAE,wBAAwB,EAAE,OAAO,EAAE,CAAC;IAC9E,IAAI,gBAAgB,CAAC,WAAW,CAAC;QAAE,OAAO,CAAC,yBAAyB,CAAC,GAAG,gBAAgB,CAAC;IACzF,OAAO,OAAO,CAAC;AACjB,CAAC"}
|
package/dist/version.d.ts
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* Format: SemVer 2.0.0. Pre-1.0 minor bumps are breaking; post-1.0
|
|
20
20
|
* follow standard SemVer.
|
|
21
21
|
*/
|
|
22
|
-
export declare const CAELO_VERSION = "0.10.
|
|
22
|
+
export declare const CAELO_VERSION = "0.10.62";
|
|
23
23
|
/**
|
|
24
24
|
* Deprecated alias for back-compat — early P17 work spelled this
|
|
25
25
|
* `CALEO_VERSION` (typo of "Caelo"). New code should import
|
|
@@ -31,7 +31,7 @@ export declare const CAELO_VERSION = "0.10.61";
|
|
|
31
31
|
*
|
|
32
32
|
* @deprecated use CAELO_VERSION
|
|
33
33
|
*/
|
|
34
|
-
export declare const CALEO_VERSION = "0.10.
|
|
34
|
+
export declare const CALEO_VERSION = "0.10.62";
|
|
35
35
|
/**
|
|
36
36
|
* Parsed shape — major/minor/patch + optional pre-release tag.
|
|
37
37
|
* Stable interface for callers that need to feature-gate (rare —
|
package/dist/version.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* Format: SemVer 2.0.0. Pre-1.0 minor bumps are breaking; post-1.0
|
|
21
21
|
* follow standard SemVer.
|
|
22
22
|
*/
|
|
23
|
-
export const CAELO_VERSION = "0.10.
|
|
23
|
+
export const CAELO_VERSION = "0.10.62";
|
|
24
24
|
/**
|
|
25
25
|
* Deprecated alias for back-compat — early P17 work spelled this
|
|
26
26
|
* `CALEO_VERSION` (typo of "Caelo"). New code should import
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@caelo-cms/shared",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.62",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "MPL-2.0",
|
|
6
6
|
"description": "Shared Zod schemas + types + the two-pass HTML composer used by every Caelo CMS workspace package. Stable enough for plugin authors and embedders to depend on directly.",
|
package/src/auto-update.test.ts
CHANGED
|
@@ -58,6 +58,7 @@ function input(over: Partial<AutoUpdateInput> & { version?: string; publishedHou
|
|
|
58
58
|
facts: FACTS,
|
|
59
59
|
upgraderAvailable: true,
|
|
60
60
|
convergeAvailable: true,
|
|
61
|
+
skippedVersion: null,
|
|
61
62
|
now: NOW,
|
|
62
63
|
...rest,
|
|
63
64
|
} satisfies AutoUpdateInput;
|
|
@@ -376,6 +377,30 @@ describe("decideAutoUpdate — modes and versions", () => {
|
|
|
376
377
|
});
|
|
377
378
|
});
|
|
378
379
|
|
|
380
|
+
describe("decideAutoUpdate — the Owner skipped a scheduled install (#868)", () => {
|
|
381
|
+
it("does not install or schedule the skipped release, but still offers it", () => {
|
|
382
|
+
for (const publishedHoursAgo of [48, 2]) {
|
|
383
|
+
const d = decideAutoUpdate(input({ publishedHoursAgo, skippedVersion: VERSIONS.patch }));
|
|
384
|
+
expect(d.action).toBe("notify");
|
|
385
|
+
if (d.action !== "notify") continue;
|
|
386
|
+
expect(d.version).toBe(VERSIONS.patch);
|
|
387
|
+
expect(d.reason).toContain("skipped the automatic install of v0.10.48");
|
|
388
|
+
}
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("installs a newer release than the skipped one automatically again", () => {
|
|
392
|
+
const d = decideAutoUpdate(input({ version: "0.10.49", skippedVersion: VERSIONS.patch }));
|
|
393
|
+
expect(d.action).toBe("install");
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it("leaves every other decision about the skipped release as it was", () => {
|
|
397
|
+
const d = decideAutoUpdate(
|
|
398
|
+
input({ skippedVersion: VERSIONS.patch, facts: { ...FACTS, contractMigrations: true } }),
|
|
399
|
+
);
|
|
400
|
+
expect(d.action).toBe("manual");
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
379
404
|
describe("nextWindowOpening", () => {
|
|
380
405
|
it("returns the start of the next window in the window's time zone", () => {
|
|
381
406
|
const p = { windowStart: "02:00", windowEnd: "05:00", windowTimezone: "America/New_York" };
|
package/src/auto-update.ts
CHANGED
|
@@ -37,6 +37,11 @@
|
|
|
37
37
|
* 10. soak period not over (security releases: their own, shorter one) → wait;
|
|
38
38
|
* 11. outside the maintenance window → wait until it opens;
|
|
39
39
|
* 12. otherwise → install.
|
|
40
|
+
*
|
|
41
|
+
* On top of the rules: a release the Owner skipped ("Skip this time" in
|
|
42
|
+
* Settings → Updates, #868) that the rules would install or schedule is
|
|
43
|
+
* offered instead (notify). Only that version: a newer release installs
|
|
44
|
+
* automatically again, and the skipped one stays installable by hand.
|
|
40
45
|
*/
|
|
41
46
|
|
|
42
47
|
import {
|
|
@@ -119,6 +124,11 @@ export interface AutoUpdateInput {
|
|
|
119
124
|
readonly convergeAvailable: boolean;
|
|
120
125
|
/** Why it has none right now, in plain words (null: none set up at all). */
|
|
121
126
|
readonly upgraderProblem?: string | null;
|
|
127
|
+
/**
|
|
128
|
+
* The release whose automatic install the Owner skipped
|
|
129
|
+
* (`platform_update_scheduler.skipped_version`, #868); null when none.
|
|
130
|
+
*/
|
|
131
|
+
readonly skippedVersion: string | null;
|
|
122
132
|
readonly now: Date;
|
|
123
133
|
}
|
|
124
134
|
|
|
@@ -199,8 +209,34 @@ function isStable(version: string): boolean {
|
|
|
199
209
|
|
|
200
210
|
const hours = (n: number) => `${n} hour${n === 1 ? "" : "s"}`;
|
|
201
211
|
|
|
212
|
+
/**
|
|
213
|
+
* The scheduler's sentence for a release whose automatic install the Owner
|
|
214
|
+
* skipped (#868); the skip op writes the same one right away.
|
|
215
|
+
*/
|
|
216
|
+
export function skippedInstallReason(version: string): string {
|
|
217
|
+
return `The Owner skipped the automatic install of v${version}. It stays available in Settings → Updates; the next newer release installs automatically again.`;
|
|
218
|
+
}
|
|
219
|
+
|
|
202
220
|
/** Decide what the scheduler does now. See the file comment for the rules. */
|
|
203
221
|
export function decideAutoUpdate(input: AutoUpdateInput): AutoUpdateDecision {
|
|
222
|
+
const decision = decideByRules(input);
|
|
223
|
+
if (
|
|
224
|
+
(decision.action === "install" || decision.action === "wait") &&
|
|
225
|
+
input.skippedVersion !== null &&
|
|
226
|
+
decision.version === input.skippedVersion
|
|
227
|
+
) {
|
|
228
|
+
return {
|
|
229
|
+
action: "notify",
|
|
230
|
+
version: decision.version,
|
|
231
|
+
kind: decision.kind,
|
|
232
|
+
reason: skippedInstallReason(decision.version),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
return decision;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** The numbered rules of the file comment, without the Owner's skip. */
|
|
239
|
+
function decideByRules(input: AutoUpdateInput): AutoUpdateDecision {
|
|
204
240
|
const { policy, release, now } = input;
|
|
205
241
|
if (!release) {
|
|
206
242
|
return { action: "none", reason: "The release check has not found a Caelo release yet." };
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
// SPDX-License-Identifier: MPL-2.0
|
|
2
2
|
|
|
3
3
|
import { describe, expect, it } from "bun:test";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
CLOUD_SHELL_UPGRADE_FIRST_VERSION,
|
|
6
|
+
cloudShellInstallUrl,
|
|
7
|
+
cloudShellUpgradeCommand,
|
|
8
|
+
cloudShellUpgradeUrl,
|
|
9
|
+
} from "./cloud-shell-install.js";
|
|
5
10
|
|
|
6
11
|
describe("cloudShellInstallUrl", () => {
|
|
7
12
|
it("pins the release branch of the install repo and the provider's tutorial", () => {
|
|
@@ -18,3 +23,33 @@ describe("cloudShellInstallUrl", () => {
|
|
|
18
23
|
expect(() => cloudShellInstallUrl("latest", "gcp")).toThrow();
|
|
19
24
|
});
|
|
20
25
|
});
|
|
26
|
+
|
|
27
|
+
describe("cloudShellUpgradeUrl (#816)", () => {
|
|
28
|
+
it("opens the release's upgrade walkthrough, never the install one", () => {
|
|
29
|
+
const url = cloudShellUpgradeUrl(CLOUD_SHELL_UPGRADE_FIRST_VERSION, "gcp-firebase");
|
|
30
|
+
expect(url).toBe(
|
|
31
|
+
`https://shell.cloud.google.com/cloudshell/editor?cloudshell_git_repo=https://github.com/caelo-cms/install&cloudshell_git_branch=release-v${CLOUD_SHELL_UPGRADE_FIRST_VERSION}&cloudshell_tutorial=upgrade-gcp-firebase.md`,
|
|
32
|
+
);
|
|
33
|
+
expect(cloudShellUpgradeUrl("v9.0.0", "gcp")).toContain(
|
|
34
|
+
"cloudshell_git_branch=release-v9.0.0&cloudshell_tutorial=upgrade-gcp.md",
|
|
35
|
+
);
|
|
36
|
+
expect(url).not.toContain("tutorial=gcp-firebase.md");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("no link for a release whose branch has no upgrade tutorial, or a pre-release", () => {
|
|
40
|
+
expect(cloudShellUpgradeUrl("0.10.59", "gcp")).toBeNull();
|
|
41
|
+
expect(cloudShellUpgradeUrl("0.11.0-rc.1", "gcp")).toBeNull();
|
|
42
|
+
expect(cloudShellUpgradeUrl("latest", "gcp")).toBeNull();
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("cloudShellUpgradeCommand", () => {
|
|
47
|
+
it("names the install, and its project when known", () => {
|
|
48
|
+
expect(cloudShellUpgradeCommand({ project: "acme-site", install: "acme.com" })).toBe(
|
|
49
|
+
"./run.sh upgrade --project acme-site --install acme.com",
|
|
50
|
+
);
|
|
51
|
+
expect(cloudShellUpgradeCommand({ project: null, install: "acme.com" })).toBe(
|
|
52
|
+
"./run.sh upgrade --install acme.com",
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* admin's "run this release in Cloud Shell" hint — so they can't drift.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { isNewerVersion } from "./version.js";
|
|
15
|
+
|
|
14
16
|
/** The repository the Cloud Shell button clones. */
|
|
15
17
|
export const CLOUD_SHELL_INSTALL_REPO = "https://github.com/caelo-cms/install";
|
|
16
18
|
|
|
@@ -45,3 +47,65 @@ export function cloudShellInstallUrl(version: string, provider: CloudShellProvid
|
|
|
45
47
|
`&cloudshell_tutorial=${provider}.md`,
|
|
46
48
|
].join("");
|
|
47
49
|
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The first release whose `release-v<version>` branch in the install
|
|
53
|
+
* repository ships the break-glass upgrade (#816): `upgrade-gcp.md`,
|
|
54
|
+
* `upgrade-gcp-firebase.md` and `./run.sh upgrade`. Release branches are
|
|
55
|
+
* cut from the install repository's main when the release is tracked and
|
|
56
|
+
* never move, so older branches lack the tutorial; for them the admin shows
|
|
57
|
+
* the CLI command instead of a link that would open nothing.
|
|
58
|
+
*
|
|
59
|
+
* Set to the release that follows caelo-cms/install#3 (the upgrade
|
|
60
|
+
* tutorials) merging; if that merge slips past a release, raise it.
|
|
61
|
+
*/
|
|
62
|
+
export const CLOUD_SHELL_UPGRADE_FIRST_VERSION = "0.10.60";
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The Cloud Shell link that opens the break-glass UPGRADE walkthrough of a
|
|
66
|
+
* stable release (#816), never the install wizard.
|
|
67
|
+
*
|
|
68
|
+
* Cloud Shell links cannot carry the project or the install: Google
|
|
69
|
+
* documents no parameter for either, and a tutorial cannot read the URL.
|
|
70
|
+
* `./run.sh upgrade` finds the Owner's install itself (the projects whose
|
|
71
|
+
* ops bucket holds `install/install.json`); the admin shows the explicit
|
|
72
|
+
* `--project … --install …` line ({@link cloudShellUpgradeCommand}) next to
|
|
73
|
+
* the button for an Owner with several installs.
|
|
74
|
+
*
|
|
75
|
+
* @returns null for a pre-release (no pinned branch) or a release before
|
|
76
|
+
* {@link CLOUD_SHELL_UPGRADE_FIRST_VERSION} (its branch has no tutorial)
|
|
77
|
+
*
|
|
78
|
+
* @example cloudShellUpgradeUrl("0.10.60", "gcp-firebase")
|
|
79
|
+
* // → "https://shell.cloud.google.com/cloudshell/editor?cloudshell_git_repo=https://github.com/caelo-cms/install&cloudshell_git_branch=release-v0.10.60&cloudshell_tutorial=upgrade-gcp-firebase.md"
|
|
80
|
+
*/
|
|
81
|
+
export function cloudShellUpgradeUrl(version: string, provider: CloudShellProvider): string | null {
|
|
82
|
+
const m = STABLE.exec(version);
|
|
83
|
+
if (!m?.[1]) return null;
|
|
84
|
+
if (isNewerVersion(CLOUD_SHELL_UPGRADE_FIRST_VERSION, m[1])) return null;
|
|
85
|
+
return [
|
|
86
|
+
"https://shell.cloud.google.com/cloudshell/editor",
|
|
87
|
+
`?cloudshell_git_repo=${CLOUD_SHELL_INSTALL_REPO}`,
|
|
88
|
+
`&cloudshell_git_branch=release-v${m[1]}`,
|
|
89
|
+
`&cloudshell_tutorial=upgrade-${provider}.md`,
|
|
90
|
+
].join("");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The one line the break-glass walkthrough runs, from the clone of the
|
|
95
|
+
* install repository: `./run.sh upgrade` with the install named when the
|
|
96
|
+
* admin knows it (`--project` from its upgrader job, `--install` = domain).
|
|
97
|
+
*
|
|
98
|
+
* @example cloudShellUpgradeCommand({ project: "acme-site", install: "acme.com" })
|
|
99
|
+
* // → "./run.sh upgrade --project acme-site --install acme.com"
|
|
100
|
+
*/
|
|
101
|
+
export function cloudShellUpgradeCommand(target: {
|
|
102
|
+
readonly project: string | null;
|
|
103
|
+
readonly install: string;
|
|
104
|
+
}): string {
|
|
105
|
+
return [
|
|
106
|
+
"./run.sh upgrade",
|
|
107
|
+
...(target.project ? ["--project", target.project] : []),
|
|
108
|
+
"--install",
|
|
109
|
+
target.install,
|
|
110
|
+
].join(" ");
|
|
111
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "bun:test";
|
|
4
|
+
import { formatCssScopeFindings, moduleRootTokens, scanModuleCssScope } from "./css-scope-scan.js";
|
|
5
|
+
|
|
6
|
+
describe("scanModuleCssScope (issue #158)", () => {
|
|
7
|
+
it("flags element-only selectors as global, at top level and inside @media", () => {
|
|
8
|
+
const f = scanModuleCssScope({
|
|
9
|
+
css: "h1 { color: red } .hero p, body { margin: 0 } @media (max-width: 600px) { a:hover { color: blue } }",
|
|
10
|
+
});
|
|
11
|
+
expect(f.global).toEqual(["h1", "body", "a:hover"]);
|
|
12
|
+
// Without the html there is no root to anchor against.
|
|
13
|
+
expect(f.unanchored).toEqual([]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("does not count a class inside a pseudo argument as scoping", () => {
|
|
17
|
+
expect(scanModuleCssScope({ css: "h1:not(.x) { color: red }" }).global).toEqual(["h1:not(.x)"]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("checks selectors against the module root class when the html is known", () => {
|
|
21
|
+
const f = scanModuleCssScope({
|
|
22
|
+
html: '<section class="hero is-dark"><div class="card"></div></section>',
|
|
23
|
+
css: ".hero h1{} .hero__title{} .hero-cta{} html.dark .hero{} .card{} .is-darkish{}",
|
|
24
|
+
});
|
|
25
|
+
expect(f.global).toEqual([]);
|
|
26
|
+
expect(f.unanchored).toEqual([".card", ".is-darkish"]);
|
|
27
|
+
expect(f.rootClasses).toEqual(["hero", "is-dark"]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("accepts the root id as an anchor", () => {
|
|
31
|
+
const f = scanModuleCssScope({
|
|
32
|
+
html: '<div id="pricing"></div>',
|
|
33
|
+
css: "#pricing .row{} .row{}",
|
|
34
|
+
});
|
|
35
|
+
expect(f.unanchored).toEqual([".row"]);
|
|
36
|
+
expect(f.rootHasNoClass).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("skips blocks that hold no selectors, comments and strings", () => {
|
|
40
|
+
const f = scanModuleCssScope({
|
|
41
|
+
html: '<div class="m"></div>',
|
|
42
|
+
css: '/* h1 {} */ @keyframes spin { from { opacity: 0 } to { opacity: 1 } } @font-face { font-family: X } @import url("x.css"); .m::after { content: "}{ h1 {" } .m { & h2 { color: red } }',
|
|
43
|
+
});
|
|
44
|
+
expect(f.global).toEqual([]);
|
|
45
|
+
expect(f.unanchored).toEqual([]);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("reports a root element with nothing to scope under", () => {
|
|
49
|
+
const f = scanModuleCssScope({ html: "<section><h2>x</h2></section>", css: ".title{}" });
|
|
50
|
+
expect(f.rootHasNoClass).toBe(true);
|
|
51
|
+
expect(formatCssScopeFindings(f)).toContain("root element has no class");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("formats nothing for scoped CSS", () => {
|
|
55
|
+
expect(
|
|
56
|
+
formatCssScopeFindings(
|
|
57
|
+
scanModuleCssScope({ html: '<div class="faq"></div>', css: ".faq summary{}" }),
|
|
58
|
+
),
|
|
59
|
+
).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("names the root class in the fix and truncates long lists", () => {
|
|
63
|
+
const text = formatCssScopeFindings(
|
|
64
|
+
scanModuleCssScope({
|
|
65
|
+
html: '<nav class="site-nav"></nav>',
|
|
66
|
+
css: "a{} b{} i{} p{} ul{} li{} em{}",
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
expect(text).toContain("`.site-nav h2`");
|
|
70
|
+
expect(text).toContain("(+2 more)");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("moduleRootTokens", () => {
|
|
75
|
+
it("reads the first element past comments and ignores templated values", () => {
|
|
76
|
+
expect(
|
|
77
|
+
moduleRootTokens('<!-- hero --> <section class="hero {{variant}}" id="{{anchor}}">'),
|
|
78
|
+
).toEqual({ classes: ["hero"], id: null });
|
|
79
|
+
expect(moduleRootTokens("just text")).toBeNull();
|
|
80
|
+
expect(moduleRootTokens('<!-- a --><!----> \n<div class="card">')).toEqual({
|
|
81
|
+
classes: ["card"],
|
|
82
|
+
id: null,
|
|
83
|
+
});
|
|
84
|
+
expect(moduleRootTokens('<!-- never closed <div class="card">')).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("stays linear on comment-heavy input (CodeQL js/redos)", () => {
|
|
88
|
+
const started = performance.now();
|
|
89
|
+
expect(moduleRootTokens(`<!--${"--><!--".repeat(50_000)}`)).toBeNull();
|
|
90
|
+
expect(moduleRootTokens(`${"<!---->".repeat(50_000)}<p class="x">`)?.classes).toEqual(["x"]);
|
|
91
|
+
expect(moduleRootTokens("<A\t".repeat(50_000))).toBeNull();
|
|
92
|
+
expect(performance.now() - started).toBeLessThan(1000);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("reads bare and attribute-carrying start tags, and rejects a malformed one", () => {
|
|
96
|
+
expect(moduleRootTokens("<section>")).toEqual({ classes: [], id: null });
|
|
97
|
+
expect(moduleRootTokens('<div\n class="a b"\n id="main">')).toEqual({
|
|
98
|
+
classes: ["a", "b"],
|
|
99
|
+
id: "main",
|
|
100
|
+
});
|
|
101
|
+
expect(moduleRootTokens('<div/class="a">')).toBeNull();
|
|
102
|
+
expect(moduleRootTokens('<div class="a"')).toBeNull();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("scanModuleCssScope comment stripping", () => {
|
|
107
|
+
it("drops closed comments and treats an unterminated one as running to the end", () => {
|
|
108
|
+
expect(scanModuleCssScope({ css: "/* h1 { x: 1 } */ p { color: red }" }).global).toEqual(["p"]);
|
|
109
|
+
expect(scanModuleCssScope({ css: "p { color: red } /* h1 { x: 1 }" }).global).toEqual(["p"]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("stays linear on many unterminated comment openers (CodeQL js/polynomial-redos)", () => {
|
|
113
|
+
const started = performance.now();
|
|
114
|
+
scanModuleCssScope({ css: `/*${"a/*".repeat(100_000)}` });
|
|
115
|
+
expect(performance.now() - started).toBeLessThan(1000);
|
|
116
|
+
});
|
|
117
|
+
});
|