@sonordev/contracts 2.8.0 → 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,24 @@
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
+
3
22
  ## 2.8.0
4
23
 
5
24
  A minor release: two new entries, `@sonordev/contracts/business-claims` and `@sonordev/contracts/outreach`. Every other entry is unchanged.
package/README.md CHANGED
@@ -34,6 +34,9 @@ pnpm add @sonordev/contracts
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
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
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 |
37
40
 
38
41
  ## Schema placeholders
39
42
 
@@ -541,6 +544,195 @@ so they can't disagree about what a person turned on.
541
544
  `OUTREACH_CONTRACT_VERSION` is bumped when a grant's shape or the segment key
542
545
  changes incompatibly.
543
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
+
544
736
  ## Why it exists
545
737
 
546
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 };
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Page-titles contract: what a page is called when a list, a sitemap or a
3
+ * case study needs its own name and not its whole <title>. The Sonor APIs and
4
+ * the agency site kit import it as `@sonordev/contracts/page-titles`.
5
+ *
6
+ * Why it exists: three places each turned a stored title or a URL slug into a
7
+ * label with their own logic, and they disagreed. "ac-replacement" became "Ac
8
+ * Replacement", "faq" became "Faq", "smith-and-smith" became "Smith And
9
+ * Smith", and a title with a stray second pipe shipped as "Loans | | Brand".
10
+ * There's one rule now:
11
+ *
12
+ * - `titleHead` is the page's own name: the title without its trailing
13
+ * " | Brand" segment. Prefer it. A page's title is the label it was
14
+ * written with, so it beats anything guessed from a URL.
15
+ * - `slugLabel` is for the rare case a label has to come from a slug, a file
16
+ * name or a path, because the page has no title at all.
17
+ * - `isRoutePlaceholder` says whether a value is a framework route
18
+ * placeholder (":slug", "[slug]", "[...rest]") and not a page's name.
19
+ *
20
+ * Pure string logic: no I/O, no Node built-ins. The brand split is
21
+ * seo-meta's `stripTitleSuffix`, imported, never copied: only the LAST
22
+ * " | " segment of a title is its brand.
23
+ */
24
+ /**
25
+ * The page's own name: the title without its trailing " | Brand" segment.
26
+ *
27
+ * Whitespace is collapsed first. Stray pipes are repaired before the brand is
28
+ * cut, so "Loans | | Brand" reads as "Loans | Brand" (a run of pipes with
29
+ * only whitespace between them is one separator) and a leading or trailing
30
+ * pipe is dropped ("Pricing |" is "Pricing"). Then the last " | " segment is
31
+ * the brand and goes; anything before it stays, pipes and all:
32
+ * "1 Bedroom | 1BR 1BA | Example Apartments" is "1 Bedroom | 1BR 1BA".
33
+ *
34
+ * Apply it once to a stored title. A title with two or more separators
35
+ * loses one segment per call, so its own output isn't a fixed point.
36
+ *
37
+ * A title with no separator comes back as written (trimmed): this can't tell
38
+ * a brand from a name, so a title that's only a brand stays that brand.
39
+ * Empty, whitespace-only, non-string, null and undefined input give "".
40
+ */
41
+ export declare function titleHead(title: string | null | undefined): string;
42
+ /**
43
+ * A readable label for a slug, a file name or a path: "ac-replacement" is
44
+ * "AC Replacement", "/services/hvac-repair/" is "HVAC Repair".
45
+ *
46
+ * Reach for it only when the page has no title (`titleHead` is the page's own
47
+ * name). It reads the last non-empty path segment of the input, after
48
+ * dropping a "https://host" origin, a query or a hash, and decodes %-escapes
49
+ * without ever throwing. A trailing .html, .htm, .php or .aspx is dropped. The
50
+ * words split on "-", "_", "+" and whitespace.
51
+ *
52
+ * Each word is cased by the first rule that fits:
53
+ * - an acronym is upper case ("ac", "ai", "cms", "faq", "hvac", "seo",
54
+ * "llc", "mls", ...), and "faqs" is "FAQs";
55
+ * - a two-letter US state or DC code is upper case ("ky" is "KY"), apart
56
+ * from the codes that are ordinary words as often as not ("in", "or",
57
+ * "me", "hi", "id", "la", "ma", "pa", "al", "de", "co"). "mt" goes by its
58
+ * position: last, it's Montana ("billings-mt" is "Billings MT"); with a
59
+ * word after it, it's Mount ("mt-adams" is "Mt Adams"). "us" isn't a
60
+ * state: "about-us" is "About Us";
61
+ * - a word with a digit keeps its case and only gains a leading capital
62
+ * when it starts with a letter ("c1" is "C1", "075ct" stays);
63
+ * - a small word ("a", "an", "and", "at", "by", "for", "in", "of", "on",
64
+ * "or", "the", "to", "vs", "with") is lower case, unless it's first;
65
+ * - anything else is capitalized with the rest lower case.
66
+ *
67
+ * Nothing left to label ("", "/", "?x=1") gives "". The home page is the
68
+ * caller's to name. A label isn't a title: "index" isn't special-cased,
69
+ * and a brand's own capitalization ("DeWalt") isn't recoverable from a slug.
70
+ */
71
+ export declare function slugLabel(input: string): string;
72
+ /**
73
+ * True when the value, or any "/"-separated segment of it, is a framework
74
+ * route placeholder: a page's pattern leaked into its name ("/floor-plans/:slug",
75
+ * ":Slug", "[...rest]"). A placeholder is one token with no spaces, so
76
+ * ordinary text that contains a colon or a bracket isn't one ("Update: new
77
+ * hours", "FAQ / [draft] notes"). Nullish, empty and non-string values are
78
+ * false.
79
+ */
80
+ export declare function isRoutePlaceholder(value: string | null | undefined): boolean;