├── .fonts └── NotoSansCJKsc-Regular.otf ├── .gitignore ├── .prettierignore ├── LICENSE ├── README.md ├── cdk.context.json ├── layers └── chromium │ ├── .gitignore │ ├── package-lock.json │ └── package.json ├── package-lock.json ├── package.json ├── seed.yml ├── src ├── clear-cache.js └── lambda.js ├── sst.config.ts ├── stacks └── MyStack.ts └── templates ├── assets ├── css │ ├── blog.css │ ├── constructs.css │ ├── fonts.css │ ├── ion-lander.css │ ├── ion.css │ ├── lander.css │ ├── main.css │ ├── pages.css │ ├── post.css │ ├── reset.css │ ├── v3-lander.css │ └── v3.css ├── fonts │ ├── RobotoMono-VariableFont_wght.ttf │ ├── RobotoSlab-Bold.ttf │ ├── RobotoSlab-Regular.ttf │ ├── RobotoSlab-SemiBold.ttf │ ├── Rubik-VariableFont_wght.ttf │ ├── SourceSansPro-Bold.ttf │ ├── SourceSansPro-Regular.ttf │ └── SourceSansPro-SemiBold.ttf └── images │ ├── logo-ion.svg │ ├── logo-sst.svg │ ├── logo-white.svg │ ├── logo.svg │ ├── logomark-white.svg │ └── profiles │ ├── frank.png │ └── jay.png ├── ion-lander.html ├── ion.html ├── serverless-stack-blog.html ├── serverless-stack-constructs.html ├── serverless-stack-docs.html ├── serverless-stack-examples.html ├── serverless-stack-guide.html ├── serverless-stack-lander.html ├── serverless-stack-pages.html ├── v3-blog.html ├── v3-docs.html └── v3-lander.html /.fonts/NotoSansCJKsc-Regular.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sst/social-cards/7d0a1b39388fc314dc563b6caf6e963c56dc5767/.fonts/NotoSansCJKsc-Regular.otf -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | 15 | # sst build output 16 | .build 17 | .sst 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # CDK output 2 | .build 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Serverless Stack 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Serverless Social Cards [![Seed Status](https://api.seed.run/anomaly/social-cards/stages/main/build_badge)](https://console.seed.run/anomaly/social-cards) 2 | 3 | A serverless app created with [SST](https://github.com/sst/sst) that dynamically generates social share or Open Graph (OG) images. 4 | 5 | ## Getting Started 6 | 7 | Read the guide on how this service is configured and how you can create your own — [**Dynamically generate social share images with serverless**](https://guide.sst.dev/archives/dynamically-generate-social-share-images-with-serverless.html). 8 | 9 | You can also reference the [example on using the Chromium Lambda Layer](https://guide.sst.dev/examples/how-to-use-lambda-layers-in-your-serverless-app.html). 10 | 11 | ## Running Locally 12 | 13 | Start by installing the dependencies. 14 | 15 | ``` bash 16 | $ npm install 17 | ``` 18 | 19 | Install the [@sparticuz/chromium](https://github.com/Sparticuz/chromium) layer. 20 | 21 | ``` bash 22 | $ cd layers/chromium && npm install 23 | ``` 24 | 25 | Then start the Live Lambda Development environment. 26 | 27 | ``` bash 28 | $ npx sst start 29 | ``` 30 | 31 | The templates to generate the share images are stored in [`templates/`](https://github.com/sst/social-cards/tree/main/templates). And all the non-Latin fonts are placed in [`.fonts/`](https://github.com/sst/social-cards/tree/main/.fonts). 32 | 33 | ## Deploying to Prod 34 | 35 | Deploy your service to prod by running. 36 | 37 | ``` bash 38 | $ npx sst deploy --stage prod 39 | ``` 40 | 41 | To clear the cache of generated images, invoke the `clear-cache.handler` with the following payload. 42 | 43 | ```json 44 | {"path":"/"} 45 | ``` 46 | 47 | ## Documentation 48 | 49 | [Learn more about the SST](https://sst.dev). 50 | -------------------------------------------------------------------------------- /cdk.context.json: -------------------------------------------------------------------------------- 1 | { 2 | "hosted-zone:account=232771856781:domainName=serverless-stack.com:region=us-east-1": { 3 | "Id": "/hostedzone/Z3SQCQPO909165", 4 | "Name": "serverless-stack.com." 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /layers/chromium/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | -------------------------------------------------------------------------------- /layers/chromium/package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ChromiumLayer", 3 | "version": "1.0.0", 4 | "lockfileVersion": 3, 5 | "requires": true, 6 | "packages": { 7 | "": { 8 | "name": "ChromiumLayer", 9 | "version": "1.0.0", 10 | "dependencies": { 11 | "@sparticuz/chromium": "113.0.1" 12 | } 13 | }, 14 | "node_modules/@sparticuz/chromium": { 15 | "version": "113.0.1", 16 | "resolved": "https://registry.npmjs.org/@sparticuz/chromium/-/chromium-113.0.1.tgz", 17 | "integrity": "sha512-m64Nc58YObfCYkPbc8EBsnhWCFaw02dbXhWNOmx+OYoIPtIRar+reGYyAeGC0HD9RUZfPEwMHHSZ8MFxdSWXZQ==", 18 | "dependencies": { 19 | "follow-redirects": "^1.15.2", 20 | "tar-fs": "^2.1.1" 21 | }, 22 | "engines": { 23 | "node": ">= 16" 24 | } 25 | }, 26 | "node_modules/base64-js": { 27 | "version": "1.5.1", 28 | "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", 29 | "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", 30 | "funding": [ 31 | { 32 | "type": "github", 33 | "url": "https://github.com/sponsors/feross" 34 | }, 35 | { 36 | "type": "patreon", 37 | "url": "https://www.patreon.com/feross" 38 | }, 39 | { 40 | "type": "consulting", 41 | "url": "https://feross.org/support" 42 | } 43 | ] 44 | }, 45 | "node_modules/bl": { 46 | "version": "4.1.0", 47 | "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", 48 | "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", 49 | "dependencies": { 50 | "buffer": "^5.5.0", 51 | "inherits": "^2.0.4", 52 | "readable-stream": "^3.4.0" 53 | } 54 | }, 55 | "node_modules/buffer": { 56 | "version": "5.7.1", 57 | "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", 58 | "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", 59 | "funding": [ 60 | { 61 | "type": "github", 62 | "url": "https://github.com/sponsors/feross" 63 | }, 64 | { 65 | "type": "patreon", 66 | "url": "https://www.patreon.com/feross" 67 | }, 68 | { 69 | "type": "consulting", 70 | "url": "https://feross.org/support" 71 | } 72 | ], 73 | "dependencies": { 74 | "base64-js": "^1.3.1", 75 | "ieee754": "^1.1.13" 76 | } 77 | }, 78 | "node_modules/chownr": { 79 | "version": "1.1.4", 80 | "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", 81 | "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" 82 | }, 83 | "node_modules/end-of-stream": { 84 | "version": "1.4.4", 85 | "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", 86 | "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", 87 | "dependencies": { 88 | "once": "^1.4.0" 89 | } 90 | }, 91 | "node_modules/follow-redirects": { 92 | "version": "1.15.5", 93 | "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.5.tgz", 94 | "integrity": "sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==", 95 | "funding": [ 96 | { 97 | "type": "individual", 98 | "url": "https://github.com/sponsors/RubenVerborgh" 99 | } 100 | ], 101 | "engines": { 102 | "node": ">=4.0" 103 | }, 104 | "peerDependenciesMeta": { 105 | "debug": { 106 | "optional": true 107 | } 108 | } 109 | }, 110 | "node_modules/fs-constants": { 111 | "version": "1.0.0", 112 | "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", 113 | "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" 114 | }, 115 | "node_modules/ieee754": { 116 | "version": "1.2.1", 117 | "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", 118 | "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", 119 | "funding": [ 120 | { 121 | "type": "github", 122 | "url": "https://github.com/sponsors/feross" 123 | }, 124 | { 125 | "type": "patreon", 126 | "url": "https://www.patreon.com/feross" 127 | }, 128 | { 129 | "type": "consulting", 130 | "url": "https://feross.org/support" 131 | } 132 | ] 133 | }, 134 | "node_modules/inherits": { 135 | "version": "2.0.4", 136 | "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", 137 | "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" 138 | }, 139 | "node_modules/mkdirp-classic": { 140 | "version": "0.5.3", 141 | "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", 142 | "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" 143 | }, 144 | "node_modules/once": { 145 | "version": "1.4.0", 146 | "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", 147 | "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", 148 | "dependencies": { 149 | "wrappy": "1" 150 | } 151 | }, 152 | "node_modules/pump": { 153 | "version": "3.0.0", 154 | "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", 155 | "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", 156 | "dependencies": { 157 | "end-of-stream": "^1.1.0", 158 | "once": "^1.3.1" 159 | } 160 | }, 161 | "node_modules/readable-stream": { 162 | "version": "3.6.2", 163 | "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", 164 | "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", 165 | "dependencies": { 166 | "inherits": "^2.0.3", 167 | "string_decoder": "^1.1.1", 168 | "util-deprecate": "^1.0.1" 169 | }, 170 | "engines": { 171 | "node": ">= 6" 172 | } 173 | }, 174 | "node_modules/safe-buffer": { 175 | "version": "5.2.1", 176 | "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", 177 | "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", 178 | "funding": [ 179 | { 180 | "type": "github", 181 | "url": "https://github.com/sponsors/feross" 182 | }, 183 | { 184 | "type": "patreon", 185 | "url": "https://www.patreon.com/feross" 186 | }, 187 | { 188 | "type": "consulting", 189 | "url": "https://feross.org/support" 190 | } 191 | ] 192 | }, 193 | "node_modules/string_decoder": { 194 | "version": "1.3.0", 195 | "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", 196 | "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", 197 | "dependencies": { 198 | "safe-buffer": "~5.2.0" 199 | } 200 | }, 201 | "node_modules/tar-fs": { 202 | "version": "2.1.1", 203 | "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", 204 | "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", 205 | "dependencies": { 206 | "chownr": "^1.1.1", 207 | "mkdirp-classic": "^0.5.2", 208 | "pump": "^3.0.0", 209 | "tar-stream": "^2.1.4" 210 | } 211 | }, 212 | "node_modules/tar-stream": { 213 | "version": "2.2.0", 214 | "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", 215 | "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", 216 | "dependencies": { 217 | "bl": "^4.0.3", 218 | "end-of-stream": "^1.4.1", 219 | "fs-constants": "^1.0.0", 220 | "inherits": "^2.0.3", 221 | "readable-stream": "^3.1.1" 222 | }, 223 | "engines": { 224 | "node": ">=6" 225 | } 226 | }, 227 | "node_modules/util-deprecate": { 228 | "version": "1.0.2", 229 | "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", 230 | "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" 231 | }, 232 | "node_modules/wrappy": { 233 | "version": "1.0.2", 234 | "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", 235 | "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" 236 | } 237 | } 238 | } 239 | -------------------------------------------------------------------------------- /layers/chromium/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ChromiumLayer", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "Chromium layer for AWS Lambda", 6 | "dependencies": { 7 | "@sparticuz/chromium": "113.0.1" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "social-cards", 3 | "version": "0.1.0", 4 | "scripts": { 5 | "test": "sst test", 6 | "start": "sst start", 7 | "build": "sst build", 8 | "deploy": "sst deploy", 9 | "remove": "sst remove", 10 | "prettier": "prettier --write **/*.{js,ts,json,md}" 11 | }, 12 | "license": "MIT", 13 | "author": { 14 | "name": "SST", 15 | "url": "https://social-cards.sst.dev" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/sst/social-cards.git" 20 | }, 21 | "devDependencies": { 22 | "lint-staged": "^10.5.3", 23 | "prettier": "^2.3.1" 24 | }, 25 | "dependencies": { 26 | "aws-cdk-lib": "2.124.0", 27 | "aws-sdk": "^2.932.0", 28 | "puppeteer-core": "20.1.2", 29 | "@sparticuz/chromium": "113.0.1", 30 | "sst": "2.40.1" 31 | }, 32 | "husky": { 33 | "hooks": { 34 | "pre-commit": "lint-staged" 35 | } 36 | }, 37 | "lint-staged": { 38 | "*.{js,ts,css,json,md}": [ 39 | "prettier --write" 40 | ] 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /seed.yml: -------------------------------------------------------------------------------- 1 | before_compile: 2 | - cd layers/chromium && npm install 3 | -------------------------------------------------------------------------------- /src/clear-cache.js: -------------------------------------------------------------------------------- 1 | import { S3, CloudFront } from "aws-sdk"; 2 | import { Bucket } from "sst/node/bucket"; 3 | import { Config } from "sst/node/config"; 4 | 5 | const cloudfront = new CloudFront(); 6 | const s3 = new S3({ apiVersion: "2006-03-01" }); 7 | 8 | export async function handler(event) { 9 | const path = event.path; // Ensure this ends with a slash 10 | 11 | await emptyS3Folder(path); 12 | await invalidateEntireDistribution(Config.distributionId); 13 | } 14 | 15 | async function emptyS3Folder(path) { 16 | try { 17 | let continuationToken; 18 | do { 19 | const listResponse = await listAllObjects(path, continuationToken); 20 | continuationToken = listResponse.IsTruncated 21 | ? listResponse.NextContinuationToken 22 | : null; 23 | 24 | if (listResponse.Contents.length > 0) { 25 | await deleteObjects(listResponse.Contents); 26 | } 27 | } while (continuationToken); 28 | 29 | console.log("All objects deleted successfully."); 30 | } catch (error) { 31 | console.error("Error deleting objects:", error); 32 | } 33 | } 34 | 35 | function listAllObjects(path, token) { 36 | const params = { 37 | Prefix: path, 38 | ContinuationToken: token, 39 | Bucket: Bucket.WebsiteBucket.bucketName, 40 | }; 41 | 42 | return new Promise((resolve, reject) => { 43 | s3.listObjectsV2(params, function (err, data) { 44 | if (err) { 45 | reject(err); 46 | } else { 47 | resolve(data); 48 | } 49 | }); 50 | }); 51 | } 52 | 53 | function deleteObjects(objects) { 54 | const deleteParams = { 55 | Bucket: Bucket.WebsiteBucket.bucketName, 56 | Delete: { 57 | Objects: objects.map((obj) => ({ Key: obj.Key })), 58 | Quiet: true, 59 | }, 60 | }; 61 | 62 | return new Promise((resolve, reject) => { 63 | s3.deleteObjects(deleteParams, function (err, data) { 64 | if (err) { 65 | reject(err); 66 | } else { 67 | resolve(data); 68 | } 69 | }); 70 | }); 71 | } 72 | 73 | async function invalidateEntireDistribution(distributionId) { 74 | const params = { 75 | DistributionId: distributionId, 76 | InvalidationBatch: { 77 | CallerReference: `invalidate-entire-distribution-${Date.now()}`, 78 | Paths: { 79 | Quantity: 1, 80 | Items: [ 81 | "/*", // This specifies that everything in the distribution should be invalidated 82 | ], 83 | }, 84 | }, 85 | }; 86 | 87 | try { 88 | const data = await cloudfront.createInvalidation(params).promise(); 89 | console.log(data); 90 | } catch (err) { 91 | console.error("Error invalidating distribution", err); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/lambda.js: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { S3 } from "aws-sdk"; 3 | import puppeteer from "puppeteer-core"; 4 | import { Bucket } from "sst/node/bucket"; 5 | import chromium from "@sparticuz/chromium"; 6 | 7 | const ext = "png"; 8 | const ContentType = `image/${ext}`; 9 | const s3 = new S3({ apiVersion: "2006-03-01" }); 10 | 11 | // This is the path to the local Chromium binary 12 | const YOUR_LOCAL_CHROMIUM_PATH = 13 | "/tmp/localChromium/chromium/mac-1165945/chrome-mac/Chromium.app/Contents/MacOS/Chromium"; 14 | 15 | export async function handler(event) { 16 | const { file, template } = event.pathParameters; 17 | 18 | const title = parseTitle(file); 19 | 20 | // Check if it's a valid request 21 | if (file === null) { 22 | return createErrorResponse(); 23 | } 24 | 25 | const options = event.rawQueryString; 26 | const key = generateS3Key(template, title, options); 27 | 28 | // Check the S3 bucket 29 | const fromBucket = await get(key); 30 | 31 | // Return from the bucket 32 | if (fromBucket) { 33 | return createResponse(fromBucket); 34 | } 35 | 36 | const browser = await puppeteer.launch({ 37 | args: chromium.args, 38 | defaultViewport: chromium.defaultViewport, 39 | executablePath: process.env.IS_LOCAL 40 | ? YOUR_LOCAL_CHROMIUM_PATH 41 | : await chromium.executablePath(), 42 | headless: chromium.headless, 43 | }); 44 | 45 | const page = await browser.newPage(); 46 | 47 | await page.setViewport({ 48 | width: 1200, 49 | height: 630, 50 | }); 51 | 52 | // Navigate to the url 53 | await page.goto( 54 | `file:${path.join( 55 | process.cwd(), 56 | `templates/${template}.html` 57 | )}?title=${title}&${options}` 58 | ); 59 | 60 | // Wait for page to complete loading 61 | await page.evaluate("document.fonts.ready"); 62 | 63 | // Take screenshot 64 | const buffer = await page.screenshot(); 65 | 66 | // Upload to the bucket 67 | await upload(key, buffer); 68 | 69 | return createResponse(buffer); 70 | } 71 | 72 | /** 73 | * Parse a base64 url encoded string of the format 74 | * 75 | * $title.png 76 | * 77 | */ 78 | function parseTitle(file) { 79 | const extension = `.${ext}`; 80 | 81 | if (!file.endsWith(extension)) { 82 | return null; 83 | } 84 | 85 | // Remove the .png extension 86 | const encodedTitle = file.slice(0, -1 * extension.length); 87 | 88 | const buffer = Buffer.from(encodedTitle, "base64"); 89 | 90 | return decodeURIComponent(buffer.toString("ascii")); 91 | } 92 | 93 | /** 94 | * Generate a S3 safe key using the path parameters and query string options 95 | */ 96 | function generateS3Key(template, title, options) { 97 | const parts = [ 98 | template, 99 | ...(options !== "" ? [encodeURIComponent(options)] : []), 100 | `${encodeURIComponent(title)}.${ext}`, 101 | ]; 102 | 103 | return parts.join("/"); 104 | } 105 | 106 | async function upload(Key, Body) { 107 | const params = { 108 | Key, 109 | Body, 110 | ContentType, 111 | Bucket: Bucket.WebsiteBucket.bucketName, 112 | }; 113 | 114 | await s3.putObject(params).promise(); 115 | } 116 | 117 | async function get(Key) { 118 | // Disabling S3 lookup on local 119 | if (process.env.IS_LOCAL) { 120 | return null; 121 | } 122 | 123 | const params = { Key, Bucket: Bucket.WebsiteBucket.bucketName }; 124 | 125 | try { 126 | const { Body } = await s3.getObject(params).promise(); 127 | return Body; 128 | } catch (e) { 129 | return null; 130 | } 131 | } 132 | 133 | function createResponse(buffer) { 134 | return { 135 | statusCode: 200, 136 | // Return as binary data 137 | isBase64Encoded: true, 138 | body: buffer.toString("base64"), 139 | headers: { "Content-Type": ContentType }, 140 | }; 141 | } 142 | 143 | function createErrorResponse() { 144 | return { 145 | statusCode: 500, 146 | body: "Invalid request", 147 | }; 148 | } 149 | -------------------------------------------------------------------------------- /sst.config.ts: -------------------------------------------------------------------------------- 1 | import type { SSTConfig } from "sst"; 2 | import { MyStack } from "./stacks/MyStack"; 3 | 4 | export default { 5 | config(_input) { 6 | return { 7 | name: "social-cards", 8 | region: "us-east-1", 9 | }; 10 | }, 11 | stacks(app) { 12 | app.setDefaultRemovalPolicy("destroy"); 13 | 14 | app.stack(MyStack, {id: "my-stack"}); 15 | }, 16 | } satisfies SSTConfig; 17 | -------------------------------------------------------------------------------- /stacks/MyStack.ts: -------------------------------------------------------------------------------- 1 | import { Fn, Duration } from "aws-cdk-lib"; 2 | import * as lambda from "aws-cdk-lib/aws-lambda"; 3 | import { Api, Bucket, Config, Function, StackContext } from "sst/constructs"; 4 | import { DnsValidatedCertificate } from "aws-cdk-lib/aws-certificatemanager"; 5 | import * as cf from "aws-cdk-lib/aws-cloudfront"; 6 | import * as route53 from "aws-cdk-lib/aws-route53"; 7 | import { HttpOrigin } from "aws-cdk-lib/aws-cloudfront-origins"; 8 | import { CloudFrontTarget } from "aws-cdk-lib/aws-route53-targets"; 9 | 10 | const rootDomain = "sst.dev"; 11 | const domainName = `social-cards.${rootDomain}`; 12 | const layerArn = 13 | "arn:aws:lambda:us-east-1:764866452798:layer:chrome-aws-lambda:22"; 14 | 15 | export function MyStack({ stack, app }: StackContext) { 16 | let hostedZone; 17 | let domainProps = {}; 18 | 19 | const useCustomDomain = app.stage === "prod" || app.stage === "main"; 20 | 21 | const layerChromium = new lambda.LayerVersion(stack, "chromiumLayers", { 22 | code: lambda.Code.fromAsset("layers/chromium"), 23 | }); 24 | 25 | // Create S3 bucket to store generated images 26 | const bucket = new Bucket(stack, "WebsiteBucket"); 27 | 28 | // Create a HTTP API 29 | const api = new Api(stack, "Api", { 30 | routes: { 31 | "GET /{template}/{file}": { 32 | function: { 33 | handler: "src/lambda.handler", 34 | // Increase the timeout for generating screenshots 35 | timeout: "15 minutes", 36 | // Increase the memory 37 | memorySize: "2 GB", 38 | // Load Chrome in a Layer 39 | layers: [layerChromium], 40 | // Copy over templates and non Latin fonts 41 | copyFiles: [ 42 | { 43 | from: "templates", 44 | to: "templates", 45 | }, 46 | { 47 | from: ".fonts", 48 | to: ".fonts", 49 | }, 50 | ], 51 | nodejs: { 52 | esbuild: { 53 | // Exclude bundling it in the Lambda function 54 | external: ["@sparticuz/chromium"], 55 | }, 56 | }, 57 | }, 58 | }, 59 | }, 60 | }); 61 | 62 | api.bind([bucket]); 63 | 64 | if (useCustomDomain) { 65 | // Lookup domain hosted zone 66 | hostedZone = route53.HostedZone.fromLookup(stack, "HostedZone", { 67 | domainName: rootDomain, 68 | }); 69 | 70 | // Create ACM certificate 71 | const certificate = new DnsValidatedCertificate(stack, "Certificate", { 72 | domainName, 73 | hostedZone, 74 | region: "us-east-1", 75 | }); 76 | 77 | domainProps = { 78 | ...domainProps, 79 | certificate, 80 | domainNames: [domainName], 81 | }; 82 | } 83 | 84 | // Create CloudFront Distribution 85 | const distribution = new cf.Distribution(stack, "WebsiteCdn", { 86 | ...domainProps, 87 | defaultBehavior: { 88 | origin: new HttpOrigin(Fn.parseDomainName(api.url)), 89 | viewerProtocolPolicy: cf.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, 90 | cachePolicy: new cf.CachePolicy(stack, "WebsiteCachePolicy", { 91 | // Set cache duration to 1 year 92 | minTtl: Duration.seconds(31536000), 93 | // Forward the query string to the origin 94 | queryStringBehavior: cf.CacheQueryStringBehavior.all(), 95 | }), 96 | }, 97 | }); 98 | 99 | if (useCustomDomain) { 100 | // Create DNS record 101 | new route53.ARecord(stack, "AliasRecord", { 102 | zone: hostedZone, 103 | recordName: domainName, 104 | target: route53.RecordTarget.fromAlias( 105 | new CloudFrontTarget(distribution) 106 | ), 107 | }); 108 | } 109 | 110 | // Create Function to clear the cache 111 | const clearFunction = new Function(stack, "ClearCache", { 112 | handler: "src/clear-cache.handler", 113 | permissions: ["cloudfront:CreateInvalidation"], 114 | }); 115 | 116 | clearFunction.bind([ 117 | bucket, 118 | new Config.Parameter(stack, "distributionId", { 119 | value: distribution.distributionId, 120 | }), 121 | ]); 122 | 123 | // Show the endpoint in the output 124 | stack.addOutputs({ 125 | ApiEndpoint: api.url, 126 | BucketName: bucket.bucketName, 127 | SiteEndpoint: `https://${ 128 | useCustomDomain ? domainName : distribution.distributionDomainName 129 | }`, 130 | }); 131 | } 132 | -------------------------------------------------------------------------------- /templates/assets/css/blog.css: -------------------------------------------------------------------------------- 1 | .spacer { 2 | margin-top: 70px; 3 | height: 230px; 4 | display: flex; 5 | justify-content: center; 6 | align-items: flex-start; 7 | flex-direction: column; 8 | } 9 | h1 { 10 | font-size: 48px; 11 | line-height: 1.4; 12 | font-weight: 600; 13 | overflow: hidden; 14 | text-overflow: ellipsis; 15 | display: -webkit-box; 16 | -webkit-line-clamp: 2; 17 | -webkit-box-orient: vertical; 18 | } 19 | div.profile { 20 | margin-top: 30px; 21 | } 22 | img#avatar { 23 | margin-right: 10px; 24 | border-radius: 100%; 25 | border: 4px solid var(--text-color); 26 | } 27 | span#author { 28 | font-family: "Source Sans Pro"; 29 | font-size: 40px; 30 | line-height: 64px; 31 | vertical-align: top; 32 | } 33 | -------------------------------------------------------------------------------- /templates/assets/css/constructs.css: -------------------------------------------------------------------------------- 1 | .spacer { 2 | margin-top: 70px; 3 | height: 230px; 4 | display: flex; 5 | align-items: center; 6 | } 7 | h1 { 8 | font-size: 84px; 9 | line-height: 1.1; 10 | font-weight: 600; 11 | } 12 | 13 | -------------------------------------------------------------------------------- /templates/assets/css/fonts.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "Roboto Slab"; 3 | src: url("../fonts/RobotoSlab-Regular.ttf") format('truetype'); 4 | font-weight: 400; 5 | font-style: normal; 6 | } 7 | @font-face { 8 | font-family: "Roboto Slab"; 9 | src: url("../fonts/RobotoSlab-SemiBold.ttf") format('truetype'); 10 | font-weight: 600; 11 | font-style: normal; 12 | } 13 | @font-face { 14 | font-family: "Roboto Slab"; 15 | src: url("../fonts/RobotoSlab-Bold.ttf") format('truetype'); 16 | font-weight: 700; 17 | font-style: normal; 18 | } 19 | @font-face { 20 | font-family: "Source Sans Pro"; 21 | src: url("../fonts/SourceSansPro-Regular.ttf") format('truetype'); 22 | font-weight: 400; 23 | font-style: normal; 24 | } 25 | @font-face { 26 | font-family: "Source Sans Pro"; 27 | src: url("../fonts/SourceSansPro-SemiBold.ttf") format('truetype'); 28 | font-weight: 600; 29 | font-style: normal; 30 | } 31 | @font-face { 32 | font-family: "Source Sans Pro"; 33 | src: url("../fonts/SourceSansPro-Bold.ttf") format('truetype'); 34 | font-weight: 700; 35 | font-style: normal; 36 | } 37 | @font-face { 38 | font-family: "Roboto Mono"; 39 | src: url("../fonts/RobotoMono-VariableFont_wght.ttf") format('truetype'); 40 | font-style: normal; 41 | } 42 | @font-face { 43 | font-family: "Rubik"; 44 | src: url("../fonts/Rubik-VariableFont_wght.ttf") format('truetype'); 45 | font-style: normal; 46 | } 47 | -------------------------------------------------------------------------------- /templates/assets/css/ion-lander.css: -------------------------------------------------------------------------------- 1 | .logo { 2 | display: flex; 3 | align-items: center; 4 | gap: 23px; 5 | } 6 | .logo span { 7 | font-size: 48px; 8 | line-height: 48px; 9 | opacity: 0.3; 10 | } 11 | .logo img:last-child { 12 | margin-top: -4px; 13 | opacity: 0.4; 14 | } 15 | h1 { 16 | margin-bottom: 150px; 17 | } 18 | -------------------------------------------------------------------------------- /templates/assets/css/ion.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: 3 | radial-gradient(49% 81% at 45% 47%, #52529E45 0%, #073AFF00 100%), 4 | radial-gradient(113% 91% at 17% -2%, #150E35FF 1%, #FF000000 99%), 5 | radial-gradient(142% 91% at 83% 7%, #322212FF 1%, #FF000000 99%), 6 | radial-gradient(142% 91% at -6% 74%, #4A311AFF 1%, #FF000000 99%), 7 | radial-gradient(142% 91% at 111% 84%, #363670FF 0%, #0F0F20FF 100%); 8 | } 9 | h1, h2, h3, h4, h5, h6 { 10 | font-weight: 500; 11 | letter-spacing: -1px; 12 | font-family: "Roboto Mono", serif; 13 | } 14 | span.section { 15 | font-family: "Rubik"; 16 | font-size: 38px; 17 | line-height: 48px; 18 | } 19 | span.section:before { 20 | margin-right: 23px; 21 | opacity: 0.3; 22 | } 23 | a { 24 | font-family: "Rubik"; 25 | font-weight: 500; 26 | font-size: 28px; 27 | border: 3px solid var(--text-color); 28 | background: transparent; 29 | opacity: 0.75; 30 | } 31 | -------------------------------------------------------------------------------- /templates/assets/css/lander.css: -------------------------------------------------------------------------------- 1 | h1 { 2 | margin-top: 100px; 3 | font-size: 60px; 4 | line-height: 1.3; 5 | font-weight: 600; 6 | color: var(--text-color); 7 | } 8 | -------------------------------------------------------------------------------- /templates/assets/css/main.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --text-color: #FFFBF9; 3 | 4 | --accent-color: #E27152; 5 | --brand-color: #395C6B; 6 | } 7 | 8 | body { 9 | background: 10 | radial-gradient(49% 81% at 45% 47%, #FFE20312 0%, #073AFF00 100%), 11 | radial-gradient(113% 91% at 17% -2%, #FF5A00FF 1%, #FF000000 99%), 12 | radial-gradient(142% 91% at 83% 7%, #E49079FF 1%, #FF000000 99%), 13 | radial-gradient(142% 91% at -6% 74%, #DA532EFF 1%, #FF000000 99%), 14 | radial-gradient(142% 91% at 111% 84%, #E27152FF 0%, #F33A07FF 100%); 15 | color: var(--text-color); 16 | overflow: hidden; 17 | position: relative; 18 | padding: 60px; 19 | } 20 | h1, h2, h3, h4, h5, h6 { 21 | font-family: "Roboto Slab", serif; 22 | font-weight: 400; 23 | } 24 | 25 | img.mark { 26 | position: absolute; 27 | opacity: 0.2; 28 | top: -94px; 29 | right: -109px; 30 | } 31 | 32 | span.section:before { 33 | margin-right: 26px; 34 | content: "/"; 35 | vertical-align: top; 36 | opacity: 0.5; 37 | } 38 | span.section { 39 | margin-left: 20px; 40 | font-family: "Source Sans Pro"; 41 | font-size: 48px; 42 | line-height: 49px; 43 | vertical-align: top; 44 | } 45 | 46 | a { 47 | margin-top: 60px; 48 | padding: 19px 36px; 49 | text-transform: uppercase; 50 | font-family: "Source Sans Pro"; 51 | display: inline-block; 52 | font-size: 32px; 53 | font-weight: 700; 54 | letter-spacing: 1px; 55 | border-radius: 16px; 56 | line-height: 1.4; 57 | color: var(--text-color); 58 | border: 3px solid var(--text-color); 59 | background: transparent; 60 | opacity: 0.85; 61 | } 62 | -------------------------------------------------------------------------------- /templates/assets/css/pages.css: -------------------------------------------------------------------------------- 1 | .spacer { 2 | margin-top: 70px; 3 | height: 230px; 4 | display: flex; 5 | align-items: center; 6 | } 7 | h1 { 8 | font-size: 54px; 9 | line-height: 1.4; 10 | font-weight: 600; 11 | overflow: hidden; 12 | text-overflow: ellipsis; 13 | display: -webkit-box; 14 | -webkit-line-clamp: 2; 15 | -webkit-box-orient: vertical; 16 | } 17 | -------------------------------------------------------------------------------- /templates/assets/css/post.css: -------------------------------------------------------------------------------- 1 | .spacer { 2 | margin-top: 70px; 3 | height: 230px; 4 | display: flex; 5 | align-items: center; 6 | } 7 | h1 { 8 | font-size: 54px; 9 | line-height: 1.4; 10 | font-weight: 600; 11 | overflow: hidden; 12 | text-overflow: ellipsis; 13 | display: -webkit-box; 14 | -webkit-line-clamp: 2; 15 | -webkit-box-orient: vertical; 16 | } 17 | -------------------------------------------------------------------------------- /templates/assets/css/reset.css: -------------------------------------------------------------------------------- 1 | /* Box sizing rules */ 2 | *, 3 | *::before, 4 | *::after { 5 | box-sizing: border-box; 6 | } 7 | 8 | /* Remove default padding */ 9 | ul[class], 10 | ol[class] { 11 | padding: 0; 12 | } 13 | 14 | /* Remove default margin */ 15 | body, 16 | h1, 17 | h2, 18 | h3, 19 | h4, 20 | p, 21 | ul[class], 22 | ol[class], 23 | li, 24 | figure, 25 | figcaption, 26 | blockquote, 27 | dl, 28 | dd { 29 | margin: 0; 30 | } 31 | 32 | /* Set core body defaults */ 33 | body { 34 | min-height: 100vh; 35 | scroll-behavior: smooth; 36 | text-rendering: optimizeSpeed; 37 | line-height: 1.5; 38 | } 39 | 40 | /* Remove list styles on ul, ol elements with a class attribute */ 41 | ul[class], 42 | ol[class] { 43 | list-style: none; 44 | } 45 | 46 | /* A elements that don't have a class get default styles */ 47 | a:not([class]) { 48 | text-decoration-skip-ink: auto; 49 | } 50 | 51 | /* Natural flow and rhythm in articles by default */ 52 | article > * + * { 53 | margin-top: 1em; 54 | } 55 | 56 | /* Inherit fonts for inputs and buttons */ 57 | input, 58 | button, 59 | textarea, 60 | select { 61 | font: inherit; 62 | } 63 | -------------------------------------------------------------------------------- /templates/assets/css/v3-lander.css: -------------------------------------------------------------------------------- 1 | img.mark { 2 | opacity: 0.1; 3 | } 4 | .logo { 5 | } 6 | h1 { 7 | margin-top: 150px; 8 | margin-bottom: 75px; 9 | } 10 | -------------------------------------------------------------------------------- /templates/assets/css/v3.css: -------------------------------------------------------------------------------- 1 | body { 2 | background: 3 | radial-gradient(49% 81% at 45% 47%, #52529E45 0%, #073AFF00 100%), 4 | radial-gradient(113% 91% at 17% -2%, #150E35FF 1%, #FF000000 99%), 5 | radial-gradient(142% 91% at 83% 7%, #322212FF 1%, #FF000000 99%), 6 | radial-gradient(142% 91% at -6% 74%, #4A311AFF 1%, #FF000000 99%), 7 | radial-gradient(142% 91% at 111% 84%, #363670FF 0%, #0F0F20FF 100%); 8 | } 9 | h1, h2, h3, h4, h5, h6 { 10 | font-weight: 500; 11 | letter-spacing: -1px; 12 | font-family: "Roboto Mono", serif; 13 | } 14 | span.section { 15 | font-family: "Rubik"; 16 | font-size: 38px; 17 | line-height: 38px; 18 | } 19 | span.section:before { 20 | margin-right: 23px; 21 | opacity: 0.3; 22 | } 23 | a { 24 | font-family: "Rubik"; 25 | font-weight: 500; 26 | font-size: 28px; 27 | border: 3px solid var(--text-color); 28 | background: transparent; 29 | opacity: 0.75; 30 | } 31 | -------------------------------------------------------------------------------- /templates/assets/fonts/RobotoMono-VariableFont_wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sst/social-cards/7d0a1b39388fc314dc563b6caf6e963c56dc5767/templates/assets/fonts/RobotoMono-VariableFont_wght.ttf -------------------------------------------------------------------------------- /templates/assets/fonts/RobotoSlab-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sst/social-cards/7d0a1b39388fc314dc563b6caf6e963c56dc5767/templates/assets/fonts/RobotoSlab-Bold.ttf -------------------------------------------------------------------------------- /templates/assets/fonts/RobotoSlab-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sst/social-cards/7d0a1b39388fc314dc563b6caf6e963c56dc5767/templates/assets/fonts/RobotoSlab-Regular.ttf -------------------------------------------------------------------------------- /templates/assets/fonts/RobotoSlab-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sst/social-cards/7d0a1b39388fc314dc563b6caf6e963c56dc5767/templates/assets/fonts/RobotoSlab-SemiBold.ttf -------------------------------------------------------------------------------- /templates/assets/fonts/Rubik-VariableFont_wght.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sst/social-cards/7d0a1b39388fc314dc563b6caf6e963c56dc5767/templates/assets/fonts/Rubik-VariableFont_wght.ttf -------------------------------------------------------------------------------- /templates/assets/fonts/SourceSansPro-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sst/social-cards/7d0a1b39388fc314dc563b6caf6e963c56dc5767/templates/assets/fonts/SourceSansPro-Bold.ttf -------------------------------------------------------------------------------- /templates/assets/fonts/SourceSansPro-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sst/social-cards/7d0a1b39388fc314dc563b6caf6e963c56dc5767/templates/assets/fonts/SourceSansPro-Regular.ttf -------------------------------------------------------------------------------- /templates/assets/fonts/SourceSansPro-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sst/social-cards/7d0a1b39388fc314dc563b6caf6e963c56dc5767/templates/assets/fonts/SourceSansPro-SemiBold.ttf -------------------------------------------------------------------------------- /templates/assets/images/logo-ion.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /templates/assets/images/logo-sst.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /templates/assets/images/logo-white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /templates/assets/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /templates/assets/images/logomark-white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /templates/assets/images/profiles/frank.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sst/social-cards/7d0a1b39388fc314dc563b6caf6e963c56dc5767/templates/assets/images/profiles/frank.png -------------------------------------------------------------------------------- /templates/assets/images/profiles/jay.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sst/social-cards/7d0a1b39388fc314dc563b6caf6e963c56dc5767/templates/assets/images/profiles/jay.png -------------------------------------------------------------------------------- /templates/ion-lander.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 17 |

18 | Get Started 19 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /templates/ion.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Docs 12 |
13 |

14 |
15 | Learn More 16 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /templates/serverless-stack-blog.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Blog 11 |
12 |

13 |
14 | 15 | 16 |
17 |
18 | Read Post 19 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /templates/serverless-stack-constructs.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Constructs 11 |
12 |

13 |
14 | Read the Doc 15 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /templates/serverless-stack-docs.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Docs 11 |
12 |

13 |
14 | Read the Doc 15 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /templates/serverless-stack-examples.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Examples 11 |
12 |

13 |
14 | View Example 15 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /templates/serverless-stack-guide.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Guide 11 |
12 |

13 |
14 | Learn More 15 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /templates/serverless-stack-lander.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |

12 | Get Started 13 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /templates/serverless-stack-pages.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 |

13 |
14 | Learn More 15 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /templates/v3-blog.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Blog 12 |
13 |

14 |
15 | Read Post 16 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /templates/v3-docs.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Docs 12 |
13 |

14 |
15 | Learn More 16 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /templates/v3-lander.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 15 |

16 | Learn more 17 | 23 | 24 | 25 | 26 | 27 | --------------------------------------------------------------------------------