@sonordev/contracts 2.8.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 +27 -0
- package/README.md +241 -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 +140 -0
- package/dist/page-titles.js +157 -0
- package/dist/page-titles.mjs +151 -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
|
@@ -0,0 +1,140 @@
|
|
|
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
|
+
* - `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.
|
|
28
|
+
*
|
|
29
|
+
* Pure string logic: no I/O, no Node built-ins. The brand split is
|
|
30
|
+
* seo-meta's `stripTitleSuffix`, imported, never copied: only the LAST
|
|
31
|
+
* " | " segment of a title is its brand.
|
|
32
|
+
*/
|
|
33
|
+
/**
|
|
34
|
+
* The page's own name: the title without its trailing " | Brand" segment.
|
|
35
|
+
*
|
|
36
|
+
* Whitespace is collapsed first. Stray pipes are repaired before the brand is
|
|
37
|
+
* cut, so "Loans | | Brand" reads as "Loans | Brand" (a run of pipes with
|
|
38
|
+
* only whitespace between them is one separator) and a leading or trailing
|
|
39
|
+
* pipe is dropped ("Pricing |" is "Pricing"). Then the last " | " segment is
|
|
40
|
+
* the brand and goes; anything before it stays, pipes and all:
|
|
41
|
+
* "1 Bedroom | 1BR 1BA | Example Apartments" is "1 Bedroom | 1BR 1BA".
|
|
42
|
+
*
|
|
43
|
+
* Apply it once to a stored title. A title with two or more separators
|
|
44
|
+
* loses one segment per call, so its own output isn't a fixed point.
|
|
45
|
+
*
|
|
46
|
+
* A title with no separator comes back as written (trimmed): this can't tell
|
|
47
|
+
* a brand from a name, so a title that's only a brand stays that brand.
|
|
48
|
+
* Empty, whitespace-only, non-string, null and undefined input give "".
|
|
49
|
+
*/
|
|
50
|
+
export declare function titleHead(title: string | null | undefined): string;
|
|
51
|
+
/**
|
|
52
|
+
* A readable label for a slug, a file name or a path: "ac-replacement" is
|
|
53
|
+
* "AC Replacement", "/services/hvac-repair/" is "HVAC Repair".
|
|
54
|
+
*
|
|
55
|
+
* Reach for it only when the page has no title (`titleHead` is the page's own
|
|
56
|
+
* name). It reads the last non-empty path segment of the input, after
|
|
57
|
+
* dropping a "https://host" origin, a query or a hash, and decodes %-escapes
|
|
58
|
+
* without ever throwing. A trailing .html, .htm, .php or .aspx is dropped. The
|
|
59
|
+
* words split on "-", "_", "+" and whitespace.
|
|
60
|
+
*
|
|
61
|
+
* Each word is cased by the first rule that fits:
|
|
62
|
+
* - an acronym is upper case ("ac", "ai", "cms", "faq", "hvac", "seo",
|
|
63
|
+
* "llc", "mls", ...), and "faqs" is "FAQs";
|
|
64
|
+
* - a two-letter US state or DC code is upper case ("ky" is "KY"), apart
|
|
65
|
+
* from the codes that are ordinary words as often as not ("in", "or",
|
|
66
|
+
* "me", "hi", "id", "la", "ma", "pa", "al", "de", "co"). "mt" goes by its
|
|
67
|
+
* position: last, it's Montana ("billings-mt" is "Billings MT"); with a
|
|
68
|
+
* word after it, it's Mount ("mt-adams" is "Mt Adams"). "us" isn't a
|
|
69
|
+
* state: "about-us" is "About Us";
|
|
70
|
+
* - a word with a digit keeps its case and only gains a leading capital
|
|
71
|
+
* when it starts with a letter ("c1" is "C1", "075ct" stays);
|
|
72
|
+
* - a small word ("a", "an", "and", "at", "by", "for", "in", "of", "on",
|
|
73
|
+
* "or", "the", "to", "vs", "with") is lower case, unless it's first;
|
|
74
|
+
* - anything else is capitalized with the rest lower case.
|
|
75
|
+
*
|
|
76
|
+
* Nothing left to label ("", "/", "?x=1") gives "". The home page is the
|
|
77
|
+
* caller's to name. A label isn't a title: "index" isn't special-cased,
|
|
78
|
+
* and a brand's own capitalization ("DeWalt") isn't recoverable from a slug.
|
|
79
|
+
*/
|
|
80
|
+
export declare function slugLabel(input: string): string;
|
|
81
|
+
/**
|
|
82
|
+
* True when the value, or any "/"-separated segment of it, is a framework
|
|
83
|
+
* route placeholder: a page's pattern leaked into its name ("/floor-plans/:slug",
|
|
84
|
+
* ":Slug", "[...rest]"). A placeholder is one token with no spaces, so
|
|
85
|
+
* ordinary text that contains a colon or a bracket isn't one ("Update: new
|
|
86
|
+
* hours", "FAQ / [draft] notes"). Nullish, empty and non-string values are
|
|
87
|
+
* false.
|
|
88
|
+
*/
|
|
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;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/contracts/seo-meta.ts
|
|
4
|
+
var TITLE_SEPARATOR = " | ";
|
|
5
|
+
function stripTitleSuffix(title) {
|
|
6
|
+
const t = title.trim();
|
|
7
|
+
const i = t.lastIndexOf(TITLE_SEPARATOR);
|
|
8
|
+
return i > 0 ? t.slice(0, i).trim() : t;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// src/contracts/page-titles.ts
|
|
12
|
+
var PIPE_RUN = /\|(?:\s*\|)+/g;
|
|
13
|
+
var EDGE_PIPES = /^[\s|]+|[\s|]+$/g;
|
|
14
|
+
function titleHead(title) {
|
|
15
|
+
if (typeof title !== "string") return "";
|
|
16
|
+
const tidy = title.replace(/\s+/g, " ").replace(PIPE_RUN, " | ").replace(EDGE_PIPES, "").replace(/\s+/g, " ");
|
|
17
|
+
return tidy ? stripTitleSuffix(tidy) : "";
|
|
18
|
+
}
|
|
19
|
+
var ACRONYMS = /* @__PURE__ */ new Map([
|
|
20
|
+
["ac", "AC"],
|
|
21
|
+
["ai", "AI"],
|
|
22
|
+
["aeo", "AEO"],
|
|
23
|
+
["api", "API"],
|
|
24
|
+
["cms", "CMS"],
|
|
25
|
+
["crm", "CRM"],
|
|
26
|
+
["faq", "FAQ"],
|
|
27
|
+
["faqs", "FAQs"],
|
|
28
|
+
["fsbo", "FSBO"],
|
|
29
|
+
["hvac", "HVAC"],
|
|
30
|
+
["jl", "JL"],
|
|
31
|
+
["llc", "LLC"],
|
|
32
|
+
["llp", "LLP"],
|
|
33
|
+
["mls", "MLS"],
|
|
34
|
+
["pdf", "PDF"],
|
|
35
|
+
["pllc", "PLLC"],
|
|
36
|
+
["ppc", "PPC"],
|
|
37
|
+
["roi", "ROI"],
|
|
38
|
+
["seo", "SEO"],
|
|
39
|
+
["ui", "UI"],
|
|
40
|
+
["usa", "USA"],
|
|
41
|
+
["ux", "UX"]
|
|
42
|
+
]);
|
|
43
|
+
var STATE_CODES = new Set(
|
|
44
|
+
"al ak az ar ca co ct de dc fl ga hi id il in ia ks ky la me md ma mi mn ms mo mt ne nv nh nj nm ny nc nd oh ok or pa ri sc sd tn tx ut vt va wa wv wi wy".split(" ")
|
|
45
|
+
);
|
|
46
|
+
var WORD_STATE_CODES = /* @__PURE__ */ new Set([
|
|
47
|
+
"in",
|
|
48
|
+
"or",
|
|
49
|
+
"me",
|
|
50
|
+
"hi",
|
|
51
|
+
"id",
|
|
52
|
+
"la",
|
|
53
|
+
"ma",
|
|
54
|
+
"pa",
|
|
55
|
+
"al",
|
|
56
|
+
"de",
|
|
57
|
+
"co"
|
|
58
|
+
]);
|
|
59
|
+
var TRAILING_STATE_CODES = /* @__PURE__ */ new Set(["mt"]);
|
|
60
|
+
function isStateCode(lower, isLast) {
|
|
61
|
+
if (!STATE_CODES.has(lower) || WORD_STATE_CODES.has(lower)) return false;
|
|
62
|
+
return TRAILING_STATE_CODES.has(lower) ? isLast : true;
|
|
63
|
+
}
|
|
64
|
+
var SMALL_WORDS = /* @__PURE__ */ new Set([
|
|
65
|
+
"a",
|
|
66
|
+
"an",
|
|
67
|
+
"and",
|
|
68
|
+
"at",
|
|
69
|
+
"by",
|
|
70
|
+
"for",
|
|
71
|
+
"in",
|
|
72
|
+
"of",
|
|
73
|
+
"on",
|
|
74
|
+
"or",
|
|
75
|
+
"the",
|
|
76
|
+
"to",
|
|
77
|
+
"vs",
|
|
78
|
+
"with"
|
|
79
|
+
]);
|
|
80
|
+
var PAGE_EXTENSION = /\.(?:html?|php|aspx)$/i;
|
|
81
|
+
var URL_ORIGIN = /^[a-z][a-z0-9+.-]*:\/\/[^/\\]*/i;
|
|
82
|
+
var PERCENT_RUN = /(?:%[0-9a-f]{2})+/gi;
|
|
83
|
+
function decodeEscapes(value) {
|
|
84
|
+
return value.replace(PERCENT_RUN, (run) => {
|
|
85
|
+
try {
|
|
86
|
+
return decodeURIComponent(run);
|
|
87
|
+
} catch {
|
|
88
|
+
return run;
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
function capitalizeFirst(word) {
|
|
93
|
+
const code = word.codePointAt(0);
|
|
94
|
+
if (code === void 0) return word;
|
|
95
|
+
const first = String.fromCodePoint(code);
|
|
96
|
+
return first.toUpperCase() + word.slice(first.length);
|
|
97
|
+
}
|
|
98
|
+
function labelWord(token, isFirst, isLast) {
|
|
99
|
+
const lower = token.toLowerCase();
|
|
100
|
+
const acronym = ACRONYMS.get(lower);
|
|
101
|
+
if (acronym) return acronym;
|
|
102
|
+
if (isStateCode(lower, isLast)) return lower.toUpperCase();
|
|
103
|
+
if (/\p{N}/u.test(token)) return /^\p{L}/u.test(token) ? capitalizeFirst(token) : token;
|
|
104
|
+
if (!isFirst && SMALL_WORDS.has(lower)) return lower;
|
|
105
|
+
return capitalizeFirst(lower);
|
|
106
|
+
}
|
|
107
|
+
function slugLabel(input) {
|
|
108
|
+
if (typeof input !== "string") return "";
|
|
109
|
+
const path = input.split(/[?#]/, 1)[0].replace(URL_ORIGIN, "");
|
|
110
|
+
const segment = path.split(/[/\\]/).filter((s) => s.trim() !== "").pop();
|
|
111
|
+
if (!segment) return "";
|
|
112
|
+
const name = decodeEscapes(segment).replace(PAGE_EXTENSION, "");
|
|
113
|
+
const tokens = name.split(/[-_+\s]+/).filter(Boolean);
|
|
114
|
+
return tokens.map((token, i) => labelWord(token, i === 0, i === tokens.length - 1)).join(" ");
|
|
115
|
+
}
|
|
116
|
+
var PLACEHOLDER_TOKEN = /^(?::[A-Za-z_][\w-]*[?*+]?|\[\[\.\.\.[A-Za-z_][\w-]*\]\]|\[(?:\.\.\.)?[A-Za-z_][\w-]*\])$/;
|
|
117
|
+
function isRoutePlaceholder(value) {
|
|
118
|
+
if (typeof value !== "string") return false;
|
|
119
|
+
return value.split("/").some((segment) => PLACEHOLDER_TOKEN.test(segment.trim()));
|
|
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
|
+
}
|
|
152
|
+
|
|
153
|
+
exports.isPageAddress = isPageAddress;
|
|
154
|
+
exports.isPageName = isPageName;
|
|
155
|
+
exports.isRoutePlaceholder = isRoutePlaceholder;
|
|
156
|
+
exports.slugLabel = slugLabel;
|
|
157
|
+
exports.titleHead = titleHead;
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// src/contracts/seo-meta.ts
|
|
2
|
+
var TITLE_SEPARATOR = " | ";
|
|
3
|
+
function stripTitleSuffix(title) {
|
|
4
|
+
const t = title.trim();
|
|
5
|
+
const i = t.lastIndexOf(TITLE_SEPARATOR);
|
|
6
|
+
return i > 0 ? t.slice(0, i).trim() : t;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// src/contracts/page-titles.ts
|
|
10
|
+
var PIPE_RUN = /\|(?:\s*\|)+/g;
|
|
11
|
+
var EDGE_PIPES = /^[\s|]+|[\s|]+$/g;
|
|
12
|
+
function titleHead(title) {
|
|
13
|
+
if (typeof title !== "string") return "";
|
|
14
|
+
const tidy = title.replace(/\s+/g, " ").replace(PIPE_RUN, " | ").replace(EDGE_PIPES, "").replace(/\s+/g, " ");
|
|
15
|
+
return tidy ? stripTitleSuffix(tidy) : "";
|
|
16
|
+
}
|
|
17
|
+
var ACRONYMS = /* @__PURE__ */ new Map([
|
|
18
|
+
["ac", "AC"],
|
|
19
|
+
["ai", "AI"],
|
|
20
|
+
["aeo", "AEO"],
|
|
21
|
+
["api", "API"],
|
|
22
|
+
["cms", "CMS"],
|
|
23
|
+
["crm", "CRM"],
|
|
24
|
+
["faq", "FAQ"],
|
|
25
|
+
["faqs", "FAQs"],
|
|
26
|
+
["fsbo", "FSBO"],
|
|
27
|
+
["hvac", "HVAC"],
|
|
28
|
+
["jl", "JL"],
|
|
29
|
+
["llc", "LLC"],
|
|
30
|
+
["llp", "LLP"],
|
|
31
|
+
["mls", "MLS"],
|
|
32
|
+
["pdf", "PDF"],
|
|
33
|
+
["pllc", "PLLC"],
|
|
34
|
+
["ppc", "PPC"],
|
|
35
|
+
["roi", "ROI"],
|
|
36
|
+
["seo", "SEO"],
|
|
37
|
+
["ui", "UI"],
|
|
38
|
+
["usa", "USA"],
|
|
39
|
+
["ux", "UX"]
|
|
40
|
+
]);
|
|
41
|
+
var STATE_CODES = new Set(
|
|
42
|
+
"al ak az ar ca co ct de dc fl ga hi id il in ia ks ky la me md ma mi mn ms mo mt ne nv nh nj nm ny nc nd oh ok or pa ri sc sd tn tx ut vt va wa wv wi wy".split(" ")
|
|
43
|
+
);
|
|
44
|
+
var WORD_STATE_CODES = /* @__PURE__ */ new Set([
|
|
45
|
+
"in",
|
|
46
|
+
"or",
|
|
47
|
+
"me",
|
|
48
|
+
"hi",
|
|
49
|
+
"id",
|
|
50
|
+
"la",
|
|
51
|
+
"ma",
|
|
52
|
+
"pa",
|
|
53
|
+
"al",
|
|
54
|
+
"de",
|
|
55
|
+
"co"
|
|
56
|
+
]);
|
|
57
|
+
var TRAILING_STATE_CODES = /* @__PURE__ */ new Set(["mt"]);
|
|
58
|
+
function isStateCode(lower, isLast) {
|
|
59
|
+
if (!STATE_CODES.has(lower) || WORD_STATE_CODES.has(lower)) return false;
|
|
60
|
+
return TRAILING_STATE_CODES.has(lower) ? isLast : true;
|
|
61
|
+
}
|
|
62
|
+
var SMALL_WORDS = /* @__PURE__ */ new Set([
|
|
63
|
+
"a",
|
|
64
|
+
"an",
|
|
65
|
+
"and",
|
|
66
|
+
"at",
|
|
67
|
+
"by",
|
|
68
|
+
"for",
|
|
69
|
+
"in",
|
|
70
|
+
"of",
|
|
71
|
+
"on",
|
|
72
|
+
"or",
|
|
73
|
+
"the",
|
|
74
|
+
"to",
|
|
75
|
+
"vs",
|
|
76
|
+
"with"
|
|
77
|
+
]);
|
|
78
|
+
var PAGE_EXTENSION = /\.(?:html?|php|aspx)$/i;
|
|
79
|
+
var URL_ORIGIN = /^[a-z][a-z0-9+.-]*:\/\/[^/\\]*/i;
|
|
80
|
+
var PERCENT_RUN = /(?:%[0-9a-f]{2})+/gi;
|
|
81
|
+
function decodeEscapes(value) {
|
|
82
|
+
return value.replace(PERCENT_RUN, (run) => {
|
|
83
|
+
try {
|
|
84
|
+
return decodeURIComponent(run);
|
|
85
|
+
} catch {
|
|
86
|
+
return run;
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
function capitalizeFirst(word) {
|
|
91
|
+
const code = word.codePointAt(0);
|
|
92
|
+
if (code === void 0) return word;
|
|
93
|
+
const first = String.fromCodePoint(code);
|
|
94
|
+
return first.toUpperCase() + word.slice(first.length);
|
|
95
|
+
}
|
|
96
|
+
function labelWord(token, isFirst, isLast) {
|
|
97
|
+
const lower = token.toLowerCase();
|
|
98
|
+
const acronym = ACRONYMS.get(lower);
|
|
99
|
+
if (acronym) return acronym;
|
|
100
|
+
if (isStateCode(lower, isLast)) return lower.toUpperCase();
|
|
101
|
+
if (/\p{N}/u.test(token)) return /^\p{L}/u.test(token) ? capitalizeFirst(token) : token;
|
|
102
|
+
if (!isFirst && SMALL_WORDS.has(lower)) return lower;
|
|
103
|
+
return capitalizeFirst(lower);
|
|
104
|
+
}
|
|
105
|
+
function slugLabel(input) {
|
|
106
|
+
if (typeof input !== "string") return "";
|
|
107
|
+
const path = input.split(/[?#]/, 1)[0].replace(URL_ORIGIN, "");
|
|
108
|
+
const segment = path.split(/[/\\]/).filter((s) => s.trim() !== "").pop();
|
|
109
|
+
if (!segment) return "";
|
|
110
|
+
const name = decodeEscapes(segment).replace(PAGE_EXTENSION, "");
|
|
111
|
+
const tokens = name.split(/[-_+\s]+/).filter(Boolean);
|
|
112
|
+
return tokens.map((token, i) => labelWord(token, i === 0, i === tokens.length - 1)).join(" ");
|
|
113
|
+
}
|
|
114
|
+
var PLACEHOLDER_TOKEN = /^(?::[A-Za-z_][\w-]*[?*+]?|\[\[\.\.\.[A-Za-z_][\w-]*\]\]|\[(?:\.\.\.)?[A-Za-z_][\w-]*\])$/;
|
|
115
|
+
function isRoutePlaceholder(value) {
|
|
116
|
+
if (typeof value !== "string") return false;
|
|
117
|
+
return value.split("/").some((segment) => PLACEHOLDER_TOKEN.test(segment.trim()));
|
|
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
|
+
}
|
|
150
|
+
|
|
151
|
+
export { isPageAddress, isPageName, isRoutePlaceholder, slugLabel, titleHead };
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { type BroadcastStyleId, type PublicationCardLayout } from './broadcast-layouts';
|
|
2
|
+
import { type PublicationCharter } from './publication-charter';
|
|
3
|
+
export { PUBLICATION_CHARTER_FIELDS, parsePublicationCharter, parseSiteCharters, publicationCharterContext, publicationCharterFor, type PublicationCharter, } from './publication-charter';
|
|
4
|
+
/** The stored profile's version. A breaking change to the shape bumps it. */
|
|
5
|
+
export declare const PUBLICATION_BRANDING_VERSION: 1;
|
|
6
|
+
/** Where a publication appears. `social` overrides every social network; each network can override again. */
|
|
7
|
+
export declare const PUBLICATION_PLATFORMS: readonly ['publication', 'email', 'social', 'instagram', 'facebook', 'linkedin', 'gbp'];
|
|
8
|
+
export type PublicationPlatform = (typeof PUBLICATION_PLATFORMS)[number];
|
|
9
|
+
/** The sections of a profile and of each platform override, in the order they're read. */
|
|
10
|
+
export declare const PUBLICATION_BRANDING_SECTIONS: readonly ['identity', 'colors', 'typography', 'editorial', 'newsletter'];
|
|
11
|
+
export type PublicationBrandingSection = (typeof PUBLICATION_BRANDING_SECTIONS)[number];
|
|
12
|
+
export declare const PUBLICATION_IDENTITY_FIELDS: readonly ['publicationName', 'wordmark', 'tagline', 'publisherName', 'publisherUrl', 'publisherLogoUrl', 'logos'];
|
|
13
|
+
export declare const PUBLICATION_LOGO_VARIANTS: readonly ['default', 'light', 'dark'];
|
|
14
|
+
export declare const PUBLICATION_COLOR_FIELDS: readonly ['primary', 'secondary', 'accent', 'background', 'surface', 'text', 'muted', 'border'];
|
|
15
|
+
export declare const PUBLICATION_TYPOGRAPHY_FIELDS: readonly ['heading', 'body', 'mono', 'emailHeadingFallback', 'emailBodyFallback'];
|
|
16
|
+
export declare const PUBLICATION_EDITORIAL_FIELDS: readonly ['articleStyle', 'cardLayout', 'charter', 'siteCharters'];
|
|
17
|
+
export declare const PUBLICATION_NEWSLETTER_FIELDS: readonly ['name', 'tagline', 'layout', 'maxWidth'];
|
|
18
|
+
/** Fonts every email client has, the only families an email fallback may name. Lowercase. */
|
|
19
|
+
export declare const EMAIL_SAFE_FONTS: ReadonlySet<string>;
|
|
20
|
+
/** CSS generic families: written bare and lowercase in a font stack, never quoted. */
|
|
21
|
+
export declare const GENERIC_FONT_FAMILIES: ReadonlySet<string>;
|
|
22
|
+
export interface PublicationIdentity {
|
|
23
|
+
publicationName?: string;
|
|
24
|
+
wordmark?: string;
|
|
25
|
+
tagline?: string;
|
|
26
|
+
publisherName?: string;
|
|
27
|
+
publisherUrl?: string;
|
|
28
|
+
/** The publisher's attribution mark, separate from the publication masthead. */
|
|
29
|
+
publisherLogoUrl?: string;
|
|
30
|
+
logos?: {
|
|
31
|
+
default?: string;
|
|
32
|
+
light?: string;
|
|
33
|
+
dark?: string;
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export interface PublicationColors {
|
|
37
|
+
primary?: string;
|
|
38
|
+
secondary?: string;
|
|
39
|
+
accent?: string;
|
|
40
|
+
background?: string;
|
|
41
|
+
surface?: string;
|
|
42
|
+
text?: string;
|
|
43
|
+
muted?: string;
|
|
44
|
+
border?: string;
|
|
45
|
+
}
|
|
46
|
+
export interface PublicationTypography {
|
|
47
|
+
heading?: string;
|
|
48
|
+
body?: string;
|
|
49
|
+
mono?: string;
|
|
50
|
+
emailHeadingFallback?: string;
|
|
51
|
+
emailBodyFallback?: string;
|
|
52
|
+
}
|
|
53
|
+
export interface PublicationEditorial {
|
|
54
|
+
charter?: PublicationCharter;
|
|
55
|
+
siteCharters?: Record<string, PublicationCharter>;
|
|
56
|
+
/** Shared Broadcast style for articles, reviews and generated posts; portfolio is excluded. Legacy wire name. */
|
|
57
|
+
articleStyle?: BroadcastStyleId;
|
|
58
|
+
cardLayout?: PublicationCardLayout;
|
|
59
|
+
}
|
|
60
|
+
export interface PublicationNewsletter {
|
|
61
|
+
name?: string;
|
|
62
|
+
tagline?: string;
|
|
63
|
+
layout?: 'editorial';
|
|
64
|
+
maxWidth?: 600 | 640;
|
|
65
|
+
}
|
|
66
|
+
export interface PublicationBrandingSections {
|
|
67
|
+
identity?: PublicationIdentity;
|
|
68
|
+
colors?: PublicationColors;
|
|
69
|
+
typography?: PublicationTypography;
|
|
70
|
+
editorial?: PublicationEditorial;
|
|
71
|
+
newsletter?: PublicationNewsletter;
|
|
72
|
+
}
|
|
73
|
+
/** Exactly the object stored in projects.settings.publication_branding. */
|
|
74
|
+
export interface PublicationBranding extends PublicationBrandingSections {
|
|
75
|
+
version: typeof PUBLICATION_BRANDING_VERSION;
|
|
76
|
+
platforms?: Partial<Record<PublicationPlatform, PublicationBrandingSections>>;
|
|
77
|
+
}
|
|
78
|
+
export type PublicationBrandingProfile = PublicationBranding;
|
|
79
|
+
type LeafPatch<T> = {
|
|
80
|
+
[K in keyof T]?: NonNullable<T[K]> extends object ? LeafPatch<NonNullable<T[K]>> : T[K] | null;
|
|
81
|
+
};
|
|
82
|
+
export type PublicationBrandingSectionsPatch = LeafPatch<PublicationBrandingSections>;
|
|
83
|
+
/** A change: any leaf set to a value, or to null to clear that override. */
|
|
84
|
+
export interface PublicationBrandingPatch extends PublicationBrandingSectionsPatch {
|
|
85
|
+
version?: typeof PUBLICATION_BRANDING_VERSION;
|
|
86
|
+
platforms?: Partial<Record<PublicationPlatform, PublicationBrandingSectionsPatch>>;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* A refusal, naming the field: `publication_branding.colors.primary: use a
|
|
90
|
+
* valid opaque color`. One case throws a plain Error instead: a charter set on
|
|
91
|
+
* a channel override, which belongs to the publication or a site.
|
|
92
|
+
*/
|
|
93
|
+
export declare class PublicationBrandingValidationError extends Error {
|
|
94
|
+
readonly path: string;
|
|
95
|
+
constructor(path: string, reason: string);
|
|
96
|
+
}
|
|
97
|
+
/** True for a control character (U+0000-U+001F, U+007F) anywhere in the text. */
|
|
98
|
+
export declare function containsControlCharacters(value: string): boolean;
|
|
99
|
+
/**
|
|
100
|
+
* A CSS font stack as data, never a stylesheet fragment: quoted family names
|
|
101
|
+
* and lowercase generic families joined by commas ("'Brand Display',serif"),
|
|
102
|
+
* or undefined when the value carries rules, variables, url() or anything
|
|
103
|
+
* that isn't a family name. The theme aliases heading, body and mono drop out.
|
|
104
|
+
*/
|
|
105
|
+
export declare function safeFontStack(value: unknown): string | undefined;
|
|
106
|
+
export interface PublicationBrandingValidatorOptions {
|
|
107
|
+
/**
|
|
108
|
+
* True when a URL's hostname must never be stored as a logo or publisher
|
|
109
|
+
* URL: loopback, private and link-local ranges, `localhost` and the like.
|
|
110
|
+
* Each API passes its own server-side SSRF host guard. It receives
|
|
111
|
+
* `new URL(value).hostname`, so IPv6 keeps its brackets.
|
|
112
|
+
*/
|
|
113
|
+
isBlockedHost: (hostname: string) => boolean;
|
|
114
|
+
}
|
|
115
|
+
export interface PublicationBrandingValidator {
|
|
116
|
+
/** A whole saved profile. Retired card layouts still read. */
|
|
117
|
+
validatePublicationBranding(value: unknown): PublicationBranding;
|
|
118
|
+
/** A change. Leaves may be null (clear that override); a card layout must be one drawn today. */
|
|
119
|
+
validatePublicationBrandingPatch(value: unknown): PublicationBrandingPatch;
|
|
120
|
+
/** The sections of a profile or a platform override, at `path`. */
|
|
121
|
+
validatePublicationBrandingSections(input: unknown, path: string, patch: boolean): PublicationBrandingSections | PublicationBrandingSectionsPatch;
|
|
122
|
+
/**
|
|
123
|
+
* The saved profile with the change applied: a null leaf clears just that
|
|
124
|
+
* override and empty containers are pruned. No profile yet reads as
|
|
125
|
+
* `{ version: 1 }`; a change that sets nothing returns the profile as it was.
|
|
126
|
+
*/
|
|
127
|
+
mergePublicationBrandingPatch(current: unknown, patch: unknown): PublicationBranding;
|
|
128
|
+
/** One text leaf: trimmed, 1 to max characters (0 when empty is allowed), no control characters. */
|
|
129
|
+
text(value: unknown, path: string, max: number, empty?: boolean): string;
|
|
130
|
+
/** One URL leaf: a public HTTPS URL without credentials, normalized. */
|
|
131
|
+
url(value: unknown, path: string): string;
|
|
132
|
+
/** One font leaf, normalized by safeFontStack; email fallbacks may name only EMAIL_SAFE_FONTS. */
|
|
133
|
+
font(value: unknown, path: string, emailSafe?: boolean): string;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Leaf-wise merge of a validated change into a profile layer: a null leaf
|
|
137
|
+
* deletes that key, an object merges recursively, and a container left empty
|
|
138
|
+
* is removed. The inputs are never mutated. The Sonor API layers resolved
|
|
139
|
+
* branding with the same merge.
|
|
140
|
+
*/
|
|
141
|
+
export declare function mergePublicationBrandingLayers(current: Record<string, unknown>, patch: Record<string, unknown>): Record<string, unknown>;
|
|
142
|
+
/** Single source of truth for publication-branding validation, bound to the caller's host guard. */
|
|
143
|
+
export declare function createPublicationBrandingValidator(options: PublicationBrandingValidatorOptions): PublicationBrandingValidator;
|