Skip to content
Closed
144 changes: 144 additions & 0 deletions scripts/backfill-slugs.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
/**
* Backfill slugs for all existing events in Sanity.
*
* Usage:
* SANITY_PROJECT=<id> SANITY_DATASET=<dataset> SANITY_TOKEN=<token> node scripts/backfill-slugs.mjs
*
* Or with a --dry-run flag to preview changes without writing:
* SANITY_PROJECT=<id> SANITY_DATASET=<dataset> SANITY_TOKEN=<token> node scripts/backfill-slugs.mjs --dry-run
*/

import { createClient } from '@sanity/client';

const projectId = process.env.SANITY_PROJECT;
const dataset = process.env.SANITY_DATASET;
const token = process.env.SANITY_TOKEN;
const dryRun = process.argv.includes('--dry-run');

if (!projectId || !dataset || !token) {
console.error(
'Missing required environment variables: SANITY_PROJECT, SANITY_DATASET, SANITY_TOKEN'
);
process.exit(1);
}

const client = createClient({
projectId,
dataset,
token,
apiVersion: '2024-01-01',
useCdn: false,
});

/**
* Generate a URL-safe slug from a string.
* Handles unicode, special characters, and normalises whitespace.
*/
function slugify(text) {
return text
.toString()
.toLowerCase()
.trim()
.normalize('NFD') // Decompose unicode characters
.replace(/[\u0300-\u036f]/g, '') // Remove diacritical marks
.replace(/[#()\[\]{}<>!@£$%^&*+=~`|\\;:'",.?/]/g, '') // Remove punctuation
.replace(/\s+/g, '-') // Replace whitespace with hyphens
.replace(/-+/g, '-') // Collapse multiple hyphens
.replace(/^-|-$/g, ''); // Trim leading/trailing hyphens
}

async function main() {
console.log(dryRun ? '🔍 DRY RUN MODE\n' : '✏️ WRITE MODE\n');

// Fetch all events without slugs
const events = await client.fetch(
`*[_type == "event" && !(_id in path("drafts.**")) && !defined(slug)] {
_id,
title,
dateStart
} | order(dateStart asc)`
);

console.log(`Found ${events.length} events without slugs.\n`);

if (events.length === 0) {
console.log('Nothing to do.');
return;
}

// Track used slugs to handle duplicates
// First, fetch any existing slugs
const existingSlugs = await client.fetch(
`*[_type == "event" && defined(slug.current)].slug.current`
);
const usedSlugs = new Set(existingSlugs);

let patchCount = 0;
const errors = [];

for (const event of events) {
let baseSlug = slugify(event.title);

if (!baseSlug) {
console.warn(
`⚠️ Could not generate slug for "${event.title}" (${event._id})`
);
errors.push({ id: event._id, title: event.title, reason: 'empty slug' });
continue;
}

// If the slug is already taken, append the year from dateStart
let slug = baseSlug;
if (usedSlugs.has(slug)) {
const year = event.dateStart
? new Date(event.dateStart).getFullYear()
: null;
if (year) {
slug = `${baseSlug}-${year}`;
}
}

// If still a duplicate, append a counter
let counter = 2;
while (usedSlugs.has(slug)) {
slug = `${baseSlug}-${counter}`;
counter++;
}

usedSlugs.add(slug);

if (dryRun) {
console.log(` ${event.title} → ${slug}`);
} else {
try {
await client
.patch(event._id)
.set({ slug: { _type: 'slug', current: slug } })
.commit();
console.log(`✅ ${event.title} → ${slug}`);
patchCount++;
} catch (err) {
console.error(
`❌ Failed to patch "${event.title}" (${event._id}): ${err.message}`
);
errors.push({ id: event._id, title: event.title, reason: err.message });
}
}
}

console.log(
`\n${dryRun ? 'Would patch' : 'Patched'} ${dryRun ? events.length - errors.length : patchCount} events.`
);

if (errors.length > 0) {
console.log(`\n⚠️ ${errors.length} errors:`);
for (const err of errors) {
console.log(` - ${err.title} (${err.id}): ${err.reason}`);
}
}
}

main().catch((err) => {
console.error('Fatal error:', err);
process.exit(1);
});
15 changes: 10 additions & 5 deletions src/components/Event.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import { isCallForSpeakersOpen } from '../utils/eventUtils';
import { isCallForSpeakersOpen, getEventUrl } from '../utils/eventUtils';
import Icon from './Icon.vue';
import EventDate from './EventDate.vue';
import EventDelivery from './EventDelivery.vue';
Expand Down Expand Up @@ -102,6 +102,11 @@ const isDedicatedToAccessibility = computed(() => {
);
});

/**
* Internal URL for the event detail page, or undefined if no slug.
*/
const eventUrl = computed(() => getEventUrl(props.event));

/**
* Formats speaker list for display
* If more than 3 speakers, randomly selects 3 to display and shows count of remaining.
Expand Down Expand Up @@ -163,7 +168,8 @@ const speakerDisplay = computed(() => {
:isDeadline="true"
/>
<span class="event__title"
>Submit proposals for <a :href="event.website">{{ event.title }}</a></span
>Submit proposals for
<a :href="eventUrl || event.website">{{ event.title }}</a></span
>
</div>
<article
Expand All @@ -174,11 +180,10 @@ const speakerDisplay = computed(() => {
:data-event-type="event.type"
>
<h3 class="event__title" itemprop="name">
<a v-if="event.website" :href="event.website" itemprop="url">{{
event.title
}}</a>
<a v-if="eventUrl" :href="eventUrl">{{ event.title }}</a>
<span v-else>{{ event.title }}</span>
</h3>
<meta v-if="event.website" itemprop="url" :content="event.website" />

<EventDate
v-if="showDate && event.dateStart"
Expand Down
9 changes: 8 additions & 1 deletion src/components/EventChild.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import { getEventUrl } from '../utils/eventUtils';
import EventDate from './EventDate.vue';
import EventDuration from './EventDuration.vue';
import type { ChildEvent } from '../types/event';
Expand Down Expand Up @@ -47,6 +48,11 @@ const formatPreposition = computed(() => {
return (props.event.format && prepositions[props.event.format]) || 'by';
});

/**
* Internal URL for the child event detail page, or undefined if no slug.
*/
const childEventUrl = computed(() => getEventUrl(props.event));

const speakersList = computed(() => {
if (!props.event.speakers?.length) return '';
return props.event.speakers
Expand All @@ -66,7 +72,8 @@ const speakersList = computed(() => {
itemtype="https://schema.org/Event"
>
<span class="child-event__title" itemprop="name">
<a v-if="event.website" :href="event.website">{{ event.title }}</a>
<a v-if="childEventUrl" :href="childEventUrl">{{ event.title }}</a>
<a v-else-if="event.website" :href="event.website">{{ event.title }}</a>
<span v-else>{{ event.title }}</span>
</span>

Expand Down
41 changes: 41 additions & 0 deletions src/components/EventDateWithTimezone.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<template>
<div class="event-date-with-timezone">
<EventDate
:dateStart="dateStart"
:dateEnd="dateEnd"
:timezone="timezone"
:day="day"
:type="type"
/>
<TimezoneSelector size="small" />
</div>
</template>

<script setup lang="ts">
import EventDate from './EventDate.vue';
import TimezoneSelector from './TimezoneSelector.vue';

withDefaults(
defineProps<{
dateStart: string;
dateEnd?: string;
timezone?: string;
day?: boolean;
type?: string;
}>(),
{
timezone: '',
day: false,
type: 'event',
}
);
</script>

<style scoped>
.event-date-with-timezone {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--p-space-xs);
}
</style>
14 changes: 9 additions & 5 deletions src/components/StaticEvent.astro
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
import localizedFormat from 'dayjs/plugin/localizedFormat';
import type { Event } from '../types/event';
import { getEventUrl } from '../utils/eventUtils';

dayjs.extend(utc);
dayjs.extend(localizedFormat);
Expand Down Expand Up @@ -53,6 +54,9 @@ function getEndFormat(): string {
return 'LLL [UTC]';
}

// Internal event page URL (if slug exists)
const eventUrl = getEventUrl(event);

// Attendance mode display
const attendanceMode = event.attendanceMode || 'none';
const displayLocation = event.location || 'International';
Expand All @@ -74,7 +78,8 @@ const displayLocation = event.location || 'International';
</span>
</div>
<span class="event__title">
Submit proposals for <a href={event.website}>{event.title}</a>
Submit proposals for{' '}
<a href={eventUrl || event.website}>{event.title}</a>
</span>
</div>
) : event.type ? (
Expand All @@ -85,14 +90,13 @@ const displayLocation = event.location || 'International';
data-event-type={event.type}
>
<h3 class="event__title" itemprop="name">
{event.website ? (
<a href={event.website} itemprop="url">
{event.title}
</a>
{eventUrl ? (
<a href={eventUrl}>{event.title}</a>
) : (
<span>{event.title}</span>
)}
</h3>
{event.website && <meta itemprop="url" content={event.website} />}

{event.dateStart && (
<div class="event__dates">
Expand Down
31 changes: 27 additions & 4 deletions src/components/TimezoneSelector.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<div>
<label class="sr-only" for="timezone-dropdown">Timezone</label>
<sl-dropdown id="timezone-dropdown" distance="3" placement="bottom-end">
<sl-button slot="trigger" caret>
<sl-button slot="trigger" :size="size" caret>
{{ selectedTimezoneLabel }}
</sl-button>
<sl-menu @sl-select="updateTimezone">
Expand Down Expand Up @@ -35,6 +35,15 @@ import timezone from 'dayjs/plugin/timezone';
dayjs.extend(utc);
dayjs.extend(timezone);

withDefaults(
defineProps<{
size?: 'small' | 'medium' | 'large';
}>(),
{
size: 'medium',
}
);

// Default timezone if user's timezone cannot be detected
const defaultTimezone = 'UTC';

Expand Down Expand Up @@ -81,10 +90,24 @@ function updateTimezone(event: CustomEvent) {
}

/**
* Initialize timezone on component mount
* Sets user's detected timezone if useLocalTimezone is true
* Initialize timezone on component mount.
* Fetches user info from the edge function if not already available
* (e.g. when landing directly on an event detail page).
* Then restores the user's previous timezone preference if applicable.
*/
onMounted(() => {
onMounted(async () => {
if (!userStore.geo?.timezone && !userStore.userInfoFetched) {
try {
const response = await fetch('/api/get-user-info');
if (response.ok) {
const data = await response.json();
userStore.setUserInfo(data.timezone, data.acceptLanguage, data.geo);
}
} catch {
// Silently fall back to UTC if the fetch fails
}
}

if (userStore.useLocalTimezone && userStore.geo?.timezone) {
userStore.setTimezone(userTimezone.value, true);
}
Expand Down
Loading