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.
Files changed (73) hide show
  1. package/dist/ai/index.d.ts +9 -0
  2. package/dist/ai/index.d.ts.map +1 -1
  3. package/dist/ai/index.js +9 -0
  4. package/dist/ai/index.js.map +1 -1
  5. package/dist/ai/to-plain-text.d.ts +57 -0
  6. package/dist/ai/to-plain-text.d.ts.map +1 -0
  7. package/dist/ai/to-plain-text.js +255 -0
  8. package/dist/ai/to-plain-text.js.map +1 -0
  9. package/dist/ai/transform-components.d.ts.map +1 -1
  10. package/dist/ai/transform-components.js +70 -5
  11. package/dist/ai/transform-components.js.map +1 -1
  12. package/dist/api/comments.d.ts +2 -2
  13. package/dist/authorization/branch.d.ts +55 -3
  14. package/dist/authorization/branch.d.ts.map +1 -1
  15. package/dist/authorization/branch.js +55 -9
  16. package/dist/authorization/branch.js.map +1 -1
  17. package/dist/authorization/types.d.ts +1 -1
  18. package/dist/authorization/types.d.ts.map +1 -1
  19. package/dist/build-canopy.d.ts +49 -0
  20. package/dist/build-canopy.d.ts.map +1 -0
  21. package/dist/build-canopy.js +51 -0
  22. package/dist/build-canopy.js.map +1 -0
  23. package/dist/cli/cli.js +78 -26
  24. package/dist/cli/generate-ai-content.js +77 -25
  25. package/dist/cli/template-files/canopycms.config.ts.template +18 -1
  26. package/dist/content-listing.d.ts +110 -5
  27. package/dist/content-listing.d.ts.map +1 -1
  28. package/dist/content-listing.js +164 -23
  29. package/dist/content-listing.js.map +1 -1
  30. package/dist/content-reader.d.ts +41 -4
  31. package/dist/content-reader.d.ts.map +1 -1
  32. package/dist/content-reader.js +17 -4
  33. package/dist/content-reader.js.map +1 -1
  34. package/dist/content-store.d.ts +10 -1
  35. package/dist/content-store.d.ts.map +1 -1
  36. package/dist/content-store.js.map +1 -1
  37. package/dist/content-tree.d.ts +31 -2
  38. package/dist/content-tree.d.ts.map +1 -1
  39. package/dist/content-tree.js +22 -5
  40. package/dist/content-tree.js.map +1 -1
  41. package/dist/context.d.ts +30 -10
  42. package/dist/context.d.ts.map +1 -1
  43. package/dist/context.js +55 -4
  44. package/dist/context.js.map +1 -1
  45. package/dist/entry-schema.d.ts +228 -8
  46. package/dist/entry-schema.d.ts.map +1 -1
  47. package/dist/entry-schema.js +124 -0
  48. package/dist/entry-schema.js.map +1 -1
  49. package/dist/index.d.ts +1 -0
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +7 -0
  52. package/dist/index.js.map +1 -1
  53. package/dist/server.d.ts +51 -0
  54. package/dist/server.d.ts.map +1 -1
  55. package/dist/server.js +49 -0
  56. package/dist/server.js.map +1 -1
  57. package/dist/services.d.ts +1 -2
  58. package/dist/services.d.ts.map +1 -1
  59. package/dist/services.js +2 -6
  60. package/dist/services.js.map +1 -1
  61. package/dist/static/index.d.ts +65 -5
  62. package/dist/static/index.d.ts.map +1 -1
  63. package/dist/static/index.js +65 -12
  64. package/dist/static/index.js.map +1 -1
  65. package/dist/static/seo.d.ts +150 -0
  66. package/dist/static/seo.d.ts.map +1 -0
  67. package/dist/static/seo.js +189 -0
  68. package/dist/static/seo.js.map +1 -0
  69. package/dist/utils/sanitize-href.d.ts +46 -0
  70. package/dist/utils/sanitize-href.d.ts.map +1 -1
  71. package/dist/utils/sanitize-href.js +63 -2
  72. package/dist/utils/sanitize-href.js.map +1 -1
  73. 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":"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"}
@@ -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,CAyChE"}
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
- const declaresScheme = /^[a-z][a-z0-9+.-]*:/i.test(trimmed);
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,MAAM,cAAc,GAAG,sBAAsB,CAAC,IAAI,CAAC,OAAO,CAAC,CAAA;QAC3D,IAAI,MAAM,CAAC,MAAM,KAAK,aAAa,IAAI,CAAC,cAAc;YAAE,OAAO,QAAQ,CAAA;QAEvE,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"}
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.62",
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": {