@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 +34 -0
- package/README.md +315 -0
- package/dist/broadcast-layouts.d.ts +133 -0
- package/dist/broadcast-layouts.js +83 -0
- package/dist/broadcast-layouts.mjs +56 -0
- package/dist/business-claims.d.ts +456 -0
- package/dist/business-claims.js +1378 -0
- package/dist/business-claims.mjs +1340 -0
- package/dist/outreach.d.ts +267 -0
- package/dist/outreach.js +351 -0
- package/dist/outreach.mjs +328 -0
- package/dist/page-titles.d.ts +80 -0
- package/dist/page-titles.js +124 -0
- package/dist/page-titles.mjs +120 -0
- package/dist/publication-branding.d.ts +143 -0
- package/dist/publication-branding.js +350 -0
- package/dist/publication-branding.mjs +328 -0
- package/dist/publication-charter.d.ts +14 -0
- package/package.json +28 -2
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 };
|