@sonordev/contracts 2.9.0 → 2.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.10.0
4
+
5
+ A minor release: two new functions in `@sonordev/contracts/page-titles`. Every entry is otherwise unchanged.
6
+
7
+ - **`page-titles`**: the rule for telling a page's name from its address, once, for everything that prints or stores a title.
8
+ - `isPageAddress(value, path?)` says whether a value is a page's address and not its name, so a path saved where a title belonged never prints as one. It's true for a path or anything starting with a slash, an absolute URL, a host and path with no scheme ("example.com/services"), a route placeholder, and, given the page's `path`, the path itself without its slashes ("services/ac-repair" for "/services/ac-repair"). Ordinary text with a slash in it ("Heating/Cooling") is a name. Ask it about `titleHead`'s result, not the whole title.
9
+ - `isPageName(title, path?)` says whether a stored title is a real name for the page, judging every piece between its pipes. A title that's printed whole prints its pipes too, so "Floor Plans | /floor-plans/studio", "Floor Plans | :slug", ":slug | Example Apartments" and the unspaced "Floor Plans|/floor-plans/studio" aren't names, while "1 Bedroom | 1BR 1BA" is. Empty pieces from a stray pipe are left out, a title that's only pipes isn't a name, and neither are empty, nullish or non-string values. A writer asks it about `titleHead`'s result, so the code that stores a name and the code that prints it can't disagree.
10
+
3
11
  ## 2.9.0
4
12
 
5
13
  A minor release: three new entries, `@sonordev/contracts/page-titles`, `@sonordev/contracts/broadcast-layouts` and `@sonordev/contracts/publication-branding`. Every other entry is unchanged.
package/README.md CHANGED
@@ -34,7 +34,7 @@ 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 |
37
+ | `@sonordev/contracts/page-titles` | A page's own name: its title without the brand (`titleHead`), a readable label from a slug (`slugLabel`), whether a value is a route placeholder like `:slug` (`isRoutePlaceholder`), whether a value is a page's address and not its name (`isPageAddress`), and whether a stored title is a name when every piece between its pipes is judged (`isPageName`) | Signal's site-architecture writer, the agency site kit's portfolio components |
38
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
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 |
40
40
 
@@ -551,15 +551,20 @@ HVAC". A list of pages, a sitemap or a case study wants the name alone, and
551
551
  the usual shortcuts get it wrong. Capitalizing each word of a slug turns
552
552
  "ac-replacement" into "Ac Replacement", "faq" into "Faq" and
553
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.
554
+ ships as "Loans | | Brand". A title that is really the page's path ships as a
555
+ page name. `titleHead`, `slugLabel`, `isRoutePlaceholder`, `isPageAddress`
556
+ and `isPageName` are the one set of rules, so every list reads the same.
556
557
 
557
558
  ```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
559
+ import { isPageAddress, isPageName, isRoutePlaceholder, slugLabel, titleHead } from '@sonordev/contracts/page-titles'
560
+
561
+ titleHead('FHA Home Loans | | Example Mortgage') // 'FHA Home Loans'
562
+ slugLabel('/services/hvac-repair/') // 'HVAC Repair'
563
+ isRoutePlaceholder('/floor-plans/:slug') // true
564
+ isPageAddress('services/ac-repair', '/services/ac-repair') // true
565
+ isPageAddress('AC Repair', '/services/ac-repair') // false
566
+ isPageName('1 Bedroom | 1BR 1BA', '/floor-plans/one-bed') // true
567
+ isPageName('Studio | /floor-plans/studio', '/floor-plans/studio') // false
563
568
  ```
564
569
 
565
570
  - **`titleHead(title)`** is the title without its trailing " | Brand". Only
@@ -612,6 +617,50 @@ isRoutePlaceholder('/floor-plans/:slug') // true
612
617
  single token with no spaces, so ordinary text that contains a colon or a
613
618
  bracket isn't one ("Update: new hours", "FAQ / [draft] notes"). Nullish and
614
619
  empty values are false.
620
+ - **`isPageAddress(value, path?)`** is true when the value is a page's address
621
+ and not its name. That's what a title becomes when a writer saved the path
622
+ where the title belonged. It's true for:
623
+ - a path, or anything that starts with a slash ("/services/ac-repair",
624
+ "//example.com/services");
625
+ - an absolute URL ("https://example.com/services");
626
+ - a host and a path with no scheme and no spaces ("example.com/services",
627
+ "www.example.co.uk/floor-plans/", "example.com:8080/x"). A browser shows an
628
+ untitled page's address this way. A bare host ("example.com") isn't an
629
+ address, and neither is text that has a slash in it ("Heating/Cooling",
630
+ "24/7 Emergency Service") or a number that looks like a host ("4.5/5",
631
+ "v2.10/changelog"), because a host ends in a letters-only domain;
632
+ - a route placeholder, the way `isRoutePlaceholder` reads one;
633
+ - the page's own path written without its slashes, when you pass `path`:
634
+ "services/ac-repair" and "services/ac-repair/" for "/services/ac-repair",
635
+ "faq" for "/faq". Leading and trailing slashes come off both sides, and
636
+ the comparison is exact: "ac-repair" isn't the address of
637
+ "/services/ac-repair", and "Pricing" for "/pricing" is a name where
638
+ "pricing" is the path.
639
+
640
+ The value is trimmed first. Empty, whitespace-only, nullish and non-string
641
+ values are false: they're nothing, not an address, and what an empty title
642
+ means is the caller's to say. It judges one string, so cut a brand tail
643
+ first and ask about what's left: `isPageAddress(titleHead(title), path)`.
644
+ - **`isPageName(title, path?)`** is true when a stored title is a real name for
645
+ the page: something is left once the pipes are set aside, and no piece
646
+ between them is a page's address, as `isPageAddress` reads one (given the
647
+ page's `path`). It's the rule for a title that's printed whole. A writer that
648
+ stores a name and a reader that prints it both ask it, so they can't disagree
649
+ about a path, a URL or a placeholder behind a pipe.
650
+ - **Every piece is judged**, the first and the last included. "Floor Plans |
651
+ /floor-plans/studio", "Floor Plans | :slug", ":slug | Example Apartments"
652
+ and "Floor Plans | example.com/floor-plans/studio" aren't names, and neither
653
+ is the unspaced "Floor Plans|/floor-plans/studio", because it prints with
654
+ its path in it. The split is on the bare `|`, and the empty pieces of a
655
+ stray pipe ("| Studio |", "Studio | | Example Apartments") are left out.
656
+ - **A name with a pipe in it stays a name** while every piece is text:
657
+ "1 Bedroom | 1BR 1BA", "Heating/Cooling | Update: new hours".
658
+ - **Nothing left isn't a name.** A title that's only pipes, an empty or
659
+ whitespace-only title, `null`, `undefined` and a non-string are all false.
660
+ - **Cut the brand tail first.** It judges the title it's given, tail
661
+ included. A writer asks about what it stores: `isPageName(titleHead(title),
662
+ path)`. A path that's only in the cut tail isn't stored, so it doesn't
663
+ count against the page.
615
664
 
616
665
  Known limits. A slug carries no capitalization or punctuation, so "DeWalt"
617
666
  reads "Dewalt", "joes-pizza" reads "Joes Pizza", and an `index.html` reads
@@ -16,6 +16,15 @@
16
16
  * name or a path, because the page has no title at all.
17
17
  * - `isRoutePlaceholder` says whether a value is a framework route
18
18
  * placeholder (":slug", "[slug]", "[...rest]") and not a page's name.
19
+ * - `isPageAddress` says whether a value is a page's address (a path, a
20
+ * URL, a route pattern, or the page's own path without its slashes) and
21
+ * not its name. It's the one "is this title really a path" rule, so the
22
+ * writer of a page list and the reader that prints it can't disagree.
23
+ * - `isPageName` says whether a stored title is a name: something is left
24
+ * once the pipes are set aside, and no piece between them is an address.
25
+ * It's the one rule for a title that gets printed whole, so the writer
26
+ * that stores a name and the reader that prints it can't disagree about
27
+ * a path, a URL or a placeholder behind a pipe.
19
28
  *
20
29
  * Pure string logic: no I/O, no Node built-ins. The brand split is
21
30
  * seo-meta's `stripTitleSuffix`, imported, never copied: only the LAST
@@ -78,3 +87,54 @@ export declare function slugLabel(input: string): string;
78
87
  * false.
79
88
  */
80
89
  export declare function isRoutePlaceholder(value: string | null | undefined): boolean;
90
+ /**
91
+ * Whether `value` is a page's address and not its name: what a page's title
92
+ * becomes when nothing better was stored (the old sitemap writers saved the
93
+ * path there), or a pattern that leaked out of the router. True for:
94
+ *
95
+ * - a path, or anything starting with a slash ("/services/ac-repair",
96
+ * "//example.com/services");
97
+ * - an absolute URL ("https://example.com/services");
98
+ * - a host and a path with no scheme ("example.com/services");
99
+ * - a route placeholder, as `isRoutePlaceholder` reads it ("/floor-plans/:slug",
100
+ * ":Slug", "[slug]");
101
+ * - the page's own path written without its slashes. Given `path`, the
102
+ * comparison drops leading and trailing slashes from both sides, so for
103
+ * "/services/ac-repair" the values "services/ac-repair" and
104
+ * "services/ac-repair/" are the address, and for "/faq" the value "faq" is.
105
+ * It compares the whole path and it's case-sensitive: "ac-repair" isn't
106
+ * the address of "/services/ac-repair", and "Pricing" for "/pricing" is a
107
+ * name where "pricing" is the path.
108
+ *
109
+ * The value is trimmed first. Empty, whitespace-only, nullish and non-string
110
+ * values give false: they aren't an address, they're nothing, and the caller
111
+ * decides what an empty title means. `path` is optional; without it only the
112
+ * checks that don't need the page's own path run.
113
+ *
114
+ * It judges one string. A title with a " | Brand" tail is `titleHead`'s to
115
+ * cut, and the caller asks about the head: `isPageAddress(titleHead(title), path)`.
116
+ * Pure string logic, linear in the length of the input.
117
+ */
118
+ export declare function isPageAddress(value: string | null | undefined, path?: string | null): boolean;
119
+ /**
120
+ * Whether a stored title is a real name for the page at `path`: something is
121
+ * left once the pipes are set aside, and none of its pieces is a page's
122
+ * address (`isPageAddress`, given the page's path). It's the one rule for a
123
+ * title that gets printed whole, so whoever stores a name and whoever prints
124
+ * it can't disagree.
125
+ *
126
+ * Every piece is judged, the first and the last included, because a title
127
+ * with a pipe in it prints with its pipes: ":slug | Example Apartments" (a
128
+ * placeholder in front of a brand tail) and "Floor Plans | /floor-plans/x" (a
129
+ * path behind the name) are both refused, and so is the unspaced
130
+ * "Floor Plans|/floor-plans/x". A name with a pipe in it stays a name while
131
+ * every piece is text ("1 Bedroom | 1BR 1BA"). A title that's only pipes has
132
+ * no piece and isn't a name. Empty, whitespace-only, nullish and non-string
133
+ * values aren't names either.
134
+ *
135
+ * It judges the title it is given, brand tail and all. A writer cuts the tail
136
+ * first and asks about what it stores: `isPageName(titleHead(title), path)`.
137
+ * `path` is optional, as it is for `isPageAddress`. Pure string logic, linear
138
+ * in the length of the input.
139
+ */
140
+ export declare function isPageName(title: string | null | undefined, path?: string | null): boolean;
@@ -118,7 +118,40 @@ function isRoutePlaceholder(value) {
118
118
  if (typeof value !== "string") return false;
119
119
  return value.split("/").some((segment) => PLACEHOLDER_TOKEN.test(segment.trim()));
120
120
  }
121
+ var ABSOLUTE_URL = /^[a-z][a-z0-9+.-]*:\/\//i;
122
+ var HOST_NAME = /^(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d{1,5})?$/i;
123
+ function withoutOuterSlashes(value) {
124
+ let start = 0;
125
+ let end = value.length;
126
+ while (start < end && value[start] === "/") start++;
127
+ while (end > start && value[end - 1] === "/") end--;
128
+ return value.slice(start, end);
129
+ }
130
+ function isHostPath(value) {
131
+ const slash = value.indexOf("/");
132
+ if (slash < 0 || /\s/.test(value)) return false;
133
+ return HOST_NAME.test(value.slice(0, slash));
134
+ }
135
+ function isPageAddress(value, path) {
136
+ if (typeof value !== "string") return false;
137
+ const given = value.trim();
138
+ if (!given) return false;
139
+ if (given.startsWith("/") || ABSOLUTE_URL.test(given) || isHostPath(given) || isRoutePlaceholder(given)) return true;
140
+ if (typeof path !== "string") return false;
141
+ const own = withoutOuterSlashes(path.trim());
142
+ return withoutOuterSlashes(given) === own;
143
+ }
144
+ function pipePieces(title) {
145
+ return title.split("|").map((piece) => piece.trim()).filter(Boolean);
146
+ }
147
+ function isPageName(title, path) {
148
+ if (typeof title !== "string") return false;
149
+ const pieces = pipePieces(title);
150
+ return pieces.length > 0 && pieces.every((piece) => !isPageAddress(piece, path));
151
+ }
121
152
 
153
+ exports.isPageAddress = isPageAddress;
154
+ exports.isPageName = isPageName;
122
155
  exports.isRoutePlaceholder = isRoutePlaceholder;
123
156
  exports.slugLabel = slugLabel;
124
157
  exports.titleHead = titleHead;
@@ -116,5 +116,36 @@ function isRoutePlaceholder(value) {
116
116
  if (typeof value !== "string") return false;
117
117
  return value.split("/").some((segment) => PLACEHOLDER_TOKEN.test(segment.trim()));
118
118
  }
119
+ var ABSOLUTE_URL = /^[a-z][a-z0-9+.-]*:\/\//i;
120
+ var HOST_NAME = /^(?:[a-z0-9-]+\.)+[a-z]{2,}(?::\d{1,5})?$/i;
121
+ function withoutOuterSlashes(value) {
122
+ let start = 0;
123
+ let end = value.length;
124
+ while (start < end && value[start] === "/") start++;
125
+ while (end > start && value[end - 1] === "/") end--;
126
+ return value.slice(start, end);
127
+ }
128
+ function isHostPath(value) {
129
+ const slash = value.indexOf("/");
130
+ if (slash < 0 || /\s/.test(value)) return false;
131
+ return HOST_NAME.test(value.slice(0, slash));
132
+ }
133
+ function isPageAddress(value, path) {
134
+ if (typeof value !== "string") return false;
135
+ const given = value.trim();
136
+ if (!given) return false;
137
+ if (given.startsWith("/") || ABSOLUTE_URL.test(given) || isHostPath(given) || isRoutePlaceholder(given)) return true;
138
+ if (typeof path !== "string") return false;
139
+ const own = withoutOuterSlashes(path.trim());
140
+ return withoutOuterSlashes(given) === own;
141
+ }
142
+ function pipePieces(title) {
143
+ return title.split("|").map((piece) => piece.trim()).filter(Boolean);
144
+ }
145
+ function isPageName(title, path) {
146
+ if (typeof title !== "string") return false;
147
+ const pieces = pipePieces(title);
148
+ return pieces.length > 0 && pieces.every((piece) => !isPageAddress(piece, path));
149
+ }
119
150
 
120
- export { isRoutePlaceholder, slugLabel, titleHead };
151
+ export { isPageAddress, isPageName, isRoutePlaceholder, slugLabel, titleHead };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sonordev/contracts",
3
- "version": "2.9.0",
3
+ "version": "2.10.0",
4
4
  "description": "The rules Sonor's sites, APIs and dashboard share: popup blocks and site design tokens, site hosts, SEO page identity, title quality, llms.txt sanitizers, the forms honeypot, fleet heartbeats, slot content and signing, site cache tags, portfolio metrics, the house voice's contraction rule, how a proposal's site plan counts its pages, what separates an error page from a page's content, which stored JSON-LD is a template's unfilled placeholder, where prose splits into sentences, which Sonor records Signal reads into a project's knowledge and the address each one is stored under, what outbound email and text may claim about a business and whether a sent email arrived intact, what a segment of outreach is and who may turn one on, a page's own name taken from its title or its slug, which Broadcast card layouts and review compositions are drawn and what a retired one draws as, and what a publication's branding may hold. Pure functions, no dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {