├── .npmignore ├── .vscode └── extensions.json ├── Contributing.md ├── .eleventy.js ├── package.json ├── tweet.njk ├── .gitignore ├── tweet.css ├── readme.md └── twitter.js /.npmignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | Contributing.md 3 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations. 3 | // Extension identifier format: ${publisher}.${name}. Example: vscode.csharp 4 | 5 | // List of extensions which should be recommended for users of this workspace. 6 | "recommendations": [ 7 | "mikestead.dotenv" 8 | ], 9 | // List of extensions recommended by VS Code that should not be recommended for users of this workspace. 10 | "unwantedRecommendations": [ 11 | 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /Contributing.md: -------------------------------------------------------------------------------- 1 | # Package Maintenance Info 2 | 3 | Info for local developers to get the package up and running locally and publish it live 4 | 5 | ## Run package locally 6 | 7 | 1. In current package directory run `npm link` 8 | 9 | ```bash 10 | npm link 11 | ``` 12 | 13 | 2. In the directory you want to consume the package, run the following: 14 | 15 | ```bash 16 | npm link eleventy-plugin-embed-tweet 17 | ``` 18 | 19 | ## Deployment 20 | 21 | ### Project Setup 22 | 23 | ```bash 24 | npm config set scope kylemit 25 | npm config set access public 26 | ``` 27 | 28 | ### User Setup 29 | 30 | 31 | #### Login to npm using either of the methods 32 | 33 | **A) Login to npm** 34 | 35 | ```bash 36 | npm login 37 | ``` 38 | 39 | or 40 | 41 | **B) For [multiple accounts](https://stackoverflow.com/a/50130282/1366033)** 42 | 43 | Add `.npmrc` file in the current directory with the following info: 44 | 45 | ```ini 46 | //registry.npmjs.org/:_authToken=*** 47 | ``` 48 | 49 | ### Publish Package 50 | 51 | Revision version number in `package.json` 52 | 53 | ```bash 54 | npm publish --access public 55 | ``` 56 | -------------------------------------------------------------------------------- /.eleventy.js: -------------------------------------------------------------------------------- 1 | const pkg = require("./package.json"); 2 | const twitter = require("./twitter") 3 | 4 | module.exports = { 5 | initArguments: {}, 6 | configFunction: function(eleventyConfig, {cacheDirectory = "", useInlineStyles = true, autoEmbed = false} = {}) { 7 | // combine destructured option params 8 | let options = {cacheDirectory, useInlineStyles, autoEmbed} 9 | 10 | try { 11 | eleventyConfig.versionCheck(pkg["11ty"].compatibility); 12 | } catch (e) { 13 | console.log( 14 | `WARN: Eleventy Plugin (${pkg.name}) Compatibility: ${e.message}` 15 | ); 16 | } 17 | // added in 0.10.0 18 | eleventyConfig.addNunjucksAsyncShortcode("tweet", async(tweetId) => { 19 | return await twitter.getTweet(tweetId, options) 20 | }); 21 | 22 | eleventyConfig.addNunjucksAsyncShortcode("tweetStyles", async() => { 23 | return await twitter.getStyles() 24 | }); 25 | 26 | if (options.autoEmbed) { 27 | eleventyConfig.addTransform("autoEmbedTweets", async (content, outputPath) => { 28 | return await twitter.autoEmbedTweets(content, outputPath, options) 29 | }); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eleventy-plugin-embed-tweet", 3 | "version": "0.3.3", 4 | "publishConfig": { 5 | "access": "public" 6 | }, 7 | "description": "A plugin for embedding tweets on the server side during build time", 8 | "main": ".eleventy.js", 9 | "scripts": {}, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/KyleMit/eleventy-plugin-embed-tweet.git" 13 | }, 14 | "bugs": { 15 | "url": "https://github.com/KyleMit/eleventy-plugin-embed-tweet/issues" 16 | }, 17 | "homepage": "https://github.com/KyleMit/eleventy-plugin-embed-tweet#readme", 18 | "keywords": [ 19 | "eleventy", 20 | "eleventy-plugin", 21 | "twitter" 22 | ], 23 | "author": { 24 | "name": "Kyle Mitofsky", 25 | "email": "kylemit@gmail.com", 26 | "url": "http://kylemit.dev/" 27 | }, 28 | "license": "MIT", 29 | "dependencies": { 30 | "dotenv": "^8.2.0", 31 | "html-minifier": "^4.0.0", 32 | "moment": "^2.23.0", 33 | "nunjucks": "^3.2.1", 34 | "request": "^2.88.0", 35 | "request-promise": "^4.2.5", 36 | "string-replace-async": "^1.2.1" 37 | }, 38 | "11ty": { 39 | "compatibility": ">=0.10.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /tweet.njk: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | Avatar for {{user.screen_name}} 5 | 6 | 14 | 15 | 17 | 18 |
19 |

20 | {{htmlText | safe}} 21 |

22 |
23 | {% for img in images %} 24 | Image from Tweet 25 | {% endfor %} 26 |
27 | 39 |
40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and not Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # Stores VSCode versions used for testing VSCode extensions 107 | .vscode-test 108 | 109 | .env -------------------------------------------------------------------------------- /tweet.css: -------------------------------------------------------------------------------- 1 | /* card */ 2 | .tweet-card { 3 | background: white; 4 | margin: 15px 0; 5 | padding: 20px; 6 | background-color: #fff; 7 | border: 1px solid #e1e8ed; 8 | border-radius: 5px; 9 | font-size: 16px; 10 | line-height: 1.4; 11 | font-family: Helvetica, Roboto, "Segoe UI", Calibri, sans-serif; 12 | color: #1c2022; 13 | max-width: 500px; 14 | text-align: left; 15 | } 16 | 17 | .tweet-card:hover { 18 | border-color: #ccd6dd; 19 | } 20 | 21 | .tweet-card a { 22 | color: #2b7bb9; 23 | text-decoration: none; 24 | } 25 | 26 | .tweet-card a:hover { 27 | color: #3b94d9; 28 | } 29 | 30 | 31 | 32 | /* header */ 33 | .tweet-header { 34 | display: flex; 35 | } 36 | 37 | .tweet-header .tweet-profile { 38 | margin-right: 9px; 39 | flex-shrink: 0; 40 | } 41 | 42 | .tweet-header .tweet-profile img { 43 | border-radius: 50%; 44 | height: 36px; 45 | width: 36px; 46 | } 47 | 48 | .tweet-header .tweet-author { 49 | display: flex; 50 | flex-direction: column; 51 | flex-grow: 1; 52 | } 53 | 54 | .tweet-header .tweet-author-name { 55 | font-weight: 700; 56 | color: #1c2022; 57 | line-height: 1.3; 58 | } 59 | 60 | .tweet-header .tweet-author-name:hover { 61 | color: #3b94d9; 62 | } 63 | 64 | .tweet-header .tweet-author-handle { 65 | color: #697882; 66 | font-size: 14px; 67 | line-height: 1; 68 | } 69 | 70 | .tweet-header .tweet-bird { 71 | margin-left: 20px; 72 | } 73 | 74 | 75 | /* images */ 76 | .tweet-images img { 77 | width: 100%; 78 | max-height: 250px; 79 | object-fit: cover; 80 | margin-bottom: 10px; 81 | border-radius: 4px; 82 | } 83 | 84 | 85 | /* footer */ 86 | .tweet-footer { 87 | display: flex; 88 | align-items: center; 89 | } 90 | 91 | .tweet-card .tweet-footer a { 92 | color: #697882; 93 | font-size: 14px; 94 | } 95 | 96 | .tweet-footer .tweet-date:hover { 97 | color: #2b7bb9; 98 | } 99 | 100 | .tweet-footer .tweet-like { 101 | margin-right: 15px; 102 | font-size: 15px; 103 | display: flex; 104 | align-items: center; 105 | } 106 | 107 | .tweet-footer .tweet-like:hover { 108 | color: #e0245e; 109 | } 110 | 111 | .tweet-footer .tweet-like-count { 112 | margin-left: 4px; 113 | } 114 | 115 | .tweet-footer .tweet-like-icon { 116 | filter: grayscale(1)brightness(1.4); 117 | transition: filter; 118 | } 119 | 120 | .tweet-footer .tweet-like:hover .tweet-like-icon { 121 | filter: none; 122 | } 123 | 124 | 125 | 126 | /* icons */ 127 | .tweet-bird-icon, 128 | .tweet-like-icon { 129 | display: inline-block; 130 | width: 1.25em; 131 | height: 1.25em; 132 | background-size: contain; 133 | background-repeat: no-repeat; 134 | vertical-align: text-bottom; 135 | } 136 | 137 | .tweet-bird-icon { 138 | background-image: url(data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2072%2072%22%3E%3Cpath%20fill%3D%22none%22%20d%3D%22M0%200h72v72H0z%22%2F%3E%3Cpath%20class%3D%22icon%22%20fill%3D%22%231da1f2%22%20d%3D%22M68.812%2015.14c-2.348%201.04-4.87%201.744-7.52%202.06%202.704-1.62%204.78-4.186%205.757-7.243-2.53%201.5-5.33%202.592-8.314%203.176C56.35%2010.59%2052.948%209%2049.182%209c-7.23%200-13.092%205.86-13.092%2013.093%200%201.026.118%202.02.338%202.98C25.543%2024.527%2015.9%2019.318%209.44%2011.396c-1.125%201.936-1.77%204.184-1.77%206.58%200%204.543%202.312%208.552%205.824%2010.9-2.146-.07-4.165-.658-5.93-1.64-.002.056-.002.11-.002.163%200%206.345%204.513%2011.638%2010.504%2012.84-1.1.298-2.256.457-3.45.457-.845%200-1.666-.078-2.464-.23%201.667%205.2%206.5%208.985%2012.23%209.09-4.482%203.51-10.13%205.605-16.26%205.605-1.055%200-2.096-.06-3.122-.184%205.794%203.717%2012.676%205.882%2020.067%205.882%2024.083%200%2037.25-19.95%2037.25-37.25%200-.565-.013-1.133-.038-1.693%202.558-1.847%204.778-4.15%206.532-6.774z%22%2F%3E%3C%2Fsvg%3E); 139 | } 140 | 141 | .tweet-like-icon { 142 | background-image: url(data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2224%22%20height%3D%2224%22%20viewBox%3D%220%200%2024%2024%22%3E%3Cpath%20class%3D%22icon%22%20fill%3D%22%23E0245E%22%20d%3D%22M12%2021.638h-.014C9.403%2021.59%201.95%2014.856%201.95%208.478c0-3.064%202.525-5.754%205.403-5.754%202.29%200%203.83%201.58%204.646%202.73.813-1.148%202.353-2.73%204.644-2.73%202.88%200%205.404%202.69%205.404%205.755%200%206.375-7.454%2013.11-10.037%2013.156H12zM7.354%204.225c-2.08%200-3.903%201.988-3.903%204.255%200%205.74%207.035%2011.596%208.55%2011.658%201.52-.062%208.55-5.917%208.55-11.658%200-2.267-1.822-4.255-3.902-4.255-2.528%200-3.94%202.936-3.952%202.965-.23.562-1.156.562-1.387%200-.015-.03-1.426-2.965-3.955-2.965z%22%2F%3E%3C%2Fsvg%3E); 143 | } -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | 2 | ## Installation 3 | 4 | Install [on npm](https://www.npmjs.com/package/eleventy-plugin-embed-tweet) 5 | 6 | ```bash 7 | npm install eleventy-plugin-embed-tweet --save 8 | ``` 9 | 10 | Add the following to your .`eleventy.js` config - see docs for options param: 11 | 12 | ```js 13 | module.exports = function(eleventyConfig) { 14 | 15 | const pluginEmbedTweet = require("eleventy-plugin-embed-tweet") 16 | eleventyConfig.addPlugin(pluginEmbedTweet); 17 | 18 | }; 19 | ``` 20 | 21 | ## Requirements 22 | 23 | Requires signing up for [~~free~~](https://twitter.com/TwitterDev/status/1621026986784337922?s=20&t=wOM6Oe0n-JGSIpbXheaYHg) twitter [developer API account](https://developer.twitter.com/en/apply-for-access) - see docs for walkthrough 24 | 25 | This plugin relies on making live API calls during build by using async shortcodes which were added in [**Eleventy v0.10.0**](https://github.com/11ty/eleventy/releases/tag/v0.10.0-beta.1) so it's listed as a peer dependency 26 | 27 | ## Basic Usage 28 | 29 | Embed a tweet anywhere you can use nunjucks templates using a shortcode with the tweet id like this: 30 | 31 | ### Nunjucks 32 | 33 | ```js 34 | {% tweet "1188837207206977536" %} 35 | ``` 36 | 37 | > **Note**: ID must be passed as a string because [long numbers will truncate in JS](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER)) 38 | 39 | ## Demo 40 | 41 | Demo project available on github at [KyleMit/eleventy-plugin-embed-tweet-demo](https://github.com/KyleMit/eleventy-plugin-embed-tweet-demo) and live version hosted on [eleventy-embed-tweet.netlify.com/](https://eleventy-embed-tweet.netlify.com/) 42 | 43 | ## Goals 44 | 45 | * Avoid client side API calls 46 | * Minimize repetitive server side API calls 47 | * Cache assets and API data so project can build offline 48 | * Leverage free (personal) twitter API tier on build server 49 | * Provide fallback for local development for distributed devs who want to run without adding credentials 50 | 51 | ## Todo 52 | 53 | * [ ] Destructure API JSON before caching - only store info we care about 54 | * [ ] Cache profile and media images 55 | * [ ] Much better docs 56 | * [ ] Figure out more consistent CSS structure 57 | * [ ] Handle `
` elements 58 | * [ ] Automate `npm publish` 59 | 60 | ## Docs 61 | 62 | ### Setting up Twitter Account 63 | 64 | ### Setting .ENV variables 65 | 66 | Once you sign up for a twitter account, you'll need to create a file named `.env` at the project root - it contains keys so it's excluded by the `.gitignore` so each person will have to manually create their own: 67 | 68 | ```env 69 | TOKEN=******** 70 | TOKEN_SECRET=******** 71 | CONSUMER_KEY=******** 72 | CONSUMER_SECRET=******** 73 | ``` 74 | 75 | Remember to update your `.gitignore` with `.env` so you don't commit your secrets 76 | 77 | You'll also have to add the environment variables on your build server. 78 | 79 | If you build without setting environment variables, the API call won't work, so the plugin will fallback to using the client side embed available from [publish.twitter.com](https://publish.twitter.com/#) 80 | 81 | ### Plugin Options 82 | 83 | ```js 84 | let pluginEmbedTweet = require("eleventy-plugin-embed-tweet") 85 | 86 | let tweetEmbedOptions = { 87 | cacheDirectory: '', // default: '' 88 | useInlineStyles: true, // default: true 89 | autoEmbed: false // default: false 90 | } 91 | 92 | eleventyConfig.addPlugin(pluginEmbedTweet, tweetEmbedOptions); 93 | ``` 94 | 95 | #### `useInlineStyles` 96 | 97 | By default the single embed will contain all the necessary html and css to render each tweet. This occurs by including a ` 109 | ``` 110 | 111 | **Feel free to also add your own styles** and customize however you'd like. The official twitter widget is implemented as a web component and [makes styling through the shadow dom really tricky](https://stackoverflow.com/a/59493027/1366033) - but this sits as flat html on your page - so customize however you'd like 112 | 113 | The rough structure of the html returned by this plugin looks like this: 114 | 115 | ```css 116 | .tweet-card 117 | .tweet-header 118 | .tweet-profile 119 | .tweet-author 120 | .tweet-author-name 121 | .tweet-author-handle 122 | .tweet-bird 123 | .tweet-bird-icon 124 | .tweet-body 125 | .tweet-images 126 | .tweet-footer 127 | .tweet-like 128 | .tweet-like-icon.tweet-icon 129 | .tweet-like-count 130 | .tweet-date 131 | ``` 132 | 133 | #### `cacheDirectory` 134 | 135 | Relying on an external, authenticated API to build your site has some tradeoffs: 136 | 137 | * The Build will fail if API is down (twitter on fire) 138 | * The Build will fail if you're offline (on a train) 139 | * People who clone the repo will need to sign up for their own API keys in order to do a full build 140 | * Increases build times with repetitive calls (especially during debugging) 141 | * Increases reliance on API access over long term 142 | 143 | To address these tradeoffs, this plugin can cache API calls locally which you can periodically commit to your repository. 144 | 145 | To enable, you can pass a `cacheDirctory` path relative to your project root where you'd like to have data saved: 146 | 147 | ```js 148 | eleventyConfig.addPlugin(pluginEmbedTweet, {cacheDirectory: 'tweets'}); 149 | ``` 150 | 151 | Because this directory will be updated during build time, you **must add a [`.eleventyignore`](https://www.11ty.dev/docs/ignores/)** with the directories name, otherwise `eleventy --serve` can get stuck in an infinite loop when it detects changes and tries to rebuild the site during the build. 152 | 153 | **File**: `.eleventyignore` 154 | 155 | ```bash 156 | tweets/ 157 | ``` 158 | 159 | I'd recommend periodically committing this file. The only data that'll change over time is the `favorites_count`, but otherwise you can have a relatively shelf stable set of data in your repository 160 | 161 | If you'd like to guarantee that the build server is always getting the latest data for `favorites_count`, you can set `CACHE_BUST=true` in your build environment's variables or include in your scripts in you `package.json` like this: 162 | 163 | ```json 164 | "scripts": { 165 | "build": "npx eleventy", 166 | "serve": "npx eleventy --serve", 167 | "full-build": "set CACHE_BUST=true && npx eleventy", 168 | "clear-cache": "rm -rf tweets" 169 | } 170 | ``` 171 | 172 | #### `autoEmbed` 173 | 174 | This configuration option, if set to `true`, adds a transform to Eleventy to find links to tweets that are "alone" in a paragraph, with the value equal to the URL, and auto embed them, replacing the link. 175 | 176 | For example: 177 | 178 | ```html 179 |

https://twitter.com/KyleMitBTV/status/1211079569245114371

180 | ``` 181 | 182 | Will be replaced by the embedded tweet. 183 | 184 | In the source Markdown, it only needs the tweet URL on one single line: 185 | 186 | ```markdown 187 | 188 | https://twitter.com/KyleMitBTV/status/1211079569245114371 189 | 190 | ``` 191 | 192 | ## Performance 193 | 194 | Static site generators work hard to bake in performance and do as much work ahead of time as possible. 195 | 196 | Which is why it's a bummer that the official way to embed tweets is to use the [publish.twitter.com](https://publish.twitter.com/#) which looks like this: 197 | 198 | ```html 199 |
200 |

201 | Nobody:

202 | Software Marketing Page: "Blazingly Fast" 203 |

204 | — Kyle Mitofsky (@KyleMitBTV) 205 | October 28, 2019 206 |
207 | 208 | ``` 209 | 210 | And performs like this: 211 | 212 | ![twitter network traffic](https://i.imgur.com/4SFqs4P.png) 213 | 214 | // TODO - include real performance metrics/deltas 215 | -------------------------------------------------------------------------------- /twitter.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config() 2 | const request = require('request-promise') 3 | const { promises: fs } = require("fs"); 4 | const syncFs = require('fs') 5 | 6 | 7 | module.exports = { 8 | getTweet, 9 | getStyles, 10 | autoEmbedTweets 11 | } 12 | 13 | async function getTweet(tweetId, options) { 14 | 15 | // if we using cache and not cache busting, check there first 16 | if (options.cacheDirectory && !process.env.CACHE_BUST) { 17 | let cachedTweets = await getCachedTweets(options); 18 | let cachedTweet = cachedTweets[tweetId] 19 | 20 | // if we have a cached tweet, use that 21 | if (cachedTweet) { 22 | return formatTweet(cachedTweet, options) 23 | } 24 | // else continue on 25 | } 26 | 27 | 28 | // if we have env variables, go get tweet 29 | if (hasAuth()) { 30 | let liveTweet = await fetchTweet(tweetId) 31 | 32 | let tweetViewModel = processTweet(liveTweet) 33 | 34 | tweetViewModel.html = renderTweet(tweetViewModel) 35 | 36 | // cache tweet 37 | if (options.cacheDirectory) { 38 | await addTweetToCache(tweetViewModel, options) 39 | } 40 | 41 | // build 42 | return formatTweet(tweetViewModel, options) 43 | } else { 44 | console.warn("Remeber to add your twitter credentials as environement variables") 45 | console.warn("Read More at https://github.com/KyleMit/eleventy-plugin-embed-tweet#setting-env-variables") 46 | // else continue on 47 | } 48 | 49 | // finally fallback to client-side injection 50 | var htmlTweet = 51 | `
` + 52 | `` 53 | 54 | return htmlTweet 55 | } 56 | 57 | /* Twitter API Call */ 58 | function hasAuth() { 59 | return process.env.TOKEN && 60 | process.env.TOKEN_SECRET && 61 | process.env.CONSUMER_KEY && 62 | process.env.CONSUMER_SECRET 63 | } 64 | 65 | function getAuth() { 66 | let oAuth = { 67 | token: process.env.TOKEN, 68 | token_secret: process.env.TOKEN_SECRET, 69 | consumer_key: process.env.CONSUMER_KEY, 70 | consumer_secret: process.env.CONSUMER_SECRET, 71 | } 72 | return oAuth; 73 | } 74 | 75 | async function fetchTweet(tweetId) { 76 | // fetch tweet 77 | let apiURI = `https://api.twitter.com/1.1/statuses/show/${tweetId}.json?tweet_mode=extended` 78 | let auth = getAuth() 79 | 80 | try { 81 | let response = await request.get(apiURI, { oauth: auth }); 82 | let tweet = JSON.parse(response) 83 | 84 | return tweet 85 | 86 | } catch (error) { 87 | // unhappy path - continue to other fallbacks 88 | console.log(error) 89 | return {} 90 | } 91 | } 92 | 93 | /* transform tweets */ 94 | function processTweet(tweet) { 95 | 96 | // parse complicated stuff 97 | let images = getTweetImages(tweet) 98 | let created_at = getTweetDates(tweet) 99 | let htmlText = getTweetTextHtml(tweet) 100 | 101 | // destructure only properties we care about 102 | let { id_str, favorite_count } = tweet 103 | let { name, screen_name, profile_image_url_https } = tweet.user 104 | let user = { name, screen_name, profile_image_url_https } 105 | 106 | // build tweet with properties we want 107 | let tweetViewModel = { 108 | id_str, 109 | htmlText, 110 | images, 111 | favorite_count, 112 | created_at, 113 | user 114 | } 115 | 116 | return tweetViewModel 117 | } 118 | 119 | function getTweetImages(tweet) { 120 | let images = [] 121 | for (media of tweet.entities.media || []) { 122 | images.push(media.media_url_https) 123 | } 124 | return images 125 | } 126 | 127 | function getTweetDates(tweet) { 128 | let moment = require("moment"); 129 | 130 | // parse 131 | let dateMoment = moment(tweet.created_at, "ddd MMM D hh:mm:ss Z YYYY"); 132 | 133 | // format 134 | let display = dateMoment.format("hh:mm A · MMM D, YYYY") 135 | let meta = dateMoment.utc().format("MMM D, YYYY hh:mm:ss (z)") 136 | 137 | return { display, meta } 138 | } 139 | 140 | function getTweetTextHtml(tweet) { 141 | let replacements = [] 142 | 143 | // hashtags 144 | for (hashtag of tweet.entities.hashtags || []) { 145 | let oldText = getOldText(tweet.full_text, hashtag.indices) 146 | let newText = `${oldText}` 147 | replacements.push({ oldText, newText }) 148 | } 149 | 150 | // users 151 | for (user of tweet.entities.user_mentions || []) { 152 | let oldText = getOldText(tweet.full_text, user.indices) 153 | let newText = `${oldText}` 154 | replacements.push({ oldText, newText }) 155 | } 156 | 157 | // urls 158 | for (url of tweet.entities.urls || []) { 159 | let oldText = getOldText(tweet.full_text, url.indices) 160 | let newText = `${url.expanded_url.replace(/https?:\/\//,"")}` 161 | replacements.push({ oldText, newText }) 162 | } 163 | 164 | // media 165 | for (media of tweet.entities.media || []) { 166 | let oldText = getOldText(tweet.full_text, media.indices) 167 | let newText = `` // get rid of img url in tweet text 168 | replacements.push({ oldText, newText }) 169 | } 170 | 171 | // make updates at the end 172 | let htmlText = tweet.full_text 173 | for (rep of replacements) { 174 | htmlText = htmlText.replace(rep.oldText, rep.newText) 175 | } 176 | 177 | // preserve line breaks to survive minification 178 | htmlText = htmlText.replace(/(?:\r\n|\r|\n)/g, '
'); 179 | 180 | return htmlText 181 | } 182 | 183 | function getOldText(text, indices) { 184 | let startPos = indices[0]; 185 | let endPos = indices[1]; 186 | let len = endPos - startPos 187 | 188 | let oldText = text.substr(startPos, len) 189 | 190 | return oldText 191 | } 192 | 193 | 194 | /* render tweets */ 195 | function renderTweet(tweet) { 196 | // get module directory 197 | let path = require("path") 198 | let moduleDir = path.parse(__filename).dir 199 | 200 | // configure nunjucks 201 | let nunjucks = require("nunjucks") 202 | nunjucks.configure(moduleDir, { autoescape: true }); 203 | 204 | // render with nunjucks 205 | let htmlTweet = nunjucks.render("tweet.njk", tweet); 206 | 207 | // minify before returning 208 | // important when injected into markdown to prevent injection of `

` tags due to whitespace 209 | let htmlMin = minifyHtml(htmlTweet) 210 | 211 | return htmlMin 212 | } 213 | 214 | async function formatTweet(tweet, options) { 215 | 216 | // add css if requested 217 | if (options.useInlineStyles) { 218 | let styles = await getStyles() 219 | let stylesHtml = `` 220 | let stylesMin = minifyHtml(stylesHtml) 221 | 222 | return stylesMin + tweet.html 223 | } 224 | 225 | return tweet.html 226 | } 227 | 228 | function minifyHtml(htmlSource) { 229 | var minify = require('html-minifier').minify; 230 | var result = minify(htmlSource, { 231 | minifyCSS: true, 232 | collapseWhitespace: true 233 | }); 234 | 235 | return result; 236 | } 237 | 238 | 239 | /* caching / file access */ 240 | async function getCachedTweets(options) { 241 | let cachePath = getCachedTweetPath(options) 242 | 243 | try { 244 | let file = await fs.readFile(cachePath, "utf8") 245 | cachedTweets = JSON.parse(file) || {} 246 | 247 | return cachedTweets 248 | 249 | } catch (error) { 250 | // otherwise, empty array is fine 251 | console.log(error) 252 | return {} 253 | } 254 | } 255 | 256 | async function addTweetToCache(tweet, options) { 257 | try { 258 | // get cache 259 | let cachedTweets = await getCachedTweets(options) 260 | 261 | // add new tweet 262 | cachedTweets[tweet.id_str] = tweet 263 | 264 | // build new cache string 265 | let tweetsJSON = JSON.stringify(cachedTweets, 2, 2) 266 | 267 | let cachePath = getCachedTweetPath(options) 268 | let cacheDir = require("path").dirname(cachePath) 269 | 270 | // makre sure directory exists 271 | await fs.mkdir(cacheDir, { recursive: true }) 272 | 273 | syncFs.writeFileSync(cachePath, tweetsJSON) 274 | 275 | console.log(`Writing ${cachePath}`) 276 | } catch (error) { 277 | console.log(error) 278 | } 279 | } 280 | 281 | function getCachedTweetPath(options) { 282 | let path = require("path") 283 | 284 | // get directory for main thread 285 | let appPath = require.main.filename // C:\user\github\app\node_modules\@11ty\eleventy\cmd.js 286 | let pos = appPath.indexOf("node_modules") 287 | let appRoot = appPath.substr(0, pos) // C:\user\github\app\ 288 | 289 | // build cache file path 290 | let cachePath = path.join(appRoot, options.cacheDirectory, "tweets.json") 291 | 292 | return cachePath 293 | } 294 | 295 | 296 | async function getStyles() { 297 | // get module directory 298 | let path = require("path") 299 | let moduleDir = path.parse(__filename).dir 300 | let stylePath = path.join(moduleDir, "/tweet.css") 301 | 302 | let styles = await fs.readFile(stylePath, "utf8") 303 | 304 | return styles 305 | } 306 | 307 | 308 | // Auto embed tweets 309 | const asyncReplace = require('string-replace-async') 310 | 311 | async function autoEmbedTweets(content, outputPath, options) { 312 | // https://regexr.com/6v8ih 313 | let findTweets = /

(\2<\/a>)?<\/p>/g 314 | return await asyncReplace(content, findTweets, async(match, p1, p2, p3) => { 315 | return await getTweet(p3, options) 316 | }) 317 | } 318 | --------------------------------------------------------------------------------