[READ-ONLY] Mirror of https://github.com/plttn/mkd.
0

Configure Feed

Select the types of activity you want to include in your feed.

Soft Tabs Please

Jack Platten (May 25, 2026, 12:33 PM -0700) 827433de 642be4ad

+546 -546
+9 -9
.changeset/config.json
··· 1 1 { 2 - "$schema": "https://unpkg.com/@changesets/config@3.1.4/schema.json", 3 - "changelog": "@changesets/cli/changelog", 4 - "commit": false, 5 - "fixed": [], 6 - "linked": [], 7 - "access": "restricted", 8 - "baseBranch": "main", 9 - "updateInternalDependencies": "patch", 10 - "ignore": [] 2 + "$schema": "https://unpkg.com/@changesets/config@3.1.4/schema.json", 3 + "changelog": "@changesets/cli/changelog", 4 + "commit": false, 5 + "fixed": [], 6 + "linked": [], 7 + "access": "restricted", 8 + "baseBranch": "main", 9 + "updateInternalDependencies": "patch", 10 + "ignore": [] 11 11 }
+32 -32
biome.json
··· 1 1 { 2 - "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json", 3 - "vcs": { 4 - "enabled": true, 5 - "clientKind": "git", 6 - "useIgnoreFile": true 7 - }, 8 - "files": { 9 - "includes": ["**", "!!**/dist"] 10 - }, 11 - "formatter": { 12 - "enabled": true, 13 - "indentStyle": "tab" 14 - }, 15 - "linter": { 16 - "enabled": true, 17 - "rules": { 18 - "recommended": true 19 - } 20 - }, 21 - "javascript": { 22 - "formatter": { 23 - "quoteStyle": "double" 24 - } 25 - }, 26 - "assist": { 27 - "enabled": true, 28 - "actions": { 29 - "source": { 30 - "organizeImports": "on" 31 - } 32 - } 33 - } 2 + "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json", 3 + "vcs": { 4 + "enabled": true, 5 + "clientKind": "git", 6 + "useIgnoreFile": true 7 + }, 8 + "files": { 9 + "includes": ["**", "!!**/dist"] 10 + }, 11 + "formatter": { 12 + "enabled": true, 13 + "indentStyle": "space" 14 + }, 15 + "linter": { 16 + "enabled": true, 17 + "rules": { 18 + "recommended": true 19 + } 20 + }, 21 + "javascript": { 22 + "formatter": { 23 + "quoteStyle": "double" 24 + } 25 + }, 26 + "assist": { 27 + "enabled": true, 28 + "actions": { 29 + "source": { 30 + "organizeImports": "on" 31 + } 32 + } 33 + } 34 34 }
+55 -55
package.json
··· 1 1 { 2 - "name": "@plttn/mkd", 3 - "version": "0.2.2", 4 - "description": "A tool for managing markdown blog posts", 5 - "scripts": { 6 - "dev": "tsx ./src/index.ts", 7 - "build": "tsdown", 8 - "prepublishOnly": "pnpm run build", 9 - "ci:publish": "pnpm build && changeset publish" 10 - }, 11 - "type": "module", 12 - "bin": { 13 - "mkd": "dist/index.cjs" 14 - }, 15 - "files": [ 16 - "dist" 17 - ], 18 - "main": "./dist/index.cjs", 19 - "module": "dist/index.js", 20 - "types": "dist/index.d.ts", 21 - "exports": { 22 - ".": { 23 - "import": "./dist/index.js", 24 - "require": "./dist/index.cjs" 25 - }, 26 - "./package.json": "./package.json" 27 - }, 28 - "keywords": [], 29 - "author": "", 30 - "license": "MIT", 31 - "packageManager": "pnpm@11.3.0", 32 - "devDependencies": { 33 - "@biomejs/biome": "2.4.15", 34 - "@changesets/cli": "^2.31.0", 35 - "@types/node": "^25.9.1", 36 - "prettier": "3.8.3", 37 - "tsdown": "^0.22.0", 38 - "tsx": "^4.22.3", 39 - "typescript": "^6.0.3" 40 - }, 41 - "dependencies": { 42 - "@clack/prompts": "^1.4.0", 43 - "@sindresorhus/slugify": "^3.0.0", 44 - "cmd-ts": "^0.15.0", 45 - "filenamify": "^7.0.1", 46 - "gray-matter": "^4.0.3", 47 - "pwd-fs": "^3.5.4" 48 - }, 49 - "repository": { 50 - "type": "git", 51 - "url": "https://github.com/plttn/mkd.git" 52 - }, 53 - "publishConfig": { 54 - "access": "public", 55 - "provenance": true 56 - } 2 + "name": "@plttn/mkd", 3 + "version": "0.2.2", 4 + "description": "A tool for managing markdown blog posts", 5 + "scripts": { 6 + "dev": "tsx ./src/index.ts", 7 + "build": "tsdown", 8 + "prepublishOnly": "pnpm run build", 9 + "ci:publish": "pnpm build && changeset publish" 10 + }, 11 + "type": "module", 12 + "bin": { 13 + "mkd": "dist/index.cjs" 14 + }, 15 + "files": [ 16 + "dist" 17 + ], 18 + "main": "./dist/index.cjs", 19 + "module": "dist/index.js", 20 + "types": "dist/index.d.ts", 21 + "exports": { 22 + ".": { 23 + "import": "./dist/index.js", 24 + "require": "./dist/index.cjs" 25 + }, 26 + "./package.json": "./package.json" 27 + }, 28 + "keywords": [], 29 + "author": "", 30 + "license": "MIT", 31 + "packageManager": "pnpm@11.3.0", 32 + "devDependencies": { 33 + "@biomejs/biome": "2.4.15", 34 + "@changesets/cli": "^2.31.0", 35 + "@types/node": "^25.9.1", 36 + "prettier": "3.8.3", 37 + "tsdown": "^0.22.0", 38 + "tsx": "^4.22.3", 39 + "typescript": "^6.0.3" 40 + }, 41 + "dependencies": { 42 + "@clack/prompts": "^1.4.0", 43 + "@sindresorhus/slugify": "^3.0.0", 44 + "cmd-ts": "^0.15.0", 45 + "filenamify": "^7.0.1", 46 + "gray-matter": "^4.0.3", 47 + "pwd-fs": "^3.5.4" 48 + }, 49 + "repository": { 50 + "type": "git", 51 + "url": "https://github.com/plttn/mkd.git" 52 + }, 53 + "publishConfig": { 54 + "access": "public", 55 + "provenance": true 56 + } 57 57 }
+93 -93
src/commands/init.ts
··· 3 3 import type { Deps } from "../lib/deps"; 4 4 5 5 export function makeInitCommand({ config, pfs }: Deps) { 6 - return command({ 7 - name: "init", 8 - description: "Create or update mkd.json configuration", 9 - args: {}, 10 - handler: async () => { 11 - intro("Initialize mkd configuration"); 6 + return command({ 7 + name: "init", 8 + description: "Create or update mkd.json configuration", 9 + args: {}, 10 + handler: async () => { 11 + intro("Initialize mkd configuration"); 12 12 13 - const blogDir = await text({ 14 - message: "Directory where generated posts are written", 15 - defaultValue: String(config.blogDir ?? "./src/blog"), 16 - }); 17 - if (isCancel(blogDir)) { 18 - outro("Init cancelled"); 19 - return; 20 - } 13 + const blogDir = await text({ 14 + message: "Directory where generated posts are written", 15 + defaultValue: String(config.blogDir ?? "./src/blog"), 16 + }); 17 + if (isCancel(blogDir)) { 18 + outro("Init cancelled"); 19 + return; 20 + } 21 21 22 - const author = await text({ 23 - message: "Default author", 24 - defaultValue: String(config.author ?? ""), 25 - }); 26 - if (isCancel(author)) { 27 - outro("Init cancelled"); 28 - return; 29 - } 22 + const author = await text({ 23 + message: "Default author", 24 + defaultValue: String(config.author ?? ""), 25 + }); 26 + if (isCancel(author)) { 27 + outro("Init cancelled"); 28 + return; 29 + } 30 30 31 - const titleKey = await text({ 32 - message: "Title frontmatter key", 33 - defaultValue: String(config.titleKey ?? "title"), 34 - }); 35 - if (isCancel(titleKey)) { 36 - outro("Init cancelled"); 37 - return; 38 - } 31 + const titleKey = await text({ 32 + message: "Title frontmatter key", 33 + defaultValue: String(config.titleKey ?? "title"), 34 + }); 35 + if (isCancel(titleKey)) { 36 + outro("Init cancelled"); 37 + return; 38 + } 39 39 40 - const publishedAtKey = await text({ 41 - message: "Published date frontmatter key", 42 - defaultValue: String(config.publishedAtKey ?? "publishedAt"), 43 - }); 44 - if (isCancel(publishedAtKey)) { 45 - outro("Init cancelled"); 46 - return; 47 - } 40 + const publishedAtKey = await text({ 41 + message: "Published date frontmatter key", 42 + defaultValue: String(config.publishedAtKey ?? "publishedAt"), 43 + }); 44 + if (isCancel(publishedAtKey)) { 45 + outro("Init cancelled"); 46 + return; 47 + } 48 48 49 - const modifiedAtKey = await text({ 50 - message: "Modified date frontmatter key", 51 - defaultValue: String(config.modifiedAtKey ?? "updatedAt"), 52 - }); 53 - if (isCancel(modifiedAtKey)) { 54 - outro("Init cancelled"); 55 - return; 56 - } 49 + const modifiedAtKey = await text({ 50 + message: "Modified date frontmatter key", 51 + defaultValue: String(config.modifiedAtKey ?? "updatedAt"), 52 + }); 53 + if (isCancel(modifiedAtKey)) { 54 + outro("Init cancelled"); 55 + return; 56 + } 57 57 58 - const authorKey = await text({ 59 - message: "Author frontmatter key", 60 - defaultValue: String(config.authorKey ?? "author"), 61 - }); 62 - if (isCancel(authorKey)) { 63 - outro("Init cancelled"); 64 - return; 65 - } 58 + const authorKey = await text({ 59 + message: "Author frontmatter key", 60 + defaultValue: String(config.authorKey ?? "author"), 61 + }); 62 + if (isCancel(authorKey)) { 63 + outro("Init cancelled"); 64 + return; 65 + } 66 66 67 - const draftKey = await text({ 68 - message: "Draft frontmatter key", 69 - defaultValue: String(config.draftKey ?? "draft"), 70 - }); 71 - if (isCancel(draftKey)) { 72 - outro("Init cancelled"); 73 - return; 74 - } 67 + const draftKey = await text({ 68 + message: "Draft frontmatter key", 69 + defaultValue: String(config.draftKey ?? "draft"), 70 + }); 71 + if (isCancel(draftKey)) { 72 + outro("Init cancelled"); 73 + return; 74 + } 75 75 76 - const descriptionKey = await text({ 77 - message: "Description frontmatter key", 78 - defaultValue: String(config.descriptionKey ?? "description"), 79 - }); 80 - if (isCancel(descriptionKey)) { 81 - outro("Init cancelled"); 82 - return; 83 - } 76 + const descriptionKey = await text({ 77 + message: "Description frontmatter key", 78 + defaultValue: String(config.descriptionKey ?? "description"), 79 + }); 80 + if (isCancel(descriptionKey)) { 81 + outro("Init cancelled"); 82 + return; 83 + } 84 84 85 - const tagsKey = await text({ 86 - message: "Tags frontmatter key", 87 - defaultValue: String(config.tagsKey ?? "tags"), 88 - }); 89 - if (isCancel(tagsKey)) { 90 - outro("Init cancelled"); 91 - return; 92 - } 85 + const tagsKey = await text({ 86 + message: "Tags frontmatter key", 87 + defaultValue: String(config.tagsKey ?? "tags"), 88 + }); 89 + if (isCancel(tagsKey)) { 90 + outro("Init cancelled"); 91 + return; 92 + } 93 93 94 - const newConfig = { 95 - blogDir: String(blogDir), 96 - author: String(author), 97 - publishedAtKey: String(publishedAtKey), 98 - modifiedAtKey: String(modifiedAtKey), 99 - authorKey: String(authorKey), 100 - draftKey: String(draftKey), 101 - descriptionKey: String(descriptionKey), 102 - tagsKey: String(tagsKey), 103 - titleKey: String(titleKey), 104 - }; 94 + const newConfig = { 95 + blogDir: String(blogDir), 96 + author: String(author), 97 + publishedAtKey: String(publishedAtKey), 98 + modifiedAtKey: String(modifiedAtKey), 99 + authorKey: String(authorKey), 100 + draftKey: String(draftKey), 101 + descriptionKey: String(descriptionKey), 102 + tagsKey: String(tagsKey), 103 + titleKey: String(titleKey), 104 + }; 105 105 106 - await pfs.write("./mkd.json", `${JSON.stringify(newConfig, null, 2)}\n`); 106 + await pfs.write("./mkd.json", `${JSON.stringify(newConfig, null, 2)}\n`); 107 107 108 - outro("Configuration saved to mkd.json"); 109 - }, 110 - }); 108 + outro("Configuration saved to mkd.json"); 109 + }, 110 + }); 111 111 }
+63 -63
src/commands/new.ts
··· 7 7 import type { Config, Deps } from "../lib/deps"; 8 8 9 9 export function makeNewCommand({ config, pfs }: Deps) { 10 - return command({ 11 - name: "new", 12 - description: "Create a new post", 13 - args: { 14 - new: restPositionals({ 15 - type: string, 16 - displayName: "file", 17 - description: "name of the new post", 18 - }), 19 - }, 20 - handler: async ({ new: titleArray }) => { 21 - intro("Create a new post"); 22 - let title: string; 23 - if (titleArray.length === 0) { 24 - title = await generateTitle(); 25 - if (title === "") { 26 - return; 27 - } 28 - } else { 29 - title = titleArray.join(" "); 30 - } 31 - const slug = slugify(title); 32 - const fileName = filenamify(slug); 33 - const frontmatter = await generateFrontmatter(title, config); 34 - const filePath = path.join(config.blogDir, `${fileName}.md`); 35 - outro(`${title} created successfully`); 36 - await pfs.write(filePath, frontmatter); 37 - }, 38 - }); 10 + return command({ 11 + name: "new", 12 + description: "Create a new post", 13 + args: { 14 + new: restPositionals({ 15 + type: string, 16 + displayName: "file", 17 + description: "name of the new post", 18 + }), 19 + }, 20 + handler: async ({ new: titleArray }) => { 21 + intro("Create a new post"); 22 + let title: string; 23 + if (titleArray.length === 0) { 24 + title = await generateTitle(); 25 + if (title === "") { 26 + return; 27 + } 28 + } else { 29 + title = titleArray.join(" "); 30 + } 31 + const slug = slugify(title); 32 + const fileName = filenamify(slug); 33 + const frontmatter = await generateFrontmatter(title, config); 34 + const filePath = path.join(config.blogDir, `${fileName}.md`); 35 + outro(`${title} created successfully`); 36 + await pfs.write(filePath, frontmatter); 37 + }, 38 + }); 39 39 } 40 40 41 41 async function generateFrontmatter( 42 - title: string, 43 - config: Config, 42 + title: string, 43 + config: Config, 44 44 ): Promise<string> { 45 - const description = await makeDescription(title); 46 - // const now = new Date(); 45 + const description = await makeDescription(title); 46 + // const now = new Date(); 47 47 48 - const data = { 49 - [config.titleKey]: title, 50 - [config.publishedAtKey]: new Date("3000-01-01T00:00:00Z"), 51 - [config.authorKey]: config.author, 52 - [config.draftKey]: true, 53 - [config.descriptionKey]: description, 54 - [config.tagsKey]: [], 55 - }; 48 + const data = { 49 + [config.titleKey]: title, 50 + [config.publishedAtKey]: new Date("3000-01-01T00:00:00Z"), 51 + [config.authorKey]: config.author, 52 + [config.draftKey]: true, 53 + [config.descriptionKey]: description, 54 + [config.tagsKey]: [], 55 + }; 56 56 57 - return matter.stringify("", data); 57 + return matter.stringify("", data); 58 58 } 59 59 60 60 async function makeDescription(title: string): Promise<string> { 61 - const description = await text({ 62 - message: "Enter a description for the post:", 63 - defaultValue: title, 64 - }); 61 + const description = await text({ 62 + message: "Enter a description for the post:", 63 + defaultValue: title, 64 + }); 65 65 66 - if (isCancel(description)) { 67 - return ""; 68 - } 66 + if (isCancel(description)) { 67 + return ""; 68 + } 69 69 70 - return description; 70 + return description; 71 71 } 72 72 73 73 async function generateTitle(): Promise<string> { 74 - const title = await text({ 75 - message: "Enter a title for the post:", 76 - validate: (value) => { 77 - if (!value) { 78 - return "Title required"; 79 - } 80 - return undefined; 81 - }, 82 - }); 74 + const title = await text({ 75 + message: "Enter a title for the post:", 76 + validate: (value) => { 77 + if (!value) { 78 + return "Title required"; 79 + } 80 + return undefined; 81 + }, 82 + }); 83 83 84 - if (isCancel(title)) { 85 - return ""; 86 - } 84 + if (isCancel(title)) { 85 + return ""; 86 + } 87 87 88 - return title; 88 + return title; 89 89 }
+45 -45
src/commands/publish.ts
··· 2 2 import { command } from "cmd-ts"; 3 3 import matter from "gray-matter"; 4 4 import { 5 - type Post, 6 - parseFrontmatter, 7 - postsToOptions, 8 - readPosts, 9 - selectedValuesToPosts, 5 + type Post, 6 + parseFrontmatter, 7 + postsToOptions, 8 + readPosts, 9 + selectedValuesToPosts, 10 10 } from "../lib/commands"; 11 11 import type { Config, Deps } from "../lib/deps"; 12 12 13 13 export function makePublishCommand({ config, pfs }: Deps) { 14 - return command({ 15 - name: "publish", 16 - description: "Undraft a post", 17 - args: {}, 18 - handler: async () => { 19 - const posts = await readPosts(pfs, config); 20 - const drafts = posts.filter( 21 - (post) => parseFrontmatter(post)[config.draftKey] === true, 22 - ); 23 - const selected = await getPostsToPublish(drafts, config); 14 + return command({ 15 + name: "publish", 16 + description: "Undraft a post", 17 + args: {}, 18 + handler: async () => { 19 + const posts = await readPosts(pfs, config); 20 + const drafts = posts.filter( 21 + (post) => parseFrontmatter(post)[config.draftKey] === true, 22 + ); 23 + const selected = await getPostsToPublish(drafts, config); 24 24 25 - for (const draft of selected) { 26 - await updateDraftFrontMatter(draft, { config, pfs }); 27 - } 28 - }, 29 - }); 25 + for (const draft of selected) { 26 + await updateDraftFrontMatter(draft, { config, pfs }); 27 + } 28 + }, 29 + }); 30 30 } 31 31 32 32 async function getPostsToPublish( 33 - drafts: Post[], 34 - config: Config, 33 + drafts: Post[], 34 + config: Config, 35 35 ): Promise<Post[]> { 36 - const options = postsToOptions(drafts, config); 37 - intro("Publishing posts"); 36 + const options = postsToOptions(drafts, config); 37 + intro("Publishing posts"); 38 38 39 - const selected = await autocomplete({ 40 - message: "Select post to publish", 41 - options, 42 - }); 39 + const selected = await autocomplete({ 40 + message: "Select post to publish", 41 + options, 42 + }); 43 43 44 - if (isCancel(selected)) { 45 - outro("Publishing cancelled."); 46 - return []; 47 - } 44 + if (isCancel(selected)) { 45 + outro("Publishing cancelled."); 46 + return []; 47 + } 48 48 49 - outro("Posts undrafted..."); 49 + outro("Posts undrafted..."); 50 50 51 - return selectedValuesToPosts( 52 - Array.isArray(selected) ? selected : [selected], 53 - drafts, 54 - ); 51 + return selectedValuesToPosts( 52 + Array.isArray(selected) ? selected : [selected], 53 + drafts, 54 + ); 55 55 } 56 56 57 57 async function updateDraftFrontMatter(draft: Post, deps: Deps) { 58 - const now = new Date(); 59 - const parsed = matter(draft.content); 60 - const fm = parsed.data as Record<string, unknown>; 61 - fm[deps.config.draftKey] = false; 62 - fm[deps.config.publishedAtKey] = now; 63 - const updatedContent = matter.stringify(parsed.content, fm); 64 - await deps.pfs.write(`${deps.config.blogDir}/${draft.file}`, updatedContent); 65 - return { ...draft, content: updatedContent }; 58 + const now = new Date(); 59 + const parsed = matter(draft.content); 60 + const fm = parsed.data as Record<string, unknown>; 61 + fm[deps.config.draftKey] = false; 62 + fm[deps.config.publishedAtKey] = now; 63 + const updatedContent = matter.stringify(parsed.content, fm); 64 + await deps.pfs.write(`${deps.config.blogDir}/${draft.file}`, updatedContent); 65 + return { ...draft, content: updatedContent }; 66 66 }
+36 -36
src/commands/unpublish.ts
··· 2 2 import { command } from "cmd-ts"; 3 3 import matter from "gray-matter"; 4 4 import { 5 - findPostByFile, 6 - type Post, 7 - postsToOptions, 8 - readPosts, 5 + findPostByFile, 6 + type Post, 7 + postsToOptions, 8 + readPosts, 9 9 } from "../lib/commands"; 10 10 import type { Config, Deps } from "../lib/deps"; 11 11 12 12 export function makeUnPublishCommand({ config, pfs }: Deps) { 13 - return command({ 14 - name: "unpublish", 15 - description: "Unpublish a post", 16 - args: {}, 17 - handler: async () => { 18 - const posts = await readPosts(pfs, config); 19 - const post = await getPostToUnpub(posts, config); 20 - if (!post) return; 13 + return command({ 14 + name: "unpublish", 15 + description: "Unpublish a post", 16 + args: {}, 17 + handler: async () => { 18 + const posts = await readPosts(pfs, config); 19 + const post = await getPostToUnpub(posts, config); 20 + if (!post) return; 21 21 22 - await unpubPost(post, { config, pfs }); 23 - }, 24 - }); 22 + await unpubPost(post, { config, pfs }); 23 + }, 24 + }); 25 25 } 26 26 27 27 async function getPostToUnpub( 28 - posts: Post[], 29 - config: Config, 28 + posts: Post[], 29 + config: Config, 30 30 ): Promise<Post | null> { 31 - const options = postsToOptions(posts, config); 31 + const options = postsToOptions(posts, config); 32 32 33 - intro("Unpublish a post"); 33 + intro("Unpublish a post"); 34 34 35 - const selected = await autocomplete({ 36 - message: "Select post to unpublish", 37 - options, 38 - }); 35 + const selected = await autocomplete({ 36 + message: "Select post to unpublish", 37 + options, 38 + }); 39 39 40 - if (isCancel(selected)) { 41 - outro("Unpublish cancelled."); 42 - return null; 43 - } 44 - outro("Post unpublished."); 40 + if (isCancel(selected)) { 41 + outro("Unpublish cancelled."); 42 + return null; 43 + } 44 + outro("Post unpublished."); 45 45 46 - const found = findPostByFile(posts, selected as string); 47 - return found ?? null; 46 + const found = findPostByFile(posts, selected as string); 47 + return found ?? null; 48 48 } 49 49 50 50 async function unpubPost(post: Post, deps: Deps) { 51 - const parsed = matter(post.content); 52 - const fm = parsed.data as Record<string, unknown>; 53 - fm[deps.config.draftKey] = true; 54 - const updatedContent = matter.stringify(parsed.content, fm); 55 - await deps.pfs.write(`${deps.config.blogDir}/${post.file}`, updatedContent); 56 - return { ...post, content: updatedContent }; 51 + const parsed = matter(post.content); 52 + const fm = parsed.data as Record<string, unknown>; 53 + fm[deps.config.draftKey] = true; 54 + const updatedContent = matter.stringify(parsed.content, fm); 55 + await deps.pfs.write(`${deps.config.blogDir}/${post.file}`, updatedContent); 56 + return { ...post, content: updatedContent }; 57 57 }
+36 -36
src/commands/update.ts
··· 2 2 import { command } from "cmd-ts"; 3 3 import matter from "gray-matter"; 4 4 import { 5 - findPostByFile, 6 - type Post, 7 - postsToOptions, 8 - readPosts, 5 + findPostByFile, 6 + type Post, 7 + postsToOptions, 8 + readPosts, 9 9 } from "../lib/commands"; 10 10 import type { Config, Deps } from "../lib/deps"; 11 11 12 12 export function makeUpdateCommand({ config, pfs }: Deps) { 13 - return command({ 14 - name: "update", 15 - description: "Update a post's modified date", 16 - args: {}, 17 - handler: async () => { 18 - const posts = await readPosts(pfs, config); 19 - const post = await getPostToUpdate(posts, config); 20 - if (!post) return; 13 + return command({ 14 + name: "update", 15 + description: "Update a post's modified date", 16 + args: {}, 17 + handler: async () => { 18 + const posts = await readPosts(pfs, config); 19 + const post = await getPostToUpdate(posts, config); 20 + if (!post) return; 21 21 22 - await updatePostDate(post, { config, pfs }); 23 - }, 24 - }); 22 + await updatePostDate(post, { config, pfs }); 23 + }, 24 + }); 25 25 } 26 26 27 27 async function getPostToUpdate( 28 - posts: Post[], 29 - config: Config, 28 + posts: Post[], 29 + config: Config, 30 30 ): Promise<Post | null> { 31 - const options = postsToOptions(posts, config); 31 + const options = postsToOptions(posts, config); 32 32 33 - intro("Update a post"); 33 + intro("Update a post"); 34 34 35 - const selected = await autocomplete({ 36 - message: "Select post to update", 37 - options, 38 - }); 35 + const selected = await autocomplete({ 36 + message: "Select post to update", 37 + options, 38 + }); 39 39 40 - if (isCancel(selected)) { 41 - outro("Update cancelled."); 42 - return null; 43 - } 44 - outro("Post updated."); 40 + if (isCancel(selected)) { 41 + outro("Update cancelled."); 42 + return null; 43 + } 44 + outro("Post updated."); 45 45 46 - const found = findPostByFile(posts, selected as string); 47 - return found ?? null; 46 + const found = findPostByFile(posts, selected as string); 47 + return found ?? null; 48 48 } 49 49 50 50 async function updatePostDate(post: Post, deps: Deps) { 51 - const parsed = matter(post.content); 52 - const fm = parsed.data as Record<string, unknown>; 53 - fm[deps.config.modifiedAtKey] = new Date(); 54 - const updatedContent = matter.stringify(parsed.content, fm); 55 - await deps.pfs.write(`${deps.config.blogDir}/${post.file}`, updatedContent); 56 - return { ...post, content: updatedContent }; 51 + const parsed = matter(post.content); 52 + const fm = parsed.data as Record<string, unknown>; 53 + fm[deps.config.modifiedAtKey] = new Date(); 54 + const updatedContent = matter.stringify(parsed.content, fm); 55 + await deps.pfs.write(`${deps.config.blogDir}/${post.file}`, updatedContent); 56 + return { ...post, content: updatedContent }; 57 57 }
+16 -16
src/index.ts
··· 9 9 import { loadConfig } from "./lib/deps"; 10 10 11 11 async function main() { 12 - const pfs = new PoweredFileSystem(); 13 - const config = await loadConfig(pfs); 14 - const deps = { config, pfs }; 12 + const pfs = new PoweredFileSystem(); 13 + const config = await loadConfig(pfs); 14 + const deps = { config, pfs }; 15 15 16 - const app = subcommands({ 17 - name: "mkd", 18 - cmds: { 19 - new: makeNewCommand(deps), 20 - publish: makePublishCommand(deps), 21 - update: makeUpdateCommand(deps), 22 - unpublish: makeUnPublishCommand(deps), 23 - init: makeInitCommand(deps), 24 - }, 25 - }); 16 + const app = subcommands({ 17 + name: "mkd", 18 + cmds: { 19 + new: makeNewCommand(deps), 20 + publish: makePublishCommand(deps), 21 + update: makeUpdateCommand(deps), 22 + unpublish: makeUnPublishCommand(deps), 23 + init: makeInitCommand(deps), 24 + }, 25 + }); 26 26 27 - await run(app, process.argv.slice(2)); 27 + await run(app, process.argv.slice(2)); 28 28 } 29 29 30 30 main().catch((error) => { 31 - console.error(error); 32 - process.exit(1); 31 + console.error(error); 32 + process.exit(1); 33 33 });
+29 -29
src/lib/commands.ts
··· 2 2 import type { Config, Deps } from "./deps"; 3 3 4 4 export type Post = { 5 - file: string; 6 - content: string; 5 + file: string; 6 + content: string; 7 7 }; 8 8 9 9 export type Frontmatter = Record<string, unknown>; 10 10 11 11 /** Read all files from the blog directory and return them as Post objects */ 12 12 export async function readPosts( 13 - pfs: Deps["pfs"], 14 - config: Config, 13 + pfs: Deps["pfs"], 14 + config: Config, 15 15 ): Promise<Post[]> { 16 - const files = (await pfs.readdir(config.blogDir)) as string[]; 17 - const posts: Post[] = []; 16 + const files = (await pfs.readdir(config.blogDir)) as string[]; 17 + const posts: Post[] = []; 18 18 19 - for (const file of files) { 20 - const content = await pfs.read(`${config.blogDir}/${file}`); 21 - posts.push({ file, content }); 22 - } 19 + for (const file of files) { 20 + const content = await pfs.read(`${config.blogDir}/${file}`); 21 + posts.push({ file, content }); 22 + } 23 23 24 - return posts; 24 + return posts; 25 25 } 26 26 27 27 /** Parse frontmatter from a Post */ 28 28 export function parseFrontmatter(post: Post): Frontmatter { 29 - const { data } = matter(post.content); 30 - return data as Frontmatter; 29 + const { data } = matter(post.content); 30 + return data as Frontmatter; 31 31 } 32 32 33 33 /** Convert Posts to prompt option objects (value/label/hint) */ 34 34 export function postsToOptions(posts: Post[], config: Config) { 35 - return posts.map((post) => { 36 - const fm = parseFrontmatter(post); 37 - const title = String(fm[config.titleKey] ?? post.file); 35 + return posts.map((post) => { 36 + const fm = parseFrontmatter(post); 37 + const title = String(fm[config.titleKey] ?? post.file); 38 38 39 - return { 40 - value: post.file, 41 - label: title, 42 - hint: post.file, 43 - }; 44 - }); 39 + return { 40 + value: post.file, 41 + label: title, 42 + hint: post.file, 43 + }; 44 + }); 45 45 } 46 46 47 47 /** Take the raw selected values returned by the prompt and return matching Post[] */ 48 48 export function selectedValuesToPosts( 49 - selected: string[] | symbol, 50 - posts: Post[], 49 + selected: string[] | symbol, 50 + posts: Post[], 51 51 ): Post[] { 52 - if (typeof selected === "symbol") return []; 53 - return posts.filter((p) => selected.includes(p.file)); 52 + if (typeof selected === "symbol") return []; 53 + return posts.filter((p) => selected.includes(p.file)); 54 54 } 55 55 56 56 export function findPostByFile( 57 - posts: Post[], 58 - fileName: string, 57 + posts: Post[], 58 + fileName: string, 59 59 ): Post | undefined { 60 - return posts.find((p) => p.file === fileName); 60 + return posts.find((p) => p.file === fileName); 61 61 }
+38 -38
src/lib/deps.ts
··· 1 1 import type { PoweredFileSystem } from "pwd-fs"; 2 2 3 3 export type Config = { 4 - blogDir: string; 5 - titleKey: string; 6 - author: string; 7 - publishedAtKey: string; 8 - modifiedAtKey: string; 9 - authorKey: string; 10 - draftKey: string; 11 - descriptionKey: string; 12 - tagsKey: string; 4 + blogDir: string; 5 + titleKey: string; 6 + author: string; 7 + publishedAtKey: string; 8 + modifiedAtKey: string; 9 + authorKey: string; 10 + draftKey: string; 11 + descriptionKey: string; 12 + tagsKey: string; 13 13 }; 14 14 15 15 export type Deps = { 16 - config: Config; 17 - pfs: PoweredFileSystem; 16 + config: Config; 17 + pfs: PoweredFileSystem; 18 18 }; 19 19 20 20 const defaultConfig: Config = { 21 - blogDir: "./src/blog", 22 - titleKey: "title", 23 - author: "", 24 - publishedAtKey: "publishedAt", 25 - modifiedAtKey: "updatedAt", 26 - authorKey: "author", 27 - draftKey: "draft", 28 - descriptionKey: "description", 29 - tagsKey: "tags", 21 + blogDir: "./src/blog", 22 + titleKey: "title", 23 + author: "", 24 + publishedAtKey: "publishedAt", 25 + modifiedAtKey: "updatedAt", 26 + authorKey: "author", 27 + draftKey: "draft", 28 + descriptionKey: "description", 29 + tagsKey: "tags", 30 30 }; 31 31 32 32 export async function loadConfig(pfs: PoweredFileSystem): Promise<Config> { 33 - try { 34 - const raw = await pfs.read("./mkd.json"); 33 + try { 34 + const raw = await pfs.read("./mkd.json"); 35 35 36 - if (!raw.trim()) { 37 - return defaultConfig; 38 - } 36 + if (!raw.trim()) { 37 + return defaultConfig; 38 + } 39 39 40 - const parsed = JSON.parse(raw); 40 + const parsed = JSON.parse(raw); 41 41 42 - if (!isRecord(parsed)) { 43 - return defaultConfig; 44 - } 42 + if (!isRecord(parsed)) { 43 + return defaultConfig; 44 + } 45 45 46 - const { $schema: _schema, ...config } = parsed; 46 + const { $schema: _schema, ...config } = parsed; 47 47 48 - return { 49 - ...defaultConfig, 50 - ...config, 51 - }; 52 - } catch { 53 - return defaultConfig; 54 - } 48 + return { 49 + ...defaultConfig, 50 + ...config, 51 + }; 52 + } catch { 53 + return defaultConfig; 54 + } 55 55 } 56 56 57 57 function isRecord(value: unknown): value is Record<string, unknown> { 58 - return typeof value === "object" && value !== null && !Array.isArray(value); 58 + return typeof value === "object" && value !== null && !Array.isArray(value); 59 59 }
+65 -65
src/lib/tags.ts
··· 2 2 import type { Deps } from "./deps"; 3 3 4 4 export type GetAllTagsOptions = { 5 - concurrency?: number; // how many files to parse in parallel 6 - lowerCase?: boolean; // normalize tags to lowercase 7 - sort?: boolean; // sort result 5 + concurrency?: number; // how many files to parse in parallel 6 + lowerCase?: boolean; // normalize tags to lowercase 7 + sort?: boolean; // sort result 8 8 }; 9 9 10 10 /** ··· 15 15 * instead of `path.join` so it respects the pfs instance's `pwd` resolution semantics. 16 16 */ 17 17 export async function getAllTags( 18 - deps: Deps, 19 - opts: GetAllTagsOptions = {}, 18 + deps: Deps, 19 + opts: GetAllTagsOptions = {}, 20 20 ): Promise<string[]> { 21 - const { pfs, config } = deps; 22 - const { concurrency = 8, lowerCase = true, sort = true } = opts; 21 + const { pfs, config } = deps; 22 + const { concurrency = 8, lowerCase = true, sort = true } = opts; 23 23 24 - const files = (await pfs.readdir(config.blogDir)) as string[]; 25 - const mdFiles = files.filter( 26 - (f) => f.endsWith(".md") || f.endsWith(".markdown"), 27 - ); 24 + const files = (await pfs.readdir(config.blogDir)) as string[]; 25 + const mdFiles = files.filter( 26 + (f) => f.endsWith(".md") || f.endsWith(".markdown"), 27 + ); 28 28 29 - // split into batches of size `concurrency` 30 - const batches: string[][] = []; 31 - for (let i = 0; i < mdFiles.length; i += concurrency) { 32 - batches.push(mdFiles.slice(i, i + concurrency)); 33 - } 29 + // split into batches of size `concurrency` 30 + const batches: string[][] = []; 31 + for (let i = 0; i < mdFiles.length; i += concurrency) { 32 + batches.push(mdFiles.slice(i, i + concurrency)); 33 + } 34 34 35 - const tagSet = new Set<string>(); 35 + const tagSet = new Set<string>(); 36 36 37 - async function parseFile(file: string): Promise<string[]> { 38 - try { 39 - // Resolve using pfs so it respects the base directory configured on the instance 40 - const filePath = pfs.resolve(`${config.blogDir}/${file}`); 41 - const content = await pfs.read(filePath); 42 - const { data } = matter(content); 43 - const raw = (data as Record<string, unknown>)[config.tagsKey]; 44 - return parseRawTags(raw); 45 - } catch { 46 - // ignore single-file failures 47 - return []; 48 - } 49 - } 37 + async function parseFile(file: string): Promise<string[]> { 38 + try { 39 + // Resolve using pfs so it respects the base directory configured on the instance 40 + const filePath = pfs.resolve(`${config.blogDir}/${file}`); 41 + const content = await pfs.read(filePath); 42 + const { data } = matter(content); 43 + const raw = (data as Record<string, unknown>)[config.tagsKey]; 44 + return parseRawTags(raw); 45 + } catch { 46 + // ignore single-file failures 47 + return []; 48 + } 49 + } 50 50 51 - for (const batch of batches) { 52 - const results = await Promise.all(batch.map(parseFile)); 53 - for (const tags of results) { 54 - for (let t of tags) { 55 - if (!t) continue; 56 - t = t.trim(); 57 - if (!t) continue; 58 - if (lowerCase) t = t.toLowerCase(); 59 - tagSet.add(t); 60 - } 61 - } 62 - } 51 + for (const batch of batches) { 52 + const results = await Promise.all(batch.map(parseFile)); 53 + for (const tags of results) { 54 + for (let t of tags) { 55 + if (!t) continue; 56 + t = t.trim(); 57 + if (!t) continue; 58 + if (lowerCase) t = t.toLowerCase(); 59 + tagSet.add(t); 60 + } 61 + } 62 + } 63 63 64 - const out = Array.from(tagSet); 65 - if (sort) out.sort(); 66 - return out; 64 + const out = Array.from(tagSet); 65 + if (sort) out.sort(); 66 + return out; 67 67 } 68 68 69 69 function parseRawTags(raw: unknown): string[] { 70 - if (!raw) return []; 71 - if (Array.isArray(raw)) 72 - return raw 73 - .map(String) 74 - .flatMap(splitOnSeparators) 75 - .map((s) => s.trim()) 76 - .filter(Boolean); 77 - if (typeof raw === "string") 78 - return splitOnSeparators(raw) 79 - .map((s) => s.trim()) 80 - .filter(Boolean); 81 - return []; 70 + if (!raw) return []; 71 + if (Array.isArray(raw)) 72 + return raw 73 + .map(String) 74 + .flatMap(splitOnSeparators) 75 + .map((s) => s.trim()) 76 + .filter(Boolean); 77 + if (typeof raw === "string") 78 + return splitOnSeparators(raw) 79 + .map((s) => s.trim()) 80 + .filter(Boolean); 81 + return []; 82 82 } 83 83 84 84 function splitOnSeparators(s: string): string[] { 85 - // Accept comma separated values and newline-separated lists, plus plain single tag strings 86 - if (s.includes(",") || s.includes("\n")) { 87 - return s 88 - .split(/[,\n]+/) 89 - .map((x) => x.trim()) 90 - .filter(Boolean); 91 - } 92 - return [s]; 85 + // Accept comma separated values and newline-separated lists, plus plain single tag strings 86 + if (s.includes(",") || s.includes("\n")) { 87 + return s 88 + .split(/[,\n]+/) 89 + .map((x) => x.trim()) 90 + .filter(Boolean); 91 + } 92 + return [s]; 93 93 }
+22 -22
tsconfig.json
··· 1 1 { 2 - "compilerOptions": { 3 - "lib": ["ES2022", "DOM"], 4 - "target": "ES2022", 5 - "module": "ESNext", 6 - "moduleResolution": "bundler", 7 - "outDir": "./dist", 8 - "rootDir": "./src", 9 - "declaration": true, 10 - "sourceMap": true, 11 - "strict": true, 12 - "skipLibCheck": true, 13 - "esModuleInterop": true, 14 - "resolveJsonModule": true, 15 - "forceConsistentCasingInFileNames": true, 16 - "noFallthroughCasesInSwitch": true, 17 - "noUncheckedIndexedAccess": true, 18 - "noUnusedLocals": false, 19 - "noUnusedParameters": false, 20 - "types": ["node"], 21 - "ignoreDeprecations": "6.0" 22 - }, 23 - "include": ["src/**/*"] 2 + "compilerOptions": { 3 + "lib": ["ES2022", "DOM"], 4 + "target": "ES2022", 5 + "module": "ESNext", 6 + "moduleResolution": "bundler", 7 + "outDir": "./dist", 8 + "rootDir": "./src", 9 + "declaration": true, 10 + "sourceMap": true, 11 + "strict": true, 12 + "skipLibCheck": true, 13 + "esModuleInterop": true, 14 + "resolveJsonModule": true, 15 + "forceConsistentCasingInFileNames": true, 16 + "noFallthroughCasesInSwitch": true, 17 + "noUncheckedIndexedAccess": true, 18 + "noUnusedLocals": false, 19 + "noUnusedParameters": false, 20 + "types": ["node"], 21 + "ignoreDeprecations": "6.0" 22 + }, 23 + "include": ["src/**/*"] 24 24 }
+7 -7
tsdown.config.ts
··· 2 2 import { defineConfig } from "tsdown"; 3 3 4 4 export default defineConfig({ 5 - entry: ["src/index.ts"], // adjust to your entry(s) 6 - format: ["esm", "cjs"], 7 - dts: true, // emits .d.ts 8 - sourcemap: true, 9 - clean: true, 10 - target: "node16", 11 - platform: "node", 5 + entry: ["src/index.ts"], // adjust to your entry(s) 6 + format: ["esm", "cjs"], 7 + dts: true, // emits .d.ts 8 + sourcemap: true, 9 + clean: true, 10 + target: "node16", 11 + platform: "node", 12 12 });