canopycms 0.0.62 → 0.0.63
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/dist/ai/index.d.ts +9 -0
- package/dist/ai/index.d.ts.map +1 -1
- package/dist/ai/index.js +9 -0
- package/dist/ai/index.js.map +1 -1
- package/dist/ai/to-plain-text.d.ts +57 -0
- package/dist/ai/to-plain-text.d.ts.map +1 -0
- package/dist/ai/to-plain-text.js +255 -0
- package/dist/ai/to-plain-text.js.map +1 -0
- package/dist/ai/transform-components.d.ts.map +1 -1
- package/dist/ai/transform-components.js +70 -5
- package/dist/ai/transform-components.js.map +1 -1
- package/dist/api/comments.d.ts +2 -2
- package/dist/authorization/branch.d.ts +55 -3
- package/dist/authorization/branch.d.ts.map +1 -1
- package/dist/authorization/branch.js +55 -9
- package/dist/authorization/branch.js.map +1 -1
- package/dist/authorization/types.d.ts +1 -1
- package/dist/authorization/types.d.ts.map +1 -1
- package/dist/build-canopy.d.ts +49 -0
- package/dist/build-canopy.d.ts.map +1 -0
- package/dist/build-canopy.js +51 -0
- package/dist/build-canopy.js.map +1 -0
- package/dist/cli/cli.js +78 -26
- package/dist/cli/generate-ai-content.js +77 -25
- package/dist/cli/template-files/canopycms.config.ts.template +18 -1
- package/dist/content-listing.d.ts +110 -5
- package/dist/content-listing.d.ts.map +1 -1
- package/dist/content-listing.js +164 -23
- package/dist/content-listing.js.map +1 -1
- package/dist/content-reader.d.ts +41 -4
- package/dist/content-reader.d.ts.map +1 -1
- package/dist/content-reader.js +17 -4
- package/dist/content-reader.js.map +1 -1
- package/dist/content-store.d.ts +10 -1
- package/dist/content-store.d.ts.map +1 -1
- package/dist/content-store.js.map +1 -1
- package/dist/content-tree.d.ts +31 -2
- package/dist/content-tree.d.ts.map +1 -1
- package/dist/content-tree.js +22 -5
- package/dist/content-tree.js.map +1 -1
- package/dist/context.d.ts +30 -10
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +55 -4
- package/dist/context.js.map +1 -1
- package/dist/entry-schema.d.ts +228 -8
- package/dist/entry-schema.d.ts.map +1 -1
- package/dist/entry-schema.js +124 -0
- package/dist/entry-schema.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +51 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +49 -0
- package/dist/server.js.map +1 -1
- package/dist/services.d.ts +1 -2
- package/dist/services.d.ts.map +1 -1
- package/dist/services.js +2 -6
- package/dist/services.js.map +1 -1
- package/dist/static/index.d.ts +65 -5
- package/dist/static/index.d.ts.map +1 -1
- package/dist/static/index.js +65 -12
- package/dist/static/index.js.map +1 -1
- package/dist/static/seo.d.ts +150 -0
- package/dist/static/seo.d.ts.map +1 -0
- package/dist/static/seo.js +189 -0
- package/dist/static/seo.js.map +1 -0
- package/dist/utils/sanitize-href.d.ts +46 -0
- package/dist/utils/sanitize-href.d.ts.map +1 -1
- package/dist/utils/sanitize-href.js +63 -2
- package/dist/utils/sanitize-href.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-agnostic SEO field extraction and URL shaping.
|
|
3
|
+
*
|
|
4
|
+
* This module is pure: it maps an entry's raw data onto a neutral `SeoFields` shape and
|
|
5
|
+
* resolves URLs. Framework adapters (e.g. canopycms-next's `entryToMetadata`) turn that shape
|
|
6
|
+
* into their own metadata objects; nothing here imports a framework or a node built-in.
|
|
7
|
+
*
|
|
8
|
+
* The field names default to the recommended SEO field group — see `defineSeoFieldGroup` in
|
|
9
|
+
* entry-schema.ts, which builds a schema group from these same names so the schema side and
|
|
10
|
+
* the read side cannot drift.
|
|
11
|
+
*/
|
|
12
|
+
import { neutralizeImplicitOffOrigin } from '../utils/sanitize-href.js';
|
|
13
|
+
/**
|
|
14
|
+
* The recommended SEO field names. `defineSeoFieldGroup()` emits a schema group using exactly
|
|
15
|
+
* these names, so an adopter who uses both ends needs no configuration at all.
|
|
16
|
+
*/
|
|
17
|
+
export const DEFAULT_SEO_FIELD_NAMES = {
|
|
18
|
+
title: 'metaTitle',
|
|
19
|
+
description: 'metaDescription',
|
|
20
|
+
ogImage: 'ogImage',
|
|
21
|
+
ogType: 'ogType',
|
|
22
|
+
canonical: 'canonical',
|
|
23
|
+
noindex: 'noindex',
|
|
24
|
+
twitterCard: 'twitterCard',
|
|
25
|
+
};
|
|
26
|
+
const asRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
27
|
+
? value
|
|
28
|
+
: undefined;
|
|
29
|
+
/**
|
|
30
|
+
* A non-empty trimmed string, or undefined.
|
|
31
|
+
*
|
|
32
|
+
* Empty is treated as UNSET, deliberately: CanopyCMS writes optional fields present-but-empty
|
|
33
|
+
* rather than omitting them, so `metaTitle: ''` is what an untouched SEO group looks like on
|
|
34
|
+
* disk. Treating it as a value would let a blank field beat the fallback title on every entry
|
|
35
|
+
* whose author never opened the SEO section.
|
|
36
|
+
*/
|
|
37
|
+
const asText = (value) => {
|
|
38
|
+
if (typeof value !== 'string')
|
|
39
|
+
return undefined;
|
|
40
|
+
const trimmed = value.trim();
|
|
41
|
+
return trimmed === '' ? undefined : trimmed;
|
|
42
|
+
};
|
|
43
|
+
const asFlag = (value) => typeof value === 'boolean' ? value : undefined;
|
|
44
|
+
/**
|
|
45
|
+
* An image URL from either convention: a plain string field, or an `image` field, whose value
|
|
46
|
+
* is a structured `{ src, alt, ... }` object. Supporting both means switching the recommended
|
|
47
|
+
* `ogImage` field to `type: 'image'` doesn't silently drop every OG image.
|
|
48
|
+
*/
|
|
49
|
+
const asImageUrl = (value) => {
|
|
50
|
+
const text = asText(value);
|
|
51
|
+
if (text)
|
|
52
|
+
return text;
|
|
53
|
+
return asText(asRecord(value)?.src);
|
|
54
|
+
};
|
|
55
|
+
const asOgType = (value) => {
|
|
56
|
+
const text = asText(value);
|
|
57
|
+
return text === 'website' || text === 'article' || text === 'profile' ? text : undefined;
|
|
58
|
+
};
|
|
59
|
+
const asTwitterCard = (value) => {
|
|
60
|
+
const text = asText(value);
|
|
61
|
+
return text === 'summary' || text === 'summary_large_image' ? text : undefined;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Map an entry's SEO fields onto the neutral shape.
|
|
65
|
+
*
|
|
66
|
+
* Entry data is `unknown` — every read is guarded, unknown enum values are dropped rather than
|
|
67
|
+
* passed through, and non-object data (null, a string, an array) yields an all-undefined result
|
|
68
|
+
* rather than throwing.
|
|
69
|
+
*/
|
|
70
|
+
export function extractSeoFields(entryData, opts = {}) {
|
|
71
|
+
const names = { ...DEFAULT_SEO_FIELD_NAMES, ...opts.fields };
|
|
72
|
+
const entry = asRecord(entryData);
|
|
73
|
+
const source = opts.group ? asRecord(entry?.[opts.group]) : entry;
|
|
74
|
+
return {
|
|
75
|
+
title: asText(source?.[names.title]) ?? asText(opts.fallbackTitle),
|
|
76
|
+
description: asText(source?.[names.description]) ?? asText(opts.fallbackDescription),
|
|
77
|
+
ogImage: asImageUrl(source?.[names.ogImage]),
|
|
78
|
+
ogType: asOgType(source?.[names.ogType]),
|
|
79
|
+
canonical: asText(source?.[names.canonical]),
|
|
80
|
+
noindex: asFlag(source?.[names.noindex]),
|
|
81
|
+
twitterCard: asTwitterCard(source?.[names.twitterCard]),
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* THE `noindex` predicate. Every surface that suppresses an entry must route through this one
|
|
86
|
+
* function rather than re-deriving the flag:
|
|
87
|
+
*
|
|
88
|
+
* - the page's own `robots: { index: false }` (canopycms-next's `entryToMetadata`), and
|
|
89
|
+
* - exclusion from the sitemap (canopycms-next's `generateContentSitemap`).
|
|
90
|
+
*
|
|
91
|
+
* Those two shipping from one predicate is the point of shipping them together. Where they were
|
|
92
|
+
* derived separately, a `noindex` entry stayed advertised in one surface while correctly absent
|
|
93
|
+
* from the other, and nothing warned.
|
|
94
|
+
*
|
|
95
|
+
* NOT applied to enumeration: `noindex` means "don't advertise this", not "don't build this".
|
|
96
|
+
* `collectRoutableEntries` and `generateStaticParams` must still emit the entry, so its URL
|
|
97
|
+
* resolves for anyone holding the link.
|
|
98
|
+
*/
|
|
99
|
+
export function isNoindexEntry(entryData, opts = {}) {
|
|
100
|
+
return extractSeoFields(entryData, opts).noindex === true;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* An off-site URL: either scheme-qualified (`https://…`) or LITERAL protocol-relative
|
|
104
|
+
* (`//cdn…`). Both name a host we don't control, so neither may be rewritten against the site
|
|
105
|
+
* origin — `resolveSeoUrl` passes a `true` result through verbatim.
|
|
106
|
+
*
|
|
107
|
+
* Deliberately narrower than "resolves off-origin": a WHATWG-backslash-equivalent spelling
|
|
108
|
+
* (`/\evil.com`, `\\evil.com`, `\/evil.com` — see `utils/sanitize-href.ts`'s
|
|
109
|
+
* `isImplicitlyOffOrigin`) also resolves off-origin in a browser, but is NOT recognized here as
|
|
110
|
+
* an intentional off-site pointer the way a literal `//cdn…` is. `resolveSeoUrl` handles that
|
|
111
|
+
* case separately, by neutralizing (`neutralizeImplicitOffOrigin`) rather than passing through: a
|
|
112
|
+
* `false` result here isn't a guarantee the value is a safe site-relative path on its own, only
|
|
113
|
+
* that it isn't a *declared* off-site one.
|
|
114
|
+
*/
|
|
115
|
+
export function isAbsoluteUrl(url) {
|
|
116
|
+
return /^[a-z][a-z0-9+.-]*:\/\//i.test(url) || url.startsWith('//');
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Strip trailing slashes without a regex.
|
|
120
|
+
*
|
|
121
|
+
* The obvious `replace(/\/+$/, '')` is a polynomial-ReDoS shape (CodeQL
|
|
122
|
+
* `js/polynomial-redos`, flagged high): on a value that is mostly slashes but does not end in
|
|
123
|
+
* one, the engine retries `\/+` from every position and the match cost is quadratic in the
|
|
124
|
+
* input length. `siteUrl` is adopter-supplied and can reach here from config or an env var, so
|
|
125
|
+
* it counts as uncontrolled. A character scan is linear and needs no reasoning about
|
|
126
|
+
* backtracking.
|
|
127
|
+
*
|
|
128
|
+
* Exported (from `canopycms/server`) so adopter code normalizing its own site-origin env var —
|
|
129
|
+
* e.g. a `SITE_URL` constant built from `NEXT_PUBLIC_SITE_URL` — has a package-provided linear
|
|
130
|
+
* way to do it instead of reaching for the same regex this function replaced.
|
|
131
|
+
*/
|
|
132
|
+
export function stripTrailingSlashes(value) {
|
|
133
|
+
let end = value.length;
|
|
134
|
+
while (end > 0 && value.charCodeAt(end - 1) === 47 /* '/' */)
|
|
135
|
+
end--;
|
|
136
|
+
return value.slice(0, end);
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Append a trailing slash to a site-relative path, matching a site that serves `/contact/`.
|
|
140
|
+
*
|
|
141
|
+
* Leaves the root (`/`) and file-like paths (a last segment containing a dot, e.g.
|
|
142
|
+
* `/blog/rss.xml`) alone, and never doubles an existing slash.
|
|
143
|
+
*
|
|
144
|
+
* A query string and/or fragment (`?page=2`, `#section`) is split off BEFORE the slash decision
|
|
145
|
+
* and placement, then reattached after — so `/blog?page=2` becomes `/blog/?page=2`, never
|
|
146
|
+
* `/blog?page=2/` (a literal trailing slash inside the query string, which is not what "serve
|
|
147
|
+
* with a trailing slash" means and breaks the URL).
|
|
148
|
+
*/
|
|
149
|
+
export function withTrailingSlash(path) {
|
|
150
|
+
const splitIndex = path.search(/[?#]/);
|
|
151
|
+
const base = splitIndex === -1 ? path : path.slice(0, splitIndex);
|
|
152
|
+
const suffix = splitIndex === -1 ? '' : path.slice(splitIndex);
|
|
153
|
+
const withLeading = base.startsWith('/') ? base : `/${base}`;
|
|
154
|
+
if (withLeading === '/' || withLeading.endsWith('/'))
|
|
155
|
+
return withLeading + suffix;
|
|
156
|
+
const lastSegment = withLeading.slice(withLeading.lastIndexOf('/') + 1);
|
|
157
|
+
if (lastSegment.includes('.'))
|
|
158
|
+
return withLeading + suffix;
|
|
159
|
+
return `${withLeading}/${suffix}`;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Resolve a possibly-relative URL for public emission (canonical tag, sitemap URL, OG image).
|
|
163
|
+
*
|
|
164
|
+
* ORDER MATTERS — the absolute check runs FIRST. An absolute or protocol-relative value is a
|
|
165
|
+
* deliberate off-site pointer (syndication, a partner-hosted copy, a CDN image) and passes
|
|
166
|
+
* through verbatim; only a site-relative path gets trailing-slash normalization and the site
|
|
167
|
+
* origin. Normalizing first turns `https://other.org/page` into
|
|
168
|
+
* `<siteUrl>/https://other.org/page/`.
|
|
169
|
+
*
|
|
170
|
+
* With no `siteUrl`, a site-relative path is returned still relative (normalized), which is
|
|
171
|
+
* what you want when the framework resolves relative metadata URLs itself.
|
|
172
|
+
*
|
|
173
|
+
* A NOT-absolute value is also run through `neutralizeImplicitOffOrigin` before normalization,
|
|
174
|
+
* so a WHATWG-backslash-equivalent spelling that `isAbsoluteUrl` correctly declines to call
|
|
175
|
+
* "absolute" (see its doc) can't still be read by a browser as protocol-relative once emitted.
|
|
176
|
+
*/
|
|
177
|
+
export function resolveSeoUrl(pathOrUrl, opts = {}) {
|
|
178
|
+
if (isAbsoluteUrl(pathOrUrl))
|
|
179
|
+
return pathOrUrl;
|
|
180
|
+
const safePathOrUrl = neutralizeImplicitOffOrigin(pathOrUrl);
|
|
181
|
+
const normalized = opts.trailingSlash
|
|
182
|
+
? withTrailingSlash(safePathOrUrl)
|
|
183
|
+
: safePathOrUrl.startsWith('/')
|
|
184
|
+
? safePathOrUrl
|
|
185
|
+
: `/${safePathOrUrl}`;
|
|
186
|
+
const origin = opts.siteUrl === undefined ? undefined : stripTrailingSlashes(opts.siteUrl);
|
|
187
|
+
return origin ? origin + normalized : normalized;
|
|
188
|
+
}
|
|
189
|
+
//# sourceMappingURL=seo.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"seo.js","sourceRoot":"","sources":["../../src/static/seo.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,2BAA2B,EAAE,MAAM,wBAAwB,CAAA;AAsBpE;;;GAGG;AACH,MAAM,CAAC,MAAM,uBAAuB,GAAG;IACrC,KAAK,EAAE,WAAW;IAClB,WAAW,EAAE,iBAAiB;IAC9B,OAAO,EAAE,SAAS;IAClB,MAAM,EAAE,QAAQ;IAChB,SAAS,EAAE,WAAW;IACtB,OAAO,EAAE,SAAS;IAClB,WAAW,EAAE,aAAa;CACM,CAAA;AAqBlC,MAAM,QAAQ,GAAG,CAAC,KAAc,EAAuC,EAAE,CACvE,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC;IAClE,CAAC,CAAE,KAAiC;IACpC,CAAC,CAAC,SAAS,CAAA;AAEf;;;;;;;GAOG;AACH,MAAM,MAAM,GAAG,CAAC,KAAc,EAAsB,EAAE;IACpD,IAAI,OAAO,KAAK,KAAK,QAAQ;QAAE,OAAO,SAAS,CAAA;IAC/C,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,EAAE,CAAA;IAC5B,OAAO,OAAO,KAAK,EAAE,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,OAAO,CAAA;AAC7C,CAAC,CAAA;AAED,MAAM,MAAM,GAAG,CAAC,KAAc,EAAuB,EAAE,CACrD,OAAO,KAAK,KAAK,SAAS,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAA;AAEhD;;;;GAIG;AACH,MAAM,UAAU,GAAG,CAAC,KAAc,EAAsB,EAAE;IACxD,MAAM,IAAI,GAAG,MAAM,CAAC,KAAK,CAAC,CAAA;IAC1B,IAAI,IAAI;QAAE,OAAO,IAAI,CAAA;IACrB,OAAO,MAAM,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,GAAG,CAAC,CAAA;AACrC,CAAC,CAAA;AAED,MAAM,QAAQ,GAAG,CAAC,KAAc,EAAyB,EAAE;IACzD,MAAM,IAAI,GAAG,MAAM,CAAC,KAAK,CAAC,CAAA;IAC1B,OAAO,IAAI,KAAK,SAAS,IAAI,IAAI,KAAK,SAAS,IAAI,IAAI,KAAK,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,CAAA;AAC1F,CAAC,CAAA;AAED,MAAM,aAAa,GAAG,CAAC,KAAc,EAA8B,EAAE;IACnE,MAAM,IAAI,GAAG,MAAM,CAAC,KAAK,CAAC,CAAA;IAC1B,OAAO,IAAI,KAAK,SAAS,IAAI,IAAI,KAAK,qBAAqB,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS,CAAA;AAChF,CAAC,CAAA;AAED;;;;;;GAMG;AACH,MAAM,UAAU,gBAAgB,CAC9B,SAAkB,EAClB,OAAgC,EAAE;IAElC,MAAM,KAAK,GAAG,EAAE,GAAG,uBAAuB,EAAE,GAAG,IAAI,CAAC,MAAM,EAAE,CAAA;IAC5D,MAAM,KAAK,GAAG,QAAQ,CAAC,SAAS,CAAC,CAAA;IACjC,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,QAAQ,CAAC,KAAK,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAA;IAEjE,OAAO;QACL,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,IAAI,MAAM,CAAC,IAAI,CAAC,aAAa,CAAC;QAClE,WAAW,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC,IAAI,MAAM,CAAC,IAAI,CAAC,mBAAmB,CAAC;QACpF,OAAO,EAAE,UAAU,CAAC,MAAM,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;QAC5C,MAAM,EAAE,QAAQ,CAAC,MAAM,EAAE,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;QACxC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC;QAC5C,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;QACxC,WAAW,EAAE,aAAa,CAAC,MAAM,EAAE,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC;KACxD,CAAA;AACH,CAAC;AAED;;;;;;;;;;;;;;GAcG;AACH,MAAM,UAAU,cAAc,CAAC,SAAkB,EAAE,OAAyB,EAAE;IAC5E,OAAO,gBAAgB,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC,OAAO,KAAK,IAAI,CAAA;AAC3D,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,aAAa,CAAC,GAAW;IACvC,OAAO,0BAA0B,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,GAAG,CAAC,UAAU,CAAC,IAAI,CAAC,CAAA;AACrE,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,UAAU,oBAAoB,CAAC,KAAa;IAChD,IAAI,GAAG,GAAG,KAAK,CAAC,MAAM,CAAA;IACtB,OAAO,GAAG,GAAG,CAAC,IAAI,KAAK,CAAC,UAAU,CAAC,GAAG,GAAG,CAAC,CAAC,KAAK,EAAE,CAAC,SAAS;QAAE,GAAG,EAAE,CAAA;IACnE,OAAO,KAAK,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,CAAA;AAC5B,CAAC;AAED;;;;;;;;;;GAUG;AACH,MAAM,UAAU,iBAAiB,CAAC,IAAY;IAC5C,MAAM,UAAU,GAAG,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,CAAA;IACtC,MAAM,IAAI,GAAG,UAAU,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,UAAU,CAAC,CAAA;IACjE,MAAM,MAAM,GAAG,UAAU,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,CAAA;IAE9D,MAAM,WAAW,GAAG,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,IAAI,EAAE,CAAA;IAC5D,IAAI,WAAW,KAAK,GAAG,IAAI,WAAW,CAAC,QAAQ,CAAC,GAAG,CAAC;QAAE,OAAO,WAAW,GAAG,MAAM,CAAA;IACjF,MAAM,WAAW,GAAG,WAAW,CAAC,KAAK,CAAC,WAAW,CAAC,WAAW,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAA;IACvE,IAAI,WAAW,CAAC,QAAQ,CAAC,GAAG,CAAC;QAAE,OAAO,WAAW,GAAG,MAAM,CAAA;IAC1D,OAAO,GAAG,WAAW,IAAI,MAAM,EAAE,CAAA;AACnC,CAAC;AAaD;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,aAAa,CAAC,SAAiB,EAAE,OAA6B,EAAE;IAC9E,IAAI,aAAa,CAAC,SAAS,CAAC;QAAE,OAAO,SAAS,CAAA;IAC9C,MAAM,aAAa,GAAG,2BAA2B,CAAC,SAAS,CAAC,CAAA;IAC5D,MAAM,UAAU,GAAG,IAAI,CAAC,aAAa;QACnC,CAAC,CAAC,iBAAiB,CAAC,aAAa,CAAC;QAClC,CAAC,CAAC,aAAa,CAAC,UAAU,CAAC,GAAG,CAAC;YAC7B,CAAC,CAAC,aAAa;YACf,CAAC,CAAC,IAAI,aAAa,EAAE,CAAA;IACzB,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,KAAK,SAAS,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,oBAAoB,CAAC,IAAI,CAAC,OAAO,CAAC,CAAA;IAC1F,OAAO,MAAM,CAAC,CAAC,CAAC,MAAM,GAAG,UAAU,CAAC,CAAC,CAAC,UAAU,CAAA;AAClD,CAAC"}
|
|
@@ -1,3 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether `url` declares an explicit scheme (`https:`, `mailto:`, `javascript:`, ...).
|
|
3
|
+
*
|
|
4
|
+
* This is the property that actually distinguishes "the author asked for another origin" from
|
|
5
|
+
* "the parser inferred one" -- see `sanitizeHref`'s doc for why a `startsWith('//')` /
|
|
6
|
+
* `startsWith('scheme://')` check is not sufficient on its own (WHATWG URL treats backslash as
|
|
7
|
+
* equivalent to forward slash for special schemes, so `/\evil.com`, `\\evil.com` and `\/evil.com`
|
|
8
|
+
* are all protocol-relative in effect despite declaring no scheme). Exported so every "is this
|
|
9
|
+
* URL off-site" check in the package shares one answer instead of re-deriving it -- see
|
|
10
|
+
* `isImplicitlyOffOrigin` below and `static/seo.ts`'s `isAbsoluteUrl`.
|
|
11
|
+
*/
|
|
12
|
+
export declare function declaresScheme(url: string): boolean;
|
|
13
|
+
/**
|
|
14
|
+
* Whether `url`, resolved as a URL reference against a fixed sentinel origin, lands on a
|
|
15
|
+
* DIFFERENT origin than the sentinel WITHOUT itself declaring a scheme.
|
|
16
|
+
*
|
|
17
|
+
* True for a literal protocol-relative reference (`//host`) and for every WHATWG
|
|
18
|
+
* backslash-equivalent spelling that a browser resolves the same way (`/\host`, `\\host`,
|
|
19
|
+
* `\/host`, and combinations with stripped whitespace/control characters) -- these all read as
|
|
20
|
+
* "site-relative" to a naive string check but actually redefine the authority. False for a
|
|
21
|
+
* normal site-relative path (`/about`, `docs/guide`) and for a scheme-qualified absolute URL
|
|
22
|
+
* (`declaresScheme` already identifies those as intentionally off-site on their own).
|
|
23
|
+
*
|
|
24
|
+
* Callers that want to treat a LITERAL `//host` as an intentionally-supported off-site pointer
|
|
25
|
+
* (e.g. `static/seo.ts`'s `isAbsoluteUrl`, for a CDN-hosted `ogImage`) should check that
|
|
26
|
+
* separately and only fall back to this function to catch the spellings that slip past a
|
|
27
|
+
* `startsWith('//')` check.
|
|
28
|
+
*/
|
|
29
|
+
export declare function isImplicitlyOffOrigin(url: string): boolean;
|
|
30
|
+
/**
|
|
31
|
+
* If `url` `isImplicitlyOffOrigin`, discard the authority it spoofed and return just the
|
|
32
|
+
* path/search/hash it resolved to -- e.g. `/\evil.com/x` (which parses to origin
|
|
33
|
+
* `https://evil.com`) becomes `/x`. Otherwise return `url` unchanged.
|
|
34
|
+
*
|
|
35
|
+
* This re-parses with the SAME sentinel technique rather than pattern-matching leading
|
|
36
|
+
* slash/backslash characters: WHATWG URL also strips tabs, newlines, and carriage returns during
|
|
37
|
+
* parsing (wherever they appear, not just at the edges), so `/\t/evil.com` is exactly as
|
|
38
|
+
* off-origin as `/\evil.com` despite having no leading backslash-or-slash run for a regex to
|
|
39
|
+
* find. Re-parsing gets this right for free instead of re-deriving the quirk a second time.
|
|
40
|
+
*
|
|
41
|
+
* Useful for a caller that wants to keep emitting SOME value for an implicitly-off-origin input
|
|
42
|
+
* rather than rejecting it outright (`sanitizeHref` rejects to a fallback instead; see
|
|
43
|
+
* `static/seo.ts`'s `resolveSeoUrl`, which has no fallback concept and must always return a
|
|
44
|
+
* same-origin string).
|
|
45
|
+
*/
|
|
46
|
+
export declare function neutralizeImplicitOffOrigin(url: string): string;
|
|
1
47
|
/**
|
|
2
48
|
* Sanitize an untrusted URL for use in `href` attributes.
|
|
3
49
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sanitize-href.d.ts","sourceRoot":"","sources":["../../src/utils/sanitize-href.ts"],"names":[],"mappings":"AAKA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,SAAM,GAAG,MAAM,
|
|
1
|
+
{"version":3,"file":"sanitize-href.d.ts","sourceRoot":"","sources":["../../src/utils/sanitize-href.ts"],"names":[],"mappings":"AAKA;;;;;;;;;;GAUG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAEnD;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,qBAAqB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAO1D;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,2BAA2B,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAI/D;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AACH,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,SAAM,GAAG,MAAM,CAwChE"}
|
|
@@ -2,6 +2,68 @@
|
|
|
2
2
|
// fragment-only, query-only) resolve instead of throwing under `new URL()`.
|
|
3
3
|
// Never exposed in output - see the relative-reference branch below.
|
|
4
4
|
const SENTINEL_BASE = 'https://relative.invalid';
|
|
5
|
+
/**
|
|
6
|
+
* Whether `url` declares an explicit scheme (`https:`, `mailto:`, `javascript:`, ...).
|
|
7
|
+
*
|
|
8
|
+
* This is the property that actually distinguishes "the author asked for another origin" from
|
|
9
|
+
* "the parser inferred one" -- see `sanitizeHref`'s doc for why a `startsWith('//')` /
|
|
10
|
+
* `startsWith('scheme://')` check is not sufficient on its own (WHATWG URL treats backslash as
|
|
11
|
+
* equivalent to forward slash for special schemes, so `/\evil.com`, `\\evil.com` and `\/evil.com`
|
|
12
|
+
* are all protocol-relative in effect despite declaring no scheme). Exported so every "is this
|
|
13
|
+
* URL off-site" check in the package shares one answer instead of re-deriving it -- see
|
|
14
|
+
* `isImplicitlyOffOrigin` below and `static/seo.ts`'s `isAbsoluteUrl`.
|
|
15
|
+
*/
|
|
16
|
+
export function declaresScheme(url) {
|
|
17
|
+
return /^[a-z][a-z0-9+.-]*:/i.test(url.trim());
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Whether `url`, resolved as a URL reference against a fixed sentinel origin, lands on a
|
|
21
|
+
* DIFFERENT origin than the sentinel WITHOUT itself declaring a scheme.
|
|
22
|
+
*
|
|
23
|
+
* True for a literal protocol-relative reference (`//host`) and for every WHATWG
|
|
24
|
+
* backslash-equivalent spelling that a browser resolves the same way (`/\host`, `\\host`,
|
|
25
|
+
* `\/host`, and combinations with stripped whitespace/control characters) -- these all read as
|
|
26
|
+
* "site-relative" to a naive string check but actually redefine the authority. False for a
|
|
27
|
+
* normal site-relative path (`/about`, `docs/guide`) and for a scheme-qualified absolute URL
|
|
28
|
+
* (`declaresScheme` already identifies those as intentionally off-site on their own).
|
|
29
|
+
*
|
|
30
|
+
* Callers that want to treat a LITERAL `//host` as an intentionally-supported off-site pointer
|
|
31
|
+
* (e.g. `static/seo.ts`'s `isAbsoluteUrl`, for a CDN-hosted `ogImage`) should check that
|
|
32
|
+
* separately and only fall back to this function to catch the spellings that slip past a
|
|
33
|
+
* `startsWith('//')` check.
|
|
34
|
+
*/
|
|
35
|
+
export function isImplicitlyOffOrigin(url) {
|
|
36
|
+
if (declaresScheme(url))
|
|
37
|
+
return false;
|
|
38
|
+
try {
|
|
39
|
+
return new URL(url, SENTINEL_BASE).origin !== SENTINEL_BASE;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* If `url` `isImplicitlyOffOrigin`, discard the authority it spoofed and return just the
|
|
47
|
+
* path/search/hash it resolved to -- e.g. `/\evil.com/x` (which parses to origin
|
|
48
|
+
* `https://evil.com`) becomes `/x`. Otherwise return `url` unchanged.
|
|
49
|
+
*
|
|
50
|
+
* This re-parses with the SAME sentinel technique rather than pattern-matching leading
|
|
51
|
+
* slash/backslash characters: WHATWG URL also strips tabs, newlines, and carriage returns during
|
|
52
|
+
* parsing (wherever they appear, not just at the edges), so `/\t/evil.com` is exactly as
|
|
53
|
+
* off-origin as `/\evil.com` despite having no leading backslash-or-slash run for a regex to
|
|
54
|
+
* find. Re-parsing gets this right for free instead of re-deriving the quirk a second time.
|
|
55
|
+
*
|
|
56
|
+
* Useful for a caller that wants to keep emitting SOME value for an implicitly-off-origin input
|
|
57
|
+
* rather than rejecting it outright (`sanitizeHref` rejects to a fallback instead; see
|
|
58
|
+
* `static/seo.ts`'s `resolveSeoUrl`, which has no fallback concept and must always return a
|
|
59
|
+
* same-origin string).
|
|
60
|
+
*/
|
|
61
|
+
export function neutralizeImplicitOffOrigin(url) {
|
|
62
|
+
if (!isImplicitlyOffOrigin(url))
|
|
63
|
+
return url;
|
|
64
|
+
const parsed = new URL(url, SENTINEL_BASE);
|
|
65
|
+
return parsed.pathname + parsed.search + parsed.hash;
|
|
66
|
+
}
|
|
5
67
|
/**
|
|
6
68
|
* Sanitize an untrusted URL for use in `href` attributes.
|
|
7
69
|
*
|
|
@@ -67,8 +129,7 @@ export function sanitizeHref(url, fallback = '#') {
|
|
|
67
129
|
// origin" from "the parser inferred one" is whether the input DECLARES a
|
|
68
130
|
// scheme. If it does not and still resolved off the sentinel, it is
|
|
69
131
|
// protocol-relative however it was written.
|
|
70
|
-
|
|
71
|
-
if (parsed.origin !== SENTINEL_BASE && !declaresScheme)
|
|
132
|
+
if (parsed.origin !== SENTINEL_BASE && !declaresScheme(trimmed))
|
|
72
133
|
return fallback;
|
|
73
134
|
if (parsed.origin === SENTINEL_BASE) {
|
|
74
135
|
// Input was relative: return it as a relative reference rather than
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sanitize-href.js","sourceRoot":"","sources":["../../src/utils/sanitize-href.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,4EAA4E;AAC5E,qEAAqE;AACrE,MAAM,aAAa,GAAG,0BAA0B,CAAA;AAEhD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AACH,MAAM,UAAU,YAAY,CAAC,GAAW,EAAE,QAAQ,GAAG,GAAG;IACtD,MAAM,OAAO,GAAG,GAAG,CAAC,IAAI,EAAE,CAAA;IAC1B,IAAI,OAAO,KAAK,EAAE;QAAE,OAAO,QAAQ,CAAA;IAEnC,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,OAAO,EAAE,aAAa,CAAC,CAAA;QAC9C,IAAI,MAAM,CAAC,QAAQ,KAAK,OAAO,IAAI,MAAM,CAAC,QAAQ,KAAK,QAAQ,EAAE,CAAC;YAChE,OAAO,QAAQ,CAAA;QACjB,CAAC;QAED,wEAAwE;QACxE,qEAAqE;QACrE,EAAE;QACF,uEAAuE;QACvE,qEAAqE;QACrE,wEAAwE;QACxE,wEAAwE;QACxE,wEAAwE;QACxE,yEAAyE;QACzE,EAAE;QACF,yEAAyE;QACzE,yEAAyE;QACzE,oEAAoE;QACpE,4CAA4C;QAC5C,
|
|
1
|
+
{"version":3,"file":"sanitize-href.js","sourceRoot":"","sources":["../../src/utils/sanitize-href.ts"],"names":[],"mappings":"AAAA,4EAA4E;AAC5E,4EAA4E;AAC5E,qEAAqE;AACrE,MAAM,aAAa,GAAG,0BAA0B,CAAA;AAEhD;;;;;;;;;;GAUG;AACH,MAAM,UAAU,cAAc,CAAC,GAAW;IACxC,OAAO,sBAAsB,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,CAAC,CAAA;AAChD,CAAC;AAED;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,qBAAqB,CAAC,GAAW;IAC/C,IAAI,cAAc,CAAC,GAAG,CAAC;QAAE,OAAO,KAAK,CAAA;IACrC,IAAI,CAAC;QACH,OAAO,IAAI,GAAG,CAAC,GAAG,EAAE,aAAa,CAAC,CAAC,MAAM,KAAK,aAAa,CAAA;IAC7D,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAA;IACd,CAAC;AACH,CAAC;AAED;;;;;;;;;;;;;;;GAeG;AACH,MAAM,UAAU,2BAA2B,CAAC,GAAW;IACrD,IAAI,CAAC,qBAAqB,CAAC,GAAG,CAAC;QAAE,OAAO,GAAG,CAAA;IAC3C,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,GAAG,EAAE,aAAa,CAAC,CAAA;IAC1C,OAAO,MAAM,CAAC,QAAQ,GAAG,MAAM,CAAC,MAAM,GAAG,MAAM,CAAC,IAAI,CAAA;AACtD,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AACH,MAAM,UAAU,YAAY,CAAC,GAAW,EAAE,QAAQ,GAAG,GAAG;IACtD,MAAM,OAAO,GAAG,GAAG,CAAC,IAAI,EAAE,CAAA;IAC1B,IAAI,OAAO,KAAK,EAAE;QAAE,OAAO,QAAQ,CAAA;IAEnC,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,OAAO,EAAE,aAAa,CAAC,CAAA;QAC9C,IAAI,MAAM,CAAC,QAAQ,KAAK,OAAO,IAAI,MAAM,CAAC,QAAQ,KAAK,QAAQ,EAAE,CAAC;YAChE,OAAO,QAAQ,CAAA;QACjB,CAAC;QAED,wEAAwE;QACxE,qEAAqE;QACrE,EAAE;QACF,uEAAuE;QACvE,qEAAqE;QACrE,wEAAwE;QACxE,wEAAwE;QACxE,wEAAwE;QACxE,yEAAyE;QACzE,EAAE;QACF,yEAAyE;QACzE,yEAAyE;QACzE,oEAAoE;QACpE,4CAA4C;QAC5C,IAAI,MAAM,CAAC,MAAM,KAAK,aAAa,IAAI,CAAC,cAAc,CAAC,OAAO,CAAC;YAAE,OAAO,QAAQ,CAAA;QAEhF,IAAI,MAAM,CAAC,MAAM,KAAK,aAAa,EAAE,CAAC;YACpC,oEAAoE;YACpE,kDAAkD;YAClD,MAAM,QAAQ,GAAG,MAAM,CAAC,QAAQ,GAAG,MAAM,CAAC,MAAM,GAAG,MAAM,CAAC,IAAI,CAAA;YAC9D,mEAAmE;YACnE,kEAAkE;YAClE,OAAO,MAAM,CAAC,QAAQ,KAAK,GAAG,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAA;QAC3F,CAAC;QAED,OAAO,MAAM,CAAC,IAAI,CAAA;IACpB,CAAC;IAAC,MAAM,CAAC;QACP,cAAc;IAChB,CAAC;IACD,OAAO,QAAQ,CAAA;AACjB,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"//": "@codemirror/language, @lezer/highlight: workaround — @mdxeditor/editor uses cm6-theme-basic-light which peer-requires these but mdxeditor doesn't declare them as dependencies",
|
|
3
3
|
"name": "canopycms",
|
|
4
|
-
"version": "0.0.
|
|
4
|
+
"version": "0.0.63",
|
|
5
5
|
"description": "CanopyCMS core package: schema-driven content, branch-aware editing, and editor UI for Next.js.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|