├── .all-contributorsrc ├── .gitattributes ├── .github └── workflows │ ├── main.yml │ └── size.yml ├── .gitignore ├── .vscode └── settings.json ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── docs └── example.jpeg ├── other ├── MAINTAINING.md ├── USERS.md └── manual-releases.md ├── package-lock.json ├── package.json ├── src ├── __tests__ │ └── index.test.ts ├── defaultRender.ts ├── encodeHtml.ts ├── index.ts └── openGraphNinja.ts └── tsconfig.json /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "transformer-open-graph", 3 | "projectOwner": "donavon", 4 | "imageSize": 100, 5 | "commit": false, 6 | "commitConvention": "none", 7 | "contributorsPerLine": 7, 8 | "repoHost": "https://github.com", 9 | "repoType": "github", 10 | "skipCi": false, 11 | "files": [ 12 | "README.md" 13 | ], 14 | "contributors": [ 15 | { 16 | "login": "donavon", 17 | "name": "Donavon West", 18 | "avatar_url": "https://avatars.githubusercontent.com/u/887639?v=4", 19 | "profile": "https://donavon.com", 20 | "contributions": [ 21 | "code", 22 | "doc", 23 | "infra", 24 | "test" 25 | ] 26 | } 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | name: Build, lint, and test on Node ${{ matrix.node }} and ${{ matrix.os }} 6 | 7 | runs-on: ${{ matrix.os }} 8 | strategy: 9 | matrix: 10 | node: ['12.x', '14.x', '16.x'] 11 | os: [ubuntu-latest, windows-latest, macOS-latest] 12 | 13 | steps: 14 | - name: Checkout repo 15 | uses: actions/checkout@v2 16 | 17 | - name: Use Node ${{ matrix.node }} 18 | uses: actions/setup-node@v2 19 | with: 20 | node-version: ${{ matrix.node }} 21 | 22 | - name: Install deps and build (with cache) 23 | uses: bahmutov/npm-install@v1 24 | 25 | - name: Lint 26 | run: yarn lint 27 | 28 | - name: Test 29 | run: yarn test --ci --coverage --maxWorkers=2 30 | 31 | - name: Build 32 | run: yarn build 33 | -------------------------------------------------------------------------------- /.github/workflows/size.yml: -------------------------------------------------------------------------------- 1 | name: size 2 | on: [pull_request] 3 | jobs: 4 | size: 5 | runs-on: ubuntu-latest 6 | env: 7 | CI_JOB_NUMBER: 1 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: andresz1/size-limit-action@v1 11 | with: 12 | github_token: ${{ secrets.GITHUB_TOKEN }} 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist 4 | .DS_Store 5 | .env 6 | .env.local 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | The changelog is automatically updated using 4 | [semantic-release](https://github.com/semantic-release/semantic-release). You 5 | can see it on the [releases page](../../releases). 6 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the overall 26 | community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or advances of 31 | any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email address, 35 | without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | coc@donavon.com. All complaints will be reviewed and investigated promptly 64 | and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series of 86 | actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or permanent 93 | ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within the 113 | community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by 122 | [Mozilla's code of conduct enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2022 Donavon West 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # transformer-open-graph 2 | 3 | A transformer for [`@remark-embedder/core`](https://github.com/remark-embedder/core) that extracts Open Graph metadata from a URL and create an HTML preview. 4 | 5 | - Works with [`@remark-embedder/core`](https://github.com/remark-embedder/core) 6 | - Supports Twitter Cards and Open Graph 7 | - Powered by [Open Graph Ninja](https://opengraph.ninja/)'s API 8 | - 100% test coverage 9 | - Written in TypeScript and fully typed 10 | - Awesome! 🎉 11 | 12 | Want to see an example? Check out [https://donavon.com/blog/markdown#social-links](https://donavon.com/blog/markdown#social-links) 13 | 14 | ## Usage 15 | 16 | ```ts 17 | import remarkEmbedder from '@remark-embedder/core'; 18 | import transformerOpenGraph from 'transformer-open-graph'; 19 | import remark from 'remark'; 20 | import html from 'remark-html'; 21 | 22 | const exampleMarkdown = ` 23 | This is my website: 24 | 25 | https://donavon.com 26 | `; 27 | 28 | async function go() { 29 | const result = await remark() 30 | .use(remarkEmbedder, { 31 | transformers: [transformerOpenGraph], 32 | }) 33 | .use(html) 34 | .process(exampleMarkdown); 35 | 36 | console.log(result.toString()); 37 | // logs: 38 | //

This is my website:

39 | //
40 | // 41 | // Donavon West's website 42 | //
43 | //

Donavon West's website

44 | //

Donavon West is a full-stack software engineer living in the New York City area.

45 | //

donavon.com

46 | //
47 | //
48 | //
49 | } 50 | 51 | go(); 52 | ``` 53 | 54 | ## Config options 55 | 56 | You can pass the following config options 57 | 58 | | Options name | description | 59 | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------- | 60 | | `render` | A custom render function that accepts one argument, `data` of type [`OpenGraphNinja`](./src/openGraphNinja.ts), and returns an HTML string. | 61 | 62 | ### Example custom render 63 | 64 | ```ts 65 | const exampleMarkdown = ` 66 | This is my website: 67 | 68 | https://donavon.com 69 | `; 70 | 71 | const customRender: OpenGraphRender = (data) => 72 | `

Hello ${data.hostname}

`; 73 | 74 | const config: OpenGraphConfig = { render: customRender }; 75 | 76 | const result = await remark() 77 | .use(remarkEmbedder, { 78 | transformers: [[transformerOpenGraph, config]], 79 | }) 80 | .use(html) 81 | .process(exampleMarkdown); 82 | //

This is my website:

83 | //

Hello donavon.com

84 | ``` 85 | 86 | ## Use with other transformers 87 | 88 | If you are using other @remark-embedder transformers (e.g. [`@remark-embedder/transformer-oembed`](https://github.com/remark-embedder/transformer-oembed)), place `transformer-open-graph` last in the `transformers` array. 89 | 90 | For example. 91 | 92 | ```ts 93 | import remarkEmbedder, { RemarkEmbedderOptions } from '@remark-embedder/core'; 94 | import oembedTransformer from '@remark-embedder/transformer-oembed'; 95 | import transformerOpenGraph from 'transformer-open-graph'; 96 | 97 | const remarkEmbedderOptions: RemarkEmbedderOptions = { 98 | transformers: [oembedTransformer, transformerOpenGraph], 99 | }; 100 | 101 | const result = await remark() 102 | .use(remarkEmbedder, remarkEmbedderOptions) 103 | .use(html) 104 | .process(exampleMarkdown); 105 | ``` 106 | 107 | ## Example output 108 | 109 | You'll need to B.Y.O. CSS, but you can get beautiful web page preview output like this. 110 | 111 | ![sample screen](./docs/example.jpeg?raw=true) 112 | 113 | ## Getting Started 114 | 115 | Install the library with your package manager of choice, e.g.: 116 | 117 | ``` 118 | $ npm i transformer-open-graph 119 | # or 120 | $ yarn add transformer-open-graph 121 | ``` 122 | 123 | ## License 124 | 125 | © 2022 Donavon West. Released under [MIT license](./LICENSE). 126 | -------------------------------------------------------------------------------- /docs/example.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/donavon/transformer-open-graph/f753e42b71f16e5bc881533674b702d6f03ad164/docs/example.jpeg -------------------------------------------------------------------------------- /other/MAINTAINING.md: -------------------------------------------------------------------------------- 1 | # Maintaining 2 | 3 | 4 | 5 | 6 | **Table of Contents** 7 | 8 | - [Code of Conduct](#code-of-conduct) 9 | - [Issues](#issues) 10 | - [Pull Requests](#pull-requests) 11 | - [Release](#release) 12 | - [Thanks!](#thanks) 13 | 14 | 15 | 16 | This is documentation for maintainers of this project. 17 | 18 | ## Code of Conduct 19 | 20 | Please review, understand, and be an example of it. Violations of the code of 21 | conduct are taken seriously, even (especially) for maintainers. 22 | 23 | ## Issues 24 | 25 | We want to support and build the community. We do that best by helping people 26 | learn to solve their own problems. We have an issue template and hopefully most 27 | folks follow it. If it's not clear what the issue is, invite them to create a 28 | minimal reproduction of what they're trying to accomplish or the bug they think 29 | they've found. 30 | 31 | Once it's determined that a code change is necessary, point people to 32 | [makeapullrequest.com](https://makeapullrequest.com) and invite them to make a 33 | pull request. If they're the one who needs the feature, they're the one who can 34 | build it. If they need some hand holding and you have time to lend a hand, 35 | please do so. It's an investment into another human being, and an investment 36 | into a potential maintainer. 37 | 38 | Remember that this is open source, so the code is not yours, it's ours. If 39 | someone needs a change in the codebase, you don't have to make it happen 40 | yourself. Commit as much time to the project as you want/need to. Nobody can ask 41 | any more of you than that. 42 | 43 | ## Pull Requests 44 | 45 | As a maintainer, you're fine to make your branches on the main repo or on your 46 | own fork. Either way is fine. 47 | 48 | When we receive a pull request, a GitHub Action is kicked off automatically (see 49 | the `.github/workflows/validate.yml` for what runs in the Action). We avoid 50 | merging anything that breaks the GitHub Action. 51 | 52 | Please review PRs and focus on the code rather than the individual. You never 53 | know when this is someone's first ever PR and we want their experience to be as 54 | positive as possible, so be uplifting and constructive. 55 | 56 | When you merge the pull request, 99% of the time you should use the 57 | [Squash and merge](https://help.github.com/articles/merging-a-pull-request/) 58 | feature. This keeps our git history clean, but more importantly, this allows us 59 | to make any necessary changes to the commit message so we release what we want 60 | to release. See the next section on Releases for more about that. 61 | 62 | ## Release 63 | 64 | Our releases are automatic. They happen whenever code lands into `main`. A 65 | GitHub Action gets kicked off and if it's successful, a tool called 66 | [`semantic-release`](https://github.com/semantic-release/semantic-release) is 67 | used to automatically publish a new release to npm as well as a changelog to 68 | GitHub. It is only able to determine the version and whether a release is 69 | necessary by the git commit messages. With this in mind, **please brush up on 70 | [the commit message convention][commit] which drives our releases.** 71 | 72 | > One important note about this: Please make sure that commit messages do NOT 73 | > contain the words "BREAKING CHANGE" in them unless we want to push a major 74 | > version. I've been burned by this more than once where someone will include 75 | > "BREAKING CHANGE: None" and it will end up releasing a new major version. Not 76 | > a huge deal honestly, but kind of annoying... 77 | 78 | ## Thanks! 79 | 80 | Thank you so much for helping to maintain this project! 81 | 82 | 83 | [commit]: https://github.com/conventional-changelog-archived-repos/conventional-changelog-angular/blob/ed32559941719a130bb0327f886d6a32a8cbc2ba/convention.md 84 | 85 | -------------------------------------------------------------------------------- /other/USERS.md: -------------------------------------------------------------------------------- 1 | # Users 2 | 3 | If you or your company uses this project, add your name to this list! Eventually 4 | we may have a website to showcase these (wanna build it!?) 5 | 6 | > No users have been added yet! 7 | 8 | 13 | -------------------------------------------------------------------------------- /other/manual-releases.md: -------------------------------------------------------------------------------- 1 | # manual-releases 2 | 3 | This project has an automated release set up. So things are only released when 4 | there are useful changes in the code that justify a release. But sometimes 5 | things get messed up one way or another and we need to trigger the release 6 | ourselves. When this happens, simply bump the number below and commit that with 7 | the following commit message based on your needs: 8 | 9 | **Major** 10 | 11 | ``` 12 | fix(release): manually release a major version 13 | 14 | There was an issue with a major release, so this manual-releases.md 15 | change is to release a new major version. 16 | 17 | Reference: # 18 | 19 | BREAKING CHANGE: 20 | ``` 21 | 22 | **Minor** 23 | 24 | ``` 25 | feat(release): manually release a minor version 26 | 27 | There was an issue with a minor release, so this manual-releases.md 28 | change is to release a new minor version. 29 | 30 | Reference: # 31 | ``` 32 | 33 | **Patch** 34 | 35 | ``` 36 | fix(release): manually release a patch version 37 | 38 | There was an issue with a patch release, so this manual-releases.md 39 | change is to release a new patch version. 40 | 41 | Reference: # 42 | ``` 43 | 44 | The number of times we've had to do a manual release is: 0 45 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "transformer-open-graph", 3 | "version": "0.3.0", 4 | "publishConfig": { 5 | "access": "public" 6 | }, 7 | "description": "@remark-embedder transformer for URLs that support Open Graph", 8 | "main": "dist/index.js", 9 | "types": "dist/index.d.ts", 10 | "keywords": [ 11 | "remark", 12 | "remark-plugin", 13 | "remark-embedder", 14 | "transformer", 15 | "open-graph", 16 | "open graph" 17 | ], 18 | "author": "Donavon West (https://donavon.com)", 19 | "license": "MIT", 20 | "engines": { 21 | "node": ">=12", 22 | "npm": ">=8" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "https://github.com/donavon/transformer-open-graph" 27 | }, 28 | "bugs": { 29 | "url": "https://github.com/donavon/transformer-open-graph/issues" 30 | }, 31 | "homepage": "https://github.com/donavon/transformer-open-graph#readme", 32 | "files": [ 33 | "dist" 34 | ], 35 | "scripts": { 36 | "build": "kcd-scripts build", 37 | "lint": "kcd-scripts lint", 38 | "setup": "npm install && npm run validate -s", 39 | "test": "kcd-scripts test", 40 | "test:update": "npm test -- --updateSnapshot --coverage", 41 | "coverage": "npm test -- --coverage", 42 | "typecheck": "kcd-scripts typecheck", 43 | "validate": "kcd-scripts validate" 44 | }, 45 | "dependencies": { 46 | "@babel/runtime": "^7.17.8", 47 | "make-fetch-happen": "^10.1.1" 48 | }, 49 | "devDependencies": { 50 | "@remark-embedder/core": "^2.0.0", 51 | "@types/make-fetch-happen": "^9.0.2", 52 | "@types/jest": "^27.4.1", 53 | "kcd-scripts": "^12.1.0", 54 | "msw": "^0.39.2", 55 | "remark": "^13.0.0", 56 | "remark-html": "^13.0.2", 57 | "typescript": "^4.6.3" 58 | }, 59 | "peerDependencies": { 60 | "@remark-embedder/core": "^2.0.0" 61 | }, 62 | "eslintConfig": { 63 | "extends": "./node_modules/kcd-scripts/eslint.js", 64 | "rules": { 65 | "babel/camelcase": "off" 66 | } 67 | }, 68 | "eslintIgnore": [ 69 | "node_modules", 70 | "coverage", 71 | "dist" 72 | ] 73 | } 74 | -------------------------------------------------------------------------------- /src/__tests__/index.test.ts: -------------------------------------------------------------------------------- 1 | import remarkEmbedder from '@remark-embedder/core'; 2 | import { rest } from 'msw'; 3 | import { setupServer } from 'msw/node'; 4 | import remark from 'remark'; 5 | import remarkHTML from 'remark-html'; 6 | import transformer, { OpenGraphConfig, OpenGraphRender } from '..'; 7 | import { OpenGraphNinja, openGraphNinjaUrl } from '../openGraphNinja'; 8 | 9 | const testJson: OpenGraphNinja = { 10 | hostname: 'donavon.com', 11 | requestUrl: 'https://donavon.com', 12 | title: 'TITLE', 13 | description: 'DESCRIPTION', 14 | image: { 15 | url: 'https://donavon.com/img/donavon-avatar.jpeg', 16 | alt: 'ALT', 17 | }, 18 | details: { 19 | author: 'Donavon West', 20 | ogTitle: "Donavon West's website", 21 | ogDescription: 22 | 'Donavon West is a full-stack software engineer living in the New York City area.', 23 | ogUrl: 'https://donavon.com/', 24 | ogLocale: 'en_US', 25 | ogType: 'website', 26 | ogSiteName: "Donavon West's website", 27 | twitterCard: 'summary', 28 | twitterTitle: "Donavon West's website", 29 | twitterDescription: 30 | 'Donavon West is a full-stack software engineer living in the New York City area.', 31 | twitterSite: '@donavon', 32 | twitterCreator: '@donavon', 33 | }, 34 | }; 35 | 36 | // this removes the quotes around strings... 37 | const unquoteSerializer = { 38 | serialize: (val: string) => val.trim(), 39 | test: (val: unknown) => typeof val === 'string', 40 | }; 41 | 42 | expect.addSnapshotSerializer(unquoteSerializer); 43 | 44 | const server = setupServer( 45 | rest.get(openGraphNinjaUrl, (req, res, ctx) => { 46 | const url = req.url.searchParams.get('url'); 47 | 48 | if (url === 'https://example.com/') { 49 | return res(ctx.json(testJson)); 50 | } 51 | if (url === 'https://example.com/noimage') { 52 | // deep clone the object 53 | const testWithoutImage = JSON.parse( 54 | JSON.stringify(testJson) 55 | ) as OpenGraphNinja; 56 | delete testWithoutImage.image; 57 | return res(ctx.json(testWithoutImage)); 58 | } 59 | if (url === 'https://example.com/noimage-alt') { 60 | // deep clone the object 61 | const testWithoutImage = JSON.parse( 62 | JSON.stringify(testJson) 63 | ) as OpenGraphNinja; 64 | delete testWithoutImage.image?.alt; 65 | return res(ctx.json(testWithoutImage)); 66 | } 67 | if (url === 'https://example.com/noimage-alt-title') { 68 | // deep clone the object 69 | const testWithoutImage = JSON.parse( 70 | JSON.stringify(testJson) 71 | ) as OpenGraphNinja; 72 | delete testWithoutImage.image?.alt; 73 | delete testWithoutImage.title; 74 | return res(ctx.json(testWithoutImage)); 75 | } 76 | if (url === 'https://example.com/nodetails') { 77 | // deep clone the object 78 | const testWithoutImage = JSON.parse( 79 | JSON.stringify(testJson) 80 | ) as OpenGraphNinja; 81 | delete testWithoutImage.details; 82 | return res(ctx.json(testWithoutImage)); 83 | } 84 | if (url === 'https://example.com/nodescription') { 85 | // deep clone the object 86 | const testWithoutImage = JSON.parse( 87 | JSON.stringify(testJson) 88 | ) as OpenGraphNinja; 89 | delete testWithoutImage.description; 90 | return res(ctx.json(testWithoutImage)); 91 | } 92 | if (url === 'https://example.com/boom') { 93 | throw new Error('boom'); 94 | } 95 | if (url === 'https://example.com/404') { 96 | return res(ctx.status(404), ctx.text('Not found')); 97 | } 98 | 99 | return res(ctx.json({})); 100 | }) 101 | ); 102 | 103 | // enable API mocking in test runs using the same request handlers 104 | // as for the client-side mocking. 105 | beforeAll(() => server.listen()); 106 | afterAll(() => server.close()); 107 | afterEach(() => server.resetHandlers()); 108 | 109 | test('renders nothing if the fetch throws', async () => { 110 | const result = await remark() 111 | .use(remarkEmbedder, { 112 | transformers: [transformer], 113 | }) 114 | .use(remarkHTML, { sanitize: false }) 115 | .process('https://example.com/boom'); 116 | 117 | expect(result.toString()).toMatchInlineSnapshot( 118 | `

https://example.com/boom

` 119 | ); 120 | }); 121 | 122 | test('renders nothing if fetch returns a non-2xx status', async () => { 123 | const result = await remark() 124 | .use(remarkEmbedder, { 125 | transformers: [transformer], 126 | }) 127 | .use(remarkHTML, { sanitize: false }) 128 | .process('https://example.com/404'); 129 | 130 | expect(result.toString()).toMatchInlineSnapshot( 131 | `

https://example.com/404

` 132 | ); 133 | }); 134 | 135 | describe('config options', () => { 136 | test('render allows for custom markup', async () => { 137 | const customRender: OpenGraphRender = (data) => 138 | `

Hello ${data.hostname}

`; 139 | 140 | const config: OpenGraphConfig = { render: customRender }; 141 | const result = await remark() 142 | .use(remarkEmbedder, { 143 | transformers: [[transformer, config]], 144 | }) 145 | .use(remarkHTML, { sanitize: false }) 146 | .process('https://example.com'); 147 | 148 | console.log(result.toString()); 149 | expect(result.toString()).toMatchInlineSnapshot( 150 | `

Hello donavon.com

` 151 | ); 152 | }); 153 | }); 154 | 155 | test('renders a website with all Open Graph meta tags', async () => { 156 | const result = await remark() 157 | .use(remarkEmbedder, { 158 | transformers: [transformer], 159 | }) 160 | .use(remarkHTML, { sanitize: false }) 161 | .process('https://example.com'); 162 | 163 | expect(result.toString()).toMatchInlineSnapshot(` 164 | 174 | `); 175 | }); 176 | 177 | describe('the default renders using fallback properties if missing', () => { 178 | test('.image', async () => { 179 | const result = await remark() 180 | .use(remarkEmbedder, { 181 | transformers: [transformer], 182 | }) 183 | .use(remarkHTML, { sanitize: false }) 184 | .process('https://example.com/noimage'); 185 | 186 | expect(result.toString()).toMatchInlineSnapshot(` 187 | 197 | `); 198 | }); 199 | 200 | test('.image.alt', async () => { 201 | const result = await remark() 202 | .use(remarkEmbedder, { 203 | transformers: [transformer], 204 | }) 205 | .use(remarkHTML, { sanitize: false }) 206 | .process('https://example.com/noimage-alt'); 207 | 208 | expect(result.toString()).toMatchInlineSnapshot(` 209 | 219 | `); 220 | }); 221 | 222 | test('.image.alt and .title', async () => { 223 | const result = await remark() 224 | .use(remarkEmbedder, { 225 | transformers: [transformer], 226 | }) 227 | .use(remarkHTML, { sanitize: false }) 228 | .process('https://example.com/noimage-alt-title'); 229 | 230 | expect(result.toString()).toMatchInlineSnapshot(` 231 | 241 | `); 242 | }); 243 | 244 | test('.details', async () => { 245 | const result = await remark() 246 | .use(remarkEmbedder, { 247 | transformers: [transformer], 248 | }) 249 | .use(remarkHTML, { sanitize: false }) 250 | .process('https://example.com/nodetails'); 251 | 252 | expect(result.toString()).toMatchInlineSnapshot(` 253 | 263 | `); 264 | }); 265 | 266 | test('.description', async () => { 267 | const result = await remark() 268 | .use(remarkEmbedder, { 269 | transformers: [transformer], 270 | }) 271 | .use(remarkHTML, { sanitize: false }) 272 | .process('https://example.com/nodescription'); 273 | 274 | expect(result.toString()).toMatchInlineSnapshot(` 275 | 285 | `); 286 | }); 287 | }); 288 | -------------------------------------------------------------------------------- /src/defaultRender.ts: -------------------------------------------------------------------------------- 1 | import { encodeHtml } from './encodeHtml'; 2 | import { OpenGraphNinja } from './openGraphNinja'; 3 | 4 | const getImageHtml = (data?: OpenGraphNinja) => 5 | data?.image 6 | ? `${encodeHtml(
 7 |         data.image.alt ?? data.title ?? ''
 8 |       )}` 9 | : ''; 10 | 11 | export const defaultRender = (data: OpenGraphNinja): string => 12 | ` 13 | 29 | `; 30 | -------------------------------------------------------------------------------- /src/encodeHtml.ts: -------------------------------------------------------------------------------- 1 | export const encodeHtml = (html: string) => 2 | html 3 | .replace(/&/g, '&') 4 | .replace(//g, '>') 6 | .replace(/"/g, '"') 7 | .replace(/'/g, '''); 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import type { Transformer } from '@remark-embedder/core'; 3 | import fetch from 'make-fetch-happen'; 4 | import { OpenGraphNinja, openGraphNinjaUrl } from './openGraphNinja'; 5 | import { defaultRender } from './defaultRender'; 6 | 7 | fetch.defaults({ 8 | cachePath: path.join( 9 | process.cwd(), 10 | 'node_modules/.cache/donavon/transformer-open-graph/fetch' 11 | ), 12 | }); 13 | 14 | async function fetchOpenGraph(url: string): Promise { 15 | try { 16 | const res = await fetch( 17 | `${openGraphNinjaUrl}?url=${encodeURIComponent(url)}` 18 | ); 19 | if (!res.ok) return null; 20 | 21 | const data = (await res.json()) as OpenGraphNinja; 22 | return data; 23 | } catch { 24 | return null; 25 | } 26 | } 27 | export type OpenGraphRender = (ogData: OpenGraphNinja) => string; 28 | export type OpenGraphConfig = { 29 | render?: OpenGraphRender; 30 | }; 31 | 32 | const transformer: Transformer = { 33 | name: 'donavon/transformer-open-graph', 34 | 35 | shouldTransform: async (url) => { 36 | const result = await fetchOpenGraph(url); 37 | return !!result; 38 | }, 39 | 40 | getHTML: async (urlString, config = {}) => { 41 | const data = await fetchOpenGraph(urlString); 42 | 43 | // istanbul ignore if (shouldTransform prevents this, but if someone calls this directly then this would save them) 44 | if (!data) return null; 45 | 46 | const { render = defaultRender } = config; 47 | return render(data); 48 | }, 49 | }; 50 | 51 | export default transformer; 52 | -------------------------------------------------------------------------------- /src/openGraphNinja.ts: -------------------------------------------------------------------------------- 1 | export const openGraphNinjaUrl = 'https://opengraph.ninja/api/v1'; 2 | 3 | export type OpenGraphNinja = { 4 | hostname: string; 5 | requestUrl: string; 6 | title?: string; 7 | description?: string; 8 | image?: { 9 | url: string; 10 | alt?: string; 11 | }; 12 | details?: { 13 | author?: string; 14 | ogUrl?: string; 15 | ogTitle?: string; 16 | ogDescription?: string; 17 | twitterCard?: string; 18 | twitterCreator?: string; 19 | twitterSite?: string; 20 | twitterTitle?: string; 21 | twitterDescription?: string; 22 | ogType?: string; 23 | ogSiteName?: string; 24 | ogImage?: { 25 | url?: string; 26 | width?: string; 27 | height?: string; 28 | type?: string; 29 | }; 30 | twitterImage?: { 31 | url?: string; 32 | width?: string; 33 | height?: string; 34 | alt?: string; 35 | }; 36 | ogLocale?: string; 37 | }; 38 | }; 39 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "exclude": ["node_modules"], 3 | "include": ["src"], 4 | "compilerOptions": { 5 | "isolatedModules": true, 6 | "esModuleInterop": true, 7 | "moduleResolution": "node", 8 | "noEmit": true, 9 | "strict": true, 10 | "jsx": "react", 11 | "skipLibCheck": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "baseUrl": "../../src", 14 | "paths": { 15 | "*": ["*", "../tests/*"] 16 | }, 17 | "preserveWatchOutput": true 18 | } 19 | } 20 | --------------------------------------------------------------------------------