@jjlmoya/landings 0.4.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/package.json +65 -0
- package/scripts/copy-public-assets.mjs +44 -0
- package/src/components/PreviewNavSidebar.astro +102 -0
- package/src/components/PreviewToolbar.astro +139 -0
- package/src/entries.ts +9 -0
- package/src/env.d.ts +4 -0
- package/src/index.ts +12 -0
- package/src/landing/team/assets/ambar-chiquilla.webp +0 -0
- package/src/landing/team/assets/ambar.webp +0 -0
- package/src/landing/team/assets/bob-model-book/bob-pointing.webp +0 -0
- package/src/landing/team/assets/bob-model-book/bob-pose-05.webp +0 -0
- package/src/landing/team/assets/bob-model-book/bob-pose-06.webp +0 -0
- package/src/landing/team/assets/bob-model-book/bob-pose-07-locked.webp +0 -0
- package/src/landing/team/assets/bob-model-book/bob-pose-08-locked.webp +0 -0
- package/src/landing/team/assets/bob-model-book/bob-pose-09-locked.webp +0 -0
- package/src/landing/team/assets/bob-model-book/bob-pose-attitude.webp +0 -0
- package/src/landing/team/assets/bob-model-book/bob-pose-cover.webp +0 -0
- package/src/landing/team/assets/bob-model-book/bob-pose-expensive.webp +0 -0
- package/src/landing/team/assets/bob-model-book/bob-pose-final.webp +0 -0
- package/src/landing/team/assets/bob.webp +0 -0
- package/src/landing/team/assets/drac.webp +0 -0
- package/src/landing/team/assets/feedback.webp +0 -0
- package/src/landing/team/assets/gamebob-team-hero.png +0 -0
- package/src/landing/team/assets/gamebob-team-hero.webp +0 -0
- package/src/landing/team/assets/jjlmoya/jjlmoya-exhausted.webp +0 -0
- package/src/landing/team/assets/jjlmoya/tinder-profile-winked.webp +0 -0
- package/src/landing/team/assets/jjlmoya/tinder-profile.webp +0 -0
- package/src/landing/team/assets/master-plan-food.webp +0 -0
- package/src/landing/team/assets/master-plan-money.webp +0 -0
- package/src/landing/team/assets/master-plan-more-food.webp +0 -0
- package/src/landing/team/assets/master-plan-more-money.webp +0 -0
- package/src/landing/team/assets/master-plan-projects.webp +0 -0
- package/src/landing/team/assets/master-plan-tools.webp +0 -0
- package/src/landing/team/assets/memorial/ambar-chiquillo.webp +0 -0
- package/src/landing/team/assets/music/bob-techno.mp3 +0 -0
- package/src/landing/team/assets/seal-of-approval.webp +0 -0
- package/src/landing/team/assets/terra.webp +0 -0
- package/src/landing/team/assets/webcomic/scene-1.webp +0 -0
- package/src/landing/team/assets/webcomic/scene-2.webp +0 -0
- package/src/landing/team/assets/webcomic/scene-3.webp +0 -0
- package/src/landing/team/assets/webcomic/scene-4.webp +0 -0
- package/src/landing/team/components/BobMasterPlan.astro +118 -0
- package/src/landing/team/components/BobModelBook.astro +253 -0
- package/src/landing/team/components/BobModelBookFrame.astro +73 -0
- package/src/landing/team/components/CompanyWorks.astro +180 -0
- package/src/landing/team/components/HumanTouch.astro +262 -0
- package/src/landing/team/components/MasterPlanScene.astro +73 -0
- package/src/landing/team/components/MemoryWall.astro +46 -0
- package/src/landing/team/components/ProductManifesto.astro +46 -0
- package/src/landing/team/components/SupportTransition.astro +67 -0
- package/src/landing/team/components/TeamComicScenes.astro +67 -0
- package/src/landing/team/components/TeamHero.astro +42 -0
- package/src/landing/team/components/TeamRoster.astro +94 -0
- package/src/landing/team/entry.ts +23 -0
- package/src/landing/team/i18n/en.ts +278 -0
- package/src/landing/team/i18n/es.ts +278 -0
- package/src/landing/team/i18n/index.ts +12 -0
- package/src/landing/team/landing.astro +31 -0
- package/src/landing/team/styles/BehindJoke.css +55 -0
- package/src/landing/team/styles/BobMasterPlan.css +213 -0
- package/src/landing/team/styles/BobModelBook.css +709 -0
- package/src/landing/team/styles/CompanyWorks.css +652 -0
- package/src/landing/team/styles/HumanTouch.css +1190 -0
- package/src/landing/team/styles/MemoryWall.css +50 -0
- package/src/landing/team/styles/ProductManifesto.css +164 -0
- package/src/landing/team/styles/SupportTransition.css +291 -0
- package/src/landing/team/styles/TeamComicScenes.css +129 -0
- package/src/landing/team/styles/TeamHero.css +149 -0
- package/src/landing/team/styles/TeamRoster.css +75 -0
- package/src/landing/team/styles/team-landing.css +83 -0
- package/src/layouts/PreviewLayout.astro +110 -0
- package/src/pages/[locale]/[slug].astro +70 -0
- package/src/pages/[locale].astro +165 -0
- package/src/pages/index.astro +3 -0
- package/src/styles/global.css +37 -0
- package/src/styles/tokens.css +107 -0
- package/src/tests/diacritics_density.test.ts +62 -0
- package/src/tests/i18n_coverage.test.ts +32 -0
- package/src/tests/inverted_punctuation.test.ts +67 -0
- package/src/tests/landing-test-helpers.ts +89 -0
- package/src/tests/landing_exports.test.ts +29 -0
- package/src/tests/mocks/astro_mock.js +1 -0
- package/src/tests/no_en_dash.test.ts +45 -0
- package/src/tests/no_h1_in_components.test.ts +33 -0
- package/src/tests/pagespeed_best_practices.test.ts +143 -0
- package/src/tests/script_density.test.ts +57 -0
- package/src/tests/slug_language_code_format.test.ts +20 -0
- package/src/tests/slug_uniqueness.test.ts +74 -0
- package/src/tests/title_quality.test.ts +34 -0
- package/src/types.ts +38 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
--color-brand-blue: #1ddabc;
|
|
3
|
+
--color-brand-dark: #000;
|
|
4
|
+
--color-brand-white: #fff;
|
|
5
|
+
--color-primary: var(--color-brand-blue);
|
|
6
|
+
--color-primary-rgb: 29, 218, 188;
|
|
7
|
+
--color-secondary: #f43f5e;
|
|
8
|
+
--color-secondary-rgb: 244, 63, 94;
|
|
9
|
+
--color-blue-tag: #1e40af;
|
|
10
|
+
--color-wp-blue: #21759b;
|
|
11
|
+
|
|
12
|
+
--space-2xs: 0.125rem;
|
|
13
|
+
--space-xs: 0.25rem;
|
|
14
|
+
--space-3xs: 0.375rem;
|
|
15
|
+
--space-sm: 0.5rem;
|
|
16
|
+
--space-base: 0.75rem;
|
|
17
|
+
--space-md: 1rem;
|
|
18
|
+
--space-md-lg: 1.25rem;
|
|
19
|
+
--space-lg: 1.5rem;
|
|
20
|
+
--space-xl: 2rem;
|
|
21
|
+
--space-1-5xl: 2.5rem;
|
|
22
|
+
--space-2xl: 3rem;
|
|
23
|
+
--space-2-5xl: 3.75rem;
|
|
24
|
+
--space-3xl: 4rem;
|
|
25
|
+
--space-3-5xl: 5rem;
|
|
26
|
+
--space-4xl: 6rem;
|
|
27
|
+
--space-5xl: 10rem;
|
|
28
|
+
--space-6xl: 20rem;
|
|
29
|
+
--space-max: 32rem;
|
|
30
|
+
|
|
31
|
+
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|
32
|
+
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
33
|
+
--shadow-lg: 0 10px 25px rgba(0, 0, 0, 0.3);
|
|
34
|
+
--shadow-xl: 0 25px 50px -12px rgba(0, 0, 0, 0.4);
|
|
35
|
+
--shadow-light: 0 25px 50px -12px rgba(0, 0, 0, 0.15);
|
|
36
|
+
|
|
37
|
+
--radius-sm: 0.375rem;
|
|
38
|
+
--radius-base: 0.5rem;
|
|
39
|
+
--radius-md: 0.75rem;
|
|
40
|
+
--radius-lg: 1rem;
|
|
41
|
+
--radius-xl: 1.5rem;
|
|
42
|
+
--radius-2xl: 2.0rem;
|
|
43
|
+
--radius-full: 9999px;
|
|
44
|
+
|
|
45
|
+
--bg-main: var(--color-brand-dark);
|
|
46
|
+
--bg-surface: #fff;
|
|
47
|
+
--bg-deep: #0b0f19;
|
|
48
|
+
--bg-translucent: rgba(0, 0, 0, 0.9);
|
|
49
|
+
--bg-backdrop: rgba(0, 0, 0, 0.4);
|
|
50
|
+
--nav-bg: rgba(0, 0, 0, 0.9);
|
|
51
|
+
--nav-bg-alt: rgba(2, 6, 23, 0.95);
|
|
52
|
+
--header-bg-light: rgba(248, 250, 252, 0.95);
|
|
53
|
+
|
|
54
|
+
--text-main: var(--color-brand-white);
|
|
55
|
+
--text-muted: #475569;
|
|
56
|
+
--border-subtle: rgba(29, 218, 188, 0.15);
|
|
57
|
+
--glass-bg: rgba(255, 255, 255, 0.03);
|
|
58
|
+
--glass-border: rgba(255, 255, 255, 0.08);
|
|
59
|
+
--color-accent: #0fa;
|
|
60
|
+
|
|
61
|
+
--font-family: 'Inter', sans-serif;
|
|
62
|
+
--font-weight-light: 300;
|
|
63
|
+
--font-weight-normal: 400;
|
|
64
|
+
--font-weight-semibold: 600;
|
|
65
|
+
--font-weight-bold: 800;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
:root,
|
|
69
|
+
.theme-light {
|
|
70
|
+
--border-base: #e2e8f0;
|
|
71
|
+
--border-color: #e2e8f0;
|
|
72
|
+
--bg-main: #f0f4f8;
|
|
73
|
+
--bg-surface: var(--color-brand-white);
|
|
74
|
+
--bg-deep: #e2e8f0;
|
|
75
|
+
--nav-bg: rgba(255, 255, 255, 0.9);
|
|
76
|
+
--text-main: var(--color-brand-dark);
|
|
77
|
+
--text-muted: #4a5568;
|
|
78
|
+
--border-subtle: rgba(230, 230, 230, 0.5);
|
|
79
|
+
--glass-bg: rgba(0, 0, 0, 0.05);
|
|
80
|
+
--glass-border: rgba(0, 0, 0, 0.1);
|
|
81
|
+
--color-accent: #0cb587;
|
|
82
|
+
--bg-page: #f8fafc;
|
|
83
|
+
--bg-muted: #f1f5f9;
|
|
84
|
+
--text-base: #0f172a;
|
|
85
|
+
--accent: #6366f1;
|
|
86
|
+
--color-success: #10b981;
|
|
87
|
+
--color-error: #f43f5e;
|
|
88
|
+
--color-warning: #f59e0b;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.dark,
|
|
92
|
+
.theme-dark {
|
|
93
|
+
--bg-main: var(--color-brand-dark);
|
|
94
|
+
--text-main: var(--color-brand-white);
|
|
95
|
+
--nav-bg: var(--nav-bg-alt);
|
|
96
|
+
--bg-surface: #0f172a;
|
|
97
|
+
--text-muted: #94a3b8;
|
|
98
|
+
--border-base: #1e293b;
|
|
99
|
+
--border-color: #1e293b;
|
|
100
|
+
--bg-page: #020617;
|
|
101
|
+
--bg-muted: #1e293b;
|
|
102
|
+
--text-base: #f8fafc;
|
|
103
|
+
--accent: #818cf8;
|
|
104
|
+
--color-success: #34d399;
|
|
105
|
+
--color-error: #fb7185;
|
|
106
|
+
--color-warning: #fbbf24;
|
|
107
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ALL_LANDING_DEFINITIONS } from '../entries';
|
|
3
|
+
import { normalizeText } from './landing-test-helpers';
|
|
4
|
+
|
|
5
|
+
type LocaleWithDiacritics = keyof typeof DIACRITIC_RULES;
|
|
6
|
+
|
|
7
|
+
const DIACRITIC_RULES = {
|
|
8
|
+
de: { language: 'German', expectedCharacters: 'a umlaut, o umlaut, u umlaut, eszett', characters: /[\u00e4\u00f6\u00fc\u00c4\u00d6\u00dc\u00df]/g, minPerThousandLetters: 0.1 },
|
|
9
|
+
es: { language: 'Spanish', expectedCharacters: 'accented vowels, u umlaut, enye', characters: /[\u00e1\u00e9\u00ed\u00f3\u00fa\u00fc\u00f1\u00c1\u00c9\u00cd\u00d3\u00da\u00dc\u00d1]/g, minPerThousandLetters: 0.1 },
|
|
10
|
+
fr: { language: 'French', expectedCharacters: 'French accented letters and ligatures', characters: /[\u00e0\u00e2\u00e6\u00e7\u00e9\u00e8\u00ea\u00eb\u00ee\u00ef\u00f4\u0153\u00f9\u00fb\u00fc\u00ff\u00c0\u00c2\u00c6\u00c7\u00c9\u00c8\u00ca\u00cb\u00ce\u00cf\u00d4\u0152\u00d9\u00db\u00dc\u0178]/g, minPerThousandLetters: 0.1 },
|
|
11
|
+
it: { language: 'Italian', expectedCharacters: 'Italian accented vowels', characters: /[\u00e0\u00e8\u00e9\u00ec\u00ed\u00ee\u00f2\u00f3\u00f9\u00fa\u00c0\u00c8\u00c9\u00cc\u00cd\u00ce\u00d2\u00d3\u00d9\u00da]/g, minPerThousandLetters: 0.1 },
|
|
12
|
+
pl: { language: 'Polish', expectedCharacters: 'Polish diacritics', characters: /[\u0105\u0107\u0119\u0142\u0144\u00f3\u015b\u017a\u017c\u0104\u0106\u0118\u0141\u0143\u00d3\u015a\u0179\u017b]/g, minPerThousandLetters: 0.1 },
|
|
13
|
+
pt: { language: 'Portuguese', expectedCharacters: 'Portuguese accented letters', characters: /[\u00e1\u00e2\u00e3\u00e0\u00e7\u00e9\u00ea\u00ed\u00f3\u00f4\u00f5\u00fa\u00fc\u00c1\u00c2\u00c3\u00c0\u00c7\u00c9\u00ca\u00cd\u00d3\u00d4\u00d5\u00da\u00dc]/g, minPerThousandLetters: 0.1 },
|
|
14
|
+
sv: { language: 'Swedish', expectedCharacters: 'a ring, a umlaut, o umlaut', characters: /[\u00e5\u00e4\u00f6\u00c5\u00c4\u00d6]/g, minPerThousandLetters: 0.1 },
|
|
15
|
+
tr: { language: 'Turkish', expectedCharacters: 'Turkish special letters', characters: /[\u00e7\u011f\u0131\u00f6\u015f\u00fc\u00c7\u011e\u0130\u00d6\u015e\u00dc]/g, minPerThousandLetters: 0.1 },
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
const LETTERS = /\p{L}/gu;
|
|
19
|
+
|
|
20
|
+
function letterCount(text: string): number {
|
|
21
|
+
return text.match(LETTERS)?.length ?? 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function diacriticCount(text: string, locale: LocaleWithDiacritics): number {
|
|
25
|
+
return text.match(DIACRITIC_RULES[locale].characters)?.length ?? 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function diacriticsPerThousandLetters(text: string, locale: LocaleWithDiacritics): number {
|
|
29
|
+
const letters = letterCount(text);
|
|
30
|
+
if (letters === 0) return 0;
|
|
31
|
+
return diacriticCount(text, locale) / letters * 1000;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('Diacritics density validation', () => {
|
|
35
|
+
ALL_LANDING_DEFINITIONS.forEach((landing) => {
|
|
36
|
+
describe(`Landing: ${landing.entry.id}`, () => {
|
|
37
|
+
Object.keys(DIACRITIC_RULES).forEach((locale) => {
|
|
38
|
+
it(`${locale} keeps the expected accent and special-letter set`, async () => {
|
|
39
|
+
const typedLocale = locale as LocaleWithDiacritics;
|
|
40
|
+
const loader = landing.entry.i18n[typedLocale];
|
|
41
|
+
if (!loader) return;
|
|
42
|
+
|
|
43
|
+
const text = normalizeText(await loader());
|
|
44
|
+
const rule = DIACRITIC_RULES[typedLocale];
|
|
45
|
+
const letters = letterCount(text);
|
|
46
|
+
const matches = diacriticCount(text, typedLocale);
|
|
47
|
+
const density = diacriticsPerThousandLetters(text, typedLocale);
|
|
48
|
+
|
|
49
|
+
expect(
|
|
50
|
+
density,
|
|
51
|
+
[
|
|
52
|
+
`Possible spelling or encoding issue detected in ${landing.entry.id}/${typedLocale} (${rule.language}).`,
|
|
53
|
+
`The text has ${matches} special characters (${density.toFixed(2)} per 1000 letters, ${letters} letters analyzed).`,
|
|
54
|
+
`This locale should include ${rule.expectedCharacters}.`,
|
|
55
|
+
'If the count is 0 or near 0, accents, tildes, or special letters were probably stripped by encoding or normalization.',
|
|
56
|
+
].join(' '),
|
|
57
|
+
).toBeGreaterThanOrEqual(rule.minPerThousandLetters);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ALL_LANDING_DEFINITIONS } from '../entries';
|
|
3
|
+
|
|
4
|
+
describe('I18n coverage validation', () => {
|
|
5
|
+
const expectedLocales = Array.from(
|
|
6
|
+
new Set(ALL_LANDING_DEFINITIONS.flatMap((landing) => Object.keys(landing.entry.i18n))),
|
|
7
|
+
).sort();
|
|
8
|
+
|
|
9
|
+
ALL_LANDING_DEFINITIONS.forEach(({ entry }) => {
|
|
10
|
+
describe(`Landing: ${entry.id}`, () => {
|
|
11
|
+
it('should include every locale currently used by landings', () => {
|
|
12
|
+
const registeredLocales = Object.keys(entry.i18n);
|
|
13
|
+
expectedLocales.forEach((locale) => {
|
|
14
|
+
expect(
|
|
15
|
+
registeredLocales,
|
|
16
|
+
`Landing "${entry.id}" is missing locale "${locale}"`,
|
|
17
|
+
).toContain(locale);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('all locale loaders should be functions', () => {
|
|
22
|
+
expectedLocales.forEach((locale) => {
|
|
23
|
+
const loader = entry.i18n[locale as keyof typeof entry.i18n];
|
|
24
|
+
expect(
|
|
25
|
+
typeof loader,
|
|
26
|
+
`Landing "${entry.id}" locale "${locale}" loader is not a function`,
|
|
27
|
+
).toBe('function');
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ALL_LANDING_DEFINITIONS } from '../entries';
|
|
3
|
+
import { collectStrings } from './landing-test-helpers';
|
|
4
|
+
|
|
5
|
+
const INVERTED_PUNCTUATION_LOCALES = {
|
|
6
|
+
es: {
|
|
7
|
+
language: 'Spanish',
|
|
8
|
+
questionStart: '\u00bf',
|
|
9
|
+
questionEnd: '?',
|
|
10
|
+
exclamationStart: '\u00a1',
|
|
11
|
+
exclamationEnd: '!',
|
|
12
|
+
},
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
type InvertedPunctuationLocale = keyof typeof INVERTED_PUNCTUATION_LOCALES;
|
|
16
|
+
|
|
17
|
+
function sentenceStart(text: string, endIndex: number): string {
|
|
18
|
+
const beforeMark = text.slice(0, endIndex).trimEnd();
|
|
19
|
+
const boundary = Math.max(
|
|
20
|
+
beforeMark.lastIndexOf('.'),
|
|
21
|
+
beforeMark.lastIndexOf(':'),
|
|
22
|
+
beforeMark.lastIndexOf(';'),
|
|
23
|
+
beforeMark.lastIndexOf('\n'),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
return beforeMark.slice(boundary + 1).trimStart();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function findMissingInvertedMarks(text: string, startMark: string, endMark: string): string[] {
|
|
30
|
+
return [...text.matchAll(new RegExp(`\\${endMark}`, 'g'))]
|
|
31
|
+
.map((match) => sentenceStart(text, match.index ?? 0))
|
|
32
|
+
.filter((segment) => segment.length > 0 && !segment.includes(startMark))
|
|
33
|
+
.map((segment) => `${segment}${endMark}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('Inverted punctuation validation', () => {
|
|
37
|
+
ALL_LANDING_DEFINITIONS.forEach((landing) => {
|
|
38
|
+
describe(`Landing: ${landing.entry.id}`, () => {
|
|
39
|
+
Object.keys(INVERTED_PUNCTUATION_LOCALES).forEach((locale) => {
|
|
40
|
+
it(`${locale} uses opening punctuation marks for questions and exclamations`, async () => {
|
|
41
|
+
const typedLocale = locale as InvertedPunctuationLocale;
|
|
42
|
+
const loader = landing.entry.i18n[typedLocale];
|
|
43
|
+
if (!loader) return;
|
|
44
|
+
|
|
45
|
+
const rule = INVERTED_PUNCTUATION_LOCALES[typedLocale];
|
|
46
|
+
const strings = collectStrings(await loader());
|
|
47
|
+
const missingQuestions = strings.flatMap((text) =>
|
|
48
|
+
findMissingInvertedMarks(text, rule.questionStart, rule.questionEnd),
|
|
49
|
+
);
|
|
50
|
+
const missingExclamations = strings.flatMap((text) =>
|
|
51
|
+
findMissingInvertedMarks(text, rule.exclamationStart, rule.exclamationEnd),
|
|
52
|
+
);
|
|
53
|
+
const failures = [...missingQuestions, ...missingExclamations];
|
|
54
|
+
|
|
55
|
+
expect(
|
|
56
|
+
failures,
|
|
57
|
+
[
|
|
58
|
+
`Missing opening punctuation marks in ${landing.entry.id}/${typedLocale} (${rule.language}).`,
|
|
59
|
+
`Questions must use ${rule.questionStart}...${rule.questionEnd} and exclamations must use ${rule.exclamationStart}...${rule.exclamationEnd}.`,
|
|
60
|
+
`Examples: ${failures.slice(0, 5).join(' | ')}`,
|
|
61
|
+
].join(' '),
|
|
62
|
+
).toHaveLength(0);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import type { LandingCardContent, LandingDefinition } from '../types';
|
|
4
|
+
|
|
5
|
+
export interface LandingLocaleCase {
|
|
6
|
+
landingId: string;
|
|
7
|
+
locale: string;
|
|
8
|
+
content: LandingCardContent;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface LandingExportValidationResult {
|
|
12
|
+
passed: boolean;
|
|
13
|
+
failures: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getFiles(dir: string, extensions?: string[]): string[] {
|
|
17
|
+
const results: string[] = [];
|
|
18
|
+
if (!fs.existsSync(dir)) return results;
|
|
19
|
+
|
|
20
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
21
|
+
const fullPath = path.join(dir, entry.name);
|
|
22
|
+
if (entry.isDirectory()) {
|
|
23
|
+
if (!['tests', 'node_modules', '.astro'].includes(entry.name)) {
|
|
24
|
+
results.push(...getFiles(fullPath, extensions));
|
|
25
|
+
}
|
|
26
|
+
} else if (!extensions || extensions.some((extension) => entry.name.endsWith(extension))) {
|
|
27
|
+
results.push(fullPath);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return results;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function relativePath(filePath: string): string {
|
|
35
|
+
return path.relative(process.cwd(), filePath).replace(/\\/g, '/');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function collectStrings(value: unknown): string[] {
|
|
39
|
+
if (typeof value === 'string') return [value];
|
|
40
|
+
if (!value || typeof value !== 'object') return [];
|
|
41
|
+
if (Array.isArray(value)) return value.flatMap(collectStrings);
|
|
42
|
+
return Object.values(value).flatMap(collectStrings);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function normalizeText(value: unknown): string {
|
|
46
|
+
return collectStrings(value).join(' ').normalize('NFC');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function landingLocaleCases(landings: LandingDefinition[]): Promise<LandingLocaleCase[]> {
|
|
50
|
+
const cases: LandingLocaleCase[] = [];
|
|
51
|
+
|
|
52
|
+
for (const landing of landings) {
|
|
53
|
+
for (const [locale, loader] of Object.entries(landing.entry.i18n)) {
|
|
54
|
+
if (!loader) continue;
|
|
55
|
+
cases.push({
|
|
56
|
+
landingId: landing.entry.id,
|
|
57
|
+
locale,
|
|
58
|
+
content: await loader(),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return cases;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function validateLandingExports(landings: LandingDefinition[]): Promise<LandingExportValidationResult> {
|
|
67
|
+
const failures: string[] = [];
|
|
68
|
+
|
|
69
|
+
for (const landing of landings) {
|
|
70
|
+
if (typeof landing.LandingComponent !== 'function') {
|
|
71
|
+
failures.push(`${landing.entry.id}: LandingComponent is not a function (${typeof landing.LandingComponent})`);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const result = await landing.LandingComponent();
|
|
77
|
+
if (!result || typeof result !== 'object') {
|
|
78
|
+
failures.push(`${landing.entry.id}: LandingComponent import returned invalid result`);
|
|
79
|
+
}
|
|
80
|
+
} catch (error) {
|
|
81
|
+
failures.push(`${landing.entry.id}: LandingComponent execution error - ${error instanceof Error ? error.message : 'unknown'}`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
passed: failures.length === 0,
|
|
87
|
+
failures,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ALL_LANDING_DEFINITIONS } from '../entries';
|
|
3
|
+
import { validateLandingExports } from './landing-test-helpers';
|
|
4
|
+
|
|
5
|
+
describe('Landing exports pattern validation', () => {
|
|
6
|
+
it('all landings should be registered', () => {
|
|
7
|
+
expect(ALL_LANDING_DEFINITIONS.length).toBeGreaterThan(0);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
ALL_LANDING_DEFINITIONS.forEach((landing) => {
|
|
11
|
+
it(`${landing.entry.id}: LandingComponent should be a lazy-loaded function`, () => {
|
|
12
|
+
expect(typeof landing.LandingComponent).toBe('function');
|
|
13
|
+
expect(landing.LandingComponent).toBeInstanceOf(Function);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it(`${landing.entry.id}: entry should define an i18n map`, () => {
|
|
17
|
+
expect(landing.entry.id).toBeTruthy();
|
|
18
|
+
expect(Object.keys(landing.entry.i18n).length).toBeGreaterThan(0);
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('all landing dynamic imports should work', async () => {
|
|
23
|
+
const result = await validateLandingExports(ALL_LANDING_DEFINITIONS);
|
|
24
|
+
if (!result.passed) {
|
|
25
|
+
throw new Error(`Landing export validation failed:\n${result.failures.join('\n')}`);
|
|
26
|
+
}
|
|
27
|
+
expect(result.passed).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default {};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { getFiles, relativePath } from './landing-test-helpers';
|
|
5
|
+
|
|
6
|
+
function isContentFile(filePath: string): boolean {
|
|
7
|
+
return filePath.includes(`${path.sep}i18n${path.sep}`);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const filesToTest = getFiles(path.join(process.cwd(), 'src')).filter(isContentFile);
|
|
11
|
+
const aiTypographyGarbage = [
|
|
12
|
+
'\u2013',
|
|
13
|
+
'\u2014',
|
|
14
|
+
'\u2026',
|
|
15
|
+
'\u201c',
|
|
16
|
+
'\u201d',
|
|
17
|
+
'\u2018',
|
|
18
|
+
'\u2019',
|
|
19
|
+
'\u00ab',
|
|
20
|
+
'\u00bb',
|
|
21
|
+
'\u200b',
|
|
22
|
+
'\u201e',
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
describe('Typography garbage character validation', () => {
|
|
26
|
+
filesToTest.forEach((filePath) => {
|
|
27
|
+
const displayPath = relativePath(filePath);
|
|
28
|
+
|
|
29
|
+
it(`should not contain typography garbage characters in ${displayPath}`, () => {
|
|
30
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
31
|
+
const hasAiPatterns = aiTypographyGarbage.some((char) => content.includes(char));
|
|
32
|
+
expect(hasAiPatterns).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it(`should not contain space before colon in ${displayPath}`, () => {
|
|
36
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
37
|
+
expect(/ : /.test(content)).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it(`should not contain double hyphen in ${displayPath}`, () => {
|
|
41
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
42
|
+
expect(content).not.toContain('--');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { ALL_LANDING_DEFINITIONS } from '../entries';
|
|
4
|
+
import { getFiles, relativePath } from './landing-test-helpers';
|
|
5
|
+
|
|
6
|
+
function h1Count(content: string): number {
|
|
7
|
+
return content.match(/<h1[\s>]/gi)?.length ?? 0;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function landingFileForFolder(folderName: string): string | undefined {
|
|
11
|
+
const files = getFiles(`${process.cwd()}\\src\\landing`, ['.astro'])
|
|
12
|
+
.filter((file) => file.endsWith('\\landing.astro') || file.endsWith('/landing.astro'));
|
|
13
|
+
return files.find((file) => file.includes(`\\${folderName}\\`) || file.includes(`/${folderName}/`));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('Landing H1 validation', () => {
|
|
17
|
+
ALL_LANDING_DEFINITIONS.forEach((landing) => {
|
|
18
|
+
it(`${landing.entry.id} should contain exactly one <h1>`, async () => {
|
|
19
|
+
const enContent = await landing.entry.i18n.en?.();
|
|
20
|
+
const file = landingFileForFolder(landing.entry.id) ?? (enContent ? landingFileForFolder(enContent.slug) : undefined);
|
|
21
|
+
if (!file) {
|
|
22
|
+
throw new Error(`Could not find landing.astro for landing "${landing.entry.id}"`);
|
|
23
|
+
}
|
|
24
|
+
const displayPath = relativePath(file);
|
|
25
|
+
const content = readFileSync(file, 'utf-8');
|
|
26
|
+
|
|
27
|
+
expect(
|
|
28
|
+
h1Count(content),
|
|
29
|
+
`Landing "${landing.entry.id}" in ${displayPath} must contain exactly one <h1>.`,
|
|
30
|
+
).toBe(1);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { describe, expect, it } from 'vitest';
|
|
4
|
+
import { relativePath } from './landing-test-helpers';
|
|
5
|
+
|
|
6
|
+
const landingDir = join(process.cwd(), 'src', 'landing');
|
|
7
|
+
const geometryReads = [
|
|
8
|
+
'offsetWidth',
|
|
9
|
+
'offsetHeight',
|
|
10
|
+
'offsetTop',
|
|
11
|
+
'offsetLeft',
|
|
12
|
+
'clientWidth',
|
|
13
|
+
'clientHeight',
|
|
14
|
+
'clientTop',
|
|
15
|
+
'clientLeft',
|
|
16
|
+
'scrollWidth',
|
|
17
|
+
'scrollHeight',
|
|
18
|
+
'scrollTop',
|
|
19
|
+
'scrollLeft',
|
|
20
|
+
'getBoundingClientRect',
|
|
21
|
+
'getClientRects',
|
|
22
|
+
'computedStyle',
|
|
23
|
+
'getComputedStyle',
|
|
24
|
+
];
|
|
25
|
+
const domWrites = [
|
|
26
|
+
'.style.',
|
|
27
|
+
'.classList.add',
|
|
28
|
+
'.classList.remove',
|
|
29
|
+
'.classList.toggle',
|
|
30
|
+
'.appendChild',
|
|
31
|
+
'.insertBefore',
|
|
32
|
+
'.prepend',
|
|
33
|
+
'.append',
|
|
34
|
+
'.remove',
|
|
35
|
+
'.innerHTML',
|
|
36
|
+
'.textContent',
|
|
37
|
+
'.setAttribute',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
function findFiles(dir: string, extensions: string[]): string[] {
|
|
41
|
+
const files: string[] = [];
|
|
42
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
43
|
+
const fullPath = join(dir, entry.name);
|
|
44
|
+
if (entry.isDirectory()) files.push(...findFiles(fullPath, extensions));
|
|
45
|
+
else if (extensions.some((extension) => entry.name.endsWith(extension))) files.push(fullPath);
|
|
46
|
+
}
|
|
47
|
+
return files;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function findFormControls(content: string, tagName: 'input' | 'select'): RegExpMatchArray[] {
|
|
51
|
+
return Array.from(content.matchAll(new RegExp(`<${tagName}\\b[^>]*>`, 'gi')));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function attrValue(tag: string, attr: string): string | null {
|
|
55
|
+
const match = tag.match(new RegExp(`\\b${attr}\\s*=\\s*(?:"([^"]+)"|'([^']+)'|\\{([^}]+)\\})`, 'i'));
|
|
56
|
+
return match?.[1] ?? match?.[2] ?? match?.[3] ?? null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function booleanAttr(tag: string, attr: string): boolean {
|
|
60
|
+
return new RegExp(`\\b${attr}\\b`, 'i').test(tag);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function hasExplicitLabel(content: string, id: string): boolean {
|
|
64
|
+
const escapedId = id.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
65
|
+
return (
|
|
66
|
+
new RegExp(`<label\\b[^>]*\\bfor\\s*=\\s*["']${escapedId}["'][^>]*>`, 'i').test(content)
|
|
67
|
+
|| new RegExp(`<label\\b[^>]*\\bfor\\s*=\\s*\\{${escapedId}\\}[^>]*>`, 'i').test(content)
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function hasWrappingLabel(content: string, tag: RegExpMatchArray): boolean {
|
|
72
|
+
const index = tag.index ?? content.indexOf(tag[0]);
|
|
73
|
+
const before = content.slice(0, index);
|
|
74
|
+
const labelOpen = before.lastIndexOf('<label');
|
|
75
|
+
const labelClose = before.lastIndexOf('</label>');
|
|
76
|
+
const nextLabelClose = content.indexOf('</label>', index + tag[0].length);
|
|
77
|
+
return labelOpen > labelClose && nextLabelClose !== -1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function hasAccessibleName(content: string, tag: RegExpMatchArray): boolean {
|
|
81
|
+
const source = tag[0];
|
|
82
|
+
if (attrValue(source, 'aria-label')) return true;
|
|
83
|
+
if (attrValue(source, 'aria-labelledby')) return true;
|
|
84
|
+
const id = attrValue(source, 'id');
|
|
85
|
+
if (id && hasExplicitLabel(content, id)) return true;
|
|
86
|
+
return hasWrappingLabel(content, tag);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isIgnoredInput(tag: string): boolean {
|
|
90
|
+
const type = attrValue(tag, 'type')?.toLowerCase() ?? 'text';
|
|
91
|
+
const attributes = `${attrValue(tag, 'style') ?? ''} ${attrValue(tag, 'class') ?? ''}`.toLowerCase();
|
|
92
|
+
const visuallyHiddenFile = type === 'file' && ['display:none', 'display: none', 'file-input'].some((pattern) => attributes.includes(pattern));
|
|
93
|
+
return ['hidden', 'button', 'submit', 'reset'].includes(type) || booleanAttr(tag, 'aria-hidden') || visuallyHiddenFile;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function controlFailures(content: string, tagName: 'input' | 'select'): string[] {
|
|
97
|
+
return findFormControls(content, tagName)
|
|
98
|
+
.filter((tag) => tagName !== 'input' || !isIgnoredInput(tag[0]))
|
|
99
|
+
.filter((tag) => !hasAccessibleName(content, tag))
|
|
100
|
+
.map((tag) => tag[0]);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function hasDomWriteBeforeGeometryRead(content: string): boolean {
|
|
104
|
+
const normalized = content.replace(/\s+/g, ' ');
|
|
105
|
+
return domWrites.some((write) => {
|
|
106
|
+
const writeIndex = normalized.indexOf(write);
|
|
107
|
+
if (writeIndex === -1) return false;
|
|
108
|
+
return geometryReads.some((read) => normalized.indexOf(read, writeIndex + write.length) !== -1);
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
describe('PageSpeed best-practice guards', () => {
|
|
113
|
+
const astroLandingFiles = findFiles(landingDir, ['.astro']);
|
|
114
|
+
const scriptFiles = findFiles(landingDir, ['.astro', '.ts', '.js']);
|
|
115
|
+
|
|
116
|
+
astroLandingFiles.forEach((file) => {
|
|
117
|
+
const displayPath = relativePath(file);
|
|
118
|
+
|
|
119
|
+
it(`${displayPath} labels every input with an explicit label`, () => {
|
|
120
|
+
const content = readFileSync(file, 'utf-8');
|
|
121
|
+
const failures = controlFailures(content, 'input');
|
|
122
|
+
expect(failures, `Input controls without label, wrapping label, aria-label or aria-labelledby in ${displayPath}:\n${failures.join('\n')}`).toEqual([]);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it(`${displayPath} labels every select with an explicit label`, () => {
|
|
126
|
+
const content = readFileSync(file, 'utf-8');
|
|
127
|
+
const failures = controlFailures(content, 'select');
|
|
128
|
+
expect(failures, `Select controls without label, wrapping label, aria-label or aria-labelledby in ${displayPath}:\n${failures.join('\n')}`).toEqual([]);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
scriptFiles.forEach((file) => {
|
|
133
|
+
const displayPath = relativePath(file);
|
|
134
|
+
|
|
135
|
+
it(`${displayPath} avoids static forced-reflow patterns`, () => {
|
|
136
|
+
const content = readFileSync(file, 'utf-8');
|
|
137
|
+
expect(
|
|
138
|
+
hasDomWriteBeforeGeometryRead(content),
|
|
139
|
+
`${displayPath} appears to read layout geometry after DOM/style mutations. Split writes and reads across frames or measure before mutating.`,
|
|
140
|
+
).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { ALL_LANDING_DEFINITIONS } from '../entries';
|
|
3
|
+
import { normalizeText } from './landing-test-helpers';
|
|
4
|
+
|
|
5
|
+
type ScriptLocale = keyof typeof SCRIPT_RULES;
|
|
6
|
+
|
|
7
|
+
const SCRIPT_RULES = {
|
|
8
|
+
ja: { language: 'Japanese', scriptName: 'kana/kanji', scriptCharacters: /[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}]/gu, minScriptRatio: 0.45 },
|
|
9
|
+
ko: { language: 'Korean', scriptName: 'hangul', scriptCharacters: /\p{Script=Hangul}/gu, minScriptRatio: 0.55 },
|
|
10
|
+
ru: { language: 'Russian', scriptName: 'cyrillic', scriptCharacters: /\p{Script=Cyrillic}/gu, minScriptRatio: 0.65 },
|
|
11
|
+
zh: { language: 'Chinese', scriptName: 'han', scriptCharacters: /\p{Script=Han}/gu, minScriptRatio: 0.45 },
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
const LETTERS = /\p{L}/gu;
|
|
15
|
+
|
|
16
|
+
function letterCount(text: string): number {
|
|
17
|
+
return text.match(LETTERS)?.length ?? 0;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function scriptCount(text: string, locale: ScriptLocale): number {
|
|
21
|
+
return text.match(SCRIPT_RULES[locale].scriptCharacters)?.length ?? 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function scriptRatio(text: string, locale: ScriptLocale): number {
|
|
25
|
+
const letters = letterCount(text);
|
|
26
|
+
if (letters === 0) return 0;
|
|
27
|
+
return scriptCount(text, locale) / letters;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('Native script density validation', () => {
|
|
31
|
+
ALL_LANDING_DEFINITIONS.forEach((landing) => {
|
|
32
|
+
describe(`Landing: ${landing.entry.id}`, () => {
|
|
33
|
+
Object.keys(SCRIPT_RULES).forEach((locale) => {
|
|
34
|
+
it(`${locale} keeps most translated text in its native script`, async () => {
|
|
35
|
+
const typedLocale = locale as ScriptLocale;
|
|
36
|
+
const loader = landing.entry.i18n[typedLocale];
|
|
37
|
+
if (!loader) return;
|
|
38
|
+
|
|
39
|
+
const rule = SCRIPT_RULES[typedLocale];
|
|
40
|
+
const text = normalizeText(await loader());
|
|
41
|
+
const letters = letterCount(text);
|
|
42
|
+
const matches = scriptCount(text, typedLocale);
|
|
43
|
+
const ratio = scriptRatio(text, typedLocale);
|
|
44
|
+
|
|
45
|
+
expect(
|
|
46
|
+
ratio,
|
|
47
|
+
[
|
|
48
|
+
`Possible broken translation detected in ${landing.entry.id}/${typedLocale} (${rule.language}).`,
|
|
49
|
+
`The text has ${matches} ${rule.scriptName} characters out of ${letters} analyzed letters (${(ratio * 100).toFixed(1)}%).`,
|
|
50
|
+
`Most translatable content should be written in ${rule.scriptName} script.`,
|
|
51
|
+
].join(' '),
|
|
52
|
+
).toBeGreaterThanOrEqual(rule.minScriptRatio);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
});
|