@caelo-cms/shared 0.10.57 → 0.10.59
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/ai-tools.d.ts +30 -0
- package/dist/ai-tools.d.ts.map +1 -1
- package/dist/ai-tools.js +26 -9
- package/dist/ai-tools.js.map +1 -1
- package/dist/bug-intake.d.ts +176 -0
- package/dist/bug-intake.d.ts.map +1 -0
- package/dist/bug-intake.js +200 -0
- package/dist/bug-intake.js.map +1 -0
- package/dist/content.js +7 -7
- package/dist/edit-markers.d.ts +49 -0
- package/dist/edit-markers.d.ts.map +1 -0
- package/dist/edit-markers.js +225 -0
- package/dist/edit-markers.js.map +1 -0
- package/dist/google-oauth.d.ts +22 -0
- package/dist/google-oauth.d.ts.map +1 -0
- package/dist/google-oauth.js +25 -0
- package/dist/google-oauth.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/link-canonicalise.d.ts +109 -0
- package/dist/link-canonicalise.d.ts.map +1 -0
- package/dist/link-canonicalise.js +471 -0
- package/dist/link-canonicalise.js.map +1 -0
- package/dist/link-refs.d.ts +63 -8
- package/dist/link-refs.d.ts.map +1 -1
- package/dist/link-refs.js +80 -14
- package/dist/link-refs.js.map +1 -1
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js +15 -0
- package/dist/media.js.map +1 -1
- package/dist/nested-module-render.d.ts +17 -0
- package/dist/nested-module-render.d.ts.map +1 -1
- package/dist/nested-module-render.js +45 -6
- package/dist/nested-module-render.js.map +1 -1
- package/dist/page-log.d.ts +1 -1
- package/dist/preview-compose.d.ts.map +1 -1
- package/dist/preview-compose.js +12 -2
- package/dist/preview-compose.js.map +1 -1
- package/dist/theme-normalize.d.ts.map +1 -1
- package/dist/theme-normalize.js +17 -2
- package/dist/theme-normalize.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/ai-tools.ts +29 -11
- package/src/bug-intake.test.ts +154 -0
- package/src/bug-intake.ts +256 -0
- package/src/content.ts +7 -7
- package/src/edit-markers.test.ts +111 -0
- package/src/edit-markers.ts +244 -0
- package/src/google-oauth.ts +27 -0
- package/src/index.ts +4 -0
- package/src/link-canonicalise.test.ts +387 -0
- package/src/link-canonicalise.ts +598 -0
- package/src/link-refs.test.ts +206 -0
- package/src/link-refs.ts +119 -21
- package/src/media.ts +16 -0
- package/src/nested-module-render.ts +59 -4
- package/src/preview-compose.ts +11 -2
- package/src/theme-normalize-typography-subfields.test.ts +54 -0
- package/src/theme-normalize.ts +19 -2
- package/src/version.ts +1 -1
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
import { describe, expect, it } from "bun:test";
|
|
4
|
+
import { finalizeEditMarkers, wrapEditableValues } from "./edit-markers.js";
|
|
5
|
+
import { renderTemplate } from "./template-engine.js";
|
|
6
|
+
|
|
7
|
+
const FIELDS = [
|
|
8
|
+
{ name: "title", kind: "text" },
|
|
9
|
+
{ name: "tags", kind: "text-list" },
|
|
10
|
+
{ name: "links", kind: "link-list" },
|
|
11
|
+
{ name: "photo", kind: "image" },
|
|
12
|
+
] as const;
|
|
13
|
+
|
|
14
|
+
function render(html: string, values: Record<string, unknown>, sharedPages = 0): string {
|
|
15
|
+
const wrapped = wrapEditableValues(values, FIELDS);
|
|
16
|
+
const rendered = renderTemplate({ html, fields: [...FIELDS], contentValues: wrapped }).html;
|
|
17
|
+
return finalizeEditMarkers(rendered, "content#2", sharedPages);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const marked = (fields: string, shared = 0) =>
|
|
21
|
+
` data-caelo-fields="${fields}" data-caelo-placement="content%232" data-caelo-shared="${shared}"`;
|
|
22
|
+
|
|
23
|
+
const open = (f: string, s = 0) => `<!--caelo:f p=content%232 s=${s} f=${f}-->`;
|
|
24
|
+
const close = "<!--/caelo:f-->";
|
|
25
|
+
|
|
26
|
+
describe("edit markers (#917)", () => {
|
|
27
|
+
it("marks a text field in text content", () => {
|
|
28
|
+
expect(render("<h1>{{title}}</h1>", { title: "Hello & welcome" })).toBe(
|
|
29
|
+
`<h1>${open("title")}Hello & welcome${close}</h1>`,
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("carries how many pages share a synced placement", () => {
|
|
34
|
+
expect(render("<h1>{{title}}</h1>", { title: "Hi" }, 4)).toContain(open("title", 4));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("marks text-list items and link-list labels with their index", () => {
|
|
38
|
+
const html = render(
|
|
39
|
+
'<ul>{{#tags}}<li>{{.}}</li>{{/tags}}</ul><nav>{{#links}}<a href="{{href}}">{{label}}</a>{{/links}}</nav>',
|
|
40
|
+
{
|
|
41
|
+
tags: ["a", "b"],
|
|
42
|
+
links: [{ label: "Docs", href: "/docs" }],
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
expect(html).toContain(`<li>${open("tags.1")}b${close}</li>`);
|
|
46
|
+
expect(html).toContain(
|
|
47
|
+
`<a href="/docs"${marked("href=links.0.href")}>${open("links.0.label")}Docs${close}</a>`,
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("leaves other attributes, scripts, styles and titles byte-identical", () => {
|
|
52
|
+
const html = render(
|
|
53
|
+
'<div data-x=\'{{title}}\' title="{{title}}"></div><script>var t = "{{title}}";</script><style>/* {{title}} */</style><title>{{title}}</title><!-- {{title}} -->',
|
|
54
|
+
{ title: "Hi > there" },
|
|
55
|
+
);
|
|
56
|
+
expect(html).toBe(
|
|
57
|
+
'<div data-x=\'Hi > there\' title="Hi > there"></div><script>var t = "Hi > there";</script><style>/* Hi > there */</style><title>Hi > there</title><!-- Hi > there -->',
|
|
58
|
+
);
|
|
59
|
+
expect(html).not.toMatch(/[\uE000-\uE002]/);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("marks link targets, images and alt text on their tag (#918)", () => {
|
|
63
|
+
const html = render(
|
|
64
|
+
'{{#links}}<a href="{{href}}">{{label}}</a>{{/links}}<img src="{{photo}}" alt="{{title}}"/>',
|
|
65
|
+
{ links: [{ label: "Docs", href: "/docs" }], photo: "/m/cup.webp", title: "A cup" },
|
|
66
|
+
2,
|
|
67
|
+
);
|
|
68
|
+
expect(html).toContain(
|
|
69
|
+
`<a href="/docs"${marked("href=links.0.href", 2)}>${open("links.0.label", 2)}Docs${close}</a>`,
|
|
70
|
+
);
|
|
71
|
+
expect(html).toContain(
|
|
72
|
+
`<img src="/m/cup.webp" alt="A cup"${marked("src=photo alt=title", 2)}/>`,
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("does not mark an attribute assembled from several parts", () => {
|
|
77
|
+
expect(render('<a href="/tags/{{title}}">x</a>', { title: "cups" })).toBe(
|
|
78
|
+
'<a href="/tags/cups">x</a>',
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("does not mark values that carry markup, empty values, or non-text kinds", () => {
|
|
83
|
+
const html = render("<h1>{{title}}</h1><p>{{#tags}}{{.}}{{/tags}}</p>", {
|
|
84
|
+
title: "<b>Bold</b>",
|
|
85
|
+
tags: [" "],
|
|
86
|
+
});
|
|
87
|
+
expect(html).toBe("<h1><b>Bold</b></h1><p> </p>");
|
|
88
|
+
expect(wrapEditableValues({ n: 3 }, [{ name: "n", kind: "number" }])).toEqual({ n: 3 });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("keeps conditional sections working on wrapped values", () => {
|
|
92
|
+
expect(render("{{#title}}<p>{{title}}</p>{{/title}}", { title: "Hi" })).toBe(
|
|
93
|
+
`<p>${open("title")}Hi${close}</p>`,
|
|
94
|
+
);
|
|
95
|
+
expect(render("{{#title}}<p>{{title}}</p>{{/title}}", {})).toBe("");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("encodes placement and field so a comment can never close early", () => {
|
|
99
|
+
const out = finalizeEditMarkers(
|
|
100
|
+
wrapEditableValues({ "a--b": "x" }, [{ name: "a--b", kind: "text" }])["a--b"] as string,
|
|
101
|
+
"main--slot#0",
|
|
102
|
+
0,
|
|
103
|
+
);
|
|
104
|
+
expect(out).toBe("<!--caelo:f p=main%2D%2Dslot%230 s=0 f=a%2D%2Db-->x<!--/caelo:f-->");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("is a no-op on HTML without sentinels", () => {
|
|
108
|
+
const html = "<h1>Plain</h1><!-- caelo:missing reason=x -->";
|
|
109
|
+
expect(finalizeEditMarkers(html, "content#0", 0)).toBe(html);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* #917 — field markers for the Live Edit preview. They let the preview
|
|
5
|
+
* iframe tell which placement + field a piece of visible text came from,
|
|
6
|
+
* so the operator can edit that text in place.
|
|
7
|
+
*
|
|
8
|
+
* Two steps around the normal module render:
|
|
9
|
+
*
|
|
10
|
+
* 1. {@link wrapEditableValues} wraps every plain-text value of a
|
|
11
|
+
* placement's content instance in private-use sentinels that carry
|
|
12
|
+
* the field path. The template engine substitutes the wrapped value
|
|
13
|
+
* like any other string (its escape is the identity), so the
|
|
14
|
+
* sentinels land wherever the module author put `{{field}}`.
|
|
15
|
+
* 2. {@link finalizeEditMarkers} scans the rendered HTML. A sentinel in
|
|
16
|
+
* text content becomes a pair of HTML comments around the value:
|
|
17
|
+
* `<!--caelo:f p=<placement> s=<0|1> f=<path>-->value<!--/caelo:f-->`.
|
|
18
|
+
* Comments do not affect layout or CSS selectors. A sentinel in a
|
|
19
|
+
* comment or a raw-text element (`<script>`, `<style>`, `<title>`,
|
|
20
|
+
* `<textarea>`) is removed and the value stays as it was. In a tag
|
|
21
|
+
* it is removed too; an `href` / `src` / `alt` that is exactly one
|
|
22
|
+
* marked value also gets `data-caelo-fields` on its tag (#918).
|
|
23
|
+
*
|
|
24
|
+
* Only the editor preview runs this. Published output never carries
|
|
25
|
+
* markers: the static generator and every other caller of
|
|
26
|
+
* `pages.render_preview` leave `editMarkers` off.
|
|
27
|
+
*
|
|
28
|
+
* Only values without `<` are wrapped: a value that carries markup cannot
|
|
29
|
+
* be edited as plain text without destroying that markup.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const START = "\uE000";
|
|
33
|
+
const SEP = "\uE001";
|
|
34
|
+
const END = "\uE002";
|
|
35
|
+
const SENTINELS = /[\uE000-\uE002]/g;
|
|
36
|
+
|
|
37
|
+
/** Comment prefix of an opening marker; the closing marker is `/caelo:f`. */
|
|
38
|
+
export const EDIT_MARKER_PREFIX = "caelo:f";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Field kinds the preview marks: text (`text`, `text-list` items,
|
|
42
|
+
* `link-list` labels) and link / image targets (`url`, `image`,
|
|
43
|
+
* `link-list` hrefs, #918) — the latter usually land in `href` / `src`.
|
|
44
|
+
*/
|
|
45
|
+
const EDITABLE_KINDS = new Set(["text", "text-list", "link-list", "url", "image"]);
|
|
46
|
+
|
|
47
|
+
/** Attributes whose marked value the editor can change (link target, image, alt text). */
|
|
48
|
+
const MARKABLE_ATTRIBUTES = new Set(["href", "src", "alt"]);
|
|
49
|
+
|
|
50
|
+
/** Field shape {@link wrapEditableValues} needs: name + kind. */
|
|
51
|
+
export interface EditableFieldDef {
|
|
52
|
+
readonly name: string;
|
|
53
|
+
readonly kind?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function editable(value: unknown): value is string {
|
|
57
|
+
return typeof value === "string" && value.trim().length > 0 && !value.includes("<");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function wrap(path: string, value: string): string {
|
|
61
|
+
return `${START}${path}${SEP}${value.replace(SENTINELS, "")}${END}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Wrap the editable text of a content instance's values in sentinels.
|
|
66
|
+
* Returns a new object; non-editable values pass through untouched.
|
|
67
|
+
*
|
|
68
|
+
* Paths: `<field>` for a `text` / `url` / `image` field, `<field>.<i>`
|
|
69
|
+
* for a `text-list` item, `<field>.<i>.label` / `<field>.<i>.href` for a
|
|
70
|
+
* `link-list` item's label / target.
|
|
71
|
+
*
|
|
72
|
+
* @example
|
|
73
|
+
* wrapEditableValues({ hero_title: "Hi" }, [{ name: "hero_title", kind: "text" }]);
|
|
74
|
+
* // → { hero_title: "\uE000hero_title\uE001Hi\uE002" }
|
|
75
|
+
*/
|
|
76
|
+
export function wrapEditableValues(
|
|
77
|
+
values: Readonly<Record<string, unknown>>,
|
|
78
|
+
fields: readonly EditableFieldDef[],
|
|
79
|
+
): Record<string, unknown> {
|
|
80
|
+
const out: Record<string, unknown> = { ...values };
|
|
81
|
+
for (const f of fields) {
|
|
82
|
+
const kind = f.kind ?? "text";
|
|
83
|
+
if (!EDITABLE_KINDS.has(kind)) continue;
|
|
84
|
+
const v = values[f.name];
|
|
85
|
+
if (kind === "text" || kind === "url" || kind === "image") {
|
|
86
|
+
if (editable(v)) out[f.name] = wrap(f.name, v);
|
|
87
|
+
} else if (kind === "text-list" && Array.isArray(v)) {
|
|
88
|
+
out[f.name] = v.map((item, i) => (editable(item) ? wrap(`${f.name}.${i}`, item) : item));
|
|
89
|
+
} else if (kind === "link-list" && Array.isArray(v)) {
|
|
90
|
+
out[f.name] = v.map((item, i) => {
|
|
91
|
+
if (typeof item !== "object" || item === null) return item;
|
|
92
|
+
const { label, href } = item as { label?: unknown; href?: unknown };
|
|
93
|
+
return {
|
|
94
|
+
...item,
|
|
95
|
+
...(editable(label) ? { label: wrap(`${f.name}.${i}.label`, label) } : {}),
|
|
96
|
+
...(editable(href) ? { href: wrap(`${f.name}.${i}.href`, href) } : {}),
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Comment-safe encoding: no `--`, no `>`, no whitespace. */
|
|
105
|
+
function attr(value: string): string {
|
|
106
|
+
return encodeURIComponent(value).replace(/-/g, "%2D");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const RAW_TEXT_ELEMENTS = new Set(["script", "style", "title", "textarea"]);
|
|
110
|
+
|
|
111
|
+
/** End index (exclusive) of the tag starting at `i`, honouring quoted attribute values. */
|
|
112
|
+
function tagEnd(html: string, i: number): number {
|
|
113
|
+
let quote: string | null = null;
|
|
114
|
+
for (let j = i + 1; j < html.length; j += 1) {
|
|
115
|
+
const c = html[j];
|
|
116
|
+
if (quote) {
|
|
117
|
+
if (c === quote) quote = null;
|
|
118
|
+
} else if (c === '"' || c === "'") {
|
|
119
|
+
quote = c;
|
|
120
|
+
} else if (c === ">") {
|
|
121
|
+
return j + 1;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return html.length;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Turn sentinels in rendered module HTML into marker comments (text
|
|
129
|
+
* content) or drop them (everywhere else). See the file header.
|
|
130
|
+
*
|
|
131
|
+
* A tag whose `href` / `src` / `alt` value is exactly one marked value
|
|
132
|
+
* gets `data-caelo-fields="href=<path> …"` plus the placement
|
|
133
|
+
* (`data-caelo-placement`, `data-caelo-shared`), so the editor can offer
|
|
134
|
+
* "Edit link" / "Replace image" (#918). A value assembled from several
|
|
135
|
+
* parts (`href="/blog/{{slug}}"`) is not editable and is not marked.
|
|
136
|
+
*
|
|
137
|
+
* @param placement The placement key, `<blockName>#<position>`.
|
|
138
|
+
* @param sharedPages 0 for a placement with its own content; for a synced
|
|
139
|
+
* (shared) one, the number of pages that show the same content.
|
|
140
|
+
*/
|
|
141
|
+
export function finalizeEditMarkers(html: string, placement: string, sharedPages: number): string {
|
|
142
|
+
if (!html.includes(START)) return html;
|
|
143
|
+
const open = (path: string) =>
|
|
144
|
+
`<!--${EDIT_MARKER_PREFIX} p=${attr(placement)} s=${sharedPages} f=${attr(path)}-->`;
|
|
145
|
+
const close = `<!--/${EDIT_MARKER_PREFIX}-->`;
|
|
146
|
+
let out = "";
|
|
147
|
+
let i = 0;
|
|
148
|
+
while (i < html.length) {
|
|
149
|
+
const c = html[i];
|
|
150
|
+
if (c === "<" && html.startsWith("<!--", i)) {
|
|
151
|
+
const end = html.indexOf("-->", i + 4);
|
|
152
|
+
const stop = end === -1 ? html.length : end + 3;
|
|
153
|
+
out += stripSentinels(html.slice(i, stop));
|
|
154
|
+
i = stop;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if (c === "<" && /[a-zA-Z/!?]/.test(html[i + 1] ?? "")) {
|
|
158
|
+
const stop = tagEnd(html, i);
|
|
159
|
+
const tag = html.slice(i, stop);
|
|
160
|
+
out += markTag(tag, placement, sharedPages);
|
|
161
|
+
i = stop;
|
|
162
|
+
const name = /^<([a-zA-Z][a-zA-Z0-9-]*)/.exec(tag)?.[1]?.toLowerCase();
|
|
163
|
+
if (name && RAW_TEXT_ELEMENTS.has(name) && !tag.endsWith("/>")) {
|
|
164
|
+
const closeAt = html.toLowerCase().indexOf(`</${name}`, i);
|
|
165
|
+
const rawStop = closeAt === -1 ? html.length : closeAt;
|
|
166
|
+
out += stripSentinels(html.slice(i, rawStop));
|
|
167
|
+
i = rawStop;
|
|
168
|
+
}
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (c === START) {
|
|
172
|
+
const sep = html.indexOf(SEP, i);
|
|
173
|
+
const end = html.indexOf(END, i);
|
|
174
|
+
if (sep !== -1 && end !== -1 && sep < end) {
|
|
175
|
+
out += `${open(html.slice(i + 1, sep))}${html.slice(sep + 1, end)}${close}`;
|
|
176
|
+
i = end + 1;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (c !== SEP && c !== END && c !== START) out += c;
|
|
181
|
+
i += 1;
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const ATTRIBUTE = /([^\s"'=<>/]+)(\s*=\s*)(?:"([^"]*)"|'([^']*)')/g;
|
|
187
|
+
const WHOLE_VALUE = /^\uE000([^\uE001]*)\uE001[^\uE002]*\uE002$/;
|
|
188
|
+
|
|
189
|
+
/** Strip sentinels from a tag and mark its editable attributes (see finalizeEditMarkers). */
|
|
190
|
+
function markTag(tag: string, placement: string, sharedPages: number): string {
|
|
191
|
+
if (!tag.includes(START)) return tag;
|
|
192
|
+
const marks: string[] = [];
|
|
193
|
+
const cleaned = stripSentinels(
|
|
194
|
+
tag.replace(ATTRIBUTE, (m, name: string, eq: string, dq?: string, sq?: string) => {
|
|
195
|
+
const value = dq ?? sq ?? "";
|
|
196
|
+
if (!value.includes(START)) return m;
|
|
197
|
+
const whole = WHOLE_VALUE.exec(value);
|
|
198
|
+
const lower = name.toLowerCase();
|
|
199
|
+
if (whole?.[1] && MARKABLE_ATTRIBUTES.has(lower)) marks.push(`${lower}=${attr(whole[1])}`);
|
|
200
|
+
const quote = dq !== undefined ? '"' : "'";
|
|
201
|
+
return `${name}${eq}${quote}${stripSentinels(value)}${quote}`;
|
|
202
|
+
}),
|
|
203
|
+
);
|
|
204
|
+
if (marks.length === 0) return cleaned;
|
|
205
|
+
const extra = ` data-caelo-fields="${marks.join(" ")}" data-caelo-placement="${attr(placement)}" data-caelo-shared="${sharedPages}"`;
|
|
206
|
+
const at = cleaned.endsWith("/>") ? cleaned.length - 2 : cleaned.length - 1;
|
|
207
|
+
return `${cleaned.slice(0, at)}${extra}${cleaned.slice(at)}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Drop sentinels and the path they carry, keeping the wrapped value. */
|
|
211
|
+
function stripSentinels(s: string): string {
|
|
212
|
+
return s.replace(/\uE000[^\uE001]*\uE001/g, "").replace(SENTINELS, "");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** A parsed opening marker. */
|
|
216
|
+
export interface EditMarker {
|
|
217
|
+
readonly blockName: string;
|
|
218
|
+
readonly position: number;
|
|
219
|
+
readonly synced: boolean;
|
|
220
|
+
readonly field: string;
|
|
221
|
+
readonly index?: number;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Parse an opening marker's comment text (without `<!--`/`-->`), or null.
|
|
226
|
+
* Mirrors what the preview runtime does in the browser.
|
|
227
|
+
*/
|
|
228
|
+
export function parseEditMarker(comment: string): EditMarker | null {
|
|
229
|
+
const m = /^caelo:f p=(\S+) s=([01]) f=(\S+)$/.exec(comment.trim());
|
|
230
|
+
if (!m?.[1] || !m[2] || !m[3]) return null;
|
|
231
|
+
const placement = decodeURIComponent(m[1]);
|
|
232
|
+
const hash = placement.lastIndexOf("#");
|
|
233
|
+
const position = Number(placement.slice(hash + 1));
|
|
234
|
+
if (hash <= 0 || !Number.isInteger(position)) return null;
|
|
235
|
+
const [field, index] = decodeURIComponent(m[3]).split(".");
|
|
236
|
+
if (!field) return null;
|
|
237
|
+
return {
|
|
238
|
+
blockName: placement.slice(0, hash),
|
|
239
|
+
position,
|
|
240
|
+
synced: m[2] === "1",
|
|
241
|
+
field,
|
|
242
|
+
...(index !== undefined ? { index: Number(index) } : {}),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Issue #914 — the shape of a Google OAuth client and the redirect URI IAP
|
|
5
|
+
* needs on it. Shared so the admin's server-side validation and the setup
|
|
6
|
+
* page (which shows IAP's redirect URI as the Owner types the client ID)
|
|
7
|
+
* use one definition.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A Google OAuth 2.0 client ID (`<project number>-<random>.apps.googleusercontent.com`).
|
|
12
|
+
* Same pattern as the `operator_oauth_client` CHECK constraint (migration 0323).
|
|
13
|
+
*/
|
|
14
|
+
export const GOOGLE_OAUTH_CLIENT_ID = /^[0-9]+-[a-z0-9]+\.apps\.googleusercontent\.com$/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* IAP's redirect URI for a custom OAuth client: Google sends a signed-in
|
|
18
|
+
* visitor back to IAP here, so the client must list it among its authorized
|
|
19
|
+
* redirect URIs (cloud.google.com/iap/docs/custom-oauth-configuration).
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* iapRedirectUri("123-abc.apps.googleusercontent.com")
|
|
23
|
+
* // → "https://iap.googleapis.com/v1/oauth/clientIds/123-abc.apps.googleusercontent.com:handleRedirect"
|
|
24
|
+
*/
|
|
25
|
+
export function iapRedirectUri(clientId: string): string {
|
|
26
|
+
return `https://iap.googleapis.com/v1/oauth/clientIds/${clientId}:handleRedirect`;
|
|
27
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ export * from "./ai-tools.js";
|
|
|
6
6
|
export * from "./auth-forms.js";
|
|
7
7
|
export * from "./auto-update.js";
|
|
8
8
|
export * from "./base-css.js";
|
|
9
|
+
export * from "./bug-intake.js";
|
|
9
10
|
export * from "./build-page.js";
|
|
10
11
|
export * from "./cap-failures.js";
|
|
11
12
|
export * from "./cloud-shell-install.js";
|
|
@@ -16,6 +17,7 @@ export * from "./css-var-scan.js";
|
|
|
16
17
|
export * from "./database-url.js";
|
|
17
18
|
export * from "./design-draft-shell.js";
|
|
18
19
|
export * from "./document-language.js";
|
|
20
|
+
export * from "./edit-markers.js";
|
|
19
21
|
export * from "./font-assets.js";
|
|
20
22
|
export * from "./font-axis-policy.js";
|
|
21
23
|
export * from "./font-usage.js";
|
|
@@ -23,8 +25,10 @@ export * from "./fonts.js";
|
|
|
23
25
|
export * from "./genesis.js";
|
|
24
26
|
export * from "./genesis-inventory.js";
|
|
25
27
|
export * from "./google-models.js";
|
|
28
|
+
export * from "./google-oauth.js";
|
|
26
29
|
export * from "./image-capabilities.js";
|
|
27
30
|
export * from "./interactions.js";
|
|
31
|
+
export * from "./link-canonicalise.js";
|
|
28
32
|
export * from "./link-refs.js";
|
|
29
33
|
export {
|
|
30
34
|
type LogContext,
|