@sonordev/contracts 2.2.0 → 2.4.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.
@@ -0,0 +1,416 @@
1
+ // src/contracts/error-page.ts
2
+ var ERROR_PAGE_CONTRACT_VERSION = 1;
3
+ var ERROR_PAGE_MAX_WORDS = 120;
4
+ var PREFIXED_MAX_WORDS = 400;
5
+ var SHORT_ONLY_MAX_WORDS = 60;
6
+ var START_STATE_MAX_WORDS = 40;
7
+ var HEAD_CHARS = 300;
8
+ var DESCRIBE_WINDOW = 45;
9
+ var NAMED_ENTITIES = {
10
+ amp: "&",
11
+ quot: '"',
12
+ apos: "'",
13
+ nbsp: " ",
14
+ lt: "<",
15
+ gt: ">",
16
+ rsquo: "'",
17
+ lsquo: "'",
18
+ ldquo: '"',
19
+ ndash: "\u2013",
20
+ mdash: "\u2014",
21
+ hellip: "\u2026"
22
+ };
23
+ function decodeEntities(text) {
24
+ let out = text;
25
+ for (let pass = 0; pass < 2 && out.indexOf("&") !== -1; pass++) {
26
+ out = out.replace(/&(?:#x([0-9a-f]{1,6})|#(\d{1,7})|([a-z]{2,8}));/gi, (whole, hex, dec, name) => {
27
+ if (name) {
28
+ const key = name.toLowerCase();
29
+ return Object.prototype.hasOwnProperty.call(NAMED_ENTITIES, key) ? NAMED_ENTITIES[key] : whole;
30
+ }
31
+ const code = hex ? parseInt(hex, 16) : parseInt(dec, 10);
32
+ return code <= 1114111 ? String.fromCodePoint(code) : whole;
33
+ });
34
+ }
35
+ return out;
36
+ }
37
+ function tidy(text) {
38
+ return decodeEntities(text).replace(/[‘’‛ʼ]/g, "'").replace(/\s+/g, " ").trim();
39
+ }
40
+ function normalize(text) {
41
+ return tidy(text).toLowerCase();
42
+ }
43
+ function textOf(text) {
44
+ const oc = tidy(text);
45
+ const lc = oc.toLowerCase();
46
+ return { lc, oc: lc.length === oc.length ? oc : lc };
47
+ }
48
+ function slice(text, from, to) {
49
+ return { lc: text.lc.slice(from, to), oc: text.oc.slice(from, to) };
50
+ }
51
+ function words(text) {
52
+ return text.split(/\s+/).filter(Boolean).length;
53
+ }
54
+ function wordsAcrossElements(text) {
55
+ return words(text.replace(/([a-z0-9.,;:!?)"'’])([A-Z])/g, "$1 $2"));
56
+ }
57
+ var NOT_A_QUESTION = String.raw`(?!\s*\?|\s+(?:in|on|for|at|with|under|from|during|to)\b[^.!?]{0,50}\?)`;
58
+ var NOT_IN_YOUR_SIZE = String.raw`(?!\s+in\s+your\s+size\b)`;
59
+ var RECORD_NOUN = String.raw`(?:page|project|post|article|product|listing|resource|file|deployment)`;
60
+ var CANT_BE_FOUND = String.raw`\b(?:page|url|link|resource|route|file|deployment|site|website|app|project)\b[^.!?]{0,40}?(?:could not|couldn't|can't|cannot|can not) be found`;
61
+ var WHICH_PAGE = String.raw`(?:\s+(?:you(?:'re| are| were)? (?:looking for|requested|asked for|(?:tried|were trying|are trying) to (?:reach|visit|access|find|open))|that you (?:requested|asked for)|at\s+\S[^.!?]{0,40}|on this (?:site|server|website)))?`;
62
+ var DOESNT_EXIST = String.raw`(?:\b(?:this|that|the|requested)\s+(?:[a-z0-9'-]+\s+){0,2}(?:page|url|link|post|article|listing|product|resource|project|profile|record|entry|story|event|course|job|item|offer|video)|\bpage)${WHICH_PAGE}\s+(?:doesn't|does not)(?: (?:seem|appear))?(?: to)? exist(?!\s+(?:to|for|so)\s)`;
63
+ var MAINTENANCE_QUALIFIER = String.raw`(?!\s+(?:on|from|every|each|this|next|between|during|at|after|before|once|twice|monthly|weekly|nightly|daily|contract|agreement|plan|program|warranty|coverage|with\s+(?:us|our)|for\s+(?:the|a|\d)|until(?!\s+(?:\d{1,2}(?::\d\d)?\s*(?:[ap]\.?m\b|o'clock)|\d{1,2}:\d\d|noon|midnight|tomorrow|further notice)))\b)`;
64
+ var ERROR_PAGE_SIGNATURES = [
65
+ // ---- Not found: the framework defaults and the common custom wordings ----
66
+ new RegExp(RECORD_NOUN + " (?:was |is )?not found" + NOT_A_QUESTION + NOT_IN_YOUR_SIZE),
67
+ // "Can't find the page you need? Use the search box" is a help line; "We can't find that page." isn't.
68
+ /(?:can't|cannot|can not|couldn't|could not|unable to) (?:be )?(?:find|found|locate)(?: that| this| the)? page(?!\s*\?|\s+(?:you|that|which)\b[^.!?]{0,40}\?)/,
69
+ // "We can't seem to find the page", "It seems we can't find what you're looking for".
70
+ /(?:we|i) (?:can't|cannot|couldn't|could not|were unable to|are unable to|weren't able to)(?: seem to)? (?:find|locate) (?:that page|this page|the page|that one|that(?=[.!,?]| link)|what you(?:'re| are| were)? looking for)/,
71
+ // "can not" is two words in the Vercel default.
72
+ new RegExp(CANT_BE_FOUND),
73
+ // Next's own not-found.tsx example: "Not FoundCould not find requested resourceReturn Home".
74
+ /(?:could not|couldn't) find (?:the )?requested resource/,
75
+ // "doesn't exist" only with a page, link or record as the subject: a short
76
+ // marketing page can say "if a tool doesn't exist, we'll build it".
77
+ new RegExp(DOESNT_EXIST),
78
+ /(?:doesn't|does not) exist or (?:has|have) been (?:moved|removed|deleted)/,
79
+ /looking for (?:doesn't|does not) exist/,
80
+ // A page-ish subject, like "doesn't exist" above: a history page can say "the 1890 mill no longer exists".
81
+ new RegExp(String.raw`\b(?:this|that|the)\s+(?:[a-z0-9'-]+\s+){0,2}(?:page|url|link|project|post|article|product|listing|resource|content|item|offer|event|record|story|profile|entry|document|course|job|video)` + WHICH_PAGE + String.raw`\s+no longer exists`),
82
+ /(?:404: ?not[ _]found|code: ?not_found)/,
83
+ // "Error 404" and "404 Error" as a label ("404 Error | Acme") or leading into what a 404 says
84
+ // ("Error 404Page not found"). Not "a custom 404 error page" and not a name ("Error 404 Studio").
85
+ /(?:^|[.!?|:]\s*)(?:http |server )?(?:error 404|404 error)(?=\s*[-–—|:.!?,)]|\s*(?:page|this|that|the|we|oops|whoops|sorry|looks|uh|hmm|nothing|there|it|oh|please)\b)|(?:error 404|404 error)(?=\s*$|\s*[-–—|:]?\s*(?:not found|this (?:page|url)|that page)\b)/,
86
+ // "The page you requested was not found", "The page you were looking for has been removed".
87
+ /page you (?:requested|(?:were|are) looking for|tried to (?:reach|visit|access))[^.!?]{0,60}(?:not found|no longer|(?:has|have|had|might have|may have|may) been|has (?:drifted|wandered|vanished|disappeared|moved|left)|isn't|is not|doesn't|does not|could not|can't|cannot|was not)/,
88
+ // Apache, Flask and Django: "The requested URL was not found on this server."
89
+ /requested (?:url|uri|resource|file|page|document)\b[^.!?]{0,80}(?:not found|not be found|cannot be found)|(?:was|is) not found on (?:this|the) server/,
90
+ // Netlify, Render, Flask and Express answer with the bare words "Not Found".
91
+ /^not found(?=$|\s*[-–—|:.!]|the requested|request id)/,
92
+ // WordPress core and its default themes: "It looks like nothing was found at this location.", "Nothing Found".
93
+ /nothing was found at this location|^nothing found(?=$|[.,:;!?|-]|sorry|it looks|we couldn|nothing|oops|the page)/,
94
+ // Custom 404 copy that never says "404": "Oops! This page took a day off.", "You've hit a dead end.",
95
+ // "This page doesn't live here anymore." The opener needs the page to be the one that's gone ("Oops! This
96
+ // page is a work in progress" is a holding page), and the dead end needs to be a sentence of its own.
97
+ /^(?:oops|whoops|uh[- ]oh)[!,.…]*\s*(?:this|that) page (?:took|went|wandered|got lost|ran away|has (?:gone|wandered|moved|left|vanished|disappeared|been (?:removed|moved|deleted))|is (?:missing|gone|lost)|isn't (?:here|available|there)|(?:doesn't|does not|can't|cannot|couldn't) (?:exist|be found|seem)|seems? (?:to be )?(?:missing|lost|gone))/,
98
+ /(?:^|[.!?]\s*)(?:you've|you have|you) (?:hit|reached) a dead end(?=[.!?…]|$)/,
99
+ /(?:page|link|url)[^.!?]{0,40}(?:doesn't|does not|no longer|isn't|is not) (?:live |exist |work )?here(?: anymore)?(?![a-z]*\s+to\b)/,
100
+ // Gatsby's 404 route: "NOT FOUNDYou just hit a route that doesn't exist... the sadness."
101
+ /hit a route that doesn't exist/,
102
+ // Tomcat and IIS: "HTTP Status 404 – Not Found", "HTTP Error 403.14 - Forbidden", "HTTP Error 503. The service is unavailable."
103
+ /http (?:error|status) [45]\d\d(?:\.\d+)?\s*[–—-]\s*(?:not found|forbidden|unauthori[sz]ed|bad request|internal|method not allowed|service unavailable)|http error [45]\d\d(?:\.\d+)?\. the /,
104
+ // A status number with its reason, anywhere in the head (a nav can precede it):
105
+ // "404 | Not Found", "502 - Bad Gateway", "403: Forbidden", "400Bad Request", "419 | Page Expired".
106
+ // Not "401 unauthorized means your key is missing": a status explained is documentation.
107
+ /(?:^|\D)(?:(?:404|410)\s*[|:–—-]?\s*(?:not found|page not found|file not found|web site not found)|408[\s:|–—-]*request time-?out|50[0-4][\s:|–—-]*(?:internal (?:server )?error|server error|bad gateway|service (?:temporarily )?unavailable|gateway time-?out)|40[13][\s:|–—-]*(?:forbidden|unauthori[sz]ed)|400[\s:|–—-]*bad request|405[\s:|–—-]*method not allowed|413[\s:|–—-]*(?:request entity|payload|content) too large|419[\s:|–—-]*page expired|429[\s:|–—-]*too many)(?!\s+(?:(?:requests?|errors?|responses?|status(?: codes?)?|codes?|messages?|pages?)\s+)?(?:means?|is|are|indicates?|occurs?|happens?|when|if|returns?|tells?|shows?|appears?|can|should|will|occurred)\b)/,
108
+ // Netlify's bare "Not Found - Request ID: ..." and Express's "Cannot GET /about", even with a nav in front.
109
+ /not found\s*[-–—]\s*request id|\bcannot (?:get|post|put|patch|delete) \/[\w\-./?=&%]*(?:$|\s)/,
110
+ // IIS's classic "Server Error 404 - File or directory not found." and its "had its name changed" sentence.
111
+ /file or directory not found|removed,? had its name changed/,
112
+ // This page has been removed / moved: its own sentence, not "removed from our menu".
113
+ /(?:^|[.!?]\s*)this page (?:has been |has |was |is )?(?:permanently )?(?:removed|deleted|moved)(?=[.!,;]|$|\s+(?:or|and)\b|\s*(?:return|go|back|click|please|visit|head)\b)/,
114
+ // ---- Server and client errors ----
115
+ // Not "Application error monitoring for Node teams".
116
+ /^application error(?!\s+(?:monitoring|tracking|reporting|handling|management|detection|analytics|logs?|alerts?|rates?|budgets?|codes?))/,
117
+ /(?:client|server)-side exception has occurred/,
118
+ // The hosts spell a status as a code too: "500: INTERNAL_SERVER_ERROR", "504: GATEWAY_TIMEOUT".
119
+ /internal[ _]server[ _]error/,
120
+ new RegExp(String.raw`(?:bad[ _]gateway|gateway[ _]time-?out)` + NOT_A_QUESTION),
121
+ // An exception's message rendered as the page: "Error: Failed to fetch data", "TypeError: x is undefined".
122
+ /^(?:(?:type|reference|syntax|range)error: |error: (?:failed|cannot|can't|unable|invalid|network|request|fetch|something|unexpected|no |not ))/,
123
+ // "Something went wrong with your insurance claim? ..." is a headline about a claim, not an error state.
124
+ new RegExp(String.raw`something(?: went|'s gone| has gone| had gone) wrong(?!\s+(?:with|during|on|in|at)\s+(?:your|my)\b)` + NOT_A_QUESTION),
125
+ /an (?:unexpected )?error (?:has )?occurred/,
126
+ /this (?:page|site|website) (?:isn't working|can't be reached)/,
127
+ // Next.js 16 error shells: "This page couldn't load. A server error occurred. Reload to try again."
128
+ // and the 403 / 401 pages ("This page could not be accessed.").
129
+ /this page couldn't load|a server error (?:has )?occurred|reload to try again(?=[.,!]|$)|this page could not be accessed/,
130
+ // React Router and Remix, Spring Boot, and the "unexpected error" boundaries.
131
+ /unexpected application error|whitelabel error page|(?:there was|encountered) an unexpected error/,
132
+ // Django and ASP.NET: "Server Error (500)", "Server Error in '/' Application", "Not Found (404)".
133
+ /server error (?:\(500\)|in '?\/)|(?:bad request|forbidden|not found) \((?:400|403|404)\)/,
134
+ // Next's runtime messages when a chunk or a hydration fails and the message is the page.
135
+ /chunkloaderror|loading chunk [\w/.-]+ failed|hydration failed because the|there was an error while hydrating/,
136
+ // Generated error.tsx boundaries: "Oops, we hit a snag.", "Something unexpected happened", "Unable to load page".
137
+ new RegExp(
138
+ String.raw`(?:^|[.!?]\s*)(?:(?:oops|whoops|uh[- ]oh)[!,.…]*\s*)?we hit a snag(?!\s+(?:with|in|on|during|when|while|at)\b)|(?:^|[.!?]\s*)something unexpected happened|(?:^|[.!?]\s*)(?:failed|unable) to load (?:the |this )?(?:page|content|data|resource)` + NOT_A_QUESTION + String.raw`|^error loading (?:the |this )?(?:page|content|data)`
139
+ ),
140
+ // "We're having trouble loading this page", "We couldn't load this page".
141
+ /(?:we're|we are|i'm) having (?:some )?(?:trouble|difficulty|difficulties|problems?) (?:loading|displaying|showing|retrieving) (?:this|that|the) (?:page|content)|we (?:couldn't|could not|can't|cannot) load (?:this|that|the) page/,
142
+ // WordPress core's fatal-error and database screens.
143
+ /there has been a critical error on this (?:website|site)|error establishing a database connection|(?:the|this) (?:site|website) is experiencing technical difficulties/,
144
+ // Apache's stock pages: 503, 400, 401, 408, 410 and 414 each in its own words.
145
+ /the server is temporarily unable to service your request|your browser sent a request that this server could not understand|this server could not verify that you are authorized|server timeout waiting for the http request|(?:was|is) no longer available on this server|request-uri too large/,
146
+ // ---- Unavailable and maintenance ----
147
+ // A qualifier ("in Hawaii", "until April") makes it content about a service, not a dead page.
148
+ /(?:this|the) (?:page|site|website|service) (?:is|are) (?:temporarily|currently) unavailable(?!\s+(?:in|on|for|at|during|to|until|from|outside|after|before|through)\b)/,
149
+ /^(?:service (?:temporarily )?unavailable|(?:temporarily|currently) unavailable(?!:\s))(?!\s+(?:in|on|for|at|during|to|until|from|outside)\b)/,
150
+ // The site or the page is the one under maintenance. Not "is under maintenance contract", not "down for
151
+ // maintenance on Fridays": a day, a time or a contract makes it content.
152
+ new RegExp(String.raw`(?:site|website|page|store|shop|app|portal|server|we're|we are)(?: is| are)?(?: currently| temporarily| now)*,? (?:under|down for) (?:scheduled |routine |some |emergency )?maintenance` + MAINTENANCE_QUALIFIER),
153
+ // "Our website is currently undergoing scheduled maintenance", "Briefly unavailable for scheduled maintenance",
154
+ // "We are offline for a short while". Not "Our rental fleet is undergoing routine maintenance": the subject is the site.
155
+ new RegExp(String.raw`(?:(?:site|website|page|store|shop|app|portal|server|we're|we are)(?: is| are)?(?: currently| temporarily| now)*|briefly|temporarily|currently|now) (?:undergoing|(?:offline|unavailable|down)(?: temporarily)? for) (?:scheduled |routine |some |emergency )?maintenance` + MAINTENANCE_QUALIFIER),
156
+ /(?:we're|we are|site is|website is) offline for an? (?:short|little|brief) (?:while|time|period)/,
157
+ /^be right back\.?$/,
158
+ // "We'll be back soon." ending its sentence, or leading into the next one. Not "We should be back soon with a replacement
159
+ // part", "back soon after our holiday break": what follows decides.
160
+ /(?:we'll|we will|we should|it should) (?:be )?(?:right )?back (?:online )?(?:soon|shortly)(?=[.!;]|$|,?\s*(?:our|the|this|thank|thanks|please|we)\b|\s+(?:once|after)\s+(?:a|the|our)\s+(?:(?:short|brief|quick|scheduled|planned)\s+)*(?:maintenance|upgrade|update|outage|downtime|migration|work)\b)/,
161
+ // An apology for the downtime makes what follows an outage notice, whatever it promises.
162
+ /sorry for the (?:downtime|inconvenience|interruption|outage)\W+(?:we'll|we will|we should|we are working)/,
163
+ // ---- Walls that stand in for the page ----
164
+ new RegExp(String.raw`(?:403 forbidden|access denied)(?!\s+(?:is|was|means|errors?|messages?|pages?)\b)` + NOT_A_QUESTION),
165
+ // A permission wall is addressed to the visitor and about THIS page or resource, and opens the text or follows its own
166
+ // heading ("Sorry, you are not allowed to access this page.", "ForbiddenYou are not authorized to access this resource.").
167
+ // Not a person or a file ("our team is not allowed to access this page of your file"), not a gallery ("you don't have
168
+ // permission to view the gallery"), and not a sentence further in ("Members only. You do not have permission to ...").
169
+ /(?:^|sorry|oops|error|forbidden|unauthori[sz]ed|access denied|40[13])\W*you(?:'re| are| aren't) (?:not |un)?(?:allowed|authori[sz]ed|permitted) to (?:access|view|see) (?:this|that) (?:page|resource|site|website|area|section|content)/,
170
+ /(?:^|sorry|oops|error|forbidden|unauthori[sz]ed|access denied|40[13])\W*you (?:don't|do not) have permission to (?:access|view) (?:this (?:page|resource|site|website|area|section|content)|\S+ on this server)/,
171
+ /^(?:forbidden|unauthori[sz]ed)(?=$|\s*[-–—|:.!]|you(?:'re| are)\b|please\b|this (?:page|resource))/,
172
+ /(?:checking your browser before accessing|enable javascript and cookies to continue(?!\s+\w)|verif(?:y|ying) you are (?:a )?human)/
173
+ ];
174
+ var LEADING_STATUS = /^(?:(?:http )?error:? ?)?(?:400|401|402|403|405|408|410|413|414|419|429|500|501|502|503|504)\W{0,3}(?:(?:service|server|temporarily|internal|gateway|bad|proxy|request|too|access|page|file|resource|connection|http|status|method|payload|entity|payment|authentication)\s+){0,3}(?:error|forbidden|unauthori[sz]ed|gone|unavailable|bad gateway|time-?out|timed out|not found|not allowed|too many|too large|bad request|page expired|payment required|not implemented)/;
175
+ var LEADING_404 = /^404(?![\d,])(?![\s.\-–—)]*\d)/;
176
+ var ADDRESS_AFTER_404 = /^404\s+(?:[a-z0-9.'-]+\s+){0,3}(?:street|st|avenue|ave|road|rd|boulevard|blvd|drive|dr|lane|ln|way|court|ct|highway|hwy|pike|parkway|pkwy|place|pl|suite|ste|unit|apt)\b/;
177
+ var SHORT_ONLY_SIGNATURES = [
178
+ // Opening the text, and not conditional: "Please refresh the page and try again." is the boundary's own line;
179
+ // "Clear your cache, then refresh the page and try again" is a tip, and "...try again if the calendar doesn't load" is one.
180
+ /^(?:please )?(?:refresh|reload)(?: the| this)? (?:page )?(?:and|or) try again(?!\s+(?:if|when|unless|whether|once|after|later|next|the next)\b)/
181
+ ];
182
+ var SHORT_START_SIGNATURES = [new RegExp(String.raw`^(?:[a-z0-9'&-]+\s+){0,2}[a-z0-9'&-]+ not found` + NOT_A_QUESTION + NOT_IN_YOUR_SIZE)];
183
+ var GLUED_404 = /(?:^|[\s|])404(?=[a-z])(?!th\b)/g;
184
+ var DELIMITED_404 = /(?:^|\s)404\s*[|:–—]\s*[a-z]/g;
185
+ var NUMBER_PREFIX = /(?:suite|ste|room|rm|unit|apt|apartment|route|rt|hwy|highway|building|bldg|floor|#)\.?\s*$/;
186
+ var ABOUT_THE_ERROR = String.raw`(?!\W*(?:errors?\b|explained|guide|what (?:it|this|that|is|are)|how to|how do|why|means|fix(?:es|ing)?\b|causes|vs\b|\?))`;
187
+ var PREFIX_SIGNATURES = [
188
+ new RegExp(String.raw`(?:page|resource|post|article|product|listing) (?:was |is )?not found`),
189
+ new RegExp(CANT_BE_FOUND),
190
+ /(?:we|i) (?:can't|cannot|couldn't|could not)(?: seem to)? (?:find|locate) (?:that page|this page|the page|what you(?:'re| are| were)? looking for)/,
191
+ new RegExp(DOESNT_EXIST),
192
+ /something went wrong|(?:client|server)-side exception|internal server error|this page couldn't load/
193
+ ].map((pattern) => new RegExp("(?:" + pattern.source + ")" + ABOUT_THE_ERROR));
194
+ var PREFIXED_LEADING_404 = new RegExp(LEADING_404.source + ABOUT_THE_ERROR);
195
+ var PREFIXED_LEADING_STATUS = new RegExp(LEADING_STATUS.source + ABOUT_THE_ERROR);
196
+ var PREFIX_SEPARATOR = " \u2014 ";
197
+ var PREFIX_MIN_CHARS = 60;
198
+ var PREFIX_MAX_CHARS = 400;
199
+ var SKIP_LINK = /^(?:skip to (?:main )?content|skip to main|skip navigation)\s*/;
200
+ var PREFIX_WINDOW_CHARS = 90;
201
+ var DESCRIBERS = String.raw`if|when|whether|unless|how to|how do|why|what is|what's|fix(?:ing|es)?|handle|handling|resolve|resolving|troubleshoot(?:ing)?|diagnos(?:e|ing)|watch(?:es|ing)? for|encounter(?:ing)?|history of`;
202
+ var LOWERCASE_DESCRIBERS = String.raw`about|monitor(?:ing)?|alert(?:s|ing)? (?:on|for)`;
203
+ var DESCRIBING_CONTEXT = new RegExp(
204
+ String.raw`(?:\b(?:(${DESCRIBERS})|(${LOWERCASE_DESCRIBERS}))(?:\s+(?:you|your|the|a|an|their|our|my|see|get|are|is|seeing|getting|receive|receiving|hit|to|every|any|each|all|\d{3})){0,3}\s*["'“]?|["“])$`
205
+ );
206
+ var INNER_DESCRIBER = /\b(?:if|when|whether|unless|how to)\b/;
207
+ var SENTENCE_OPENING = /[.!?:;|—–"“(]\s*$/;
208
+ var VISIBILITY_SUBJECT = /\b(?:you|your|we|our|they|their|customers?|clients?|people|visitors?|buyers?|patients?|business(?:es)?|brands?|compan(?:y|ies))\s+$/;
209
+ var FORM_FIELDS = /\b(?:name|e-?mail|phone|message|subscribe|submit)\b/;
210
+ function globalOf(patterns) {
211
+ return patterns.map((pattern) => new RegExp(pattern.source, "g"));
212
+ }
213
+ var SIGNATURES = globalOf(ERROR_PAGE_SIGNATURES);
214
+ var SHORT_ONLY_SIGS = globalOf(SHORT_ONLY_SIGNATURES);
215
+ var SHORT_START_SIGS = globalOf(SHORT_START_SIGNATURES);
216
+ var PREFIX_SIGS = globalOf(PREFIX_SIGNATURES);
217
+ var TITLE_VERB = /\b(?:fix|handle|resolve|troubleshoot|diagnose)\b/;
218
+ function describerIsProse(orig, start, describer, context) {
219
+ const word = orig.slice(start, start + describer.length);
220
+ if (word === describer || word === describer.charAt(0).toUpperCase() + describer.slice(1)) return true;
221
+ return start === 0 || SENTENCE_OPENING.test(orig.slice(Math.max(0, start - 3), start)) || TITLE_VERB.test(context);
222
+ }
223
+ function isDescribed(head, hit) {
224
+ const from = Math.max(0, hit.index - DESCRIBE_WINDOW);
225
+ const before = head.lc.slice(from, hit.index);
226
+ const context = DESCRIBING_CONTEXT.exec(before);
227
+ if (context) {
228
+ const [, describer, lowercaseDescriber] = context;
229
+ const at = from + context.index;
230
+ if (describer !== void 0) {
231
+ if (describerIsProse(head.oc, at, describer, context[0])) return true;
232
+ } else if (lowercaseDescriber !== void 0) {
233
+ if (head.oc.slice(at, at + lowercaseDescriber.length) === lowercaseDescriber) return true;
234
+ } else {
235
+ return true;
236
+ }
237
+ }
238
+ const inner = INNER_DESCRIBER.exec(hit[0]);
239
+ if (inner && head.oc.slice(hit.index + inner.index, hit.index + inner.index + inner[0].length) === inner[0]) return true;
240
+ return /be found$/.test(hit[0]) && VISIBILITY_SUBJECT.test(before) || /you are (?:a )?human$/.test(hit[0]) && FORM_FIELDS.test(head.lc);
241
+ }
242
+ function showsSignature(head, patterns, inFirstSentence = false) {
243
+ return patterns.some((pattern) => {
244
+ pattern.lastIndex = 0;
245
+ for (let hit = pattern.exec(head.lc); hit; hit = pattern.exec(head.lc)) {
246
+ if (inFirstSentence && /[.!?]/.test(head.lc.slice(0, hit.index))) return false;
247
+ if (!isDescribed(head, hit)) return true;
248
+ pattern.lastIndex = hit.index + 1;
249
+ }
250
+ return false;
251
+ });
252
+ }
253
+ function showsLoose404(head) {
254
+ for (const pattern of [GLUED_404, DELIMITED_404]) {
255
+ pattern.lastIndex = 0;
256
+ for (let hit = pattern.exec(head); hit; hit = pattern.exec(head)) {
257
+ if (!NUMBER_PREFIX.test(head.slice(Math.max(0, hit.index - 14), hit.index))) return true;
258
+ }
259
+ }
260
+ return false;
261
+ }
262
+ function afterPrefix(text) {
263
+ let start = 0;
264
+ const cut = text.lc.indexOf(PREFIX_SEPARATOR);
265
+ if (cut >= PREFIX_MIN_CHARS && cut < PREFIX_MAX_CHARS) start = cut + PREFIX_SEPARATOR.length;
266
+ const skip = SKIP_LINK.exec(text.lc.slice(start));
267
+ if (skip) start += skip[0].length;
268
+ return start === 0 ? text : slice(text, start);
269
+ }
270
+ function opensWith404(head) {
271
+ return LEADING_404.test(head) && !ADDRESS_AFTER_404.test(head);
272
+ }
273
+ function showsErrorState(head, count) {
274
+ return opensWith404(head.lc) || LEADING_STATUS.test(head.lc) || showsSignature(head, SIGNATURES) || count <= START_STATE_MAX_WORDS && showsSignature(head, SHORT_START_SIGS) || count <= SHORT_ONLY_MAX_WORDS && (showsSignature(head, SHORT_ONLY_SIGS) || showsLoose404(head.lc));
275
+ }
276
+ function looksLikeErrorPage(text, wordCount) {
277
+ if (!text || typeof text !== "string") return false;
278
+ const trimmed = text.trim();
279
+ if (!trimmed) return false;
280
+ const own = wordsAcrossElements(trimmed);
281
+ const count = typeof wordCount === "number" && wordCount >= 0 ? Math.max(wordCount, own) : own;
282
+ if (count > PREFIXED_MAX_WORDS) return false;
283
+ const whole = textOf(trimmed);
284
+ const rest = afterPrefix(whole);
285
+ if (own > ERROR_PAGE_MAX_WORDS) {
286
+ const lead = rest.lc.slice(0, HEAD_CHARS);
287
+ return PREFIXED_LEADING_404.test(lead) && !ADDRESS_AFTER_404.test(lead) || PREFIXED_LEADING_STATUS.test(lead) || showsSignature(slice(rest, 0, PREFIX_WINDOW_CHARS), PREFIX_SIGS, true);
288
+ }
289
+ if (count > ERROR_PAGE_MAX_WORDS) return false;
290
+ return showsErrorState(slice(whole, 0, HEAD_CHARS), count) || // A long description pushes the page's own words past the first 300 characters.
291
+ rest !== whole && showsErrorState(slice(rest, 0, HEAD_CHARS), words(rest.lc));
292
+ }
293
+ function usablePageText(text, wordCount) {
294
+ if (!text || typeof text !== "string" || !text.trim()) return null;
295
+ return looksLikeErrorPage(text, wordCount) ? null : text;
296
+ }
297
+ var QUALIFIED = String.raw`(?!\s+(?:in|on|during|until|outside|after|before|from|when|while|if|with|to|through|between|over|due|at(?!\s+(?:this|the)\s+(?:time|moment))|for(?!\s+(?:this|the|that)\s+(?:[\w'’&-]+\s+){0,3}?(?:page|url|link|listing|post|article|project|menu|item|resource|insight|study|portfolio)))\b)`;
298
+ var QUALIFIED_LIGHT = String.raw`(?!\s+(?:when|while|if|until|after|before|during|on|in|from|to|behind|outside|within)\b)`;
299
+ var THE_PAGE = String.raw`\b(?:this|the|that)\s+(?:[\w'’&.-]+\s+){0,4}?(?:page|url|link|route|listing|post|article|project|product|resource|insight|study|portfolio|document)`;
300
+ var ERROR_CLAIMS = [
301
+ // "Page Not Found", "Project Not Found", and the hyphenated compound a model coins:
302
+ // "a page-not-found message". Not "Page not found? Use our site map": a question is a help line.
303
+ new RegExp(String.raw`\b(?:page|project|post|article|listing|product)[\s-]+not[\s-]+found\b(?!\s*\?)`, "i"),
304
+ // "the pricing page is currently unavailable", "this URL is unavailable", "the page is currently down",
305
+ // "This page is no longer available", "This URL is not found"
306
+ new RegExp(
307
+ String.raw`\b(?:web\s?page|page|url|link|route)(?:\s+(?:from|at|on|for|of)\s+[\w'’&.-]+(?:\s+[\w'’&.-]+){0,4})?\s+(?:is|was|are|were|has\s+been|appears\s+to\s+be|seems\s+to\s+be|remains)\s+(?:(?:currently\s+|temporarily\s+|now\s+)?(?:unavailable|not\s+available|missing|offline|broken|gone)\b` + QUALIFIED + String.raw`|(?:currently\s+|temporarily\s+|now\s+)?(?:down|inaccessible|not\s+accessible|unreachable|not\s+found)\b` + QUALIFIED_LIGHT + String.raw`|no\s+longer\s+(?:available|accessible|reachable|live|online)\b` + QUALIFIED_LIGHT + String.raw`)`,
308
+ "i"
309
+ ),
310
+ // "Page Unavailable", "Page Currently Unavailable"
311
+ // "offline" only as a state ("the page is offline"): "save any page offline" is a feature.
312
+ new RegExp(String.raw`\bpage\s+(?:(?:is|was)\s+)?(?:currently\s+|temporarily\s+|now\s+)?(?:unavailable|not\s+available)\b` + QUALIFIED + String.raw`|\bpage\s+(?:is|was)\s+(?:currently\s+|temporarily\s+|now\s+)?offline\b`, "i"),
313
+ // "an unavailable-page presentation": the hyphenated compound again.
314
+ /\bunavailable-page\b/i,
315
+ // "could not be found" (or located, reached, loaded) with a page, URL or link as the subject
316
+ // The subject is THIS page ("this/the/that ... page"): "why your business page can't be found on Google" is an SEO headline.
317
+ /\b(?:this|the|that)\s+(?:[\w'’&.-]+\s+){0,6}?(?:page|url|link|route)\b[^.|;:]{0,60}?\b(?:could\s+not|couldn(?:'|’)t|can(?:'|’)t|cannot)\s+be\s+(?:found|located|reached|loaded|displayed|shown)\b/i,
318
+ // "currently returns a 404", "shows a not-found page", "displays a 404 page", "returns an HTTP 404 status"
319
+ /\b(?:return(?:s|ed|ing)?|show(?:s|ed|ing)?|display(?:s|ed|ing)?|serv(?:e|es|ed|ing)|lands?\s+on)\s+(?:that\s+)?(?:an?\s+|the\s+)?(?:http\s+)?(?:404|not[- ]found|error\s+page)/i,
320
+ /\b404[\s-]page\b/i,
321
+ // "details are unavailable", "menu information is unavailable"
322
+ new RegExp(String.raw`\b(?:details|information|info|content|menu|page)\s+(?:is|are)\s+(?:currently\s+|temporarily\s+)?unavailable\b` + QUALIFIED, "i"),
323
+ // A sentence that opens with the page as its subject: "This insight is unavailable."
324
+ // ...and the sentence's subject has to BE a page-like thing: "The patio is not available on Fridays" is about a patio.
325
+ new RegExp(String.raw`(?:^|[.!?]\s+)(?:this|the)\s+(?:[^.!?|\s][^.!?|]{0,69}?\s+)?(?:page|url|link|route|insight|article|post|project|study|resource|portfolio|guide|document)(?:\s+(?:you|that|which)\s+[^.!?|]{0,40}?)?\s+is\s+(?:currently\s+|temporarily\s+)?(?:unavailable|not\s+available)\b` + QUALIFIED, "i"),
326
+ // JSON-LD labels: "Unavailable Harbor Buffet page". A label, not "block unavailable dates on your booking page".
327
+ /(?:^|[|:.!?–—-]\s*)unavailable\s+(?:[\w'’&-]+\s+){0,6}?(?:page|url|menu|insight|article|project)\b/i,
328
+ // "This page doesn't exist.", "This page does not currently exist.", "No page exists at this address."
329
+ new RegExp(THE_PAGE + String.raw`\s+(?:(?:does\s+not|doesn['’]t)\s+(?:currently\s+|seem\s+to\s+|appear\s+to\s+)?exist|no\s+longer\s+exists?)\b(?!\s+(?:for|so)\s)` + QUALIFIED_LIGHT, "i"),
330
+ /\bno\s+(?:page|content|resource|listing)\s+exists?\s+(?:at|for|on|here)\b/i,
331
+ // "This page has been removed or moved.", "This listing has been removed." Moved to somewhere ("moved to our new site") is a redirect note.
332
+ new RegExp(THE_PAGE + String.raw`\s+(?:has|have|had)\s+(?:since\s+)?been\s+(?:permanently\s+)?(?:removed|moved|deleted|taken\s+down)\b` + QUALIFIED, "i"),
333
+ // "The page you're looking for isn't available.", "The page you requested has been removed."
334
+ new RegExp(
335
+ String.raw`\bpage\s+you(?:['’]re|\s+are|\s+were)?\s+(?:looking\s+for|requested|tried\s+to\s+(?:reach|visit|access))\s+(?:(?:is(?:n['’]t|\s+not)|was(?:n['’]t|\s+not))\s+(?:available|found|here|there|accessible)\b` + QUALIFIED + String.raw`|(?:has|have|might\s+have|may\s+have)\s+been\s+(?:removed|moved|deleted|renamed)\b|(?:doesn['’]t|does\s+not)\s+exist\b|no\s+longer\s+(?:exists?|available)\b)`,
336
+ "i"
337
+ ),
338
+ // "The page failed to load." (past tense: "if a page fails to load, refresh" is advice).
339
+ /\b(?:page|url|link|route)\s+(?:has\s+)?failed\s+to\s+load\b/i,
340
+ // "This URL contains only an error message.", "The page returned an error message instead of service details."
341
+ // Not "The checkout page shows an error message when payment fails."
342
+ new RegExp(String.raw`\b(?:page|url|link|route)\s+(?:currently\s+)?(?:(?:returned|returns?|displays?|displayed|shows?|showed|serves?|served)\s+(?:only\s+)?|(?:contains?|contained)\s+only\s+)(?:an?\s+|the\s+)?(?:error\s+(?:message|page)|http\s+error|not[- ]found\s+(?:message|error|page))\b(?=\s*(?:[.!?;:,|]|$)|\s+(?:instead|rather|in\s+place|and|but|only|with\s+no|with\s+nothing)\b)`, "i"),
343
+ // "404 Error | Acme Mowing", "Error 404: Acme Mowing", "HTTP 404", "404 Not Found | Acme": a label opening the copy or a segment of it.
344
+ /(?:^|[|:.!?–—-]\s*)(?:http\s+)?(?:error\s+404|404\s+error|http\s+404)\s*(?:[|:–—.!?-]|$)|\b404[\s:|–—-]*not\s+found\b/i,
345
+ // "This page isn't available.", "This page wasn't found." (the contracted forms of the above)
346
+ new RegExp(String.raw`\b(?:web\s?page|page|url|link|route)\s+(?:is|was)(?:n['’]t)\s+(?:currently\s+|now\s+)?(?:available|found|accessible|here|live)\b` + QUALIFIED_LIGHT, "i"),
347
+ // "We couldn't find this page on the Acme website.", "Sorry, we can't find that page.", "We could not locate the page you requested."
348
+ // Not "Can't find the page you need? Use search": a question in the same sentence is a help line.
349
+ /\b(?:we|i)\s+(?:could\s+not|couldn['’]t|can['’]t|cannot|(?:were|was|are|am)\s+unable\s+to|weren['’]t\s+able\s+to)\s+(?:seem\s+to\s+)?(?:find|locate)\s+(?:this|that|the)\s+(?:requested\s+)?(?:web\s?page|page|url|link)\b(?![^.!?]*\?)/i,
350
+ // "Unable to find the requested page."
351
+ /\bunable\s+to\s+(?:find|locate)\s+(?:this|that|the)\s+requested\s+(?:web\s?page|page|url|link|resource)\b/i,
352
+ // "The requested URL was not found on this server."
353
+ /\brequested\s+(?:url|uri|resource|page)\b[^.!?]{0,60}?\b(?:was\s+not|wasn['’]t|could\s+not\s+be|cannot\s+be)\s+found\b/i,
354
+ // "Page missing", "Error loading page": labels (a segment or the whole copy), not "pages missing from the index".
355
+ /(?:^|[|:.!?–—-]\s*)(?:page\s+(?:is\s+)?missing|error\s+loading\s+(?:the\s+)?page|not\s+found)\s*(?:[|:–—.!?-]|$)/i
356
+ ];
357
+ function ownTextOf(pageText) {
358
+ return pageText && typeof pageText === "string" && !looksLikeErrorPage(pageText) ? normalize(pageText) : "";
359
+ }
360
+ var CLAIM_MAX_CHARS = 5e3;
361
+ function claimIn(copy, own) {
362
+ const text = copy.replace(/\s+/g, " ").trim().slice(0, CLAIM_MAX_CHARS);
363
+ if (!text) return null;
364
+ for (const claim of ERROR_CLAIMS) {
365
+ const hit = text.match(claim);
366
+ if (!hit) continue;
367
+ const phrase = hit[0].replace(/^[.!?|:–—-]\s*/, "").trim();
368
+ if (own && own.includes(normalize(phrase))) continue;
369
+ return phrase;
370
+ }
371
+ return null;
372
+ }
373
+ function findErrorPageClaim(copy, pageText) {
374
+ if (!copy || typeof copy !== "string") return null;
375
+ return claimIn(copy, ownTextOf(pageText));
376
+ }
377
+ function describesErrorPage(copy, pageText) {
378
+ return findErrorPageClaim(copy, pageText) !== null;
379
+ }
380
+ var COPY_MAX_DEPTH = 8;
381
+ var COPY_MAX_LEAVES = 2e3;
382
+ var COPY_MAX_TOTAL_CHARS = 1e5;
383
+ function findPageCopyClaim(fields, pageText) {
384
+ if (!fields || typeof fields !== "object") return null;
385
+ const own = ownTextOf(pageText);
386
+ let leaves = 0;
387
+ let containers = 0;
388
+ let chars = 0;
389
+ const spent = () => leaves >= COPY_MAX_LEAVES || containers > COPY_MAX_LEAVES || chars > COPY_MAX_TOTAL_CHARS;
390
+ const walk = (value, level) => {
391
+ if (typeof value === "string") {
392
+ leaves++;
393
+ chars += Math.min(value.length, CLAIM_MAX_CHARS);
394
+ return spent() && chars > COPY_MAX_TOTAL_CHARS ? null : claimIn(value, own);
395
+ }
396
+ if (!value || typeof value !== "object") return null;
397
+ if (level >= COPY_MAX_DEPTH || ++containers > COPY_MAX_LEAVES) return null;
398
+ const record2 = value;
399
+ for (const key in record2) {
400
+ if (spent()) break;
401
+ if (!Object.prototype.hasOwnProperty.call(record2, key)) continue;
402
+ const phrase = walk(record2[key], level + 1);
403
+ if (phrase) return phrase;
404
+ }
405
+ return null;
406
+ };
407
+ const record = fields;
408
+ for (const key of Object.keys(record)) {
409
+ if (spent()) break;
410
+ const phrase = walk(record[key], 1);
411
+ if (phrase) return { field: key, phrase };
412
+ }
413
+ return null;
414
+ }
415
+
416
+ export { ERROR_PAGE_CONTRACT_VERSION, ERROR_PAGE_MAX_WORDS, describesErrorPage, findErrorPageClaim, findPageCopyClaim, looksLikeErrorPage, usablePageText };
package/dist/llms.d.ts CHANGED
@@ -28,6 +28,7 @@ export interface LLMsPayloadMeta {
28
28
  /** Optional single-line, public-safe note in the llms.txt blockquote (e.g. scope / not legal advice). */
29
29
  llms_disclaimer?: string | null;
30
30
  }
31
+ export declare function clipAtWordBoundary(text: string, max: number): string;
31
32
  /**
32
33
  * Public-safe summary for llms.txt `[title](url): notes` — never CRM-only or internal copy.
33
34
  */
package/dist/llms.js CHANGED
@@ -18,14 +18,64 @@ var MANAGED_LLM_SCHEMA_KNOWN_KEYS = /* @__PURE__ */ new Set([
18
18
  "speakable",
19
19
  "additionalType"
20
20
  ]);
21
+ var SENTENCE_SHARE = 0.4;
22
+ var ELLIPSIS = "\u2026";
23
+ var CLOSERS = /* @__PURE__ */ new Set(['"', "'", ")", "]", "\u201D", "\u2019", "\xBB"]);
24
+ var ABBREVIATIONS = /* @__PURE__ */ new Set([
25
+ "mr",
26
+ "mrs",
27
+ "ms",
28
+ "dr",
29
+ "prof",
30
+ "st",
31
+ "mt",
32
+ "ft",
33
+ "jr",
34
+ "sr",
35
+ "inc",
36
+ "ltd",
37
+ "co",
38
+ "corp",
39
+ "vs",
40
+ "no"
41
+ ]);
42
+ var TRAILING_SEPARATORS = /[\s.,;:\-\u2013\u2014/&([{]+$/;
43
+ function clipAtWordBoundary(text, max) {
44
+ const flat = text.replace(/\s+/g, " ").trim();
45
+ if (flat.length <= max) return flat;
46
+ if (max < 2) return flat.slice(0, Math.max(0, max));
47
+ const floor = Math.floor(max * (1 - SENTENCE_SHARE));
48
+ for (let end = max; end > floor; end--) {
49
+ if (endsSentence(flat, end)) return flat.slice(0, end);
50
+ }
51
+ const room = max - 1;
52
+ const space = flat.lastIndexOf(" ", room);
53
+ if (space <= 0) return `${flat.slice(0, room)}${ELLIPSIS}`;
54
+ if (endsSentence(flat, space)) return flat.slice(0, space);
55
+ const head = flat.slice(0, space).replace(TRAILING_SEPARATORS, "");
56
+ return `${head || flat.slice(0, room)}${ELLIPSIS}`;
57
+ }
58
+ function endsSentence(flat, end) {
59
+ if (flat[end] !== " ") return false;
60
+ let i = end - 1;
61
+ while (i > 0 && CLOSERS.has(flat[i])) i--;
62
+ const terminator = flat[i];
63
+ if (terminator === "!" || terminator === "?") return true;
64
+ return terminator === "." && !isAbbreviation(flat, i);
65
+ }
66
+ function isAbbreviation(flat, dot) {
67
+ let start = dot;
68
+ while (start > 0 && flat[start - 1] !== " ") start--;
69
+ const token = flat.slice(start, dot).replace(/^[("'[\u201c\u2018]+/, "").toLowerCase();
70
+ if (token.includes(".") || /^[a-z]$/.test(token)) return true;
71
+ return ABBREVIATIONS.has(token);
72
+ }
21
73
  function sanitizeLlmsPublicSummary(raw) {
22
74
  if (raw === null || raw === void 0) return null;
23
75
  if (typeof raw !== "string") return null;
24
76
  let s = raw.trim().replace(/\s+/g, " ");
25
77
  s = s.replace(/[\[\]\r\n]/g, " ").replace(/\s+/g, " ").trim();
26
- if (s.length > LLMS_PUBLIC_SUMMARY_MAX_LENGTH) {
27
- s = `${s.slice(0, LLMS_PUBLIC_SUMMARY_MAX_LENGTH - 1).trimEnd()}\u2026`;
28
- }
78
+ s = clipAtWordBoundary(s, LLMS_PUBLIC_SUMMARY_MAX_LENGTH);
29
79
  if (/\b[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}\b/.test(s)) return null;
30
80
  if (/\b\+?\d[\d\s().-]{8,}\b/.test(s)) return null;
31
81
  if (s.length < 8) return null;
@@ -36,9 +86,7 @@ function sanitizeLlmsDisclaimerLine(raw) {
36
86
  if (typeof raw !== "string") return null;
37
87
  let s = raw.trim().replace(/\s+/g, " ");
38
88
  s = s.replace(/[\[\]\r\n]/g, " ").replace(/\s+/g, " ").trim();
39
- if (s.length > LLMS_DISCLAIMER_MAX_LENGTH) {
40
- s = `${s.slice(0, LLMS_DISCLAIMER_MAX_LENGTH - 1).trimEnd()}\u2026`;
41
- }
89
+ s = clipAtWordBoundary(s, LLMS_DISCLAIMER_MAX_LENGTH);
42
90
  if (/\b[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}\b/.test(s)) return null;
43
91
  if (/\b\+?\d[\d\s().-]{8,}\b/.test(s)) return null;
44
92
  if (s.length < 3) return null;
@@ -100,6 +148,7 @@ exports.LLMS_DISCLAIMER_MAX_LENGTH = LLMS_DISCLAIMER_MAX_LENGTH;
100
148
  exports.LLMS_PUBLIC_SUMMARY_MAX_LENGTH = LLMS_PUBLIC_SUMMARY_MAX_LENGTH;
101
149
  exports.LLM_GEO_CONTRACT_VERSION = LLM_GEO_CONTRACT_VERSION;
102
150
  exports.MANAGED_LLM_SCHEMA_KNOWN_KEYS = MANAGED_LLM_SCHEMA_KNOWN_KEYS;
151
+ exports.clipAtWordBoundary = clipAtWordBoundary;
103
152
  exports.isEmittableReviewNode = isEmittableReviewNode;
104
153
  exports.pickManagedLlmSchemaForJsonLd = pickManagedLlmSchemaForJsonLd;
105
154
  exports.sanitizeLlmsDisclaimerLine = sanitizeLlmsDisclaimerLine;
package/dist/llms.mjs CHANGED
@@ -16,14 +16,64 @@ var MANAGED_LLM_SCHEMA_KNOWN_KEYS = /* @__PURE__ */ new Set([
16
16
  "speakable",
17
17
  "additionalType"
18
18
  ]);
19
+ var SENTENCE_SHARE = 0.4;
20
+ var ELLIPSIS = "\u2026";
21
+ var CLOSERS = /* @__PURE__ */ new Set(['"', "'", ")", "]", "\u201D", "\u2019", "\xBB"]);
22
+ var ABBREVIATIONS = /* @__PURE__ */ new Set([
23
+ "mr",
24
+ "mrs",
25
+ "ms",
26
+ "dr",
27
+ "prof",
28
+ "st",
29
+ "mt",
30
+ "ft",
31
+ "jr",
32
+ "sr",
33
+ "inc",
34
+ "ltd",
35
+ "co",
36
+ "corp",
37
+ "vs",
38
+ "no"
39
+ ]);
40
+ var TRAILING_SEPARATORS = /[\s.,;:\-\u2013\u2014/&([{]+$/;
41
+ function clipAtWordBoundary(text, max) {
42
+ const flat = text.replace(/\s+/g, " ").trim();
43
+ if (flat.length <= max) return flat;
44
+ if (max < 2) return flat.slice(0, Math.max(0, max));
45
+ const floor = Math.floor(max * (1 - SENTENCE_SHARE));
46
+ for (let end = max; end > floor; end--) {
47
+ if (endsSentence(flat, end)) return flat.slice(0, end);
48
+ }
49
+ const room = max - 1;
50
+ const space = flat.lastIndexOf(" ", room);
51
+ if (space <= 0) return `${flat.slice(0, room)}${ELLIPSIS}`;
52
+ if (endsSentence(flat, space)) return flat.slice(0, space);
53
+ const head = flat.slice(0, space).replace(TRAILING_SEPARATORS, "");
54
+ return `${head || flat.slice(0, room)}${ELLIPSIS}`;
55
+ }
56
+ function endsSentence(flat, end) {
57
+ if (flat[end] !== " ") return false;
58
+ let i = end - 1;
59
+ while (i > 0 && CLOSERS.has(flat[i])) i--;
60
+ const terminator = flat[i];
61
+ if (terminator === "!" || terminator === "?") return true;
62
+ return terminator === "." && !isAbbreviation(flat, i);
63
+ }
64
+ function isAbbreviation(flat, dot) {
65
+ let start = dot;
66
+ while (start > 0 && flat[start - 1] !== " ") start--;
67
+ const token = flat.slice(start, dot).replace(/^[("'[\u201c\u2018]+/, "").toLowerCase();
68
+ if (token.includes(".") || /^[a-z]$/.test(token)) return true;
69
+ return ABBREVIATIONS.has(token);
70
+ }
19
71
  function sanitizeLlmsPublicSummary(raw) {
20
72
  if (raw === null || raw === void 0) return null;
21
73
  if (typeof raw !== "string") return null;
22
74
  let s = raw.trim().replace(/\s+/g, " ");
23
75
  s = s.replace(/[\[\]\r\n]/g, " ").replace(/\s+/g, " ").trim();
24
- if (s.length > LLMS_PUBLIC_SUMMARY_MAX_LENGTH) {
25
- s = `${s.slice(0, LLMS_PUBLIC_SUMMARY_MAX_LENGTH - 1).trimEnd()}\u2026`;
26
- }
76
+ s = clipAtWordBoundary(s, LLMS_PUBLIC_SUMMARY_MAX_LENGTH);
27
77
  if (/\b[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}\b/.test(s)) return null;
28
78
  if (/\b\+?\d[\d\s().-]{8,}\b/.test(s)) return null;
29
79
  if (s.length < 8) return null;
@@ -34,9 +84,7 @@ function sanitizeLlmsDisclaimerLine(raw) {
34
84
  if (typeof raw !== "string") return null;
35
85
  let s = raw.trim().replace(/\s+/g, " ");
36
86
  s = s.replace(/[\[\]\r\n]/g, " ").replace(/\s+/g, " ").trim();
37
- if (s.length > LLMS_DISCLAIMER_MAX_LENGTH) {
38
- s = `${s.slice(0, LLMS_DISCLAIMER_MAX_LENGTH - 1).trimEnd()}\u2026`;
39
- }
87
+ s = clipAtWordBoundary(s, LLMS_DISCLAIMER_MAX_LENGTH);
40
88
  if (/\b[\w.%+-]+@[\w.-]+\.[A-Za-z]{2,}\b/.test(s)) return null;
41
89
  if (/\b\+?\d[\d\s().-]{8,}\b/.test(s)) return null;
42
90
  if (s.length < 3) return null;
@@ -94,4 +142,4 @@ function pickManagedLlmSchemaForJsonLd(schema) {
94
142
  return Object.keys(out).length ? out : null;
95
143
  }
96
144
 
97
- export { LLMS_DISCLAIMER_MAX_LENGTH, LLMS_PUBLIC_SUMMARY_MAX_LENGTH, LLM_GEO_CONTRACT_VERSION, MANAGED_LLM_SCHEMA_KNOWN_KEYS, isEmittableReviewNode, pickManagedLlmSchemaForJsonLd, sanitizeLlmsDisclaimerLine, sanitizeLlmsPublicSummary, sanitizePrimaryLanguageTag };
145
+ export { LLMS_DISCLAIMER_MAX_LENGTH, LLMS_PUBLIC_SUMMARY_MAX_LENGTH, LLM_GEO_CONTRACT_VERSION, MANAGED_LLM_SCHEMA_KNOWN_KEYS, clipAtWordBoundary, isEmittableReviewNode, pickManagedLlmSchemaForJsonLd, sanitizeLlmsDisclaimerLine, sanitizeLlmsPublicSummary, sanitizePrimaryLanguageTag };