Add support for enhanced links 0f9dc2f0
Resolves #32
Heath Stewart · 2026-05-23 23:59 9 file(s) · +385 −59
docs/docs/pages/config.mdx +4 −0
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:
docs/docs/pages/verifying.mdx +41 −6
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.
packages/cli/src/commands/init.ts +2 −1
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");
packages/cli/src/commands/inject.ts +82 −27
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 +
}
packages/cli/src/commands/publish.ts +44 −16
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 -
packages/cli/src/lib/atproto.ts +36 −7
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
packages/cli/src/lib/sync.ts +32 −2
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 +
}
packages/cli/src/lib/types.ts +3 −0
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
packages/cli/test/inject.test.ts (added) +141 −0
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 +
});