@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 +19 -0
- package/README.md +192 -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/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 +18 -2
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;
|