diff --git a/app/components/TheHome.server.vue b/app/components/TheHome.server.vue index 91d54ce9..192f7450 100644 --- a/app/components/TheHome.server.vue +++ b/app/components/TheHome.server.vue @@ -221,7 +221,7 @@ const [{ data: currentLocation }, { data: upcomingConferences }, { data: streams :src="conference.image.url" width="320" height="128" - :alt="`Logo for ${conference.name}`" + :alt="conference.image.alt" class="w-full h-full object-cover object-center" /> diff --git a/app/components/TheUses.server.vue b/app/components/TheUses.server.vue index 86f64166..963cb244 100644 --- a/app/components/TheUses.server.vue +++ b/app/components/TheUses.server.vue @@ -27,7 +27,7 @@ > () const form = reactive({ - city: props.initial?.city ?? '', - region: props.initial?.region ?? '', - country: props.initial?.country ?? '', - countryCode: props.initial?.countryCode ?? '', + city: props.initial?.address?.locality ?? '', + region: props.initial?.address?.region ?? '', + countryCode: props.initial?.address?.country ?? '', meetupAvailable: props.initial?.meetupAvailable ?? true, }) @@ -28,10 +27,12 @@ async function onSubmit () { submitting.value = true try { const value: LocationValue = { - city: form.city, - ...(form.region ? { region: form.region } : {}), - country: form.country, - countryCode: form.countryCode.toUpperCase(), + address: { + $type: 'community.lexicon.location.address', + country: form.countryCode.toUpperCase(), + ...(form.region ? { region: form.region } : {}), + locality: form.city, + }, meetupAvailable: form.meetupAvailable, ...(props.initial?.createdAt ? { createdAt: props.initial.createdAt } : { createdAt: new Date().toISOString() }), } @@ -78,29 +79,19 @@ async function onSubmit () { - - - Country * - - - - Country code (ISO 2-letter) * - - - + + Country code (ISO 3166-1 alpha-2) * + + The country name shown on the site is derived from this code. + COMMUNITY_IMAGE_MAX_BYTES) { + error.value = `That screenshot is ${(file.size / 1_000_000).toFixed(1)} MB; the record format allows 2 MB.` + return + } + if (localPreviewUrl.value) URL.revokeObjectURL(localPreviewUrl.value) localPreviewUrl.value = URL.createObjectURL(file) @@ -381,7 +386,15 @@ async function onSubmit () { - Screenshot + + Screenshot + + - +import { community } from '#shared/lex' import type { com, dev } from '#shared/lex' import { ref } from 'vue' -import { blobUrlFor, cidFromBlob } from '#shared/cms/blob' +import { COMMUNITY_IMAGE_MAX_BYTES, blobUrlFor, cidFromBlob } from '#shared/cms/blob' import type { Loose, Strict } from '#shared/cms/strict' type TalkValue = Omit>, '$type'> @@ -52,8 +53,9 @@ const form = reactive({ demo: props.initial?.demo ?? '', repo: props.initial?.repo ?? '', groupUri: props.initial?.group?.uri ?? '', - image: props.initial?.image, - aspectRatio: props.initial?.aspectRatio, + image: props.initial?.image?.image, + imageAlt: props.initial?.image?.alt ?? '', + aspectRatio: props.initial?.image?.aspectRatio, }) /** @@ -86,6 +88,11 @@ async function onImageChange (e: Event) { const file = (e.target as HTMLInputElement).files?.[0] if (!file) return + if (file.size > COMMUNITY_IMAGE_MAX_BYTES) { + error.value = `That image is ${(file.size / 1_000_000).toFixed(1)} MB; the record format allows 2 MB.` + return + } + if (localPreviewUrl.value) URL.revokeObjectURL(localPreviewUrl.value) localPreviewUrl.value = URL.createObjectURL(file) @@ -93,7 +100,7 @@ async function onImageChange (e: Event) { try { const [uploaded, clientAspectRatio] = await Promise.all([ $fetch<{ - blob: NonNullable + blob: NonNullable['image']> aspectRatio?: { width: number, height: number } }>('/api/admin/blobs', { method: 'POST', @@ -104,6 +111,7 @@ async function onImageChange (e: Event) { ]) form.image = uploaded.blob form.aspectRatio = clientAspectRatio ?? uploaded.aspectRatio + if (!form.imageAlt && form.source) form.imageAlt = `Logo for ${form.source}` } catch (err) { error.value = err instanceof Error ? err.message : String(err) @@ -117,6 +125,7 @@ async function onImageChange (e: Event) { function clearImage () { form.image = undefined form.aspectRatio = undefined + form.imageAlt = '' if (localPreviewUrl.value) { URL.revokeObjectURL(localPreviewUrl.value) localPreviewUrl.value = null @@ -127,6 +136,12 @@ async function onSubmit () { error.value = null submitting.value = true try { + if (form.image && !form.imageAlt) { + error.value = 'Please describe the image for screen reader users.' + submitting.value = false + return + } + const group = (groups.value ?? []).find(g => g.uri === form.groupUri) const groupRef: Loose | undefined = group ? { uri: group.uri, cid: group.cid } @@ -147,8 +162,17 @@ async function onSubmit () { ...(form.demo ? { demo: form.demo } : {}), ...(form.repo ? { repo: form.repo } : {}), ...(groupRef ? { group: groupRef } : {}), - ...(form.image ? { image: form.image } : {}), - ...(form.image && form.aspectRatio ? { aspectRatio: form.aspectRatio } : {}), + ...(form.image + ? { + image: { + $type: 'community.lexicon.app.defs#image' as const, + purpose: community.lexicon.app.defs.purposeLogo.value, + image: form.image, + alt: form.imageAlt, + ...(form.aspectRatio ? { aspectRatio: form.aspectRatio } : {}), + }, + } + : {}), ...(props.initial?.createdAt ? { createdAt: props.initial.createdAt } : { createdAt: new Date().toISOString() }), } @@ -332,8 +356,16 @@ async function onSubmit () { - - Image + + + Image + + - - + Alt text * + + + +import { community } from '#shared/lex' import type { com, dev } from '#shared/lex' import { ref } from 'vue' -import { blobUrlFor, cidFromBlob } from '#shared/cms/blob' +import { COMMUNITY_IMAGE_MAX_BYTES, blobUrlFor, cidFromBlob } from '#shared/cms/blob' import type { Loose, Strict } from '#shared/cms/strict' type UsesItemValue = Omit>, '$type'> @@ -24,6 +25,16 @@ const emit = defineEmits<{ const { data: categories } = useAdminFetch('/api/admin/uses-categories', { default: () => [] }) +const { defs } = community.lexicon.app + +const LINK_ROLES = [ + { value: '', label: 'No role' }, + { value: defs.linkRoleWebsite.value, label: 'Website' }, + { value: defs.linkRoleSourceCode.value, label: 'Source code' }, + { value: defs.linkRoleDocs.value, label: 'Docs' }, + { value: defs.linkRoleSupport.value, label: 'Support' }, +] + const { public: publicConfig } = useRuntimeConfig() const pdsService = publicConfig.atproto?.service || null const pdsDid = publicConfig.atproto?.did || null @@ -33,9 +44,10 @@ const form = reactive({ name: props.initial?.name ?? '', description: props.initial?.description ?? '', order: props.initial?.order ?? 100, - image: props.initial?.image, - aspectRatio: props.initial?.aspectRatio, - links: (props.initial?.links ?? []).map(l => ({ url: l.url, label: l.label ?? '' })), + image: props.initial?.image?.image, + imageAlt: props.initial?.image?.alt ?? '', + aspectRatio: props.initial?.image?.aspectRatio, + links: (props.initial?.links ?? []).map(l => ({ uri: l.uri, label: l.label ?? '', role: l.role ?? '' })), }) const localPreviewUrl = ref(null) @@ -58,6 +70,11 @@ async function onImageChange (e: Event) { const file = (e.target as HTMLInputElement).files?.[0] if (!file) return + if (file.size > COMMUNITY_IMAGE_MAX_BYTES) { + error.value = `That image is ${(file.size / 1_000_000).toFixed(1)} MB; the record format allows 2 MB.` + return + } + if (localPreviewUrl.value) URL.revokeObjectURL(localPreviewUrl.value) localPreviewUrl.value = URL.createObjectURL(file) @@ -65,7 +82,7 @@ async function onImageChange (e: Event) { try { const [uploaded, clientAspectRatio] = await Promise.all([ $fetch<{ - blob: NonNullable + blob: NonNullable['image']> aspectRatio?: { width: number, height: number } }>('/api/admin/blobs', { method: 'POST', @@ -76,6 +93,7 @@ async function onImageChange (e: Event) { ]) form.image = uploaded.blob form.aspectRatio = clientAspectRatio ?? uploaded.aspectRatio + if (!form.imageAlt && form.name) form.imageAlt = form.name } catch (err) { error.value = err instanceof Error ? err.message : String(err) @@ -89,6 +107,7 @@ async function onImageChange (e: Event) { function clearImage () { form.image = undefined form.aspectRatio = undefined + form.imageAlt = '' if (localPreviewUrl.value) { URL.revokeObjectURL(localPreviewUrl.value) localPreviewUrl.value = null @@ -96,7 +115,7 @@ function clearImage () { } function addLink () { - form.links.push({ url: '', label: '' }) + form.links.push({ uri: '', label: '', role: '' }) } function removeLink (i: number) { @@ -115,24 +134,37 @@ async function onSubmit () { } const categoryRef: Loose = { uri: category.uri, cid: category.cid } + if (form.image && !form.imageAlt) { + error.value = 'Please describe the image for screen reader users.' + submitting.value = false + return + } + + const links = form.links + .filter(l => l.uri) + .map(l => ({ + $type: 'community.lexicon.app.defs#link' as const, + uri: l.uri, + ...(l.label ? { label: l.label } : {}), + ...(l.role ? { role: l.role } : {}), + })) + const value: UsesItemValue = { category: categoryRef, name: form.name, ...(form.description ? { description: form.description } : {}), order: form.order, - ...(form.image ? { image: form.image } : {}), - ...(form.image && form.aspectRatio ? { aspectRatio: form.aspectRatio } : {}), - ...(form.links.filter(l => l.url).length + ...(form.image ? { - links: form.links - .filter(l => l.url) - .map(l => ({ - $type: 'dev.roe.usesItem#link' as const, - url: l.url, - ...(l.label ? { label: l.label } : {}), - })), + image: { + $type: 'community.lexicon.app.defs#image' as const, + image: form.image, + alt: form.imageAlt, + ...(form.aspectRatio ? { aspectRatio: form.aspectRatio } : {}), + }, } : {}), + ...(links.length ? { links } : {}), ...(props.initial?.createdAt ? { createdAt: props.initial.createdAt } : { createdAt: new Date().toISOString() }), } emit('submit', value) @@ -215,7 +247,7 @@ async function onSubmit () { class="flex gap-2 items-center" > + + + {{ role.label }} + + - - Image + + + Image + + - - + Alt text * + + + `links[].uri` (+ a role where the label + * says what the link is), `image` blob + sibling + * `aspectRatio` -> a single `community.lexicon.app.defs#image` + * carrying its own dimensions and alt text. + * dev.roe.talk `image` blob + sibling `aspectRatio` -> the same `#image`, + * tagged `#purposeLogo`. + * dev.roe.location flat `city`/`region`/`country`/`countryCode` -> + * `community.lexicon.location.address`. + * + * Every rewrite preserves the rkey and `createdAt`, and is skipped when the + * record is already in the new shape, so reruns are no-ops. Publish the updated + * lexicons (`pnpm lex:publish`) before running this. + * + * Usage: + * node --env-file=.env scripts/migrate-records-to-community-lexicons.ts --dry-run + * node --env-file=.env scripts/migrate-records-to-community-lexicons.ts + * node --env-file=.env scripts/migrate-records-to-community-lexicons.ts --collection usesItem + * + * `--dry-run` reads over the unauthenticated client and validates the rewritten + * records locally, so it needs no credentials. Writing needs + * NUXT_ATPROTO_PASSWORD. + */ +import { promises as dns } from 'node:dns' +import process from 'node:process' + +import { Client, asStringFormat, lexToJson } from '@atproto/lex' +import type { DidString, Infer, JsonValue, PutOptions, RecordSchema } from '@atproto/lex' +import { PasswordSession } from '@atproto/lex-password-session' +import { defineCommand, runMain } from 'citty' + +import { community, dev } from '../shared/lex/index.ts' + +const { defs } = community.lexicon.app + +type LegacyAspectRatio = { width?: number, height?: number } | undefined +type LegacyBlob = NonNullable['image'] + +/** + * `community.lexicon.app.defs#image` caps blobs at 2 MB, where the fields it + * replaces allowed 5 MB. A handful of records hold oversized PNGs; they can't be + * rewritten until the image is replaced with a smaller one, which has to happen + * in the editor because re-encoding here would silently change the artwork. + */ +const IMAGE_MAX_BYTES = 2_000_000 + +function oversizedBlob (blob: unknown): number | null { + const size = (blob as { size?: unknown } | null)?.size + return typeof size === 'number' && size > IMAGE_MAX_BYTES ? size : null +} + +class NeedsSmallerImage extends Error { + constructor (size: number) { + super(`image is ${(size / 1_000_000).toFixed(1)} MB; replace it with one under 2 MB in the editor first`) + } +} + +interface Rewrite { + schema: T + /** Returns the new record value, or null when it is already migrated. */ + rewrite: (value: Infer) => Omit, '$type'> | null +} + +/** + * Roles worth inferring from the labels already on `dev.roe.usesItem` links. + * Labels are left alone: they are what the page renders. + */ +const ROLE_BY_LABEL: Record = { + website: defs.linkRoleWebsite.value, + source: defs.linkRoleSourceCode.value, + docs: defs.linkRoleDocs.value, + marketplace: defs.linkRoleAppStore.value, +} + +function communityImage (blob: LegacyBlob, alt: string, aspectRatio: LegacyAspectRatio, purpose?: string) { + const size = oversizedBlob(blob) + if (size) throw new NeedsSmallerImage(size) + + const ratio = aspectRatio?.width && aspectRatio?.height + ? { width: aspectRatio.width, height: aspectRatio.height } + : undefined + return { + $type: 'community.lexicon.app.defs#image' as const, + ...(purpose ? { purpose } : {}), + image: blob, + alt, + ...(ratio ? { aspectRatio: ratio } : {}), + } +} + +const usesItem: Rewrite = { + schema: dev.roe.usesItem.main, + rewrite (value) { + const legacy = value as Omit & { + aspectRatio?: LegacyAspectRatio + links?: Array<{ url?: string, uri?: string, label?: string, role?: string }> + } + + const linksNeedWork = !!legacy.links?.some(l => l.url !== undefined) + const imageNeedsWork = !!value.image && !('alt' in value.image) + if (!linksNeedWork && !imageNeedsWork && legacy.aspectRatio === undefined) return null + + const { $type, aspectRatio, ...rest } = legacy + + return { + ...rest, + ...(legacy.links?.length + ? { + links: legacy.links.map(link => { + const uri = link.uri ?? link.url ?? '' + const role = link.role ?? ROLE_BY_LABEL[link.label?.trim().toLowerCase() ?? ''] + return { + $type: 'community.lexicon.app.defs#link' as const, + uri: asStringFormat(uri, 'uri'), + ...(link.label ? { label: link.label } : {}), + ...(role ? { role } : {}), + } + }), + } + : {}), + ...(value.image + ? { + image: 'alt' in value.image + ? value.image + : communityImage(value.image as LegacyBlob, value.name, aspectRatio), + } + : {}), + } as Omit, '$type'> + }, +} + +const talk: Rewrite = { + schema: dev.roe.talk.main, + rewrite (value) { + const legacy = value as typeof value & { aspectRatio?: LegacyAspectRatio } + const imageNeedsWork = !!value.image && !('alt' in value.image) + if (!imageNeedsWork && legacy.aspectRatio === undefined) return null + + const { $type, aspectRatio, ...rest } = legacy + + return { + ...rest, + ...(value.image + ? { + image: 'alt' in value.image + ? value.image + : communityImage(value.image as LegacyBlob, `Logo for ${value.source}`, aspectRatio, defs.purposeLogo.value), + } + : {}), + } as Omit, '$type'> + }, +} + +const location: Rewrite = { + schema: dev.roe.location.main, + rewrite (value) { + if (value.address) return null + + const legacy = value as typeof value & { + city?: string + region?: string + country?: string + countryCode?: string + } + + if (!legacy.countryCode) throw new Error('location record has neither `address` nor `countryCode`') + + return { + address: { + $type: 'community.lexicon.location.address' as const, + country: legacy.countryCode.toUpperCase(), + ...(legacy.region ? { region: legacy.region } : {}), + ...(legacy.city ? { locality: legacy.city } : {}), + }, + ...(legacy.meetupAvailable === undefined ? {} : { meetupAvailable: legacy.meetupAvailable }), + createdAt: value.createdAt, + } + }, +} + +const REWRITES = { usesItem, talk, location } as const +type CollectionName = keyof typeof REWRITES + +async function resolveDid (domain: string): Promise { + const records = await dns.resolveTxt(`_atproto.${domain}`).catch(() => []) + for (const chunks of records) { + const value = chunks.join('') + if (value.startsWith('did=')) return value.slice('did='.length) + } + + const res = await fetch(`https://${domain}/.well-known/atproto-did`) + if (!res.ok) throw new Error(`could not resolve a DID for ${domain}`) + return (await res.text()).trim() +} + +async function resolvePdsEndpoint (did: string): Promise { + const url = did.startsWith('did:plc:') + ? `https://plc.directory/${did}` + : did.startsWith('did:web:') + ? `https://${did.slice('did:web:'.length)}/.well-known/did.json` + : null + if (!url) throw new Error(`unsupported DID method: ${did}`) + + const res = await fetch(url) + if (!res.ok) throw new Error(`${url} -> ${res.status}`) + const doc = await res.json() as { service?: Array<{ id: string, serviceEndpoint: string }> } + const pds = doc.service?.find(s => s.id === '#atproto_pds' || s.id.endsWith('#atproto_pds')) + if (!pds?.serviceEndpoint) throw new Error(`DID doc for ${did} has no #atproto_pds service entry`) + return pds.serviceEndpoint +} + +interface RewriteContext { + reader: Client + writer?: Client + did: DidString + print: boolean +} + +/** Rewrites one collection, returning the number of records that failed. */ +async function rewriteCollection ( + name: string, + { schema, rewrite }: Rewrite, + { reader, writer, did, print }: RewriteContext, +): Promise { + let migrated = 0 + let unchanged = 0 + let failed = 0 + const blocked: string[] = [] + + for await (const record of reader.listAll(schema, { repo: did, limit: 100 })) { + const rkey = record.uri.split('/').pop()! + + let next: Omit, '$type'> | null + try { + next = rewrite(record.value as Infer) + } + catch (err) { + if (err instanceof NeedsSmallerImage) { + blocked.push(`${name}/${rkey}: ${err.message}`) + continue + } + failed++ + console.log(`đ´ ${name}/${rkey}: ${err instanceof Error ? err.message : String(err)}`) + continue + } + + if (!next) { + unchanged++ + continue + } + + const check = schema.safeValidate({ $type: schema.$type, ...next } as never) + if (!check.success) { + failed++ + console.log(`đ´ ${name}/${rkey}: ${check.message}`) + continue + } + + if (print && migrated === 0) { + console.log(JSON.stringify(lexToJson(next as JsonValue), null, 2)) + } + + if (writer) { + await writer.put(schema, next, { repo: did, rkey, validateRequest: true } as unknown as PutOptions) + } + migrated++ + console.log(`${writer ? 'đ˘ rewrote' : ' would rewrite'} ${name}/${rkey}`) + } + + for (const message of blocked) console.log(`â ď¸ ${message}`) + console.log(`\n${name}: ${migrated} to rewrite, ${unchanged} unchanged, ${blocked.length} waiting on a smaller image, ${failed} failed\n`) + + return failed +} + +const main = defineCommand({ + meta: { + name: 'migrate-records-to-community-lexicons', + description: 'Rewrite existing dev.roe records into the community.lexicon shapes', + }, + args: { + 'dry-run': { + type: 'boolean', + description: 'Validate the rewrites without writing to the PDS', + default: false, + }, + 'print': { + type: 'boolean', + description: 'Print the first rewritten record of each collection', + default: false, + }, + 'collection': { + type: 'string', + description: `One of ${Object.keys(REWRITES).join(', ')}; defaults to all`, + }, + }, + async run ({ args }) { + const collections = args.collection + ? [args.collection as CollectionName] + : (Object.keys(REWRITES) as CollectionName[]) + + for (const name of collections) { + if (!(name in REWRITES)) throw new Error(`unknown collection: ${name}`) + } + + const did = asStringFormat(process.env.NUXT_ATPROTO_DID || await resolveDid('roe.dev'), 'did') + const service = process.env.NUXT_PUBLIC_ATPROTO_SERVICE || await resolvePdsEndpoint(did) + console.log(`${did} @ ${service}${args['dry-run'] ? ' (dry run)' : ''}\n`) + + const reader = new Client(service) + + let writer: Client | undefined + if (!args['dry-run']) { + const password = process.env.NUXT_ATPROTO_PASSWORD + if (!password) { + console.error('Set NUXT_ATPROTO_PASSWORD (see .env.example), or pass --dry-run.') + process.exitCode = 1 + return + } + writer = new Client(await PasswordSession.login({ service, identifier: did, password })) + } + + for (const name of collections) { + const context = { reader, writer, did, print: args.print } + const failed = name === 'usesItem' + ? await rewriteCollection(name, usesItem, context) + : name === 'talk' + ? await rewriteCollection(name, talk, context) + : await rewriteCollection(name, location, context) + if (failed) process.exitCode = 1 + } + }, +}) + +runMain(main) diff --git a/server/api/current-location.get.ts b/server/api/current-location.get.ts index 7143bbdd..f1923e03 100644 --- a/server/api/current-location.get.ts +++ b/server/api/current-location.get.ts @@ -1,28 +1,31 @@ import { getCurrentLocation } from '../utils/cms/location' +const regionalFlags: Record = { + Scotland: 'đ´ó §ó ˘ó łó Łó ´ó ż', +} + +const countryNames = new Intl.DisplayNames(['en'], { type: 'region' }) + export default defineEventHandler(async event => { const location = await getCurrentLocation(event) if (!location) return null - const flagEmoji = location.region === 'Scotland' - ? 'đ´ó §ó ˘ó łó Łó ´ó ż' - : location.countryCode - ? String.fromCodePoint(...[...location.countryCode.toUpperCase()].map(char => - char.charCodeAt(0) + 127397)) - : 'đ' + const countryCode = location.countryCode.toUpperCase() - // Some geocoders return verbose ISO 3166 country names (e.g. "United - // Kingdom of Great Britain and Northern Ireland (the)"); prefer the - // sub-region for those countries when we have one. - const locationMaps: Record = { - 'United Kingdom of Great Britain and Northern Ireland (the)': location.region, - 'United States of America (the)': location.region, - } + const flagEmoji = (location.region && regionalFlags[location.region]) + || (countryCode + ? String.fromCodePoint(...[...countryCode].map(char => char.charCodeAt(0) + 127397)) + : 'đ') + + // The UK and the US are big enough that the subdivision is the more useful + // label; elsewhere the country reads better. + const area = (['GB', 'US'].includes(countryCode) && location.region) + || (countryCode ? countryNames.of(countryCode) : undefined) return { meetupAvailable: location.meetupAvailable, - city: locationMaps[location.city] || location.city, - area: location.region === 'Scotland' ? 'Scotland' : locationMaps[location.country] || location.country, + city: location.city, + area: area ?? '', flagEmoji, } }) diff --git a/server/api/upcoming-conferences.get.ts b/server/api/upcoming-conferences.get.ts index 6aff6772..5692ebb5 100644 --- a/server/api/upcoming-conferences.get.ts +++ b/server/api/upcoming-conferences.get.ts @@ -36,6 +36,7 @@ export default defineEventHandler(async event => { ...conference, image: { url: null, + alt: '', width: null, height: null, }, @@ -49,6 +50,7 @@ export default defineEventHandler(async event => { ...conference, image: { url: imageUrl, + alt: conference.image?.alt || `Logo for ${conference.name}`, width: metadata.width, height: metadata.height, }, diff --git a/server/api/update-location.post.ts b/server/api/update-location.post.ts index 4ad5df18..568c3d6c 100644 --- a/server/api/update-location.post.ts +++ b/server/api/update-location.post.ts @@ -36,7 +36,6 @@ export default defineEventHandler(async event => { const locationData = { city: response.city || response.locality || 'Unknown', region: response.principalSubdivision || response.localityInfo?.administrative?.[1]?.name || '', - country: response.countryName || 'Unknown', countryCode: response.countryCode || 'XX', meetupAvailable: meetupAvailable !== undefined ? meetupAvailable : true, } @@ -71,7 +70,6 @@ type BigDataResponse = { city: string locality?: string principalSubdivision?: string - countryName: string countryCode: string localityInfo?: { administrative?: Array<{ diff --git a/server/routes/uses.md.get.ts b/server/routes/uses.md.get.ts index eaa74af8..19cf84a5 100644 --- a/server/routes/uses.md.get.ts +++ b/server/routes/uses.md.get.ts @@ -21,14 +21,14 @@ export default defineEventHandler(async event => { const items = [...(category.items || [])].sort((a, b) => (a.order || 100) - (b.order || 100)) for (const item of items) { - const primaryLink = item.links?.[0]?.url + const primaryLink = item.links?.[0]?.uri const name = primaryLink ? `[${item.name}](${primaryLink})` : item.name const desc = item.description ? ` â ${item.description}` : '' lines.push(`- **${name}**${desc}`) const extraLinks = item.links?.slice(1) if (extraLinks?.length) { - const linkStr = extraLinks.map(l => `[${l.label || 'Link'}](${l.url})`).join(', ') + const linkStr = extraLinks.map(l => `[${l.label || 'Link'}](${l.uri})`).join(', ') lines.push(` ${linkStr}`) } } diff --git a/server/utils/atproto.ts b/server/utils/atproto.ts index c3784f0b..36860692 100644 --- a/server/utils/atproto.ts +++ b/server/utils/atproto.ts @@ -116,6 +116,27 @@ export async function getRecord ( } } +/** + * Get a single record without validating it against a schema, for reading + * records written before a lexicon change. Returns null if it doesn't exist. + */ +export async function getRawRecord ( + event: H3Event, + collection: `${string}.${string}.${string}`, + rkey: string, +): Promise | null> { + const did = await resolveDid(event) + const client = getReadClient(event) + try { + const res = await client.getRecord(collection, rkey, { repo: did }) + return res.body.value as Record + } + catch (err: unknown) { + if (err instanceof XrpcResponseError && err.status === 404) return null + throw err + } +} + /** List all records in a collection, paginating until exhausted. */ export async function listRecords ( event: H3Event, diff --git a/server/utils/cms/location.ts b/server/utils/cms/location.ts index a8e71ee9..0c5b4da3 100644 --- a/server/utils/cms/location.ts +++ b/server/utils/cms/location.ts @@ -1,38 +1,64 @@ import type { H3Event } from 'h3' -import { getRecord, putRecord } from '../atproto' +import { LexValidationError } from '@atproto/lex' + +import { getRawRecord, getRecord, putRecord } from '../atproto' import { dev } from '#shared/lex' import type { Loose } from '#shared/cms/strict' export interface Location { city: string region?: string - country: string countryCode: string meetupAvailable: boolean } export async function getCurrentLocation (event: H3Event): Promise { - const rec = await getRecord(event, dev.roe.location.main, 'self') - if (!rec) return null - const v = rec.value - return { - city: v.city, - region: v.region, - country: v.country, - countryCode: v.countryCode, - meetupAvailable: v.meetupAvailable ?? false, + try { + const rec = await getRecord(event, dev.roe.location.main, 'self') + if (!rec) return null + const v = rec.value + return { + city: v.address.locality ?? '', + region: v.address.region, + countryCode: v.address.country.toUpperCase(), + meetupAvailable: v.meetupAvailable ?? false, + } + } + catch (err) { + if (!(err instanceof LexValidationError)) throw err + return legacyLocation(event) } } export async function setCurrentLocation (event: H3Event, loc: Location): Promise { const value: Loose> = { - city: loc.city, - ...(loc.region ? { region: loc.region } : {}), - country: loc.country, - countryCode: loc.countryCode.toUpperCase(), + address: { + $type: 'community.lexicon.location.address', + country: loc.countryCode.toUpperCase(), + ...(loc.region ? { region: loc.region } : {}), + locality: loc.city, + }, meetupAvailable: loc.meetupAvailable, createdAt: new Date().toISOString(), } await putRecord(event, dev.roe.location.main, 'self', value) } + +/** + * City, region and country used to be flat fields on the record; they now live + * in a `community.lexicon.location.address`, which the schema requires. A record + * written before the switch therefore fails validation on read, so fall back to + * an unvalidated read until the next location update rewrites it. + */ +async function legacyLocation (event: H3Event): Promise { + const value = await getRawRecord(event, 'dev.roe.location', 'self') + if (!value) return null + const legacy = value as { city?: string, region?: string, countryCode?: string, meetupAvailable?: boolean } + return { + city: legacy.city ?? '', + region: legacy.region, + countryCode: (legacy.countryCode ?? '').toUpperCase(), + meetupAvailable: legacy.meetupAvailable ?? false, + } +} diff --git a/server/utils/cms/talks.ts b/server/utils/cms/talks.ts index b916becc..11a4490d 100644 --- a/server/utils/cms/talks.ts +++ b/server/utils/cms/talks.ts @@ -33,6 +33,7 @@ export interface UpcomingConference { location: string image?: { url: string + alt: string width: number height: number } | null @@ -48,7 +49,7 @@ export async function getUpcomingTalks (event: H3Event): Promise { const v = t.value - const image = v.image ? await blobImage(event, v.image, v.aspectRatio) : null + const image = await talkImage(event, v) return { ...(v.title ? { title: v.title } : {}), name: v.source || v.title || '', @@ -56,11 +57,39 @@ export async function getUpcomingTalks (event: H3Event): Promise { + const image = value.image + if (!image) return null + + if (!('alt' in image)) { + const legacy = value as { aspectRatio?: { width?: number, height?: number } } + const blob = await blobImage(event, image, legacy.aspectRatio) + return blob ? { url: blob.url, alt: value.source, width: blob.width ?? 0, height: blob.height ?? 0 } : null + } + + if (image.image) { + const blob = await blobImage(event, image.image, image.aspectRatio) + return blob ? { url: blob.url, alt: image.alt, width: blob.width ?? 0, height: blob.height ?? 0 } : null + } + + if (!image.uri) return null + return { + url: image.uri, + alt: image.alt, + width: image.aspectRatio?.width ?? 0, + height: image.aspectRatio?.height ?? 0, + } +} + export { rkeyFromUri } diff --git a/server/utils/cms/uses.ts b/server/utils/cms/uses.ts index b94a13d2..6f7fd603 100644 --- a/server/utils/cms/uses.ts +++ b/server/utils/cms/uses.ts @@ -29,10 +29,18 @@ export async function getUses (event: H3Event): Promise { return Promise.all(categories.map(async cat => { const bucket = itemsByCategoryUri.get(cat.uri) ?? [] const mappedItems: UsesItem[] = await Promise.all(bucket.map(async it => { - const { $type, category, image, aspectRatio, createdAt, ...passthrough } = it.value + const { $type, category, image, links, createdAt, ...passthrough } = it.value as typeof it.value & LegacyAspectRatioSibling + const { aspectRatio, ...item } = passthrough return { - ...passthrough, - image: image ? await blobImage(event, image, aspectRatio) : null, + ...item, + links: (links ?? []).map(link => ({ + uri: legacyLinkUri(link), + ...(link.label ? { label: link.label } : {}), + })), + image: await usesImage(event, image, { + name: passthrough.name, + legacyAspectRatio: passthrough.aspectRatio, + }), } })) @@ -45,6 +53,51 @@ export async function getUses (event: H3Event): Promise { })) } +type UsesImage = dev.roe.usesItem.Main['image'] +type UsesLink = NonNullable[number] +type LegacyAspectRatioSibling = { aspectRatio?: { width?: number, height?: number } } + +/** + * `dev.roe.usesItem#link` held the destination in `url`; + * `community.lexicon.app.defs#link` calls it `uri`. Records written before the + * switch keep the old key, so read both until they have all been rewritten. + */ +function legacyLinkUri (link: UsesLink): string { + return link.uri ?? (link as { url?: string }).url ?? '' +} + +/** + * `image` used to be a bare blob with dimensions in a sibling `aspectRatio` + * field; it is now a `community.lexicon.app.defs#image` carrying its own + * dimensions and alt text. Records written before the switch still have the old + * shape, so handle both until they have all been rewritten. + */ +async function usesImage ( + event: H3Event, + image: UsesImage, + legacy: { name: string, legacyAspectRatio?: { width?: number, height?: number } }, +): Promise { + if (!image) return null + + if (!('alt' in image)) { + const blob = await blobImage(event, image, legacy.legacyAspectRatio) + return blob ? { ...blob, alt: legacy.name } : null + } + + if (image.image) { + const blob = await blobImage(event, image.image, image.aspectRatio) + return blob ? { ...blob, alt: image.alt } : null + } + + if (!image.uri) return null + return { + url: image.uri, + alt: image.alt, + width: image.aspectRatio?.width ?? null, + height: image.aspectRatio?.height ?? null, + } +} + function rkeyFromUri (uri: string): string { const i = uri.lastIndexOf('/') return i === -1 ? uri : uri.slice(i + 1) diff --git a/shared/cms/blob.ts b/shared/cms/blob.ts index 8aa6559c..c97e37ab 100644 --- a/shared/cms/blob.ts +++ b/shared/cms/blob.ts @@ -29,6 +29,9 @@ export function blobUrlFor (service: string, did: string, cid: string): string { export const BLUESKY_IMAGE_MAX_BYTES = 1_000_000 +/** `community.lexicon.app.defs#image` rejects blobs larger than this. */ +export const COMMUNITY_IMAGE_MAX_BYTES = 2_000_000 + export function blobSize (blob: unknown): number | null { if (!blob || typeof blob !== 'object') return null const size = (blob as { size?: unknown }).size diff --git a/shared/cms/talk-mapper.ts b/shared/cms/talk-mapper.ts index 2a386b0d..b9b4d495 100644 --- a/shared/cms/talk-mapper.ts +++ b/shared/cms/talk-mapper.ts @@ -18,7 +18,8 @@ export interface PdsTalkGroupInput { } export function toTalk (input: PdsTalkInput, group?: PdsTalkGroupInput): Talk { - const { $type, createdAt, endDate, location, image, title, group: _strongRef, ...passthrough } = input.value + const value = input.value as dev.roe.talk.Main & { aspectRatio?: unknown } + const { $type, createdAt, endDate, location, image, aspectRatio, title, group: _strongRef, ...passthrough } = value return { ...passthrough, _id: rkeyFromUri(input.uri), diff --git a/shared/cms/uses.ts b/shared/cms/uses.ts index 4ae787ff..18e85ec6 100644 --- a/shared/cms/uses.ts +++ b/shared/cms/uses.ts @@ -1,9 +1,11 @@ import type { dev } from '../lex/index.ts' import type { Strict } from './strict.ts' -export type UsesItem = Omit, '$type' | 'category' | 'image' | 'createdAt'> & { +export type UsesItem = Omit, '$type' | 'category' | 'image' | 'links' | 'createdAt'> & { + links: { uri: string, label?: string }[] image: { url: string + alt: string width: number | null height: number | null } | null diff --git a/shared/lex/community/lexicon.ts b/shared/lex/community/lexicon.ts index d027c156..1ffcebfa 100644 --- a/shared/lex/community/lexicon.ts +++ b/shared/lex/community/lexicon.ts @@ -3,3 +3,4 @@ */ export * as app from './lexicon/app.ts' +export * as location from './lexicon/location.ts' diff --git a/shared/lex/dev/roe/defs.ts b/shared/lex/community/lexicon/location.ts similarity index 57% rename from shared/lex/dev/roe/defs.ts rename to shared/lex/community/lexicon/location.ts index f3af51b8..67dbd9e2 100644 --- a/shared/lex/dev/roe/defs.ts +++ b/shared/lex/community/lexicon/location.ts @@ -2,4 +2,4 @@ * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. */ -export * from './defs.defs.ts' +export * as address from './location/address.ts' diff --git a/shared/lex/community/lexicon/location/address.defs.ts b/shared/lex/community/lexicon/location/address.defs.ts new file mode 100644 index 00000000..d54272f8 --- /dev/null +++ b/shared/lex/community/lexicon/location/address.defs.ts @@ -0,0 +1,82 @@ +/* + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. + */ + +import { l } from '@atproto/lex' + +const $nsid = 'community.lexicon.location.address' + +type $nsid = typeof $nsid + +export { $nsid } + +/** A physical location in the form of a street address. */ +type Main = { + $type?: 'community.lexicon.location.address' + + /** + * The ISO 3166 country code. Preferably the 2-letter code. + */ + country: string + + /** + * The postal code of the location. + */ + postalCode?: string + + /** + * The administrative region of the country. For example, a state in the USA. + */ + region?: string + + /** + * The locality of the region. For example, a city in the USA. + */ + locality?: string + + /** + * The street address. + */ + street?: string + + /** + * The name of the location. + */ + name?: string +} + +export type { Main } + +/** A physical location in the form of a street address. */ +const main = /*#__PURE__*/ l.typedObject( + $nsid, + 'main', + /*#__PURE__*/ l.object({ + country: /*#__PURE__*/ l.string({ minLength: 2, maxLength: 10 }), + postalCode: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.string()), + region: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.string()), + locality: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.string()), + street: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.string()), + name: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.string()), + }), +) + +export { main } + +const $type = $nsid + +type $type = typeof $type + +export { $type } + +export const $isTypeOf = /*#__PURE__*/ main.isTypeOf.bind(main) +export const $build = /*#__PURE__*/ main.build.bind(main) +export const $assert = /*#__PURE__*/ main.assert.bind(main) +export const $check = /*#__PURE__*/ main.check.bind(main) +export const $cast = /*#__PURE__*/ main.cast.bind(main) +export const $ifMatches = /*#__PURE__*/ main.ifMatches.bind(main) +export const $matches = /*#__PURE__*/ main.matches.bind(main) +export const $parse = /*#__PURE__*/ main.parse.bind(main) +export const $safeParse = /*#__PURE__*/ main.safeParse.bind(main) +export const $validate = /*#__PURE__*/ main.validate.bind(main) +export const $safeValidate = /*#__PURE__*/ main.safeValidate.bind(main) diff --git a/shared/lex/community/lexicon/location/address.ts b/shared/lex/community/lexicon/location/address.ts new file mode 100644 index 00000000..b6189ee2 --- /dev/null +++ b/shared/lex/community/lexicon/location/address.ts @@ -0,0 +1,6 @@ +/* + * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. + */ + +export * from './address.defs.ts' +export { main as default } from './address.defs.ts' diff --git a/shared/lex/dev/roe.ts b/shared/lex/dev/roe.ts index 978eac23..050beda4 100644 --- a/shared/lex/dev/roe.ts +++ b/shared/lex/dev/roe.ts @@ -13,4 +13,3 @@ export * as talk from './roe/talk.ts' export * as talkGroup from './roe/talkGroup.ts' export * as usesCategory from './roe/usesCategory.ts' export * as usesItem from './roe/usesItem.ts' -export * as defs from './roe/defs.ts' diff --git a/shared/lex/dev/roe/ama.defs.ts b/shared/lex/dev/roe/ama.defs.ts index 8b372cb6..324951a2 100644 --- a/shared/lex/dev/roe/ama.defs.ts +++ b/shared/lex/dev/roe/ama.defs.ts @@ -3,7 +3,7 @@ */ import { l } from '@atproto/lex' -import * as RoeDefs from './defs.defs.ts' +import * as AppDefs from '../../community/lexicon/app/defs.defs.ts' import * as RepoStrongRef from '../../com/atproto/repo/strongRef.defs.ts' const $nsid = 'dev.roe.ama' @@ -46,7 +46,7 @@ type Main = { /** * Pixel dimensions of `image`. Stored alongside because PDS blobs don't carry intrinsic dimensions and Bluesky's image embed needs `aspectRatio`. */ - imageDimensions?: RoeDefs.AspectRatio + imageDimensions?: AppDefs.AspectRatio /** * ID of the background style used to render the image, either a preset name or a serialised custom emoji-and-gradient id. The set of styles is internal to roe.dev. @@ -87,8 +87,8 @@ const main = /*#__PURE__*/ l.record<'tid', Main>( /*#__PURE__*/ l.blob({ accept: ['image/*'], maxSize: 5000000 }), ), imageDimensions: /*#__PURE__*/ l.optional( - /*#__PURE__*/ l.ref( - (() => RoeDefs.aspectRatio) as any, + /*#__PURE__*/ l.ref( + (() => AppDefs.aspectRatio) as any, ), ), backgroundStyle: /*#__PURE__*/ l.optional( diff --git a/shared/lex/dev/roe/defs.defs.ts b/shared/lex/dev/roe/defs.defs.ts deleted file mode 100644 index 5de88fce..00000000 --- a/shared/lex/dev/roe/defs.defs.ts +++ /dev/null @@ -1,32 +0,0 @@ -/* - * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT. - */ - -import { l } from '@atproto/lex' - -const $nsid = 'dev.roe.defs' - -type $nsid = typeof $nsid - -export { $nsid } - -/** Intrinsic width:height of an image in pixels. Mirrors `app.bsky.embed.defs#aspectRatio`. */ -type AspectRatio = { - $type?: 'dev.roe.defs#aspectRatio' - width: number - height: number -} - -export type { AspectRatio } - -/** Intrinsic width:height of an image in pixels. Mirrors `app.bsky.embed.defs#aspectRatio`. */ -const aspectRatio = /*#__PURE__*/ l.typedObject( - $nsid, - 'aspectRatio', - /*#__PURE__*/ l.object({ - width: /*#__PURE__*/ l.integer({ minimum: 1 }), - height: /*#__PURE__*/ l.integer({ minimum: 1 }), - }), -) - -export { aspectRatio } diff --git a/shared/lex/dev/roe/location.defs.ts b/shared/lex/dev/roe/location.defs.ts index e66a2d06..7ae168e1 100644 --- a/shared/lex/dev/roe/location.defs.ts +++ b/shared/lex/dev/roe/location.defs.ts @@ -3,6 +3,7 @@ */ import { l } from '@atproto/lex' +import * as LocationAddress from '../../community/lexicon/location/address.defs.ts' const $nsid = 'dev.roe.location' @@ -13,18 +14,11 @@ export { $nsid } /** Current location (singleton; rkey is always 'self'). */ type Main = { $type: 'dev.roe.location' - city: string /** - * Region/state. Used to special-case Scotland and US/UK subdivisions on the public site. + * Where I am: `locality` is the city, `region` the state/subdivision (used to special-case Scotland), `country` the ISO 3166-1 alpha-2 code (used to compute a flag emoji). */ - region?: string - country: string - - /** - * ISO 3166-1 alpha-2 country code, uppercase. Used to compute a flag emoji. - */ - countryCode: string + address: LocationAddress.Main meetupAvailable?: boolean createdAt: l.DatetimeString } @@ -36,12 +30,9 @@ const main = /*#__PURE__*/ l.record<'literal:self', Main>( 'literal:self', $nsid, /*#__PURE__*/ l.object({ - city: /*#__PURE__*/ l.string({ maxLength: 2560, maxGraphemes: 256 }), - region: /*#__PURE__*/ l.optional( - /*#__PURE__*/ l.string({ maxLength: 2560, maxGraphemes: 256 }), + address: /*#__PURE__*/ l.ref( + (() => LocationAddress.main) as any, ), - country: /*#__PURE__*/ l.string({ maxLength: 2560, maxGraphemes: 256 }), - countryCode: /*#__PURE__*/ l.string({ minLength: 2, maxLength: 2 }), meetupAvailable: /*#__PURE__*/ l.optional( /*#__PURE__*/ l.withDefault(/*#__PURE__*/ l.boolean(), true), ), diff --git a/shared/lex/dev/roe/talk.defs.ts b/shared/lex/dev/roe/talk.defs.ts index d44095de..dfbb35b2 100644 --- a/shared/lex/dev/roe/talk.defs.ts +++ b/shared/lex/dev/roe/talk.defs.ts @@ -4,7 +4,7 @@ import { l } from '@atproto/lex' import * as RepoStrongRef from '../../com/atproto/repo/strongRef.defs.ts' -import * as RoeDefs from './defs.defs.ts' +import * as AppDefs from '../../community/lexicon/app/defs.defs.ts' const $nsid = 'dev.roe.talk' @@ -68,12 +68,11 @@ type Main = { * Strong-ref to a dev.roe.talkGroup record. */ group?: RepoStrongRef.Main - image?: l.BlobRef /** - * Intrinsic pixel dimensions of `image`. + * Event or conference logo, shown on the home page carousel. */ - aspectRatio?: RoeDefs.AspectRatio + image?: AppDefs.Image createdAt: l.DatetimeString } @@ -127,12 +126,7 @@ const main = /*#__PURE__*/ l.record<'tid', Main>( ), ), image: /*#__PURE__*/ l.optional( - /*#__PURE__*/ l.blob({ accept: ['image/*'], maxSize: 5000000 }), - ), - aspectRatio: /*#__PURE__*/ l.optional( - /*#__PURE__*/ l.ref( - (() => RoeDefs.aspectRatio) as any, - ), + /*#__PURE__*/ l.ref((() => AppDefs.image) as any), ), createdAt: /*#__PURE__*/ l.string({ format: 'datetime' }), }), diff --git a/shared/lex/dev/roe/usesItem.defs.ts b/shared/lex/dev/roe/usesItem.defs.ts index 72cdf2de..03394f3b 100644 --- a/shared/lex/dev/roe/usesItem.defs.ts +++ b/shared/lex/dev/roe/usesItem.defs.ts @@ -4,7 +4,7 @@ import { l } from '@atproto/lex' import * as RepoStrongRef from '../../com/atproto/repo/strongRef.defs.ts' -import * as RoeDefs from './defs.defs.ts' +import * as AppDefs from '../../community/lexicon/app/defs.defs.ts' const $nsid = 'dev.roe.usesItem' @@ -27,13 +27,12 @@ type Main = { * Lower values render first within the category. Defaults to 100 in the editor. */ order?: number - image?: l.BlobRef /** - * Intrinsic pixel dimensions of `image`. + * Product shot or screenshot. Grid categories render it; list categories ignore it. */ - aspectRatio?: RoeDefs.AspectRatio - links?: Link[] + image?: AppDefs.Image + links?: AppDefs.Link[] createdAt: l.DatetimeString } @@ -55,17 +54,13 @@ const main = /*#__PURE__*/ l.record<'tid', Main>( /*#__PURE__*/ l.withDefault(/*#__PURE__*/ l.integer(), 100), ), image: /*#__PURE__*/ l.optional( - /*#__PURE__*/ l.blob({ accept: ['image/*'], maxSize: 5000000 }), - ), - aspectRatio: /*#__PURE__*/ l.optional( - /*#__PURE__*/ l.ref( - (() => RoeDefs.aspectRatio) as any, - ), + /*#__PURE__*/ l.ref((() => AppDefs.image) as any), ), links: /*#__PURE__*/ l.optional( - /*#__PURE__*/ l.array(/*#__PURE__*/ l.ref((() => link) as any), { - maxLength: 32, - }), + /*#__PURE__*/ l.array( + /*#__PURE__*/ l.ref((() => AppDefs.link) as any), + { maxLength: 32 }, + ), ), createdAt: /*#__PURE__*/ l.string({ format: 'datetime' }), }), @@ -90,24 +85,3 @@ export const $parse = /*#__PURE__*/ main.parse.bind(main) export const $safeParse = /*#__PURE__*/ main.safeParse.bind(main) export const $validate = /*#__PURE__*/ main.validate.bind(main) export const $safeValidate = /*#__PURE__*/ main.safeValidate.bind(main) - -type Link = { - $type?: 'dev.roe.usesItem#link' - url: l.UriString - label?: string -} - -export type { Link } - -const link = /*#__PURE__*/ l.typedObject( - $nsid, - 'link', - /*#__PURE__*/ l.object({ - url: /*#__PURE__*/ l.string({ format: 'uri' }), - label: /*#__PURE__*/ l.optional( - /*#__PURE__*/ l.string({ maxLength: 640, maxGraphemes: 64 }), - ), - }), -) - -export { link }