@eifi1/ui-kit 0.30.0 → 0.31.1
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 +42 -5
- package/dist/account/data-export-setting.js +3 -2
- package/dist/account/data-export-setting.js.map +1 -1
- package/dist/account/delete-account-setting.d.ts +5 -1
- package/dist/account/delete-account-setting.js +3 -2
- package/dist/account/delete-account-setting.js.map +1 -1
- package/dist/account/email-change-setting.js +4 -3
- package/dist/account/email-change-setting.js.map +1 -1
- package/dist/account/sessions-setting.js +3 -2
- package/dist/account/sessions-setting.js.map +1 -1
- package/dist/admin/admin-action-confirm.d.ts +5 -1
- package/dist/admin/admin-parts.d.ts +8 -1
- package/dist/admin/admin-parts.js +1 -1
- package/dist/admin/admin-parts.js.map +1 -1
- package/dist/admin/invitations-panel.d.ts +5 -1
- package/dist/admin/invitations-panel.js +3 -2
- package/dist/admin/invitations-panel.js.map +1 -1
- package/dist/admin/reviewer-scope-editor.d.ts +5 -1
- package/dist/admin/role-select.d.ts +5 -1
- package/dist/admin/transfer-ownership-dialog.d.ts +5 -1
- package/dist/admin/user-roster.d.ts +5 -1
- package/dist/auth/auth-errors.d.ts +24 -6
- package/dist/auth/auth-errors.js +7 -1
- package/dist/auth/auth-errors.js.map +1 -1
- package/dist/auth/register-form.d.ts +5 -1
- package/dist/auth/reset-password-form.d.ts +1 -1
- package/dist/auth/reset-password-form.js +2 -1
- package/dist/auth/reset-password-form.js.map +1 -1
- package/dist/auth/sign-in-form.d.ts +15 -1
- package/dist/auth/sign-in-form.js +12 -3
- package/dist/auth/sign-in-form.js.map +1 -1
- package/dist/auth/verify-email.d.ts +4 -0
- package/dist/auth/verify-email.js +3 -2
- package/dist/auth/verify-email.js.map +1 -1
- package/dist/chart.d.ts +5 -1
- package/dist/components/account-settings-labels.d.ts +12 -0
- package/dist/components/account-settings-labels.js +2 -1
- package/dist/components/account-settings-labels.js.map +1 -1
- package/dist/components/account-settings.d.ts +8 -2
- package/dist/components/account-settings.js +19 -11
- package/dist/components/account-settings.js.map +1 -1
- package/dist/components/amount-input.d.ts +5 -1
- package/dist/components/button-group.d.ts +5 -1
- package/dist/components/calculator.d.ts +5 -1
- package/dist/components/confirm-dialog.d.ts +5 -1
- package/dist/components/confirm-dialog.js +7 -1
- package/dist/components/confirm-dialog.js.map +1 -1
- package/dist/components/copy-button.d.ts +5 -1
- package/dist/components/danger-confirm.d.ts +5 -1
- package/dist/components/data-table.js +8 -1
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.js +9 -4
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/facing-pair.d.ts +5 -1
- package/dist/components/file-button.d.ts +5 -1
- package/dist/components/file-dropzone.d.ts +5 -1
- package/dist/components/form-actions.d.ts +5 -1
- package/dist/components/iban-input.d.ts +5 -1
- package/dist/components/language-select.d.ts +5 -1
- package/dist/components/legal-page.d.ts +5 -1
- package/dist/components/legal.d.ts +5 -1
- package/dist/components/money-field.d.ts +5 -1
- package/dist/components/number-field.d.ts +5 -1
- package/dist/components/number-input.d.ts +5 -1
- package/dist/components/numpad-sheet.d.ts +5 -1
- package/dist/components/passkeys-setting.js +2 -1
- package/dist/components/passkeys-setting.js.map +1 -1
- package/dist/components/phone-input.d.ts +5 -1
- package/dist/components/series-chart.d.ts +5 -1
- package/dist/components/settings-fields.d.ts +40 -3
- package/dist/components/settings-fields.js +20 -1
- package/dist/components/settings-fields.js.map +1 -1
- package/dist/components/share-card.d.ts +5 -1
- package/dist/components/text-link.d.ts +5 -1
- package/dist/components/time-input.d.ts +5 -1
- package/dist/components/ui.d.ts +5 -1
- package/dist/{delete-account-setting-CGVMckMw.d.ts → delete-account-setting-CpIhzxxZ.d.ts} +60 -23
- package/dist/demo/demo-banner.d.ts +51 -0
- package/dist/demo/demo-banner.js +97 -0
- package/dist/demo/demo-banner.js.map +1 -0
- package/dist/demo/demo-countdown.d.ts +58 -0
- package/dist/demo/demo-countdown.js +79 -0
- package/dist/demo/demo-countdown.js.map +1 -0
- package/dist/demo/demo-ended.d.ts +45 -0
- package/dist/demo/demo-ended.js +42 -0
- package/dist/demo/demo-ended.js.map +1 -0
- package/dist/demo/demo-labels.d.ts +53 -0
- package/dist/demo/demo-labels.js +29 -0
- package/dist/demo/demo-labels.js.map +1 -0
- package/dist/demo/demo-session.d.ts +30 -0
- package/dist/demo/demo-session.js +8 -0
- package/dist/demo/demo-session.js.map +1 -0
- package/dist/demo/demo-start.d.ts +54 -0
- package/dist/demo/demo-start.js +68 -0
- package/dist/demo/demo-start.js.map +1 -0
- package/dist/feedback/feedback-attachment.d.ts +5 -1
- package/dist/feedback/feedback-context.d.ts +5 -1
- package/dist/feedback/feedback-crash.d.ts +5 -1
- package/dist/feedback/feedback-dialog.d.ts +5 -1
- package/dist/feedback/feedback-inbox.d.ts +5 -1
- package/dist/feedback/feedback-inbox.js +12 -2
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/feedback/feedback-labels.d.ts +5 -1
- package/dist/feedback/feedback-labels.js +1 -0
- package/dist/feedback/feedback-labels.js.map +1 -1
- package/dist/feedback/feedback-menu.d.ts +5 -1
- package/dist/feedback/feedback-record.d.ts +5 -1
- package/dist/feedback/feedback-record.js +2 -0
- package/dist/feedback/feedback-record.js.map +1 -1
- package/dist/feedback/feedback-row-detail.d.ts +5 -1
- package/dist/feedback/feedback-row-detail.js +7 -0
- package/dist/feedback/feedback-row-detail.js.map +1 -1
- package/dist/feedback/feedback-status-undo.d.ts +5 -1
- package/dist/feedback/feedback-submit.d.ts +5 -1
- package/dist/feedback/feedback-swipe.d.ts +6 -2
- package/dist/feedback/feedback-swipe.js.map +1 -1
- package/dist/feedback/feedback-table.d.ts +5 -1
- package/dist/feedback/feedback-thread.d.ts +5 -1
- package/dist/feedback.d.ts +5 -1
- package/dist/hooks/use-file-drop.d.ts +5 -1
- package/dist/i18n/defaults.d.ts +5 -1
- package/dist/i18n/defaults.js +6 -0
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.d.ts +5 -1
- package/dist/i18n/german.js +85 -2
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +5 -1
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/languages.d.ts +5 -1
- package/dist/i18n/locales/de-CH.d.ts +5 -1
- package/dist/i18n/locales/en.d.ts +5 -1
- package/dist/i18n/locales/es.d.ts +5 -1
- package/dist/i18n/locales/es.js +82 -2
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +5 -1
- package/dist/i18n/locales/fr.js +83 -2
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +5 -1
- package/dist/i18n/locales/hu.js +83 -2
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +5 -1
- package/dist/i18n/locales/it.js +82 -2
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +5 -1
- package/dist/i18n/locales/zh.js +81 -2
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.d.ts +5 -1
- package/dist/i18n/review.js +15 -1
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +25 -3
- package/dist/index.js +96 -0
- package/dist/index.js.map +1 -1
- package/dist/landing/access.d.ts +48 -0
- package/dist/landing/access.js +38 -0
- package/dist/landing/access.js.map +1 -0
- package/dist/landing/landing-actions.d.ts +140 -0
- package/dist/landing/landing-actions.js +39 -0
- package/dist/landing/landing-actions.js.map +1 -0
- package/dist/landing/landing-labels.d.ts +43 -0
- package/dist/landing/landing-labels.js +23 -0
- package/dist/landing/landing-labels.js.map +1 -0
- package/dist/landing/landing-sections.d.ts +218 -0
- package/dist/landing/landing-sections.js +167 -0
- package/dist/landing/landing-sections.js.map +1 -0
- package/dist/landing/page-seo.d.ts +99 -0
- package/dist/landing/page-seo.js +121 -0
- package/dist/landing/page-seo.js.map +1 -0
- package/dist/landing/public-header.d.ts +148 -0
- package/dist/landing/public-header.js +49 -0
- package/dist/landing/public-header.js.map +1 -0
- package/dist/landing/routing.d.ts +231 -0
- package/dist/landing/routing.js +84 -0
- package/dist/landing/routing.js.map +1 -0
- package/dist/rhf/fields.d.ts +5 -1
- package/dist/rhf/form.d.ts +5 -1
- package/dist/rhf.d.ts +5 -1
- package/dist/settings/settings-catalogue.d.ts +84 -0
- package/dist/settings/settings-catalogue.js +47 -0
- package/dist/settings/settings-catalogue.js.map +1 -0
- package/dist/settings/settings-context.d.ts +31 -0
- package/dist/settings/settings-context.js +11 -0
- package/dist/settings/settings-context.js.map +1 -0
- package/dist/settings/settings-heading.d.ts +50 -0
- package/dist/settings/settings-heading.js +27 -0
- package/dist/settings/settings-heading.js.map +1 -0
- package/dist/settings/settings-labels.d.ts +48 -0
- package/dist/settings/settings-labels.js +43 -0
- package/dist/settings/settings-labels.js.map +1 -0
- package/dist/settings/settings-layout.d.ts +77 -0
- package/dist/settings/settings-layout.js +242 -0
- package/dist/settings/settings-layout.js.map +1 -0
- package/dist/settings/settings-section.d.ts +133 -0
- package/dist/settings/settings-section.js +63 -0
- package/dist/settings/settings-section.js.map +1 -0
- package/dist/settings/use-account-language.d.ts +83 -0
- package/dist/settings/use-account-language.js +62 -0
- package/dist/settings/use-account-language.js.map +1 -0
- package/dist/settings/use-settings-focus.d.ts +34 -0
- package/dist/settings/use-settings-focus.js +77 -0
- package/dist/settings/use-settings-focus.js.map +1 -0
- package/dist/settings/use-settings-route.d.ts +118 -0
- package/dist/settings/use-settings-route.js +120 -0
- package/dist/settings/use-settings-route.js.map +1 -0
- package/dist/shell/app-shell.d.ts +11 -1
- package/dist/shell/app-shell.js +3 -2
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/top-bar-brand.d.ts +5 -1
- package/dist/shell.d.ts +15 -1
- package/dist/shell.js +56 -0
- package/dist/shell.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +5 -1
- package/dist/wizard.d.ts +5 -1
- package/package.json +2 -1
- package/src/account/data-export-setting.tsx +3 -2
- package/src/account/delete-account-setting.tsx +3 -2
- package/src/account/email-change-setting.tsx +4 -7
- package/src/account/sessions-setting.tsx +3 -2
- package/src/admin/admin-parts.ts +9 -2
- package/src/admin/invitations-panel.tsx +11 -4
- package/src/auth/auth-errors.ts +36 -5
- package/src/auth/reset-password-form.tsx +6 -2
- package/src/auth/sign-in-form.tsx +34 -3
- package/src/auth/verify-email.tsx +14 -2
- package/src/components/account-settings-labels.ts +13 -0
- package/src/components/account-settings.tsx +33 -12
- package/src/components/confirm-dialog.tsx +20 -1
- package/src/components/data-table.tsx +8 -0
- package/src/components/date-picker.tsx +9 -4
- package/src/components/passkeys-setting.tsx +2 -1
- package/src/components/settings-fields.tsx +50 -1
- package/src/demo/demo-banner.tsx +159 -0
- package/src/demo/demo-countdown.ts +141 -0
- package/src/demo/demo-ended.tsx +89 -0
- package/src/demo/demo-labels.ts +78 -0
- package/src/demo/demo-session.ts +32 -0
- package/src/demo/demo-start.tsx +150 -0
- package/src/feedback/feedback-inbox.tsx +21 -8
- package/src/feedback/feedback-labels.ts +2 -1
- package/src/feedback/feedback-record.ts +10 -7
- package/src/feedback/feedback-row-detail.tsx +28 -4
- package/src/feedback/feedback-swipe.tsx +1 -1
- package/src/i18n/defaults.ts +6 -0
- package/src/i18n/german.ts +87 -0
- package/src/i18n/kit-labels.tsx +9 -0
- package/src/i18n/locales/es.ts +84 -0
- package/src/i18n/locales/fr.ts +85 -0
- package/src/i18n/locales/hu.ts +85 -0
- package/src/i18n/locales/it.ts +84 -0
- package/src/i18n/locales/zh.ts +82 -0
- package/src/i18n/review.ts +14 -0
- package/src/index.ts +98 -1
- package/src/landing/access.ts +97 -0
- package/src/landing/landing-actions.tsx +96 -0
- package/src/landing/landing-labels.ts +59 -0
- package/src/landing/landing-sections.tsx +337 -0
- package/src/landing/page-seo.ts +251 -0
- package/src/landing/public-header.tsx +120 -0
- package/src/landing/routing.tsx +226 -0
- package/src/settings/settings-catalogue.ts +132 -0
- package/src/settings/settings-context.ts +33 -0
- package/src/settings/settings-heading.tsx +65 -0
- package/src/settings/settings-labels.ts +87 -0
- package/src/settings/settings-layout.tsx +379 -0
- package/src/settings/settings-section.tsx +108 -0
- package/src/settings/use-account-language.ts +146 -0
- package/src/settings/use-settings-focus.ts +120 -0
- package/src/settings/use-settings-route.ts +258 -0
- package/src/shell/app-shell.tsx +10 -2
- package/src/shell.ts +51 -0
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
import { Chip } from "../components/chip";
|
|
5
|
+
import { LegalFooter } from "../components/legal";
|
|
6
|
+
import type { LegalFooterProps } from "../components/legal";
|
|
7
|
+
import { LandingActions } from "./landing-actions";
|
|
8
|
+
import type { LandingActionsProps } from "./landing-actions";
|
|
9
|
+
import { useLandingLabels } from "./landing-labels";
|
|
10
|
+
import type { LandingLabels } from "./landing-labels";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The sections of the public landing page (docs/landing-demo-harmonization.md §4.1),
|
|
14
|
+
* top to bottom: `Hero`, `FeatureRows` of `FeatureRow`, `TrustStrip`, `CtaBand`,
|
|
15
|
+
* `PublicFooter` — keksdose's landing-page.tsx, taken apart.
|
|
16
|
+
*
|
|
17
|
+
* The kit draws the frame; every word of marketing copy is the app's. Colours are the
|
|
18
|
+
* kit's tokens (`--brand`, `--text-*`, `--bg-surface`, `--border`), so keksdose's ten
|
|
19
|
+
* hard-coded teal sites follow the palette now (§2.11).
|
|
20
|
+
*
|
|
21
|
+
* **Visuals are decorative** (§4.1): the `visual` slots are `aria-hidden` and `inert` —
|
|
22
|
+
* mock UI drawn with kit tokens, never screenshots with real data, and never anything a
|
|
23
|
+
* keyboard could land on. The copy beside each one carries the information. A mock's
|
|
24
|
+
* amounts use the demo data's currency, so the landing shows what "Try the demo" opens.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
type HeadingLevel = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
|
28
|
+
|
|
29
|
+
/** The decorative slot: hidden from assistive tech and out of the tab order. */
|
|
30
|
+
function Visual({ children, className }: { children: ReactNode; className?: string }) {
|
|
31
|
+
return (
|
|
32
|
+
<div aria-hidden inert data-slot="landing-visual" className={cn("mx-auto w-full select-none", className)}>
|
|
33
|
+
{children}
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The action pair's props, as `Hero` and `CtaBand` pass them on. */
|
|
39
|
+
type ActionProps = Pick<LandingActionsProps, "session" | "access" | "demoHref" | "openAppHref">;
|
|
40
|
+
|
|
41
|
+
/* ── Hero ────────────────────────────────────────────────────────────────── */
|
|
42
|
+
|
|
43
|
+
export interface HeroProps extends Omit<ComponentPropsWithoutRef<"section">, "title" | "children">, ActionProps {
|
|
44
|
+
/** The page's `h1`: what the app does, in a line. */
|
|
45
|
+
title: ReactNode;
|
|
46
|
+
/** Under it: for whom, and why. */
|
|
47
|
+
subtitle?: ReactNode;
|
|
48
|
+
/** The kit's "Beta" chip above the title. */
|
|
49
|
+
beta?: boolean;
|
|
50
|
+
/** A badge of the app's own in that place; wins over `beta`. */
|
|
51
|
+
badge?: ReactNode;
|
|
52
|
+
/** Under the actions, quiet: "Free while in beta. Your data stays yours." */
|
|
53
|
+
trust?: ReactNode;
|
|
54
|
+
/** The product glimpse beside the copy (below it on a phone). Decorative. */
|
|
55
|
+
visual?: ReactNode;
|
|
56
|
+
/** Replaces the action pair ({@link LandingActions}). */
|
|
57
|
+
actions?: ReactNode;
|
|
58
|
+
/** Default `h1`; lower where the hero is embedded in another page. */
|
|
59
|
+
headingAs?: HeadingLevel;
|
|
60
|
+
labels?: Partial<LandingLabels>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The top of the landing (§4.1.2): an optional "Beta" badge, the `h1`, a subtitle, the
|
|
65
|
+
* action pair for the visitor's state ({@link LandingActions}: "Request access" and "Try
|
|
66
|
+
* the demo", or "Open app" for a session), a trust line, and a visual. Copy left and
|
|
67
|
+
* visual right from `lg` up, stacked below.
|
|
68
|
+
*/
|
|
69
|
+
export function Hero({
|
|
70
|
+
title,
|
|
71
|
+
subtitle,
|
|
72
|
+
beta = false,
|
|
73
|
+
badge,
|
|
74
|
+
trust,
|
|
75
|
+
visual,
|
|
76
|
+
actions,
|
|
77
|
+
headingAs: Heading = "h1",
|
|
78
|
+
session,
|
|
79
|
+
access,
|
|
80
|
+
demoHref,
|
|
81
|
+
openAppHref,
|
|
82
|
+
labels: labelsProp,
|
|
83
|
+
className,
|
|
84
|
+
...rest
|
|
85
|
+
}: HeroProps) {
|
|
86
|
+
const labels = useLandingLabels(labelsProp);
|
|
87
|
+
const shownBadge =
|
|
88
|
+
badge ??
|
|
89
|
+
(beta ? (
|
|
90
|
+
// keksdose's chip, padded out to the hero's scale.
|
|
91
|
+
<Chip size="sm" tone="warning" className="px-3 py-1 font-medium">
|
|
92
|
+
{labels.beta}
|
|
93
|
+
</Chip>
|
|
94
|
+
) : null);
|
|
95
|
+
return (
|
|
96
|
+
<section
|
|
97
|
+
{...rest}
|
|
98
|
+
data-slot="landing-hero"
|
|
99
|
+
className={cn(
|
|
100
|
+
"mx-auto grid max-w-6xl items-center gap-12 px-4 py-16 sm:py-24",
|
|
101
|
+
visual != null && "lg:grid-cols-2",
|
|
102
|
+
className,
|
|
103
|
+
)}
|
|
104
|
+
>
|
|
105
|
+
<div>
|
|
106
|
+
{shownBadge != null && <div className="mb-4">{shownBadge}</div>}
|
|
107
|
+
<Heading className="text-4xl font-bold tracking-tight text-balance text-[var(--text-primary)] sm:text-5xl">
|
|
108
|
+
{title}
|
|
109
|
+
</Heading>
|
|
110
|
+
{subtitle != null && <p className="mt-4 max-w-xl text-lg text-[var(--text-secondary)]">{subtitle}</p>}
|
|
111
|
+
<div className="mt-8">
|
|
112
|
+
{actions ?? (
|
|
113
|
+
<LandingActions
|
|
114
|
+
session={session}
|
|
115
|
+
access={access}
|
|
116
|
+
demoHref={demoHref}
|
|
117
|
+
openAppHref={openAppHref}
|
|
118
|
+
labels={labelsProp}
|
|
119
|
+
/>
|
|
120
|
+
)}
|
|
121
|
+
</div>
|
|
122
|
+
{trust != null && <p className="mt-4 text-sm text-[var(--text-muted)]">{trust}</p>}
|
|
123
|
+
</div>
|
|
124
|
+
{visual != null && <Visual className="max-w-sm">{visual}</Visual>}
|
|
125
|
+
</section>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* ── Feature rows ────────────────────────────────────────────────────────── */
|
|
130
|
+
|
|
131
|
+
export type FeatureRowsProps = ComponentPropsWithoutRef<"section">;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The band that holds the feature rows (§4.1.3): two to four {@link FeatureRow}s on the
|
|
135
|
+
* surface colour, between hairlines. The rows alternate sides by themselves — the copy
|
|
136
|
+
* of every second row moves to the end from `lg` up — so the app lists them in order
|
|
137
|
+
* and nothing else.
|
|
138
|
+
*/
|
|
139
|
+
export function FeatureRows({ className, children, ...rest }: FeatureRowsProps) {
|
|
140
|
+
return (
|
|
141
|
+
<section
|
|
142
|
+
{...rest}
|
|
143
|
+
data-slot="landing-feature-rows"
|
|
144
|
+
className={cn("border-t border-[var(--border)] bg-[var(--bg-surface)]", className)}
|
|
145
|
+
>
|
|
146
|
+
<div className="mx-auto max-w-6xl px-4 py-8 sm:py-12">{children}</div>
|
|
147
|
+
</section>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface FeatureRowProps extends Omit<ComponentPropsWithoutRef<"div">, "title" | "children"> {
|
|
152
|
+
/** A lucide icon (or any SVG) before the eyebrow. Decorative. */
|
|
153
|
+
icon?: ReactNode;
|
|
154
|
+
/** The small uppercase line above the heading: "Capture", "Plan". */
|
|
155
|
+
eyebrow?: ReactNode;
|
|
156
|
+
/** The row's `h2`. */
|
|
157
|
+
title: ReactNode;
|
|
158
|
+
description?: ReactNode;
|
|
159
|
+
/** Up to three short points; the first three are shown. */
|
|
160
|
+
bullets?: readonly ReactNode[];
|
|
161
|
+
/** The mock beside the copy. Decorative. */
|
|
162
|
+
visual?: ReactNode;
|
|
163
|
+
/** Default `h2`. */
|
|
164
|
+
headingAs?: HeadingLevel;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* One job of the app (§4.1.3), keksdose's "capture → budget → understand": an icon
|
|
169
|
+
* eyebrow, the `h2`, a description, up to three bullets, and a visual on the other side.
|
|
170
|
+
* Inside {@link FeatureRows} every second row puts its copy at the end (`group-even`),
|
|
171
|
+
* which is keksdose's alternation without an index to pass.
|
|
172
|
+
*/
|
|
173
|
+
export function FeatureRow({
|
|
174
|
+
icon,
|
|
175
|
+
eyebrow,
|
|
176
|
+
title,
|
|
177
|
+
description,
|
|
178
|
+
bullets,
|
|
179
|
+
visual,
|
|
180
|
+
headingAs: Heading = "h2",
|
|
181
|
+
className,
|
|
182
|
+
...rest
|
|
183
|
+
}: FeatureRowProps) {
|
|
184
|
+
const shown = bullets?.slice(0, 3) ?? [];
|
|
185
|
+
return (
|
|
186
|
+
<div
|
|
187
|
+
{...rest}
|
|
188
|
+
data-slot="landing-feature-row"
|
|
189
|
+
className={cn("group/feature grid items-center gap-8 py-10", visual != null && "lg:grid-cols-2 lg:gap-16", className)}
|
|
190
|
+
>
|
|
191
|
+
<div className="lg:group-even/feature:order-last">
|
|
192
|
+
{(icon != null || eyebrow != null) && (
|
|
193
|
+
<p className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-[var(--brand)]">
|
|
194
|
+
{icon != null && (
|
|
195
|
+
<span aria-hidden className="flex shrink-0 [&_svg]:size-4">
|
|
196
|
+
{icon}
|
|
197
|
+
</span>
|
|
198
|
+
)}
|
|
199
|
+
{eyebrow}
|
|
200
|
+
</p>
|
|
201
|
+
)}
|
|
202
|
+
<Heading className="mt-2 text-2xl font-semibold tracking-tight text-balance text-[var(--text-primary)] sm:text-3xl">
|
|
203
|
+
{title}
|
|
204
|
+
</Heading>
|
|
205
|
+
{description != null && <p className="mt-3 max-w-xl text-[var(--text-secondary)]">{description}</p>}
|
|
206
|
+
{shown.length > 0 && (
|
|
207
|
+
<ul className="mt-4 space-y-2">
|
|
208
|
+
{shown.map((bullet, i) => (
|
|
209
|
+
<li key={i} className="flex gap-2.5 text-sm text-[var(--text-secondary)]">
|
|
210
|
+
<span aria-hidden className="mt-1.5 size-1.5 shrink-0 rounded-full bg-[var(--brand)]" />
|
|
211
|
+
<span className="min-w-0">{bullet}</span>
|
|
212
|
+
</li>
|
|
213
|
+
))}
|
|
214
|
+
</ul>
|
|
215
|
+
)}
|
|
216
|
+
</div>
|
|
217
|
+
{visual != null && <Visual className="max-w-md">{visual}</Visual>}
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/* ── Trust strip ─────────────────────────────────────────────────────────── */
|
|
223
|
+
|
|
224
|
+
export interface TrustItem {
|
|
225
|
+
/** A lucide icon. Decorative. */
|
|
226
|
+
icon?: ReactNode;
|
|
227
|
+
/** The item's `h3`: "Hosted in Switzerland". */
|
|
228
|
+
title: ReactNode;
|
|
229
|
+
/** One sentence. */
|
|
230
|
+
description: ReactNode;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export interface TrustStripProps extends Omit<ComponentPropsWithoutRef<"section">, "children"> {
|
|
234
|
+
/** Three or four (§4.1.4): hosting, security, data ownership, sharing. */
|
|
235
|
+
items: readonly TrustItem[];
|
|
236
|
+
/** The items' heading level. Default `h3`. */
|
|
237
|
+
headingAs?: HeadingLevel;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Quiet, start-aligned facts after the feature rows (§4.1.4) — an icon, an `h3` and one
|
|
242
|
+
* sentence each, on one row from `lg` up (three or four columns), two on a tablet, one
|
|
243
|
+
* on a phone. No marketing volume: keksdose's "trust strip".
|
|
244
|
+
*/
|
|
245
|
+
export function TrustStrip({ items, headingAs: Heading = "h3", className, ...rest }: TrustStripProps) {
|
|
246
|
+
return (
|
|
247
|
+
<section {...rest} data-slot="landing-trust" className={cn("mx-auto max-w-6xl px-4 py-12 sm:py-16", className)}>
|
|
248
|
+
<ul className={cn("grid gap-8 sm:grid-cols-2", items.length === 3 ? "lg:grid-cols-3" : "lg:grid-cols-4")}>
|
|
249
|
+
{items.map((item, i) => (
|
|
250
|
+
<li key={i}>
|
|
251
|
+
{item.icon != null && (
|
|
252
|
+
<span aria-hidden className="flex text-[var(--brand)] [&_svg]:size-5">
|
|
253
|
+
{item.icon}
|
|
254
|
+
</span>
|
|
255
|
+
)}
|
|
256
|
+
<Heading className="mt-2 text-sm font-semibold text-[var(--text-primary)]">{item.title}</Heading>
|
|
257
|
+
<p className="mt-1 text-sm text-[var(--text-secondary)]">{item.description}</p>
|
|
258
|
+
</li>
|
|
259
|
+
))}
|
|
260
|
+
</ul>
|
|
261
|
+
</section>
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/* ── CTA band ────────────────────────────────────────────────────────────── */
|
|
266
|
+
|
|
267
|
+
export interface CtaBandProps extends Omit<ComponentPropsWithoutRef<"section">, "title" | "children">, ActionProps {
|
|
268
|
+
/** The band's `h2`: the last ask. */
|
|
269
|
+
title: ReactNode;
|
|
270
|
+
subtitle?: ReactNode;
|
|
271
|
+
/** Replaces the action pair ({@link LandingActions}). */
|
|
272
|
+
actions?: ReactNode;
|
|
273
|
+
/** Default `h2`. */
|
|
274
|
+
headingAs?: HeadingLevel;
|
|
275
|
+
labels?: Partial<LandingLabels>;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The closing band before the footer (§4.1.5): a centred `h2`, a subtitle, and the same
|
|
280
|
+
* action pair as the hero.
|
|
281
|
+
*/
|
|
282
|
+
export function CtaBand({
|
|
283
|
+
title,
|
|
284
|
+
subtitle,
|
|
285
|
+
actions,
|
|
286
|
+
headingAs: Heading = "h2",
|
|
287
|
+
session,
|
|
288
|
+
access,
|
|
289
|
+
demoHref,
|
|
290
|
+
openAppHref,
|
|
291
|
+
labels,
|
|
292
|
+
className,
|
|
293
|
+
...rest
|
|
294
|
+
}: CtaBandProps) {
|
|
295
|
+
return (
|
|
296
|
+
<section
|
|
297
|
+
{...rest}
|
|
298
|
+
data-slot="landing-cta"
|
|
299
|
+
className={cn("border-t border-[var(--border)] bg-[var(--bg-surface)]", className)}
|
|
300
|
+
>
|
|
301
|
+
<div className="mx-auto max-w-5xl px-4 py-16 text-center">
|
|
302
|
+
<Heading className="text-2xl font-semibold tracking-tight text-balance text-[var(--text-primary)] sm:text-3xl">
|
|
303
|
+
{title}
|
|
304
|
+
</Heading>
|
|
305
|
+
{subtitle != null && <p className="mx-auto mt-3 max-w-xl text-[var(--text-secondary)]">{subtitle}</p>}
|
|
306
|
+
<div className="mt-8">
|
|
307
|
+
{actions ?? (
|
|
308
|
+
<LandingActions
|
|
309
|
+
session={session}
|
|
310
|
+
access={access}
|
|
311
|
+
demoHref={demoHref}
|
|
312
|
+
openAppHref={openAppHref}
|
|
313
|
+
align="center"
|
|
314
|
+
labels={labels}
|
|
315
|
+
/>
|
|
316
|
+
)}
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
319
|
+
</section>
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/* ── Footer ──────────────────────────────────────────────────────────────── */
|
|
324
|
+
|
|
325
|
+
export interface PublicFooterProps extends Omit<LegalFooterProps, "children"> {
|
|
326
|
+
/** The app's line under the legal links: "Self-hosted household budgeting." */
|
|
327
|
+
tagline?: ReactNode;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* The public pages' footer (§4.1.6): the kit's {@link LegalFooter} — the three legal
|
|
332
|
+
* links — with the app's tagline under them. keksdose's `SiteFooter`, which was already
|
|
333
|
+
* this.
|
|
334
|
+
*/
|
|
335
|
+
export function PublicFooter({ tagline, ...rest }: PublicFooterProps) {
|
|
336
|
+
return <LegalFooter {...rest}>{tagline}</LegalFooter>;
|
|
337
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Search-engine helpers for the public landing page (docs/landing-demo-harmonization.md
|
|
5
|
+
* §4.3): the hook that declares a page's canonical URL, title, description and JSON-LD,
|
|
6
|
+
* and the two pure helpers each app's SEO test is built from.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Declares the current page's canonical URL — and optionally its title and meta
|
|
11
|
+
* description — while the calling component is mounted (feedback #129).
|
|
12
|
+
*
|
|
13
|
+
* Client-side because there is nowhere else to put it: the SPA is served by
|
|
14
|
+
* Caddy's `try_files … /index.html`, so every route shares one static head.
|
|
15
|
+
* Googlebot renders JS and reads the canonical from the RENDERED DOM, which is
|
|
16
|
+
* what makes this work; non-rendering scrapers (WhatsApp, Slack) only ever see
|
|
17
|
+
* index.html's static tags, which is why those stay in place as the fallback.
|
|
18
|
+
*
|
|
19
|
+
* The href is resolved against `window.location.origin` rather than hardcoded, so
|
|
20
|
+
* a preview deployment or the VPS host stays self-referential instead of pointing
|
|
21
|
+
* every copy of the app at production.
|
|
22
|
+
*
|
|
23
|
+
* The cleanup matters for the same reason as in {@link useNoIndex}: an SPA
|
|
24
|
+
* navigation from the landing page into the app must not leave the landing's
|
|
25
|
+
* canonical (or title) behind on an app page.
|
|
26
|
+
*
|
|
27
|
+
* 0.31 (§4.3): keksdose's `usePageSeo` (shared/hooks/use-page-seo.ts), verbatim. The
|
|
28
|
+
* landing calls it with `canonicalPath: "/"` on both `/` and `/welcome`, so the two URLs
|
|
29
|
+
* of one page are one result; there are no per-language URLs and no hreflang.
|
|
30
|
+
*/
|
|
31
|
+
export function usePageSeo({
|
|
32
|
+
canonicalPath,
|
|
33
|
+
title,
|
|
34
|
+
description,
|
|
35
|
+
jsonLd,
|
|
36
|
+
}: {
|
|
37
|
+
canonicalPath: string;
|
|
38
|
+
title?: string;
|
|
39
|
+
description?: string;
|
|
40
|
+
/** Structured data for this page, as the object (not a string). Injected as an
|
|
41
|
+
* `application/ld+json` DATA BLOCK — a script element the browser never executes,
|
|
42
|
+
* which is also why it can be added here rather than in `index.html`: the CSP
|
|
43
|
+
* carries a HASH of the one inline script that file is allowed to have, and
|
|
44
|
+
* `csp-inline-script.test.ts` fails the build on a second one (live #250). */
|
|
45
|
+
jsonLd?: Record<string, unknown>;
|
|
46
|
+
}): void {
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const link = document.createElement("link");
|
|
49
|
+
link.rel = "canonical";
|
|
50
|
+
link.href = new URL(canonicalPath, window.location.origin).toString();
|
|
51
|
+
document.head.appendChild(link);
|
|
52
|
+
|
|
53
|
+
const previousTitle = document.title;
|
|
54
|
+
if (title) document.title = title;
|
|
55
|
+
|
|
56
|
+
// The description tag ships in index.html, so upsert rather than append —
|
|
57
|
+
// two `meta[name=description]` tags would be a coin flip for the scraper.
|
|
58
|
+
const meta = description
|
|
59
|
+
? document.head.querySelector<HTMLMetaElement>('meta[name="description"]')
|
|
60
|
+
: null;
|
|
61
|
+
const previousDescription = meta?.content;
|
|
62
|
+
if (meta && description) meta.content = description;
|
|
63
|
+
|
|
64
|
+
let ld: HTMLScriptElement | null = null;
|
|
65
|
+
if (jsonLd) {
|
|
66
|
+
ld = document.createElement("script");
|
|
67
|
+
ld.type = "application/ld+json";
|
|
68
|
+
ld.textContent = JSON.stringify(jsonLd);
|
|
69
|
+
document.head.appendChild(ld);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return () => {
|
|
73
|
+
link.remove();
|
|
74
|
+
ld?.remove();
|
|
75
|
+
if (title) document.title = previousTitle;
|
|
76
|
+
if (meta && previousDescription !== undefined) meta.content = previousDescription;
|
|
77
|
+
};
|
|
78
|
+
// `jsonLd` is built inline by the caller, so it is a new object every render and
|
|
79
|
+
// cannot be a dependency — it is derived from `title`/`description`, which are.
|
|
80
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
81
|
+
}, [canonicalPath, title, description]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* ── The SEO test's helpers ──────────────────────────────────────────────── */
|
|
85
|
+
|
|
86
|
+
/** What a search result shows of the landing, in one language. */
|
|
87
|
+
export interface SeoCopy {
|
|
88
|
+
/** The page title (`landing.seo.title`): "Keksdose — envelope budgeting". */
|
|
89
|
+
title: string;
|
|
90
|
+
/** The meta description. */
|
|
91
|
+
description: string;
|
|
92
|
+
/** The product name the title carries, before or after its tagline. */
|
|
93
|
+
brand: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The width budgets, as characters. */
|
|
97
|
+
export interface SeoCopyLimits {
|
|
98
|
+
/** Default 60: a result's title is cut around 580px. */
|
|
99
|
+
maxTitle?: number;
|
|
100
|
+
/** Default 155: a snippet is cut around 920px. */
|
|
101
|
+
maxDescription?: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type SeoCopyProblemCode =
|
|
105
|
+
| "title-empty"
|
|
106
|
+
| "description-empty"
|
|
107
|
+
| "title-too-long"
|
|
108
|
+
| "description-too-long"
|
|
109
|
+
| "title-without-brand"
|
|
110
|
+
| "description-repeats-title";
|
|
111
|
+
|
|
112
|
+
/** One thing wrong with a language's copy. `message` is for the test's failure output. */
|
|
113
|
+
export interface SeoCopyProblem {
|
|
114
|
+
code: SeoCopyProblemCode;
|
|
115
|
+
message: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Characters as a reader counts them: code points, so an emoji or a rare CJK character
|
|
119
|
+
* is one, not two UTF-16 units. */
|
|
120
|
+
const length = (text: string) => Array.from(text).length;
|
|
121
|
+
|
|
122
|
+
const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
123
|
+
|
|
124
|
+
/** The separators a title puts between the brand and its tagline. */
|
|
125
|
+
const SEPARATOR = "\\s*[—–\\-|:·•]\\s*";
|
|
126
|
+
|
|
127
|
+
/** The title without the brand and its separator, or `null` when the brand is in
|
|
128
|
+
* neither place. */
|
|
129
|
+
function taglineOf(title: string, brand: string): string | null {
|
|
130
|
+
const name = escapeRegExp(brand.trim());
|
|
131
|
+
const leading = new RegExp(`^${name}${SEPARATOR}`, "i");
|
|
132
|
+
if (leading.test(title)) return title.replace(leading, "").trim();
|
|
133
|
+
const trailing = new RegExp(`${SEPARATOR}${name}$`, "i");
|
|
134
|
+
if (trailing.test(title)) return title.replace(trailing, "").trim();
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* What is wrong with one language's search-result copy (§4.3) — an empty list when
|
|
140
|
+
* nothing is. keksdose's landing-seo.test.ts (live #305), made reusable:
|
|
141
|
+
*
|
|
142
|
+
* - the title fits `maxTitle` characters (60) and the description `maxDescription`
|
|
143
|
+
* (155) — conservative stand-ins for Google's pixel budgets; German is the language
|
|
144
|
+
* most likely to blow through them, and what gets cut is always the end;
|
|
145
|
+
* - the title carries the brand, before or after its tagline ("Keksdose — …");
|
|
146
|
+
* - the description does not open with the title's tagline again — a snippet that
|
|
147
|
+
* repeats the line above it spends its first forty characters saying nothing new.
|
|
148
|
+
*
|
|
149
|
+
* ```ts
|
|
150
|
+
* for (const [locale, bundle] of Object.entries(LOCALES)) {
|
|
151
|
+
* it(`fits ${locale} into a search result`, () => {
|
|
152
|
+
* expect(seoCopyProblems({ ...bundle.landing.seo, brand: "Keksdose" })).toEqual([]);
|
|
153
|
+
* });
|
|
154
|
+
* }
|
|
155
|
+
* ```
|
|
156
|
+
*/
|
|
157
|
+
export function seoCopyProblems(copy: SeoCopy, limits: SeoCopyLimits = {}): SeoCopyProblem[] {
|
|
158
|
+
const { maxTitle = 60, maxDescription = 155 } = limits;
|
|
159
|
+
const title = copy.title.replace(/\s+/g, " ").trim();
|
|
160
|
+
const description = copy.description.replace(/\s+/g, " ").trim();
|
|
161
|
+
const problems: SeoCopyProblem[] = [];
|
|
162
|
+
if (!title) problems.push({ code: "title-empty", message: "the title is empty" });
|
|
163
|
+
if (!description) problems.push({ code: "description-empty", message: "the description is empty" });
|
|
164
|
+
if (length(title) > maxTitle) {
|
|
165
|
+
problems.push({
|
|
166
|
+
code: "title-too-long",
|
|
167
|
+
message: `the title is ${length(title)} characters, over ${maxTitle}: "${title}"`,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
if (length(description) > maxDescription) {
|
|
171
|
+
problems.push({
|
|
172
|
+
code: "description-too-long",
|
|
173
|
+
message: `the description is ${length(description)} characters, over ${maxDescription}: "${description}"`,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
if (!title) return problems;
|
|
177
|
+
const tagline = taglineOf(title, copy.brand);
|
|
178
|
+
if (tagline === null) {
|
|
179
|
+
problems.push({
|
|
180
|
+
code: "title-without-brand",
|
|
181
|
+
message: `the title does not start or end with "${copy.brand}" and a separator: "${title}"`,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
const repeated = (tagline ?? title).toLowerCase();
|
|
185
|
+
if (repeated && description.toLowerCase().startsWith(repeated)) {
|
|
186
|
+
problems.push({
|
|
187
|
+
code: "description-repeats-title",
|
|
188
|
+
message: `the description opens with the title's tagline "${tagline ?? title}"`,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
return problems;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The five named references and the numeric ones — enough for a meta tag's text. */
|
|
195
|
+
function decodeEntities(text: string): string {
|
|
196
|
+
const named: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
|
|
197
|
+
return text.replace(/&(#x[\da-f]+|#\d+|[a-z]+);/gi, (whole, ref: string) => {
|
|
198
|
+
if (ref[0] === "#") {
|
|
199
|
+
const code = ref[1] === "x" || ref[1] === "X" ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1), 10);
|
|
200
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : whole;
|
|
201
|
+
}
|
|
202
|
+
return named[ref.toLowerCase()] ?? whole;
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** A tag's attributes, names lower-cased; values double-, single- or un-quoted. */
|
|
207
|
+
function attributesOf(tag: string): Map<string, string> {
|
|
208
|
+
const found = new Map<string, string>();
|
|
209
|
+
for (const m of tag.matchAll(/([^\s=/<>"']+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g)) {
|
|
210
|
+
found.set(m[1]!.toLowerCase(), m[2] ?? m[3] ?? m[4] ?? "");
|
|
211
|
+
}
|
|
212
|
+
return found;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The `content` of the first `<meta>` in `html` that `selector` picks, whitespace
|
|
217
|
+
* collapsed and entities decoded — or `undefined` when there is none. For each app's
|
|
218
|
+
* parity test (§4.3): `index.html`'s static description and `og:` tags must equal the
|
|
219
|
+
* default language's strings, because a scraper that runs no script (WhatsApp, Slack)
|
|
220
|
+
* sees only those.
|
|
221
|
+
*
|
|
222
|
+
* `selector` names attributes, as in CSS — `meta[name="description"]`,
|
|
223
|
+
* `[property="og:title"]` or just `property="og:description"` — and every one named must
|
|
224
|
+
* match. A `RegExp` is matched against the tag's source instead (keksdose's form).
|
|
225
|
+
* String parsing, not a DOM: the test runs under `@vitest-environment node`.
|
|
226
|
+
*
|
|
227
|
+
* ```ts
|
|
228
|
+
* expect(metaContent(html, 'meta[name="description"]')).toBe(de.landing.seo.description);
|
|
229
|
+
* ```
|
|
230
|
+
*/
|
|
231
|
+
export function metaContent(html: string, selector: string | RegExp): string | undefined {
|
|
232
|
+
const wanted =
|
|
233
|
+
typeof selector === "string"
|
|
234
|
+
? Array.from(selector.matchAll(/([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g), (m) => [
|
|
235
|
+
m[1]!.toLowerCase(),
|
|
236
|
+
m[2] ?? m[3] ?? "",
|
|
237
|
+
])
|
|
238
|
+
: null;
|
|
239
|
+
if (wanted !== null && wanted.length === 0) {
|
|
240
|
+
throw new Error(`metaContent: the selector names no attribute: ${selector as string}`);
|
|
241
|
+
}
|
|
242
|
+
const pattern = selector instanceof RegExp ? new RegExp(selector.source, selector.flags.replace("g", "")) : null;
|
|
243
|
+
for (const tag of html.match(/<meta\b[^>]*>/gi) ?? []) {
|
|
244
|
+
const attrs = attributesOf(tag);
|
|
245
|
+
const matches = pattern ? pattern.test(tag) : wanted!.every(([name, value]) => attrs.get(name!) === value);
|
|
246
|
+
if (!matches) continue;
|
|
247
|
+
const content = attrs.get("content");
|
|
248
|
+
return content === undefined ? undefined : decodeEntities(content).replace(/\s+/g, " ").trim();
|
|
249
|
+
}
|
|
250
|
+
return undefined;
|
|
251
|
+
}
|