diff --git a/app/pages/admin/projects/categories/[rkey].vue b/app/pages/admin/projects/categories/[rkey].vue
new file mode 100644
index 00000000..1693dced
--- /dev/null
+++ b/app/pages/admin/projects/categories/[rkey].vue
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
diff --git a/app/pages/admin/projects/categories/new.vue b/app/pages/admin/projects/categories/new.vue
new file mode 100644
index 00000000..4a704110
--- /dev/null
+++ b/app/pages/admin/projects/categories/new.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
diff --git a/app/pages/admin/projects/index.vue b/app/pages/admin/projects/index.vue
new file mode 100644
index 00000000..03e046c8
--- /dev/null
+++ b/app/pages/admin/projects/index.vue
@@ -0,0 +1,184 @@
+
+
+
+
+
+
+ New category
+
+
+ New project
+
+
+
+
+ No categories yet. Create a category before adding projects.
+
+
+
+
+
+
+
diff --git a/app/pages/admin/projects/items/[rkey].vue b/app/pages/admin/projects/items/[rkey].vue
new file mode 100644
index 00000000..cf284b81
--- /dev/null
+++ b/app/pages/admin/projects/items/[rkey].vue
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
diff --git a/app/pages/admin/projects/items/new.vue b/app/pages/admin/projects/items/new.vue
new file mode 100644
index 00000000..56576f5f
--- /dev/null
+++ b/app/pages/admin/projects/items/new.vue
@@ -0,0 +1,23 @@
+
+
+
+
+
+
+
diff --git a/lexicons/README.md b/lexicons/README.md
index 912f79be..65d5e526 100644
--- a/lexicons/README.md
+++ b/lexicons/README.md
@@ -1,7 +1,16 @@
# lexicons
-Schemas for the records my CMS writes to my PDS. `com/` holds vendored copies of
-upstream schemas we reference; everything under `dev/roe/` is mine.
+Schemas for the records my CMS writes to my PDS. `com/` and `community/` hold
+vendored copies of upstream schemas we reference; everything under `dev/roe/` is
+mine.
+
+`community/lexicon/app/defs.json` is a verbatim copy of the
+[Lexicon Community](https://tangled.org/lexicon.community/lexicons) schema,
+checked against the `com.atproto.lexicon.schema` record published at
+`did:plc:mtr7qrqtcyseedx3jyr5o7db`. `dev.roe.project` uses its `#link`,
+`#image` and `#status` defs rather than reinventing them. Refresh it from
+upstream when the shared defs change; `pnpm lex:publish` only writes `dev.roe.*`
+schemas, so vendored files are never republished under my authority.
- `pnpm lex:gen` regenerates the TypeScript types in `shared/lex/`.
- `pnpm lex:publish` writes each `dev.roe.*` schema to the PDS as a
diff --git a/lexicons/community/lexicon/app/defs.json b/lexicons/community/lexicon/app/defs.json
new file mode 100644
index 00000000..34ea7595
--- /dev/null
+++ b/lexicons/community/lexicon/app/defs.json
@@ -0,0 +1,295 @@
+{
+ "lexicon": 1,
+ "id": "community.lexicon.app.defs",
+ "defs": {
+ "link": {
+ "type": "object",
+ "description": "A labeled URI associated with an app.",
+ "required": ["uri"],
+ "properties": {
+ "uri": {
+ "type": "string",
+ "format": "uri",
+ "description": "Destination URI."
+ },
+ "label": {
+ "type": "string",
+ "maxLength": 100,
+ "maxGraphemes": 50,
+ "description": "Human-readable label for the URI."
+ },
+ "role": {
+ "type": "ref",
+ "ref": "#linkRole",
+ "description": "Known role of this link, if any."
+ }
+ }
+ },
+ "linkRole": {
+ "type": "string",
+ "description": "Known role of a link associated with an app.",
+ "knownValues": [
+ "community.lexicon.app.defs#linkRoleWebsite",
+ "community.lexicon.app.defs#linkRoleWebManifest",
+ "community.lexicon.app.defs#linkRolePrivacyPolicy",
+ "community.lexicon.app.defs#linkRoleTermsOfService",
+ "community.lexicon.app.defs#linkRoleSupport",
+ "community.lexicon.app.defs#linkRoleSourceCode",
+ "community.lexicon.app.defs#linkRoleDocs",
+ "community.lexicon.app.defs#linkRoleChangelog",
+ "community.lexicon.app.defs#linkRoleStatus",
+ "community.lexicon.app.defs#linkRoleAppStore",
+ "community.lexicon.app.defs#linkRolePlayStore",
+ "community.lexicon.app.defs#linkRoleFDroid"
+ ]
+ },
+ "linkRoleWebsite": {
+ "type": "token",
+ "description": "Primary website or landing page."
+ },
+ "linkRoleWebManifest": {
+ "type": "token",
+ "description": "Web App Manifest."
+ },
+ "linkRolePrivacyPolicy": {
+ "type": "token",
+ "description": "Privacy policy."
+ },
+ "linkRoleTermsOfService": {
+ "type": "token",
+ "description": "Terms of service."
+ },
+ "linkRoleSupport": {
+ "type": "token",
+ "description": "Support, help, or contact page."
+ },
+ "linkRoleSourceCode": {
+ "type": "token",
+ "description": "Source code repository or source distribution."
+ },
+ "linkRoleDocs": {
+ "type": "token",
+ "description": "Documentation."
+ },
+ "linkRoleChangelog": {
+ "type": "token",
+ "description": "Changelog or release notes."
+ },
+ "linkRoleStatus": {
+ "type": "token",
+ "description": "Service status page."
+ },
+ "linkRoleAppStore": {
+ "type": "token",
+ "description": "Apple App Store listing."
+ },
+ "linkRolePlayStore": {
+ "type": "token",
+ "description": "Google Play Store listing."
+ },
+ "linkRoleFDroid": {
+ "type": "token",
+ "description": "F-Droid listing."
+ },
+ "image": {
+ "type": "object",
+ "description": "An image associated with an app, including accessibility and display metadata. Exactly one of `image` (ATProto blob) or `uri` (remote URL) MUST be present. Consumers SHOULD ignore image items that have neither or both.",
+ "required": ["alt"],
+ "properties": {
+ "purpose": {
+ "type": "string",
+ "knownValues": [
+ "community.lexicon.app.defs#purposeIcon",
+ "community.lexicon.app.defs#purposeLogo",
+ "community.lexicon.app.defs#purposeHero",
+ "community.lexicon.app.defs#purposeScreenshot",
+ "community.lexicon.app.defs#purposeBanner",
+ "community.lexicon.app.defs#purposeSocialCard",
+ "community.lexicon.app.defs#purposeAppStore",
+ "community.lexicon.app.defs#purposeAd"
+ ],
+ "description": "How directories and stores should use the image. Omit this field when no known purpose fits."
+ },
+ "image": {
+ "type": "blob",
+ "description": "The raw image file.",
+ "accept": ["image/*"],
+ "maxSize": 2000000
+ },
+ "uri": {
+ "type": "string",
+ "format": "uri",
+ "description": "Remote image URI."
+ },
+ "alt": {
+ "type": "string",
+ "maxLength": 1000,
+ "maxGraphemes": 300,
+ "description": "Alt text description of the image, for accessibility."
+ },
+ "aspectRatio": {
+ "type": "ref",
+ "ref": "#aspectRatio"
+ }
+ }
+ },
+ "aspectRatio": {
+ "type": "object",
+ "description": "width:height represents an aspect ratio. It may be approximate.",
+ "required": ["width", "height"],
+ "properties": {
+ "width": {
+ "type": "integer",
+ "minimum": 1
+ },
+ "height": {
+ "type": "integer",
+ "minimum": 1
+ }
+ }
+ },
+ "purposeIcon": {
+ "type": "token",
+ "description": "A small square icon representing the app, typically used in launchers, lists, and tab bars."
+ },
+ "purposeLogo": {
+ "type": "token",
+ "description": "A logotype or wordmark for the app."
+ },
+ "purposeHero": {
+ "type": "token",
+ "description": "A large promotional or feature image, typically used at the top of a directory listing."
+ },
+ "purposeScreenshot": {
+ "type": "token",
+ "description": "A screenshot of the app's UI, used in directory and store listings."
+ },
+ "purposeBanner": {
+ "type": "token",
+ "description": "A wide banner image, such as a header image for a profile or listing page."
+ },
+ "purposeSocialCard": {
+ "type": "token",
+ "description": "An image sized and formatted for social media sharing previews (Open Graph, Twitter Card, etc.)."
+ },
+ "purposeAppStore": {
+ "type": "token",
+ "description": "A promotional image formatted for a native app store listing."
+ },
+ "purposeAd": {
+ "type": "token",
+ "description": "A promotional or advertising image."
+ },
+ "status": {
+ "type": "string",
+ "description": "Current release or maintenance status of an app.",
+ "knownValues": [
+ "community.lexicon.app.defs#unreleased",
+ "community.lexicon.app.defs#preview",
+ "community.lexicon.app.defs#released",
+ "community.lexicon.app.defs#unmaintained",
+ "community.lexicon.app.defs#discontinued"
+ ]
+ },
+ "unreleased": {
+ "type": "token",
+ "description": "The app has been announced but is not yet available."
+ },
+ "preview": {
+ "type": "token",
+ "description": "The app is available as an alpha, beta, early access, or preview release."
+ },
+ "released": {
+ "type": "token",
+ "description": "The app is generally available."
+ },
+ "unmaintained": {
+ "type": "token",
+ "description": "The app may still be available, but is no longer actively maintained."
+ },
+ "discontinued": {
+ "type": "token",
+ "description": "The app is no longer available or supported."
+ },
+ "platform": {
+ "type": "string",
+ "description": "Platform where an app is available.",
+ "knownValues": [
+ "community.lexicon.app.defs#platformWeb",
+ "community.lexicon.app.defs#platformIOS",
+ "community.lexicon.app.defs#platformAndroid",
+ "community.lexicon.app.defs#platformMacOS",
+ "community.lexicon.app.defs#platformWindows",
+ "community.lexicon.app.defs#platformLinux",
+ "community.lexicon.app.defs#platformCLI"
+ ]
+ },
+ "platformWeb": {
+ "type": "token",
+ "description": "Web app or website."
+ },
+ "platformIOS": {
+ "type": "token",
+ "description": "iOS app."
+ },
+ "platformAndroid": {
+ "type": "token",
+ "description": "Android app."
+ },
+ "platformMacOS": {
+ "type": "token",
+ "description": "macOS app."
+ },
+ "platformWindows": {
+ "type": "token",
+ "description": "Windows app."
+ },
+ "platformLinux": {
+ "type": "token",
+ "description": "Linux app."
+ },
+ "platformCLI": {
+ "type": "token",
+ "description": "Command line interface."
+ },
+ "lexiconInterop": {
+ "type": "object",
+ "description": "Self-declared AT Protocol lexicon interoperability signals.",
+ "properties": {
+ "produces": {
+ "type": "array",
+ "description": "Lexicon collections this app creates or publishes records for.",
+ "items": {
+ "type": "string",
+ "format": "nsid"
+ }
+ },
+ "consumes": {
+ "type": "array",
+ "description": "Lexicon collections this app reads, displays, imports, or otherwise consumes.",
+ "items": {
+ "type": "string",
+ "format": "nsid"
+ }
+ }
+ }
+ },
+ "accountIndicator": {
+ "type": "object",
+ "description": "A record whose presence in an account can indicate that the account probably uses this app.",
+ "required": ["collection"],
+ "properties": {
+ "collection": {
+ "type": "string",
+ "format": "nsid",
+ "description": "Record collection to look for in an account repository."
+ },
+ "rkey": {
+ "type": "string",
+ "format": "record-key",
+ "description": "Optional record key to look for within the collection."
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/dev/roe/project.json b/lexicons/dev/roe/project.json
new file mode 100644
index 00000000..351bc407
--- /dev/null
+++ b/lexicons/dev/roe/project.json
@@ -0,0 +1,70 @@
+{
+ "lexicon": 1,
+ "id": "dev.roe.project",
+ "defs": {
+ "main": {
+ "type": "record",
+ "description": "A project on the /projects page, belonging to a dev.roe.projectCategory.",
+ "key": "tid",
+ "record": {
+ "type": "object",
+ "required": ["category", "name", "createdAt"],
+ "properties": {
+ "category": {
+ "type": "ref",
+ "ref": "com.atproto.repo.strongRef",
+ "description": "Strong-ref to the parent dev.roe.projectCategory record."
+ },
+ "name": {
+ "type": "string",
+ "maxLength": 2560,
+ "maxGraphemes": 256
+ },
+ "description": {
+ "type": "string",
+ "maxLength": 25000,
+ "maxGraphemes": 2500
+ },
+ "links": {
+ "type": "array",
+ "description": "Relevant destinations for the project. The site links the card at the first `linkRoleWebsite`, falling back to the first link of any role.",
+ "items": {
+ "type": "ref",
+ "ref": "community.lexicon.app.defs#link"
+ },
+ "maxLength": 12
+ },
+ "images": {
+ "type": "array",
+ "description": "Visual assets for the project. The site renders the first `purposeScreenshot`, falling back to the first image of any purpose.",
+ "items": {
+ "type": "ref",
+ "ref": "community.lexicon.app.defs#image"
+ },
+ "maxLength": 8
+ },
+ "status": {
+ "type": "ref",
+ "ref": "community.lexicon.app.defs#status",
+ "description": "Release or maintenance status. `unmaintained` and `discontinued` render the card as archived."
+ },
+ "icon": {
+ "type": "string",
+ "maxLength": 640,
+ "maxGraphemes": 64,
+ "description": "UnoCSS icon class, e.g. `i-ri:rocket-line`. Used when there is no image."
+ },
+ "order": {
+ "type": "integer",
+ "description": "Lower values render first within the category. Defaults to 100 in the editor.",
+ "default": 100
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "datetime"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/lexicons/dev/roe/projectCategory.json b/lexicons/dev/roe/projectCategory.json
new file mode 100644
index 00000000..e01a5a56
--- /dev/null
+++ b/lexicons/dev/roe/projectCategory.json
@@ -0,0 +1,31 @@
+{
+ "lexicon": 1,
+ "id": "dev.roe.projectCategory",
+ "defs": {
+ "main": {
+ "type": "record",
+ "description": "A category on the /projects page. Projects belong to a category via dev.roe.project.category.",
+ "key": "tid",
+ "record": {
+ "type": "object",
+ "required": ["title", "createdAt"],
+ "properties": {
+ "title": {
+ "type": "string",
+ "maxLength": 2560,
+ "maxGraphemes": 256
+ },
+ "order": {
+ "type": "integer",
+ "description": "Lower values render first. Defaults to 100 in the editor.",
+ "default": 100
+ },
+ "createdAt": {
+ "type": "string",
+ "format": "datetime"
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/modules/projects.ts b/modules/projects.ts
deleted file mode 100644
index b4d026ed..00000000
--- a/modules/projects.ts
+++ /dev/null
@@ -1,94 +0,0 @@
-import { readFile } from 'node:fs/promises'
-
-import { addTemplate, addTypeTemplate, defineNuxtModule, useNuxt } from 'nuxt/kit'
-import { resolve } from 'pathe'
-import yaml from 'js-yaml'
-
-export interface ProjectItem {
- name: string
- description?: string
- url?: string
- repo?: string
- image?: string
- icon?: string
- archived?: boolean
- order?: number
-}
-
-export interface ProjectCategory {
- category: string
- order?: number
- items: ProjectItem[]
-}
-
-export default defineNuxtModule({
- meta: {
- name: 'projects',
- },
- async setup () {
- const nuxt = useNuxt()
- const filePath = resolve(nuxt.options.rootDir, 'content/projects.yml')
-
- let categories: ProjectCategory[] = []
- try {
- const raw = await readFile(filePath, 'utf-8')
- const parsed = yaml.load(raw) as ProjectCategory[] | null
- categories = Array.isArray(parsed) ? parsed : []
- }
- catch (err: unknown) {
- if ((err as NodeJS.ErrnoException)?.code !== 'ENOENT') throw err
- }
-
- categories = categories
- .map(c => ({
- ...c,
- items: [...(c.items || [])].sort(
- (a, b) => (a.order ?? 100) - (b.order ?? 100),
- ),
- }))
- .sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
-
- addTemplate({
- filename: 'projects.mjs',
- getContents: () => `export const projects = ${JSON.stringify(categories)}`,
- write: true,
- })
-
- nuxt.options.nitro.virtual ||= {}
- nuxt.options.nitro.virtual['#projects.json'] = () =>
- `export const projects = ${JSON.stringify(categories)}`
-
- nuxt.options.nitro.externals ||= {}
- nuxt.options.nitro.externals.inline ||= []
- nuxt.options.nitro.externals.inline.push('#projects.json')
-
- addTypeTemplate({
- filename: 'types/projects.d.ts',
- getContents: () => `
-interface ProjectItem {
- name: string
- description?: string
- url?: string
- repo?: string
- image?: string
- icon?: string
- archived?: boolean
- order?: number
-}
-interface ProjectCategory {
- category: string
- order?: number
- items: ProjectItem[]
-}
-
-declare module '#build/projects.mjs' {
- export const projects: ProjectCategory[]
-}
-
-declare module '#projects.json' {
- export const projects: ProjectCategory[]
-}
-`,
- }, { nuxt: true, nitro: true })
- },
-})
diff --git a/nuxt.config.ts b/nuxt.config.ts
index 3d8e4b01..b57397a9 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -170,6 +170,7 @@ export default defineNuxtConfig({
'/api/talks': { swr: 60 * 60 },
'/api/upcoming-conferences': { swr: 60 * 60 },
'/api/uses': { swr: 60 * 60 },
+ '/api/projects': { swr: 60 * 60 },
'/api/current-location': { swr: 60 * 5 },
'/api/sponsors': { prerender: true },
'/api/hi': { cors: true },
diff --git a/package.json b/package.json
index 8b45633e..f52741c1 100644
--- a/package.json
+++ b/package.json
@@ -20,7 +20,8 @@
"auth:youtube": "node --env-file=.env scripts/youtube-auth.ts",
"auth:youtube:refresh": "node --env-file=.env scripts/youtube-auth.ts refresh",
"lex:gen": "lex build --lexicons ./lexicons --out ./shared/lex --clear --index-file --import-ext .ts",
- "lex:publish": "node --env-file=.env scripts/publish-lex.ts"
+ "lex:publish": "node --env-file=.env scripts/publish-lex.ts",
+ "migrate:projects": "node --env-file=.env scripts/migrate-projects-to-atproto.ts"
},
"nano-staged": {
"*.{js,ts,mjs,vue}": [
diff --git a/scripts/migrate-projects-to-atproto.ts b/scripts/migrate-projects-to-atproto.ts
new file mode 100644
index 00000000..cc89ec46
--- /dev/null
+++ b/scripts/migrate-projects-to-atproto.ts
@@ -0,0 +1,276 @@
+/**
+ * One-shot `content/projects.yml` → atproto migration script. Creates a
+ * `dev.roe.projectCategory` record per category and a `dev.roe.project` record
+ * per item, uploading each item's `image` (a path under `public/`) as a blob.
+ *
+ * Usage:
+ * node --env-file=.env scripts/migrate-projects-to-atproto.ts --dry-run
+ * node --env-file=.env scripts/migrate-projects-to-atproto.ts
+ * node --env-file=.env scripts/migrate-projects-to-atproto.ts --reset
+ *
+ * Flags:
+ * --dry-run Don't write anything; print what would happen.
+ * --reset Delete every record this script created (per the manifest),
+ * clear the manifest, then exit.
+ *
+ * Idempotency: a manifest at scripts/.projects-migration-state.json maps
+ * `category:
` / `project:/` keys to the at:// URI that
+ * was created for them, so reruns skip anything already migrated. Categories
+ * are written before projects, because `project.category` is a strong-ref.
+ *
+ * Identity resolution matches scripts/publish-lex.ts: only
+ * NUXT_ATPROTO_PASSWORD is required.
+ *
+ * Both this script and `content/projects.yml` can be deleted once the
+ * migration has run against the live PDS.
+ */
+import { existsSync, readFileSync, writeFileSync } from 'node:fs'
+import { promises as dns } from 'node:dns'
+import { dirname, join, resolve } from 'node:path'
+import process from 'node:process'
+import { fileURLToPath } from 'node:url'
+
+import { Client, asStringFormat } from '@atproto/lex'
+import type { l } from '@atproto/lex'
+import { PasswordSession } from '@atproto/lex-password-session'
+import { defineCommand, runMain } from 'citty'
+import { imageMeta } from 'image-meta'
+import { load as loadYaml } from 'js-yaml'
+
+import { community, dev } from '../shared/lex/index.ts'
+
+const { defs } = community.lexicon.app
+
+const here = dirname(fileURLToPath(import.meta.url))
+const root = resolve(here, '..')
+const manifestPath = join(here, '.projects-migration-state.json')
+
+interface YamlProject {
+ name: string
+ description?: string
+ url?: string
+ repo?: string
+ image?: string
+ icon?: string
+ archived?: boolean
+ order?: number
+}
+
+interface YamlCategory {
+ category: string
+ order?: number
+ items?: YamlProject[]
+}
+
+interface ManifestEntry {
+ uri: string
+ cid: string
+}
+
+type Manifest = Record
+
+function loadManifest (): Manifest {
+ if (!existsSync(manifestPath)) return {}
+ try {
+ return JSON.parse(readFileSync(manifestPath, 'utf8')) as Manifest
+ }
+ catch {
+ return {}
+ }
+}
+
+function saveManifest (manifest: Manifest): void {
+ const sorted: Manifest = {}
+ for (const key of Object.keys(manifest).sort()) sorted[key] = manifest[key]!
+ writeFileSync(manifestPath, JSON.stringify(sorted, null, 2) + '\n')
+}
+
+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} via _atproto TXT or /.well-known/atproto-did`)
+ 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
+}
+
+const MIME_BY_EXT: Record = {
+ '.png': 'image/png',
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+ '.webp': 'image/webp',
+ '.avif': 'image/avif',
+ '.gif': 'image/gif',
+ '.svg': 'image/svg+xml',
+}
+
+function readCategories (): YamlCategory[] {
+ const parsed = loadYaml(readFileSync(join(root, 'content/projects.yml'), 'utf8'))
+ if (!Array.isArray(parsed)) throw new Error('content/projects.yml did not parse to an array')
+ return parsed as YamlCategory[]
+}
+
+const main = defineCommand({
+ meta: {
+ name: 'migrate-projects-to-atproto',
+ description: 'Migrate content/projects.yml into dev.roe.projectCategory + dev.roe.project records',
+ },
+ args: {
+ 'dry-run': {
+ type: 'boolean',
+ description: 'Print what would change without writing to the PDS',
+ default: false,
+ },
+ 'reset': {
+ type: 'boolean',
+ description: 'Delete every record in the manifest and clear it',
+ default: false,
+ },
+ },
+ async run ({ args }) {
+ const password = process.env.NUXT_ATPROTO_PASSWORD
+ if (!password) {
+ console.error('Set NUXT_ATPROTO_PASSWORD (see .env.example).')
+ process.exitCode = 1
+ return
+ }
+
+ 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}\n`)
+
+ const session = await PasswordSession.login({ service, identifier: did, password })
+ const client = new Client(session)
+ const manifest = loadManifest()
+
+ if (args.reset) {
+ const remaining: Manifest = { ...manifest }
+ for (const [key, entry] of Object.entries(manifest)) {
+ const [collection, rkey] = [entry.uri.split('/').at(-2)!, entry.uri.split('/').at(-1)!]
+ if (args['dry-run']) {
+ console.log(` delete (dry run) ${entry.uri}`)
+ continue
+ }
+ await client.deleteRecord(collection as `${string}.${string}.${string}`, rkey, { repo: did })
+ Reflect.deleteProperty(remaining, key)
+ console.log(`🗑 deleted ${entry.uri}`)
+ }
+ if (!args['dry-run']) saveManifest(remaining)
+ return
+ }
+
+ const createdAt = asStringFormat(new Date().toISOString(), 'datetime')
+
+ for (const category of readCategories()) {
+ const categoryKey = `category:${category.category}`
+ let categoryEntry = manifest[categoryKey]
+
+ if (categoryEntry) {
+ console.log(` unchanged ${category.category}`)
+ }
+ else if (args['dry-run']) {
+ console.log(` create (dry run) category ${category.category}`)
+ categoryEntry = { uri: `at://${did}/dev.roe.projectCategory/dry-run`, cid: 'dry-run' }
+ }
+ else {
+ const res = await client.create(dev.roe.projectCategory.main, {
+ title: category.category,
+ order: category.order ?? 100,
+ createdAt,
+ }, { repo: did, validateRequest: true })
+ categoryEntry = { uri: res.uri, cid: res.cid }
+ manifest[categoryKey] = categoryEntry
+ saveManifest(manifest)
+ console.log(`🟢 created category ${category.category}`)
+ }
+
+ for (const item of category.items ?? []) {
+ const itemKey = `project:${category.category}/${item.name}`
+ if (manifest[itemKey]) {
+ console.log(` unchanged ${item.name}`)
+ continue
+ }
+
+ let image: l.BlobRef | undefined
+ let aspectRatio: { width: number, height: number } | undefined
+ if (item.image) {
+ const path = join(root, 'public', item.image.replace(/^\//, ''))
+ if (!existsSync(path)) throw new Error(`${item.name}: ${item.image} does not exist under public/`)
+ const bytes = new Uint8Array(readFileSync(path))
+ const ext = item.image.slice(item.image.lastIndexOf('.')).toLowerCase()
+ const encoding = MIME_BY_EXT[ext]
+ if (!encoding) throw new Error(`${item.name}: unsupported image extension ${ext}`)
+
+ const { width, height } = imageMeta(bytes)
+ if (width && height) aspectRatio = { width, height }
+
+ if (args['dry-run']) {
+ console.log(` upload (dry run) ${item.image} (${bytes.byteLength} bytes)`)
+ }
+ else {
+ const uploaded = await client.uploadBlob(bytes, { encoding })
+ image = uploaded.body.blob
+ }
+ }
+
+ if (args['dry-run']) {
+ console.log(` create (dry run) project ${item.name}`)
+ continue
+ }
+
+ const links = [
+ ...(item.url ? [{ uri: asStringFormat(item.url, 'uri'), role: defs.linkRoleWebsite.value }] : []),
+ ...(item.repo && item.repo !== item.url
+ ? [{ uri: asStringFormat(item.repo, 'uri'), role: defs.linkRoleSourceCode.value }]
+ : []),
+ ]
+
+ const res = await client.create(dev.roe.project.main, {
+ category: { uri: asStringFormat(categoryEntry.uri, 'at-uri'), cid: categoryEntry.cid },
+ name: item.name,
+ ...(item.description ? { description: item.description } : {}),
+ ...(links.length ? { links } : {}),
+ ...(image
+ ? {
+ images: [{
+ purpose: defs.purposeScreenshot.value,
+ image,
+ alt: `Screenshot of ${item.name}`,
+ ...(aspectRatio ? { aspectRatio } : {}),
+ }],
+ }
+ : {}),
+ ...(item.archived ? { status: defs.unmaintained.value } : {}),
+ ...(item.icon ? { icon: item.icon } : {}),
+ order: item.order ?? 100,
+ createdAt,
+ }, { repo: did, validateRequest: true })
+
+ manifest[itemKey] = { uri: res.uri, cid: res.cid }
+ saveManifest(manifest)
+ console.log(`🟢 created project ${item.name}${image ? ' (+ image)' : ''}`)
+ }
+ }
+ },
+})
+
+runMain(main)
diff --git a/server/api/admin/project-categories/[rkey].delete.ts b/server/api/admin/project-categories/[rkey].delete.ts
new file mode 100644
index 00000000..b663fc1c
--- /dev/null
+++ b/server/api/admin/project-categories/[rkey].delete.ts
@@ -0,0 +1,6 @@
+import { deleteAdminRecord } from '../../../utils/admin/crud'
+import { dev } from '#shared/lex'
+
+export default defineEventHandler(event => {
+ return deleteAdminRecord(event, dev.roe.projectCategory.main, getRouterParam(event, 'rkey'))
+})
diff --git a/server/api/admin/project-categories/[rkey].get.ts b/server/api/admin/project-categories/[rkey].get.ts
new file mode 100644
index 00000000..58f9a47a
--- /dev/null
+++ b/server/api/admin/project-categories/[rkey].get.ts
@@ -0,0 +1,6 @@
+import { getAdminRecord } from '../../../utils/admin/crud'
+import { dev } from '#shared/lex'
+
+export default defineEventHandler(event => {
+ return getAdminRecord(event, dev.roe.projectCategory.main, getRouterParam(event, 'rkey'))
+})
diff --git a/server/api/admin/project-categories/[rkey].put.ts b/server/api/admin/project-categories/[rkey].put.ts
new file mode 100644
index 00000000..9fcf50bd
--- /dev/null
+++ b/server/api/admin/project-categories/[rkey].put.ts
@@ -0,0 +1,7 @@
+import { updateAdminRecord } from '../../../utils/admin/crud'
+import { dev } from '#shared/lex'
+
+export default defineEventHandler(async event => {
+ const body = await readBody>(event)
+ return updateAdminRecord(event, dev.roe.projectCategory.main, getRouterParam(event, 'rkey'), body)
+})
diff --git a/server/api/admin/project-categories/index.get.ts b/server/api/admin/project-categories/index.get.ts
new file mode 100644
index 00000000..789dcd92
--- /dev/null
+++ b/server/api/admin/project-categories/index.get.ts
@@ -0,0 +1,8 @@
+import { listAdminRecords } from '../../../utils/admin/crud'
+import { dev } from '#shared/lex'
+
+export default defineEventHandler(event => {
+ return listAdminRecords(event, dev.roe.projectCategory.main, {
+ sortBy: r => r.value.order ?? 100,
+ })
+})
diff --git a/server/api/admin/project-categories/index.post.ts b/server/api/admin/project-categories/index.post.ts
new file mode 100644
index 00000000..57e56b74
--- /dev/null
+++ b/server/api/admin/project-categories/index.post.ts
@@ -0,0 +1,7 @@
+import { createAdminRecord } from '../../../utils/admin/crud'
+import { dev } from '#shared/lex'
+
+export default defineEventHandler(async event => {
+ const body = await readBody>(event)
+ return createAdminRecord(event, dev.roe.projectCategory.main, body)
+})
diff --git a/server/api/admin/projects/[rkey].delete.ts b/server/api/admin/projects/[rkey].delete.ts
new file mode 100644
index 00000000..37f13fc0
--- /dev/null
+++ b/server/api/admin/projects/[rkey].delete.ts
@@ -0,0 +1,6 @@
+import { deleteAdminRecord } from '../../../utils/admin/crud'
+import { dev } from '#shared/lex'
+
+export default defineEventHandler(event => {
+ return deleteAdminRecord(event, dev.roe.project.main, getRouterParam(event, 'rkey'))
+})
diff --git a/server/api/admin/projects/[rkey].get.ts b/server/api/admin/projects/[rkey].get.ts
new file mode 100644
index 00000000..8df66653
--- /dev/null
+++ b/server/api/admin/projects/[rkey].get.ts
@@ -0,0 +1,6 @@
+import { getAdminRecord } from '../../../utils/admin/crud'
+import { dev } from '#shared/lex'
+
+export default defineEventHandler(event => {
+ return getAdminRecord(event, dev.roe.project.main, getRouterParam(event, 'rkey'))
+})
diff --git a/server/api/admin/projects/[rkey].put.ts b/server/api/admin/projects/[rkey].put.ts
new file mode 100644
index 00000000..0a2a2207
--- /dev/null
+++ b/server/api/admin/projects/[rkey].put.ts
@@ -0,0 +1,7 @@
+import { updateAdminRecord } from '../../../utils/admin/crud'
+import { dev } from '#shared/lex'
+
+export default defineEventHandler(async event => {
+ const body = await readBody>(event)
+ return updateAdminRecord(event, dev.roe.project.main, getRouterParam(event, 'rkey'), body)
+})
diff --git a/server/api/admin/projects/index.get.ts b/server/api/admin/projects/index.get.ts
new file mode 100644
index 00000000..5e5e2dd1
--- /dev/null
+++ b/server/api/admin/projects/index.get.ts
@@ -0,0 +1,8 @@
+import { listAdminRecords } from '../../../utils/admin/crud'
+import { dev } from '#shared/lex'
+
+export default defineEventHandler(event => {
+ return listAdminRecords(event, dev.roe.project.main, {
+ sortBy: r => r.value.order ?? 100,
+ })
+})
diff --git a/server/api/admin/projects/index.post.ts b/server/api/admin/projects/index.post.ts
new file mode 100644
index 00000000..751b744f
--- /dev/null
+++ b/server/api/admin/projects/index.post.ts
@@ -0,0 +1,7 @@
+import { createAdminRecord } from '../../../utils/admin/crud'
+import { dev } from '#shared/lex'
+
+export default defineEventHandler(async event => {
+ const body = await readBody>(event)
+ return createAdminRecord(event, dev.roe.project.main, body)
+})
diff --git a/server/api/admin/stats.get.ts b/server/api/admin/stats.get.ts
index 925eb5bf..54118145 100644
--- a/server/api/admin/stats.get.ts
+++ b/server/api/admin/stats.get.ts
@@ -12,11 +12,13 @@ export default defineEventHandler(async event => {
return total
}
- const [talks, talkGroups, usesCategories, usesItems] = await Promise.all([
+ const [talks, talkGroups, usesCategories, usesItems, projectCategories, projects] = await Promise.all([
count(dev.roe.talk.main),
count(dev.roe.talkGroup.main),
count(dev.roe.usesCategory.main),
count(dev.roe.usesItem.main),
+ count(dev.roe.projectCategory.main),
+ count(dev.roe.project.main),
])
// Location is a singleton; check by attempting to fetch `self`.
@@ -27,5 +29,5 @@ export default defineEventHandler(async event => {
}
catch { /* missing record → false */ }
- return { talks, talkGroups, usesCategories, usesItems, hasLocation }
+ return { talks, talkGroups, usesCategories, usesItems, projectCategories, projects, hasLocation }
})
diff --git a/server/api/projects.get.ts b/server/api/projects.get.ts
new file mode 100644
index 00000000..f6b5e7e8
--- /dev/null
+++ b/server/api/projects.get.ts
@@ -0,0 +1,5 @@
+import { getProjects } from '../utils/cms/projects'
+
+export default defineEventHandler(async event => {
+ return getProjects(event)
+})
diff --git a/server/routes/projects.md.get.ts b/server/routes/projects.md.get.ts
index 65f56ed4..299aefb2 100644
--- a/server/routes/projects.md.get.ts
+++ b/server/routes/projects.md.get.ts
@@ -1,14 +1,21 @@
-import { projects } from '#projects.json'
import { pageMeta } from '#md-page-meta.json'
-export default defineEventHandler(() => {
+import { getProjects } from '../utils/cms/projects'
+
+export default defineEventHandler(async event => {
+ if (import.meta.test) {
+ return mdResponse('')
+ }
+
+ const categories = await getProjects(event)
+
const lines = [
mdFrontmatter('/projects', pageMeta['/projects']!),
'',
]
- for (const category of projects) {
- lines.push(`## ${category.category}`)
+ for (const category of categories) {
+ lines.push(`## ${category.title}`)
lines.push('')
for (const item of category.items) {
diff --git a/server/utils/admin/oauth.ts b/server/utils/admin/oauth.ts
index bea40e87..0304f998 100644
--- a/server/utils/admin/oauth.ts
+++ b/server/utils/admin/oauth.ts
@@ -24,6 +24,8 @@ const SCOPES = [
'repo:dev.roe.entity',
'repo:dev.roe.invite',
'repo:dev.roe.location',
+ 'repo:dev.roe.project',
+ 'repo:dev.roe.projectCategory',
'repo:dev.roe.talk',
'repo:dev.roe.talkGroup',
'repo:dev.roe.usesCategory',
diff --git a/server/utils/cms/projects.ts b/server/utils/cms/projects.ts
new file mode 100644
index 00000000..cc658572
--- /dev/null
+++ b/server/utils/cms/projects.ts
@@ -0,0 +1,97 @@
+import type { H3Event } from 'h3'
+
+import { listRecords, blobImage } from '../atproto'
+import { community, dev } from '#shared/lex'
+import type { Project, ProjectCategory } from '#shared/cms/projects'
+
+export type { Project, ProjectCategory } from '#shared/cms/projects'
+
+const { defs } = community.lexicon.app
+
+const ARCHIVED_STATUSES = new Set([
+ defs.unmaintained.value,
+ defs.discontinued.value,
+])
+
+/**
+ * Fetch all project categories with their projects, joined by strong-ref, each
+ * sorted by `order`. Projects whose parent category isn't in the repo are
+ * dropped (defence against orphans if a category was deleted without cascading
+ * to its projects).
+ */
+export async function getProjects (event: H3Event): Promise {
+ const [categories, projects] = await Promise.all([
+ listRecords(event, dev.roe.projectCategory.main),
+ listRecords(event, dev.roe.project.main),
+ ])
+
+ const projectsByCategoryUri = new Map()
+ for (const project of projects) {
+ const parentUri = project.value.category?.uri
+ if (!parentUri) continue
+ const bucket = projectsByCategoryUri.get(parentUri) ?? []
+ bucket.push(project)
+ projectsByCategoryUri.set(parentUri, bucket)
+ }
+
+ const mapped = await Promise.all(categories.map(async cat => {
+ const bucket = projectsByCategoryUri.get(cat.uri) ?? []
+ const items: Project[] = await Promise.all(bucket.map(async it => {
+ const { $type, category, links, images, status, createdAt, ...passthrough } = it.value
+ return {
+ ...passthrough,
+ url: linkFor(links, defs.linkRoleWebsite.value) ?? linkFor(links) ?? null,
+ repo: linkFor(links, defs.linkRoleSourceCode.value) ?? null,
+ archived: !!status && ARCHIVED_STATUSES.has(status),
+ image: await projectImage(event, images),
+ }
+ }))
+ items.sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
+
+ const { $type, createdAt, ...passthrough } = cat.value
+ return {
+ ...passthrough,
+ _id: rkeyFromUri(cat.uri),
+ items,
+ }
+ }))
+
+ return mapped.sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
+}
+
+type Links = dev.roe.project.Main['links']
+type Images = dev.roe.project.Main['images']
+
+/** The first link with `role`, or the first link of any role when `role` is omitted. */
+function linkFor (links: Links, role?: string): string | null {
+ const link = role ? links?.find(l => l.role === role) : links?.[0]
+ return link?.uri ?? null
+}
+
+/**
+ * The screenshot the card renders, preferring an explicit `purposeScreenshot`.
+ * `community.lexicon.app.defs#image` allows either an uploaded blob or a
+ * remote `uri`; we render whichever the record carries.
+ */
+async function projectImage (event: H3Event, images: Images): Promise {
+ const image = images?.find(i => i.purpose === defs.purposeScreenshot.value) ?? images?.[0]
+ if (!image) return 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/projects.ts b/shared/cms/projects.ts
new file mode 100644
index 00000000..ed83dbfe
--- /dev/null
+++ b/shared/cms/projects.ts
@@ -0,0 +1,25 @@
+import type { dev } from '../lex/index.ts'
+import type { Strict } from './strict.ts'
+
+/**
+ * The view model the /projects page renders. Records store
+ * `community.lexicon.app.defs` links and images; the read path picks the ones
+ * the page uses (site link, source link, screenshot) so the page doesn't have
+ * to know about link roles and image purposes.
+ */
+export type Project = Omit, '$type' | 'category' | 'links' | 'images' | 'status' | 'createdAt'> & {
+ url: string | null
+ repo: string | null
+ archived: boolean
+ image: {
+ url: string
+ alt: string
+ width: number | null
+ height: number | null
+ } | null
+}
+
+export type ProjectCategory = Omit, '$type' | 'createdAt'> & {
+ _id: string
+ items: Project[]
+}
diff --git a/shared/lex/community.ts b/shared/lex/community.ts
new file mode 100644
index 00000000..f86015c5
--- /dev/null
+++ b/shared/lex/community.ts
@@ -0,0 +1,5 @@
+/*
+ * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT.
+ */
+
+export * as lexicon from './community/lexicon.ts'
diff --git a/shared/lex/community/lexicon.ts b/shared/lex/community/lexicon.ts
new file mode 100644
index 00000000..d027c156
--- /dev/null
+++ b/shared/lex/community/lexicon.ts
@@ -0,0 +1,5 @@
+/*
+ * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT.
+ */
+
+export * as app from './lexicon/app.ts'
diff --git a/shared/lex/community/lexicon/app.ts b/shared/lex/community/lexicon/app.ts
new file mode 100644
index 00000000..df02b815
--- /dev/null
+++ b/shared/lex/community/lexicon/app.ts
@@ -0,0 +1,5 @@
+/*
+ * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT.
+ */
+
+export * as defs from './app/defs.ts'
diff --git a/shared/lex/community/lexicon/app/defs.defs.ts b/shared/lex/community/lexicon/app/defs.defs.ts
new file mode 100644
index 00000000..fd0cd335
--- /dev/null
+++ b/shared/lex/community/lexicon/app/defs.defs.ts
@@ -0,0 +1,621 @@
+/*
+ * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT.
+ */
+
+import { l } from '@atproto/lex'
+
+const $nsid = 'community.lexicon.app.defs'
+
+type $nsid = typeof $nsid
+
+export { $nsid }
+
+/** A labeled URI associated with an app. */
+type Link = {
+ $type?: 'community.lexicon.app.defs#link'
+
+ /**
+ * Destination URI.
+ */
+ uri: l.UriString
+
+ /**
+ * Human-readable label for the URI.
+ */
+ label?: string
+
+ /**
+ * Known role of this link, if any.
+ */
+ role?: LinkRole
+}
+
+export type { Link }
+
+/** A labeled URI associated with an app. */
+const link = /*#__PURE__*/ l.typedObject(
+ $nsid,
+ 'link',
+ /*#__PURE__*/ l.object({
+ uri: /*#__PURE__*/ l.string({ format: 'uri' }),
+ label: /*#__PURE__*/ l.optional(
+ /*#__PURE__*/ l.string({ maxLength: 100, maxGraphemes: 50 }),
+ ),
+ role: /*#__PURE__*/ l.optional(
+ /*#__PURE__*/ l.ref((() => linkRole) as any),
+ ),
+ }),
+)
+
+export { link }
+
+/** Known role of a link associated with an app. */
+type LinkRole =
+ | 'community.lexicon.app.defs#linkRoleWebsite'
+ | 'community.lexicon.app.defs#linkRoleWebManifest'
+ | 'community.lexicon.app.defs#linkRolePrivacyPolicy'
+ | 'community.lexicon.app.defs#linkRoleTermsOfService'
+ | 'community.lexicon.app.defs#linkRoleSupport'
+ | 'community.lexicon.app.defs#linkRoleSourceCode'
+ | 'community.lexicon.app.defs#linkRoleDocs'
+ | 'community.lexicon.app.defs#linkRoleChangelog'
+ | 'community.lexicon.app.defs#linkRoleStatus'
+ | 'community.lexicon.app.defs#linkRoleAppStore'
+ | 'community.lexicon.app.defs#linkRolePlayStore'
+ | 'community.lexicon.app.defs#linkRoleFDroid'
+ | l.UnknownString
+
+export type { LinkRole }
+
+/** Known role of a link associated with an app. */
+const linkRole = /*#__PURE__*/ l.string<{
+ knownValues: [
+ 'community.lexicon.app.defs#linkRoleWebsite',
+ 'community.lexicon.app.defs#linkRoleWebManifest',
+ 'community.lexicon.app.defs#linkRolePrivacyPolicy',
+ 'community.lexicon.app.defs#linkRoleTermsOfService',
+ 'community.lexicon.app.defs#linkRoleSupport',
+ 'community.lexicon.app.defs#linkRoleSourceCode',
+ 'community.lexicon.app.defs#linkRoleDocs',
+ 'community.lexicon.app.defs#linkRoleChangelog',
+ 'community.lexicon.app.defs#linkRoleStatus',
+ 'community.lexicon.app.defs#linkRoleAppStore',
+ 'community.lexicon.app.defs#linkRolePlayStore',
+ 'community.lexicon.app.defs#linkRoleFDroid',
+ ]
+}>()
+
+export { linkRole }
+
+/** Primary website or landing page. */
+type LinkRoleWebsite = 'community.lexicon.app.defs#linkRoleWebsite'
+
+export type { LinkRoleWebsite }
+
+/** Primary website or landing page. */
+const linkRoleWebsite = /*#__PURE__*/ l.token($nsid, 'linkRoleWebsite')
+
+export { linkRoleWebsite }
+
+/** Web App Manifest. */
+type LinkRoleWebManifest = 'community.lexicon.app.defs#linkRoleWebManifest'
+
+export type { LinkRoleWebManifest }
+
+/** Web App Manifest. */
+const linkRoleWebManifest = /*#__PURE__*/ l.token($nsid, 'linkRoleWebManifest')
+
+export { linkRoleWebManifest }
+
+/** Privacy policy. */
+type LinkRolePrivacyPolicy = 'community.lexicon.app.defs#linkRolePrivacyPolicy'
+
+export type { LinkRolePrivacyPolicy }
+
+/** Privacy policy. */
+const linkRolePrivacyPolicy = /*#__PURE__*/ l.token(
+ $nsid,
+ 'linkRolePrivacyPolicy',
+)
+
+export { linkRolePrivacyPolicy }
+
+/** Terms of service. */
+type LinkRoleTermsOfService =
+ 'community.lexicon.app.defs#linkRoleTermsOfService'
+
+export type { LinkRoleTermsOfService }
+
+/** Terms of service. */
+const linkRoleTermsOfService = /*#__PURE__*/ l.token(
+ $nsid,
+ 'linkRoleTermsOfService',
+)
+
+export { linkRoleTermsOfService }
+
+/** Support, help, or contact page. */
+type LinkRoleSupport = 'community.lexicon.app.defs#linkRoleSupport'
+
+export type { LinkRoleSupport }
+
+/** Support, help, or contact page. */
+const linkRoleSupport = /*#__PURE__*/ l.token($nsid, 'linkRoleSupport')
+
+export { linkRoleSupport }
+
+/** Source code repository or source distribution. */
+type LinkRoleSourceCode = 'community.lexicon.app.defs#linkRoleSourceCode'
+
+export type { LinkRoleSourceCode }
+
+/** Source code repository or source distribution. */
+const linkRoleSourceCode = /*#__PURE__*/ l.token($nsid, 'linkRoleSourceCode')
+
+export { linkRoleSourceCode }
+
+/** Documentation. */
+type LinkRoleDocs = 'community.lexicon.app.defs#linkRoleDocs'
+
+export type { LinkRoleDocs }
+
+/** Documentation. */
+const linkRoleDocs = /*#__PURE__*/ l.token($nsid, 'linkRoleDocs')
+
+export { linkRoleDocs }
+
+/** Changelog or release notes. */
+type LinkRoleChangelog = 'community.lexicon.app.defs#linkRoleChangelog'
+
+export type { LinkRoleChangelog }
+
+/** Changelog or release notes. */
+const linkRoleChangelog = /*#__PURE__*/ l.token($nsid, 'linkRoleChangelog')
+
+export { linkRoleChangelog }
+
+/** Service status page. */
+type LinkRoleStatus = 'community.lexicon.app.defs#linkRoleStatus'
+
+export type { LinkRoleStatus }
+
+/** Service status page. */
+const linkRoleStatus = /*#__PURE__*/ l.token($nsid, 'linkRoleStatus')
+
+export { linkRoleStatus }
+
+/** Apple App Store listing. */
+type LinkRoleAppStore = 'community.lexicon.app.defs#linkRoleAppStore'
+
+export type { LinkRoleAppStore }
+
+/** Apple App Store listing. */
+const linkRoleAppStore = /*#__PURE__*/ l.token($nsid, 'linkRoleAppStore')
+
+export { linkRoleAppStore }
+
+/** Google Play Store listing. */
+type LinkRolePlayStore = 'community.lexicon.app.defs#linkRolePlayStore'
+
+export type { LinkRolePlayStore }
+
+/** Google Play Store listing. */
+const linkRolePlayStore = /*#__PURE__*/ l.token($nsid, 'linkRolePlayStore')
+
+export { linkRolePlayStore }
+
+/** F-Droid listing. */
+type LinkRoleFDroid = 'community.lexicon.app.defs#linkRoleFDroid'
+
+export type { LinkRoleFDroid }
+
+/** F-Droid listing. */
+const linkRoleFDroid = /*#__PURE__*/ l.token($nsid, 'linkRoleFDroid')
+
+export { linkRoleFDroid }
+
+/** An image associated with an app, including accessibility and display metadata. Exactly one of `image` (ATProto blob) or `uri` (remote URL) MUST be present. Consumers SHOULD ignore image items that have neither or both. */
+type Image = {
+ $type?: 'community.lexicon.app.defs#image'
+
+ /**
+ * How directories and stores should use the image. Omit this field when no known purpose fits.
+ */
+ purpose?:
+ | 'community.lexicon.app.defs#purposeIcon'
+ | 'community.lexicon.app.defs#purposeLogo'
+ | 'community.lexicon.app.defs#purposeHero'
+ | 'community.lexicon.app.defs#purposeScreenshot'
+ | 'community.lexicon.app.defs#purposeBanner'
+ | 'community.lexicon.app.defs#purposeSocialCard'
+ | 'community.lexicon.app.defs#purposeAppStore'
+ | 'community.lexicon.app.defs#purposeAd'
+ | l.UnknownString
+
+ /**
+ * The raw image file.
+ */
+ image?: l.BlobRef
+
+ /**
+ * Remote image URI.
+ */
+ uri?: l.UriString
+
+ /**
+ * Alt text description of the image, for accessibility.
+ */
+ alt: string
+ aspectRatio?: AspectRatio
+}
+
+export type { Image }
+
+/** An image associated with an app, including accessibility and display metadata. Exactly one of `image` (ATProto blob) or `uri` (remote URL) MUST be present. Consumers SHOULD ignore image items that have neither or both. */
+const image = /*#__PURE__*/ l.typedObject(
+ $nsid,
+ 'image',
+ /*#__PURE__*/ l.object({
+ purpose: /*#__PURE__*/ l.optional(
+ /*#__PURE__*/ l.string<{
+ knownValues: [
+ 'community.lexicon.app.defs#purposeIcon',
+ 'community.lexicon.app.defs#purposeLogo',
+ 'community.lexicon.app.defs#purposeHero',
+ 'community.lexicon.app.defs#purposeScreenshot',
+ 'community.lexicon.app.defs#purposeBanner',
+ 'community.lexicon.app.defs#purposeSocialCard',
+ 'community.lexicon.app.defs#purposeAppStore',
+ 'community.lexicon.app.defs#purposeAd',
+ ]
+ }>(),
+ ),
+ image: /*#__PURE__*/ l.optional(
+ /*#__PURE__*/ l.blob({ accept: ['image/*'], maxSize: 2000000 }),
+ ),
+ uri: /*#__PURE__*/ l.optional(/*#__PURE__*/ l.string({ format: 'uri' })),
+ alt: /*#__PURE__*/ l.string({ maxLength: 1000, maxGraphemes: 300 }),
+ aspectRatio: /*#__PURE__*/ l.optional(
+ /*#__PURE__*/ l.ref((() => aspectRatio) as any),
+ ),
+ }),
+)
+
+export { image }
+
+/** width:height represents an aspect ratio. It may be approximate. */
+type AspectRatio = {
+ $type?: 'community.lexicon.app.defs#aspectRatio'
+ width: number
+ height: number
+}
+
+export type { AspectRatio }
+
+/** width:height represents an aspect ratio. It may be approximate. */
+const aspectRatio = /*#__PURE__*/ l.typedObject(
+ $nsid,
+ 'aspectRatio',
+ /*#__PURE__*/ l.object({
+ width: /*#__PURE__*/ l.integer({ minimum: 1 }),
+ height: /*#__PURE__*/ l.integer({ minimum: 1 }),
+ }),
+)
+
+export { aspectRatio }
+
+/** A small square icon representing the app, typically used in launchers, lists, and tab bars. */
+type PurposeIcon = 'community.lexicon.app.defs#purposeIcon'
+
+export type { PurposeIcon }
+
+/** A small square icon representing the app, typically used in launchers, lists, and tab bars. */
+const purposeIcon = /*#__PURE__*/ l.token($nsid, 'purposeIcon')
+
+export { purposeIcon }
+
+/** A logotype or wordmark for the app. */
+type PurposeLogo = 'community.lexicon.app.defs#purposeLogo'
+
+export type { PurposeLogo }
+
+/** A logotype or wordmark for the app. */
+const purposeLogo = /*#__PURE__*/ l.token($nsid, 'purposeLogo')
+
+export { purposeLogo }
+
+/** A large promotional or feature image, typically used at the top of a directory listing. */
+type PurposeHero = 'community.lexicon.app.defs#purposeHero'
+
+export type { PurposeHero }
+
+/** A large promotional or feature image, typically used at the top of a directory listing. */
+const purposeHero = /*#__PURE__*/ l.token($nsid, 'purposeHero')
+
+export { purposeHero }
+
+/** A screenshot of the app's UI, used in directory and store listings. */
+type PurposeScreenshot = 'community.lexicon.app.defs#purposeScreenshot'
+
+export type { PurposeScreenshot }
+
+/** A screenshot of the app's UI, used in directory and store listings. */
+const purposeScreenshot = /*#__PURE__*/ l.token($nsid, 'purposeScreenshot')
+
+export { purposeScreenshot }
+
+/** A wide banner image, such as a header image for a profile or listing page. */
+type PurposeBanner = 'community.lexicon.app.defs#purposeBanner'
+
+export type { PurposeBanner }
+
+/** A wide banner image, such as a header image for a profile or listing page. */
+const purposeBanner = /*#__PURE__*/ l.token($nsid, 'purposeBanner')
+
+export { purposeBanner }
+
+/** An image sized and formatted for social media sharing previews (Open Graph, Twitter Card, etc.). */
+type PurposeSocialCard = 'community.lexicon.app.defs#purposeSocialCard'
+
+export type { PurposeSocialCard }
+
+/** An image sized and formatted for social media sharing previews (Open Graph, Twitter Card, etc.). */
+const purposeSocialCard = /*#__PURE__*/ l.token($nsid, 'purposeSocialCard')
+
+export { purposeSocialCard }
+
+/** A promotional image formatted for a native app store listing. */
+type PurposeAppStore = 'community.lexicon.app.defs#purposeAppStore'
+
+export type { PurposeAppStore }
+
+/** A promotional image formatted for a native app store listing. */
+const purposeAppStore = /*#__PURE__*/ l.token($nsid, 'purposeAppStore')
+
+export { purposeAppStore }
+
+/** A promotional or advertising image. */
+type PurposeAd = 'community.lexicon.app.defs#purposeAd'
+
+export type { PurposeAd }
+
+/** A promotional or advertising image. */
+const purposeAd = /*#__PURE__*/ l.token($nsid, 'purposeAd')
+
+export { purposeAd }
+
+/** Current release or maintenance status of an app. */
+type Status =
+ | 'community.lexicon.app.defs#unreleased'
+ | 'community.lexicon.app.defs#preview'
+ | 'community.lexicon.app.defs#released'
+ | 'community.lexicon.app.defs#unmaintained'
+ | 'community.lexicon.app.defs#discontinued'
+ | l.UnknownString
+
+export type { Status }
+
+/** Current release or maintenance status of an app. */
+const status = /*#__PURE__*/ l.string<{
+ knownValues: [
+ 'community.lexicon.app.defs#unreleased',
+ 'community.lexicon.app.defs#preview',
+ 'community.lexicon.app.defs#released',
+ 'community.lexicon.app.defs#unmaintained',
+ 'community.lexicon.app.defs#discontinued',
+ ]
+}>()
+
+export { status }
+
+/** The app has been announced but is not yet available. */
+type Unreleased = 'community.lexicon.app.defs#unreleased'
+
+export type { Unreleased }
+
+/** The app has been announced but is not yet available. */
+const unreleased = /*#__PURE__*/ l.token($nsid, 'unreleased')
+
+export { unreleased }
+
+/** The app is available as an alpha, beta, early access, or preview release. */
+type Preview = 'community.lexicon.app.defs#preview'
+
+export type { Preview }
+
+/** The app is available as an alpha, beta, early access, or preview release. */
+const preview = /*#__PURE__*/ l.token($nsid, 'preview')
+
+export { preview }
+
+/** The app is generally available. */
+type Released = 'community.lexicon.app.defs#released'
+
+export type { Released }
+
+/** The app is generally available. */
+const released = /*#__PURE__*/ l.token($nsid, 'released')
+
+export { released }
+
+/** The app may still be available, but is no longer actively maintained. */
+type Unmaintained = 'community.lexicon.app.defs#unmaintained'
+
+export type { Unmaintained }
+
+/** The app may still be available, but is no longer actively maintained. */
+const unmaintained = /*#__PURE__*/ l.token($nsid, 'unmaintained')
+
+export { unmaintained }
+
+/** The app is no longer available or supported. */
+type Discontinued = 'community.lexicon.app.defs#discontinued'
+
+export type { Discontinued }
+
+/** The app is no longer available or supported. */
+const discontinued = /*#__PURE__*/ l.token($nsid, 'discontinued')
+
+export { discontinued }
+
+/** Platform where an app is available. */
+type Platform =
+ | 'community.lexicon.app.defs#platformWeb'
+ | 'community.lexicon.app.defs#platformIOS'
+ | 'community.lexicon.app.defs#platformAndroid'
+ | 'community.lexicon.app.defs#platformMacOS'
+ | 'community.lexicon.app.defs#platformWindows'
+ | 'community.lexicon.app.defs#platformLinux'
+ | 'community.lexicon.app.defs#platformCLI'
+ | l.UnknownString
+
+export type { Platform }
+
+/** Platform where an app is available. */
+const platform = /*#__PURE__*/ l.string<{
+ knownValues: [
+ 'community.lexicon.app.defs#platformWeb',
+ 'community.lexicon.app.defs#platformIOS',
+ 'community.lexicon.app.defs#platformAndroid',
+ 'community.lexicon.app.defs#platformMacOS',
+ 'community.lexicon.app.defs#platformWindows',
+ 'community.lexicon.app.defs#platformLinux',
+ 'community.lexicon.app.defs#platformCLI',
+ ]
+}>()
+
+export { platform }
+
+/** Web app or website. */
+type PlatformWeb = 'community.lexicon.app.defs#platformWeb'
+
+export type { PlatformWeb }
+
+/** Web app or website. */
+const platformWeb = /*#__PURE__*/ l.token($nsid, 'platformWeb')
+
+export { platformWeb }
+
+/** iOS app. */
+type PlatformIOS = 'community.lexicon.app.defs#platformIOS'
+
+export type { PlatformIOS as PlatformIos }
+
+/** iOS app. */
+const platformIOS = /*#__PURE__*/ l.token($nsid, 'platformIOS')
+
+export { platformIOS }
+
+/** Android app. */
+type PlatformAndroid = 'community.lexicon.app.defs#platformAndroid'
+
+export type { PlatformAndroid }
+
+/** Android app. */
+const platformAndroid = /*#__PURE__*/ l.token($nsid, 'platformAndroid')
+
+export { platformAndroid }
+
+/** macOS app. */
+type PlatformMacOS = 'community.lexicon.app.defs#platformMacOS'
+
+export type { PlatformMacOS as PlatformMacOs }
+
+/** macOS app. */
+const platformMacOS = /*#__PURE__*/ l.token($nsid, 'platformMacOS')
+
+export { platformMacOS }
+
+/** Windows app. */
+type PlatformWindows = 'community.lexicon.app.defs#platformWindows'
+
+export type { PlatformWindows }
+
+/** Windows app. */
+const platformWindows = /*#__PURE__*/ l.token($nsid, 'platformWindows')
+
+export { platformWindows }
+
+/** Linux app. */
+type PlatformLinux = 'community.lexicon.app.defs#platformLinux'
+
+export type { PlatformLinux }
+
+/** Linux app. */
+const platformLinux = /*#__PURE__*/ l.token($nsid, 'platformLinux')
+
+export { platformLinux }
+
+/** Command line interface. */
+type PlatformCLI = 'community.lexicon.app.defs#platformCLI'
+
+export type { PlatformCLI as PlatformCli }
+
+/** Command line interface. */
+const platformCLI = /*#__PURE__*/ l.token($nsid, 'platformCLI')
+
+export { platformCLI }
+
+/** Self-declared AT Protocol lexicon interoperability signals. */
+type LexiconInterop = {
+ $type?: 'community.lexicon.app.defs#lexiconInterop'
+
+ /**
+ * Lexicon collections this app creates or publishes records for.
+ */
+ produces?: l.NsidString[]
+
+ /**
+ * Lexicon collections this app reads, displays, imports, or otherwise consumes.
+ */
+ consumes?: l.NsidString[]
+}
+
+export type { LexiconInterop }
+
+/** Self-declared AT Protocol lexicon interoperability signals. */
+const lexiconInterop = /*#__PURE__*/ l.typedObject(
+ $nsid,
+ 'lexiconInterop',
+ /*#__PURE__*/ l.object({
+ produces: /*#__PURE__*/ l.optional(
+ /*#__PURE__*/ l.array(/*#__PURE__*/ l.string({ format: 'nsid' })),
+ ),
+ consumes: /*#__PURE__*/ l.optional(
+ /*#__PURE__*/ l.array(/*#__PURE__*/ l.string({ format: 'nsid' })),
+ ),
+ }),
+)
+
+export { lexiconInterop }
+
+/** A record whose presence in an account can indicate that the account probably uses this app. */
+type AccountIndicator = {
+ $type?: 'community.lexicon.app.defs#accountIndicator'
+
+ /**
+ * Record collection to look for in an account repository.
+ */
+ collection: l.NsidString
+
+ /**
+ * Optional record key to look for within the collection.
+ */
+ rkey?: l.RecordKeyString
+}
+
+export type { AccountIndicator }
+
+/** A record whose presence in an account can indicate that the account probably uses this app. */
+const accountIndicator = /*#__PURE__*/ l.typedObject(
+ $nsid,
+ 'accountIndicator',
+ /*#__PURE__*/ l.object({
+ collection: /*#__PURE__*/ l.string({ format: 'nsid' }),
+ rkey: /*#__PURE__*/ l.optional(
+ /*#__PURE__*/ l.string({ format: 'record-key' }),
+ ),
+ }),
+)
+
+export { accountIndicator }
diff --git a/shared/lex/community/lexicon/app/defs.ts b/shared/lex/community/lexicon/app/defs.ts
new file mode 100644
index 00000000..f3af51b8
--- /dev/null
+++ b/shared/lex/community/lexicon/app/defs.ts
@@ -0,0 +1,5 @@
+/*
+ * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT.
+ */
+
+export * from './defs.defs.ts'
diff --git a/shared/lex/dev/roe.ts b/shared/lex/dev/roe.ts
index 388732b6..978eac23 100644
--- a/shared/lex/dev/roe.ts
+++ b/shared/lex/dev/roe.ts
@@ -6,6 +6,8 @@ export * as ama from './roe/ama.ts'
export * as entity from './roe/entity.ts'
export * as invite from './roe/invite.ts'
export * as location from './roe/location.ts'
+export * as project from './roe/project.ts'
+export * as projectCategory from './roe/projectCategory.ts'
export * as sync from './roe/sync.ts'
export * as talk from './roe/talk.ts'
export * as talkGroup from './roe/talkGroup.ts'
diff --git a/shared/lex/dev/roe/project.defs.ts b/shared/lex/dev/roe/project.defs.ts
new file mode 100644
index 00000000..85556572
--- /dev/null
+++ b/shared/lex/dev/roe/project.defs.ts
@@ -0,0 +1,110 @@
+/*
+ * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT.
+ */
+
+import { l } from '@atproto/lex'
+import * as RepoStrongRef from '../../com/atproto/repo/strongRef.defs.ts'
+import * as AppDefs from '../../community/lexicon/app/defs.defs.ts'
+
+const $nsid = 'dev.roe.project'
+
+type $nsid = typeof $nsid
+
+export { $nsid }
+
+/** A project on the /projects page, belonging to a dev.roe.projectCategory. */
+type Main = {
+ $type: 'dev.roe.project'
+
+ /**
+ * Strong-ref to the parent dev.roe.projectCategory record.
+ */
+ category: RepoStrongRef.Main
+ name: string
+ description?: string
+
+ /**
+ * Relevant destinations for the project. The site links the card at the first `linkRoleWebsite`, falling back to the first link of any role.
+ */
+ links?: AppDefs.Link[]
+
+ /**
+ * Visual assets for the project. The site renders the first `purposeScreenshot`, falling back to the first image of any purpose.
+ */
+ images?: AppDefs.Image[]
+
+ /**
+ * Release or maintenance status. `unmaintained` and `discontinued` render the card as archived.
+ */
+ status?: AppDefs.Status
+
+ /**
+ * UnoCSS icon class, e.g. `i-ri:rocket-line`. Used when there is no image.
+ */
+ icon?: string
+
+ /**
+ * Lower values render first within the category. Defaults to 100 in the editor.
+ */
+ order?: number
+ createdAt: l.DatetimeString
+}
+
+export type { Main }
+
+/** A project on the /projects page, belonging to a dev.roe.projectCategory. */
+const main = /*#__PURE__*/ l.record<'tid', Main>(
+ 'tid',
+ $nsid,
+ /*#__PURE__*/ l.object({
+ category: /*#__PURE__*/ l.ref(
+ (() => RepoStrongRef.main) as any,
+ ),
+ name: /*#__PURE__*/ l.string({ maxLength: 2560, maxGraphemes: 256 }),
+ description: /*#__PURE__*/ l.optional(
+ /*#__PURE__*/ l.string({ maxLength: 25000, maxGraphemes: 2500 }),
+ ),
+ links: /*#__PURE__*/ l.optional(
+ /*#__PURE__*/ l.array(
+ /*#__PURE__*/ l.ref((() => AppDefs.link) as any),
+ { maxLength: 12 },
+ ),
+ ),
+ images: /*#__PURE__*/ l.optional(
+ /*#__PURE__*/ l.array(
+ /*#__PURE__*/ l.ref((() => AppDefs.image) as any),
+ { maxLength: 8 },
+ ),
+ ),
+ status: /*#__PURE__*/ l.optional(
+ /*#__PURE__*/ l.ref((() => AppDefs.status) as any),
+ ),
+ icon: /*#__PURE__*/ l.optional(
+ /*#__PURE__*/ l.string({ maxLength: 640, maxGraphemes: 64 }),
+ ),
+ order: /*#__PURE__*/ l.optional(
+ /*#__PURE__*/ l.withDefault(/*#__PURE__*/ l.integer(), 100),
+ ),
+ createdAt: /*#__PURE__*/ l.string({ format: 'datetime' }),
+ }),
+)
+
+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/dev/roe/project.ts b/shared/lex/dev/roe/project.ts
new file mode 100644
index 00000000..da441a2e
--- /dev/null
+++ b/shared/lex/dev/roe/project.ts
@@ -0,0 +1,6 @@
+/*
+ * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT.
+ */
+
+export * from './project.defs.ts'
+export { main as default } from './project.defs.ts'
diff --git a/shared/lex/dev/roe/projectCategory.defs.ts b/shared/lex/dev/roe/projectCategory.defs.ts
new file mode 100644
index 00000000..bd9205a1
--- /dev/null
+++ b/shared/lex/dev/roe/projectCategory.defs.ts
@@ -0,0 +1,58 @@
+/*
+ * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT.
+ */
+
+import { l } from '@atproto/lex'
+
+const $nsid = 'dev.roe.projectCategory'
+
+type $nsid = typeof $nsid
+
+export { $nsid }
+
+/** A category on the /projects page. Projects belong to a category via dev.roe.project.category. */
+type Main = {
+ $type: 'dev.roe.projectCategory'
+ title: string
+
+ /**
+ * Lower values render first. Defaults to 100 in the editor.
+ */
+ order?: number
+ createdAt: l.DatetimeString
+}
+
+export type { Main }
+
+/** A category on the /projects page. Projects belong to a category via dev.roe.project.category. */
+const main = /*#__PURE__*/ l.record<'tid', Main>(
+ 'tid',
+ $nsid,
+ /*#__PURE__*/ l.object({
+ title: /*#__PURE__*/ l.string({ maxLength: 2560, maxGraphemes: 256 }),
+ order: /*#__PURE__*/ l.optional(
+ /*#__PURE__*/ l.withDefault(/*#__PURE__*/ l.integer(), 100),
+ ),
+ createdAt: /*#__PURE__*/ l.string({ format: 'datetime' }),
+ }),
+)
+
+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/dev/roe/projectCategory.ts b/shared/lex/dev/roe/projectCategory.ts
new file mode 100644
index 00000000..56b35a6e
--- /dev/null
+++ b/shared/lex/dev/roe/projectCategory.ts
@@ -0,0 +1,6 @@
+/*
+ * THIS FILE WAS GENERATED BY "@atproto/lex". DO NOT EDIT.
+ */
+
+export * from './projectCategory.defs.ts'
+export { main as default } from './projectCategory.defs.ts'
diff --git a/shared/lex/index.ts b/shared/lex/index.ts
index b889d2cd..2242963d 100644
--- a/shared/lex/index.ts
+++ b/shared/lex/index.ts
@@ -3,4 +3,5 @@
*/
export * as com from './com.ts'
+export * as community from './community.ts'
export * as dev from './dev.ts'
diff --git a/shared/project-icons.ts b/shared/project-icons.ts
new file mode 100644
index 00000000..5c586251
--- /dev/null
+++ b/shared/project-icons.ts
@@ -0,0 +1,41 @@
+/**
+ * Icons offered for `dev.roe.project.icon`. Project records live on the PDS, so
+ * nothing in the source tree references these classes and UnoCSS can't discover
+ * them; `uno.config.ts` safelists this list and the admin editor offers exactly
+ * the same set. Adding an icon here is what makes it available to pick.
+ */
+export const projectIcons = [
+ 'i-ri:archive-line',
+ 'i-ri:archive-stack-line',
+ 'i-ri:arrow-up-circle-line',
+ 'i-ri:braces-line',
+ 'i-ri:bubble-chart-line',
+ 'i-ri:cpu-line',
+ 'i-ri:css3-line',
+ 'i-ri:dashboard-3-line',
+ 'i-ri:database-2-line',
+ 'i-ri:database-line',
+ 'i-ri:error-warning-line',
+ 'i-ri:exchange-line',
+ 'i-ri:file-zip-line',
+ 'i-ri:filter-off-line',
+ 'i-ri:flashlight-line',
+ 'i-ri:folder-download-line',
+ 'i-ri:font-family',
+ 'i-ri:font-size',
+ 'i-ri:keyboard-line',
+ 'i-ri:link',
+ 'i-ri:list-ordered',
+ 'i-ri:magic-line',
+ 'i-ri:reactjs-line',
+ 'i-ri:refresh-line',
+ 'i-ri:restaurant-line',
+ 'i-ri:route-line',
+ 'i-ri:server-line',
+ 'i-ri:shield-check-line',
+ 'i-ri:shield-keyhole-line',
+ 'i-ri:text-spacing',
+ 'i-ri:toggle-line',
+ 'i-ri:vidicon-line',
+ 'i-ri:walk-line',
+] as const
diff --git a/test/unit/bundle.spec.ts b/test/unit/bundle.spec.ts
index 467a6310..cdd1b2ff 100644
--- a/test/unit/bundle.spec.ts
+++ b/test/unit/bundle.spec.ts
@@ -63,7 +63,7 @@ describe('project sizes', () => {
expect
.soft(roundToKilobytes(stats.client.totalBytes))
- .toMatchInlineSnapshot(`"268k"`)
+ .toMatchInlineSnapshot(`"269k"`)
expect.soft(stats.client.files.map(f => f.replace(/\..*\.js/, '.js')).sort())
.toMatchInlineSnapshot(`
[
@@ -77,7 +77,7 @@ describe('project sizes', () => {
stats.server = await analyzeSizes(['**/*.mjs', '!node_modules'], serverDir)
expect
.soft(roundToKilobytes(stats.server.totalBytes, 10))
- .toMatchInlineSnapshot(`"1880k"`)
+ .toMatchInlineSnapshot(`"1940k"`)
const modules = await analyzeSizes('node_modules/**/*', serverDir)
const portableModules = await analyzeSizes(
diff --git a/uno.config.ts b/uno.config.ts
index 620413d4..c1f856ab 100644
--- a/uno.config.ts
+++ b/uno.config.ts
@@ -1,16 +1,18 @@
import { defineConfig, presetIcons, presetWind3 } from 'unocss'
import { createResolver } from 'nuxt/kit'
+import { projectIcons } from './shared/project-icons'
+
const resolver = createResolver(import.meta.url)
export default defineConfig({
content: {
filesystem: [
resolver.resolve('./content/**/*.md'),
- resolver.resolve('./content/projects.yml'),
resolver.resolve('./app/**/*.vue'),
],
},
+ safelist: [...projectIcons],
shortcuts: [
{
'underlined-link':