SKIP TO
One problem I sometime face is I will be out and about and have an idea for an article or a new project for this site. I have been known to let my site get out of date in the past, so this got me thinking about solutions to unblock this flow. If I have some downtime (for whatever reason) wouldn't it be nice to be able to start drafting these ideas in a user-friendly way that could let us use Markdown as a base so we could actually start formatting things. I had recently been evaluating Sweep AI and found myself drawn to its prompting system: Basically you install their Github app onto your repository and it begins watching for issued that start with Sweep:
and this kicks off a code task that eventually creates a PR, linking it all together so you have proper metadata. Smart! Lets steal this idea and make it our own!
Initial script
Initially I just wanted a way to template out a new project locally with a CLI so I had already started the createPost.js
node script using inquirer
to ask the user (me) questions about the project. I built this using Typescript and using TSX a esbuild-based TS compilation wrapper for Node.
Expand createProject.ts
1
2import fs from "fs";3import path from "path";4import slugify from "slugify";5import inquirer from "inquirer";6import fetch from "node-fetch";7
8import collaborators from "../src/content/json/collaborators.json";9
10let imageCounter = 1;11
12async function fetchImage(url: string): Promise<Buffer> {13 const response = await fetch(url, {14 headers: {15 'Authorization': `token ${process.env.GITHUB_TOKEN}`16 },17 });18
19 if (!response.ok) {20 throw new Error(`HTTP error! status: ${response.status}`);21 }22
23 return await response.buffer();24}25
26async function createProject(title?: string, body: string = ''): Promise<void> {27 let answers;28 if (title && body) {29 answers = { title, body };30 } else {31 answers = await inquirer.prompt([32 {33 type: "input",34 name: "title",35 message: "Project title:",36 validate: function (value) {37 if (value.length) {38 return true;39 } else {40 return "Please provide a title.";41 }42 },43 },44 {45 type: "list",46 name: "status",47 message: "Project status:",48 choices: ["Featured", "Draft", "Published"],49 default: "Draft",50 },51 {52 type: "input",53 name: "date",54 message: "Project date:",55 default: new Date().toISOString().split("T")[0],56 },57 {58 type: "checkbox",59 name: "collaborators",60 message: "Project collaborators:",61 choices: collaborators.map(collaborator => collaborator.name),62 },63 {64 type: "input",65 name: "client",66 message: "Client:",67 },68 {69 type: "confirm",70 name: "ctaConfirm",71 message: "Do you want to add a CTA?",72 default: false,73 },74 {75 type: "input",76 name: "ctaHeading",77 message: "CTA Heading:",78 when: answers => answers.ctaConfirm,79 },80 {81 type: "input",82 name: "ctaLinkDesc",83 message: "CTA Link Description:",84 when: answers => answers.ctaConfirm,85 },86 {87 type: "input",88 name: "ctaLinkType",89 message: "CTA Link Type:",90 when: answers => answers.ctaConfirm,91 },92 {93 type: "input",94 name: "ctaLinkURL",95 message: "CTA Link URL:",96 when: answers => answers.ctaConfirm,97 },98 {99 type: "input",100 name: "ctaSubHeading",101 message: "CTA Sub Heading:",102 when: answers => answers.ctaConfirm,103 },104 {105 type: "checkbox",106 name: "categories",107 message: "Project categories:",108 choices: [109 "Product Development",110 "Web Development",111 "Backend Development",112 ],113 },114 ]);115 }116 const slug = slugify(answers.title, { lower: true });117 const dir = path.join(__dirname, "../src/content/projects");118 const filePath = path.join(dir, `${slug}/index.mdx`);119
120 if (body) {121 // Parse the body to find image URLs122 const imageUrls = [];123 body = body.replace(/![.*?]((.*?))/g, (match, url) => {124 imageUrls.push(url as never);125 return match;126 });127 console.log("imageUrls", imageUrls);128
129 // Ensure the /images directory exists130 const imagesDir = path.join(131 __dirname,132 `../src/content/projects/${slug}/images`133 );134 if (!fs.existsSync(imagesDir)) {135 fs.mkdirSync(imagesDir, { recursive: true });136 }137
138 // Download the images and update the image references139 for (const url of imageUrls) {140 try {141 const buffer = await fetchImage(url);142 const filename = `image-${imageCounter}.png`;143 const localPath = path.join(imagesDir, filename);144
145 // Save the image to a local file146 fs.writeFileSync(localPath, buffer);147
148 // Update the image reference in the body149 body = body.replace(url, `./images/${filename}`);150
151 // Increment the counter for the next image152 imageCounter++;153 } catch (error) {154 console.error(`Failed to download image from ${url}: ${error.message}`);155 }156 }157 }158 // Conditionally include featuredImage159 let featuredImage = "";160 if (answers.featuredImage) {161 featuredImage = `featuredImage: ${answers.featuredImage}162 `;163 }164 const postStatus = answers.status || "Draft";165 const date = answers.date || new Date().toISOString().split("T")[0]; // Get today's date in YYYY-MM-DD format166
167 if (fs.existsSync(filePath)) {168 console.error(`File ${filePath} already exists.`);169 process.exit(1);170 }171
172 let ctaContent = "";173 if (answers.ctaConfirm) {174 ctaContent = `175cta:176 heading: '${answers.ctaHeading}'177 linkDesc: '${answers.ctaLinkDesc}'178 linkType: '${answers.ctaLinkType}'179 linkURL: '${answers.ctaLinkURL}'180 subHeading: '${answers.ctaSubHeading}'`;181 }182
183 const content = `---184title: "${answers.title}"185status: ${postStatus}186date: ${date}187collaborators: [${188 Array.isArray(answers.collaborators) ? answers.collaborators.join(", ") : ""189 }]190
191${featuredImage}192client: "${answers.client}"193${ctaContent}194categories: [${195 Array.isArray(answers.categories) ? answers.categories.join(", ") : "TBD"196 }]197---198
199# ${answers.title}200
201${body}202`;203
204 fs.mkdirSync(path.join(dir, slug), { recursive: true });205 fs.writeFileSync(filePath, content);206}207
208
209let title: string | undefined, body: string | undefined;210if (process.argv.length >= 4) {211 [title, body] = process.argv.slice(2);212 body = fs.readFileSync(body, 'utf8');213}214createProject(title, body);215
Expand createPost.ts
1
2import fs from "fs";3import path from "path";4import slugify from "slugify";5import inquirer from "inquirer";6import fetch from "node-fetch";7
8let imageCounter = 1;9
10async function fetchImage(url: string): Promise<Buffer> {11 const response = await fetch(url, {12 headers: {13 'Authorization': `token ${process.env.GITHUB_TOKEN}`14 },15 });16
17 if (!response.ok) {18 throw new Error(`HTTP error! status: ${response.status}`);19 }20
21 return await response.buffer();22}23
24interface Answers {25 title: string;26 subtitle?: string;27 status?: string;28 date?: string;29 categories?: string[];30 body?: string;31}32
33async function createPost(title?: string, body: string = ''): Promise<void> {34 let answers: Answers;35 if (title && body) {36 answers = { title, body };37 } else {38 answers = await inquirer.prompt([39 {40 type: "input",41 name: "title",42 message: "Post title:",43 validate: function (value) {44 if (value.length) {45 return true;46 } else {47 return "Please provide a title.";48 }49 },50 },51 {52 type: "input",53 name: "subtitle",54 message: "Post subtitle:",55 },56 {57 type: "list",58 name: "status",59 message: "Post status:",60 choices: ["Featured", "Draft", "Published"],61 default: "Draft",62 },63 {64 type: "input",65 name: "date",66 message: "Post date:",67 default: new Date().toISOString().split("T")[0],68 },69 {70 type: "checkbox",71 name: "categories",72 message: "Post categories:",73 choices: ["Updates", "Tutorial", "News", "Review"],74 },75 ]);76 }77 const slug = slugify(answers.title, { lower: true });78 const dir = path.join(__dirname, "../src/content/posts");79 const filePath = path.join(dir, `${slug}/index.mdx`);80
81 if (body) {82 // Parse the body to find image URLs83 const imageUrls = [];84 body = body.replace(/![.*?]((.*?))/g, (match, url) => {85 imageUrls.push(url as never);86 return match;87 });88 console.log("imageUrls", imageUrls);89
90 // Ensure the /images directory exists91 const imagesDir = path.join(92 __dirname,93 `../src/content/posts/${slug}/images`94 );95 if (!fs.existsSync(imagesDir)) {96 fs.mkdirSync(imagesDir, { recursive: true });97 }98
99 // Download the images and update the image references100 for (const url of imageUrls) {101 try {102 const buffer = await fetchImage(url);103 const filename = `image-${imageCounter}.png`;104 const localPath = path.join(imagesDir, filename);105
106 // Save the image to a local file107 fs.writeFileSync(localPath, buffer);108
109 // Update the image reference in the body110 body = body.replace(url, `./images/${filename}`);111
112 // Increment the counter for the next image113 imageCounter++;114 } catch (error) {115 console.error(`Failed to download image from ${url}: ${error.message}`);116 }117 }118 }119
120 // Conditionally include subtitle121 let subtitle = "";122 if (answers.subtitle) {123 subtitle = `subtitle: ${answers.subtitle}124 `;125 }126 const postStatus = answers.status || "Draft";127 const date = answers.date || new Date().toISOString().split("T")[0]; // Get today's date in YYYY-MM-DD format128
129 if (fs.existsSync(filePath)) {130 console.error(`File ${filePath} already exists.`);131 process.exit(1);132 }133
134 const content = `---135title: ${answers.title}136${subtitle}137status: ${postStatus}138date: ${date}139categories: [${Array.isArray(answers.categories) ? answers.categories.join(", ") : "TBD"140 }]141---142
143# ${answers.title}144
145${body}146`;147
148 fs.mkdirSync(path.join(dir, slug), { recursive: true });149 fs.writeFileSync(filePath, content);150
151}152
153let title: string | undefined, body: string | undefined;154if (process.argv.length >= 4) {155 [title, body] = process.argv.slice(2);156 body = fs.readFileSync(body, 'utf8').toString();157}158createPost(title, body);159
Creating a Github Action
Next, I wanted to be able to run this script in a Github action based on the creation of a new issue just like Sweep. So off I went researching and creating a github action that can:
- Support full markdown syntax for easy writing
- Support for grabbing any images attached to an issue (this was tricky)
Expand github-actions-issue-to-post.yml
1
2name: Create Post/Project3
4on:5 issues:6 types:7 - opened8
9jobs:10 create-post-project:11 runs-on: ubuntu-latest12
13 steps:14 - name: Check issue title15 run: |16 if [[ ! "${{ github.event.issue.title }}" =~ ^(Post:|Project:) ]]; then17 echo "Issue title does not start with 'Post:' or 'Project:', exiting..."18 exit 7819 fi20 shell: bash21
22 - name: Checkout code23 uses: actions/checkout@v224
25 - name: Setup Node26 uses: actions/setup-node@v227 with:28 node-version: "18"29 cache: "yarn"30 - run: |31 cd scripts32 yarn install --frozen-lockfile33
34 - name: Write issue body to temp file35 run: |36 cat << 'EOF' > issue_body.txt37 ${{ github.event.issue.body }}38 EOF39
40 - name: Create Post41 run: |42 BRANCH_NAME="${{ github.event.issue.title }}"43 BRANCH_NAME=${BRANCH_NAME#*: } # This will remove the 'Project: ' prefix44 BRANCH_NAME=$(echo "${BRANCH_NAME}" | sed 's/[^a-zA-Z0-9]/-/g') # Sanitize further45 export GITHUB_TOKEN=${{ secrets.BROAD_GITHUB_TOKEN }}46 yarn post "$BRANCH_NAME" issue_body.txt47 shell: bash48 if: startsWith(github.event.issue.title, 'Post:')49
50 - name: Create Project51 run: |52 BRANCH_NAME="${{ github.event.issue.title }}"53 BRANCH_NAME=${BRANCH_NAME#*: } # This will remove the 'Project: ' prefix54 BRANCH_NAME=$(echo "${BRANCH_NAME}" | sed 's/[^a-zA-Z0-9]/-/g') # Sanitize further55 export GITHUB_TOKEN=${{ secrets.BROAD_GITHUB_TOKEN }}56 yarn project "$BRANCH_NAME" issue_body.txt57 shell: bash58 if: startsWith(github.event.issue.title, 'Project:')59
60 - name: Set up Git user61 run: |62 git config --global user.email "actions@github.com"63 git config --global user.name "GitHub Actions"64
65 - name: Delete temp file66 run: rm issue_body.txt67
68 - name: Create branch69 id: create_branch70 run: |71 BRANCH_NAME="${{ github.event.issue.title }}"72 BRANCH_NAME=${BRANCH_NAME#*: } # This will remove the 'Project: ' prefix73 BRANCH_NAME=${BRANCH_NAME// /-} # This will replace spaces with hyphens74 BRANCH_NAME=$(echo "${BRANCH_NAME}" | sed 's/[^a-zA-Z0-9]/-/g') # Sanitize further75 if [[ "${{ startsWith(github.event.issue.title, 'Project:') }}" == "true" ]]; then76 BRANCH_TYPE="project"77 else78 BRANCH_TYPE="post"79 fi80 BRANCH_NAME="${BRANCH_TYPE}/${BRANCH_NAME}" # This will add the branch type as a prefix81 git checkout -b "$BRANCH_NAME"82 git add .83 git commit -m "Created post/project from issue"84 git push https://${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}.git "$BRANCH_NAME"85 echo "branch_name=$BRANCH_NAME" >> $GITHUB_ENV86
87 - name: Link branch to issue88 run: |89 BRANCH_NAME="${{ github.event.issue.title }}"90 BRANCH_NAME=${BRANCH_NAME#*: } # This will remove the 'Project: ' prefix91 BRANCH_NAME=${BRANCH_NAME// /-} # This will replace spaces with hyphens92 curl -X POST -H "Authorization: token ${{ secrets.BROAD_GITHUB_TOKEN }}" -d "{ "body": "Branch: [${BRANCH_NAME}](https://github.com/${{ github.repository }}/tree/${BRANCH_NAME})" }" "https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.issue.number }}/comments"93
94 - name: Close issue95 run: |96 curl -X PATCH -H "Authorization: token ${{ secrets.BROAD_GITHUB_TOKEN }}" -d '{ "state": "closed" }' "https://api.github.com/repos/${{ github.repository }}/issues/${{ github.event.issue.number }}"97
98 - name: Create Pull Request99 uses: actions/github-script@v5100 with:101 github-token: ${{ secrets.GITHUB_TOKEN }}102 script: |103 const { owner, repo } = context.repo104 const branch = process.env.branch_name105 const issue_number = "${{ github.event.issue.number }}"106 const pr = await github.rest.pulls.create({107 owner,108 repo,109 title: `PR for ${branch}`,110 head: branch,111 base: 'main', // or the branch you want to merge into112 body: `Related to issue #${issue_number}`,113 // draft: true, // not supporting on GitHub free plans114 })115 console.log(`Created PR ${pr.data.html_url}`)116
Conclusion
This lets me draft content wherever I am, on mobile or otherwise! Once the issue is created 5 mins later I have a new PR created for me, and thanks to Netlify, I have a deploy link to review the draft before it goes live!