@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 +8 -0
- package/README.md +57 -8
- package/dist/page-titles.d.ts +60 -0
- package/dist/page-titles.js +33 -0
- package/dist/page-titles.mjs +32 -1
- package/package.json +1 -1
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`),
|
|
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".
|
|
555
|
-
|
|
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')
|
|
561
|
-
slugLabel('/services/hvac-repair/')
|
|
562
|
-
isRoutePlaceholder('/floor-plans/:slug')
|
|
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
|
package/dist/page-titles.d.ts
CHANGED
|
@@ -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;
|
package/dist/page-titles.js
CHANGED
|
@@ -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;
|
package/dist/page-titles.mjs
CHANGED
|
@@ -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.
|
|
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": {
|