lgtm
This commit is contained in:
parent
a08b5d2ae0
commit
b98f0e3904
38 changed files with 3732 additions and 483 deletions
|
|
@ -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}`;
|
||||
|
|
|
|||
11
frontend/src/lib/json-ld.ts
Normal file
11
frontend/src/lib/json-ld.ts
Normal 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');
|
||||
}
|
||||
|
|
@ -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)),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
27
frontend/src/lib/travel-params.ts
Normal file
27
frontend/src/lib/travel-params.ts
Normal 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('|');
|
||||
}
|
||||
|
|
@ -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];
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue