├── .github └── workflows │ ├── checkin.yml │ └── main.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── __tests__ └── generate-html.test.ts ├── action.yml ├── demo ├── test-file.md └── this-file.jpg ├── docs └── contributors.md ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── comment-markdown.ts ├── commit-file.ts ├── constants.ts ├── create-comment.ts ├── find-file.ts ├── generate-html.ts ├── generate-image.ts ├── get-pr-number.ts ├── github-api.ts ├── main.ts ├── repo-props.ts └── types.ts └── tsconfig.json /.github/workflows/checkin.yml: -------------------------------------------------------------------------------- 1 | name: "Run tests" 2 | on: [pull_request, push] 3 | 4 | jobs: 5 | check_pr: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v1 9 | 10 | - name: "npm ci" 11 | run: npm ci 12 | 13 | - name: "npm run build" 14 | run: npm run build 15 | 16 | - name: "npm run test" 17 | run: npm run test 18 | 19 | - name: "check for uncommitted changes" 20 | # Ensure no changes, but ignore node_modules dir since dev/fresh ci deps installed. 21 | run: | 22 | git diff --exit-code --stat -- . ':!node_modules' \ 23 | || (echo "##[error] found changed files after build. please 'npm run build && npm run format'" \ 24 | "and check in all changes" \ 25 | && exit 1) 26 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: "Generate OG Images" 2 | on: pull_request 3 | 4 | jobs: 5 | generate_og_job: 6 | runs-on: ubuntu-latest 7 | name: Generate OG Images 8 | steps: 9 | - name: Checkout 10 | uses: actions/checkout@v1 11 | - name: Generate Image 12 | uses: ./ # Uses an action in the root directory 13 | env: 14 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 15 | GITHUB_CONTEXT: ${{ toJson(github) }} 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __tests__/runner/* 2 | 3 | # Rest pulled from https://github.com/github/gitignore/blob/master/Node.gitignore 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | 12 | # Diagnostic reports (https://nodejs.org/api/report.html) 13 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 14 | 15 | # Runtime data 16 | pids 17 | *.pid 18 | *.seed 19 | *.pid.lock 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | *.lcov 27 | 28 | # nyc test coverage 29 | .nyc_output 30 | 31 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 32 | .grunt 33 | 34 | # Bower dependency directory (https://bower.io/) 35 | bower_components 36 | 37 | # node-waf configuration 38 | .lock-wscript 39 | 40 | # Compiled binary addons (https://nodejs.org/api/addons.html) 41 | build/Release 42 | 43 | # Dependency directories 44 | node_modules/ 45 | jspm_packages/ 46 | 47 | # TypeScript v1 declaration files 48 | typings/ 49 | 50 | # TypeScript cache 51 | *.tsbuildinfo 52 | 53 | # Optional npm cache directory 54 | .npm 55 | 56 | # Optional eslint cache 57 | .eslintcache 58 | 59 | # Optional REPL history 60 | .node_repl_history 61 | 62 | # Output of 'npm pack' 63 | *.tgz 64 | 65 | # Yarn Integrity file 66 | .yarn-integrity 67 | 68 | # dotenv environment variables file 69 | .env 70 | .env.test 71 | 72 | # parcel-bundler cache (https://parceljs.org/) 73 | .cache 74 | 75 | # next.js build output 76 | .next 77 | 78 | # nuxt.js build output 79 | .nuxt 80 | 81 | # vuepress build output 82 | .vuepress/dist 83 | 84 | # Serverless directories 85 | .serverless/ 86 | 87 | # FuseBox cache 88 | .fusebox/ 89 | 90 | # DynamoDB Local files 91 | .dynamodb/ 92 | 93 | # Build 94 | lib 95 | dist 96 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | 2 | FROM node:16 3 | 4 | RUN apt-get update \ 5 | # See https://crbug.com/795759 6 | && apt-get install -yq libgconf-2-4 gconf-service libasound2 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 ca-certificates fonts-liberation libappindicator1 libnss3 lsb-release xdg-utils wget \ 7 | # Install latest chrome dev package, which installs the necessary libs to 8 | # make the bundled version of Chromium that Puppeteer installs work. 9 | && apt-get install -y wget --no-install-recommends \ 10 | && wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \ 11 | && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \ 12 | && apt-get update \ 13 | && apt-get install -y google-chrome-unstable --no-install-recommends \ 14 | && rm -rf /var/lib/apt/lists/* 15 | 16 | # When installing Puppeteer through npm, instruct it to not download Chromium. 17 | # Puppeteer will need to be launched with: 18 | # browser.launch({ executablePath: 'google-chrome-unstable' }) 19 | ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true 20 | 21 | RUN mkdir -p /usr/local/src/generate-og-image 22 | WORKDIR /usr/local/src/generate-og-image 23 | 24 | COPY package.json package-lock.json /usr/local/src/generate-og-image/ 25 | RUN npm ci 26 | 27 | # copy in src 28 | COPY tsconfig.json /usr/local/src/generate-og-image/ 29 | COPY src/ /usr/local/src/generate-og-image/src/ 30 | COPY __tests__/ /usr/local/src/generate-og-image/__tests__/ 31 | 32 | RUN npm run build-release 33 | 34 | RUN chmod +x /usr/local/src/generate-og-image/dist/index.js 35 | 36 | ENTRYPOINT ["/usr/local/src/generate-og-image/dist/index.js"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2018 GitHub, Inc. and contributors 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Open Graph Image Generator 2 | 3 | ![](https://github.com/BoyWithSilverWings/generate-og-image/workflows/Run%20tests/badge.svg) 4 | 5 | Generates open graph images for your blog with Github Actions. 6 | 7 | This github action scans your PR for changes to `md` or `mdx` files, reads frontmatter configuration from them and generates images for your SEO. 8 | 9 | If looks very bland without an image when you share the URL, but adding a cover pic with nothing do with your article also does not suit you well. Here is a Github Action based generator that got you covered. 10 | 11 | In your action file: 12 | 13 | ```yml 14 | name: "Generate OG Images" 15 | on: pull_request 16 | 17 | jobs: 18 | generate_og_job: 19 | runs-on: ubuntu-latest 20 | name: Generate OG Images 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@v1 24 | - name: Generate Image 25 | uses: BoyWithSilverWings/generate-og-image@1.0.3 26 | env: 27 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 28 | GITHUB_CONTEXT: ${{ toJson(github) }} 29 | with: 30 | path: src/images/post-images/ 31 | ``` 32 | 33 | For configuring the parameters, add following fields to the frontmatter: 34 | 35 | ```md 36 | --- 37 | ogImage: 38 | title: "Things you don't know" 39 | subtitle: "There must be something" 40 | imageUrl: "https://example.com/image-url.jpg" 41 | background: "yellow" 42 | fontColor: "rgb(0, 0, 0)" 43 | fontSize: "100%" 44 | --- 45 | ``` 46 | 47 | ## Frontmatter Props 48 | 49 | | Props | Description | Required | Default | 50 | | ---------- | :-------------------------------------: | :------: | :-------------------------------------------------------------------: | 51 | | title | Title in the image | | | 52 | | subtitle | Subtitle in the image | | | 53 | | imageUrl | The image thumbnail on the top | | | 54 | | background | Background color, gradient or image url | | | 55 | | fontColor | any css supported color | | | 56 | | fontSize | the font size | | | 57 | | fileName | name of the file | | title prop in [kebab](https://lodash.com/docs/4.17.15#kebabCase) case | 58 | 59 | Works only with Pull Requests and `md` and `mdx` files. 60 | 61 | ## Repository level Props 62 | 63 | These are props that you can configure in the action file to customise the working. 64 | 65 | | Props | Description | Required | 66 | | ------------ | :-----------------------------------------------------: | :------: | 67 | | path | Path to place the image URL in | true | 68 | | commitMsg | Commit message when image is added | | 69 | | background | Background color, gradient or image url | | 70 | | fontColor | any css supported color | | 71 | | fontSize | the font size | | 72 | | componentUrl | Web Component to be rendered for output. | | 73 | | botComments | Whether a comment with a preview image should be posted | 74 | 75 | Frontmatter level props on a document always takes precedence over Repository level props. 76 | 77 | ## How do I customise the output? 78 | 79 | 1. **I need a gradient background** 80 | 81 | Just as in CSS your frontmatter or Repo level prop can contain: 82 | 83 | ``` 84 | background: 'linear-gradient(to right, #ec008c, #fc6767)' 85 | ``` 86 | 87 | 2. **What if I need an image as background?** 88 | 89 | Write the CSS for it. 90 | 91 | ``` 92 | background: 'url(https://example.com/image.png)' 93 | ``` 94 | 95 | 3. **What about font sizes?** 96 | 97 | you can customise the repository level or frontmatter `fontSize` props which defaults to 100%. The heading and paragraph font sizes vary depending on it. 98 | 99 | ``` 100 | fontSize: 120%; 101 | ``` 102 | 103 | 4. **I need an emoji instead of image** 104 | 105 | You can pass in the unicode representation of the emoji from the [List](https://unicode.org/emoji/charts/full-emoji-list.html) in `imageUrl` prop. 106 | 107 | 5. **I need to format the title** 108 | 109 | The `title` prop supports markdown, feel _free_ to _use_ it. 110 | 111 | 6. **I don't want these preview image bot comments** 112 | 113 | the `botComments` repo prop should be set to `"no"` 114 | 115 | ### I need more customisation on the output. 116 | 117 | The generator uses a web component to create the default output and provides a repository level prop to customise this web component. 118 | 119 | The component currently being used is on [Github](https://github.com/BoyWithSilverWings/og-image-element) and published on [NPM](https://www.npmjs.com/package/@agney/og-image-element). The default URL is from [Unpkg](https://unpkg.com/) with [https://unpkg.com/@agney/og-image-element@0.2.0](https://unpkg.com/@agney/og-image-element@0.2.0). 120 | 121 | You can substitute the same with `componentUrl` input in your workflow file. For more info on creating this web component, visit [source](https://github.com/BoyWithSilverWings/generate-og-image/blob/304fd9aa0b21b01b0fdc8a3d1a63a19ffdc1840d/demo/test-file.jpg) 122 | 123 | ## Contributing 124 | 125 | See [docs](./docs/contributors.md) 126 | 127 | ## Credits 128 | 129 | 1. [Zeit OG Image](https://github.com/zeit/og-image) 130 | 131 | Serverless based open graph image generator from zeit 132 | 133 | 2. [Zeit NCC](Compiler) 134 | 135 | 3. [Github Image Actions](https://github.com/calibreapp/image-actions) 136 | 137 | For some utils to copy from. 138 | -------------------------------------------------------------------------------- /__tests__/generate-html.test.ts: -------------------------------------------------------------------------------- 1 | import generateHtml from "../src/generate-html"; 2 | 3 | describe("Generate HTML", () => { 4 | it(`returns a string`, () => { 5 | const result = generateHtml({}); 6 | expect(result).toBeTruthy(); 7 | }); 8 | 9 | it(`contains background variable`, () => { 10 | const result = generateHtml({ 11 | background: "{{name}}" 12 | }); 13 | expect(result.includes("--background")).toBe(true); 14 | }); 15 | 16 | it(`contains font color variable`, () => { 17 | const result = generateHtml({ 18 | fontColor: "{{name}}" 19 | }); 20 | expect(result.includes("--fontColor")).toBe(true); 21 | }); 22 | 23 | it(`contains font size variable`, () => { 24 | const result = generateHtml({ 25 | fontSize: "{{name}}" 26 | }); 27 | expect(result.includes("--fontSize")).toBe(true); 28 | }); 29 | 30 | it(`contains passed in title`, () => { 31 | const result = generateHtml({ 32 | title: `{{name}}` 33 | }); 34 | expect(result.includes("{{name}}")).toBe(true); 35 | expect(result.includes(`slot="title"`)).toBe(true); 36 | }); 37 | 38 | it(`creates html output for large payload`, () => { 39 | const result = generateHtml({ 40 | assetPath: "demo/", 41 | componentUrl: "https://unpkg.com/@agney/og-image-element@0.2.0", 42 | commitMsg: "just some wholesome content. yo all!", 43 | background: "red", 44 | fontColor: "yellow", 45 | fontSize: "90%", 46 | title: "Generating open graph images with Github Actions", 47 | subtitle: "Works with Markdown files", 48 | imageUrl: "https://avatars3.githubusercontent.com/u/8883368?s=40&v=4" 49 | }); 50 | expect(result).toBeTruthy(); 51 | }); 52 | 53 | it("adds gradient as background", () => { 54 | const result = generateHtml({ 55 | background: "linear-gradient(to right, #000428, #004e92)" 56 | }); 57 | expect(result.includes("linear-gradient(to right, #000428, #004e92)")).toBe( 58 | true 59 | ); 60 | }); 61 | 62 | it("process emojis", () => { 63 | const result = generateHtml({ 64 | imageUrl: "😍", 65 | title: "Generating open graph images with Github Actions", 66 | subtitle: "Works with Markdown files", 67 | componentUrl: "https://unpkg.com/@agney/og-image-element@0.2.0" 68 | }); 69 | console.log(result); 70 | expect(result.includes(`class="emoji"`)).toBe(true); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: "Generate OG Image" 2 | description: "Helps users generate a OG Image from frontmatter" 3 | author: "Agney" 4 | branding: 5 | icon: "share-2" 6 | color: "orange" 7 | inputs: 8 | path: 9 | description: "Path to place generated assets" 10 | default: "demo/" 11 | commitMsg: 12 | description: "commit message to be shown when adding image" 13 | default: "just some wholesome content. yo all!" 14 | background: 15 | description: "background color for image" 16 | default: "#ffffff" 17 | fontColor: 18 | description: "font color for image" 19 | default: "#000000" 20 | componentUrl: 21 | description: "URL for web component" 22 | default: "https://unpkg.com/@agney/og-image-element@0.2.3" 23 | fontSize: 24 | description: "Font size for the root" 25 | default: "100%" 26 | width: 27 | description: "Width of the screen" 28 | default: "1200" 29 | height: 30 | description: "Height of the screen" 31 | default: "630" 32 | botComments: 33 | description: "Whether a comment with a preview image should be posted" 34 | default: "yes" 35 | runs: 36 | using: "docker" 37 | image: "Dockerfile" 38 | -------------------------------------------------------------------------------- /demo/test-file.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Just hack'n 3 | description: Nothing to see here 4 | ogImage: 5 | title: Generating *open graph* images with Github Actions 6 | subtitle: Images for your blog 7 | imageUrl: "🥳" 8 | filename: this-file 9 | --- 10 | 11 | This is some text about some stuff that happened sometime ago 12 | -------------------------------------------------------------------------------- /demo/this-file.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agneym/generate-og-image/7a29ed787311d446022103b0eec8ff4a64bb4ca8/demo/this-file.jpg -------------------------------------------------------------------------------- /docs/contributors.md: -------------------------------------------------------------------------------- 1 | # Contributors 2 | 3 | ## Getting started 4 | 5 | 1. Clone the repository 6 | 7 | ## Prerequisites 8 | 9 | 1. NodeJS 10 | 11 | ## Developing 12 | 13 | ``` 14 | npm install 15 | ``` 16 | 17 | Push the branch after making the changes and create a PR. 18 | 19 | The output is the workflow named _Generate OG Images_ running in Github Actions on every PR. 20 | 21 | ## Build 22 | 23 | Uses `ncc` to generate builds for application. 24 | 25 | ``` 26 | npm install 27 | npm run build-release 28 | ``` 29 | 30 | ## Tests 31 | 32 | Tests are written with `Jest`. 33 | 34 | ``` 35 | npm test 36 | ``` 37 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | clearMocks: true, 3 | moduleFileExtensions: ['js', 'ts'], 4 | testEnvironment: 'node', 5 | testMatch: ['**/*.test.ts'], 6 | testRunner: 'jest-circus/runner', 7 | transform: { 8 | '^.+\\.ts$': 'ts-jest' 9 | }, 10 | verbose: true 11 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "javascript-template-action", 3 | "version": "0.0.0", 4 | "private": true, 5 | "description": "JavaScript template action", 6 | "main": "dist/index.js", 7 | "scripts": { 8 | "build": "tsc", 9 | "build-release": "ncc build src/main.ts", 10 | "format": "pretty-quick --pattern \"**/*.*(ts|tsx)\"", 11 | "test": "jest" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git@github.com:BoyWithSilverWings/generate-og-image.git" 16 | }, 17 | "keywords": [ 18 | "actions", 19 | "node" 20 | ], 21 | "author": "Agney ", 22 | "license": "MIT", 23 | "dependencies": { 24 | "@actions/core": "^1.9.1", 25 | "@actions/github": "^1.1.0", 26 | "@zeit/ncc": "^0.20.5", 27 | "front-matter": "^3.0.2", 28 | "lodash": "^4.17.21", 29 | "marked": "^4.0.10", 30 | "puppeteer-core": "^1.20.0", 31 | "twemoji": "^12.1.3" 32 | }, 33 | "devDependencies": { 34 | "@types/jest": "^24.0.13", 35 | "@types/marked": "^4.0.3", 36 | "@types/node": "^12.0.4", 37 | "@types/puppeteer": "^1.19.1", 38 | "husky": "^3.0.5", 39 | "jest": "^29.3.1", 40 | "jest-circus": "^24.7.1", 41 | "prettier": "^1.18.2", 42 | "pretty-quick": "^1.11.1", 43 | "ts-jest": "^29.0.3", 44 | "typescript": "^5.0.4" 45 | }, 46 | "husky": { 47 | "hooks": { 48 | "pre-commit": "pretty-quick --staged" 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/comment-markdown.ts: -------------------------------------------------------------------------------- 1 | import { USER_REPO, GITHUB_HEAD_REF } from "./constants"; 2 | 3 | function commentMarkdown(path: string) { 4 | const [owner, repo] = USER_REPO; 5 | 6 | return `Your open graph image is ready: 7 | ![](https://github.com/${owner}/${repo}/raw/${GITHUB_HEAD_REF}/${path}.jpg) 8 | `; 9 | } 10 | 11 | export default commentMarkdown; 12 | -------------------------------------------------------------------------------- /src/commit-file.ts: -------------------------------------------------------------------------------- 1 | import { error } from "@actions/core"; 2 | 3 | import octokit from "./github-api"; 4 | import { USER_REPO, COMMITTER, GITHUB_HEAD_REF } from "./constants"; 5 | import { IRepoProps } from "./types"; 6 | 7 | /** 8 | * Commit the image with reported filename and commit messsage 9 | * @param content Image to be commited 10 | * @param repoProps properties 11 | * @param filename file to be commited as 12 | */ 13 | async function commitFile( 14 | content: string, 15 | repoProps: Partial, 16 | filename: string 17 | ) { 18 | const [owner, repo] = USER_REPO; 19 | 20 | try { 21 | await octokit.repos.createOrUpdateFile({ 22 | owner, 23 | repo, 24 | path: `${repoProps.assetPath || ""}${filename}.jpg`, 25 | branch: GITHUB_HEAD_REF, 26 | message: repoProps.commitMsg || "", 27 | content, 28 | ...COMMITTER 29 | }); 30 | } catch (err) { 31 | error(`Adding a commit to branch ${GITHUB_HEAD_REF} failed with ${err}`); 32 | } 33 | } 34 | 35 | export default commitFile; 36 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | const GITHUB_TOKEN = process.env["GITHUB_TOKEN"]; 2 | const GITHUB_EVENT_NAME = process.env["GITHUB_EVENT_NAME"]; 3 | const REPO_DIRECTORY = process.env["GITHUB_WORKSPACE"]; 4 | const GITHUB_REPOSITORY = process.env["GITHUB_REPOSITORY"]; 5 | const GITHUB_EVENT_PATH = process.env["GITHUB_EVENT_PATH"]; 6 | const GITHUB_HEAD_REF = process.env["GITHUB_HEAD_REF"]; 7 | const GITHUB_CONTEXT = process.env["GITHUB_CONTEXT"]; 8 | 9 | if (!REPO_DIRECTORY) { 10 | console.log("There is no GITHUB_WORKSPACE environment variable"); 11 | process.exit(1); 12 | } 13 | 14 | if (!GITHUB_REPOSITORY) { 15 | console.log("Can't find github repository"); 16 | process.exit(1); 17 | } 18 | 19 | const FORMATS = [".md", ".mdx"]; 20 | 21 | const USER_REPO = (GITHUB_REPOSITORY as string).split("/"); 22 | 23 | const COMMITTER = { 24 | name: "OG Bot", 25 | email: "hello@agney.dev" 26 | }; 27 | 28 | export { 29 | COMMITTER, 30 | FORMATS, 31 | GITHUB_TOKEN, 32 | GITHUB_EVENT_NAME, 33 | GITHUB_EVENT_PATH, 34 | REPO_DIRECTORY, 35 | USER_REPO, 36 | GITHUB_HEAD_REF, 37 | GITHUB_CONTEXT 38 | }; 39 | -------------------------------------------------------------------------------- /src/create-comment.ts: -------------------------------------------------------------------------------- 1 | import getPrNumber from "./get-pr-number"; 2 | import { USER_REPO } from "./constants"; 3 | import octokit from "./github-api"; 4 | 5 | const createComment = async body => { 6 | const [owner, repo] = USER_REPO; 7 | const prNumber = getPrNumber(); 8 | 9 | return octokit.issues.createComment({ 10 | owner, 11 | repo, 12 | issue_number: prNumber, 13 | body 14 | }); 15 | }; 16 | 17 | export default createComment; 18 | -------------------------------------------------------------------------------- /src/find-file.ts: -------------------------------------------------------------------------------- 1 | import fm from "front-matter"; 2 | import { readFileSync } from "fs"; 3 | import { kebabCase } from "lodash"; 4 | import { PullsListFilesResponseItem } from "@octokit/rest"; 5 | 6 | import { USER_REPO, FORMATS, REPO_DIRECTORY } from "./constants"; 7 | import octokit from "./github-api"; 8 | import { IFrontMatter, IFileProps } from "./types"; 9 | import getPrNumber from "./get-pr-number"; 10 | 11 | /** 12 | * Get name of the file if provided by the user or title in kebab case 13 | * @param filename 14 | * @param title 15 | */ 16 | function getFileName(filename: string | undefined, title: string) { 17 | if (filename) { 18 | return filename; 19 | } else { 20 | return kebabCase(title); 21 | } 22 | } 23 | 24 | /** 25 | * Extract JSON from markdown frontmatter 26 | * @param files List of files in the PR 27 | */ 28 | function getAttributes(files: PullsListFilesResponseItem[]): IFileProps[] { 29 | return files.map(file => { 30 | const filename = file.filename; 31 | const repoDirectory = REPO_DIRECTORY as string; 32 | const contents = readFileSync(`${repoDirectory}/${filename}`, { 33 | encoding: "utf8" 34 | }); 35 | const { attributes } = fm(contents); 36 | const reqdAttributes = Object.keys(attributes).length 37 | ? { ...(attributes.ogImage || {}) } 38 | : {}; 39 | return { 40 | filename: getFileName( 41 | reqdAttributes["fileName"], 42 | reqdAttributes["title"] 43 | ), 44 | attributes: reqdAttributes 45 | }; 46 | }); 47 | } 48 | 49 | /** 50 | * Find files with md and mdx extensions and extract information 51 | * @returns Front matter attributes as JSON 52 | */ 53 | async function findFile() { 54 | const [owner, repo] = USER_REPO; 55 | const pullNumber = getPrNumber(); 56 | 57 | const { data: filesList } = await octokit.pulls.listFiles({ 58 | owner, 59 | repo, 60 | pull_number: pullNumber 61 | }); 62 | const markdownFiles = filesList.filter(file => { 63 | return FORMATS.some(format => file.filename.endsWith(format)); 64 | }); 65 | 66 | const frontmatterAttributes = getAttributes(markdownFiles); 67 | 68 | return frontmatterAttributes.filter( 69 | frontmatterAttribute => Object.keys(frontmatterAttribute.attributes).length 70 | ); 71 | } 72 | export default findFile; 73 | -------------------------------------------------------------------------------- /src/generate-html.ts: -------------------------------------------------------------------------------- 1 | import { marked } from "marked"; 2 | import twemoji from "twemoji"; 3 | 4 | import { IRepoProps } from "./types"; 5 | 6 | function createVariables(name: string, value?: string) { 7 | if (value) { 8 | return `--${name}: ${value};`; 9 | } 10 | return ""; 11 | } 12 | 13 | function getImageUrl(imageUrl?: string) { 14 | if (!imageUrl) { 15 | return ""; 16 | } 17 | if (twemoji.test(imageUrl)) { 18 | return twemoji.parse(imageUrl, { 19 | attributes: () => ({ 20 | slot: "image" 21 | }) 22 | }); 23 | } 24 | return ``; 25 | } 26 | 27 | function getMarked(text?: string) { 28 | if (!text) { 29 | return ""; 30 | } 31 | return marked(text); 32 | } 33 | 34 | function generateHtml(prop: Partial) { 35 | return ` 36 | 37 | 38 | 39 | 40 | 41 | 53 | 54 | 55 | 56 | 57 | ${getImageUrl(prop.imageUrl)} 58 |
${getMarked(prop.title)}
59 |
60 | 61 | 62 | `; 63 | } 64 | 65 | export default generateHtml; 66 | -------------------------------------------------------------------------------- /src/generate-image.ts: -------------------------------------------------------------------------------- 1 | import puppeteer from "puppeteer-core"; 2 | import { IViewport } from "./types"; 3 | 4 | async function generateImage(viewport: IViewport, html: string) { 5 | const browser = await puppeteer.launch({ 6 | executablePath: "/usr/bin/google-chrome-unstable", 7 | args: ["--no-sandbox"] 8 | }); 9 | const page = await browser.newPage(); 10 | page.setViewport({ 11 | width: +viewport.width, 12 | height: +viewport.height 13 | }); 14 | await page.setContent(html); 15 | const image = await page.screenshot({ encoding: "base64" }); 16 | await browser.close(); 17 | return image; 18 | } 19 | 20 | export default generateImage; 21 | -------------------------------------------------------------------------------- /src/get-pr-number.ts: -------------------------------------------------------------------------------- 1 | import { GITHUB_CONTEXT } from "./constants"; 2 | 3 | function getPrNumber(): number { 4 | const githubCtx: any = JSON.parse(GITHUB_CONTEXT as string); 5 | const pullNumber = githubCtx.event.number; 6 | return pullNumber; 7 | } 8 | 9 | export default getPrNumber; 10 | -------------------------------------------------------------------------------- /src/github-api.ts: -------------------------------------------------------------------------------- 1 | import { GitHub } from "@actions/github"; 2 | import { GITHUB_TOKEN } from "./constants"; 3 | 4 | const octokit = new GitHub(GITHUB_TOKEN as string); 5 | 6 | export default octokit; -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { warning } from "@actions/core"; 3 | 4 | import { GITHUB_TOKEN, GITHUB_EVENT_NAME } from "./constants"; 5 | import generateImage from "./generate-image"; 6 | import commitFile from "./commit-file"; 7 | import generateHtml from "./generate-html"; 8 | import findFile from "./find-file"; 9 | import getRepoProps from "./repo-props"; 10 | import commentMarkdown from "./comment-markdown"; 11 | import createComment from "./create-comment"; 12 | 13 | if (!GITHUB_TOKEN) { 14 | console.log("You must enable the GITHUB_TOKEN secret"); 15 | process.exit(1); 16 | } 17 | 18 | async function run() { 19 | // Bail out if the event that executed the action wasn’t a pull_request 20 | if (GITHUB_EVENT_NAME !== "pull_request") { 21 | console.log("This action only runs for pushes to PRs"); 22 | process.exit(78); 23 | } 24 | 25 | const repoProps = await getRepoProps(); 26 | const fileProperties = await findFile(); 27 | 28 | if (!fileProperties.length) { 29 | warning("No compatible files found"); 30 | } 31 | 32 | fileProperties.forEach(async property => { 33 | const html = generateHtml({ 34 | ...repoProps, 35 | ...property.attributes 36 | }); 37 | 38 | const image = await generateImage( 39 | { 40 | width: repoProps.width, 41 | height: repoProps.height 42 | }, 43 | html 44 | ); 45 | 46 | commitFile(image, repoProps, property.filename); 47 | 48 | if (repoProps.botComments != "no") { 49 | const markdown = commentMarkdown( 50 | `${repoProps.assetPath}${property.filename}` 51 | ); 52 | await createComment(markdown); 53 | } 54 | }); 55 | } 56 | 57 | run(); 58 | -------------------------------------------------------------------------------- /src/repo-props.ts: -------------------------------------------------------------------------------- 1 | import { getInput } from "@actions/core"; 2 | 3 | /** 4 | * Get repository level property defaults. 5 | */ 6 | async function getRepoProps() { 7 | const assetPath = getInput(`path`); 8 | const commitMsg = getInput(`commitMsg`); 9 | const background = getInput(`background`); 10 | const fontColor = getInput(`fontColor`); 11 | const componentUrl = getInput("componentUrl"); 12 | const fontSize = getInput("fontSize"); 13 | const width = getInput("width"); 14 | const height = getInput("height"); 15 | const botComments = getInput("botComments"); 16 | return { 17 | assetPath, 18 | componentUrl, 19 | commitMsg, 20 | background, 21 | fontColor, 22 | fontSize, 23 | width, 24 | height, 25 | botComments 26 | }; 27 | } 28 | 29 | export default getRepoProps; 30 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export interface IFileProps { 2 | filename: string; 3 | attributes: Partial; 4 | } 5 | 6 | export interface IProps { 7 | title: string; 8 | subtitle: string; 9 | filename: string; 10 | imageUrl: string; 11 | background: string; 12 | fontColor: string; 13 | fontSize: string; 14 | } 15 | 16 | export interface IFrontMatter { 17 | ogImage: IProps; 18 | } 19 | 20 | export interface IRepoProps extends IProps { 21 | assetPath: string; 22 | commitMsg: string; 23 | componentUrl: string; 24 | width: string | number; 25 | height: string | number; 26 | } 27 | 28 | export interface IViewport { 29 | width: string | number; 30 | height: string | number; 31 | } 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | // "incremental": true, /* Enable incremental compilation */ 5 | "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */ 6 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 7 | // "allowJs": true, /* Allow javascript files to be compiled. */ 8 | // "checkJs": true, /* Report errors in .js files. */ 9 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 10 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 11 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 12 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 13 | // "outFile": "./", /* Concatenate and emit output to single file. */ 14 | "outDir": "./lib", /* Redirect output structure to the directory. */ 15 | "rootDir": "./src", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 16 | // "composite": true, /* Enable project compilation */ 17 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 18 | // "removeComments": true, /* Do not emit comments to output. */ 19 | // "noEmit": true, /* Do not emit outputs. */ 20 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 21 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 22 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 23 | 24 | /* Strict Type-Checking Options */ 25 | "strict": true, /* Enable all strict type-checking options. */ 26 | "noImplicitAny": false, /* Raise error on expressions and declarations with an implied 'any' type. */ 27 | // "strictNullChecks": true, /* Enable strict null checks. */ 28 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 29 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 30 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 31 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 32 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 33 | 34 | /* Additional Checks */ 35 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 36 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 37 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 38 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 39 | 40 | /* Module Resolution Options */ 41 | // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 42 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 43 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 44 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 45 | // "typeRoots": [], /* List of folders to include type definitions from. */ 46 | // "types": [], /* Type declaration files to be included in compilation. */ 47 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 48 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 49 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 50 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 51 | 52 | /* Source Map Options */ 53 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 54 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 55 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 56 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 57 | 58 | /* Experimental Options */ 59 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 60 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 61 | }, 62 | "exclude": ["node_modules", "**/*.test.ts"] 63 | } 64 | --------------------------------------------------------------------------------