@sonordev/contracts 2.7.1 → 2.9.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/CHANGELOG.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.9.0
4
+
5
+ A minor release: three new entries, `@sonordev/contracts/page-titles`, `@sonordev/contracts/broadcast-layouts` and `@sonordev/contracts/publication-branding`. Every other entry is unchanged.
6
+
7
+ - **New entry `@sonordev/contracts/page-titles`**: what a page is called when a list, a sitemap or a case study needs its own name and not its whole title, so every one of them reads the same.
8
+ - `titleHead(title)` returns the title without its trailing " | Brand". Stray pipes are repaired first (a run of pipes with only whitespace between them is one separator, and a leading or trailing pipe is dropped), then only the last " | " segment goes, using `seo-meta`'s `stripTitleSuffix`. "Roof Repair | | Example Roofing" is "Roof Repair".
9
+ - `slugLabel(input)` makes a readable label from a slug, a file name or a path when a page has no title: "ac-replacement" is "AC Replacement", "faq" is "FAQ", "smith-and-smith" is "Smith and Smith", "homes-in-ky" is "Homes in KY". It knows acronyms, US state codes, small words and part numbers, and never throws on a malformed %-escape. "mt" is read by position: last it's Montana ("billings-mt" is "Billings MT"), and before another word it's Mount ("mt-adams" is "Mt Adams").
10
+ - `isRoutePlaceholder(value)` says whether a value, or any "/"-separated segment of it, is a route placeholder such as `:slug`, `[slug]` or `[[...rest]]`, so a page's pattern never ends up as its name.
11
+ - **New entry `@sonordev/contracts/broadcast-layouts`**: which Broadcast card compositions are drawn, and what a retired one draws as, so the API, its agents and the dashboard can't disagree.
12
+ - `CARD_LAYOUTS` is every layout ID a stored row may carry (`stacked`, `headline`, `split`, `anchor`) and `ARTICLE_COVER_LAYOUTS` is the two that are drawn: centered `headline` and low `anchor`. Nothing is top-aligned.
13
+ - `RETIRED_CARD_LAYOUTS` and `normalizeArticleCoverLayout` draw a stored `stacked` as `anchor` and `split` as `headline`. `isCardLayoutId`, `isArticleCoverLayoutId` and `isRetiredCardLayoutId` are the checks, and `CARD_LAYOUT_LABELS` and `CARD_LAYOUT_OPTIONS` the names.
14
+ - `PUBLICATION_CARD_LAYOUTS` is what a publication-branding change may choose and `SAVED_PUBLICATION_CARD_LAYOUTS` what a saved profile may still carry.
15
+ - `BROADCAST_STYLES` lists the shared Broadcast looks.
16
+ - `REVIEW_CARD_VARIATIONS` (Signature and Framed), `normalizeReviewCardVariation` (a retired `spotlight` draws as Signature) and `reviewCardVariationFromDigest`, which draws each review's composition from the SHA-256 of `reviewCompositionSeed(projectId, reviewId)` so it never moves.
17
+ - **New entry `@sonordev/contracts/publication-branding`**: what a project's publication branding may hold and what a change may set.
18
+ - `createPublicationBrandingValidator({ isBlockedHost })` returns `validatePublicationBranding`, `validatePublicationBrandingPatch` and `mergePublicationBrandingPatch`, bound to the caller's own private-address guard. A change may only choose a card layout that's drawn today; a saved profile still reads a retired one.
19
+ - `PublicationBrandingValidationError` names the refused field.
20
+ - The editorial charter (`parsePublicationCharter`, `parseSiteCharters`, `publicationCharterFor`, `publicationCharterContext`), `safeFontStack`, `containsControlCharacters` and `EMAIL_SAFE_FONTS`, plus the profile's types, platforms and field lists.
21
+
22
+ ## 2.8.0
23
+
24
+ A minor release: two new entries, `@sonordev/contracts/business-claims` and `@sonordev/contracts/outreach`. Every other entry is unchanged.
25
+
26
+ - **New entry `@sonordev/contracts/business-claims`**: what an outbound email or text may say about a business, checked the same way where it's written and where it's sent.
27
+ - `checkOutboundCopy(copy, ctx)` sorts what it finds into `blocking` (refused outright), `flags` (a person looks before it sends) and `style` (never blocks), and returns the body as the reader sees it in `plain`. It reads the copy sentence by sentence after rendering HTML to text and decoding entities once.
28
+ - `PLATFORM_BANNED_CLAIMS` lists the claims no business's outreach may make. A business adds its own through `ctx.rules`.
29
+ - `OutboundClaimReview`, `readOutboundClaimReview` and `unsettledFlags` are the stored record of a check and the rule for when it's settled. `outboundRefusalMessage` is the one wording of a refusal.
30
+ - `readBackVerdict(intended, read)` says whether a sent email arrived intact.
31
+ - The quote, price and number helpers (`normalizeForQuote`, `quoteFoundIn`, `checkQuotedPrice`, `numbersGrounded`), `decodeHtmlEntities`, `removeDashes`, `outboundPlainText` and `isWriterNote` are exported so nothing re-implements them.
32
+ - **New entry `@sonordev/contracts/outreach`**: what a segment of outreach is and who may turn one on.
33
+ - `outreachSegment`, `parseOutreachSegmentKey`, `isGrantableSegmentKey` and `outreachSegmentLabel` build, read and label segment keys. `prospectIndustry` and `normalizeIndustry` decide the industry, and `narrativeFingerprint` the pitch.
34
+ - `SegmentGrant`, `readSegmentGrants`, `activeSegmentGrant`, `segmentGrantCovers` and `grantEvidenceRefusal` read a person's standing approval of a segment and check it against the approvals it stands on. `GRADUATION_BAR` is the one bar a segment earns its own sending by.
35
+ - `segmentGranterEligible`, `PUBLIC_MAILBOX_DOMAINS`, `isPublicMailboxDomain` and `dripRowDisposition` round it out.
36
+
3
37
  ## 2.7.1
4
38
 
5
39
  - Preserve article photo source identity during duplicate detection when a responsive image optimizer rewrites the displayed URL.
package/README.md CHANGED
@@ -32,6 +32,11 @@ pnpm add @sonordev/contracts
32
32
  | `@sonordev/contracts/schema-placeholders` | Whether stored JSON-LD carries a template's unfilled slots (`example.com` URLs, an `Organization` named "Example", a placeholder phone, `[Resident Name]`, `{plan.name}`), and the value without them | site-kit's schema output, the Sonor API's schema reads and writes |
33
33
  | `@sonordev/contracts/sentences` | Where prose splits into sentences, without dropping a word or ending one at "p.m." (`splitSentences`) | anything that cuts an excerpt, a social slide or a summary out of an article: the Sonor API's carousels, Signal's article pipeline |
34
34
  | `@sonordev/contracts/knowledge` | Which Sonor records Signal reads into a project's knowledge, the address each one's knowledge is stored under (`portfolio://<id>`), and the limits of the call that tells Signal a site's pages changed | the Sonor API sends those calls and Signal answers them |
35
+ | `@sonordev/contracts/business-claims` | What an outbound email or text may say about a business (`checkOutboundCopy`), the stored review of what it claimed and when that review is settled, and whether a sent email arrived intact (`readBackVerdict`) | the Sonor API checks again on the bytes it sends, Signal checks what its writers and agents file |
36
+ | `@sonordev/contracts/outreach` | What a segment of outreach is (pitch, sending mailbox, prospect industry), who may turn one on, what a turned-on segment covers, and what a drip's ledger row means for its lead | Signal writes and reads segment grants, the Sonor API checks the same grant before it sends |
37
+ | `@sonordev/contracts/page-titles` | A page's own name: its title without the brand (`titleHead`), a readable label from a slug (`slugLabel`), and whether a value is a route placeholder like `:slug` (`isRoutePlaceholder`) | Signal's site-architecture writer, the agency site kit's portfolio components |
38
+ | `@sonordev/contracts/broadcast-layouts` | Which Broadcast card layouts are drawn (centered `headline`, low `anchor`), which retired IDs still read and what each draws as (`normalizeArticleCoverLayout`), the Broadcast styles, and the review compositions with the frozen draw that gives each review its own (`reviewCardVariationFromDigest`) | the Sonor API's card renderers and settings, Signal's agent tools, the dashboard's pickers |
39
+ | `@sonordev/contracts/publication-branding` | What a publication's branding may hold and what a change may set (`createPublicationBrandingValidator`), how a change merges, the editorial charter, and safe font stacks | the Sonor API stores and resolves it, Signal checks an agent's change before filing it, the dashboard edits it |
35
40
 
36
41
  ## Schema placeholders
37
42
 
@@ -418,6 +423,316 @@ const batch = [...removed, ...changed].slice(0, KNOWLEDGE_SYNC_MAX_PAGES)
418
423
  // removals first, so the cut drops changed pages before any removal
419
424
  ```
420
425
 
426
+ ## The business-claims contract
427
+
428
+ An email that goes out under a business's name can't promise what the
429
+ business never said. Every writer (a model, an agent, a person's own
430
+ template) and every sender checks copy with the same function, so a
431
+ sentence refused where it's written is refused again where it's sent.
432
+
433
+ ```ts
434
+ import { checkOutboundCopy } from '@sonordev/contracts/business-claims'
435
+
436
+ const check = checkOutboundCopy(
437
+ { subject: 'A quick idea for your site', body, format: 'text', channel: 'email' },
438
+ { author: 'signal', rules, claims },
439
+ )
440
+ check.blocking // refused outright
441
+ check.flags // needs a person's look before it sends
442
+ check.style // house style, never blocks
443
+ check.plain // the body as the reader sees it
444
+ ```
445
+
446
+ - **Who wrote it.** `author` is `'signal'` (copy a model wrote), `'agent'`
447
+ (an agent's own copy, the strictest set) or `'operator'` (a person's fixed
448
+ template). Operator copy gets the blocking set only. An author it doesn't
449
+ know is checked as an agent.
450
+ - **Blocking.** An empty subject or body, a merge tag that never rendered
451
+ (`{{first_name}}`, `[Company]`), a claim no business may make
452
+ (`PLATFORM_BANNED_CLAIMS`: guaranteed rankings, top-3 or map pack spots
453
+ or results, "#1 on Google",
454
+ "risk free", fake scarcity, implied Google endorsement, and any promise
455
+ that they'll pay nothing or get their money back), the business's own
456
+ banned claims and the pitch's forbidden phrases, a price that matches none
457
+ the business publishes, and a word that mixes alphabets. An agent's own copy
458
+ is also refused for dashes and for unsafe HTML.
459
+ - **Flags.** A price it can't verify, a number nothing backs, a citation that
460
+ doesn't match what it cites, a banned claim that a negation takes back, a
461
+ note the writer left to itself, and a number an agent took from its
462
+ research. Research about the recipient backs a number only in a sentence
463
+ about the recipient (`claimSubjectOf`). It never backs one about the sender.
464
+ A clause that names nobody ("the measured performance score is 61") counts
465
+ as the recipient's only when their research carries that number, and a
466
+ subject line may repeat a number the body backs that way.
467
+ - **Rules come from the caller.** `rules` carries the business's published
468
+ prices, banned claims, forbidden phrases, verified facts and its own names,
469
+ resolved before the call, so no business data lives in the package. Price
470
+ and banned-claim rules run only when `rules` is set.
471
+ - **Findings are stable.** Each finding's `id` is a hash of its code, its
472
+ sentence and the match (`outboundFindingId`), so a person's acknowledgement
473
+ of one sentence never carries over to a rewritten one.
474
+ - **The stored review.** `OutboundClaimReview` is what was checked, against
475
+ which rules, and which flags a person acknowledged.
476
+ `readOutboundClaimReview(value)` reads a stored one defensively, and
477
+ `unsettledFlags(review)` lists the flags nobody has settled yet. A sender
478
+ refuses while that list isn't empty.
479
+ - **The refusal.** `outboundRefusalMessage(findings)` is the one wording for
480
+ what's wrong and how to fix it, numbered and plain.
481
+ - **Read-back.** `readBackVerdict(intended, read)` compares the email a
482
+ mailbox says it sent with the one that was meant: `verified` only when the
483
+ whole intended text arrived (a footer added after it is fine), otherwise
484
+ `empty`, `mismatch` or `unreadable`.
485
+ - **Shared helpers.** The same normalizers the checker uses are exported for
486
+ anything that grounds a quote, price or number in a source
487
+ (`normalizeForQuote`, `quoteFoundIn`, `checkQuotedPrice`,
488
+ `numbersGrounded`), plus `decodeHtmlEntities`, `removeDashes`,
489
+ `outboundPlainText` and `isWriterNote`.
490
+
491
+ `BUSINESS_CLAIMS_CONTRACT_VERSION` is bumped when the stored review's shape
492
+ changes incompatibly.
493
+
494
+ ## The outreach contract
495
+
496
+ Outreach earns the right to send on its own one segment at a time. A segment
497
+ is one pitch, sent from one mailbox, to one kind of business. Both the API
498
+ that writes outreach and the one that sends it read segments by these rules,
499
+ so they can't disagree about what a person turned on.
500
+
501
+ - **Segment keys.** `outreachSegment({ source, narrativeId, mailboxId,
502
+ industry })` builds one: `drip:<pitch id>@<mailbox id>|<industry>` for a
503
+ drip, or `mcp:-|<industry>` for an agent's one-off email, which is counted
504
+ but never turned on. `parseOutreachSegmentKey(key)` reads a key strictly
505
+ and returns `null` for anything else, so a caller-chosen key never reaches
506
+ storage. `isGrantableSegmentKey(key)` is true only for a drip segment with a
507
+ known industry. `outreachSegmentLabel` is the one human label.
508
+ - **Industry.** `prospectIndustry(prospect)` picks the prospect's industry
509
+ and `normalizeIndustry(value)` folds it to one key ("Roofing Contractors"
510
+ and "roofing" are the same segment). A prospect with none is
511
+ `UNSPECIFIED_INDUSTRY`, and those always wait for a person.
512
+ - **The pitch's fingerprint.** `narrativeFingerprint(narrative)` hashes the
513
+ pitch fields only (`NARRATIVE_PITCH_FIELDS`). Rewriting the pitch changes
514
+ it and lapses the segment. Renaming it, switching it off and on, or
515
+ tightening its forbidden phrases doesn't.
516
+ - **States.** `SEGMENT_STATES` is `ungranted`, `granted`, `paused` (it
517
+ stopped itself), `lapsed` (the pitch changed) and `revoked`.
518
+ - **Grants.** `SegmentGrant` is a person's standing approval of one segment,
519
+ either from approving its first email unedited (`first_email`) or by
520
+ accepting it once the segment earned it (`earned`).
521
+ `readSegmentGrants(settings)` and `activeSegmentGrant(settings, key)` read
522
+ them. `segmentGrantCovers(grant, row)` says whether a grant covers one
523
+ email: not when it's revoked or paused, names someone else, was written
524
+ before the grant, has a different pitch fingerprint or sends from another
525
+ mailbox.
526
+ - **Evidence.** `grantEvidenceRefusal(grant, rows, scope)` checks the
527
+ approvals a grant stands on and returns why it can't be trusted, or `null`.
528
+ A grant that fails counts as no grant, so one written into settings by hand
529
+ never sends anything.
530
+ - **The bar.** `GRADUATION_BAR` is the one track record that earns sending
531
+ without a click per email: at least 5 measured, at least 70% of them won,
532
+ and none that went wrong without being undone. An earned grant needs
533
+ `GRADUATION_BAR.minMeasured` approvals on record.
534
+ - **Who may turn one on.** `segmentGranterEligible(roles)`: never an agent;
535
+ otherwise an owner or admin of the project, its organization or the agency
536
+ that manages it.
537
+ - **Company domains.** `PUBLIC_MAILBOX_DOMAINS` and
538
+ `isPublicMailboxDomain(value)`: free mailbox providers, which never count as
539
+ a company's domain.
540
+ - **A drip's row.** `dripRowDisposition(row)` says what a drip's ledger row
541
+ means for its lead: `live`, `sent`, `retire`, `declined` or `refile`. A
542
+ status it doesn't know reads as `live`, so a lead is never written to twice.
543
+
544
+ `OUTREACH_CONTRACT_VERSION` is bumped when a grant's shape or the segment key
545
+ changes incompatibly.
546
+
547
+ ## The page-titles contract
548
+
549
+ A page's title is its own name followed by the brand: "AC Repair | Example
550
+ HVAC". A list of pages, a sitemap or a case study wants the name alone, and
551
+ the usual shortcuts get it wrong. Capitalizing each word of a slug turns
552
+ "ac-replacement" into "Ac Replacement", "faq" into "Faq" and
553
+ "smith-and-smith" into "Smith And Smith". A title with a stray second pipe
554
+ ships as "Loans | | Brand". `titleHead`, `slugLabel` and `isRoutePlaceholder`
555
+ are the one set of rules, so every list reads the same.
556
+
557
+ ```ts
558
+ import { isRoutePlaceholder, slugLabel, titleHead } from '@sonordev/contracts/page-titles'
559
+
560
+ titleHead('FHA Home Loans | | Example Mortgage') // 'FHA Home Loans'
561
+ slugLabel('/services/hvac-repair/') // 'HVAC Repair'
562
+ isRoutePlaceholder('/floor-plans/:slug') // true
563
+ ```
564
+
565
+ - **`titleHead(title)`** is the title without its trailing " | Brand". Only
566
+ the last " | " segment is the brand, so "1 Bedroom | 1BR 1BA | Example
567
+ Apartments" is "1 Bedroom | 1BR 1BA". The cut is the one `seo-meta`'s
568
+ `stripTitleSuffix` makes.
569
+ - **Stray pipes are repaired first.** A run of pipes with only whitespace
570
+ between them is one separator ("Roof Repair | | Example Roofing" reads as
571
+ "Roof Repair | Example Roofing"), and a leading or trailing pipe is dropped
572
+ ("Pricing |" is "Pricing"). Whitespace is trimmed and collapsed.
573
+ - **No separator, no cut.** A title without one comes back as written. It
574
+ can't tell a brand from a name, so a title that's only a brand stays that
575
+ brand, and a dash ("AC Repair - Example HVAC") isn't a brand separator.
576
+ Empty, whitespace-only, `null` and `undefined` input give `""`.
577
+ - **Apply it once.** A title with two or more separators loses one segment
578
+ per call, so the output of `titleHead` isn't a title to run it on again.
579
+ - **`slugLabel(input)`** is for the rare label that has to come from a slug,
580
+ a file name or a path because the page has no title. Prefer `titleHead`:
581
+ a title is what the page was written with, and a slug is a guess at it.
582
+ - **Which part it reads.** The last non-empty path segment, after dropping a
583
+ `https://host` origin, a query or a hash, and a trailing `.html`, `.htm`,
584
+ `.php` or `.aspx`. %-escapes are decoded without ever throwing: a malformed
585
+ one stays as written. Words split on `-`, `_`, `+` and whitespace.
586
+ - **How each word is cased**, by the first rule that fits:
587
+ - an acronym is upper case: `ac`, `ai`, `aeo`, `api`, `cms`, `crm`, `faq`,
588
+ `fsbo`, `hvac`, `jl`, `llc`, `llp`, `mls`, `pdf`, `pllc`, `ppc`, `roi`,
589
+ `seo`, `ui`, `ux` and `usa`. The plural `faqs` is "FAQs";
590
+ - a stand-alone two-letter US state or DC code is upper case
591
+ ("covington-ky" is "Covington KY"). Codes that are ordinary words as
592
+ often as not stay words: `in`, `or`, `me`, `hi`, `id`, `la`, `ma`, `pa`,
593
+ `al`, `de` and `co`. `mt` goes by its position: as the last word it's
594
+ Montana ("billings-mt" is "Billings MT"), and with a word after it it's
595
+ Mount ("mt-adams" is "Mt Adams", "academy-of-mt-adams" is "Academy of Mt
596
+ Adams"). `us` isn't a state: "about-us" is "About Us";
597
+ - a word with a digit keeps its case, and gains a capital only when it
598
+ starts with a letter: "c1" is "C1", "075ct" and "1003" stay;
599
+ - a small word is lower case unless it's first: `a`, `an`, `and`, `at`,
600
+ `by`, `for`, `in`, `of`, `on`, `or`, `the`, `to`, `vs`, `with`;
601
+ - anything else gets a capital first letter and lower case after it.
602
+ The first word of a label is always capitalized, and an acronym stays upper
603
+ case there.
604
+ - **Nothing to label gives `""`.** `""`, `"/"`, `"?x=1"` and a bare origin
605
+ all give an empty string. Naming the home page is the caller's job.
606
+ - **Stable.** Running a label back through `slugLabel` gives the same label,
607
+ as long as its words follow the rules above. A brand's own capitalization
608
+ ("DeWalt") is the exception.
609
+ - **`isRoutePlaceholder(value)`** is true when the value, or any
610
+ "/"-separated segment of it, is a framework route placeholder: `:slug`,
611
+ `:Slug`, `:id?`, `[slug]`, `[...rest]` or `[[...rest]]`. A placeholder is a
612
+ single token with no spaces, so ordinary text that contains a colon or a
613
+ bracket isn't one ("Update: new hours", "FAQ / [draft] notes"). Nullish and
614
+ empty values are false.
615
+
616
+ Known limits. A slug carries no capitalization or punctuation, so "DeWalt"
617
+ reads "Dewalt", "joes-pizza" reads "Joes Pizza", and an `index.html` reads
618
+ "Index". A slug with `mt` anywhere but last reads it as Mount
619
+ ("billings-mt-homes" reads "Billings Mt Homes"), since position is all that
620
+ tells Montana from Mount. A bare `[draft]` is a placeholder, because nothing
621
+ tells it from `[slug]`. Only the pipe is a brand separator, the way
622
+ `stripTitleSuffix` has it.
623
+
624
+ ## The broadcast-layouts contract
625
+
626
+ A Broadcast post is centered or low, never top-aligned. The title-on-top
627
+ composition was retired, but posts and settings saved before that still name
628
+ it, so there are two lists: every ID a stored row may carry, and the IDs
629
+ that are drawn. A retired ID keeps validating and draws as its replacement.
630
+
631
+ ```ts
632
+ import { createHash } from 'node:crypto'
633
+ import {
634
+ ARTICLE_COVER_LAYOUTS, CARD_LAYOUTS, isCardLayoutId, normalizeArticleCoverLayout,
635
+ normalizeReviewCardVariation, reviewCardVariationFromDigest, reviewCompositionSeed,
636
+ } from '@sonordev/contracts/broadcast-layouts'
637
+
638
+ CARD_LAYOUTS // ['stacked', 'headline', 'split', 'anchor']
639
+ ARTICLE_COVER_LAYOUTS // ['headline', 'anchor']
640
+ isCardLayoutId('stacked') // true: an old post still reads
641
+ normalizeArticleCoverLayout('stacked') // 'anchor'
642
+ normalizeArticleCoverLayout('split') // 'headline'
643
+ normalizeReviewCardVariation('spotlight') // 'signature'
644
+
645
+ // On the server: a review's composition, stable for its identity.
646
+ const digest = createHash('sha256').update(reviewCompositionSeed(projectId, reviewId)).digest()
647
+ reviewCardVariationFromDigest(digest) // 'signature' or 'frame'
648
+ ```
649
+
650
+ - **`CARD_LAYOUTS`** is every layout ID a stored row, a saved setting or a
651
+ request may carry, retired ones included. **`ARTICLE_COVER_LAYOUTS`** is
652
+ what's drawn and offered. Both orders are part of the contract: rotation
653
+ picks by index, and a refusal lists the IDs in order.
654
+ - **`RETIRED_CARD_LAYOUTS`** maps each retired ID to what it draws as
655
+ (`stacked` to `anchor`, `split` to `headline`).
656
+ **`normalizeArticleCoverLayout`** applies it and returns anything else as
657
+ it came, so a caller still checks `isCardLayoutId` (or passes `auto`)
658
+ before rendering. Only an own key of the map is retired, so `"toString"`
659
+ isn't.
660
+ - **`CARD_LAYOUT_LABELS`** names the drawn layouts and
661
+ **`CARD_LAYOUT_OPTIONS`** serves them Anchor first.
662
+ - **`PUBLICATION_CARD_LAYOUTS`** is what a publication-branding change may
663
+ choose (`auto` or a drawn layout). **`SAVED_PUBLICATION_CARD_LAYOUTS`** is
664
+ what a saved profile may carry (`auto` or any readable ID).
665
+ - **`BROADCAST_STYLES`** is the shared look for article, review and generated
666
+ post artwork (`glass` by default).
667
+ - **`REVIEW_CARD_VARIATIONS`** is the review compositions drawn, Signature
668
+ and Framed. **`normalizeReviewCardVariation`** draws a retired `spotlight`
669
+ as Signature and anything unknown as the default, Signature.
670
+ **`isReviewCardVariation`** is the strict check for a request that must
671
+ name a drawn one.
672
+ - **A review's composition never moves.** It's drawn from the SHA-256 of
673
+ `reviewCompositionSeed(projectId, reviewId)`, never from anything a person
674
+ can edit. `reviewCardVariationFromDigest` reads the first four bytes
675
+ against the frozen v1 list (`REVIEW_CARD_VARIATION_V1_DRAW`); a review that
676
+ drew the retired Spotlight redraws between the drawn two from the next four
677
+ bytes, so no other review changes. The hash stays with the caller, so the
678
+ contract needs no `crypto`.
679
+
680
+ Changing a list's order or the v1 draw list reshuffles stored posts, so treat
681
+ both as a breaking change and bump `BROADCAST_LAYOUTS_CONTRACT_VERSION`.
682
+
683
+ ## The publication-branding contract
684
+
685
+ A project's publication branding is one stored profile: identity (names,
686
+ publisher, logos), colors, type, editorial direction (the Broadcast style,
687
+ the card layout, the charter) and newsletter settings, each of which a
688
+ channel (email, social, a single network) can override. The API that saves it
689
+ and the agent tools that propose changes to it have to agree exactly on what's
690
+ valid, so the validator lives here once.
691
+
692
+ ```ts
693
+ import { createPublicationBrandingValidator } from '@sonordev/contracts/publication-branding'
694
+
695
+ // Bind it once, to the server's own private-address guard.
696
+ const branding = createPublicationBrandingValidator({ isBlockedHost })
697
+
698
+ branding.validatePublicationBrandingPatch({ colors: { primary: '#ABC' } })
699
+ // { colors: { primary: '#aabbcc' } }
700
+ branding.validatePublicationBrandingPatch({ editorial: { cardLayout: 'stacked' } })
701
+ // throws: publication_branding.editorial.cardLayout: choose auto, headline, anchor
702
+ branding.mergePublicationBrandingPatch(saved, { identity: { logos: { dark: null } } })
703
+ // saved, minus that one override
704
+ ```
705
+
706
+ - **`createPublicationBrandingValidator({ isBlockedHost })`** returns the
707
+ validator. A URL leaf must be a public HTTPS URL without credentials;
708
+ whether its host is private (loopback, a LAN range, link-local,
709
+ `localhost`) is the caller's guard, because that check needs Node. It gets
710
+ `new URL(value).hostname`, so IPv6 keeps its brackets.
711
+ - **`validatePublicationBranding`** reads a whole saved profile.
712
+ **`validatePublicationBrandingPatch`** reads a change: a leaf set to `null`
713
+ clears that override, and a card layout must be one that's drawn today.
714
+ A saved profile may still name a retired layout.
715
+ - **`mergePublicationBrandingPatch(saved, change)`** applies a change leaf by
716
+ leaf and prunes containers it empties. No saved profile reads as
717
+ `{ version: 1 }`, and a change that sets nothing returns the profile as it
718
+ was. **`mergePublicationBrandingLayers`** is the same leaf-wise merge for
719
+ resolving layers.
720
+ - **Refusals name the field.** A `PublicationBrandingValidationError` carries
721
+ the path (`publication_branding.platforms.email.colors.primary`) and says
722
+ what's allowed. A charter set on a channel override is a plain `Error`: a
723
+ charter belongs to the publication or a site.
724
+ - **The editorial charter** (`parsePublicationCharter`, `parseSiteCharters`,
725
+ `publicationCharterFor`, `publicationCharterContext`) is data-only
726
+ direction: who the publication is for, what they're deciding, the proof it
727
+ holds itself to. It's never evidence of expertise.
728
+ - **`safeFontStack`** keeps a font stack as data ("'Brand Display',serif"),
729
+ never a stylesheet fragment, and `EMAIL_SAFE_FONTS` is what an email
730
+ fallback may name.
731
+
732
+ The stored shape's version is `PUBLICATION_BRANDING_VERSION`. Resolving a
733
+ profile against a project's own colors and its measured site design stays in
734
+ the Sonor API.
735
+
421
736
  ## Why it exists
422
737
 
423
738
  A rule that lives in several copies is how two of them end up disagreeing.
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Broadcast layouts: which compositions a Broadcast card draws, which retired
3
+ * IDs stay readable and what each one draws as, the Broadcast styles, and the
4
+ * review card variations with the frozen draw that gives each review its
5
+ * composition. Sonor's APIs and dashboard import it as
6
+ * `@sonordev/contracts/broadcast-layouts`.
7
+ *
8
+ * Why it exists: a post is centered (`headline`) or low (`anchor`), never
9
+ * top-aligned. Retiring the top-aligned compositions meant changing the same
10
+ * lists in three places (the Sonor API, Signal and the dashboard), and any one
11
+ * of them drifting would let an old ID reach a renderer or a picker. So the
12
+ * IDs, the retired-to-drawn map, the normalizers, the labels and the review
13
+ * draw live here once.
14
+ *
15
+ * Two lists, on purpose:
16
+ * - CARD_LAYOUTS is every ID a stored row, a saved setting or a request may
17
+ * carry. Retired IDs stay in it so old rows keep validating; they're
18
+ * normalized on the way to a renderer.
19
+ * - ARTICLE_COVER_LAYOUTS is what's drawn and offered. Nothing else is.
20
+ * Both orders are visible (rotation order and refusal messages), so they're
21
+ * part of the contract.
22
+ *
23
+ * Pure: no I/O, no Node built-ins. The review draw takes the SHA-256 digest
24
+ * of reviewCompositionSeed, which the caller hashes, so the hash stays with
25
+ * the server.
26
+ */
27
+ /** Bumped when a stored ID here changes meaning. */
28
+ export declare const BROADCAST_LAYOUTS_CONTRACT_VERSION = 1;
29
+ /**
30
+ * Every composition ID a stored row, a saved setting or a request may carry.
31
+ *
32
+ * - `headline`: centered, the title set large.
33
+ * - `anchor`: the background leads, the title sits low.
34
+ * - `stacked`: retired 2026-10-14 (title pinned to the top, art below). Draws as `anchor`.
35
+ * - `split`: retired (title and stage in columns). Draws as `headline`.
36
+ */
37
+ export declare const CARD_LAYOUTS: readonly ['stacked', 'headline', 'split', 'anchor'];
38
+ export type CardLayoutId = (typeof CARD_LAYOUTS)[number];
39
+ /** The compositions Broadcast draws and offers: centered or low. Nothing is top-aligned. */
40
+ export declare const ARTICLE_COVER_LAYOUTS: readonly ['headline', 'anchor'];
41
+ export type ArticleCoverLayoutId = (typeof ARTICLE_COVER_LAYOUTS)[number];
42
+ export type RetiredCardLayoutId = Exclude<CardLayoutId, ArticleCoverLayoutId>;
43
+ /** Each retired layout ID, and the composition it draws as. */
44
+ export declare const RETIRED_CARD_LAYOUTS: Readonly<Record<RetiredCardLayoutId, ArticleCoverLayoutId>>;
45
+ /** A readable layout ID, retired ones included. The check every input boundary uses. */
46
+ export declare const isCardLayoutId: (value: unknown) => value is CardLayoutId;
47
+ /** A layout that's drawn and offered today. */
48
+ export declare const isArticleCoverLayoutId: (value: unknown) => value is ArticleCoverLayoutId;
49
+ /** A retired layout ID (an own key of RETIRED_CARD_LAYOUTS, never an inherited one). */
50
+ export declare const isRetiredCardLayoutId: (value: unknown) => value is RetiredCardLayoutId;
51
+ /**
52
+ * Which composition a layout ID draws. A retired ID becomes what it draws as;
53
+ * anything else is returned as it came, so callers check isCardLayoutId (or
54
+ * pass `auto` through) before rendering. Every renderer reads its layout
55
+ * through here, so a retired ID never reaches one.
56
+ */
57
+ export declare function normalizeArticleCoverLayout(layout: CardLayoutId): ArticleCoverLayoutId;
58
+ export declare function normalizeArticleCoverLayout<T>(layout: T): T | ArticleCoverLayoutId;
59
+ /** What each drawn layout is called wherever a person picks one. */
60
+ export declare const CARD_LAYOUT_LABELS: Readonly<Record<ArticleCoverLayoutId, string>>;
61
+ /** The layout choices the API serves to pickers and agents, Anchor first. */
62
+ export declare const CARD_LAYOUT_OPTIONS: ReadonlyArray<{
63
+ id: ArticleCoverLayoutId;
64
+ label: string;
65
+ }>;
66
+ /** A publication's card layout when it doesn't pin one: rotate. */
67
+ export declare const AUTO_CARD_LAYOUT: 'auto';
68
+ export type PublicationCardLayout = CardLayoutId | typeof AUTO_CARD_LAYOUT;
69
+ /** What a publication-branding write may choose: rotate, or a drawn layout. */
70
+ export declare const PUBLICATION_CARD_LAYOUTS: readonly ["auto", "headline", "anchor"];
71
+ /** What a saved publication-branding profile may carry: a write's choices plus the retired IDs. */
72
+ export declare const SAVED_PUBLICATION_CARD_LAYOUTS: readonly ["auto", "stacked", "headline", "split", "anchor"];
73
+ /**
74
+ * The shared Broadcast look for article, review and generated post artwork.
75
+ * One choice per project (stored as `articleStyle`, its legacy wire name).
76
+ *
77
+ * - `glass`: the background full bleed, the words on a frosted card.
78
+ * - `editorial`: paper, a serif headline, the image framed like a magazine plate.
79
+ * - `poster`: one flat accent field, condensed type, big numbers.
80
+ * - `cinematic`: the image as a widescreen still over solid ground.
81
+ * - `blueprint`: a drawing sheet, with a grid, mono labels and a title block.
82
+ * - `focus`: real project photos, a dark reading gradient and circular details.
83
+ */
84
+ export declare const BROADCAST_STYLES: readonly ['glass', 'editorial', 'poster', 'cinematic', 'blueprint', 'focus'];
85
+ export type BroadcastStyleId = (typeof BROADCAST_STYLES)[number];
86
+ export declare const DEFAULT_BROADCAST_STYLE: BroadcastStyleId;
87
+ export declare const isBroadcastStyleId: (value: unknown) => value is BroadcastStyleId;
88
+ /**
89
+ * The review compositions Broadcast draws: `signature` (photo up top, the
90
+ * quote low) and `frame` (centered). Order matters: a review whose v1 draw
91
+ * was retired redraws by index into this list.
92
+ */
93
+ export declare const REVIEW_CARD_VARIATIONS: readonly ['signature', 'frame'];
94
+ export type ReviewCardVariation = (typeof REVIEW_CARD_VARIATIONS)[number];
95
+ /** A review composition when none is chosen, or an unknown one is asked for. */
96
+ export declare const DEFAULT_REVIEW_CARD_VARIATION: ReviewCardVariation;
97
+ /** Each retired review variation, and what it draws as. `spotlight` pinned the quote to the top; retired 2026-10-14. */
98
+ export declare const RETIRED_REVIEW_CARD_VARIATIONS: Readonly<Record<'spotlight', ReviewCardVariation>>;
99
+ /** What each review variation is called wherever a person picks one. */
100
+ export declare const REVIEW_CARD_VARIATION_LABELS: Readonly<Record<ReviewCardVariation, string>>;
101
+ /** The review variation choices, in REVIEW_CARD_VARIATIONS order. */
102
+ export declare const REVIEW_CARD_VARIATION_OPTIONS: ReadonlyArray<{
103
+ id: ReviewCardVariation;
104
+ label: string;
105
+ }>;
106
+ /** A review variation that's drawn today. Strict: a retired one isn't. */
107
+ export declare const isReviewCardVariation: (value: unknown) => value is ReviewCardVariation;
108
+ /**
109
+ * The review composition to draw for a stored or requested value: a drawn
110
+ * one as it is, a retired one as what it draws as, anything else (null, '',
111
+ * an unknown string) as the default.
112
+ */
113
+ export declare function normalizeReviewCardVariation(value: unknown): ReviewCardVariation;
114
+ /**
115
+ * The v1 draw list, frozen. A review's composition belongs to its immutable
116
+ * identity, so changing this list (or its order) would reshuffle every
117
+ * review's card. New compositions get a new seed version, never an edit here.
118
+ */
119
+ export declare const REVIEW_CARD_VARIATION_V1_DRAW: readonly ['signature', 'spotlight', 'frame'];
120
+ /**
121
+ * What a review's composition is drawn from: the project and the review, and
122
+ * nothing a person can edit (platform, artwork, copy and brand never move it).
123
+ * Hash this string with SHA-256 and pass the digest to
124
+ * reviewCardVariationFromDigest.
125
+ */
126
+ export declare function reviewCompositionSeed(projectId: string, reviewId: string): string;
127
+ /**
128
+ * The composition a review's digest draws. The first four bytes (big-endian)
129
+ * pick from the v1 list; a review that drew a retired composition redraws
130
+ * between the drawn ones from the next four, so no other review moves.
131
+ * `digest` is the SHA-256 of reviewCompositionSeed (a Node Buffer works).
132
+ */
133
+ export declare function reviewCardVariationFromDigest(digest: ArrayLike<number>): ReviewCardVariation;
@@ -0,0 +1,83 @@
1
+ 'use strict';
2
+
3
+ // src/contracts/broadcast-layouts.ts
4
+ var BROADCAST_LAYOUTS_CONTRACT_VERSION = 1;
5
+ var own = (record, key) => typeof key === "string" && Object.prototype.hasOwnProperty.call(record, key);
6
+ var CARD_LAYOUTS = ["stacked", "headline", "split", "anchor"];
7
+ var ARTICLE_COVER_LAYOUTS = ["headline", "anchor"];
8
+ var RETIRED_CARD_LAYOUTS = {
9
+ stacked: "anchor",
10
+ split: "headline"
11
+ };
12
+ var isCardLayoutId = (value) => typeof value === "string" && CARD_LAYOUTS.includes(value);
13
+ var isArticleCoverLayoutId = (value) => typeof value === "string" && ARTICLE_COVER_LAYOUTS.includes(value);
14
+ var isRetiredCardLayoutId = (value) => own(RETIRED_CARD_LAYOUTS, value);
15
+ function normalizeArticleCoverLayout(layout) {
16
+ return isRetiredCardLayoutId(layout) ? RETIRED_CARD_LAYOUTS[layout] : layout;
17
+ }
18
+ var CARD_LAYOUT_LABELS = {
19
+ headline: "Headline",
20
+ anchor: "Anchor"
21
+ };
22
+ var CARD_LAYOUT_OPTIONS = [
23
+ { id: "anchor", label: CARD_LAYOUT_LABELS.anchor },
24
+ { id: "headline", label: CARD_LAYOUT_LABELS.headline }
25
+ ];
26
+ var AUTO_CARD_LAYOUT = "auto";
27
+ var PUBLICATION_CARD_LAYOUTS = [AUTO_CARD_LAYOUT, ...ARTICLE_COVER_LAYOUTS];
28
+ var SAVED_PUBLICATION_CARD_LAYOUTS = [AUTO_CARD_LAYOUT, ...CARD_LAYOUTS];
29
+ var BROADCAST_STYLES = ["glass", "editorial", "poster", "cinematic", "blueprint", "focus"];
30
+ var DEFAULT_BROADCAST_STYLE = "glass";
31
+ var isBroadcastStyleId = (value) => typeof value === "string" && BROADCAST_STYLES.includes(value);
32
+ var REVIEW_CARD_VARIATIONS = ["signature", "frame"];
33
+ var DEFAULT_REVIEW_CARD_VARIATION = "signature";
34
+ var RETIRED_REVIEW_CARD_VARIATIONS = {
35
+ spotlight: "signature"
36
+ };
37
+ var REVIEW_CARD_VARIATION_LABELS = {
38
+ signature: "Signature",
39
+ frame: "Framed"
40
+ };
41
+ var REVIEW_CARD_VARIATION_OPTIONS = REVIEW_CARD_VARIATIONS.map((id) => ({ id, label: REVIEW_CARD_VARIATION_LABELS[id] }));
42
+ var isReviewCardVariation = (value) => typeof value === "string" && REVIEW_CARD_VARIATIONS.includes(value);
43
+ function normalizeReviewCardVariation(value) {
44
+ if (isReviewCardVariation(value)) return value;
45
+ return own(RETIRED_REVIEW_CARD_VARIATIONS, value) ? RETIRED_REVIEW_CARD_VARIATIONS[value] : DEFAULT_REVIEW_CARD_VARIATION;
46
+ }
47
+ var REVIEW_CARD_VARIATION_V1_DRAW = ["signature", "spotlight", "frame"];
48
+ function reviewCompositionSeed(projectId, reviewId) {
49
+ return JSON.stringify(["review-composition-v1", projectId, reviewId]);
50
+ }
51
+ var uint32BE = (bytes, offset) => (bytes[offset] << 24 | bytes[offset + 1] << 16 | bytes[offset + 2] << 8 | bytes[offset + 3]) >>> 0;
52
+ function reviewCardVariationFromDigest(digest) {
53
+ if (!digest || digest.length < 8) throw new RangeError("A review composition digest needs at least 8 bytes");
54
+ const drawn = REVIEW_CARD_VARIATION_V1_DRAW[uint32BE(digest, 0) % REVIEW_CARD_VARIATION_V1_DRAW.length];
55
+ return isReviewCardVariation(drawn) ? drawn : REVIEW_CARD_VARIATIONS[uint32BE(digest, 4) % REVIEW_CARD_VARIATIONS.length];
56
+ }
57
+
58
+ exports.ARTICLE_COVER_LAYOUTS = ARTICLE_COVER_LAYOUTS;
59
+ exports.AUTO_CARD_LAYOUT = AUTO_CARD_LAYOUT;
60
+ exports.BROADCAST_LAYOUTS_CONTRACT_VERSION = BROADCAST_LAYOUTS_CONTRACT_VERSION;
61
+ exports.BROADCAST_STYLES = BROADCAST_STYLES;
62
+ exports.CARD_LAYOUTS = CARD_LAYOUTS;
63
+ exports.CARD_LAYOUT_LABELS = CARD_LAYOUT_LABELS;
64
+ exports.CARD_LAYOUT_OPTIONS = CARD_LAYOUT_OPTIONS;
65
+ exports.DEFAULT_BROADCAST_STYLE = DEFAULT_BROADCAST_STYLE;
66
+ exports.DEFAULT_REVIEW_CARD_VARIATION = DEFAULT_REVIEW_CARD_VARIATION;
67
+ exports.PUBLICATION_CARD_LAYOUTS = PUBLICATION_CARD_LAYOUTS;
68
+ exports.RETIRED_CARD_LAYOUTS = RETIRED_CARD_LAYOUTS;
69
+ exports.RETIRED_REVIEW_CARD_VARIATIONS = RETIRED_REVIEW_CARD_VARIATIONS;
70
+ exports.REVIEW_CARD_VARIATIONS = REVIEW_CARD_VARIATIONS;
71
+ exports.REVIEW_CARD_VARIATION_LABELS = REVIEW_CARD_VARIATION_LABELS;
72
+ exports.REVIEW_CARD_VARIATION_OPTIONS = REVIEW_CARD_VARIATION_OPTIONS;
73
+ exports.REVIEW_CARD_VARIATION_V1_DRAW = REVIEW_CARD_VARIATION_V1_DRAW;
74
+ exports.SAVED_PUBLICATION_CARD_LAYOUTS = SAVED_PUBLICATION_CARD_LAYOUTS;
75
+ exports.isArticleCoverLayoutId = isArticleCoverLayoutId;
76
+ exports.isBroadcastStyleId = isBroadcastStyleId;
77
+ exports.isCardLayoutId = isCardLayoutId;
78
+ exports.isRetiredCardLayoutId = isRetiredCardLayoutId;
79
+ exports.isReviewCardVariation = isReviewCardVariation;
80
+ exports.normalizeArticleCoverLayout = normalizeArticleCoverLayout;
81
+ exports.normalizeReviewCardVariation = normalizeReviewCardVariation;
82
+ exports.reviewCardVariationFromDigest = reviewCardVariationFromDigest;
83
+ exports.reviewCompositionSeed = reviewCompositionSeed;
@@ -0,0 +1,56 @@
1
+ // src/contracts/broadcast-layouts.ts
2
+ var BROADCAST_LAYOUTS_CONTRACT_VERSION = 1;
3
+ var own = (record, key) => typeof key === "string" && Object.prototype.hasOwnProperty.call(record, key);
4
+ var CARD_LAYOUTS = ["stacked", "headline", "split", "anchor"];
5
+ var ARTICLE_COVER_LAYOUTS = ["headline", "anchor"];
6
+ var RETIRED_CARD_LAYOUTS = {
7
+ stacked: "anchor",
8
+ split: "headline"
9
+ };
10
+ var isCardLayoutId = (value) => typeof value === "string" && CARD_LAYOUTS.includes(value);
11
+ var isArticleCoverLayoutId = (value) => typeof value === "string" && ARTICLE_COVER_LAYOUTS.includes(value);
12
+ var isRetiredCardLayoutId = (value) => own(RETIRED_CARD_LAYOUTS, value);
13
+ function normalizeArticleCoverLayout(layout) {
14
+ return isRetiredCardLayoutId(layout) ? RETIRED_CARD_LAYOUTS[layout] : layout;
15
+ }
16
+ var CARD_LAYOUT_LABELS = {
17
+ headline: "Headline",
18
+ anchor: "Anchor"
19
+ };
20
+ var CARD_LAYOUT_OPTIONS = [
21
+ { id: "anchor", label: CARD_LAYOUT_LABELS.anchor },
22
+ { id: "headline", label: CARD_LAYOUT_LABELS.headline }
23
+ ];
24
+ var AUTO_CARD_LAYOUT = "auto";
25
+ var PUBLICATION_CARD_LAYOUTS = [AUTO_CARD_LAYOUT, ...ARTICLE_COVER_LAYOUTS];
26
+ var SAVED_PUBLICATION_CARD_LAYOUTS = [AUTO_CARD_LAYOUT, ...CARD_LAYOUTS];
27
+ var BROADCAST_STYLES = ["glass", "editorial", "poster", "cinematic", "blueprint", "focus"];
28
+ var DEFAULT_BROADCAST_STYLE = "glass";
29
+ var isBroadcastStyleId = (value) => typeof value === "string" && BROADCAST_STYLES.includes(value);
30
+ var REVIEW_CARD_VARIATIONS = ["signature", "frame"];
31
+ var DEFAULT_REVIEW_CARD_VARIATION = "signature";
32
+ var RETIRED_REVIEW_CARD_VARIATIONS = {
33
+ spotlight: "signature"
34
+ };
35
+ var REVIEW_CARD_VARIATION_LABELS = {
36
+ signature: "Signature",
37
+ frame: "Framed"
38
+ };
39
+ var REVIEW_CARD_VARIATION_OPTIONS = REVIEW_CARD_VARIATIONS.map((id) => ({ id, label: REVIEW_CARD_VARIATION_LABELS[id] }));
40
+ var isReviewCardVariation = (value) => typeof value === "string" && REVIEW_CARD_VARIATIONS.includes(value);
41
+ function normalizeReviewCardVariation(value) {
42
+ if (isReviewCardVariation(value)) return value;
43
+ return own(RETIRED_REVIEW_CARD_VARIATIONS, value) ? RETIRED_REVIEW_CARD_VARIATIONS[value] : DEFAULT_REVIEW_CARD_VARIATION;
44
+ }
45
+ var REVIEW_CARD_VARIATION_V1_DRAW = ["signature", "spotlight", "frame"];
46
+ function reviewCompositionSeed(projectId, reviewId) {
47
+ return JSON.stringify(["review-composition-v1", projectId, reviewId]);
48
+ }
49
+ var uint32BE = (bytes, offset) => (bytes[offset] << 24 | bytes[offset + 1] << 16 | bytes[offset + 2] << 8 | bytes[offset + 3]) >>> 0;
50
+ function reviewCardVariationFromDigest(digest) {
51
+ if (!digest || digest.length < 8) throw new RangeError("A review composition digest needs at least 8 bytes");
52
+ const drawn = REVIEW_CARD_VARIATION_V1_DRAW[uint32BE(digest, 0) % REVIEW_CARD_VARIATION_V1_DRAW.length];
53
+ return isReviewCardVariation(drawn) ? drawn : REVIEW_CARD_VARIATIONS[uint32BE(digest, 4) % REVIEW_CARD_VARIATIONS.length];
54
+ }
55
+
56
+ export { ARTICLE_COVER_LAYOUTS, AUTO_CARD_LAYOUT, BROADCAST_LAYOUTS_CONTRACT_VERSION, BROADCAST_STYLES, CARD_LAYOUTS, CARD_LAYOUT_LABELS, CARD_LAYOUT_OPTIONS, DEFAULT_BROADCAST_STYLE, DEFAULT_REVIEW_CARD_VARIATION, PUBLICATION_CARD_LAYOUTS, RETIRED_CARD_LAYOUTS, RETIRED_REVIEW_CARD_VARIATIONS, REVIEW_CARD_VARIATIONS, REVIEW_CARD_VARIATION_LABELS, REVIEW_CARD_VARIATION_OPTIONS, REVIEW_CARD_VARIATION_V1_DRAW, SAVED_PUBLICATION_CARD_LAYOUTS, isArticleCoverLayoutId, isBroadcastStyleId, isCardLayoutId, isRetiredCardLayoutId, isReviewCardVariation, normalizeArticleCoverLayout, normalizeReviewCardVariation, reviewCardVariationFromDigest, reviewCompositionSeed };