@aglyn/aglyn 1.0.0-beta.252 → 1.0.0-beta.254

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,39 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */
17
+ /** The email plugin's bundle id: the `pluginId` of every element in its bands. */
18
+ export declare const EMAIL_BLOCK_STARTER_PLUGIN_ID = "email";
19
+ /** One element of a nested starter tree, as an element preset's `data` holds it. */
20
+ export interface EmailBlockStarterNode {
21
+ $id: null;
22
+ componentId: string;
23
+ pluginId: string;
24
+ props: Record<string, unknown>;
25
+ nodes?: EmailBlockStarterNode[];
26
+ }
27
+ /**
28
+ * The Footer's small print, a muted gray that still reads at 5:1 on white.
29
+ * A literal on purpose: it is written into the sent mail's own HTML, where no
30
+ * theme token resolves.
31
+ */
32
+ export declare const EMAIL_FOOTER_TEXT_COLOR = "#6b6b6b";
33
+ export declare const EMAIL_BLOCK_STARTER_TREES: Readonly<Record<'header' | 'footer', EmailBlockStarterNode>>;
34
+ /**
35
+ * {@link EMAIL_BLOCK_STARTER_TREES} as JSON text: the form the native apps
36
+ * read them in (`Contracts.emailBlockStarterTreesJson`), parsed when a block
37
+ * is started.
38
+ */
39
+ export declare const EMAIL_BLOCK_STARTER_TREES_JSON: string;
@@ -0,0 +1,122 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2026 Aglyn LLC
4
+ *
5
+ * Licensed under the Apache License, Version 2.0 (the "License");
6
+ * you may not use this file except in compliance with the License.
7
+ * You may obtain a copy of the License at
8
+ *
9
+ * http://www.apache.org/licenses/LICENSE-2.0
10
+ *
11
+ * Unless required by applicable law or agreed to in writing, software
12
+ * distributed under the License is distributed on an "AS IS" BASIS,
13
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ * See the License for the specific language governing permissions and
15
+ * limitations under the License.
16
+ */ /*
17
+ * The two email bands a Header or Footer email block starts from (AGL-3287),
18
+ * as plain data: the email plugin registers them as element presets (so an
19
+ * author can drop one into an email), the Components page starts a reusable
20
+ * block from the same trees, and the native apps start theirs from the values
21
+ * generated from here. One tree each, wherever it is started from.
22
+ *
23
+ * The words are placeholders to replace. No merge token names the company in
24
+ * both kinds of email — a campaign supplies `{{org.name}}`, and no token is
25
+ * supplied by every one of a site's own emails — while a token an email does
26
+ * not supply is blanked before sending, which would leave the header with no
27
+ * name at all.
28
+ *
29
+ * The footer carries no unsubscribe link: a campaign send appends its own,
30
+ * and a second one would do the same thing twice.
31
+ */ /** The email plugin's bundle id: the `pluginId` of every element in its bands. */ export const EMAIL_BLOCK_STARTER_PLUGIN_ID = 'email';
32
+ /**
33
+ * The Footer's small print, a muted gray that still reads at 5:1 on white.
34
+ * A literal on purpose: it is written into the sent mail's own HTML, where no
35
+ * theme token resolves.
36
+ */ export const EMAIL_FOOTER_TEXT_COLOR = '#6b6b6b';
37
+ const FOOTER_TEXT_COLOR = EMAIL_FOOTER_TEXT_COLOR;
38
+ export const EMAIL_BLOCK_STARTER_TREES = {
39
+ header: {
40
+ $id: null,
41
+ componentId: 'emailSection',
42
+ pluginId: EMAIL_BLOCK_STARTER_PLUGIN_ID,
43
+ // Marks the band as the email's header, so the sender's own header is
44
+ // not drawn above it (AGL-3372).
45
+ props: {
46
+ align: 'center',
47
+ padding: 24,
48
+ emailRole: 'header'
49
+ },
50
+ nodes: [
51
+ {
52
+ $id: null,
53
+ componentId: 'emailImage',
54
+ pluginId: EMAIL_BLOCK_STARTER_PLUGIN_ID,
55
+ props: {
56
+ alt: 'Your company logo',
57
+ width: 160,
58
+ align: 'center'
59
+ }
60
+ },
61
+ {
62
+ $id: null,
63
+ componentId: 'emailText',
64
+ pluginId: EMAIL_BLOCK_STARTER_PLUGIN_ID,
65
+ props: {
66
+ children: 'Your company',
67
+ variant: 'subheading',
68
+ align: 'center'
69
+ }
70
+ }
71
+ ]
72
+ },
73
+ footer: {
74
+ $id: null,
75
+ componentId: 'emailSection',
76
+ pluginId: EMAIL_BLOCK_STARTER_PLUGIN_ID,
77
+ // Marks the band as the email's footer, so the sender's own footer is
78
+ // not drawn under it (AGL-3372).
79
+ props: {
80
+ align: 'center',
81
+ padding: 24,
82
+ emailRole: 'footer'
83
+ },
84
+ nodes: [
85
+ {
86
+ $id: null,
87
+ componentId: 'emailDivider',
88
+ pluginId: EMAIL_BLOCK_STARTER_PLUGIN_ID,
89
+ props: {}
90
+ },
91
+ {
92
+ $id: null,
93
+ componentId: 'emailText',
94
+ pluginId: EMAIL_BLOCK_STARTER_PLUGIN_ID,
95
+ props: {
96
+ children: 'Your company · 123 Main St, City',
97
+ variant: 'caption',
98
+ align: 'center',
99
+ color: FOOTER_TEXT_COLOR
100
+ }
101
+ },
102
+ {
103
+ $id: null,
104
+ componentId: 'emailText',
105
+ pluginId: EMAIL_BLOCK_STARTER_PLUGIN_ID,
106
+ props: {
107
+ children: "You're receiving this because you're a customer of Your company.",
108
+ variant: 'caption',
109
+ align: 'center',
110
+ color: FOOTER_TEXT_COLOR
111
+ }
112
+ }
113
+ ]
114
+ }
115
+ };
116
+ /**
117
+ * {@link EMAIL_BLOCK_STARTER_TREES} as JSON text: the form the native apps
118
+ * read them in (`Contracts.emailBlockStarterTreesJson`), parsed when a block
119
+ * is started.
120
+ */ export const EMAIL_BLOCK_STARTER_TREES_JSON = JSON.stringify(EMAIL_BLOCK_STARTER_TREES);
121
+
122
+ //# sourceMappingURL=email-block-starters.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../libs/aglyn/src/lib/app-utils/email-block-starters.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2026 Aglyn LLC\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */\n\n/*\n * The two email bands a Header or Footer email block starts from (AGL-3287),\n * as plain data: the email plugin registers them as element presets (so an\n * author can drop one into an email), the Components page starts a reusable\n * block from the same trees, and the native apps start theirs from the values\n * generated from here. One tree each, wherever it is started from.\n *\n * The words are placeholders to replace. No merge token names the company in\n * both kinds of email — a campaign supplies `{{org.name}}`, and no token is\n * supplied by every one of a site's own emails — while a token an email does\n * not supply is blanked before sending, which would leave the header with no\n * name at all.\n *\n * The footer carries no unsubscribe link: a campaign send appends its own,\n * and a second one would do the same thing twice.\n */\n\n/** The email plugin's bundle id: the `pluginId` of every element in its bands. */\nexport const EMAIL_BLOCK_STARTER_PLUGIN_ID = 'email'\n\n/** One element of a nested starter tree, as an element preset's `data` holds it. */\nexport interface EmailBlockStarterNode {\n $id: null\n componentId: string\n pluginId: string\n props: Record<string, unknown>\n nodes?: EmailBlockStarterNode[]\n}\n\n/**\n * The Footer's small print, a muted gray that still reads at 5:1 on white.\n * A literal on purpose: it is written into the sent mail's own HTML, where no\n * theme token resolves.\n */\nexport const EMAIL_FOOTER_TEXT_COLOR = '#6b6b6b'\nconst FOOTER_TEXT_COLOR = EMAIL_FOOTER_TEXT_COLOR\n\nexport const EMAIL_BLOCK_STARTER_TREES: Readonly<\n Record<'header' | 'footer', EmailBlockStarterNode>\n> = {\n header: {\n $id: null,\n componentId: 'emailSection',\n pluginId: EMAIL_BLOCK_STARTER_PLUGIN_ID,\n // Marks the band as the email's header, so the sender's own header is\n // not drawn above it (AGL-3372).\n props: { align: 'center', padding: 24, emailRole: 'header' },\n nodes: [\n {\n $id: null,\n componentId: 'emailImage',\n pluginId: EMAIL_BLOCK_STARTER_PLUGIN_ID,\n props: { alt: 'Your company logo', width: 160, align: 'center' },\n },\n {\n $id: null,\n componentId: 'emailText',\n pluginId: EMAIL_BLOCK_STARTER_PLUGIN_ID,\n props: {\n children: 'Your company',\n variant: 'subheading',\n align: 'center',\n },\n },\n ],\n },\n footer: {\n $id: null,\n componentId: 'emailSection',\n pluginId: EMAIL_BLOCK_STARTER_PLUGIN_ID,\n // Marks the band as the email's footer, so the sender's own footer is\n // not drawn under it (AGL-3372).\n props: { align: 'center', padding: 24, emailRole: 'footer' },\n nodes: [\n {\n $id: null,\n componentId: 'emailDivider',\n pluginId: EMAIL_BLOCK_STARTER_PLUGIN_ID,\n props: {},\n },\n {\n $id: null,\n componentId: 'emailText',\n pluginId: EMAIL_BLOCK_STARTER_PLUGIN_ID,\n props: {\n children: 'Your company · 123 Main St, City',\n variant: 'caption',\n align: 'center',\n color: FOOTER_TEXT_COLOR,\n },\n },\n {\n $id: null,\n componentId: 'emailText',\n pluginId: EMAIL_BLOCK_STARTER_PLUGIN_ID,\n props: {\n children:\n \"You're receiving this because you're a customer of Your company.\",\n variant: 'caption',\n align: 'center',\n color: FOOTER_TEXT_COLOR,\n },\n },\n ],\n },\n}\n\n/**\n * {@link EMAIL_BLOCK_STARTER_TREES} as JSON text: the form the native apps\n * read them in (`Contracts.emailBlockStarterTreesJson`), parsed when a block\n * is started.\n */\nexport const EMAIL_BLOCK_STARTER_TREES_JSON: string = JSON.stringify(\n EMAIL_BLOCK_STARTER_TREES,\n)\n"],"names":["EMAIL_BLOCK_STARTER_PLUGIN_ID","EMAIL_FOOTER_TEXT_COLOR","FOOTER_TEXT_COLOR","EMAIL_BLOCK_STARTER_TREES","header","$id","componentId","pluginId","props","align","padding","emailRole","nodes","alt","width","children","variant","footer","color","EMAIL_BLOCK_STARTER_TREES_JSON","JSON","stringify"],"mappings":"AAAA;;;;;;;;;;;;;;;CAeC,GAED;;;;;;;;;;;;;;;CAeC,GAED,gFAAgF,GAChF,OAAO,MAAMA,gCAAgC,QAAO;AAWpD;;;;CAIC,GACD,OAAO,MAAMC,0BAA0B,UAAS;AAChD,MAAMC,oBAAoBD;AAE1B,OAAO,MAAME,4BAET;IACFC,QAAQ;QACNC,KAAK;QACLC,aAAa;QACbC,UAAUP;QACV,sEAAsE;QACtE,iCAAiC;QACjCQ,OAAO;YAAEC,OAAO;YAAUC,SAAS;YAAIC,WAAW;QAAS;QAC3DC,OAAO;YACL;gBACEP,KAAK;gBACLC,aAAa;gBACbC,UAAUP;gBACVQ,OAAO;oBAAEK,KAAK;oBAAqBC,OAAO;oBAAKL,OAAO;gBAAS;YACjE;YACA;gBACEJ,KAAK;gBACLC,aAAa;gBACbC,UAAUP;gBACVQ,OAAO;oBACLO,UAAU;oBACVC,SAAS;oBACTP,OAAO;gBACT;YACF;SACD;IACH;IACAQ,QAAQ;QACNZ,KAAK;QACLC,aAAa;QACbC,UAAUP;QACV,sEAAsE;QACtE,iCAAiC;QACjCQ,OAAO;YAAEC,OAAO;YAAUC,SAAS;YAAIC,WAAW;QAAS;QAC3DC,OAAO;YACL;gBACEP,KAAK;gBACLC,aAAa;gBACbC,UAAUP;gBACVQ,OAAO,CAAC;YACV;YACA;gBACEH,KAAK;gBACLC,aAAa;gBACbC,UAAUP;gBACVQ,OAAO;oBACLO,UAAU;oBACVC,SAAS;oBACTP,OAAO;oBACPS,OAAOhB;gBACT;YACF;YACA;gBACEG,KAAK;gBACLC,aAAa;gBACbC,UAAUP;gBACVQ,OAAO;oBACLO,UACE;oBACFC,SAAS;oBACTP,OAAO;oBACPS,OAAOhB;gBACT;YACF;SACD;IACH;AACF,EAAC;AAED;;;;CAIC,GACD,OAAO,MAAMiB,iCAAyCC,KAAKC,SAAS,CAClElB,2BACD"}
@@ -15,136 +15,7 @@
15
15
  * limitations under the License.
16
16
  */
17
17
  import { type ScreenUid } from '../foundation';
18
- /**
19
- * Route path of a host's root screen. The tenant matcher joins the catch-all
20
- * segments and collapses an empty one to this value (`load-page-data.ts`:
21
- * `slugSegments.length ? slugSegments.join('/') : SCREEN_ROOT_PATH`), so the
22
- * root is `'/'` and every other path is slash-joined segments WITHOUT a
23
- * leading slash (`about`, later `company/about`).
24
- */
25
- export declare const SCREEN_ROOT_PATH = "/";
26
- /**
27
- * Normalizes user slug input into the routing-map path format described on
28
- * {@link SCREEN_ROOT_PATH}. `'/'` normalizes to the root path; anything else
29
- * becomes a single lowercase url-safe segment.
30
- *
31
- * Returns `undefined` for EMPTY input and for anything that sanitizes away
32
- * (`'###'`) — the two cases are deliberately the same answer, and the caller
33
- * decides what it means. In the Screens page's slug field an empty string
34
- * means "no address typed"; in a starter template it meant "home". Two
35
- * meanings for one value is what made AGL-1575 possible, so this function
36
- * refuses to pick one: a caller that wants the root must say `'/'`.
37
- *
38
- * This docstring used to claim empty input normalized to the root, which the
39
- * code has never done (see the spec, which asserts the `undefined`). A fix
40
- * written against the docstring instead of the behaviour would have shipped
41
- * broken; if you are here to make the code match the comment, do not.
42
- */
43
- export declare function normalizeScreenSlug(input: string | null | undefined): string | undefined;
44
- /**
45
- * Route segments the published site CANNOT serve, whatever the routing map
46
- * says (AGL-2076).
47
- *
48
- * Every entry was measured against production on 2026-08-19 with a fresh
49
- * `x-vercel-cache: MISS`, because a stale negative always passes. Two
50
- * mechanisms, both of which resolve ahead of `[host]/[scheme]/[[...slug]]`:
51
- *
52
- * - `404`, `500` — Next emits `pages/404.html` and `pages/500.html` even for
53
- * an app-router-only build, and the deployed filesystem answers them as
54
- * static files (`content-disposition: inline; filename="404"`,
55
- * `accept-ranges`, an `etag`, and no `x-nextjs-prerender`). The tenant
56
- * middleware's own CSP headers are on those responses, so the middleware
57
- * ran and its rewrite still lost. There is no rewrite this app can write
58
- * that beats them, which is why the honest fix is to refuse the slug at
59
- * authoring time rather than to keep promising an address that is dead.
60
- * - `search`, `api`, `_next`, `_static` — routes and exclusions the tenant
61
- * app owns. `/search` matches `app/[host]/[scheme]/search`; the other three are the
62
- * middleware matcher's exclusions, so no host rewrite happens at all and
63
- * the path is parsed as `[host]` with an empty slug.
64
- * - `aglyn-preview` — the same mechanism as `search`, one route over
65
- * (AGL-3205). The live-site preview of an unpublished collection entry is
66
- * a rewrite to `app/[host]/[scheme]/aglyn-preview/[...slug]`, because it
67
- * must be a DIFFERENT Next route from the ISR-cached catch-all; a static
68
- * folder wins over the optional catch-all beside it, so `/aglyn-preview/x`
69
- * would reach the preview page and 404 rather than render a screen. The
70
- * bare `/aglyn-preview` would in fact still serve — the preview route's
71
- * catch-all is required, not optional — and it is reserved anyway for the
72
- * reason given below about child paths: a child's path is composed from
73
- * its parent's slug, and this rule refuses that slug.
74
- * - `_aglyn` — the platform's own pages on every site, the leaving notice
75
- * at `/_aglyn/leaving` first (AGL-3452). The middleware rewrites that path
76
- * to an api route before the catch-all could see it.
77
- *
78
- * NOT reserved, and deliberately so, because measuring found them fine:
79
- * `401`, `403`, `503` (`/401` returns 200 through the catch-all — two of the
80
- * four designed error screens have always been viewable at their slugs, which
81
- * is what made the `404` case look like a Next.js defect), and `index`.
82
- *
83
- * `fonts` and `examples` USED to be dead for the third reason and are not
84
- * here: they were exclusions inherited from the Vercel platforms starter kit
85
- * for directories `apps/tenant/public` has never contained. They are given
86
- * back rather than reserved — see the matcher in `apps/tenant/middleware.ts`.
87
- */
88
- export declare const RESERVED_SCREEN_ROUTE_SEGMENTS: readonly string[];
89
- /**
90
- * The reserved segment a routing-map path would collide with, or `undefined`
91
- * when the path is servable. The segment rather than a boolean, so a refusal
92
- * can name what it is refusing over.
93
- *
94
- * Reads the FIRST segment only. For `api`/`_next`/`_static` that is exactly
95
- * right — the middleware excludes the whole subtree, so `api/docs` is as dead
96
- * as `api`. For `404`/`500`/`search` it over-refuses in principle (`/404/why`
97
- * would serve), and that is deliberate: a child path is composed from its
98
- * PARENT's slug, the parent would have to hold `404`, and this same rule
99
- * refuses that slug wherever one is typed. A second list to express a case
100
- * that cannot arise would be a second list to keep correct.
101
- *
102
- * Takes a composed routing-map path (`about`, `company/about`, `'/'`), not a
103
- * raw slug field — `'/'` is the home screen and is never reserved.
104
- */
105
- export declare function reservedScreenRouteSegment(path: string | null | undefined): string | undefined;
106
- /**
107
- * The refusal an author reads when {@link reservedScreenRouteSegment} names a
108
- * segment. One sentence, in one place, because the Screens page, the version
109
- * view and the besigner all have to say the same thing — three surfaces
110
- * wording a platform constraint three ways is how AGL-2093's precheck drifted.
111
- */
112
- export declare function reservedScreenRouteMessage(segment: string): string;
113
- /**
114
- * Does a raw slug value carry a path separator the one segment it describes
115
- * cannot hold (AGL-2572)?
116
- *
117
- * {@link normalizeScreenSlug} answers with a SINGLE segment, and it gets there
118
- * by deleting every character outside `[a-z0-9-_]` — an interior `/` included.
119
- * Whitespace becomes a `-`, but a `/` becomes nothing at all, so
120
- * `alternatives/webflow` normalizes to `alternativeswebflow` and the two words
121
- * are stored glued. Nothing about that is visible: the field accepts the
122
- * keystroke, the composed path reads plausibly, and the screen ends up
123
- * answering an address nobody typed. Two screens on `aglyn-marketing` carry a
124
- * slug of that shape.
125
- *
126
- * So the separator is a question a slug field asks BEFORE normalizing, and the
127
- * answer is a refusal rather than a silent repair — the caller cannot know
128
- * which segment the author meant, and guessing is how the glue happened.
129
- *
130
- * LEADING and TRAILING slashes are not separators here. `normalizeScreenSlug`
131
- * strips them deliberately (`/about/` → `about`), an author typing an address
132
- * writes them without meaning a hierarchy, and refusing them would reject a
133
- * value the normalizer has always handled correctly. `'/'` alone is the home
134
- * page and is never a separator.
135
- */
136
- export declare function screenSlugHasPathSeparator(input: string | null | undefined): boolean;
137
- /**
138
- * The refusal an author reads when {@link screenSlugHasPathSeparator} answers
139
- * true. One sentence, in one place, for the same reason
140
- * {@link reservedScreenRouteMessage} is: the Screens page, the version view
141
- * and the besigner all take a slug, and a constraint worded three ways drifts
142
- * into three different constraints.
143
- *
144
- * It names the alternative, because a `/` in this field almost always means
145
- * "nest me" and the hierarchy is what actually composes the longer path.
146
- */
147
- export declare const SCREEN_SLUG_PATH_SEPARATOR_MESSAGE: string;
18
+ export { normalizeScreenSlug, RESERVED_SCREEN_ROUTE_SEGMENTS, reservedScreenRouteMessage, reservedScreenRouteSegment, SCREEN_ROOT_PATH, SCREEN_SLUG_PATH_SEPARATOR_MESSAGE, screenSlugHasPathSeparator, } from './screen-slug';
148
19
  /**
149
20
  * `kind` of a page GROUP (AGL-3463): a folder in the Pages list that holds
150
21
  * pages and is not one.
@@ -16,159 +16,10 @@ import { _ as _extends } from "@swc/helpers/_/_extends";
16
16
  * limitations under the License.
17
17
  */ import { HOST_ERROR_SCREEN_SLOTS } from "../foundation/index.js";
18
18
  import { collectionListUrl } from "./collection-entries.js";
19
- import { ENTRY_PREVIEW_ROUTE_SEGMENT } from "./entry-preview-link.js";
20
- import { LEAVING_NOTICE_ROUTE_SEGMENT } from "./leaving-notice.js";
21
- import { PLATFORM_BRAND_NAME } from "./platform-brand.js";
22
19
  import { SCREEN_KIND_EMAIL } from "./screen-kind.js";
20
+ import { SCREEN_ROOT_PATH } from "./screen-slug.js";
23
21
  import { formatCollectionLinkValue, formatEntryLinkValue, formatFeedLinkValue, parseEntryLinkValue } from "./screen-link-value.js";
24
- /**
25
- * Route path of a host's root screen. The tenant matcher joins the catch-all
26
- * segments and collapses an empty one to this value (`load-page-data.ts`:
27
- * `slugSegments.length ? slugSegments.join('/') : SCREEN_ROOT_PATH`), so the
28
- * root is `'/'` and every other path is slash-joined segments WITHOUT a
29
- * leading slash (`about`, later `company/about`).
30
- */ export const SCREEN_ROOT_PATH = '/';
31
- /**
32
- * Normalizes user slug input into the routing-map path format described on
33
- * {@link SCREEN_ROOT_PATH}. `'/'` normalizes to the root path; anything else
34
- * becomes a single lowercase url-safe segment.
35
- *
36
- * Returns `undefined` for EMPTY input and for anything that sanitizes away
37
- * (`'###'`) — the two cases are deliberately the same answer, and the caller
38
- * decides what it means. In the Screens page's slug field an empty string
39
- * means "no address typed"; in a starter template it meant "home". Two
40
- * meanings for one value is what made AGL-1575 possible, so this function
41
- * refuses to pick one: a caller that wants the root must say `'/'`.
42
- *
43
- * This docstring used to claim empty input normalized to the root, which the
44
- * code has never done (see the spec, which asserts the `undefined`). A fix
45
- * written against the docstring instead of the behaviour would have shipped
46
- * broken; if you are here to make the code match the comment, do not.
47
- */ export function normalizeScreenSlug(input) {
48
- const trimmed = (input != null ? input : '').trim();
49
- if (!trimmed) return undefined;
50
- if (trimmed === SCREEN_ROOT_PATH) return SCREEN_ROOT_PATH;
51
- const segment = trimmed.toLowerCase().replace(/^\/+|\/+$/g, '').replace(/\s+/g, '-').replace(/[^a-z0-9-_]/g, '').replace(/-{2,}/g, '-').replace(/^-+|-+$/g, '');
52
- return segment || undefined;
53
- }
54
- /**
55
- * Route segments the published site CANNOT serve, whatever the routing map
56
- * says (AGL-2076).
57
- *
58
- * Every entry was measured against production on 2026-08-19 with a fresh
59
- * `x-vercel-cache: MISS`, because a stale negative always passes. Two
60
- * mechanisms, both of which resolve ahead of `[host]/[scheme]/[[...slug]]`:
61
- *
62
- * - `404`, `500` — Next emits `pages/404.html` and `pages/500.html` even for
63
- * an app-router-only build, and the deployed filesystem answers them as
64
- * static files (`content-disposition: inline; filename="404"`,
65
- * `accept-ranges`, an `etag`, and no `x-nextjs-prerender`). The tenant
66
- * middleware's own CSP headers are on those responses, so the middleware
67
- * ran and its rewrite still lost. There is no rewrite this app can write
68
- * that beats them, which is why the honest fix is to refuse the slug at
69
- * authoring time rather than to keep promising an address that is dead.
70
- * - `search`, `api`, `_next`, `_static` — routes and exclusions the tenant
71
- * app owns. `/search` matches `app/[host]/[scheme]/search`; the other three are the
72
- * middleware matcher's exclusions, so no host rewrite happens at all and
73
- * the path is parsed as `[host]` with an empty slug.
74
- * - `aglyn-preview` — the same mechanism as `search`, one route over
75
- * (AGL-3205). The live-site preview of an unpublished collection entry is
76
- * a rewrite to `app/[host]/[scheme]/aglyn-preview/[...slug]`, because it
77
- * must be a DIFFERENT Next route from the ISR-cached catch-all; a static
78
- * folder wins over the optional catch-all beside it, so `/aglyn-preview/x`
79
- * would reach the preview page and 404 rather than render a screen. The
80
- * bare `/aglyn-preview` would in fact still serve — the preview route's
81
- * catch-all is required, not optional — and it is reserved anyway for the
82
- * reason given below about child paths: a child's path is composed from
83
- * its parent's slug, and this rule refuses that slug.
84
- * - `_aglyn` — the platform's own pages on every site, the leaving notice
85
- * at `/_aglyn/leaving` first (AGL-3452). The middleware rewrites that path
86
- * to an api route before the catch-all could see it.
87
- *
88
- * NOT reserved, and deliberately so, because measuring found them fine:
89
- * `401`, `403`, `503` (`/401` returns 200 through the catch-all — two of the
90
- * four designed error screens have always been viewable at their slugs, which
91
- * is what made the `404` case look like a Next.js defect), and `index`.
92
- *
93
- * `fonts` and `examples` USED to be dead for the third reason and are not
94
- * here: they were exclusions inherited from the Vercel platforms starter kit
95
- * for directories `apps/tenant/public` has never contained. They are given
96
- * back rather than reserved — see the matcher in `apps/tenant/middleware.ts`.
97
- */ export const RESERVED_SCREEN_ROUTE_SEGMENTS = [
98
- '404',
99
- '500',
100
- 'search',
101
- ENTRY_PREVIEW_ROUTE_SEGMENT,
102
- 'api',
103
- '_next',
104
- '_static',
105
- LEAVING_NOTICE_ROUTE_SEGMENT
106
- ];
107
- /**
108
- * The reserved segment a routing-map path would collide with, or `undefined`
109
- * when the path is servable. The segment rather than a boolean, so a refusal
110
- * can name what it is refusing over.
111
- *
112
- * Reads the FIRST segment only. For `api`/`_next`/`_static` that is exactly
113
- * right — the middleware excludes the whole subtree, so `api/docs` is as dead
114
- * as `api`. For `404`/`500`/`search` it over-refuses in principle (`/404/why`
115
- * would serve), and that is deliberate: a child path is composed from its
116
- * PARENT's slug, the parent would have to hold `404`, and this same rule
117
- * refuses that slug wherever one is typed. A second list to express a case
118
- * that cannot arise would be a second list to keep correct.
119
- *
120
- * Takes a composed routing-map path (`about`, `company/about`, `'/'`), not a
121
- * raw slug field — `'/'` is the home screen and is never reserved.
122
- */ export function reservedScreenRouteSegment(path) {
123
- if (!path || path === SCREEN_ROOT_PATH) return undefined;
124
- const [first] = path.replace(/^\/+/, '').split('/');
125
- return RESERVED_SCREEN_ROUTE_SEGMENTS.includes(first) ? first : undefined;
126
- }
127
- /**
128
- * The refusal an author reads when {@link reservedScreenRouteSegment} names a
129
- * segment. One sentence, in one place, because the Screens page, the version
130
- * view and the besigner all have to say the same thing — three surfaces
131
- * wording a platform constraint three ways is how AGL-2093's precheck drifted.
132
- */ export function reservedScreenRouteMessage(segment) {
133
- return `"/${segment}" is a reserved address on every ${PLATFORM_BRAND_NAME} site — pick another slug`;
134
- }
135
- /**
136
- * Does a raw slug value carry a path separator the one segment it describes
137
- * cannot hold (AGL-2572)?
138
- *
139
- * {@link normalizeScreenSlug} answers with a SINGLE segment, and it gets there
140
- * by deleting every character outside `[a-z0-9-_]` — an interior `/` included.
141
- * Whitespace becomes a `-`, but a `/` becomes nothing at all, so
142
- * `alternatives/webflow` normalizes to `alternativeswebflow` and the two words
143
- * are stored glued. Nothing about that is visible: the field accepts the
144
- * keystroke, the composed path reads plausibly, and the screen ends up
145
- * answering an address nobody typed. Two screens on `aglyn-marketing` carry a
146
- * slug of that shape.
147
- *
148
- * So the separator is a question a slug field asks BEFORE normalizing, and the
149
- * answer is a refusal rather than a silent repair — the caller cannot know
150
- * which segment the author meant, and guessing is how the glue happened.
151
- *
152
- * LEADING and TRAILING slashes are not separators here. `normalizeScreenSlug`
153
- * strips them deliberately (`/about/` → `about`), an author typing an address
154
- * writes them without meaning a hierarchy, and refusing them would reject a
155
- * value the normalizer has always handled correctly. `'/'` alone is the home
156
- * page and is never a separator.
157
- */ export function screenSlugHasPathSeparator(input) {
158
- const trimmed = (input != null ? input : '').trim();
159
- if (!trimmed || trimmed === SCREEN_ROOT_PATH) return false;
160
- return trimmed.replace(/^\/+|\/+$/g, '').includes('/');
161
- }
162
- /**
163
- * The refusal an author reads when {@link screenSlugHasPathSeparator} answers
164
- * true. One sentence, in one place, for the same reason
165
- * {@link reservedScreenRouteMessage} is: the Screens page, the version view
166
- * and the besigner all take a slug, and a constraint worded three ways drifts
167
- * into three different constraints.
168
- *
169
- * It names the alternative, because a `/` in this field almost always means
170
- * "nest me" and the hierarchy is what actually composes the longer path.
171
- */ export const SCREEN_SLUG_PATH_SEPARATOR_MESSAGE = 'A slug is one path segment — remove the "/" and nest the page under a ' + 'parent page instead';
22
+ export { normalizeScreenSlug, RESERVED_SCREEN_ROUTE_SEGMENTS, reservedScreenRouteMessage, reservedScreenRouteSegment, SCREEN_ROOT_PATH, SCREEN_SLUG_PATH_SEPARATOR_MESSAGE, screenSlugHasPathSeparator } from "./screen-slug.js";
172
23
  /**
173
24
  * `kind` of a page GROUP (AGL-3463): a folder in the Pages list that holds
174
25
  * pages and is not one.