@aglyn/plugins-redirects 1.0.0-beta.221 → 1.0.0-beta.223

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,81 @@
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
+ import type { MatchKeySpec, TransferAliasDictionary, TransferCatalogInput, TransferField, TransferPolicyDefaults } from '@aglyn/aglyn/data-transfer';
18
+ import { REDIRECT_KINDS } from '../model/redirects';
19
+ /**
20
+ * What the redirects resource is made of, without reading anything: its key,
21
+ * its fields, the keys a row finds its rule by and other products' header
22
+ * spellings. Light on purpose — the console's boot registers the resource
23
+ * with these, and only an import or an export loads the module that reads
24
+ * and writes (`redirects-transfer.ts`).
25
+ */
26
+ /** The resource key declared in `plugins.config.json` (`transferResources`). */
27
+ export declare const REDIRECTS_TRANSFER_KEY = "redirects";
28
+ /** The kind `/api/hosts/resources` creates a rule as, and whose declaration holds its allow-list. */
29
+ export declare const REDIRECT_HOST_RESOURCE_KIND = "redirect";
30
+ /** The rule's match mode, as stored. */
31
+ export type RedirectKind = (typeof REDIRECT_KINDS)[number];
32
+ /** Field ids, as the catalog, the plan and the export name them. */
33
+ export declare const REDIRECT_FIELD: {
34
+ readonly source: "source";
35
+ readonly kind: "kind";
36
+ readonly destination: "destination";
37
+ readonly statusCode: "statusCode";
38
+ readonly priority: "priority";
39
+ readonly enabled: "enabled";
40
+ readonly approvedBy: "externalDestinationApprovedBy";
41
+ readonly createdAt: "createdAt";
42
+ readonly updatedAt: "updatedAt";
43
+ readonly createdBy: "createdBy";
44
+ readonly lastHitAt: "lastHitAt";
45
+ readonly hits: "hits30d";
46
+ };
47
+ /** The fields a file may write: the rule's own six, the create route's allow-list. */
48
+ export declare const REDIRECT_WRITABLE_FIELDS: readonly ["source", "destination", "statusCode", "kind", "priority", "enabled"];
49
+ /**
50
+ * The keys a row finds its rule by: the Aglyn ID, then the path it redirects
51
+ * from. The core folds the from-path only by trimming it; the lookup and the
52
+ * plan read it the way the redirects page stores it (lowercase, no trailing
53
+ * slash, no query) and within its match mode, so `/Old-Page/` finds the exact
54
+ * rule for `/old-page` and not the prefix rule beside it.
55
+ */
56
+ export declare const REDIRECTS_MATCH_KEYS: readonly MatchKeySpec[];
57
+ /** The field catalog: the rule, its usage, and what the platform stamps on it. */
58
+ export declare function redirectsTransferCatalog(): TransferCatalogInput;
59
+ /**
60
+ * Other products' redirect exports, by their own header spellings, so their
61
+ * files map without a person matching columns by hand.
62
+ */
63
+ export declare const REDIRECTS_ALIAS_DICTIONARIES: readonly TransferAliasDictionary[];
64
+ /** A Kind cell as the stored mode, or `null` when it names none. */
65
+ export declare function canonicalRedirectKind(value: unknown): RedirectKind | null;
66
+ /**
67
+ * Where an import of redirects starts: a rule already on the site takes the
68
+ * file's values. A redirect file is the statement of where each path goes —
69
+ * re-importing one with a new destination means "point it there" — so the
70
+ * core's fill blanks would keep every old destination and change nothing. A
71
+ * blank cell still leaves the rule's value alone.
72
+ */
73
+ export declare const REDIRECTS_DEFAULT_POLICY: TransferPolicyDefaults;
74
+ /**
75
+ * Whether a file's value and a rule's are the same as the page stores them
76
+ * (the review's Conflicts step and the dry run agree): a from-path by its
77
+ * stored form (`/Old-Page/` is `/old-page`) unless either side is a pattern,
78
+ * which is compared as typed; a destination by its stored form; a mode by
79
+ * its canonical name; a status code and a priority as numbers.
80
+ */
81
+ export declare function redirectValuesEqual(field: TransferField, a: unknown, b: unknown): boolean | undefined;
@@ -0,0 +1,398 @@
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
+ */ import { normalizeRedirectDestination, normalizeRedirectSource } from "../model/redirects.js";
17
+ /**
18
+ * What the redirects resource is made of, without reading anything: its key,
19
+ * its fields, the keys a row finds its rule by and other products' header
20
+ * spellings. Light on purpose — the console's boot registers the resource
21
+ * with these, and only an import or an export loads the module that reads
22
+ * and writes (`redirects-transfer.ts`).
23
+ */ /** The resource key declared in `plugins.config.json` (`transferResources`). */ export const REDIRECTS_TRANSFER_KEY = 'redirects';
24
+ /** The kind `/api/hosts/resources` creates a rule as, and whose declaration holds its allow-list. */ export const REDIRECT_HOST_RESOURCE_KIND = 'redirect';
25
+ /** Field ids, as the catalog, the plan and the export name them. */ export const REDIRECT_FIELD = {
26
+ source: 'source',
27
+ kind: 'kind',
28
+ destination: 'destination',
29
+ statusCode: 'statusCode',
30
+ priority: 'priority',
31
+ enabled: 'enabled',
32
+ approvedBy: 'externalDestinationApprovedBy',
33
+ createdAt: 'createdAt',
34
+ updatedAt: 'updatedAt',
35
+ createdBy: 'createdBy',
36
+ lastHitAt: 'lastHitAt',
37
+ hits: 'hits30d'
38
+ };
39
+ /** The fields a file may write: the rule's own six, the create route's allow-list. */ export const REDIRECT_WRITABLE_FIELDS = [
40
+ REDIRECT_FIELD.source,
41
+ REDIRECT_FIELD.destination,
42
+ REDIRECT_FIELD.statusCode,
43
+ REDIRECT_FIELD.kind,
44
+ REDIRECT_FIELD.priority,
45
+ REDIRECT_FIELD.enabled
46
+ ];
47
+ /**
48
+ * The keys a row finds its rule by: the Aglyn ID, then the path it redirects
49
+ * from. The core folds the from-path only by trimming it; the lookup and the
50
+ * plan read it the way the redirects page stores it (lowercase, no trailing
51
+ * slash, no query) and within its match mode, so `/Old-Page/` finds the exact
52
+ * rule for `/old-page` and not the prefix rule beside it.
53
+ */ export const REDIRECTS_MATCH_KEYS = [
54
+ {
55
+ fieldId: 'id',
56
+ normalizer: 'aglynId'
57
+ },
58
+ {
59
+ fieldId: REDIRECT_FIELD.source,
60
+ normalizer: 'trim'
61
+ }
62
+ ];
63
+ const GROUPS = [
64
+ {
65
+ id: 'rule',
66
+ label: 'Rule'
67
+ },
68
+ {
69
+ id: 'usage',
70
+ label: 'Usage'
71
+ }
72
+ ];
73
+ /** The field catalog: the rule, its usage, and what the platform stamps on it. */ export function redirectsTransferCatalog() {
74
+ return {
75
+ groups: GROUPS,
76
+ standard: [
77
+ {
78
+ id: REDIRECT_FIELD.source,
79
+ label: 'From path',
80
+ group: 'rule',
81
+ type: 'text',
82
+ required: true,
83
+ matchKey: true,
84
+ description: 'The path visitors arrive at, like /old-page; for a regex rule, the pattern.',
85
+ aliases: [
86
+ 'from',
87
+ 'source',
88
+ 'source path',
89
+ 'from url',
90
+ 'old path',
91
+ 'old url',
92
+ 'redirect from',
93
+ 'origin',
94
+ 'pattern'
95
+ ]
96
+ },
97
+ {
98
+ id: REDIRECT_FIELD.kind,
99
+ label: 'Kind',
100
+ group: 'rule',
101
+ type: 'text',
102
+ description: 'exact, prefix or regex; exact when blank.',
103
+ aliases: [
104
+ 'match',
105
+ 'match mode',
106
+ 'match type',
107
+ 'kind',
108
+ 'mode',
109
+ 'regex'
110
+ ]
111
+ },
112
+ {
113
+ id: REDIRECT_FIELD.destination,
114
+ label: 'To',
115
+ group: 'rule',
116
+ type: 'text',
117
+ required: true,
118
+ description: 'A path on this site, like /new-page, or an https:// address.',
119
+ aliases: [
120
+ 'to',
121
+ 'destination',
122
+ 'target',
123
+ 'to url',
124
+ 'new path',
125
+ 'new url',
126
+ 'redirect to',
127
+ 'redirect url'
128
+ ]
129
+ },
130
+ {
131
+ id: REDIRECT_FIELD.statusCode,
132
+ label: 'Status code',
133
+ group: 'rule',
134
+ type: 'integer',
135
+ description: '301, 302, 307 or 308; 302 when blank.',
136
+ aliases: [
137
+ 'status',
138
+ 'status code',
139
+ 'code',
140
+ 'http code',
141
+ 'http status',
142
+ 'type',
143
+ 'redirect type'
144
+ ]
145
+ },
146
+ {
147
+ id: REDIRECT_FIELD.priority,
148
+ label: 'Priority',
149
+ group: 'rule',
150
+ type: 'integer',
151
+ description: 'Lower fires first when several rules match; 100 when blank.',
152
+ aliases: [
153
+ 'order',
154
+ 'position'
155
+ ]
156
+ },
157
+ {
158
+ id: REDIRECT_FIELD.enabled,
159
+ label: 'Enabled',
160
+ group: 'rule',
161
+ type: 'boolean',
162
+ description: 'Whether the rule is on; on when blank.',
163
+ aliases: [
164
+ 'active',
165
+ 'on',
166
+ 'enabled'
167
+ ]
168
+ }
169
+ ],
170
+ derived: [
171
+ {
172
+ id: REDIRECT_FIELD.hits,
173
+ label: 'Hits in the last 30 days',
174
+ group: 'usage',
175
+ type: 'integer',
176
+ readOnly: true,
177
+ description: 'Sampled: one per cache window with traffic.'
178
+ }
179
+ ],
180
+ system: [
181
+ {
182
+ id: REDIRECT_FIELD.approvedBy,
183
+ label: 'Off-site destination approved by',
184
+ type: 'text',
185
+ readOnly: true,
186
+ description: 'Who confirmed an off-site destination. Importing one approves it in your name; it is never read from a file.'
187
+ },
188
+ {
189
+ id: REDIRECT_FIELD.lastHitAt,
190
+ label: 'Last hit',
191
+ group: 'usage',
192
+ type: 'datetime',
193
+ readOnly: true
194
+ },
195
+ {
196
+ id: REDIRECT_FIELD.createdAt,
197
+ label: 'Created',
198
+ type: 'datetime',
199
+ readOnly: true
200
+ },
201
+ {
202
+ id: REDIRECT_FIELD.updatedAt,
203
+ label: 'Updated',
204
+ type: 'datetime',
205
+ readOnly: true
206
+ },
207
+ {
208
+ id: REDIRECT_FIELD.createdBy,
209
+ label: 'Created by',
210
+ type: 'text',
211
+ readOnly: true
212
+ }
213
+ ]
214
+ };
215
+ }
216
+ /**
217
+ * Other products' redirect exports, by their own header spellings, so their
218
+ * files map without a person matching columns by hand.
219
+ */ export const REDIRECTS_ALIAS_DICTIONARIES = [
220
+ {
221
+ source: 'Shopify URL redirects',
222
+ aliases: {
223
+ [REDIRECT_FIELD.source]: [
224
+ 'Redirect from'
225
+ ],
226
+ [REDIRECT_FIELD.destination]: [
227
+ 'Redirect to'
228
+ ]
229
+ }
230
+ },
231
+ {
232
+ source: 'WordPress Redirection',
233
+ aliases: {
234
+ [REDIRECT_FIELD.source]: [
235
+ 'source'
236
+ ],
237
+ [REDIRECT_FIELD.destination]: [
238
+ 'target'
239
+ ],
240
+ [REDIRECT_FIELD.statusCode]: [
241
+ 'code'
242
+ ],
243
+ [REDIRECT_FIELD.kind]: [
244
+ 'regex'
245
+ ]
246
+ }
247
+ },
248
+ {
249
+ source: 'Yoast SEO redirects',
250
+ aliases: {
251
+ [REDIRECT_FIELD.source]: [
252
+ 'Origin'
253
+ ],
254
+ [REDIRECT_FIELD.destination]: [
255
+ 'Target'
256
+ ],
257
+ [REDIRECT_FIELD.statusCode]: [
258
+ 'Type'
259
+ ],
260
+ [REDIRECT_FIELD.kind]: [
261
+ 'Format'
262
+ ]
263
+ }
264
+ },
265
+ {
266
+ source: 'Wix URL redirects',
267
+ aliases: {
268
+ [REDIRECT_FIELD.source]: [
269
+ 'Old URL'
270
+ ],
271
+ [REDIRECT_FIELD.destination]: [
272
+ 'New URL'
273
+ ]
274
+ }
275
+ },
276
+ {
277
+ source: 'Webflow 301 redirects',
278
+ aliases: {
279
+ [REDIRECT_FIELD.source]: [
280
+ 'Old path'
281
+ ],
282
+ [REDIRECT_FIELD.destination]: [
283
+ 'Redirect to path'
284
+ ]
285
+ }
286
+ },
287
+ {
288
+ source: 'Spreadsheet',
289
+ aliases: {
290
+ [REDIRECT_FIELD.source]: [
291
+ 'Source',
292
+ 'From',
293
+ 'Old'
294
+ ],
295
+ [REDIRECT_FIELD.destination]: [
296
+ 'Destination',
297
+ 'Target',
298
+ 'To',
299
+ 'New'
300
+ ],
301
+ [REDIRECT_FIELD.statusCode]: [
302
+ 'Status',
303
+ 'Type',
304
+ 'HTTP status code'
305
+ ]
306
+ }
307
+ }
308
+ ];
309
+ /**
310
+ * The spellings a file's Kind cell may take, read to the stored mode. Covers
311
+ * the console's own labels ("Path prefix"), the words other products use,
312
+ * and a yes/no "regex" column (the WordPress Redirection export's).
313
+ * Yoast's Format column says `plain` or `regex`.
314
+ */ const KIND_SPELLINGS = {
315
+ exact: 'exact',
316
+ 'exact path': 'exact',
317
+ 'exact match': 'exact',
318
+ plain: 'exact',
319
+ path: 'exact',
320
+ page: 'exact',
321
+ '0': 'exact',
322
+ no: 'exact',
323
+ false: 'exact',
324
+ prefix: 'prefix',
325
+ 'path prefix': 'prefix',
326
+ 'starts with': 'prefix',
327
+ folder: 'prefix',
328
+ directory: 'prefix',
329
+ regex: 'regex',
330
+ regexp: 'regex',
331
+ 'regular expression': 'regex',
332
+ pattern: 'regex',
333
+ '1': 'regex',
334
+ yes: 'regex',
335
+ true: 'regex'
336
+ };
337
+ /** A Kind cell as the stored mode, or `null` when it names none. */ export function canonicalRedirectKind(value) {
338
+ var _KIND_SPELLINGS_text;
339
+ if (typeof value === 'boolean') return value ? 'regex' : 'exact';
340
+ const text = String(value != null ? value : '').trim().toLowerCase().replace(/\s+/g, ' ');
341
+ return (_KIND_SPELLINGS_text = KIND_SPELLINGS[text]) != null ? _KIND_SPELLINGS_text : null;
342
+ }
343
+ /**
344
+ * Where an import of redirects starts: a rule already on the site takes the
345
+ * file's values. A redirect file is the statement of where each path goes —
346
+ * re-importing one with a new destination means "point it there" — so the
347
+ * core's fill blanks would keep every old destination and change nothing. A
348
+ * blank cell still leaves the rule's value alone.
349
+ */ export const REDIRECTS_DEFAULT_POLICY = {
350
+ fieldDefault: {
351
+ mode: 'overwrite',
352
+ blank: 'leave'
353
+ },
354
+ note: 'A redirect file says where each path goes, so a rule already on the site takes the ' + 'file’s destination, status, priority and on/off. Choose Fill blanks to keep what the site has.'
355
+ };
356
+ const textOf = (value)=>typeof value === 'string' ? value : '';
357
+ const isBlankCell = (value)=>value === null || value === undefined || typeof value === 'string' && !value.trim();
358
+ /** Characters only a pattern holds; a path compared with one is compared as typed. */ const PATTERN_CHARACTERS = /[\^$*+?()[\]{}|\\]/;
359
+ /**
360
+ * Whether a file's value and a rule's are the same as the page stores them
361
+ * (the review's Conflicts step and the dry run agree): a from-path by its
362
+ * stored form (`/Old-Page/` is `/old-page`) unless either side is a pattern,
363
+ * which is compared as typed; a destination by its stored form; a mode by
364
+ * its canonical name; a status code and a priority as numbers.
365
+ */ export function redirectValuesEqual(field, a, b) {
366
+ if (isBlankCell(a) || isBlankCell(b)) return undefined;
367
+ switch(field.id){
368
+ case REDIRECT_FIELD.source:
369
+ {
370
+ const left = textOf(a).trim();
371
+ const right = textOf(b).trim();
372
+ if (PATTERN_CHARACTERS.test(left) || PATTERN_CHARACTERS.test(right)) return left === right;
373
+ const stored = normalizeRedirectSource(left);
374
+ return stored !== null && stored === normalizeRedirectSource(right) ? true : left === right;
375
+ }
376
+ case REDIRECT_FIELD.destination:
377
+ {
378
+ const stored = normalizeRedirectDestination(textOf(a));
379
+ return stored !== null && stored === normalizeRedirectDestination(textOf(b)) ? true : textOf(a).trim() === textOf(b).trim();
380
+ }
381
+ case REDIRECT_FIELD.kind:
382
+ {
383
+ const kind = canonicalRedirectKind(a);
384
+ return kind !== null && kind === canonicalRedirectKind(b) ? true : undefined;
385
+ }
386
+ case REDIRECT_FIELD.statusCode:
387
+ case REDIRECT_FIELD.priority:
388
+ {
389
+ const left = Number(a);
390
+ const right = Number(b);
391
+ return Number.isFinite(left) && Number.isFinite(right) ? left === right : undefined;
392
+ }
393
+ default:
394
+ return undefined;
395
+ }
396
+ }
397
+
398
+ //# sourceMappingURL=redirects-transfer-fields.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../../../../../../libs/plugins/redirects/src/lib/transfer/redirects-transfer-fields.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\nimport type {\n MatchKeySpec,\n TransferAliasDictionary,\n TransferCatalogInput,\n TransferField,\n TransferPolicyDefaults,\n} from '@aglyn/aglyn/data-transfer'\nimport { REDIRECT_KINDS, normalizeRedirectDestination, normalizeRedirectSource } from '../model/redirects'\n\n/**\n * What the redirects resource is made of, without reading anything: its key,\n * its fields, the keys a row finds its rule by and other products' header\n * spellings. Light on purpose — the console's boot registers the resource\n * with these, and only an import or an export loads the module that reads\n * and writes (`redirects-transfer.ts`).\n */\n\n/** The resource key declared in `plugins.config.json` (`transferResources`). */\nexport const REDIRECTS_TRANSFER_KEY = 'redirects'\n\n/** The kind `/api/hosts/resources` creates a rule as, and whose declaration holds its allow-list. */\nexport const REDIRECT_HOST_RESOURCE_KIND = 'redirect'\n\n/** The rule's match mode, as stored. */\nexport type RedirectKind = (typeof REDIRECT_KINDS)[number]\n\n/** Field ids, as the catalog, the plan and the export name them. */\nexport const REDIRECT_FIELD = {\n source: 'source',\n kind: 'kind',\n destination: 'destination',\n statusCode: 'statusCode',\n priority: 'priority',\n enabled: 'enabled',\n approvedBy: 'externalDestinationApprovedBy',\n createdAt: 'createdAt',\n updatedAt: 'updatedAt',\n createdBy: 'createdBy',\n lastHitAt: 'lastHitAt',\n hits: 'hits30d',\n} as const\n\n/** The fields a file may write: the rule's own six, the create route's allow-list. */\nexport const REDIRECT_WRITABLE_FIELDS = [\n REDIRECT_FIELD.source,\n REDIRECT_FIELD.destination,\n REDIRECT_FIELD.statusCode,\n REDIRECT_FIELD.kind,\n REDIRECT_FIELD.priority,\n REDIRECT_FIELD.enabled,\n] as const\n\n/**\n * The keys a row finds its rule by: the Aglyn ID, then the path it redirects\n * from. The core folds the from-path only by trimming it; the lookup and the\n * plan read it the way the redirects page stores it (lowercase, no trailing\n * slash, no query) and within its match mode, so `/Old-Page/` finds the exact\n * rule for `/old-page` and not the prefix rule beside it.\n */\nexport const REDIRECTS_MATCH_KEYS: readonly MatchKeySpec[] = [\n { fieldId: 'id', normalizer: 'aglynId' },\n { fieldId: REDIRECT_FIELD.source, normalizer: 'trim' },\n]\n\nconst GROUPS = [\n { id: 'rule', label: 'Rule' },\n { id: 'usage', label: 'Usage' },\n]\n\n/** The field catalog: the rule, its usage, and what the platform stamps on it. */\nexport function redirectsTransferCatalog(): TransferCatalogInput {\n return {\n groups: GROUPS,\n standard: [\n {\n id: REDIRECT_FIELD.source,\n label: 'From path',\n group: 'rule',\n type: 'text',\n required: true,\n matchKey: true,\n description:\n 'The path visitors arrive at, like /old-page; for a regex rule, the pattern.',\n aliases: ['from', 'source', 'source path', 'from url', 'old path', 'old url', 'redirect from', 'origin', 'pattern'],\n },\n {\n id: REDIRECT_FIELD.kind,\n label: 'Kind',\n group: 'rule',\n type: 'text',\n description: 'exact, prefix or regex; exact when blank.',\n aliases: ['match', 'match mode', 'match type', 'kind', 'mode', 'regex'],\n },\n {\n id: REDIRECT_FIELD.destination,\n label: 'To',\n group: 'rule',\n type: 'text',\n required: true,\n description: 'A path on this site, like /new-page, or an https:// address.',\n aliases: ['to', 'destination', 'target', 'to url', 'new path', 'new url', 'redirect to', 'redirect url'],\n },\n {\n id: REDIRECT_FIELD.statusCode,\n label: 'Status code',\n group: 'rule',\n type: 'integer',\n description: '301, 302, 307 or 308; 302 when blank.',\n aliases: ['status', 'status code', 'code', 'http code', 'http status', 'type', 'redirect type'],\n },\n {\n id: REDIRECT_FIELD.priority,\n label: 'Priority',\n group: 'rule',\n type: 'integer',\n description: 'Lower fires first when several rules match; 100 when blank.',\n aliases: ['order', 'position'],\n },\n {\n id: REDIRECT_FIELD.enabled,\n label: 'Enabled',\n group: 'rule',\n type: 'boolean',\n description: 'Whether the rule is on; on when blank.',\n aliases: ['active', 'on', 'enabled'],\n },\n ],\n derived: [\n {\n id: REDIRECT_FIELD.hits,\n label: 'Hits in the last 30 days',\n group: 'usage',\n type: 'integer',\n readOnly: true,\n description: 'Sampled: one per cache window with traffic.',\n },\n ],\n system: [\n {\n id: REDIRECT_FIELD.approvedBy,\n label: 'Off-site destination approved by',\n type: 'text',\n readOnly: true,\n description:\n 'Who confirmed an off-site destination. Importing one approves it in your name; it is never read from a file.',\n },\n { id: REDIRECT_FIELD.lastHitAt, label: 'Last hit', group: 'usage', type: 'datetime', readOnly: true },\n { id: REDIRECT_FIELD.createdAt, label: 'Created', type: 'datetime', readOnly: true },\n { id: REDIRECT_FIELD.updatedAt, label: 'Updated', type: 'datetime', readOnly: true },\n { id: REDIRECT_FIELD.createdBy, label: 'Created by', type: 'text', readOnly: true },\n ],\n }\n}\n\n/**\n * Other products' redirect exports, by their own header spellings, so their\n * files map without a person matching columns by hand.\n */\nexport const REDIRECTS_ALIAS_DICTIONARIES: readonly TransferAliasDictionary[] = [\n {\n source: 'Shopify URL redirects',\n aliases: {\n [REDIRECT_FIELD.source]: ['Redirect from'],\n [REDIRECT_FIELD.destination]: ['Redirect to'],\n },\n },\n {\n source: 'WordPress Redirection',\n aliases: {\n [REDIRECT_FIELD.source]: ['source'],\n [REDIRECT_FIELD.destination]: ['target'],\n [REDIRECT_FIELD.statusCode]: ['code'],\n [REDIRECT_FIELD.kind]: ['regex'],\n },\n },\n {\n source: 'Yoast SEO redirects',\n aliases: {\n [REDIRECT_FIELD.source]: ['Origin'],\n [REDIRECT_FIELD.destination]: ['Target'],\n [REDIRECT_FIELD.statusCode]: ['Type'],\n [REDIRECT_FIELD.kind]: ['Format'],\n },\n },\n {\n source: 'Wix URL redirects',\n aliases: {\n [REDIRECT_FIELD.source]: ['Old URL'],\n [REDIRECT_FIELD.destination]: ['New URL'],\n },\n },\n {\n source: 'Webflow 301 redirects',\n aliases: {\n [REDIRECT_FIELD.source]: ['Old path'],\n [REDIRECT_FIELD.destination]: ['Redirect to path'],\n },\n },\n {\n source: 'Spreadsheet',\n aliases: {\n [REDIRECT_FIELD.source]: ['Source', 'From', 'Old'],\n [REDIRECT_FIELD.destination]: ['Destination', 'Target', 'To', 'New'],\n [REDIRECT_FIELD.statusCode]: ['Status', 'Type', 'HTTP status code'],\n },\n },\n]\n\n/**\n * The spellings a file's Kind cell may take, read to the stored mode. Covers\n * the console's own labels (\"Path prefix\"), the words other products use,\n * and a yes/no \"regex\" column (the WordPress Redirection export's).\n * Yoast's Format column says `plain` or `regex`.\n */\nconst KIND_SPELLINGS: Readonly<Record<string, RedirectKind>> = {\n exact: 'exact',\n 'exact path': 'exact',\n 'exact match': 'exact',\n plain: 'exact',\n path: 'exact',\n page: 'exact',\n '0': 'exact',\n no: 'exact',\n false: 'exact',\n prefix: 'prefix',\n 'path prefix': 'prefix',\n 'starts with': 'prefix',\n folder: 'prefix',\n directory: 'prefix',\n regex: 'regex',\n regexp: 'regex',\n 'regular expression': 'regex',\n pattern: 'regex',\n '1': 'regex',\n yes: 'regex',\n true: 'regex',\n}\n\n/** A Kind cell as the stored mode, or `null` when it names none. */\nexport function canonicalRedirectKind(value: unknown): RedirectKind | null {\n if (typeof value === 'boolean') return value ? 'regex' : 'exact'\n const text = String(value ?? '')\n .trim()\n .toLowerCase()\n .replace(/\\s+/g, ' ')\n return KIND_SPELLINGS[text] ?? null\n}\n\n/**\n * Where an import of redirects starts: a rule already on the site takes the\n * file's values. A redirect file is the statement of where each path goes —\n * re-importing one with a new destination means \"point it there\" — so the\n * core's fill blanks would keep every old destination and change nothing. A\n * blank cell still leaves the rule's value alone.\n */\nexport const REDIRECTS_DEFAULT_POLICY: TransferPolicyDefaults = {\n fieldDefault: { mode: 'overwrite', blank: 'leave' },\n note:\n 'A redirect file says where each path goes, so a rule already on the site takes the ' +\n 'file’s destination, status, priority and on/off. Choose Fill blanks to keep what the site has.',\n}\n\nconst textOf = (value: unknown): string => (typeof value === 'string' ? value : '')\nconst isBlankCell = (value: unknown): boolean =>\n value === null || value === undefined || (typeof value === 'string' && !value.trim())\n\n/** Characters only a pattern holds; a path compared with one is compared as typed. */\nconst PATTERN_CHARACTERS = /[\\^$*+?()[\\]{}|\\\\]/\n\n/**\n * Whether a file's value and a rule's are the same as the page stores them\n * (the review's Conflicts step and the dry run agree): a from-path by its\n * stored form (`/Old-Page/` is `/old-page`) unless either side is a pattern,\n * which is compared as typed; a destination by its stored form; a mode by\n * its canonical name; a status code and a priority as numbers.\n */\nexport function redirectValuesEqual(field: TransferField, a: unknown, b: unknown): boolean | undefined {\n if (isBlankCell(a) || isBlankCell(b)) return undefined\n switch (field.id) {\n case REDIRECT_FIELD.source: {\n const left = textOf(a).trim()\n const right = textOf(b).trim()\n if (PATTERN_CHARACTERS.test(left) || PATTERN_CHARACTERS.test(right)) return left === right\n const stored = normalizeRedirectSource(left)\n return stored !== null && stored === normalizeRedirectSource(right) ? true : left === right\n }\n case REDIRECT_FIELD.destination: {\n const stored = normalizeRedirectDestination(textOf(a))\n return stored !== null && stored === normalizeRedirectDestination(textOf(b)) ? true : textOf(a).trim() === textOf(b).trim()\n }\n case REDIRECT_FIELD.kind: {\n const kind = canonicalRedirectKind(a)\n return kind !== null && kind === canonicalRedirectKind(b) ? true : undefined\n }\n case REDIRECT_FIELD.statusCode:\n case REDIRECT_FIELD.priority: {\n const left = Number(a)\n const right = Number(b)\n return Number.isFinite(left) && Number.isFinite(right) ? left === right : undefined\n }\n default:\n return undefined\n }\n}\n"],"names":["normalizeRedirectDestination","normalizeRedirectSource","REDIRECTS_TRANSFER_KEY","REDIRECT_HOST_RESOURCE_KIND","REDIRECT_FIELD","source","kind","destination","statusCode","priority","enabled","approvedBy","createdAt","updatedAt","createdBy","lastHitAt","hits","REDIRECT_WRITABLE_FIELDS","REDIRECTS_MATCH_KEYS","fieldId","normalizer","GROUPS","id","label","redirectsTransferCatalog","groups","standard","group","type","required","matchKey","description","aliases","derived","readOnly","system","REDIRECTS_ALIAS_DICTIONARIES","KIND_SPELLINGS","exact","plain","path","page","no","false","prefix","folder","directory","regex","regexp","pattern","yes","true","canonicalRedirectKind","value","text","String","trim","toLowerCase","replace","REDIRECTS_DEFAULT_POLICY","fieldDefault","mode","blank","note","textOf","isBlankCell","undefined","PATTERN_CHARACTERS","redirectValuesEqual","field","a","b","left","right","test","stored","Number","isFinite"],"mappings":"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"}
@@ -0,0 +1,73 @@
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
+ import { checkEntitlement } from '@aglyn/aglyn/app-utils/plan-entitlements';
18
+ import { type RowMatchOutcome } from '@aglyn/aglyn/data-transfer';
19
+ import { type ResolvedPluginHostResource } from '@aglyn/aglyn/plugin-manager/plugin-host-resources';
20
+ import type { PluginTransferResource } from '@aglyn/aglyn/plugin-manager/plugin-transfer-resources';
21
+ /** The billing document a plan's entitlement and counter are read from. */
22
+ type OrgBilling = Parameters<typeof checkEntitlement>[0];
23
+ export interface RedirectsTransferDeps {
24
+ firestore: FirebaseFirestore.Firestore;
25
+ /** Defaults to `Date.now`. */
26
+ now?: () => number;
27
+ /** The site's owning workspace's billing document (`getOrgForHost`); `null` reads as the free plan. */
28
+ loadOrg(hostId: string): Promise<OrgBilling | null>;
29
+ /**
30
+ * Drops the site's cached pages at these addresses, best effort. Defaults
31
+ * to the platform's site cache (`dropPluginSiteCache`), the announcement
32
+ * the create route makes for a new rule.
33
+ */
34
+ announcePaths?(hostId: string, paths: readonly string[]): Promise<void>;
35
+ /** Writes the site activity line for a created rule, best effort. */
36
+ logCreated?(hostId: string, actorUid: string, ruleId: string, resource: ResolvedPluginHostResource): Promise<void>;
37
+ }
38
+ type StoredRule = Record<string, unknown>;
39
+ /**
40
+ * A stored rule's fields as the redirects page reads them into its editor: a
41
+ * missing mode is exact, a missing priority 100, a status code outside the
42
+ * four is 302 (as the serve path reads it), and a rule is on unless it says
43
+ * `false`. The export, the dry run's "before" and undo all read this, so a
44
+ * file exported and imported back is unchanged.
45
+ */
46
+ export declare function redirectRuleValues(id: string, data: StoredRule): Record<string, unknown>;
47
+ /**
48
+ * Each row's rule, as the page tells rules apart: a row the engine found by
49
+ * its id keeps that rule; any other row is the rule of its mode (exact when
50
+ * the file gives none) at its from-path as stored — so `/blog` names the
51
+ * exact rule or the prefix rule beside it by the row's mode, never both.
52
+ * Two rows naming one rule are both matched to it here, and the plan refuses
53
+ * the later one, saying which row it repeats, rather than holding it back as
54
+ * a duplicate. `existing` holds every live rule's values by id; running this
55
+ * over its own outcomes changes nothing.
56
+ */
57
+ export declare function redirectMatchOutcomes(rows: ReadonlyArray<Readonly<Record<string, unknown>>>, engine: readonly RowMatchOutcome[], existing: ReadonlyMap<string, Readonly<Record<string, unknown>>>, live: ReadonlyArray<{
58
+ id: string;
59
+ data: StoredRule;
60
+ }>): RowMatchOutcome[];
61
+ /**
62
+ * The id a rule an import creates is written under: the job's row, so a
63
+ * write retried after it landed and before the ledger recorded it finds its
64
+ * own rule instead of making a second one. Without a job, a fresh resource id.
65
+ */
66
+ export declare function importedRedirectId(jobId: string | undefined, row: number): string;
67
+ /**
68
+ * The redirects resource over `deps.firestore`: `hosts/{hostId}/redirects`,
69
+ * with every hook the job engine and the export route call. See the block
70
+ * above for what each write checks.
71
+ */
72
+ export declare function createRedirectsTransferResource(deps: RedirectsTransferDeps): PluginTransferResource;
73
+ export {};