@antelopejs/dms-mailing 0.7.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -31
- package/dist/automation/email-event-trigger.d.ts +2 -2
- package/dist/automation/send-template-action.d.ts +11 -3
- package/dist/automation/send-template-action.js +1 -1
- package/dist/automation/send-template-action.js.map +1 -1
- package/dist/crons/jobs.d.ts +1 -1
- package/dist/data/body.d.ts +2 -1
- package/dist/data/body.js +4 -2
- package/dist/data/body.js.map +1 -1
- package/dist/data/read-only-routes.d.ts +4 -4
- package/dist/data/sends-table.d.ts +4 -4
- package/dist/data/suppressions-table.d.ts +4 -4
- package/dist/data/templates-table.d.ts +35 -12
- package/dist/data/templates-table.js +30 -24
- package/dist/data/templates-table.js.map +1 -1
- package/dist/data-types/categories-type.d.ts +1 -2
- package/dist/data-types/categories-type.js +3 -5
- package/dist/data-types/categories-type.js.map +1 -1
- package/dist/data-types/category-type.d.ts +1 -2
- package/dist/data-types/category-type.js +3 -5
- package/dist/data-types/category-type.js.map +1 -1
- package/dist/db/models/send-events.model.d.ts +7 -5
- package/dist/db/models/sends.model.d.ts +7 -5
- package/dist/db/models/sends.model.js.map +1 -1
- package/dist/db/models/settings.model.d.ts +7 -5
- package/dist/db/models/suppressions.model.d.ts +7 -5
- package/dist/db/models/template-versions.model.d.ts +7 -5
- package/dist/db/models/template-versions.model.js +4 -3
- package/dist/db/models/template-versions.model.js.map +1 -1
- package/dist/db/models/templates.model.d.ts +7 -5
- package/dist/db/models/templates.model.js +2 -1
- package/dist/db/models/templates.model.js.map +1 -1
- package/dist/db/models/webhook-status.model.d.ts +7 -5
- package/dist/db/tables/templates.table.js.map +1 -1
- package/dist/engine/changes.js +8 -18
- package/dist/engine/changes.js.map +1 -1
- package/dist/engine/conditions.d.ts +2 -1
- package/dist/engine/conditions.js +5 -5
- package/dist/engine/conditions.js.map +1 -1
- package/dist/engine/coverage.d.ts +2 -1
- package/dist/engine/coverage.js.map +1 -1
- package/dist/engine/fallback-html.js +8 -8
- package/dist/engine/fallback-html.js.map +1 -1
- package/dist/engine/formats.d.ts +4 -6
- package/dist/engine/formats.js +22 -8
- package/dist/engine/formats.js.map +1 -1
- package/dist/engine/paths.d.ts +4 -2
- package/dist/engine/paths.js +16 -5
- package/dist/engine/paths.js.map +1 -1
- package/dist/engine/plain-text.js +2 -4
- package/dist/engine/plain-text.js.map +1 -1
- package/dist/engine/resolve.d.ts +2 -1
- package/dist/engine/resolve.js +1 -1
- package/dist/engine/resolve.js.map +1 -1
- package/dist/engine/system-variables.d.ts +2 -1
- package/dist/engine/system-variables.js +1 -1
- package/dist/engine/system-variables.js.map +1 -1
- package/dist/engine/tokens.d.ts +2 -1
- package/dist/engine/tokens.js.map +1 -1
- package/dist/hooks/tenant-export.js +10 -9
- package/dist/hooks/tenant-export.js.map +1 -1
- package/dist/pages/sends.js.map +1 -1
- package/dist/pages/templates.js +3 -1
- package/dist/pages/templates.js.map +1 -1
- package/dist/pages/toolbar.js.map +1 -1
- package/dist/routes/events.js +6 -4
- package/dist/routes/events.js.map +1 -1
- package/dist/routes/index.d.ts +1 -0
- package/dist/routes/index.js +1 -0
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/metrics.js +31 -31
- package/dist/routes/metrics.js.map +1 -1
- package/dist/routes/period.js +5 -5
- package/dist/routes/period.js.map +1 -1
- package/dist/routes/sends.js +4 -5
- package/dist/routes/sends.js.map +1 -1
- package/dist/routes/settings.d.ts +1 -1
- package/dist/routes/settings.js +16 -14
- package/dist/routes/settings.js.map +1 -1
- package/dist/routes/suppressions.js +9 -6
- package/dist/routes/suppressions.js.map +1 -1
- package/dist/routes/template-content.d.ts +27 -0
- package/dist/routes/template-content.js +3 -0
- package/dist/routes/template-content.js.map +1 -0
- package/dist/routes/templates.d.ts +7 -30
- package/dist/routes/templates.js +7 -6
- package/dist/routes/templates.js.map +1 -1
- package/dist/routes/zod-json.d.ts +3 -0
- package/dist/routes/zod-json.js +21 -0
- package/dist/routes/zod-json.js.map +1 -0
- package/dist/services/attention.js +16 -8
- package/dist/services/attention.js.map +1 -1
- package/dist/services/catalog.js +4 -2
- package/dist/services/catalog.js.map +1 -1
- package/dist/services/categories.js +6 -6
- package/dist/services/categories.js.map +1 -1
- package/dist/services/checked-send.js +16 -9
- package/dist/services/checked-send.js.map +1 -1
- package/dist/services/declaration-error.d.ts +1 -1
- package/dist/services/declaration-error.js.map +1 -1
- package/dist/services/declarations.js +4 -1
- package/dist/services/declarations.js.map +1 -1
- package/dist/services/events.js +13 -7
- package/dist/services/events.js.map +1 -1
- package/dist/services/localized-text.d.ts +2 -1
- package/dist/services/localized-text.js +2 -1
- package/dist/services/localized-text.js.map +1 -1
- package/dist/services/metrics.js +2 -5
- package/dist/services/metrics.js.map +1 -1
- package/dist/services/render.js.map +1 -1
- package/dist/services/send-import.js +7 -3
- package/dist/services/send-import.js.map +1 -1
- package/dist/services/send.js +7 -7
- package/dist/services/send.js.map +1 -1
- package/dist/services/settings.d.ts +4 -2
- package/dist/services/settings.js +5 -4
- package/dist/services/settings.js.map +1 -1
- package/dist/services/starters.d.ts +3 -2
- package/dist/services/starters.js +5 -5
- package/dist/services/starters.js.map +1 -1
- package/dist/services/template-stats.js +5 -2
- package/dist/services/template-stats.js.map +1 -1
- package/dist/services/templates.d.ts +2 -1
- package/dist/services/templates.js +8 -4
- package/dist/services/templates.js.map +1 -1
- package/dist/services/webhook-status.d.ts +2 -2
- package/dist/services/webhook-status.js +6 -4
- package/dist/services/webhook-status.js.map +1 -1
- package/dist/types/json.d.ts +1 -0
- package/dist/types/json.js +3 -0
- package/dist/types/json.js.map +1 -0
- package/dist/types/settings.d.ts +2 -2
- package/dist/validation/declarations.schema.d.ts +44 -44
- package/dist/validation/declarations.schema.js +6 -5
- package/dist/validation/declarations.schema.js.map +1 -1
- package/dist/validation/guards.d.ts +3 -0
- package/dist/validation/guards.js +15 -0
- package/dist/validation/guards.js.map +1 -0
- package/dist/validation/json.d.ts +5 -0
- package/dist/validation/json.js +54 -0
- package/dist/validation/json.js.map +1 -0
- package/dist/validation/json.schema.d.ts +2 -0
- package/dist/validation/json.schema.js +9 -0
- package/dist/validation/json.schema.js.map +1 -0
- package/dist/validation/send-event-type.schema.d.ts +2 -0
- package/dist/validation/send-event-type.schema.js +11 -0
- package/dist/validation/send-event-type.schema.js.map +1 -0
- package/dist/validation/settings.schema.js +1 -1
- package/dist/validation/settings.schema.js.map +1 -1
- package/dist/validation/templates.schema.d.ts +44 -42
- package/dist/validation/templates.schema.js +4 -3
- package/dist/validation/templates.schema.js.map +1 -1
- package/frontend-vue/app/build/components/TemplateDrawerFooter.vue +45 -0
- package/frontend-vue/app/build/components/TemplateDrawerPerformance.vue +102 -0
- package/frontend-vue/app/build/components/TemplateDrawerVariables.vue +110 -0
- package/frontend-vue/app/build/composables/useDrawerStats.ts +78 -0
- package/frontend-vue/app/components/DuplicateModal.vue +3 -11
- package/frontend-vue/app/components/FixAddressModal.vue +2 -1
- package/frontend-vue/app/components/NewTemplateModal.vue +3 -11
- package/frontend-vue/app/components/RealSendModal.vue +6 -17
- package/frontend-vue/app/components/RenderingModal.vue +2 -1
- package/frontend-vue/app/components/SendDrawer.vue +5 -4
- package/frontend-vue/app/components/StarterGallery.vue +3 -11
- package/frontend-vue/app/components/TemplateDrawer.vue +40 -269
- package/frontend-vue/app/components/TestSendModal.vue +7 -18
- package/frontend-vue/app/components/VersionHistory.vue +2 -7
- package/frontend-vue/app/components/WebhookStatus.vue +2 -1
- package/frontend-vue/app/components/editor/EditorWorkspace.vue +2 -7
- package/frontend-vue/build/backend-boundary.ts +122 -0
- package/frontend-vue/dms.frontend.build.ts +7 -0
- package/frontend-vue/eslint.config.mjs +13 -0
- package/frontend-vue/package.json +4 -2
- package/frontend-vue/pnpm-lock.yaml +250 -61
- package/frontend-vue/tests/backend-boundary.test.ts +203 -0
- package/frontend-vue/tests/dnd.test.ts +1 -2
- package/frontend-vue/tests/editor-block-list.test.ts +136 -53
- package/frontend-vue/tests/helpers/auth-fetch.ts +123 -0
- package/frontend-vue/tests/helpers/nuxt-globals.ts +17 -1
- package/frontend-vue/tests/new-template-modal.test.ts +41 -6
- package/frontend-vue/tests/page-reads.test.ts +26 -6
- package/frontend-vue/tests/send-actions.test.ts +0 -1
- package/frontend-vue/tests/send-drawer.test.ts +2 -6
- package/frontend-vue/tests/send-modals.test.ts +8 -5
- package/frontend-vue/tests/sortable-list.test.ts +54 -62
- package/frontend-vue/tests/suppression.test.ts +1 -2
- package/frontend-vue/tests/template-drawer.test.ts +27 -18
- package/frontend-vue/tests/template-gallery.test.ts +7 -5
- package/frontend-vue/tests/template-preview-frame.test.ts +2 -5
- package/package.json +16 -20
- package/skills/dms-mailing/SKILL.md +1 -1
- package/skills/dms-mailing/cases.json +49 -0
- package/src/automation/email-event-trigger.ts +2 -2
- package/src/automation/send-template-action.ts +33 -14
- package/src/crons/jobs.ts +1 -1
- package/src/data/body.ts +7 -3
- package/src/data/templates-table.ts +55 -56
- package/src/data-types/categories-type.ts +2 -2
- package/src/data-types/category-type.ts +2 -2
- package/src/db/models/sends.model.ts +2 -2
- package/src/db/models/template-versions.model.ts +10 -4
- package/src/db/models/templates.model.ts +6 -3
- package/src/db/tables/templates.table.ts +1 -1
- package/src/engine/changes.ts +11 -28
- package/src/engine/conditions.ts +14 -8
- package/src/engine/coverage.ts +2 -1
- package/src/engine/fallback-html.ts +13 -13
- package/src/engine/formats.ts +35 -21
- package/src/engine/paths.ts +41 -7
- package/src/engine/plain-text.ts +2 -4
- package/src/engine/resolve.ts +12 -4
- package/src/engine/system-variables.ts +6 -4
- package/src/engine/tokens.ts +3 -3
- package/src/hooks/tenant-export.ts +11 -13
- package/src/pages/sends.ts +5 -3
- package/src/pages/templates.ts +4 -2
- package/src/pages/toolbar.ts +4 -2
- package/src/routes/events.ts +10 -10
- package/src/routes/index.ts +1 -0
- package/src/routes/metrics.ts +35 -33
- package/src/routes/period.ts +5 -5
- package/src/routes/sends.ts +23 -26
- package/src/routes/settings.ts +21 -17
- package/src/routes/suppressions.ts +12 -8
- package/src/routes/template-content.ts +45 -0
- package/src/routes/templates.ts +16 -49
- package/src/routes/zod-json.ts +28 -0
- package/src/services/attention.ts +20 -18
- package/src/services/catalog.ts +4 -8
- package/src/services/categories.ts +6 -6
- package/src/services/checked-send.ts +17 -19
- package/src/services/declaration-error.ts +1 -1
- package/src/services/declarations.ts +7 -6
- package/src/services/events.ts +18 -13
- package/src/services/localized-text.ts +4 -2
- package/src/services/metrics.ts +5 -7
- package/src/services/notify.ts +1 -1
- package/src/services/render.ts +5 -1
- package/src/services/send-import.ts +16 -11
- package/src/services/send.ts +10 -13
- package/src/services/settings.ts +17 -16
- package/src/services/starters.ts +12 -8
- package/src/services/template-stats.ts +11 -4
- package/src/services/templates.ts +24 -15
- package/src/services/webhook-status.ts +13 -8
- package/src/types/json.ts +1 -0
- package/src/types/settings.ts +2 -2
- package/src/validation/declarations.schema.ts +6 -5
- package/src/validation/guards.ts +17 -0
- package/src/validation/json.schema.ts +7 -0
- package/src/validation/json.ts +66 -0
- package/src/validation/send-event-type.schema.ts +10 -0
- package/src/validation/settings.schema.ts +7 -2
- package/src/validation/templates.schema.ts +9 -4
- package/frontend-vue/tests/i18n-editor.test.ts +0 -50
- package/frontend-vue/tests/i18n.test.ts +0 -138
|
@@ -27,6 +27,7 @@ import type {
|
|
|
27
27
|
VariableDefinition,
|
|
28
28
|
} from "../types";
|
|
29
29
|
import { DEFAULT_FALLBACK_LOCALE } from "../types";
|
|
30
|
+
import type { JsonObject } from "../types/json";
|
|
30
31
|
|
|
31
32
|
export interface TemplateAuthor {
|
|
32
33
|
name?: string;
|
|
@@ -73,6 +74,7 @@ export async function refuseTakenSlug<T>(write: Promise<T>): Promise<T> {
|
|
|
73
74
|
|
|
74
75
|
/** The content customers receive: the last published version. */
|
|
75
76
|
export function parseContent(template: MailingTemplate): TemplateContent {
|
|
77
|
+
// SAFETY: json_content is written only as JSON.stringify of a TemplateContent that saveContentSchema validated or the module built.
|
|
76
78
|
return JSON.parse(template.json_content || EMPTY_CONTENT) as TemplateContent;
|
|
77
79
|
}
|
|
78
80
|
|
|
@@ -83,6 +85,7 @@ export function hasDraft(template: MailingTemplate): boolean {
|
|
|
83
85
|
/** The content the editor works on: the draft, or the published content. */
|
|
84
86
|
export function workingContent(template: MailingTemplate): TemplateContent {
|
|
85
87
|
if (!template.json_draft) return parseContent(template);
|
|
88
|
+
// SAFETY: json_draft is written only as JSON.stringify of a TemplateContent that saveContentSchema validated or the module built.
|
|
86
89
|
return JSON.parse(template.json_draft) as TemplateContent;
|
|
87
90
|
}
|
|
88
91
|
|
|
@@ -102,6 +105,7 @@ export function isPublished(template: MailingTemplate): boolean {
|
|
|
102
105
|
export function parseVariables(
|
|
103
106
|
template: MailingTemplate,
|
|
104
107
|
): VariableDefinition[] {
|
|
108
|
+
// SAFETY: json_variables is written only as JSON.stringify of a VariableDefinition[] that variableListSchema validated or the module built.
|
|
105
109
|
return JSON.parse(template.json_variables) as VariableDefinition[];
|
|
106
110
|
}
|
|
107
111
|
|
|
@@ -114,6 +118,7 @@ export function workingVariables(
|
|
|
114
118
|
): VariableDefinition[] {
|
|
115
119
|
if (!hasDraft(template) || !template.json_variables_draft)
|
|
116
120
|
return parseVariables(template);
|
|
121
|
+
// SAFETY: json_variables_draft is written only as JSON.stringify of a VariableDefinition[] that variableListSchema validated or the module built.
|
|
117
122
|
return JSON.parse(template.json_variables_draft) as VariableDefinition[];
|
|
118
123
|
}
|
|
119
124
|
|
|
@@ -169,10 +174,9 @@ export function pendingChanges(template: MailingTemplate): TemplateChange[] {
|
|
|
169
174
|
});
|
|
170
175
|
}
|
|
171
176
|
|
|
172
|
-
export function parseTestData(
|
|
173
|
-
|
|
174
|
-
)
|
|
175
|
-
return JSON.parse(template.json_test_data) as Record<string, unknown>;
|
|
177
|
+
export function parseTestData(template: MailingTemplate): JsonObject {
|
|
178
|
+
// SAFETY: json_test_data is written only as JSON.stringify of an object that its route or declaration schema validated, or the module built.
|
|
179
|
+
return JSON.parse(template.json_test_data) as JsonObject;
|
|
176
180
|
}
|
|
177
181
|
|
|
178
182
|
interface FooterLabels {
|
|
@@ -180,21 +184,26 @@ interface FooterLabels {
|
|
|
180
184
|
preferences: string;
|
|
181
185
|
}
|
|
182
186
|
|
|
187
|
+
const ENGLISH_FOOTER_LABELS: FooterLabels = {
|
|
188
|
+
unsubscribe: "Unsubscribe",
|
|
189
|
+
preferences: "Preferences",
|
|
190
|
+
};
|
|
191
|
+
|
|
183
192
|
/**
|
|
184
193
|
* The one piece of content the module still writes for a new template. An
|
|
185
194
|
* e-mail without an unsubscribe link is a legal problem, so a blank template
|
|
186
195
|
* ships a footer — and nothing else: no invented subject, prose or address.
|
|
187
196
|
*/
|
|
188
|
-
const FOOTER_LABELS
|
|
189
|
-
en
|
|
190
|
-
fr
|
|
191
|
-
|
|
197
|
+
const FOOTER_LABELS = new Map<string, FooterLabels>([
|
|
198
|
+
["en", ENGLISH_FOOTER_LABELS],
|
|
199
|
+
["fr", { unsubscribe: "Se désinscrire", preferences: "Préférences" }],
|
|
200
|
+
]);
|
|
192
201
|
|
|
193
202
|
const FOOTER_BLOCK_ID = "footer";
|
|
194
203
|
|
|
195
204
|
/** A footer in `locale`: its legal line and the labels of its two links. */
|
|
196
205
|
export function footerBlock(locale: string, text = ""): Block {
|
|
197
|
-
const labels = FOOTER_LABELS
|
|
206
|
+
const labels = FOOTER_LABELS.get(locale) ?? ENGLISH_FOOTER_LABELS;
|
|
198
207
|
return {
|
|
199
208
|
id: FOOTER_BLOCK_ID,
|
|
200
209
|
type: "footer",
|
|
@@ -226,16 +235,13 @@ export function isStaleRevision(
|
|
|
226
235
|
return revision !== undefined && revision !== template.draftRevision;
|
|
227
236
|
}
|
|
228
237
|
|
|
229
|
-
function draftWriteFields(
|
|
230
|
-
template: MailingTemplate,
|
|
231
|
-
author: string,
|
|
232
|
-
): TemplateFields {
|
|
238
|
+
function draftWriteFields(template: MailingTemplate, author: string) {
|
|
233
239
|
return {
|
|
234
240
|
draftRevision: template.draftRevision + 1,
|
|
235
241
|
draftUpdatedAt: new Date(),
|
|
236
242
|
draftUpdatedBy: author,
|
|
237
243
|
updatedBy: author,
|
|
238
|
-
};
|
|
244
|
+
} satisfies TemplateFields;
|
|
239
245
|
}
|
|
240
246
|
|
|
241
247
|
/** Whether `draft` is exactly what customers and callers already have. */
|
|
@@ -271,7 +277,7 @@ export async function saveDraft(
|
|
|
271
277
|
...locales,
|
|
272
278
|
});
|
|
273
279
|
await refreshAttentionIfFlagged(tenantId, template, STALE_DRAFT);
|
|
274
|
-
return fields.draftRevision
|
|
280
|
+
return fields.draftRevision;
|
|
275
281
|
}
|
|
276
282
|
|
|
277
283
|
export async function discardDraft(
|
|
@@ -291,6 +297,7 @@ export async function discardDraft(
|
|
|
291
297
|
export function parseVersionChanges(
|
|
292
298
|
row: MailingTemplateVersion,
|
|
293
299
|
): TemplateChange[] {
|
|
300
|
+
// SAFETY: json_changes is written only as JSON.stringify of the TemplateChange[] from diffContents and diffVariables.
|
|
294
301
|
return JSON.parse(row.json_changes) as TemplateChange[];
|
|
295
302
|
}
|
|
296
303
|
|
|
@@ -298,6 +305,7 @@ export function parseVersionChanges(
|
|
|
298
305
|
export function parseVersionVariables(
|
|
299
306
|
row: MailingTemplateVersion,
|
|
300
307
|
): VariableDefinition[] {
|
|
308
|
+
// SAFETY: a version row's json_variables is written only as JSON.stringify of a VariableDefinition[] that variableListSchema validated or the module built.
|
|
301
309
|
return JSON.parse(row.json_variables) as VariableDefinition[];
|
|
302
310
|
}
|
|
303
311
|
|
|
@@ -453,6 +461,7 @@ export async function contentOfVersion(
|
|
|
453
461
|
export function parseVersionContent(
|
|
454
462
|
row: MailingTemplateVersion,
|
|
455
463
|
): TemplateContent {
|
|
464
|
+
// SAFETY: a version row's json_content is written only as JSON.stringify of a TemplateContent that saveContentSchema validated.
|
|
456
465
|
return JSON.parse(row.json_content) as TemplateContent;
|
|
457
466
|
}
|
|
458
467
|
|
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
WebhookRefusal,
|
|
8
8
|
WebhookStatus,
|
|
9
9
|
} from "../types";
|
|
10
|
+
import { isObject, isText } from "../validation/guards";
|
|
10
11
|
import { webhookSecretMatches } from "./events";
|
|
11
12
|
import { WEBHOOK_SECRET_HEADER } from "../constants";
|
|
12
13
|
|
|
@@ -28,11 +29,11 @@ export const WEBHOOK_REFUSAL_MESSAGES: Record<WebhookRefusal, string> = {
|
|
|
28
29
|
};
|
|
29
30
|
|
|
30
31
|
/** The body of a refused webhook call. */
|
|
31
|
-
export
|
|
32
|
+
export type WebhookRefusalBody = {
|
|
32
33
|
error: "webhook_refused";
|
|
33
34
|
reason: WebhookRefusal;
|
|
34
35
|
message: string;
|
|
35
|
-
}
|
|
36
|
+
};
|
|
36
37
|
|
|
37
38
|
/** What "Send a test event" found when it called the webhook URL. */
|
|
38
39
|
export interface WebhookTestOutcome {
|
|
@@ -143,15 +144,13 @@ export async function readWebhookStatus(
|
|
|
143
144
|
);
|
|
144
145
|
}
|
|
145
146
|
|
|
146
|
-
const describeError = (error: unknown): string =>
|
|
147
|
-
error instanceof Error
|
|
148
|
-
? String((error.cause as Error | undefined)?.message ?? error.message)
|
|
149
|
-
: String(error);
|
|
150
|
-
|
|
151
147
|
async function refusalOf(
|
|
152
148
|
response: Response,
|
|
153
149
|
): Promise<WebhookRefusal | undefined> {
|
|
154
150
|
try {
|
|
151
|
+
// SAFETY: Response.json() answers any, and the URL under test is this module's own webhook route, which refuses
|
|
152
|
+
// with refusalBody(reason); another endpoint can answer any JSON, which is read the same way, and a non-object
|
|
153
|
+
// reads as no refusal (only null throws, into the catch below).
|
|
155
154
|
const body = (await response.json()) as Partial<WebhookRefusalBody>;
|
|
156
155
|
return body.reason;
|
|
157
156
|
} catch {
|
|
@@ -192,11 +191,17 @@ export async function sendWebhookTest(
|
|
|
192
191
|
refusal: response.ok ? undefined : await refusalOf(response),
|
|
193
192
|
};
|
|
194
193
|
} catch (error) {
|
|
194
|
+
const cause = error instanceof Error ? error.cause : undefined;
|
|
195
|
+
const causeMessage =
|
|
196
|
+
isObject(cause) && isText(cause.message) ? cause.message : undefined;
|
|
195
197
|
return {
|
|
196
198
|
isReceived: false,
|
|
197
199
|
url,
|
|
198
200
|
durationMs: elapsed(),
|
|
199
|
-
networkError:
|
|
201
|
+
networkError:
|
|
202
|
+
error instanceof Error
|
|
203
|
+
? (causeMessage ?? error.message)
|
|
204
|
+
: String(error),
|
|
200
205
|
};
|
|
201
206
|
}
|
|
202
207
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type { JsonObject, JsonValue } from "@antelopejs/interface-dms-mailing";
|
package/src/types/settings.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { LocalizedText } from "@antelopejs/interface-dms-mailing";
|
|
2
2
|
|
|
3
3
|
/** A template category as stored: its label written once per language. */
|
|
4
|
-
export
|
|
4
|
+
export type TemplateCategory = {
|
|
5
5
|
id: string;
|
|
6
6
|
label: LocalizedText;
|
|
7
7
|
icon: string;
|
|
8
|
-
}
|
|
8
|
+
};
|
|
9
9
|
|
|
10
10
|
/** A template category as one reader sees it: the label in their language. */
|
|
11
11
|
export interface CategoryInLocale {
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { LOCALIZED_TEXT_FALLBACK_LOCALE } from "@antelopejs/interface-dms-mailing";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { MAX_NAME_LENGTH } from "../constants";
|
|
4
|
-
import {
|
|
4
|
+
import { sendEventTypeSchema } from "./send-event-type.schema";
|
|
5
5
|
import { categorySchema, settingsSchema } from "./settings.schema";
|
|
6
6
|
import {
|
|
7
7
|
slugSchema,
|
|
8
8
|
templateContentSchema,
|
|
9
9
|
variableListSchema,
|
|
10
10
|
} from "./templates.schema";
|
|
11
|
+
import { jsonObjectSchema } from "./json.schema";
|
|
11
12
|
|
|
12
13
|
const MAX_AUTHOR_LENGTH = MAX_NAME_LENGTH;
|
|
13
14
|
const MAX_IMPORT_ID_LENGTH = 200;
|
|
@@ -36,7 +37,7 @@ export const templateDeclarationSchema = z.object({
|
|
|
36
37
|
category: z.string().trim().default(""),
|
|
37
38
|
content: templateContentSchema,
|
|
38
39
|
variables: variableListSchema.default([]),
|
|
39
|
-
testData:
|
|
40
|
+
testData: jsonObjectSchema.default({}),
|
|
40
41
|
author: z.string().trim().min(1).max(MAX_AUTHOR_LENGTH).optional(),
|
|
41
42
|
});
|
|
42
43
|
|
|
@@ -76,9 +77,9 @@ const recipientSchema = z.union([
|
|
|
76
77
|
]);
|
|
77
78
|
|
|
78
79
|
const importedEventSchema = z.object({
|
|
79
|
-
type:
|
|
80
|
+
type: sendEventTypeSchema,
|
|
80
81
|
at: z.coerce.date(),
|
|
81
|
-
details:
|
|
82
|
+
details: jsonObjectSchema.optional(),
|
|
82
83
|
});
|
|
83
84
|
|
|
84
85
|
const importedSendSchema = z.object({
|
|
@@ -87,7 +88,7 @@ const importedSendSchema = z.object({
|
|
|
87
88
|
to: recipientSchema,
|
|
88
89
|
sentAt: z.coerce.date(),
|
|
89
90
|
locale: z.string().trim().min(1).optional(),
|
|
90
|
-
variables:
|
|
91
|
+
variables: jsonObjectSchema.default({}),
|
|
91
92
|
provider: z.string().optional(),
|
|
92
93
|
providerMessageId: z.string().optional(),
|
|
93
94
|
source: z.string().optional(),
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/** Whether `value` is a string. */
|
|
2
|
+
export function isText(value: unknown): value is string {
|
|
3
|
+
return typeof value === "string";
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Whether `value` is an object, arrays included, and not `null`: reading any
|
|
8
|
+
* key of it yields a value of unknown type.
|
|
9
|
+
*/
|
|
10
|
+
export function isObject(value: unknown): value is Record<string, unknown> {
|
|
11
|
+
return typeof value === "object" && value !== null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Whether `value` is a number. */
|
|
15
|
+
export function isNumber(value: unknown): value is number {
|
|
16
|
+
return typeof value === "number";
|
|
17
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { JsonObject, JsonValue } from "../types/json";
|
|
2
|
+
import { isNumber, isObject, isText } from "./guards";
|
|
3
|
+
|
|
4
|
+
/** A step of the walk in {@link isJsonValue}: visit a node, or leave one. */
|
|
5
|
+
type JsonWalkStep = { visit: unknown } | { leave: object };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Whether `value` is wholly JSON data: strings, numbers, booleans, null, and
|
|
9
|
+
* arrays and plain objects of the same, none of them reached through itself.
|
|
10
|
+
* Walked with a stack, so a deeply nested value cannot overflow it.
|
|
11
|
+
*/
|
|
12
|
+
export function isJsonValue(value: unknown): value is JsonValue {
|
|
13
|
+
const stack: JsonWalkStep[] = [{ visit: value }];
|
|
14
|
+
const ancestors = new Set<object>();
|
|
15
|
+
for (let step = stack.pop(); step; step = stack.pop()) {
|
|
16
|
+
if ("leave" in step) {
|
|
17
|
+
ancestors.delete(step.leave);
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
const item = step.visit;
|
|
21
|
+
if (
|
|
22
|
+
item === null ||
|
|
23
|
+
isText(item) ||
|
|
24
|
+
isNumber(item) ||
|
|
25
|
+
typeof item === "boolean"
|
|
26
|
+
)
|
|
27
|
+
continue;
|
|
28
|
+
if (typeof item !== "object" || ancestors.has(item)) return false;
|
|
29
|
+
const prototype: unknown = Object.getPrototypeOf(item);
|
|
30
|
+
if (
|
|
31
|
+
!Array.isArray(item) &&
|
|
32
|
+
prototype !== Object.prototype &&
|
|
33
|
+
prototype !== null
|
|
34
|
+
)
|
|
35
|
+
return false;
|
|
36
|
+
ancestors.add(item);
|
|
37
|
+
stack.push({ leave: item });
|
|
38
|
+
for (const child of Object.values(item)) stack.push({ visit: child });
|
|
39
|
+
}
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Whether `value` is wholly JSON data and an object: neither `null` nor an array. */
|
|
44
|
+
export function isJsonObject(value: unknown): value is JsonObject {
|
|
45
|
+
return (
|
|
46
|
+
typeof value === "object" &&
|
|
47
|
+
value !== null &&
|
|
48
|
+
!Array.isArray(value) &&
|
|
49
|
+
isJsonValue(value)
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `text` parsed as JSON; a `SyntaxError` when it is not. */
|
|
54
|
+
export function parseJson(text: string): JsonValue {
|
|
55
|
+
const parsed: unknown = JSON.parse(text);
|
|
56
|
+
if (!isJsonValue(parsed)) throw new TypeError("expected JSON data");
|
|
57
|
+
return parsed;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `text` parsed as a JSON object. */
|
|
61
|
+
export function parseJsonObject(text: string): JsonObject {
|
|
62
|
+
const parsed = parseJson(text);
|
|
63
|
+
if (!isObject(parsed) || Array.isArray(parsed))
|
|
64
|
+
throw new TypeError("expected a JSON object");
|
|
65
|
+
return parsed;
|
|
66
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { SEND_EVENT_TYPES } from "../types";
|
|
3
|
+
|
|
4
|
+
const [FIRST_EVENT_TYPE, ...OTHER_EVENT_TYPES] = SEND_EVENT_TYPES;
|
|
5
|
+
|
|
6
|
+
/** A send event's type: one of {@link SEND_EVENT_TYPES}. */
|
|
7
|
+
export const sendEventTypeSchema = z.enum([
|
|
8
|
+
FIRST_EVENT_TYPE,
|
|
9
|
+
...OTHER_EVENT_TYPES,
|
|
10
|
+
]);
|
|
@@ -123,12 +123,18 @@ function moveIssues(
|
|
|
123
123
|
kept: Set<string>,
|
|
124
124
|
): CategoryIssue[] {
|
|
125
125
|
return entries.flatMap((entry, index) =>
|
|
126
|
-
|
|
126
|
+
entry.moveTo === undefined || kept.has(entry.moveTo)
|
|
127
127
|
? []
|
|
128
128
|
: [{ message: UNKNOWN_MOVE_TARGET, path: [index, "moveTo"] }],
|
|
129
129
|
);
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Adds the issues of the category rows: blank labels and duplicates in each
|
|
134
|
+
* language, a removed marketing category, a move to a category that is not
|
|
135
|
+
* kept. Each issue is reported once per row and part, whichever language
|
|
136
|
+
* shows it first.
|
|
137
|
+
*/
|
|
132
138
|
function refineCategoryEntries(
|
|
133
139
|
entries: CategoryEntry[],
|
|
134
140
|
context: z.RefinementCtx,
|
|
@@ -147,7 +153,6 @@ function refineCategoryEntries(
|
|
|
147
153
|
...moveIssues(entriesIn(entries, LOCALIZED_TEXT_FALLBACK_LOCALE), kept),
|
|
148
154
|
]
|
|
149
155
|
.filter((issue) => {
|
|
150
|
-
// One issue per row and part, whichever language shows it first.
|
|
151
156
|
const id = JSON.stringify(issue);
|
|
152
157
|
if (seen.has(id)) return false;
|
|
153
158
|
seen.add(id);
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { MAX_NAME_LENGTH, MAX_SLUG_LENGTH, SLUG_PATTERN } from "../constants";
|
|
3
|
+
import type { Block } from "../types";
|
|
4
|
+
import { jsonObjectSchema } from "./json.schema";
|
|
3
5
|
|
|
4
6
|
const MIN_HEADING_SIZE = 12;
|
|
5
7
|
const MAX_HEADING_SIZE = 48;
|
|
@@ -31,7 +33,7 @@ export const conditionSchema = z.object({
|
|
|
31
33
|
const alignSchema = z.enum(["left", "center", "right"]);
|
|
32
34
|
const base = { id: z.string().min(1), visibleIf: conditionSchema.nullable() };
|
|
33
35
|
|
|
34
|
-
export const blockSchema: z.ZodType<unknown> = z.lazy(() =>
|
|
36
|
+
export const blockSchema: z.ZodType<Block, z.ZodTypeDef, unknown> = z.lazy(() =>
|
|
35
37
|
z.discriminatedUnion("type", [
|
|
36
38
|
z.object({
|
|
37
39
|
...base,
|
|
@@ -148,7 +150,7 @@ export const saveContentSchema = z.object({
|
|
|
148
150
|
revision: draftRevisionSchema,
|
|
149
151
|
});
|
|
150
152
|
|
|
151
|
-
export const testDataSchema = z.object({ data:
|
|
153
|
+
export const testDataSchema = z.object({ data: jsonObjectSchema });
|
|
152
154
|
|
|
153
155
|
/**
|
|
154
156
|
* Which content a preview renders when it carries none: the working draft
|
|
@@ -165,7 +167,7 @@ export const previewSchema = z.object({
|
|
|
165
167
|
locale: z.string().min(1).optional(),
|
|
166
168
|
version: previewVersionSchema.optional(),
|
|
167
169
|
content: templateContentSchema.optional(),
|
|
168
|
-
data:
|
|
170
|
+
data: jsonObjectSchema.optional(),
|
|
169
171
|
});
|
|
170
172
|
|
|
171
173
|
/**
|
|
@@ -175,7 +177,7 @@ export const previewSchema = z.object({
|
|
|
175
177
|
export const realSendSchema = z.object({
|
|
176
178
|
to: z.array(z.string().email()).min(1).max(MAX_TEST_RECIPIENT_COUNT),
|
|
177
179
|
locale: z.string().min(1).optional(),
|
|
178
|
-
data:
|
|
180
|
+
data: jsonObjectSchema.optional(),
|
|
179
181
|
});
|
|
180
182
|
|
|
181
183
|
export const testSendSchema = previewSchema.extend({
|
|
@@ -192,6 +194,9 @@ export const templateDetailsSchema = z.object({
|
|
|
192
194
|
category: z.string().nullable().optional(),
|
|
193
195
|
});
|
|
194
196
|
|
|
197
|
+
/** The details a template's drawer or the editor saves, validated. */
|
|
198
|
+
export type TemplateDetails = z.infer<typeof templateDetailsSchema>;
|
|
199
|
+
|
|
195
200
|
export const duplicateSchema = z.object({
|
|
196
201
|
slug: slugSchema,
|
|
197
202
|
name: z.string().trim().min(1).max(MAX_NAME_LENGTH),
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
import { readFileSync, readdirSync } from 'node:fs'
|
|
2
|
-
import { join } from 'node:path'
|
|
3
|
-
import { describe, expect, it } from 'vitest'
|
|
4
|
-
import en from '../i18n/locales/mailing-en-GB.json'
|
|
5
|
-
|
|
6
|
-
const APP = join(__dirname, '..', 'app')
|
|
7
|
-
const EDITOR_SOURCES = [
|
|
8
|
-
join(APP, 'components', 'Editor.vue'),
|
|
9
|
-
join(APP, 'composables', 'useEditorActions.ts'),
|
|
10
|
-
join(APP, 'composables', 'useEditorShortcuts.ts'),
|
|
11
|
-
...readdirSync(join(APP, 'components', 'editor')).map((file) =>
|
|
12
|
-
join(APP, 'components', 'editor', file),
|
|
13
|
-
),
|
|
14
|
-
]
|
|
15
|
-
const LITERAL_KEY =
|
|
16
|
-
/'(dms_mailing\.(?:editor|blocks|templates\.status)\.[a-z_.]+)'/g
|
|
17
|
-
const PREFIX_CONSTANT = /const (\w*KEY) = '(dms_mailing[a-z_.]*)'/g
|
|
18
|
-
const PREFIXED_KEY = /\$\{(\w*KEY)\}\.([a-z_.]+)`/g
|
|
19
|
-
|
|
20
|
-
function keysUsedIn(source: string): string[] {
|
|
21
|
-
const prefixes = new Map(
|
|
22
|
-
[...source.matchAll(PREFIX_CONSTANT)].map((match) => [match[1], match[2]]),
|
|
23
|
-
)
|
|
24
|
-
const prefixValues = new Set(prefixes.values())
|
|
25
|
-
const literal = [...source.matchAll(LITERAL_KEY)]
|
|
26
|
-
.map((match) => match[1] as string)
|
|
27
|
-
.filter((key) => !prefixValues.has(key))
|
|
28
|
-
const prefixed = [...source.matchAll(PREFIXED_KEY)]
|
|
29
|
-
.filter((match) => prefixes.has(match[1]))
|
|
30
|
-
.map((match) => `${prefixes.get(match[1])}.${match[2]}`)
|
|
31
|
-
return [...literal, ...prefixed]
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function keysOf(value: unknown, prefix = ''): string[] {
|
|
35
|
-
if (typeof value !== 'object' || value === null) return [prefix]
|
|
36
|
-
return Object.entries(value).flatMap(([key, child]) =>
|
|
37
|
-
keysOf(child, prefix ? `${prefix}.${key}` : key),
|
|
38
|
-
)
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
describe('editor locales', () => {
|
|
42
|
-
it('define every literal key the editor components use', () => {
|
|
43
|
-
const known = new Set(keysOf(en))
|
|
44
|
-
const used = EDITOR_SOURCES.flatMap((file) =>
|
|
45
|
-
keysUsedIn(readFileSync(file, 'utf8')),
|
|
46
|
-
)
|
|
47
|
-
expect(used.length).toBeGreaterThan(0)
|
|
48
|
-
expect(used.filter((key) => !known.has(key))).toEqual([])
|
|
49
|
-
})
|
|
50
|
-
})
|
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
import { readFileSync, readdirSync } from 'node:fs'
|
|
2
|
-
import { join } from 'node:path'
|
|
3
|
-
import { describe, expect, it } from 'vitest'
|
|
4
|
-
import en from '../i18n/locales/mailing-en-GB.json'
|
|
5
|
-
import fr from '../i18n/locales/mailing-fr-FR.json'
|
|
6
|
-
|
|
7
|
-
// vitest runs with the layer as its root; the backend sits one level above it.
|
|
8
|
-
const BACKEND_SOURCE_DIR = join(process.cwd(), '..', 'src')
|
|
9
|
-
|
|
10
|
-
// `$dms_mailing.a.b` as the backend writes it. A literal that stops on a dot is
|
|
11
|
-
// the prefix of a key completed by a template expression (`${status}`); those
|
|
12
|
-
// are resolved at runtime and cannot be checked here.
|
|
13
|
-
const KEY_LITERAL = /\$dms_mailing\.[\w.]+/g
|
|
14
|
-
const DYNAMIC_KEY_SUFFIX = '.'
|
|
15
|
-
|
|
16
|
-
function keysOf(value: unknown, prefix = ''): string[] {
|
|
17
|
-
if (typeof value !== 'object' || value === null) return [prefix]
|
|
18
|
-
return Object.entries(value).flatMap(([key, child]) =>
|
|
19
|
-
keysOf(child, prefix ? `${prefix}.${key}` : key),
|
|
20
|
-
)
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function typescriptFiles(directory: string): string[] {
|
|
24
|
-
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
|
25
|
-
const path = join(directory, entry.name)
|
|
26
|
-
if (entry.isDirectory()) return typescriptFiles(path)
|
|
27
|
-
return entry.isFile() && path.endsWith('.ts') ? [path] : []
|
|
28
|
-
})
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function backendKeys(): string[] {
|
|
32
|
-
const found = new Set<string>()
|
|
33
|
-
for (const file of typescriptFiles(BACKEND_SOURCE_DIR)) {
|
|
34
|
-
for (const literal of readFileSync(file, 'utf8').match(KEY_LITERAL) ?? []) {
|
|
35
|
-
const key = literal.slice(1)
|
|
36
|
-
if (!key.endsWith(DYNAMIC_KEY_SUFFIX)) found.add(key)
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
return [...found].sort()
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function messagesOf(value: unknown, prefix = ''): [string, string][] {
|
|
43
|
-
if (typeof value === 'string') return [[prefix, value]]
|
|
44
|
-
if (typeof value !== 'object' || value === null) return []
|
|
45
|
-
return Object.entries(value).flatMap(([key, child]) =>
|
|
46
|
-
messagesOf(child, prefix ? `${prefix}.${key}` : key),
|
|
47
|
-
)
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// `{name}` placeholders and `{'…'}` literals, the only braces a message holds.
|
|
51
|
-
const BRACED = /\{'[^']*'\}|\{\w+\}/g
|
|
52
|
-
const PLACEHOLDER = /\{(\w+)\}/g
|
|
53
|
-
const PLURAL_SEPARATOR = '|'
|
|
54
|
-
// vue-i18n's default rule picks, for three forms, the first at 0 and the
|
|
55
|
-
// second at 1: French says « 0 envoi », so every French plural needs all three.
|
|
56
|
-
const FRENCH_PLURAL_FORMS = 3
|
|
57
|
-
|
|
58
|
-
function placeholdersOf(message: string): string[] {
|
|
59
|
-
return [
|
|
60
|
-
...new Set([...message.matchAll(PLACEHOLDER)].map((match) => match[1])),
|
|
61
|
-
].sort()
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function resolve(root: unknown, key: string): unknown {
|
|
65
|
-
return key
|
|
66
|
-
.split('.')
|
|
67
|
-
.reduce<unknown>(
|
|
68
|
-
(node, part) =>
|
|
69
|
-
typeof node === 'object' && node !== null
|
|
70
|
-
? (node as Record<string, unknown>)[part]
|
|
71
|
-
: undefined,
|
|
72
|
-
root,
|
|
73
|
-
)
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
describe('locales', () => {
|
|
77
|
-
it('carry the same key set', () => {
|
|
78
|
-
expect(keysOf(fr).sort()).toEqual(keysOf(en).sort())
|
|
79
|
-
})
|
|
80
|
-
it('cover every namespace the pages use', () => {
|
|
81
|
-
for (const namespace of [
|
|
82
|
-
'overview',
|
|
83
|
-
'templates',
|
|
84
|
-
'editor',
|
|
85
|
-
'sends',
|
|
86
|
-
'settings',
|
|
87
|
-
'metrics',
|
|
88
|
-
'funnel',
|
|
89
|
-
'errors',
|
|
90
|
-
'blocks',
|
|
91
|
-
]) {
|
|
92
|
-
expect(en.dms_mailing).toHaveProperty(namespace)
|
|
93
|
-
}
|
|
94
|
-
})
|
|
95
|
-
it('resolve every static key the backend emits', () => {
|
|
96
|
-
const keys = backendKeys()
|
|
97
|
-
expect(keys.length).toBeGreaterThan(0)
|
|
98
|
-
expect(keys.filter((key) => typeof resolve(en, key) !== 'string')).toEqual(
|
|
99
|
-
[],
|
|
100
|
-
)
|
|
101
|
-
expect(keys.filter((key) => typeof resolve(fr, key) !== 'string')).toEqual(
|
|
102
|
-
[],
|
|
103
|
-
)
|
|
104
|
-
})
|
|
105
|
-
it('compile: no stray brace or unescaped @ outside placeholders', () => {
|
|
106
|
-
const broken = [...messagesOf(en), ...messagesOf(fr)].filter(
|
|
107
|
-
([, message]) => /[{}@]/.test(message.replace(BRACED, '')),
|
|
108
|
-
)
|
|
109
|
-
expect(broken).toEqual([])
|
|
110
|
-
})
|
|
111
|
-
it('give each message the same placeholders in both languages', () => {
|
|
112
|
-
const french = new Map(messagesOf(fr))
|
|
113
|
-
const mismatched = messagesOf(en)
|
|
114
|
-
.filter(
|
|
115
|
-
([key, message]) =>
|
|
116
|
-
placeholdersOf(message).join() !==
|
|
117
|
-
placeholdersOf(french.get(key) ?? '').join(),
|
|
118
|
-
)
|
|
119
|
-
.map(([key]) => key)
|
|
120
|
-
expect(mismatched).toEqual([])
|
|
121
|
-
})
|
|
122
|
-
it('say « modèle », never « template », in French', () => {
|
|
123
|
-
const anglicisms = messagesOf(fr)
|
|
124
|
-
.filter(([, message]) => /\btemplates?\b/i.test(message))
|
|
125
|
-
.map(([key]) => key)
|
|
126
|
-
expect(anglicisms).toEqual([])
|
|
127
|
-
})
|
|
128
|
-
it('write every French plural with three forms, 0 in the singular', () => {
|
|
129
|
-
const incomplete = messagesOf(fr)
|
|
130
|
-
.filter(
|
|
131
|
-
([, message]) =>
|
|
132
|
-
message.includes(PLURAL_SEPARATOR) &&
|
|
133
|
-
message.split(PLURAL_SEPARATOR).length !== FRENCH_PLURAL_FORMS,
|
|
134
|
-
)
|
|
135
|
-
.map(([key]) => key)
|
|
136
|
-
expect(incomplete).toEqual([])
|
|
137
|
-
})
|
|
138
|
-
})
|