diff --git a/src/commands/new.ts b/src/commands/new.ts index 80a30d7..35f461e 100644 --- a/src/commands/new.ts +++ b/src/commands/new.ts @@ -3,13 +3,13 @@ import path from "node:path"; import slugify from "@sindresorhus/slugify"; import filenamify from "filenamify"; import matter from "gray-matter"; -import { isCancel, text } from "@clack/prompts"; +import { isCancel, text, intro, outro } from "@clack/prompts"; import type { Config, Deps } from "../lib/deps"; export function makeNewCommand({ config, pfs }: Deps) { return command({ name: "new", - description: "Create a new markdown file", + description: "Create a new post", args: { new: restPositionals({ type: string, @@ -18,6 +18,7 @@ export function makeNewCommand({ config, pfs }: Deps) { }), }, handler: async ({ new: titleArray }) => { + intro("Create a new post"); let title: string; if (titleArray.length === 0) { title = await generateTitle(); @@ -47,7 +48,7 @@ async function generateFrontmatter( const data = { [config.titleKey]: title, [config.publishedAtKey]: now, - [config.modifiedAtKey]: now, + // [config.modifiedAtKey]: now, [config.authorKey]: config.author, [config.draftKey]: true, [config.descriptionKey]: description, diff --git a/src/commands/publish.ts b/src/commands/publish.ts index e0e0d9d..3e7d8f2 100644 --- a/src/commands/publish.ts +++ b/src/commands/publish.ts @@ -1,65 +1,45 @@ import { command } from "cmd-ts"; -import { autocompleteMultiselect, isCancel } from "@clack/prompts"; +import { + autocompleteMultiselect, + isCancel, + intro, + outro, +} from "@clack/prompts"; import matter from "gray-matter"; import type { Config, Deps } from "../lib/deps"; - -type Frontmatter = Record; - -type Post = { - file: string; - content: string; -}; +import { + readPosts, + parseFrontmatter, + postsToOptions, + selectedValuesToPosts, + Post, +} from "../lib/commands"; export function makePublishCommand({ config, pfs }: Deps) { return command({ name: "publish", - description: "Publish a markdown file", + description: "Undraft a post", args: {}, handler: async () => { - const drafts = await getDraftPosts(pfs, config); - let selected = await getPostsToPublish(drafts, config); + const posts = await readPosts(pfs, config); + const drafts = posts.filter( + (post) => parseFrontmatter(post)[config.draftKey] === true, + ); + const selected = await getPostsToPublish(drafts, config); - void drafts; + for (const draft of selected) { + await updateDraftFrontMatter(draft, { config, pfs }); + } }, }); } -async function getDraftPosts(pfs: Deps["pfs"], config: Config) { - const posts = await getPosts(pfs, config); - return posts.filter((post) => { - const frontMatter = getPostFrontMatter(post); - return frontMatter[config.draftKey] === true; - }); -} - -async function getPosts(pfs: Deps["pfs"], config: Config): Promise { - const files = await pfs.readdir(config.blogDir); - const posts: Post[] = []; - - for (const file of files) { - const content = await pfs.read(`${config.blogDir}/${file}`); - posts.push({ file, content }); - } - - return posts; -} - -function getPostFrontMatter(post: Post) { - const { data } = matter(post.content); - return data as Frontmatter; -} - -async function getPostsToPublish(drafts: Post[], config: Config) { - const options = drafts.map((draft) => { - const fm = getPostFrontMatter(draft); - const title = String(fm[config.titleKey] ?? draft.file); - - return { - value: draft.file, - label: title, - hint: draft.file, - }; - }); +async function getPostsToPublish( + drafts: Post[], + config: Config, +): Promise { + const options = postsToOptions(drafts, config); + intro("Publishing posts"); const selected = await autocompleteMultiselect({ message: "Select posts to publish", @@ -67,8 +47,20 @@ async function getPostsToPublish(drafts: Post[], config: Config) { }); if (isCancel(selected)) { + outro("Publishing cancelled."); return []; } - return drafts.filter((draft) => selected.includes(draft.file)); + outro("Posts undrafted..."); + + return selectedValuesToPosts(selected, drafts); +} + +async function updateDraftFrontMatter(draft: Post, deps: Deps) { + const parsed = matter(draft.content); + const fm = parsed.data as Record; + fm[deps.config.draftKey] = false; + const updatedContent = matter.stringify(parsed.content, fm); + await deps.pfs.write(`${deps.config.blogDir}/${draft.file}`, updatedContent); + return { ...draft, content: updatedContent }; } diff --git a/src/commands/retract.ts b/src/commands/retract.ts new file mode 100644 index 0000000..89988f9 --- /dev/null +++ b/src/commands/retract.ts @@ -0,0 +1,59 @@ +import { command } from "cmd-ts"; +import { autocomplete, isCancel, intro, outro } from "@clack/prompts"; +import matter from "gray-matter"; +import type { Config, Deps } from "../lib/deps"; +import { + readPosts, + postsToOptions, + findPostByFile, + Post, +} from "../lib/commands"; + +type Frontmatter = Record; + +export function makeUnPublishCommand({ config, pfs }: Deps) { + return command({ + name: "unpublish", + description: "Unpublish a post", + args: {}, + handler: async () => { + const posts = await readPosts(pfs, config); + const post = await getPostToUnpub(posts, config); + if (!post) return; + + await unpubPost(post, { config, pfs }); + }, + }); +} + +async function getPostToUnpub( + posts: Post[], + config: Config, +): Promise { + const options = postsToOptions(posts, config); + + intro("Unpublish a post"); + + const selected = await autocomplete({ + message: "Select post to unpublish", + options, + }); + + if (isCancel(selected)) { + outro("Unpublish cancelled."); + return null; + } + outro("Post unpublished."); + + const found = findPostByFile(posts, selected as string); + return found ?? null; +} + +async function unpubPost(post: Post, deps: Deps) { + const parsed = matter(post.content); + const fm = parsed.data as Record; + fm[deps.config.draftKey] = true; + const updatedContent = matter.stringify(parsed.content, fm); + await deps.pfs.write(`${deps.config.blogDir}/${post.file}`, updatedContent); + return { ...post, content: updatedContent }; +} diff --git a/src/commands/update.ts b/src/commands/update.ts new file mode 100644 index 0000000..b120655 --- /dev/null +++ b/src/commands/update.ts @@ -0,0 +1,59 @@ +import { command } from "cmd-ts"; +import { autocomplete, isCancel, intro, outro } from "@clack/prompts"; +import matter from "gray-matter"; +import type { Config, Deps } from "../lib/deps"; +import { + readPosts, + postsToOptions, + findPostByFile, + Post, +} from "../lib/commands"; + +type Frontmatter = Record; + +export function makeUpdateCommand({ config, pfs }: Deps) { + return command({ + name: "update", + description: "Update a post's modified date", + args: {}, + handler: async () => { + const posts = await readPosts(pfs, config); + const post = await getPostToUpdate(posts, config); + if (!post) return; + + await updatePostDate(post, { config, pfs }); + }, + }); +} + +async function getPostToUpdate( + posts: Post[], + config: Config, +): Promise { + const options = postsToOptions(posts, config); + + intro("Update a post"); + + const selected = await autocomplete({ + message: "Select post to update", + options, + }); + + if (isCancel(selected)) { + outro("Update cancelled."); + return null; + } + outro("Post updated."); + + const found = findPostByFile(posts, selected as string); + return found ?? null; +} + +async function updatePostDate(post: Post, deps: Deps) { + const parsed = matter(post.content); + const fm = parsed.data as Record; + fm[deps.config.modifiedAtKey] = new Date(); + const updatedContent = matter.stringify(parsed.content, fm); + await deps.pfs.write(`${deps.config.blogDir}/${post.file}`, updatedContent); + return { ...post, content: updatedContent }; +} diff --git a/src/index.ts b/src/index.ts index 2b5f8a0..c65bed0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,8 @@ import { PoweredFileSystem } from "pwd-fs"; import { loadConfig } from "./lib/deps"; import { makeNewCommand } from "./commands/new"; import { makePublishCommand } from "./commands/publish"; +import { makeUpdateCommand } from "./commands/update"; +import { makeUnPublishCommand } from "./commands/retract"; async function main() { const pfs = new PoweredFileSystem(); @@ -15,6 +17,8 @@ async function main() { cmds: { new: makeNewCommand(deps), publish: makePublishCommand(deps), + update: makeUpdateCommand(deps), + unpublish: makeUnPublishCommand(deps), }, }); diff --git a/src/lib/commands.ts b/src/lib/commands.ts new file mode 100644 index 0000000..f0525c1 --- /dev/null +++ b/src/lib/commands.ts @@ -0,0 +1,52 @@ +import matter from "gray-matter"; +import type { Config, Deps } from "./deps"; + +export type Post = { + file: string; + content: string; +}; + +export type Frontmatter = Record; + +/** Read all files from the blog directory and return them as Post objects */ +export async function readPosts(pfs: Deps["pfs"], config: Config): Promise { + const files = (await pfs.readdir(config.blogDir)) as string[]; + const posts: Post[] = []; + + for (const file of files) { + const content = await pfs.read(`${config.blogDir}/${file}`); + posts.push({ file, content }); + } + + return posts; +} + +/** Parse frontmatter from a Post */ +export function parseFrontmatter(post: Post): Frontmatter { + const { data } = matter(post.content); + return data as Frontmatter; +} + +/** Convert Posts to prompt option objects (value/label/hint) */ +export function postsToOptions(posts: Post[], config: Config) { + return posts.map((post) => { + const fm = parseFrontmatter(post); + const title = String(fm[config.titleKey] ?? post.file); + + return { + value: post.file, + label: title, + hint: post.file, + }; + }); +} + +/** Take the raw selected values returned by the prompt and return matching Post[] */ +export function selectedValuesToPosts(selected: string[] | symbol, posts: Post[]): Post[] { + if (typeof selected === "symbol") return []; + return posts.filter((p) => selected.includes(p.file)); +} + +export function findPostByFile(posts: Post[], fileName: string): Post | undefined { + return posts.find((p) => p.file === fileName); +} diff --git a/src/lib/tags.ts b/src/lib/tags.ts new file mode 100644 index 0000000..f5bb63f --- /dev/null +++ b/src/lib/tags.ts @@ -0,0 +1,80 @@ +import matter from "gray-matter"; +import type { Deps } from "./deps"; + +export type GetAllTagsOptions = { + concurrency?: number; // how many files to parse in parallel + lowerCase?: boolean; // normalize tags to lowercase + sort?: boolean; // sort result +}; + +/** + * Read all markdown files in `config.blogDir`, extract tags using the configured `tagsKey`, + * normalize & dedupe them, and return the list. + * + * This implementation uses the PoweredFileSystem API (pfs) for all path resolution and IO + * instead of `path.join` so it respects the pfs instance's `pwd` resolution semantics. + */ +export async function getAllTags( + deps: Deps, + opts: GetAllTagsOptions = {}, +): Promise { + const { pfs, config } = deps; + const { concurrency = 8, lowerCase = true, sort = true } = opts; + + const files = (await pfs.readdir(config.blogDir)) as string[]; + const mdFiles = files.filter((f) => f.endsWith(".md") || f.endsWith(".markdown")); + + // split into batches of size `concurrency` + const batches: string[][] = []; + for (let i = 0; i < mdFiles.length; i += concurrency) { + batches.push(mdFiles.slice(i, i + concurrency)); + } + + const tagSet = new Set(); + + async function parseFile(file: string): Promise { + try { + // Resolve using pfs so it respects the base directory configured on the instance + const filePath = pfs.resolve(`${config.blogDir}/${file}`); + const content = await pfs.read(filePath); + const { data } = matter(content); + const raw = (data as Record)[config.tagsKey]; + return parseRawTags(raw); + } catch { + // ignore single-file failures + return []; + } + } + + for (const batch of batches) { + const results = await Promise.all(batch.map(parseFile)); + for (const tags of results) { + for (let t of tags) { + if (!t) continue; + t = t.trim(); + if (!t) continue; + if (lowerCase) t = t.toLowerCase(); + tagSet.add(t); + } + } + } + + const out = Array.from(tagSet); + if (sort) out.sort(); + return out; +} + +function parseRawTags(raw: unknown): string[] { + if (!raw) return []; + if (Array.isArray(raw)) return raw.map(String).flatMap(splitOnSeparators).map((s) => s.trim()).filter(Boolean); + if (typeof raw === "string") return splitOnSeparators(raw).map((s) => s.trim()).filter(Boolean); + return []; +} + +function splitOnSeparators(s: string): string[] { + // Accept comma separated values and newline-separated lists, plus plain single tag strings + if (s.includes(",") || s.includes("\n")) { + return s.split(/[,\n]+/).map((x) => x.trim()).filter(Boolean); + } + return [s]; +} diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index c495260..b4231b0 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/deps.ts","./src/index.ts","./src/commands/new.ts"],"version":"6.0.3"} \ No newline at end of file +{"root":["./src/index.ts","./src/commands/new.ts","./src/commands/publish.ts","./src/commands/update.ts","./src/lib/deps.ts"],"version":"6.0.3"} \ No newline at end of file