[READ-ONLY] a fast, modern browser for the npm registry

feat: add server side atproto blog apis (#841)

Co-authored-by: Brandon Hurrington <brandon.o.hurrington@gmail.com>

authored by jonathanyeong.tngl.sh

Brandon Hurrington and committed by
GitHub
180317b6 1732f154

+536 -9
+134
modules/standard-site-sync.ts
··· 1 + import { readFileSync } from 'node:fs' 2 + import { createHash } from 'node:crypto' 3 + import { defineNuxtModule, useNuxt, createResolver } from 'nuxt/kit' 4 + import { safeParse } from 'valibot' 5 + import * as site from '../shared/types/lexicons/site' 6 + import { BlogPostSchema } from '../shared/schemas/blog' 7 + import { NPMX_SITE } from '../shared/utils/constants' 8 + import { parseBasicFrontmatter } from '../shared/utils/parse-basic-frontmatter' 9 + import { TID } from '@atproto/common' 10 + import { Client } from '@atproto/lex' 11 + 12 + const syncedDocuments = new Map<string, string>() 13 + const CLOCK_ID_THREE = 3 14 + const DATE_TO_MICROSECONDS = 1000 15 + 16 + // TODO: Currently logging quite a lot, can remove some later if we want 17 + export default defineNuxtModule({ 18 + meta: { name: 'standard-site-sync' }, 19 + async setup() { 20 + const nuxt = useNuxt() 21 + const { resolve } = createResolver(import.meta.url) 22 + const contentDir = resolve('../app/pages/blog') 23 + 24 + // Authentication with PDS using an app password 25 + const pdsUrl = process.env.NPMX_PDS_URL 26 + if (!pdsUrl) { 27 + console.warn('[standard-site-sync] NPMX_PDS_URL not set, skipping sync') 28 + return 29 + } 30 + // Instantiate a single new client instance that is reused for every file 31 + const client = new Client(pdsUrl) 32 + 33 + if (nuxt.options._prepare) return 34 + 35 + nuxt.hook('build:before', async () => { 36 + const { glob } = await import('tinyglobby') 37 + const files: string[] = await glob(`${contentDir}/**/*.md`) 38 + 39 + // INFO: Arbitrarily chosen concurrency limit, can be changed if needed 40 + const concurrencyLimit = 5 41 + for (let i = 0; i < files.length; i += concurrencyLimit) { 42 + const batch = files.slice(i, i + concurrencyLimit) 43 + // Process files in parallel 44 + await Promise.all( 45 + batch.map(file => 46 + syncFile(file, NPMX_SITE, client).catch(error => 47 + console.error(`[standard-site-sync] Error in ${file}:` + error), 48 + ), 49 + ), 50 + ) 51 + } 52 + }) 53 + 54 + nuxt.hook('builder:watch', async (event, path) => { 55 + if (!path.endsWith('.md')) return 56 + 57 + // Ignore deleted files 58 + if (event === 'unlink') { 59 + console.log(`[standard-site-sync] File deleted: ${path}`) 60 + return 61 + } 62 + 63 + // Process add/change events only 64 + await syncFile(resolve(nuxt.options.rootDir, path), NPMX_SITE, client).catch(err => 65 + console.error(`[standard-site-sync] Failed ${path}:`, err), 66 + ) 67 + }) 68 + }, 69 + }) 70 + 71 + /* 72 + * INFO: Loads record to atproto and ensures uniqueness by checking the date the article is published 73 + * publishedAt is an id that does not change 74 + * Atomicity is enforced with upsert using publishedAt so we always update existing records instead of creating new ones 75 + * Clock id(3) provides a deterministic ID 76 + * WARN: DOES NOT CATCH ERRORS, THIS MUST BE HANDLED 77 + */ 78 + const syncFile = async (filePath: string, siteUrl: string, client: Client) => { 79 + const fileContent = readFileSync(filePath, 'utf-8') 80 + const frontmatter = parseBasicFrontmatter(fileContent) 81 + 82 + // Schema expects 'path' & frontmatter provides 'slug' 83 + const normalizedFrontmatter = { 84 + ...frontmatter, 85 + path: typeof frontmatter.slug === 'string' ? `/blog/${frontmatter.slug}` : frontmatter.path, 86 + } 87 + 88 + const result = safeParse(BlogPostSchema, normalizedFrontmatter) 89 + if (!result.success) { 90 + console.warn(`[standard-site-sync] Validation failed for ${filePath}`, result.issues) 91 + return 92 + } 93 + 94 + const data = result.output 95 + 96 + // filter drafts 97 + if (data.draft) { 98 + if (process.env.DEBUG === 'true') { 99 + console.debug(`[standard-site-sync] Skipping draft: ${data.path}`) 100 + } 101 + return 102 + } 103 + 104 + // Keys are sorted to provide a more stable hash 105 + const hash = createHash('sha256') 106 + .update(JSON.stringify(data, Object.keys(data).sort())) 107 + .digest('hex') 108 + 109 + if (syncedDocuments.get(data.path) === hash) { 110 + return 111 + } 112 + 113 + const document = site.standard.document.$build({ 114 + site: siteUrl as `${string}:${string}`, 115 + path: data.path, 116 + title: data.title, 117 + description: data.description ?? data.excerpt, 118 + tags: data.tags, 119 + // This can be extended to update the site.standard.document .updatedAt if it is changed and use the posts date here 120 + publishedAt: new Date(data.date).toISOString(), 121 + }) 122 + 123 + const dateInMicroSeconds = new Date(result.output.date).getTime() * DATE_TO_MICROSECONDS 124 + 125 + // Clock id(3) needs to be the same everytime to get the same TID from a timestamp 126 + const tid = TID.fromTime(dateInMicroSeconds, CLOCK_ID_THREE) 127 + 128 + // client.put is async and needs to be awaited 129 + await client.put(site.standard.document, document, { 130 + rkey: tid.str, 131 + }) 132 + 133 + syncedDocuments.set(data.path, hash) 134 + }
+4 -2
package.json
··· 25 25 "npmx-connector": "pnpm --filter npmx-connector dev", 26 26 "generate-pwa-icons": "pwa-assets-generator", 27 27 "preview": "nuxt preview", 28 - "postinstall": "nuxt prepare && simple-git-hooks && pnpm generate:lexicons", 28 + "postinstall": "pnpm generate:lexicons && nuxt prepare && simple-git-hooks", 29 29 "generate:fixtures": "node scripts/generate-fixtures.ts", 30 30 "generate:lexicons": "lex build --lexicons lexicons --out shared/types/lexicons --clear", 31 31 "test": "vite test", ··· 34 34 "test:browser:ui": "pnpm build:playwright && pnpm test:browser:prebuilt --ui", 35 35 "test:browser:update": "pnpm build:playwright && pnpm test:browser:prebuilt --update-snapshots", 36 36 "test:nuxt": "vite test --project nuxt", 37 - "test:types": "nuxt prepare && vue-tsc -b --noEmit && pnpm --filter npmx-connector test:types", 37 + "test:types": "pnpm generate:lexicons && nuxt prepare && vue-tsc -b --noEmit && pnpm --filter npmx-connector test:types", 38 38 "test:unit": "vite test --project unit", 39 39 "start:playwright:webserver": "NODE_ENV=test pnpm preview --port 5678" 40 40 }, 41 41 "dependencies": { 42 42 "@atproto/api": "^0.18.17", 43 + "@atproto/common": "0.5.10", 43 44 "@atproto/lex": "0.0.13", 44 45 "@atproto/oauth-client-node": "^0.3.15", 45 46 "@deno/doc": "jsr:^0.189.1", ··· 75 76 "defu": "6.1.4", 76 77 "fast-npm-meta": "1.0.0", 77 78 "focus-trap": "^7.8.0", 79 + "tinyglobby": "0.2.15", 78 80 "marked": "17.0.1", 79 81 "module-replacements": "2.11.0", 80 82 "nuxt": "4.3.0",
+21 -6
pnpm-lock.yaml
··· 23 23 '@atproto/api': 24 24 specifier: ^0.18.17 25 25 version: 0.18.20 26 + '@atproto/common': 27 + specifier: 0.5.10 28 + version: 0.5.10 26 29 '@atproto/lex': 27 30 specifier: 0.0.13 28 31 version: 0.0.13 ··· 164 167 std-env: 165 168 specifier: 3.10.0 166 169 version: 3.10.0 170 + tinyglobby: 171 + specifier: 0.2.15 172 + version: 0.2.15 167 173 ufo: 168 174 specifier: 1.6.3 169 175 version: 1.6.3 ··· 371 377 '@atproto/common-web@0.4.15': 372 378 resolution: {integrity: sha512-A4l9gyqUNez8CjZp/Trypz/D3WIQsNj8dN05WR6+RoBbvwc9JhWjKPrm+WoVYc/F16RPdXHLkE3BEJlGIyYIiA==} 373 379 374 - '@atproto/common@0.5.9': 375 - resolution: {integrity: sha512-rzl8dB7ErpA/VUgCidahUtbxEph50J4g7j68bZmlwwrHlrtvTe8DjrwH5EUFEcegl9dadIhcVJ3qi0kPKEUr+g==} 380 + '@atproto/common@0.5.10': 381 + resolution: {integrity: sha512-A1+4W3JmjZIgmtJFLJBAaoVruZhRL0ANtyjZ91aJR4rjHcZuaQ+v4IFR1UcE6yyTATacLdBk6ADy8OtxXzq14g==} 376 382 engines: {node: '>=18.7.0'} 377 383 378 384 '@atproto/crypto@0.4.5': ··· 393 399 394 400 '@atproto/lex-builder@0.0.12': 395 401 resolution: {integrity: sha512-ObWnmsbkPwjKKIX/L0JyMptmggr3gvbZKPDcpr1eSBUWyWeqqX8OfIlHYLgm5veNuO776RV05CE7BdQFQUA+9Q==} 402 + 403 + '@atproto/lex-cbor@0.0.10': 404 + resolution: {integrity: sha512-5RtV90iIhRNCXXvvETd3KlraV8XGAAAgOmiszUb+l8GySDU/sGk7AlVvArFfXnj/S/GXJq8DP6IaUxCw/sPASA==} 396 405 397 406 '@atproto/lex-cbor@0.0.9': 398 407 resolution: {integrity: sha512-szkS569j1eZsIxZKh2VZHVq7pSpewy1wHh8c6nVYekHfYcJhFkevQq/DjTeatZ7YZKNReGYthQulgaZq2ytfWQ==} ··· 5956 5965 glob@11.1.0: 5957 5966 resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} 5958 5967 engines: {node: 20 || >=22} 5968 + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me 5959 5969 hasBin: true 5960 5970 5961 5971 glob@13.0.0: ··· 9671 9681 '@atproto/syntax': 0.4.3 9672 9682 zod: 3.25.76 9673 9683 9674 - '@atproto/common@0.5.9': 9684 + '@atproto/common@0.5.10': 9675 9685 dependencies: 9676 9686 '@atproto/common-web': 0.4.15 9677 - '@atproto/lex-cbor': 0.0.9 9678 - '@atproto/lex-data': 0.0.9 9687 + '@atproto/lex-cbor': 0.0.10 9688 + '@atproto/lex-data': 0.0.10 9679 9689 iso-datestring-validator: 2.2.2 9680 9690 multiformats: 9.9.0 9681 9691 pino: 8.21.0 ··· 9712 9722 '@atproto/lex-schema': 0.0.10 9713 9723 prettier: 3.8.1 9714 9724 ts-morph: 27.0.2 9725 + tslib: 2.8.1 9726 + 9727 + '@atproto/lex-cbor@0.0.10': 9728 + dependencies: 9729 + '@atproto/lex-data': 0.0.10 9715 9730 tslib: 2.8.1 9716 9731 9717 9732 '@atproto/lex-cbor@0.0.9': ··· 9840 9855 9841 9856 '@atproto/repo@0.8.12': 9842 9857 dependencies: 9843 - '@atproto/common': 0.5.9 9858 + '@atproto/common': 0.5.10 9844 9859 '@atproto/common-web': 0.4.15 9845 9860 '@atproto/crypto': 0.4.5 9846 9861 '@atproto/lexicon': 0.6.1
+87
server/api/atproto/author-profiles.get.ts
··· 1 + import * as v from 'valibot' 2 + import { CACHE_MAX_AGE_ONE_DAY, BLUESKY_API } from '#shared/utils/constants' 3 + import { AuthorSchema } from '#shared/schemas/blog' 4 + import type { Author, ResolvedAuthor } from '#shared/schemas/blog' 5 + 6 + type ProfilesResponse = { 7 + profiles: Array<{ 8 + did: string 9 + handle: string 10 + displayName?: string 11 + avatar?: string 12 + }> 13 + } 14 + 15 + export default defineCachedEventHandler( 16 + async event => { 17 + const query = getQuery(event) 18 + const authorsParam = query.authors 19 + 20 + if (!authorsParam || typeof authorsParam !== 'string') { 21 + throw createError({ 22 + statusCode: 400, 23 + statusMessage: 'authors query parameter is required (JSON array)', 24 + }) 25 + } 26 + 27 + let authors: Author[] 28 + try { 29 + const parsed = JSON.parse(authorsParam) 30 + authors = v.parse(v.array(AuthorSchema), parsed) 31 + } catch (error) { 32 + if (error instanceof v.ValiError) { 33 + throw createError({ 34 + statusCode: 400, 35 + statusMessage: `Invalid authors format: ${error.message}`, 36 + }) 37 + } 38 + throw createError({ 39 + statusCode: 400, 40 + statusMessage: 'authors must be valid JSON', 41 + }) 42 + } 43 + 44 + if (!Array.isArray(authors) || authors.length === 0) { 45 + return { authors: [] } 46 + } 47 + 48 + const handles = authors.filter(a => a.blueskyHandle).map(a => a.blueskyHandle as string) 49 + 50 + if (handles.length === 0) { 51 + return { 52 + authors: authors.map(author => ({ 53 + ...author, 54 + avatar: null, 55 + profileUrl: null, 56 + })), 57 + } 58 + } 59 + 60 + const response = await $fetch<ProfilesResponse>(`${BLUESKY_API}app.bsky.actor.getProfiles`, { 61 + query: { actors: handles }, 62 + }).catch(() => ({ profiles: [] })) 63 + 64 + const avatarMap = new Map<string, string>() 65 + for (const profile of response.profiles) { 66 + if (profile.avatar) { 67 + avatarMap.set(profile.handle, profile.avatar) 68 + } 69 + } 70 + 71 + const resolvedAuthors: ResolvedAuthor[] = authors.map(author => ({ 72 + ...author, 73 + avatar: author.blueskyHandle ? avatarMap.get(author.blueskyHandle) || null : null, 74 + profileUrl: author.blueskyHandle ? `https://bsky.app/profile/${author.blueskyHandle}` : null, 75 + })) 76 + 77 + return { authors: resolvedAuthors } 78 + }, 79 + { 80 + name: 'author-profiles', 81 + maxAge: CACHE_MAX_AGE_ONE_DAY, 82 + getKey: event => { 83 + const { authors } = getQuery(event) 84 + return `author-profiles:${authors ?? 'npmx.dev'}` 85 + }, 86 + }, 87 + )
+179
server/api/atproto/bluesky-comments.get.ts
··· 1 + import { safeParse, flatten } from 'valibot' 2 + import type { Comment, CommentEmbed } from '#shared/types/blog-post' 3 + import { 4 + AppBskyFeedDefs, 5 + AppBskyFeedPost, 6 + AppBskyEmbedImages, 7 + AppBskyEmbedExternal, 8 + } from '@atproto/api' 9 + import { BlueSkyUriSchema } from '#shared/schemas/atproto' 10 + import { CACHE_MAX_AGE_ONE_MINUTE, BLUESKY_API, AT_URI_REGEX } from '#shared/utils/constants' 11 + 12 + import { jsonToLex } from '@atproto/api' 13 + 14 + type ThreadResponse = { thread: AppBskyFeedDefs.ThreadViewPost } 15 + 16 + type LikesResponse = { 17 + likes: Array<{ 18 + actor: { 19 + did: string 20 + handle: string 21 + displayName?: string 22 + avatar?: string 23 + } 24 + }> 25 + } 26 + 27 + type PostsResponse = { posts: Array<{ likeCount?: number }> } 28 + 29 + const $bluesky = $fetch.create({ baseURL: BLUESKY_API }) 30 + 31 + /** 32 + * Provides both build and runtime comments refreshes 33 + * During build, cache aggressively to avoid rate limits 34 + * During runtime, refresh cache once every minute 35 + */ 36 + export default defineCachedEventHandler( 37 + async event => { 38 + const query = getQuery(event) 39 + const parsed = safeParse(BlueSkyUriSchema, query) 40 + 41 + if (!parsed.success) { 42 + throw createError({ 43 + statusCode: 400, 44 + statusMessage: `Invalid URI format: ${flatten(parsed.issues).root?.[0] || 'Must be a valid at:// URI'}`, 45 + }) 46 + } 47 + 48 + const { uri } = parsed.output 49 + 50 + try { 51 + // Fetch thread, likes, and post metadata in parallel 52 + const [threadResponse, likesResponse, postsResponse] = await Promise.all([ 53 + $bluesky<ThreadResponse>('/app.bsky.feed.getPostThread', { 54 + query: { uri, depth: 10 }, 55 + }).catch((err: Error) => { 56 + console.warn(`[Bluesky] Thread fetch failed for ${uri}:`, err.message) 57 + return null 58 + }), 59 + 60 + $bluesky<LikesResponse>('/app.bsky.feed.getLikes', { 61 + query: { uri, limit: 50 }, 62 + }).catch(() => ({ likes: [] })), 63 + 64 + $bluesky<PostsResponse>('/app.bsky.feed.getPosts', { 65 + query: { uris: [uri] }, 66 + }).catch(() => ({ posts: [] })), 67 + ]) 68 + 69 + // Early return if thread fetch fails w/o 404 70 + if (!threadResponse) { 71 + return { 72 + thread: null, 73 + likes: [], 74 + totalLikes: 0, 75 + postUrl: atUriToWebUrl(uri), 76 + _empty: true, 77 + } 78 + } 79 + 80 + const thread = parseThread(threadResponse.thread) 81 + 82 + return { 83 + thread, 84 + likes: likesResponse.likes, 85 + totalLikes: postsResponse.posts?.[0]?.likeCount ?? thread?.likeCount ?? 0, 86 + postUrl: atUriToWebUrl(uri), 87 + } 88 + } catch (error) { 89 + // Fail open during build to prevent build breakage 90 + console.error('[Bluesky] Unexpected error:', error) 91 + return { 92 + thread: null, 93 + likes: [], 94 + totalLikes: 0, 95 + postUrl: atUriToWebUrl(uri), 96 + _error: true, 97 + } 98 + } 99 + }, 100 + { 101 + name: 'bluesky-comments', 102 + maxAge: CACHE_MAX_AGE_ONE_MINUTE, 103 + getKey: event => { 104 + const { uri } = getQuery(event) 105 + return `bluesky:${uri}` 106 + }, 107 + }, 108 + ) 109 + 110 + // Helper to convert AT URI to web URL 111 + function atUriToWebUrl(uri: string): string | null { 112 + const match = uri.match(AT_URI_REGEX) 113 + if (!match) return null 114 + const [, did, rkey] = match 115 + return `https://bsky.app/profile/${did}/post/${rkey}` 116 + } 117 + 118 + function parseEmbed(embed: AppBskyFeedDefs.PostView['embed']): CommentEmbed | undefined { 119 + if (!embed) return undefined 120 + 121 + if (AppBskyEmbedImages.isView(embed)) { 122 + return { 123 + type: 'images', 124 + images: embed.images, 125 + } 126 + } 127 + 128 + if (AppBskyEmbedExternal.isView(embed)) { 129 + return { 130 + type: 'external', 131 + external: embed.external, 132 + } 133 + } 134 + 135 + return undefined 136 + } 137 + 138 + function parseThread(thread: AppBskyFeedDefs.ThreadViewPost): Comment | null { 139 + if (!AppBskyFeedDefs.isThreadViewPost(thread)) return null 140 + 141 + const { post } = thread 142 + 143 + // This casts our external.thumb as a blobRef which is needed to validateRecord 144 + const lexPostRecord = jsonToLex(post.record) 145 + const recordValidation = AppBskyFeedPost.validateRecord(lexPostRecord) 146 + 147 + if (!recordValidation.success) return null 148 + const record = recordValidation.value 149 + 150 + const replies: Comment[] = [] 151 + if (thread.replies) { 152 + for (const reply of thread.replies) { 153 + if (AppBskyFeedDefs.isThreadViewPost(reply)) { 154 + const parsed = parseThread(reply) 155 + if (parsed) replies.push(parsed) 156 + } 157 + } 158 + replies.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) 159 + } 160 + 161 + return { 162 + uri: post.uri, 163 + cid: post.cid, 164 + author: { 165 + did: post.author.did, 166 + handle: post.author.handle, 167 + displayName: post.author.displayName, 168 + avatar: post.author.avatar, 169 + }, 170 + text: record.text, 171 + facets: record.facets, 172 + embed: parseEmbed(post.embed), 173 + createdAt: record.createdAt, 174 + likeCount: post.likeCount ?? 0, 175 + replyCount: post.replyCount ?? 0, 176 + repostCount: post.repostCount ?? 0, 177 + replies, 178 + } 179 + }
+14
shared/schemas/atproto.ts
··· 1 + import { object, string, startsWith, minLength, regex, pipe } from 'valibot' 2 + import type { InferOutput } from 'valibot' 3 + import { AT_URI_REGEX } from '#shared/utils/constants' 4 + 5 + export const BlueSkyUriSchema = object({ 6 + uri: pipe( 7 + string(), 8 + startsWith('at://'), 9 + minLength(10), 10 + regex(AT_URI_REGEX, 'Must be a valid at:// URI'), 11 + ), 12 + }) 13 + 14 + export type BlueSkyUri = InferOutput<typeof BlueSkyUriSchema>
+32
shared/schemas/blog.ts
··· 1 + import { object, string, optional, array, boolean, pipe, isoDate } from 'valibot' 2 + import type { InferOutput } from 'valibot' 3 + 4 + export const AuthorSchema = object({ 5 + name: string(), 6 + blueskyHandle: optional(string()), 7 + }) 8 + 9 + export const BlogPostSchema = object({ 10 + authors: array(AuthorSchema), 11 + title: string(), 12 + date: pipe(string(), isoDate()), 13 + description: string(), 14 + path: string(), 15 + slug: string(), 16 + excerpt: optional(string()), 17 + tags: optional(array(string())), 18 + draft: optional(boolean()), 19 + }) 20 + 21 + export type Author = InferOutput<typeof AuthorSchema> 22 + 23 + export interface ResolvedAuthor extends Author { 24 + avatar: string | null 25 + profileUrl: string | null 26 + } 27 + 28 + /** 29 + * Inferred type for blog post frontmatter 30 + */ 31 + /** @public */ 32 + export type BlogPostFrontmatter = InferOutput<typeof BlogPostSchema>
+24
shared/types/blog-post.ts
··· 1 + import type { 2 + AppBskyActorDefs, 3 + AppBskyRichtextFacet, 4 + AppBskyEmbedImages, 5 + AppBskyEmbedExternal, 6 + } from '@atproto/api' 7 + 8 + export type CommentEmbed = 9 + | { type: 'images'; images: AppBskyEmbedImages.ViewImage[] } 10 + | { type: 'external'; external: AppBskyEmbedExternal.ViewExternal } 11 + 12 + export interface Comment { 13 + uri: string 14 + cid: string 15 + author: Pick<AppBskyActorDefs.ProfileViewBasic, 'did' | 'handle' | 'displayName' | 'avatar'> 16 + text: string 17 + facets?: AppBskyRichtextFacet.Main[] 18 + embed?: CommentEmbed 19 + createdAt: string 20 + likeCount: number 21 + replyCount: number 22 + repostCount: number 23 + replies: Comment[] 24 + }
+7 -1
shared/utils/constants.ts
··· 1 - import * as dev from '#shared/types/lexicons/dev' 1 + import * as dev from '../types/lexicons/dev' 2 2 3 3 // Duration 4 4 export const CACHE_MAX_AGE_ONE_MINUTE = 60 ··· 8 8 export const CACHE_MAX_AGE_ONE_YEAR = 60 * 60 * 24 * 365 9 9 10 10 // API Strings 11 + export const NPMX_SITE = 'https://npmx.dev' 12 + export const BLUESKY_API = 'https://public.api.bsky.app/xrpc/' 13 + export const BLUESKY_COMMENTS_REQUEST = '/api/atproto/bluesky-comments' 11 14 export const NPM_REGISTRY = 'https://registry.npmjs.org' 12 15 export const ERROR_PACKAGE_ANALYSIS_FAILED = 'Failed to analyze package.' 13 16 export const ERROR_PACKAGE_VERSION_AND_FILE_FAILED = 'Version and file path are required.' ··· 57 60 slate: 'oklch(0.555 0.046 257.407)', 58 61 black: 'oklch(0.4 0 0)', 59 62 } as const 63 + 64 + // Regex 65 + export const AT_URI_REGEX = /^at:\/\/(did:plc:[a-z0-9]+)\/app\.bsky\.feed\.post\/([a-z0-9]+)$/
+34
shared/utils/parse-basic-frontmatter.ts
··· 1 + export function parseBasicFrontmatter(fileContent: string): Record<string, unknown> { 2 + const match = fileContent.match(/^---\s*\n([\s\S]*?)\n---\s*(?:\n|$)/) 3 + if (!match?.[1]) return {} 4 + 5 + return match[1].split('\n').reduce( 6 + (acc, line) => { 7 + const idx = line.indexOf(':') 8 + if (idx === -1) return acc 9 + 10 + const key = line.slice(0, idx).trim() 11 + 12 + // Remove surrounding quotes 13 + let value = line 14 + .slice(idx + 1) 15 + .trim() 16 + .replace(/^["']|["']$/g, '') 17 + 18 + // Type coercion (handles 123, 45.6, boolean, arrays) 19 + if (value === 'true') acc[key] = true 20 + else if (value === 'false') acc[key] = false 21 + else if (/^-?\d+$/.test(value)) acc[key] = parseInt(value, 10) 22 + else if (/^-?\d+\.\d+$/.test(value)) acc[key] = parseFloat(value) 23 + else if (value.startsWith('[') && value.endsWith(']')) { 24 + acc[key] = value 25 + .slice(1, -1) 26 + .split(',') 27 + .map(s => s.trim().replace(/^["']|["']$/g, '')) 28 + } else acc[key] = value 29 + 30 + return acc 31 + }, 32 + {} as Record<string, unknown>, 33 + ) 34 + }