@odla-ai/chapter 0.34.0 → 0.35.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/README.md +83 -2
- package/dist/index.cjs +124 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +54 -1
- package/dist/index.d.ts +54 -1
- package/dist/index.js +124 -3
- package/dist/index.js.map +1 -1
- package/dist/worker/index.cjs +143 -4
- package/dist/worker/index.cjs.map +1 -1
- package/dist/worker/index.d.cts +99 -1
- package/dist/worker/index.d.ts +99 -1
- package/dist/worker/index.js +143 -4
- package/dist/worker/index.js.map +1 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -432,8 +432,11 @@ The document carries the source, exact chapter/environment/Stripe-mode target,
|
|
|
432
432
|
monotonic revision, ordered and available tiers, immutable Stripe Price ids,
|
|
433
433
|
the membership-page renderer document, and explicitly delegated application,
|
|
434
434
|
policy, scheduling, and theme sections. Theme tokens include typography as
|
|
435
|
-
|
|
436
|
-
`--ui-font-
|
|
435
|
+
allowlisted `--ui-*` CSS custom properties such as `--ui-font-display` and
|
|
436
|
+
`--ui-font-sans`. Chapter rejects non-`--ui-*` names, non-finite values,
|
|
437
|
+
oversized token sets, control characters, style-tag breakout, declaration
|
|
438
|
+
breakout, comments, imports, expressions, and URL-bearing values. Application
|
|
439
|
+
aliases such as `--teal` or `--deepocean` remain host-owned CSS.
|
|
437
440
|
|
|
438
441
|
The packaged follower receiver validates the signed request timestamp, sender,
|
|
439
442
|
target, environment, Stripe mode, schema, and SHA-256 digest. It rejects a stale
|
|
@@ -455,6 +458,84 @@ count before asking odla-db to order revision history. A freshly provisioned
|
|
|
455
458
|
follower therefore acknowledges revision 1 immediately instead of depending on
|
|
456
459
|
an ordered query over an empty result set.
|
|
457
460
|
|
|
461
|
+
#### Render active tokens before first paint
|
|
462
|
+
|
|
463
|
+
Put Chapter's marker after the local bootstrap theme and before any application
|
|
464
|
+
boot script in the HTML head:
|
|
465
|
+
|
|
466
|
+
```html
|
|
467
|
+
<link rel="stylesheet" href="/bootstrap-theme.css">
|
|
468
|
+
<!--odla:signup-control-theme-->
|
|
469
|
+
<script type="module" src="/app.js"></script>
|
|
470
|
+
```
|
|
471
|
+
|
|
472
|
+
A host route can transform the static asset with the follower-local active row:
|
|
473
|
+
|
|
474
|
+
```ts
|
|
475
|
+
import { chapterWorker, withActiveSignupControlTheme } from "@odla-ai/chapter/worker";
|
|
476
|
+
import { chapter } from "./chapter.config.mjs";
|
|
477
|
+
|
|
478
|
+
export default chapterWorker({
|
|
479
|
+
chapter,
|
|
480
|
+
routes: [async (request, url, env, context) => {
|
|
481
|
+
if (request.method !== "GET" || url.pathname !== "/apply") return null;
|
|
482
|
+
const asset = await env.ASSETS.fetch(new URL("/index.html", url));
|
|
483
|
+
return withActiveSignupControlTheme(asset, {
|
|
484
|
+
db: context.makeDb(env),
|
|
485
|
+
chapterId: chapter.id,
|
|
486
|
+
environment: env.ODLA_ENV,
|
|
487
|
+
// The site's existing deterministic fallback when no revision is active.
|
|
488
|
+
bootstrapTokens: { "--ui-accent": "#096ea8" },
|
|
489
|
+
});
|
|
490
|
+
}],
|
|
491
|
+
});
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
`withActiveSignupControlTheme` reads only the follower database, revalidates the
|
|
495
|
+
stored document and its canonical digest, and injects one bounded style block
|
|
496
|
+
with matching revision/digest metadata. It never calls the controlling hub. A
|
|
497
|
+
missing active row emits the documented `bootstrap` state; a failed local read
|
|
498
|
+
or corrupt active row fails closed instead of looking like absence. The response
|
|
499
|
+
defaults to `Cache-Control: no-store`, carries digest/revision headers, and gets
|
|
500
|
+
an ETag over the complete transformed HTML. This prevents a pre-activation page
|
|
501
|
+
from surviving activation. A host-owned cache may instead call
|
|
502
|
+
`renderActiveSignupControlHtml()` and include
|
|
503
|
+
`result.theme.metadata.cacheKey` in its complete page cache key.
|
|
504
|
+
|
|
505
|
+
For an SSR or static-HTML transform, use the same helper on the generated string:
|
|
506
|
+
|
|
507
|
+
```ts
|
|
508
|
+
import { renderActiveSignupControlHtml } from "@odla-ai/chapter/worker";
|
|
509
|
+
|
|
510
|
+
const result = await renderActiveSignupControlHtml(generatedHtml, {
|
|
511
|
+
db, chapterId: chapter.id, environment,
|
|
512
|
+
bootstrapTokens: { "--ui-accent": "#096ea8" },
|
|
513
|
+
});
|
|
514
|
+
await writeHtml(result.html); // cache with result.theme.metadata.cacheKey
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
After `GET /api/join-config`, hydrate the existing style element rather than
|
|
518
|
+
writing a second theme system. Matching revision/digest metadata produces no DOM
|
|
519
|
+
write and therefore no visual token change:
|
|
520
|
+
|
|
521
|
+
```ts
|
|
522
|
+
import {
|
|
523
|
+
SIGNUP_CONTROL_THEME_STYLE_ID,
|
|
524
|
+
hydrateSignupControlTheme,
|
|
525
|
+
} from "@odla-ai/chapter";
|
|
526
|
+
|
|
527
|
+
const style = document.getElementById(SIGNUP_CONTROL_THEME_STYLE_ID);
|
|
528
|
+
if (style instanceof HTMLStyleElement) {
|
|
529
|
+
hydrateSignupControlTheme(style, joinConfig.signupControl ?? null, {
|
|
530
|
+
bootstrapTokens: { "--ui-accent": "#096ea8" },
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
The host still decides which routes use managed theming and owns any legacy
|
|
536
|
+
token aliases. Keep the marker after bootstrap declarations so the active
|
|
537
|
+
`--ui-*` values win in the initial parsed document.
|
|
538
|
+
|
|
458
539
|
The hub owns a durable outbox: create/verify immutable Stripe Prices first,
|
|
459
540
|
persist the desired revision, retry signed delivery, and mark it active only
|
|
460
541
|
after the exact revision/digest receipt. Version history is for audit and
|
package/dist/index.cjs
CHANGED
|
@@ -27,6 +27,8 @@ __export(index_exports, {
|
|
|
27
27
|
SCHEDULING_DEFAULTS: () => SCHEDULING_DEFAULTS,
|
|
28
28
|
SIGNUP_CONTROL_PATH: () => SIGNUP_CONTROL_PATH,
|
|
29
29
|
SIGNUP_CONTROL_SCHEMA_VERSION: () => SIGNUP_CONTROL_SCHEMA_VERSION,
|
|
30
|
+
SIGNUP_CONTROL_THEME_MARKER: () => SIGNUP_CONTROL_THEME_MARKER,
|
|
31
|
+
SIGNUP_CONTROL_THEME_STYLE_ID: () => SIGNUP_CONTROL_THEME_STYLE_ID,
|
|
30
32
|
applicantProfile: () => applicantProfile,
|
|
31
33
|
applicationBookingUpdate: () => applicationBookingUpdate,
|
|
32
34
|
applicationSummary: () => applicationSummary,
|
|
@@ -70,6 +72,8 @@ __export(index_exports, {
|
|
|
70
72
|
formationFields: () => formationFields,
|
|
71
73
|
getVaultSecret: () => getVaultSecret,
|
|
72
74
|
hasDisclaimerAck: () => hasDisclaimerAck,
|
|
75
|
+
hydrateSignupControlTheme: () => hydrateSignupControlTheme,
|
|
76
|
+
injectSignupControlThemeHead: () => injectSignupControlThemeHead,
|
|
73
77
|
introIdempotencyKey: () => introIdempotencyKey,
|
|
74
78
|
isAdminRole: () => isAdminRole,
|
|
75
79
|
isAlreadySent: () => isAlreadySent,
|
|
@@ -99,6 +103,7 @@ __export(index_exports, {
|
|
|
99
103
|
reconcileMeetings: () => reconcileMeetings,
|
|
100
104
|
refundedPatch: () => refundedPatch,
|
|
101
105
|
render: () => render,
|
|
106
|
+
renderSignupControlThemeHead: () => renderSignupControlThemeHead,
|
|
102
107
|
renderSummary: () => renderSummary,
|
|
103
108
|
renderTemplateBody: () => renderTemplateBody,
|
|
104
109
|
renewalPatch: () => renewalPatch,
|
|
@@ -3026,12 +3031,18 @@ function themeOf(value, errors) {
|
|
|
3026
3031
|
}
|
|
3027
3032
|
unknownKeys(input, /* @__PURE__ */ new Set(["tokens"]), "document.theme", errors);
|
|
3028
3033
|
if (Object.keys(tokens).length > 256) errors.push("document.theme.tokens supports at most 256 entries");
|
|
3034
|
+
let serializedLength = 0;
|
|
3029
3035
|
for (const [key, token] of Object.entries(tokens)) {
|
|
3030
|
-
if (!/^--[a-z0-9-]{
|
|
3031
|
-
|
|
3032
|
-
|
|
3036
|
+
if (!/^--ui-[a-z0-9][a-z0-9-]{0,94}$/.test(key)) {
|
|
3037
|
+
errors.push(`document.theme.tokens.${key} must be an allowlisted --ui-* custom property`);
|
|
3038
|
+
}
|
|
3039
|
+
const rendered = String(token).trim();
|
|
3040
|
+
serializedLength += key.length + rendered.length + 4;
|
|
3041
|
+
if (typeof token !== "string" && typeof token !== "number" || typeof token === "number" && !Number.isFinite(token) || !rendered || rendered.length > 1e3 || /[\u0000-\u001f\u007f\\<>{};]|\/\*|\*\/|(?:^|[^a-z])(?:url|expression)\s*\(|@import/i.test(rendered)) {
|
|
3042
|
+
errors.push(`document.theme.tokens.${key} must be a bounded breakout-safe CSS value`);
|
|
3033
3043
|
}
|
|
3034
3044
|
}
|
|
3045
|
+
if (serializedLength > 65536) errors.push("document.theme.tokens exceeds the serialized size bound");
|
|
3035
3046
|
return { tokens };
|
|
3036
3047
|
}
|
|
3037
3048
|
function validateSignupControlDocument(input) {
|
|
@@ -3083,6 +3094,116 @@ function validateSignupControlDocument(input) {
|
|
|
3083
3094
|
} };
|
|
3084
3095
|
}
|
|
3085
3096
|
|
|
3097
|
+
// src/signup-control-theme.ts
|
|
3098
|
+
var SIGNUP_CONTROL_THEME_MARKER = "<!--odla:signup-control-theme-->";
|
|
3099
|
+
var SIGNUP_CONTROL_THEME_STYLE_ID = "odla-signup-control-theme";
|
|
3100
|
+
var TOKEN_NAME = /^--ui-[a-z0-9][a-z0-9-]{0,94}$/;
|
|
3101
|
+
var DIGEST = /^sha256:[0-9a-f]{64}$/;
|
|
3102
|
+
var UNSAFE_VALUE = /[\u0000-\u001f\u007f\\<>{};]|\/\*|\*\/|(?:^|[^a-z])(?:url|expression)\s*\(|@import/i;
|
|
3103
|
+
var MAX_TOKENS = 256;
|
|
3104
|
+
var MAX_VALUE_LENGTH = 1e3;
|
|
3105
|
+
var MAX_SERIALIZED_LENGTH = 65536;
|
|
3106
|
+
function validatedTokens(input) {
|
|
3107
|
+
const entries = Object.entries(input ?? {});
|
|
3108
|
+
if (entries.length > MAX_TOKENS) {
|
|
3109
|
+
throw new TypeError(`signup-control theme supports at most ${MAX_TOKENS} tokens`);
|
|
3110
|
+
}
|
|
3111
|
+
const tokens = {};
|
|
3112
|
+
let serializedLength = 0;
|
|
3113
|
+
for (const [name, raw] of entries.sort(([left], [right]) => left.localeCompare(right))) {
|
|
3114
|
+
if (!TOKEN_NAME.test(name)) {
|
|
3115
|
+
throw new TypeError(`signup-control theme token "${name}" must be an allowlisted --ui-* property`);
|
|
3116
|
+
}
|
|
3117
|
+
if (typeof raw !== "string" && typeof raw !== "number" || typeof raw === "number" && !Number.isFinite(raw)) {
|
|
3118
|
+
throw new TypeError(`signup-control theme token "${name}" must be a finite string or number`);
|
|
3119
|
+
}
|
|
3120
|
+
const value = String(raw).trim();
|
|
3121
|
+
if (!value || value.length > MAX_VALUE_LENGTH || UNSAFE_VALUE.test(value)) {
|
|
3122
|
+
throw new TypeError(`signup-control theme token "${name}" has an unsafe or oversized value`);
|
|
3123
|
+
}
|
|
3124
|
+
serializedLength += name.length + value.length + 4;
|
|
3125
|
+
if (serializedLength > MAX_SERIALIZED_LENGTH) {
|
|
3126
|
+
throw new TypeError("signup-control theme exceeds the serialized size bound");
|
|
3127
|
+
}
|
|
3128
|
+
tokens[name] = value;
|
|
3129
|
+
}
|
|
3130
|
+
return tokens;
|
|
3131
|
+
}
|
|
3132
|
+
function cssFor(tokens) {
|
|
3133
|
+
const declarations = Object.entries(tokens).map(([name, value]) => ` ${name}: ${value};`);
|
|
3134
|
+
return declarations.length ? `:root {
|
|
3135
|
+
${declarations.join("\n")}
|
|
3136
|
+
}` : "";
|
|
3137
|
+
}
|
|
3138
|
+
function stateFor(revision, options) {
|
|
3139
|
+
if (!revision) {
|
|
3140
|
+
return {
|
|
3141
|
+
css: cssFor(validatedTokens(options.bootstrapTokens)),
|
|
3142
|
+
metadata: { source: "bootstrap", revision: null, digest: null, cacheKey: "signup-control:bootstrap" }
|
|
3143
|
+
};
|
|
3144
|
+
}
|
|
3145
|
+
if (!Number.isSafeInteger(revision.revision) || revision.revision < 1 || !DIGEST.test(revision.digest)) {
|
|
3146
|
+
throw new TypeError("signup-control theme revision metadata is invalid");
|
|
3147
|
+
}
|
|
3148
|
+
return {
|
|
3149
|
+
css: cssFor(validatedTokens(revision.theme?.tokens)),
|
|
3150
|
+
metadata: {
|
|
3151
|
+
source: "active",
|
|
3152
|
+
revision: revision.revision,
|
|
3153
|
+
digest: revision.digest,
|
|
3154
|
+
cacheKey: `signup-control:${revision.digest}`
|
|
3155
|
+
}
|
|
3156
|
+
};
|
|
3157
|
+
}
|
|
3158
|
+
function attributes(metadata) {
|
|
3159
|
+
return [
|
|
3160
|
+
`id="${SIGNUP_CONTROL_THEME_STYLE_ID}"`,
|
|
3161
|
+
`data-signup-control-source="${metadata.source}"`,
|
|
3162
|
+
`data-signup-control-cache-key="${metadata.cacheKey}"`,
|
|
3163
|
+
...metadata.revision === null ? [] : [`data-signup-control-revision="${metadata.revision}"`],
|
|
3164
|
+
...metadata.digest === null ? [] : [`data-signup-control-digest="${metadata.digest}"`]
|
|
3165
|
+
].join(" ");
|
|
3166
|
+
}
|
|
3167
|
+
function renderSignupControlThemeHead(revision, options = {}) {
|
|
3168
|
+
const { css, metadata } = stateFor(revision, options);
|
|
3169
|
+
return { css, metadata, head: `<style ${attributes(metadata)}>${css}</style>` };
|
|
3170
|
+
}
|
|
3171
|
+
function injectSignupControlThemeHead(html, rendered, marker = SIGNUP_CONTROL_THEME_MARKER) {
|
|
3172
|
+
const existing = /<style\b(?=[^>]*\bid\s*=\s*["']odla-signup-control-theme["'])[^>]*>[\s\S]*?<\/style>/gi;
|
|
3173
|
+
const existingBlocks = html.match(existing) ?? [];
|
|
3174
|
+
if (existingBlocks.length > 1) {
|
|
3175
|
+
throw new TypeError("signup-control theme style block must appear at most once");
|
|
3176
|
+
}
|
|
3177
|
+
if (existingBlocks.length === 1) return html.replace(existing, rendered.head);
|
|
3178
|
+
const markerIndex = html.indexOf(marker);
|
|
3179
|
+
if (markerIndex >= 0) {
|
|
3180
|
+
if (html.indexOf(marker, markerIndex + marker.length) >= 0) {
|
|
3181
|
+
throw new TypeError("signup-control theme marker must appear exactly once");
|
|
3182
|
+
}
|
|
3183
|
+
return `${html.slice(0, markerIndex)}${rendered.head}${html.slice(markerIndex + marker.length)}`;
|
|
3184
|
+
}
|
|
3185
|
+
const closingHead = html.search(/<\/head\s*>/i);
|
|
3186
|
+
if (closingHead < 0) throw new TypeError("signup-control theme requires an HTML head");
|
|
3187
|
+
return `${html.slice(0, closingHead)}${rendered.head}${html.slice(closingHead)}`;
|
|
3188
|
+
}
|
|
3189
|
+
function hydrateSignupControlTheme(target, revision, options = {}) {
|
|
3190
|
+
const next = stateFor(revision, options);
|
|
3191
|
+
const currentSource = target.getAttribute("data-signup-control-source");
|
|
3192
|
+
const currentRevision = target.getAttribute("data-signup-control-revision");
|
|
3193
|
+
const currentDigest = target.getAttribute("data-signup-control-digest");
|
|
3194
|
+
if (currentSource === next.metadata.source && currentRevision === (next.metadata.revision === null ? null : String(next.metadata.revision)) && currentDigest === next.metadata.digest) {
|
|
3195
|
+
return false;
|
|
3196
|
+
}
|
|
3197
|
+
target.textContent = next.css;
|
|
3198
|
+
target.setAttribute("data-signup-control-source", next.metadata.source);
|
|
3199
|
+
target.setAttribute("data-signup-control-cache-key", next.metadata.cacheKey);
|
|
3200
|
+
if (next.metadata.revision === null) target.removeAttribute("data-signup-control-revision");
|
|
3201
|
+
else target.setAttribute("data-signup-control-revision", String(next.metadata.revision));
|
|
3202
|
+
if (next.metadata.digest === null) target.removeAttribute("data-signup-control-digest");
|
|
3203
|
+
else target.setAttribute("data-signup-control-digest", next.metadata.digest);
|
|
3204
|
+
return true;
|
|
3205
|
+
}
|
|
3206
|
+
|
|
3086
3207
|
// src/membership-markdown.ts
|
|
3087
3208
|
var MAX_BENEFITS2 = 24;
|
|
3088
3209
|
var MAX_MARKDOWN2 = 65536;
|