This commit is contained in:
Andras Schmelczer 2026-05-13 12:11:54 +01:00
parent a08b5d2ae0
commit b98f0e3904
38 changed files with 3732 additions and 483 deletions

View file

@ -70,11 +70,14 @@ export function prewarmScreenshot(params: string): void {
}
export async function shortenUrl(params: string): Promise<string> {
const res = await fetch(apiUrl('shorten'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ params }),
});
const res = await fetch(
apiUrl('shorten'),
authHeaders({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ params }),
})
);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
return `${window.location.origin}${data.url}`;

View file

@ -0,0 +1,11 @@
// Safely serialise a value for embedding in a <script type="application/ld+json"> tag.
// Escapes every character that could end the script context or be reinterpreted
// by the HTML parser before the JSON parser sees it: '<', '>', '&', U+2028, U+2029.
export function safeJsonLd(data: unknown): string {
return JSON.stringify(data)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');
}

View file

@ -1,4 +1,5 @@
import type { SeoContentKey, SeoLandingKey } from './seoRoutes';
import { SEO_TRANSLATIONS, type SeoTranslationLanguage } from './seoTranslations';
export type { SeoContentKey, SeoLandingKey, SeoPageKey } from './seoRoutes';
@ -48,6 +49,8 @@ export interface SeoContentPage {
cta?: string;
}
type SeoLanguage = 'en' | SeoTranslationLanguage;
const COMMON_RELATED_LINKS: SeoLink[] = [
{
label: 'Data sources and coverage',
@ -753,3 +756,66 @@ export const SEO_CONTENT_PAGES: Record<SeoContentKey, SeoContentPage> = {
],
},
};
function toSeoLanguage(language: string | undefined): SeoLanguage {
const code = language?.toLowerCase().split('-')[0];
if (code && Object.prototype.hasOwnProperty.call(SEO_TRANSLATIONS, code)) {
return code as SeoTranslationLanguage;
}
return 'en';
}
function translateSeoString(value: string, language: SeoLanguage): string {
if (language === 'en' || value.startsWith('/')) return value;
return SEO_TRANSLATIONS[language][value] ?? value;
}
function localizeSeoValue<T>(value: T, language: SeoLanguage): T {
if (typeof value === 'string') return translateSeoString(value, language) as T;
if (Array.isArray(value)) {
return value.map((item) => localizeSeoValue(item, language)) as T;
}
if (!value || typeof value !== 'object') return value;
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, item]) => [
key,
localizeSeoValue(item, language),
])
) as T;
}
export function getLocalizedSeoLandingPages(
language: string | undefined
): Record<SeoLandingKey, SeoLandingContent> {
return localizeSeoValue(SEO_LANDING_PAGES, toSeoLanguage(language));
}
export function getLocalizedSeoContentPages(
language: string | undefined
): Record<SeoContentKey, SeoContentPage> {
return localizeSeoValue(SEO_CONTENT_PAGES, toSeoLanguage(language));
}
export function getLocalizedSeoLandingPage(
pageKey: SeoLandingKey,
language: string | undefined
): SeoLandingContent {
return getLocalizedSeoLandingPages(language)[pageKey];
}
export function getLocalizedSeoContentPage(
pageKey: SeoContentKey,
language: string | undefined
): SeoContentPage {
return getLocalizedSeoContentPages(language)[pageKey];
}
export function getLocalizedSeoPages(
language: string | undefined
): Array<SeoLandingContent | SeoContentPage> {
return [
...Object.values(getLocalizedSeoLandingPages(language)),
...Object.values(getLocalizedSeoContentPages(language)),
];
}

View file

@ -0,0 +1,27 @@
import type { TravelTimeEntry } from '../hooks/useTravelTime';
export function buildTravelParam(
entries: TravelTimeEntry[],
excludeFieldKey?: string,
includeUnboundedExcludedRange = false
): string {
const segments: string[] = [];
for (const entry of entries) {
if (!entry.slug) continue;
let segment = `${entry.mode}:${entry.slug}`;
if (entry.useBest) segment += ':best';
const isExcluded = excludeFieldKey === `tt_${entry.mode}_${entry.slug}`;
if (isExcluded && includeUnboundedExcludedRange) {
segment += ':0:1440';
} else if (!isExcluded && entry.timeRange) {
segment += `:${entry.timeRange[0]}:${entry.timeRange[1]}`;
}
segments.push(segment);
}
return segments.join('|');
}

View file

@ -211,8 +211,8 @@ export function parseUrlState(): UrlState {
tab: 'area',
};
// Share-link code: grants bbox-scoped access to the area the link references
// even for unlicensed users. The backend looks the code up against PocketBase.
// Share-link code: may grant bbox-scoped access when the backend record
// contains an explicit server-created grant.
const share = params.get('share');
if (share && /^[a-z0-9]{1,20}$/i.test(share)) {
result.share = share;
@ -357,7 +357,7 @@ export function stateToParams(
}
const meta = features.find((f) => f.name === name);
if (meta?.type === 'enum') {
if (meta?.type === 'enum' || typeof value[0] === 'string') {
params.append('filter', `${name}:${(value as string[]).join('|')}`);
} else {
const [min, max] = value as [number, number];