chore: updated inject to handle new slug options 18647185
Steve · 2026-01-31 07:58 3 file(s) · +29 −50
packages/cli/src/commands/inject.ts +27 −50
44 44
		// Load state to get atUri mappings
45 45
		const state = await loadState(configDir);
46 46
47 -
		// Generic filenames where the slug is the parent directory, not the filename
48 -
		// Covers: SvelteKit (+page), Astro/Hugo (index), Next.js (page), etc.
49 -
		const genericFilenames = new Set([
50 -
			"+page",
51 -
			"index",
52 -
			"_index",
53 -
			"page",
54 -
			"readme",
55 -
		]);
56 -
57 -
		// Build a map of slug/path to atUri from state
58 -
		const pathToAtUri = new Map<string, string>();
47 +
		// Build a map of slug to atUri from state
48 +
		// The slug is stored in state by the publish command, using the configured slug options
49 +
		const slugToAtUri = new Map<string, string>();
59 50
		for (const [filePath, postState] of Object.entries(state.posts)) {
60 -
			if (postState.atUri) {
61 -
				// Extract slug from file path (e.g., ./content/blog/my-post.md -> my-post)
62 -
				let basename = path.basename(filePath, path.extname(filePath));
51 +
			if (postState.atUri && postState.slug) {
52 +
				// Use the slug stored in state (computed by publish with config options)
53 +
				slugToAtUri.set(postState.slug, postState.atUri);
63 54
64 -
				// If the filename is a generic convention name, use the parent directory as slug
65 -
				if (genericFilenames.has(basename.toLowerCase())) {
66 -
					// Split path and filter out route groups like (blog-article)
67 -
					const pathParts = filePath
68 -
						.split(/[/\\]/)
69 -
						.filter((p) => p && !(p.startsWith("(") && p.endsWith(")")));
70 -
					// The slug should be the second-to-last part (last is the filename)
71 -
					if (pathParts.length >= 2) {
72 -
						const slug = pathParts[pathParts.length - 2];
73 -
						if (slug && slug !== "." && slug !== "content" && slug !== "routes" && slug !== "src") {
74 -
							basename = slug;
75 -
						}
76 -
					}
55 +
				// Also add the last segment for simpler matching
56 +
				// e.g., "40th-puzzle-box/what-a-gift" -> also map "what-a-gift"
57 +
				const lastSegment = postState.slug.split("/").pop();
58 +
				if (lastSegment && lastSegment !== postState.slug) {
59 +
					slugToAtUri.set(lastSegment, postState.atUri);
77 60
				}
78 -
79 -
				pathToAtUri.set(basename, postState.atUri);
80 -
81 -
				// Also add variations that might match HTML file paths
82 -
				// e.g., /blog/my-post, /posts/my-post, my-post/index
83 -
				const dirName = path.basename(path.dirname(filePath));
84 -
				// Skip route groups and common directory names
85 -
				if (dirName !== "." && dirName !== "content" && !(dirName.startsWith("(") && dirName.endsWith(")"))) {
86 -
					pathToAtUri.set(`${dirName}/${basename}`, postState.atUri);
87 -
				}
61 +
			} else if (postState.atUri) {
62 +
				// Fallback for older state files without slug field
63 +
				// Extract slug from file path (e.g., ./content/blog/my-post.md -> my-post)
64 +
				const basename = path.basename(filePath, path.extname(filePath));
65 +
				slugToAtUri.set(basename.toLowerCase(), postState.atUri);
88 66
			}
89 67
		}
90 68
91 -
		if (pathToAtUri.size === 0) {
69 +
		if (slugToAtUri.size === 0) {
92 70
			log.warn(
93 71
				"No published posts found in state. Run 'sequoia publish' first.",
94 72
			);
95 73
			return;
96 74
		}
97 75
98 -
		log.info(`Found ${pathToAtUri.size} published posts in state`);
76 +
		log.info(`Found ${slugToAtUri.size} slug mappings from published posts`);
99 77
100 78
		// Scan for HTML files
101 79
		const htmlFiles = await glob("**/*.html", {
125 103
			let atUri: string | undefined;
126 104
127 105
			// Strategy 1: Direct basename match (e.g., my-post.html -> my-post)
128 -
			atUri = pathToAtUri.get(htmlBasename);
106 +
			atUri = slugToAtUri.get(htmlBasename);
129 107
130 -
			// Strategy 2: Directory name for index.html (e.g., my-post/index.html -> my-post)
108 +
			// Strategy 2: For index.html, try the directory path
109 +
			// e.g., posts/40th-puzzle-box/what-a-gift/index.html -> 40th-puzzle-box/what-a-gift
131 110
			if (!atUri && htmlBasename === "index" && htmlDir !== ".") {
132 -
				const slug = path.basename(htmlDir);
133 -
				atUri = pathToAtUri.get(slug);
111 +
				// Try full directory path (for nested subdirectories)
112 +
				atUri = slugToAtUri.get(htmlDir);
134 113
135 -
				// Also try parent/slug pattern
114 +
				// Also try just the last directory segment
136 115
				if (!atUri) {
137 -
					const parentDir = path.dirname(htmlDir);
138 -
					if (parentDir !== ".") {
139 -
						atUri = pathToAtUri.get(`${path.basename(parentDir)}/${slug}`);
140 -
					}
116 +
					const lastDir = path.basename(htmlDir);
117 +
					atUri = slugToAtUri.get(lastDir);
141 118
				}
142 119
			}
143 120
144 121
			// Strategy 3: Full path match (e.g., blog/my-post.html -> blog/my-post)
145 122
			if (!atUri && htmlDir !== ".") {
146 -
				atUri = pathToAtUri.get(`${htmlDir}/${htmlBasename}`);
123 +
				atUri = slugToAtUri.get(`${htmlDir}/${htmlBasename}`);
147 124
			}
148 125
149 126
			if (!atUri) {
packages/cli/src/commands/publish.ts +1 −0
221 221
          contentHash,
222 222
          atUri,
223 223
          lastPublished: new Date().toISOString(),
224 +
          slug: post.slug,
224 225
        };
225 226
      } catch (error) {
226 227
        const errorMessage = error instanceof Error ? error.message : String(error);
packages/cli/src/lib/types.ts +1 −0
67 67
	contentHash: string;
68 68
	atUri?: string;
69 69
	lastPublished?: string;
70 +
	slug?: string; // The generated slug for this post (used by inject command)
70 71
}
71 72
72 73
export interface PublicationRecord {