Add support for enhanced links
f957588b
Resolves #32
9 file(s) · +385 −59
Resolves #32
| 126 | 126 | ||
| 127 | 127 | When `pathTemplate` is set, it overrides `pathPrefix`. If `pathTemplate` is not set, the default `pathPrefix`/slug behavior is used. |
|
| 128 | 128 | ||
| 129 | + | :::warning |
|
| 130 | + | When `pathTemplate` is set, automation injection of `<link>` tags in the `<head>` may not work. See [verifying](/verifying) for more information. |
|
| 131 | + | ::: |
|
| 132 | + | ||
| 129 | 133 | ### Ignoring Files |
|
| 130 | 134 | ||
| 131 | 135 | Some frameworks use special files like `_index.md` (Zola) for section pages that aren't actual blog posts. Use the `ignore` field to skip these files during publishing: |
| 1 | 1 | # Verifying |
|
| 2 | 2 | ||
| 3 | - | In order for your posts to show up on indexers you need to make sure your publication and your documents are verified. |
|
| 3 | + | In order for your posts to show up on indexers you need to make sure your publication and your documents are verified. |
|
| 4 | 4 | ||
| 5 | 5 | :::tip |
|
| 6 | 6 | You can learn more about Standard.site verification [here](https://standard.site/) |
|
| 8 | 8 | ||
| 9 | 9 | ## Publication Verification |
|
| 10 | 10 | ||
| 11 | - | As specified by Standard.site, the `site.standard.publication` record is verified by placing the record `https://example.com/.well-known/site.standard.publication`. That record might look something like `at://did:plc:abc123/site.standard.publication/rkey`. Sequoia handles this for you automatically if you designate your public/static folder during the [setup](/setup). When the record is created, the record AT URI is saved in `.well-known/site.standard.publication` of your public folder. Once you deploy your site with this addition, the publication will be verified! |
|
| 11 | + | As specified by Standard.site, the `site.standard.publication` record is verified by placing the record `https://example.com/.well-known/site.standard.publication`. |
|
| 12 | + | That record might look something like `at://did:plc:abc123/site.standard.publication/rkey`. |
|
| 13 | + | Pages may also [aid discovery](https://standard.site/docs/verification/#discovery-hint) with a `<link>` tag in the `<head>` with your publication URI. |
|
| 14 | + | Sequoia handles this for you automatically if you designate your public/static folder during the [setup](/setup). |
|
| 15 | + | When the record is created, the record AT URI is saved in `.well-known/site.standard.publication` of your public folder and a `<link>` tag added to your posts. |
|
| 16 | + | Once you deploy your site with this addition, the publication will be verified! |
|
| 12 | 17 | ||
| 13 | 18 | ## Document Verification |
|
| 14 | 19 | ||
| 15 | - | Every document or blog post that is published needs a `<link>` tag in the `<head>` of your blog post HTML page. The content of that link tag needs to be the AT URI for the record we just published on your PDS. There are two ways you can handle these: |
|
| 16 | - | - `sequoia inject` (recommended) - By running this command after publishing, and after building the site with your SSG, Sequoia will inject the link tags into your finished HTML. This way you don't have to manually edit it or mess with an SSG config to set it up. Just deploy the build folder after you have run `sequoia inject`! |
|
| 17 | - | - Manual - After you have run `sequoia publish` the CLI will add in a new `atUri` field to every post's frontmatter. This way you can configure your SSG to read that frontmatter and include it in the build step, similar to how it might include an opengraph image in the meta tags. This approach gives you full control over the HTML files but will take a bit more skill. |
|
| 20 | + | Every document or blog post that is published needs a `<link>` tag in the `<head>` of your blog post HTML page. |
|
| 21 | + | The content of that link tag needs to be the AT URI for the record we just published on your PDS, and an optional publication URI for enhanced links in Bluesky posts if enabled. |
|
| 22 | + | There are two ways you can handle these: |
|
| 23 | + | ||
| 24 | + | - `sequoia inject` (recommended) - By running this command after publishing, and after building the site with your SSG, Sequoia will inject the link tags into your finished HTML. |
|
| 25 | + | This way you don't have to manually edit it or mess with an SSG config to set it up. |
|
| 26 | + | Just deploy the build folder after you have run `sequoia inject`! |
|
| 27 | + | ||
| 28 | + | - Manual - After you have run `sequoia publish` the CLI will add in a new `atUri` field to every post's frontmatter. |
|
| 29 | + | This way you can configure your SSG to read that frontmatter and include it in the build step, similar to how it might include an opengraph image in the meta tags. |
|
| 30 | + | You should also include the publication URI in a separate `<link>` tag. |
|
| 31 | + | This approach gives you full control over the HTML files but will take a bit more skill. |
|
| 32 | + | ||
| 33 | + | :::code-group |
|
| 34 | + | ||
| 35 | + | ```html [Hugo] |
|
| 36 | + | <!-- layouts/_partials/head.html --> |
|
| 37 | + | {{ if .Params.atUri }} |
|
| 38 | + | <link rel="site.standard.document" href="{{ .Params.atUri }}" /> |
|
| 39 | + | {{ end }} |
|
| 40 | + | <link rel="site.standard.publication" href="at://did:plc:abc123/site.standard.publication/rkey" /> |
|
| 41 | + | ``` |
|
| 42 | + | ||
| 43 | + | ```html [Jekyll] |
|
| 44 | + | <!-- _includes/head.html --> |
|
| 45 | + | {%- if page.atUri %} |
|
| 46 | + | <link rel="site.standard.document" href="{{ page.atUri }}" /> |
|
| 47 | + | {%- endif %} |
|
| 48 | + | <link rel="site.standard.publication" href="at://did:plc:abc123/site.standard.publication/rkey" /> |
|
| 49 | + | ``` |
|
| 50 | + | ||
| 51 | + | ::: |
|
| 18 | 52 | ||
| 19 | 53 | ## Testing Verification |
|
| 20 | 54 | ||
| 21 | - | After your publication and your document records have been published and you site has been deployed, you can test the verification of your records a few ways. |
|
| 55 | + | After your publication and your document records have been published and you site has been deployed, you can test the verification of your records a few ways. |
|
| 22 | 56 | ||
| 23 | 57 | ### pds.ls |
|
| 24 | 58 | ||
| 31 | 65 | ## Troubleshooting |
|
| 32 | 66 | ||
| 33 | 67 | - Make sure that you are either using `sequoia inject` or manually handling the required `<link>` tags for each post. Read [workflows](/workflows) for a clear order of operations to publish, inject, and deploy. |
|
| 68 | + | - |
|
| 34 | 69 | - Make sure that the `.well-known` publication record is present in your public/static folder, and that it's populating to your build folder (e.g. `dist`). There are some SSGs that will not automatically include dot files or directories. |
|
| 264 | 264 | ||
| 265 | 265 | s.start("Creating publication..."); |
|
| 266 | 266 | try { |
|
| 267 | - | publicationUri = await createPublication(agent, { |
|
| 267 | + | const publicationRef = await createPublication(agent, { |
|
| 268 | 268 | url: siteConfig.siteUrl, |
|
| 269 | 269 | name: publicationConfig.name, |
|
| 270 | 270 | description: publicationConfig.description || undefined, |
|
| 271 | 271 | iconPath: publicationConfig.iconPath || undefined, |
|
| 272 | 272 | showInDiscover: publicationConfig.showInDiscover, |
|
| 273 | 273 | }); |
|
| 274 | + | publicationUri = publicationRef.uri; |
|
| 274 | 275 | s.stop(`Publication created: ${publicationUri}`); |
|
| 275 | 276 | } catch (error) { |
|
| 276 | 277 | s.stop("Failed to create publication"); |
| 130 | 130 | // Read the HTML file |
|
| 131 | 131 | let content = await fs.readFile(htmlPath, "utf-8"); |
|
| 132 | 132 | ||
| 133 | - | // Check if link tag already exists |
|
| 134 | - | const linkTag = `<link rel="site.standard.document" href="${atUri}">`; |
|
| 135 | - | if (content.includes('rel="site.standard.document"')) { |
|
| 136 | - | alreadyHasCount++; |
|
| 137 | - | continue; |
|
| 133 | + | // Inject the tags |
|
| 134 | + | let injected = injectLinkTags( |
|
| 135 | + | dryRun, |
|
| 136 | + | relativePath, |
|
| 137 | + | content, |
|
| 138 | + | atUri, |
|
| 139 | + | config.publicationUri, |
|
| 140 | + | ); |
|
| 141 | + | switch (injected) { |
|
| 142 | + | case Injected.AlreadyPresent: |
|
| 143 | + | alreadyHasCount++; |
|
| 144 | + | continue; |
|
| 145 | + | case Injected.Skipped: |
|
| 146 | + | skippedCount++; |
|
| 147 | + | continue; |
|
| 148 | + | case Injected.Faked: |
|
| 149 | + | injectedCount++; |
|
| 150 | + | continue; |
|
| 151 | + | default: |
|
| 152 | + | content = injected; |
|
| 138 | 153 | } |
|
| 139 | 154 | ||
| 140 | - | // Find </head> and inject before it |
|
| 141 | - | const headCloseIndex = content.indexOf("</head>"); |
|
| 142 | - | if (headCloseIndex === -1) { |
|
| 143 | - | log.warn(` No </head> found in ${relativePath}, skipping`); |
|
| 144 | - | skippedCount++; |
|
| 145 | - | continue; |
|
| 146 | - | } |
|
| 147 | - | ||
| 148 | - | if (dryRun) { |
|
| 149 | - | log.message(` Would inject into: ${relativePath}`); |
|
| 150 | - | log.message(` ${linkTag}`); |
|
| 151 | - | injectedCount++; |
|
| 152 | - | continue; |
|
| 153 | - | } |
|
| 154 | - | ||
| 155 | - | // Inject the link tag |
|
| 156 | - | const indent = " "; // Standard indentation |
|
| 157 | - | content = |
|
| 158 | - | content.slice(0, headCloseIndex) + |
|
| 159 | - | `${indent}${linkTag}\n${indent}` + |
|
| 160 | - | content.slice(headCloseIndex); |
|
| 161 | - | ||
| 162 | 155 | await fs.writeFile(htmlPath, content); |
|
| 163 | 156 | log.success(` Injected into: ${relativePath}`); |
|
| 164 | 157 | injectedCount++; |
|
| 180 | 173 | } |
|
| 181 | 174 | }, |
|
| 182 | 175 | }); |
|
| 176 | + | ||
| 177 | + | export enum Injected { |
|
| 178 | + | AlreadyPresent = 0, |
|
| 179 | + | Skipped, |
|
| 180 | + | Faked, |
|
| 181 | + | } |
|
| 182 | + | ||
| 183 | + | export function injectLinkTags( |
|
| 184 | + | dryRun: boolean, |
|
| 185 | + | relativePath: string, |
|
| 186 | + | content: string, |
|
| 187 | + | atUri: string, |
|
| 188 | + | publicationUri: string, |
|
| 189 | + | ): string | Injected { |
|
| 190 | + | // Check if link tags already exist |
|
| 191 | + | let documentLinkTag: string | undefined = |
|
| 192 | + | `<link rel="site.standard.document" href="${atUri}">`; |
|
| 193 | + | let publicationLinkTag: string | undefined = |
|
| 194 | + | `<link rel="site.standard.publication" href="${publicationUri}">`; |
|
| 195 | + | if (content.includes('rel="site.standard.document"')) { |
|
| 196 | + | documentLinkTag = undefined; |
|
| 197 | + | } |
|
| 198 | + | if (content.includes('rel="site.standard.publication"')) { |
|
| 199 | + | publicationLinkTag = undefined; |
|
| 200 | + | } |
|
| 201 | + | ||
| 202 | + | if (!documentLinkTag && !publicationLinkTag) { |
|
| 203 | + | return Injected.AlreadyPresent; |
|
| 204 | + | } |
|
| 205 | + | ||
| 206 | + | // Find </head> and inject before it |
|
| 207 | + | const headCloseIndex = content.indexOf("</head>"); |
|
| 208 | + | if (headCloseIndex === -1) { |
|
| 209 | + | log.warn(` No </head> found in ${relativePath}, skipping`); |
|
| 210 | + | return Injected.Skipped; |
|
| 211 | + | } |
|
| 212 | + | ||
| 213 | + | if (dryRun) { |
|
| 214 | + | log.message(` Would inject into: ${relativePath}`); |
|
| 215 | + | if (documentLinkTag) { |
|
| 216 | + | log.message(` ${documentLinkTag}`); |
|
| 217 | + | } |
|
| 218 | + | if (publicationLinkTag) { |
|
| 219 | + | log.message(` ${publicationLinkTag}`); |
|
| 220 | + | } |
|
| 221 | + | return Injected.Faked; |
|
| 222 | + | } |
|
| 223 | + | ||
| 224 | + | // Inject the link tags |
|
| 225 | + | const indent = " "; // Standard indentation |
|
| 226 | + | const after = content.slice(headCloseIndex); |
|
| 227 | + | content = content.slice(0, headCloseIndex); |
|
| 228 | + | if (documentLinkTag) { |
|
| 229 | + | content += `${indent}${documentLinkTag}\n${indent}`; |
|
| 230 | + | } |
|
| 231 | + | if (publicationLinkTag) { |
|
| 232 | + | content += `${indent}${publicationLinkTag}\n${indent}`; |
|
| 233 | + | } |
|
| 234 | + | content += after; |
|
| 235 | + | ||
| 236 | + | return content; |
|
| 237 | + | } |
|
| 2 | 2 | import { command, flag } from "cmd-ts"; |
|
| 3 | 3 | import { select, spinner, log } from "@clack/prompts"; |
|
| 4 | 4 | import * as path from "node:path"; |
|
| 5 | - | import { CONFIG_FILENAME, loadConfig, loadState, saveState, findConfig } from "../lib/config"; |
|
| 5 | + | import { |
|
| 6 | + | CONFIG_FILENAME, |
|
| 7 | + | loadConfig, |
|
| 8 | + | loadState, |
|
| 9 | + | saveState, |
|
| 10 | + | findConfig, |
|
| 11 | + | } from "../lib/config"; |
|
| 6 | 12 | import { |
|
| 7 | 13 | loadCredentials, |
|
| 8 | 14 | listAllCredentials, |
|
| 17 | 23 | resolveImagePath, |
|
| 18 | 24 | createBlueskyPost, |
|
| 19 | 25 | addBskyPostRefToDocument, |
|
| 20 | - | COVER_IMAGE_MAX_SIZE, |
|
| 26 | + | COVER_IMAGE_MAX_SIZE, |
|
| 27 | + | getPublication, |
|
| 21 | 28 | } from "../lib/atproto"; |
|
| 22 | 29 | import { |
|
| 23 | 30 | scanContentDirectory, |
|
| 26 | 33 | resolvePostPath, |
|
| 27 | 34 | } from "../lib/markdown"; |
|
| 28 | 35 | import type { BlogPost, BlobObject, StrongRef } from "../lib/types"; |
|
| 29 | - | import { syncStateFromPDS } from "../lib/sync"; |
|
| 36 | + | import { syncPublication, syncStateFromPDS } from "../lib/sync"; |
|
| 30 | 37 | import { exitOnCancel } from "../lib/prompts"; |
|
| 31 | 38 | ||
| 32 | 39 | export const publishCommand = command({ |
|
| 155 | 162 | ||
| 156 | 163 | if ( |
|
| 157 | 164 | config.autoSync !== false && |
|
| 158 | - | Object.keys(state.posts).length === 0 && |
|
| 165 | + | (!state.publication || Object.keys(state.posts).length === 0) && |
|
| 159 | 166 | !dryRun |
|
| 160 | 167 | ) { |
|
| 161 | 168 | // Create agent early for sync (will be reused for publishing) |
|
| 334 | 341 | } |
|
| 335 | 342 | } |
|
| 336 | 343 | ||
| 344 | + | // Make sure publication state is available for enhanced links. |
|
| 345 | + | if (!state.publication) { |
|
| 346 | + | state.publication = await syncPublication( |
|
| 347 | + | agent, |
|
| 348 | + | config.publicationUri, |
|
| 349 | + | false, |
|
| 350 | + | ); |
|
| 351 | + | } |
|
| 352 | + | ||
| 337 | 353 | // Publish posts |
|
| 338 | 354 | let publishedCount = 0; |
|
| 339 | 355 | let updatedCount = 0; |
|
| 347 | 363 | // Handle cover image upload |
|
| 348 | 364 | let coverImage: BlobObject | undefined; |
|
| 349 | 365 | if (post.coverImagePath) { |
|
| 350 | - | log.info(` Uploading cover image: ${path.basename(post.coverImagePath)}`); |
|
| 366 | + | log.info( |
|
| 367 | + | ` Uploading cover image: ${path.basename(post.coverImagePath)}`, |
|
| 368 | + | ); |
|
| 351 | 369 | coverImage = await uploadImage(agent, post.coverImagePath); |
|
| 352 | 370 | if (coverImage) { |
|
| 353 | 371 | log.info(` Uploaded image blob: ${coverImage.ref.$link}`); |
|
| 357 | 375 | } |
|
| 358 | 376 | ||
| 359 | 377 | // Track atUri, content for state saving, and bskyPostRef |
|
| 360 | - | let atUri: string; |
|
| 378 | + | let documentRef: StrongRef; |
|
| 361 | 379 | let contentForHash: string; |
|
| 362 | 380 | let bskyPostRef: StrongRef | undefined; |
|
| 363 | 381 | const relativeFilePath = path.relative(configDir, post.filePath); |
|
| 366 | 384 | const existingBskyPostRef = state.posts[relativeFilePath]?.bskyPostRef; |
|
| 367 | 385 | ||
| 368 | 386 | if (action === "create") { |
|
| 369 | - | atUri = await createDocument(agent, post, config, coverImage); |
|
| 370 | - | s.stop(`Created: ${atUri}`); |
|
| 387 | + | documentRef = await createDocument(agent, post, config, coverImage); |
|
| 388 | + | s.stop(`Created: ${documentRef.uri}`); |
|
| 371 | 389 | ||
| 372 | 390 | // Update frontmatter with atUri |
|
| 373 | 391 | const updatedContent = updateFrontmatterWithAtUri( |
|
| 374 | 392 | post.rawContent, |
|
| 375 | - | atUri, |
|
| 393 | + | documentRef.uri, |
|
| 376 | 394 | ); |
|
| 377 | 395 | await fs.writeFile(post.filePath, updatedContent); |
|
| 378 | 396 | log.info(` Updated frontmatter in ${path.basename(post.filePath)}`); |
|
| 381 | 399 | contentForHash = updatedContent; |
|
| 382 | 400 | publishedCount++; |
|
| 383 | 401 | } else { |
|
| 384 | - | ||
| 385 | 402 | // Validate post. |
|
| 386 | - | atUri = post.frontmatter.atUri!; |
|
| 387 | - | await updateDocument(agent, post, atUri, config, coverImage); |
|
| 388 | - | s.stop(`Updated: ${atUri}`); |
|
| 403 | + | const atUri = post.frontmatter.atUri!; |
|
| 404 | + | documentRef = await updateDocument( |
|
| 405 | + | agent, |
|
| 406 | + | post, |
|
| 407 | + | atUri, |
|
| 408 | + | config, |
|
| 409 | + | coverImage, |
|
| 410 | + | ); |
|
| 411 | + | s.stop(`Updated: ${documentRef.uri}`); |
|
| 389 | 412 | ||
| 390 | 413 | // For updates, rawContent already has atUri |
|
| 391 | 414 | contentForHash = post.rawContent; |
|
| 414 | 437 | description: post.frontmatter.description, |
|
| 415 | 438 | bskyPost: post.frontmatter.bskyPost, |
|
| 416 | 439 | canonicalUrl, |
|
| 440 | + | documentRef, |
|
| 441 | + | publicationRef: state.publication, |
|
| 417 | 442 | coverImage, |
|
| 418 | 443 | publishedAt: post.frontmatter.publishDate, |
|
| 419 | 444 | }); |
|
| 420 | 445 | ||
| 421 | 446 | // Update document record with bskyPostRef |
|
| 422 | - | await addBskyPostRefToDocument(agent, atUri, bskyPostRef); |
|
| 447 | + | await addBskyPostRefToDocument( |
|
| 448 | + | agent, |
|
| 449 | + | documentRef.uri, |
|
| 450 | + | bskyPostRef, |
|
| 451 | + | ); |
|
| 423 | 452 | log.info(` Created Bluesky post: ${bskyPostRef.uri}`); |
|
| 424 | 453 | bskyPostCount++; |
|
| 425 | 454 | } catch (bskyError) { |
|
| 437 | 466 | const contentHash = await getContentHash(contentForHash); |
|
| 438 | 467 | state.posts[relativeFilePath] = { |
|
| 439 | 468 | contentHash, |
|
| 440 | - | atUri, |
|
| 469 | + | atUri: documentRef.uri, |
|
| 441 | 470 | lastPublished: new Date().toISOString(), |
|
| 442 | 471 | slug: post.slug, |
|
| 443 | 472 | bskyPostRef, |
|
| 478 | 507 | ||
| 479 | 508 | return true; |
|
| 480 | 509 | } |
|
| 481 | - | ||
| 241 | 241 | } |
|
| 242 | 242 | } |
|
| 243 | 243 | ||
| 244 | - | return null; |
|
| 244 | + | return undefined; |
|
| 245 | 245 | } |
|
| 246 | 246 | ||
| 247 | 247 | export async function createDocument( |
|
| 249 | 249 | post: BlogPost, |
|
| 250 | 250 | config: PublisherConfig, |
|
| 251 | 251 | coverImage?: BlobObject, |
|
| 252 | - | ): Promise<string> { |
|
| 252 | + | ): Promise<StrongRef> { |
|
| 253 | 253 | const postPath = resolvePostPath( |
|
| 254 | 254 | post, |
|
| 255 | 255 | config.pathPrefix, |
|
| 307 | 307 | record, |
|
| 308 | 308 | }); |
|
| 309 | 309 | ||
| 310 | - | return response.data.uri; |
|
| 310 | + | return { |
|
| 311 | + | cid: response.data.cid, |
|
| 312 | + | uri: response.data.uri, |
|
| 313 | + | }; |
|
| 311 | 314 | } |
|
| 312 | 315 | ||
| 313 | 316 | export async function updateDocument( |
|
| 316 | 319 | atUri: string, |
|
| 317 | 320 | config: PublisherConfig, |
|
| 318 | 321 | coverImage?: BlobObject, |
|
| 319 | - | ): Promise<void> { |
|
| 322 | + | ): Promise<StrongRef> { |
|
| 320 | 323 | // Parse the atUri to get the collection and rkey |
|
| 321 | 324 | // Format: at://did:plc:xxx/collection/rkey |
|
| 322 | 325 | const uriMatch = atUri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); |
|
| 385 | 388 | record.tags = post.frontmatter.tags; |
|
| 386 | 389 | } |
|
| 387 | 390 | ||
| 388 | - | await agent.com.atproto.repo.putRecord({ |
|
| 391 | + | const response = await agent.com.atproto.repo.putRecord({ |
|
| 389 | 392 | repo: agent.did!, |
|
| 390 | 393 | collection: collection!, |
|
| 391 | 394 | rkey: rkey!, |
|
| 392 | 395 | record, |
|
| 393 | 396 | }); |
|
| 397 | + | ||
| 398 | + | return { |
|
| 399 | + | cid: response.data.cid, |
|
| 400 | + | uri: response.data.uri, |
|
| 401 | + | }; |
|
| 394 | 402 | } |
|
| 395 | 403 | ||
| 396 | 404 | export function parseAtUri( |
|
| 467 | 475 | export async function createPublication( |
|
| 468 | 476 | agent: Agent, |
|
| 469 | 477 | options: CreatePublicationOptions, |
|
| 470 | - | ): Promise<string> { |
|
| 478 | + | ): Promise<StrongRef> { |
|
| 471 | 479 | let icon: BlobObject | undefined; |
|
| 472 | 480 | ||
| 473 | 481 | if (options.iconPath) { |
|
| 501 | 509 | record, |
|
| 502 | 510 | }); |
|
| 503 | 511 | ||
| 504 | - | return response.data.uri; |
|
| 512 | + | return { |
|
| 513 | + | cid: response.data.cid, |
|
| 514 | + | uri: response.data.uri, |
|
| 515 | + | }; |
|
| 505 | 516 | } |
|
| 506 | 517 | ||
| 507 | 518 | export interface GetPublicationResult { |
|
| 607 | 618 | description?: string; |
|
| 608 | 619 | bskyPost?: string; |
|
| 609 | 620 | canonicalUrl: string; |
|
| 621 | + | documentRef: StrongRef; |
|
| 622 | + | publicationRef: StrongRef; |
|
| 610 | 623 | coverImage?: BlobObject; |
|
| 611 | 624 | publishedAt: string; // Used as createdAt for the post |
|
| 612 | 625 | } |
|
| 654 | 667 | description, |
|
| 655 | 668 | bskyPost, |
|
| 656 | 669 | canonicalUrl, |
|
| 670 | + | documentRef, |
|
| 671 | + | publicationRef, |
|
| 657 | 672 | coverImage, |
|
| 658 | 673 | publishedAt, |
|
| 659 | 674 | } = options; |
|
| 704 | 719 | uri: canonicalUrl, |
|
| 705 | 720 | title: title.substring(0, 500), // Max 500 chars for title |
|
| 706 | 721 | description: (description || "").substring(0, 1000), // Max 1000 chars for description |
|
| 722 | + | associatedRefs: [ |
|
| 723 | + | { |
|
| 724 | + | // site.standard.document |
|
| 725 | + | $type: "com.atproto.repo.strongRef", |
|
| 726 | + | cid: documentRef.cid, |
|
| 727 | + | uri: documentRef.uri, |
|
| 728 | + | }, |
|
| 729 | + | { |
|
| 730 | + | // site.standard.publication |
|
| 731 | + | $type: "com.atproto.repo.strongRef", |
|
| 732 | + | cid: publicationRef.cid, |
|
| 733 | + | uri: publicationRef.uri, |
|
| 734 | + | }, |
|
| 735 | + | ], |
|
| 707 | 736 | }, |
|
| 708 | 737 | }; |
|
| 709 | 738 | ||
| 1 | 1 | import * as fs from "node:fs/promises"; |
|
| 2 | 2 | import * as path from "node:path"; |
|
| 3 | 3 | import { log } from "@clack/prompts"; |
|
| 4 | - | import { listDocuments, type createAgent } from "./atproto"; |
|
| 4 | + | import { getPublication, listDocuments, type createAgent } from "./atproto"; |
|
| 5 | 5 | import { loadState, saveState } from "./config"; |
|
| 6 | 6 | import { |
|
| 7 | 7 | scanContentDirectory, |
|
| 9 | 9 | updateFrontmatterWithAtUri, |
|
| 10 | 10 | resolvePostPath, |
|
| 11 | 11 | } from "./markdown"; |
|
| 12 | - | import type { PublisherConfig, PublisherState } from "./types"; |
|
| 12 | + | import type { PublisherConfig, PublisherState, StrongRef } from "./types"; |
|
| 13 | 13 | ||
| 14 | 14 | export interface SyncOptions { |
|
| 15 | 15 | updateFrontmatter?: boolean; |
|
| 80 | 80 | ||
| 81 | 81 | // Load existing state |
|
| 82 | 82 | const state = await loadState(configDir); |
|
| 83 | + | ||
| 84 | + | // Update the publication information for enhanced links. |
|
| 85 | + | if (!state.publication) { |
|
| 86 | + | state.publication = await syncPublication( |
|
| 87 | + | agent, |
|
| 88 | + | config.publicationUri, |
|
| 89 | + | quiet, |
|
| 90 | + | ); |
|
| 91 | + | } |
|
| 83 | 92 | ||
| 84 | 93 | // Track changes |
|
| 85 | 94 | let matchedCount = 0; |
|
| 201 | 210 | ||
| 202 | 211 | return { state, matchedCount, unmatchedCount, frontmatterUpdatesApplied }; |
|
| 203 | 212 | } |
|
| 213 | + | ||
| 214 | + | export async function syncPublication( |
|
| 215 | + | agent: Awaited<ReturnType<typeof createAgent>>, |
|
| 216 | + | publicationUri: string, |
|
| 217 | + | quiet: boolean, |
|
| 218 | + | ): Promise<StrongRef> { |
|
| 219 | + | const publicationRef = await getPublication(agent, publicationUri); |
|
| 220 | + | if (!publicationRef) { |
|
| 221 | + | if (!quiet) { |
|
| 222 | + | log.error( |
|
| 223 | + | `Publication ${publicationUri} not found. Update your publication record and try again.`, |
|
| 224 | + | ); |
|
| 225 | + | } |
|
| 226 | + | process.exit(1); |
|
| 227 | + | } |
|
| 228 | + | ||
| 229 | + | return { |
|
| 230 | + | cid: publicationRef.cid, |
|
| 231 | + | uri: publicationRef.uri, |
|
| 232 | + | }; |
|
| 233 | + | } |
|
| 120 | 120 | size: number; |
|
| 121 | 121 | } |
|
| 122 | 122 | ||
| 123 | + | export interface PublicationState extends StrongRef {} |
|
| 124 | + | ||
| 123 | 125 | export interface PublisherState { |
|
| 126 | + | publication?: PublicationState; |
|
| 124 | 127 | posts: Record<string, PostState>; |
|
| 125 | 128 | } |
|
| 126 | 129 |
| 1 | + | import { describe, expect, it, spyOn } from "bun:test"; |
|
| 2 | + | import { log } from "@clack/prompts"; |
|
| 3 | + | import { Injected, injectLinkTags } from "../src/commands/inject"; |
|
| 4 | + | ||
| 5 | + | const atUri = "at://did:plc:abc123/app.bsky.feed.post/xyz"; |
|
| 6 | + | const publicationUri = "at://did:plc:def456/app.bsky.feed.generator/main"; |
|
| 7 | + | ||
| 8 | + | describe("injectLinkTags", () => { |
|
| 9 | + | describe("neither tag needs injection", () => { |
|
| 10 | + | it("returns AlreadyPresent when both tags already exist", () => { |
|
| 11 | + | const content = `<html><head> |
|
| 12 | + | <link rel="site.standard.document" href="${atUri}"> |
|
| 13 | + | <link rel="site.standard.publication" href="${publicationUri}"> |
|
| 14 | + | </head></html>`; |
|
| 15 | + | const result = injectLinkTags( |
|
| 16 | + | false, |
|
| 17 | + | "test.html", |
|
| 18 | + | content, |
|
| 19 | + | atUri, |
|
| 20 | + | publicationUri, |
|
| 21 | + | ); |
|
| 22 | + | expect(result).toBe(Injected.AlreadyPresent); |
|
| 23 | + | }); |
|
| 24 | + | }); |
|
| 25 | + | ||
| 26 | + | describe("one tag needs injection", () => { |
|
| 27 | + | it("injects only documentLinkTag when publicationLinkTag is already present", () => { |
|
| 28 | + | const content = `<html><head> |
|
| 29 | + | <link rel="site.standard.publication" href="${publicationUri}"> |
|
| 30 | + | </head></html>`; |
|
| 31 | + | const result = injectLinkTags( |
|
| 32 | + | false, |
|
| 33 | + | "test.html", |
|
| 34 | + | content, |
|
| 35 | + | atUri, |
|
| 36 | + | publicationUri, |
|
| 37 | + | ); |
|
| 38 | + | expect(typeof result).toBe("string"); |
|
| 39 | + | expect(result as string).toContain( |
|
| 40 | + | `<link rel="site.standard.document" href="${atUri}">`, |
|
| 41 | + | ); |
|
| 42 | + | expect( |
|
| 43 | + | result as string, |
|
| 44 | + | ).not.toContain(`<link rel="site.standard.publication" href="${publicationUri}"> |
|
| 45 | + | <link rel="site.standard.publication"`); |
|
| 46 | + | }); |
|
| 47 | + | ||
| 48 | + | it("injects only publicationLinkTag when documentLinkTag is already present", () => { |
|
| 49 | + | const content = `<html><head> |
|
| 50 | + | <link rel="site.standard.document" href="${atUri}"> |
|
| 51 | + | </head></html>`; |
|
| 52 | + | const result = injectLinkTags( |
|
| 53 | + | false, |
|
| 54 | + | "test.html", |
|
| 55 | + | content, |
|
| 56 | + | atUri, |
|
| 57 | + | publicationUri, |
|
| 58 | + | ); |
|
| 59 | + | expect(typeof result).toBe("string"); |
|
| 60 | + | expect(result as string).toContain( |
|
| 61 | + | `<link rel="site.standard.publication" href="${publicationUri}">`, |
|
| 62 | + | ); |
|
| 63 | + | }); |
|
| 64 | + | }); |
|
| 65 | + | ||
| 66 | + | describe("both tags need injection", () => { |
|
| 67 | + | it("injects both tags when neither is present", () => { |
|
| 68 | + | const content = "<html><head>\n</head></html>"; |
|
| 69 | + | const result = injectLinkTags( |
|
| 70 | + | false, |
|
| 71 | + | "test.html", |
|
| 72 | + | content, |
|
| 73 | + | atUri, |
|
| 74 | + | publicationUri, |
|
| 75 | + | ); |
|
| 76 | + | expect(typeof result).toBe("string"); |
|
| 77 | + | expect(result as string).toContain( |
|
| 78 | + | `<link rel="site.standard.document" href="${atUri}">`, |
|
| 79 | + | ); |
|
| 80 | + | expect(result as string).toContain( |
|
| 81 | + | `<link rel="site.standard.publication" href="${publicationUri}">`, |
|
| 82 | + | ); |
|
| 83 | + | }); |
|
| 84 | + | ||
| 85 | + | it("injects tags before </head>", () => { |
|
| 86 | + | const content = "<html><head>\n</head><body></body></html>"; |
|
| 87 | + | const result = injectLinkTags( |
|
| 88 | + | false, |
|
| 89 | + | "test.html", |
|
| 90 | + | content, |
|
| 91 | + | atUri, |
|
| 92 | + | publicationUri, |
|
| 93 | + | ) as string; |
|
| 94 | + | const headCloseIndex = result.indexOf("</head>"); |
|
| 95 | + | expect(result.indexOf('rel="site.standard.document"')).toBeLessThan( |
|
| 96 | + | headCloseIndex, |
|
| 97 | + | ); |
|
| 98 | + | expect(result.indexOf('rel="site.standard.publication"')).toBeLessThan( |
|
| 99 | + | headCloseIndex, |
|
| 100 | + | ); |
|
| 101 | + | }); |
|
| 102 | + | ||
| 103 | + | it("returns Skipped when no </head> is found", () => { |
|
| 104 | + | const warnSpy = spyOn(log, "warn").mockImplementation(() => {}); |
|
| 105 | + | const content = "<html><body>No head tag here</body></html>"; |
|
| 106 | + | const result = injectLinkTags( |
|
| 107 | + | false, |
|
| 108 | + | "test.html", |
|
| 109 | + | content, |
|
| 110 | + | atUri, |
|
| 111 | + | publicationUri, |
|
| 112 | + | ); |
|
| 113 | + | expect(result).toBe(Injected.Skipped); |
|
| 114 | + | expect(warnSpy).toHaveBeenCalledWith( |
|
| 115 | + | " No </head> found in test.html, skipping", |
|
| 116 | + | ); |
|
| 117 | + | warnSpy.mockRestore(); |
|
| 118 | + | }); |
|
| 119 | + | ||
| 120 | + | it("returns Faked and does not modify content during dry run", () => { |
|
| 121 | + | const messageSpy = spyOn(log, "message").mockImplementation(() => {}); |
|
| 122 | + | const content = "<html><head>\n</head></html>"; |
|
| 123 | + | const result = injectLinkTags( |
|
| 124 | + | true, |
|
| 125 | + | "test.html", |
|
| 126 | + | content, |
|
| 127 | + | atUri, |
|
| 128 | + | publicationUri, |
|
| 129 | + | ); |
|
| 130 | + | expect(result).toBe(Injected.Faked); |
|
| 131 | + | expect(messageSpy).toHaveBeenCalledWith(" Would inject into: test.html"); |
|
| 132 | + | expect(messageSpy).toHaveBeenCalledWith( |
|
| 133 | + | ` <link rel="site.standard.document" href="${atUri}">`, |
|
| 134 | + | ); |
|
| 135 | + | expect(messageSpy).toHaveBeenCalledWith( |
|
| 136 | + | ` <link rel="site.standard.publication" href="${publicationUri}">`, |
|
| 137 | + | ); |
|
| 138 | + | messageSpy.mockRestore(); |
|
| 139 | + | }); |
|
| 140 | + | }); |
|
| 141 | + | }); |