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

Configure Feed

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

add publish scaffolding

Jack Platten (May 24, 2026, 11:36 AM -0700) 1008c58f b6f14214

+204 -42
+9 -2
mkd.json
··· 1 - // this is so that testing works locally 2 1 { 3 - "blogDir": "./test/src/blog" 2 + "blogDir": "./test/src/blog", 3 + "author": "Wendy", 4 + "publishedAtKey": "publishedAt", 5 + "modifiedAtKey": "updatedAt", 6 + "authorKey": "author", 7 + "draftKey": "draft", 8 + "descriptionKey": "description", 9 + "tagsKey": "tags", 10 + "titleKey": "title" 4 11 }
+57 -6
src/commands/new.ts
··· 3 3 import slugify from "@sindresorhus/slugify"; 4 4 import filenamify from "filenamify"; 5 5 import matter from "gray-matter"; 6 - import type { Deps } from "../deps"; 6 + import { isCancel, text } from "@clack/prompts"; 7 + import type { Config, Deps } from "../lib/deps"; 7 8 8 9 export function makeNewCommand({ config, pfs }: Deps) { 9 10 return command({ ··· 17 18 }), 18 19 }, 19 20 handler: async ({ new: titleArray }) => { 20 - const title = titleArray.join(" "); 21 + let title: string; 22 + if (titleArray.length === 0) { 23 + title = await generateTitle(); 24 + if (title === "") { 25 + return; 26 + } 27 + } else { 28 + title = titleArray.join(" "); 29 + } 21 30 const slug = slugify(title); 22 31 const fileName = filenamify(slug); 23 - const frontmatter = generateFrontmatter(title); 32 + const frontmatter = await generateFrontmatter(title, config); 24 33 const filePath = path.join(config.blogDir, `${fileName}.md`); 25 34 26 35 await pfs.write(filePath, frontmatter); ··· 28 37 }); 29 38 } 30 39 31 - function generateFrontmatter(title: string): string { 40 + async function generateFrontmatter( 41 + title: string, 42 + config: Config, 43 + ): Promise<string> { 44 + const description = await makeDescription(title); 45 + const now = new Date(); 46 + 32 47 const data = { 33 - title, 34 - date: new Date().toISOString(), 48 + [config.titleKey]: title, 49 + [config.publishedAtKey]: now, 50 + [config.modifiedAtKey]: now, 51 + [config.authorKey]: config.author, 52 + [config.draftKey]: true, 53 + [config.descriptionKey]: description, 54 + [config.tagsKey]: [], 35 55 }; 36 56 37 57 return matter.stringify("", data); 38 58 } 59 + 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 + }); 65 + 66 + if (isCancel(description)) { 67 + return ""; 68 + } 69 + 70 + return description; 71 + } 72 + 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 + }); 83 + 84 + if (isCancel(title)) { 85 + return ""; 86 + } 87 + 88 + return title; 89 + }
+74
src/commands/publish.ts
··· 1 + import { command } from "cmd-ts"; 2 + import { autocompleteMultiselect, isCancel } from "@clack/prompts"; 3 + import matter from "gray-matter"; 4 + import type { Config, Deps } from "../lib/deps"; 5 + 6 + type Frontmatter = Record<string, unknown>; 7 + 8 + type Post = { 9 + file: string; 10 + content: string; 11 + }; 12 + 13 + export function makePublishCommand({ config, pfs }: Deps) { 14 + return command({ 15 + name: "publish", 16 + description: "Publish a markdown file", 17 + args: {}, 18 + handler: async () => { 19 + const drafts = await getDraftPosts(pfs, config); 20 + let selected = await getPostsToPublish(drafts, config); 21 + 22 + void drafts; 23 + }, 24 + }); 25 + } 26 + 27 + async function getDraftPosts(pfs: Deps["pfs"], config: Config) { 28 + const posts = await getPosts(pfs, config); 29 + return posts.filter((post) => { 30 + const frontMatter = getPostFrontMatter(post); 31 + return frontMatter[config.draftKey] === true; 32 + }); 33 + } 34 + 35 + async function getPosts(pfs: Deps["pfs"], config: Config): Promise<Post[]> { 36 + const files = await pfs.readdir(config.blogDir); 37 + const posts: Post[] = []; 38 + 39 + for (const file of files) { 40 + const content = await pfs.read(`${config.blogDir}/${file}`); 41 + posts.push({ file, content }); 42 + } 43 + 44 + return posts; 45 + } 46 + 47 + function getPostFrontMatter(post: Post) { 48 + const { data } = matter(post.content); 49 + return data as Frontmatter; 50 + } 51 + 52 + async function getPostsToPublish(drafts: Post[], config: Config) { 53 + const options = drafts.map((draft) => { 54 + const fm = getPostFrontMatter(draft); 55 + const title = String(fm[config.titleKey] ?? draft.file); 56 + 57 + return { 58 + value: draft.file, 59 + label: title, 60 + hint: draft.file, 61 + }; 62 + }); 63 + 64 + const selected = await autocompleteMultiselect({ 65 + message: "Select posts to publish", 66 + options, 67 + }); 68 + 69 + if (isCancel(selected)) { 70 + return []; 71 + } 72 + 73 + return drafts.filter((draft) => selected.includes(draft.file)); 74 + }
-31
src/deps.ts
··· 1 - import type { PoweredFileSystem } from "pwd-fs"; 2 - 3 - export type Config = { 4 - blogDir: string; 5 - }; 6 - 7 - export type Deps = { 8 - config: Config; 9 - pfs: PoweredFileSystem; 10 - }; 11 - 12 - const defaultConfig: Config = { 13 - blogDir: "./src/blog", 14 - }; 15 - 16 - export async function loadConfig(pfs: PoweredFileSystem): Promise<Config> { 17 - try { 18 - const raw = await pfs.read("./mkd.json"); 19 - 20 - if (!raw.trim()) { 21 - return defaultConfig; 22 - } 23 - 24 - return { 25 - ...defaultConfig, 26 - ...JSON.parse(raw), 27 - }; 28 - } catch { 29 - return defaultConfig; 30 - } 31 - }
+3 -1
src/index.ts
··· 1 1 #!/usr/bin/env node 2 2 import { run, subcommands } from "cmd-ts"; 3 3 import { PoweredFileSystem } from "pwd-fs"; 4 - import { loadConfig } from "./deps"; 4 + import { loadConfig } from "./lib/deps"; 5 5 import { makeNewCommand } from "./commands/new"; 6 + import { makePublishCommand } from "./commands/publish"; 6 7 7 8 async function main() { 8 9 const pfs = new PoweredFileSystem(); ··· 13 14 name: "mkd", 14 15 cmds: { 15 16 new: makeNewCommand(deps), 17 + publish: makePublishCommand(deps), 16 18 }, 17 19 }); 18 20
+59
src/lib/deps.ts
··· 1 + import type { PoweredFileSystem } from "pwd-fs"; 2 + 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; 13 + }; 14 + 15 + export type Deps = { 16 + config: Config; 17 + pfs: PoweredFileSystem; 18 + }; 19 + 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", 30 + }; 31 + 32 + export async function loadConfig(pfs: PoweredFileSystem): Promise<Config> { 33 + try { 34 + const raw = await pfs.read("./mkd.json"); 35 + 36 + if (!raw.trim()) { 37 + return defaultConfig; 38 + } 39 + 40 + const parsed = JSON.parse(raw); 41 + 42 + if (!isRecord(parsed)) { 43 + return defaultConfig; 44 + } 45 + 46 + const { $schema: _schema, ...config } = parsed; 47 + 48 + return { 49 + ...defaultConfig, 50 + ...config, 51 + }; 52 + } catch { 53 + return defaultConfig; 54 + } 55 + } 56 + 57 + function isRecord(value: unknown): value is Record<string, unknown> { 58 + return typeof value === "object" && value !== null && !Array.isArray(value); 59 + }
+2 -2
tsconfig.json
··· 17 17 "noUncheckedIndexedAccess": true, 18 18 "noUnusedLocals": false, 19 19 "noUnusedParameters": false, 20 - "types": ["node"], 20 + "types": ["node"] 21 21 }, 22 - "include": ["src/**/*"], 22 + "include": ["src/**/*"] 23 23 }