tangled
alpha
login
or
join now
stevedylan.dev
/
sequoia
A CLI for publishing standard.site documents to ATProto
sequoia.pub
standard
site
lexicon
cli
publishing
29
fork
atom
overview
issues
4
pulls
1
pipelines
chore: updated inject to handle new slug options
stevedylan.dev
1 week ago
18647185
427f7591
+29
-50
3 changed files
expand all
collapse all
unified
split
packages
cli
src
commands
inject.ts
publish.ts
lib
types.ts
+27
-50
packages/cli/src/commands/inject.ts
···
44
44
// Load state to get atUri mappings
45
45
const state = await loadState(configDir);
46
46
47
47
-
// Generic filenames where the slug is the parent directory, not the filename
48
48
-
// Covers: SvelteKit (+page), Astro/Hugo (index), Next.js (page), etc.
49
49
-
const genericFilenames = new Set([
50
50
-
"+page",
51
51
-
"index",
52
52
-
"_index",
53
53
-
"page",
54
54
-
"readme",
55
55
-
]);
56
56
-
57
57
-
// Build a map of slug/path to atUri from state
58
58
-
const pathToAtUri = new Map<string, string>();
47
47
+
// Build a map of slug to atUri from state
48
48
+
// The slug is stored in state by the publish command, using the configured slug options
49
49
+
const slugToAtUri = new Map<string, string>();
59
50
for (const [filePath, postState] of Object.entries(state.posts)) {
60
60
-
if (postState.atUri) {
61
61
-
// Extract slug from file path (e.g., ./content/blog/my-post.md -> my-post)
62
62
-
let basename = path.basename(filePath, path.extname(filePath));
51
51
+
if (postState.atUri && postState.slug) {
52
52
+
// Use the slug stored in state (computed by publish with config options)
53
53
+
slugToAtUri.set(postState.slug, postState.atUri);
63
54
64
64
-
// If the filename is a generic convention name, use the parent directory as slug
65
65
-
if (genericFilenames.has(basename.toLowerCase())) {
66
66
-
// Split path and filter out route groups like (blog-article)
67
67
-
const pathParts = filePath
68
68
-
.split(/[/\\]/)
69
69
-
.filter((p) => p && !(p.startsWith("(") && p.endsWith(")")));
70
70
-
// The slug should be the second-to-last part (last is the filename)
71
71
-
if (pathParts.length >= 2) {
72
72
-
const slug = pathParts[pathParts.length - 2];
73
73
-
if (slug && slug !== "." && slug !== "content" && slug !== "routes" && slug !== "src") {
74
74
-
basename = slug;
75
75
-
}
76
76
-
}
55
55
+
// Also add the last segment for simpler matching
56
56
+
// e.g., "40th-puzzle-box/what-a-gift" -> also map "what-a-gift"
57
57
+
const lastSegment = postState.slug.split("/").pop();
58
58
+
if (lastSegment && lastSegment !== postState.slug) {
59
59
+
slugToAtUri.set(lastSegment, postState.atUri);
77
60
}
78
78
-
79
79
-
pathToAtUri.set(basename, postState.atUri);
80
80
-
81
81
-
// Also add variations that might match HTML file paths
82
82
-
// e.g., /blog/my-post, /posts/my-post, my-post/index
83
83
-
const dirName = path.basename(path.dirname(filePath));
84
84
-
// Skip route groups and common directory names
85
85
-
if (dirName !== "." && dirName !== "content" && !(dirName.startsWith("(") && dirName.endsWith(")"))) {
86
86
-
pathToAtUri.set(`${dirName}/${basename}`, postState.atUri);
87
87
-
}
61
61
+
} else if (postState.atUri) {
62
62
+
// Fallback for older state files without slug field
63
63
+
// Extract slug from file path (e.g., ./content/blog/my-post.md -> my-post)
64
64
+
const basename = path.basename(filePath, path.extname(filePath));
65
65
+
slugToAtUri.set(basename.toLowerCase(), postState.atUri);
88
66
}
89
67
}
90
68
91
91
-
if (pathToAtUri.size === 0) {
69
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
98
-
log.info(`Found ${pathToAtUri.size} published posts in state`);
76
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
128
-
atUri = pathToAtUri.get(htmlBasename);
106
106
+
atUri = slugToAtUri.get(htmlBasename);
129
107
130
130
-
// Strategy 2: Directory name for index.html (e.g., my-post/index.html -> my-post)
108
108
+
// Strategy 2: For index.html, try the directory path
109
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
132
-
const slug = path.basename(htmlDir);
133
133
-
atUri = pathToAtUri.get(slug);
111
111
+
// Try full directory path (for nested subdirectories)
112
112
+
atUri = slugToAtUri.get(htmlDir);
134
113
135
135
-
// Also try parent/slug pattern
114
114
+
// Also try just the last directory segment
136
115
if (!atUri) {
137
137
-
const parentDir = path.dirname(htmlDir);
138
138
-
if (parentDir !== ".") {
139
139
-
atUri = pathToAtUri.get(`${path.basename(parentDir)}/${slug}`);
140
140
-
}
116
116
+
const lastDir = path.basename(htmlDir);
117
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
146
-
atUri = pathToAtUri.get(`${htmlDir}/${htmlBasename}`);
123
123
+
atUri = slugToAtUri.get(`${htmlDir}/${htmlBasename}`);
147
124
}
148
125
149
126
if (!atUri) {
+1
packages/cli/src/commands/publish.ts
···
221
221
contentHash,
222
222
atUri,
223
223
lastPublished: new Date().toISOString(),
224
224
+
slug: post.slug,
224
225
};
225
226
} catch (error) {
226
227
const errorMessage = error instanceof Error ? error.message : String(error);
+1
packages/cli/src/lib/types.ts
···
67
67
contentHash: string;
68
68
atUri?: string;
69
69
lastPublished?: string;
70
70
+
slug?: string; // The generated slug for this post (used by inject command)
70
71
}
71
72
72
73
export interface PublicationRecord {