├── index.js
├── .babelrc
├── .gitignore
├── .npmignore
├── package.json
├── README.md
└── src
└── gatsby-ssr.js
/index.js:
--------------------------------------------------------------------------------
1 | // noop
2 |
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["babel-preset-gatsby-package"]
3 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /*.js
2 | !index.js
3 | /gatsby-ssr.js
4 | node_modules
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 |
5 | # Runtime data
6 | pids
7 | *.pid
8 | *.seed
9 |
10 | # Directory for instrumented libs generated by jscoverage/JSCover
11 | lib-cov
12 |
13 | # Coverage directory used by tools like Istanbul
14 | coverage
15 |
16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
17 | .grunt
18 |
19 | # node-waf configuration
20 | .lock-wscript
21 |
22 | # Compiled binary addons (http://nodejs.org/api/addons.html)
23 | build/Release
24 |
25 | # Dependency directory
26 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git
27 | node_modules
28 | *.un~
29 | yarn.lock
30 | src
31 | flow-typed
32 | coverage
33 | decls
34 | examples
35 | # /*.js
36 | !/index.js
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "gatsby-plugin-amp",
3 | "version": "0.2.2",
4 | "description": "A gatsby plugin for scaffolding AMP pages",
5 | "main": "index.js",
6 | "scripts": {
7 | "build": "babel src --out-dir . --ignore **/__tests__",
8 | "prepare": "npm run build",
9 | "watch": "babel -w src --out-dir . --ignore **/__tests__",
10 | "commit": "git add . && git commit && git push -u origin master"
11 | },
12 | "author": "Jonathan Faircloth",
13 | "license": "MIT",
14 | "homepage": "https://github.com/jafaircl/gatsby-plugin-amp#readme",
15 | "repository": "https://github.com/jafaircl/gatsby-plugin-amp",
16 | "bugs": {
17 | "url": "https://github.com/jafaircl/gatsby-plugin-amp/issues"
18 | },
19 | "dependencies": {
20 | "jsdom": "^13.0.0",
21 | "lodash.flattendeep": "^4.4.0",
22 | "minimatch": "^3.0.4",
23 | "react": "^16.6.1"
24 | },
25 | "keywords": [
26 | "gatsby",
27 | "gatsby-plugin",
28 | "gatsby-plugin-amp",
29 | "amp",
30 | "accelerated-mobile-pages"
31 | ],
32 | "devDependencies": {
33 | "babel-preset-gatsby-package": "^0.1.2",
34 | "@babel/cli": "^7.1.5",
35 | "@babel/core": "^7.1.5"
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # gatsby-plugin-amp
2 |
3 | Formats AMP-specific pages by removing javascript, combining styles and adding boilerplate. Read more about AMP (Accelerated Mobile Pages) [here](https://amp.dev/).
4 |
5 | ## Install
6 |
7 | `npm install --save gatsby-plugin-amp`
8 |
9 | ## How to use
10 |
11 | Create AMP-specific templates. Assume you have the following blog post template in `post.js`
12 |
13 | ```javascript
14 | import React from 'react'
15 | import Img from 'gatsby-image'
16 | import Layout from '../../components/layout'
17 |
18 | export default ({ data }) => (
19 |
20 |
21 | REGULAR PAGE
22 | regular page content
23 |
24 | )
25 | ```
26 |
27 | Create an AMP template `post.amp.js`
28 |
29 | ```javascript
30 | import React from 'react'
31 | import Layout from '../../components/layout'
32 |
33 | export default ({ data }) => (
34 |
35 |
36 | AMP PAGE
37 | amp page content
38 |
39 | )
40 | ```
41 |
42 | To assist with situations like images in markdown or other externally created HTML, the plugin will attempt to turn `img` tags to `amp-img` or `amp-anim`. While creating posts in your `gatsby-node.js` file, create an additional page in the `/amp/` directory using the AMP template you just made
43 |
44 | ```javascript
45 | _.each(posts, (post, index) => {
46 | const previous = index === posts.length - 1 ? null : posts[index + 1].node;
47 | const next = index === 0 ? null : posts[index - 1].node;
48 |
49 | createPage({
50 | path: post.node.fields.slug,
51 | component: path.resolve('./src/templates/post.js'),
52 | context: {
53 | slug: post.node.fields.slug,
54 | previous,
55 | next,
56 | },
57 | })
58 |
59 | createPage({
60 | path: `${post.node.fields.slug}/amp`,
61 | component: path.resolve('./src/templates/post.amp.js'),
62 | context: {
63 | slug: post.node.fields.slug,
64 | previous,
65 | next,
66 | },
67 | })
68 | })
69 | ```
70 |
71 | When you build your site, you should now have pages at `/my-awesome-post/index.html` and `/my-awesome-post/amp/index.html`
72 |
73 | Add the plugin to the plugins array in your `gatsby-config.js`
74 |
75 | ```javascript
76 | {
77 | resolve: `gatsby-plugin-amp`,
78 | options: {
79 | analytics: {
80 | type: 'gtag',
81 | dataCredentials: 'include',
82 | config: {
83 | vars: {
84 | gtag_id: ,
85 | config: {
86 | : {
87 | page_location: '{{pathname}}'
88 | },
89 | },
90 | },
91 | },
92 | },
93 | canonicalBaseUrl: 'http://www.example.com/',
94 | components: ['amp-form'],
95 | excludedPaths: ['/404*', '/'],
96 | pathIdentifier: '/amp',
97 | relAmpHtmlPattern: '{{canonicalBaseUrl}}{{pathname}}{{pathIdentifier}}',
98 | useAmpClientIdApi: true,
99 | },
100 | },
101 | ```
102 |
103 | When your site builds, your page in the `/amp` directory should now be a valid AMP page
104 |
105 | ## Options
106 |
107 | **analytics** `{Object}`
108 | If you want to include any `amp-analytics` tags, set that configuration here.
109 |
110 | **type** `{String}`
111 | Your analytics type. See the list of available vendors [here](https://www.ampproject.org/docs/analytics/analytics-vendors).
112 |
113 | **dataCredentials** `{String}`
114 | You value for the `data-credentials` attribute. Omit to remove the attribute.
115 |
116 | **config** `{Object | String}`
117 | This can be either an object containing your analytics configuration or a url to your analytics configuration. If you use Google Analytics gtag, your cofiguration might look like this:
118 |
119 | ```javascript
120 | vars: {
121 | gtag_id: ,
122 | config: {
123 | : {
124 | page_location: '{{pathname}}'
125 | },
126 | },
127 | },
128 | ```
129 |
130 | If you use a tag manager, your config would simply be a url like `https://www.googletagmanager.com/amp.json?id=GTM-1234567>m.url=SOURCE_URL`. You can use double curly braces to interpolate the pathname into a configuration value e.g. `page_location: '{{pathname}}'`. See [here](https://www.ampproject.org/docs/reference/components/amp-analytics) to learn more about `amp-analytics` configurations.
131 |
132 | **canonicalBaseUrl** `{String}`
133 | The base URL for your site. This will be used to create a `rel="canonical"` link in your amp template and `rel="amphtml"` link in your base page.
134 |
135 | **components** `{Array, version}>}`
136 | The components you will need for your AMP templates. Read more about the available components [here](https://www.ampproject.org/docs/reference/components).
137 |
138 | **excludedPaths**`{Array}`
139 | By default, this plugin will create `rel="amphtml"` links in all pages. If there are pages you would like to not have those links, include them here. You may use glob patterns in your strings (e.g. `/admin/*`). *this may go away if a way can be found to programatically exclude pages based on whether or not they have an AMP equivalent. But for now, this will work*
140 |
141 | **includedPaths**`{Array}`
142 | By default, this plugin will create `rel="amphtml"` links in all pages. If, you would instead like to whitelist pages, include them here. You may use glob patterns in your strings (e.g. `/blog/*`). *this may go away if a way can be found to programatically exclude pages based on whether or not they have an AMP equivalent. But for now, this will work*
143 |
144 | **pathIdentifier** `{String}`
145 | The url segment which identifies AMP pages. If your regular page is at `http://www.example.com/blog/my-awesome-post` and your AMP page is at `http://www.example.com/blog/my-awesome-post/amp/`, your pathIdentifier should be `/amp/`
146 |
147 | **relAmpHtmlPattern** `{String}`
148 | The url pattern for your `rel="amphtml"` links. If your AMP pages follow the pattern `http://www.example.com/my-awesome-post/amp/`, the value for this should be `{{canonicalBaseUrl}}{{pathname}}{{pathIdentifier}}`.
149 |
150 | **relCanonicalPattern** `{String}`
151 | The url pattern for your `rel="canonical"` links. The default value is `{{canonicalBaseUrl}}{{pathname}}`.
152 |
153 | **useAmpClientIdApi** `{Boolean}`
154 | If you are using a Client ID for Google Analytics, you can use the [Google AMP Client ID](https://support.google.com/analytics/answer/7486764) to determine if events belong to the same user when they visit your site on AMP and non-AMP pages. Set this to `true` if you would like to include the necessary meta tag in your AMP pages. You can read more about this concept [here](https://www.simoahava.com/analytics/accelerated-mobile-pages-via-google-tag-manager/#2-1-client-id)
155 |
156 | ## Caveats
157 |
158 | The standard HTML template that Gatsby uses will cause a validation error. This is because it is missing `minimum-scale=1` in the meta viewport tag. You can create a `html.js` template file under your `src/` directory in order to override the default Gatsby one available [here](https://github.com/gatsbyjs/gatsby/blob/master/packages/gatsby/cache-dir/default-html.js). Alternatively, you can simply clone it by runnig the shell command below at the root of your project. Read more [here](https://www.gatsbyjs.org/docs/custom-html/) on customizing your `html.js`.
159 |
160 | ```shell
161 | cp .cache/default-html.js src/html.js
162 | ```
163 |
164 | Don't forget to update the meta viewport tag value from its initial to the required AMP value.
165 |
166 | ```html
167 |
168 |
169 |
170 |
171 | ```
172 |
173 | ## Automatically Converted Elements
174 |
175 | While it is preferable to create AMP-specific templates, there may be situations where an image, iframe or some other element can't be modified. To cover these cases, the plugin will attempt to convert certain tags to their AMP equivalent.
176 |
177 | | HTML Tag | AMP Tag | Status | Issue |
178 | |----------------|-------------------|----------------------------|-------|
179 | | `img` | `amp-img` | Completed | |
180 | | `img (.gif)` | `amp-anim` | Completed | |
181 | | `iframe` | `amp-iframe` | Completed | |
182 | | `audio` | `amp-audio` | Planned, Not Started | |
183 | | `video` | `amp-video` | Planned, Not Started | |
184 | | YouTube | `amp-youtube` | Completed | |
185 | | Facebook | `amp-facebook` | Planned, Not Started | |
186 | | Instagram | `amp-instagram` | Planned, Not Started | |
187 | | Twitter | `amp-twitter` | Completed | |
188 |
--------------------------------------------------------------------------------
/src/gatsby-ssr.js:
--------------------------------------------------------------------------------
1 | import React, { Fragment } from "react";
2 | import { renderToString } from "react-dom/server";
3 | import { Minimatch } from "minimatch";
4 | import flattenDeep from "lodash.flattendeep";
5 | const JSDOM = eval('require("jsdom")').JSDOM;
6 | const minimatch = require("minimatch");
7 |
8 | const ampBoilerplate = `body{-webkit-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-moz-animation:-amp-start 8s steps(1,end) 0s 1 normal both;-ms-animation:-amp-start 8s steps(1,end) 0s 1 normal both;animation:-amp-start 8s steps(1,end) 0s 1 normal both}@-webkit-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-moz-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-ms-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@-o-keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}@keyframes -amp-start{from{visibility:hidden}to{visibility:visible}}`;
9 | const ampNoscriptBoilerplate = `body{-webkit-animation:none;-moz-animation:none;-ms-animation:none;animation:none}`;
10 |
11 | const interpolate = (str, map) =>
12 | str.replace(/{{\s*[\w\.]+\s*}}/g, match => map[match.replace(/[{}]/g, "")]);
13 |
14 | export const onPreRenderHTML = (
15 | {
16 | getHeadComponents,
17 | replaceHeadComponents,
18 | getPreBodyComponents,
19 | replacePreBodyComponents,
20 | getPostBodyComponents,
21 | replacePostBodyComponents,
22 | pathname
23 | },
24 | {
25 | analytics,
26 | canonicalBaseUrl,
27 | components = [],
28 | includedPaths = [],
29 | excludedPaths = [],
30 | pathIdentifier = "/amp/",
31 | relAmpHtmlPattern = "{{canonicalBaseUrl}}{{pathname}}{{pathIdentifier}}"
32 | }
33 | ) => {
34 | const headComponents = flattenDeep(getHeadComponents());
35 | const preBodyComponents = getPreBodyComponents();
36 | const postBodyComponents = getPostBodyComponents();
37 | const isAmp = pathname && pathname.indexOf(pathIdentifier) > -1;
38 | if (isAmp) {
39 | const styles = headComponents.reduce((str, x) => {
40 | if (x.type === "style") {
41 | if (x.props.dangerouslySetInnerHTML) {
42 | str += x.props.dangerouslySetInnerHTML.__html;
43 | }
44 | } else if (x.key && x.key === "TypographyStyle") {
45 | str = `${x.props.typography.toString()}${str}`;
46 | }
47 | return str;
48 | }, "");
49 | replaceHeadComponents([
50 | ,
51 | ,
55 | ,
61 | ,
62 | ...components.map((component, i) => (
63 |
73 | )),
74 | analytics !== undefined ? (
75 |
80 | ) : (
81 |
82 | ),
83 | ...headComponents.filter(
84 | x =>
85 | x.type !== "style" &&
86 | (x.type !== "script" || x.props.type === "application/ld+json") &&
87 | x.key !== "TypographyStyle"
88 | )
89 | ]);
90 | replacePreBodyComponents([
91 | ...preBodyComponents.filter(x => x.key !== "plugin-google-tagmanager")
92 | ]);
93 | replacePostBodyComponents(
94 | postBodyComponents.filter(x => x.type !== "script")
95 | );
96 | } else if (
97 | (excludedPaths.length > 0 &&
98 | pathname &&
99 | excludedPaths.findIndex(_path => new Minimatch(pathname).match(_path)) <
100 | 0) ||
101 | (includedPaths.length > 0 &&
102 | pathname &&
103 | includedPaths.findIndex(_path => minimatch(pathname, _path)) > -1) ||
104 | (excludedPaths.length === 0 && includedPaths.length === 0)
105 | ) {
106 | replaceHeadComponents([
107 | ,
116 | ...headComponents
117 | ]);
118 | }
119 | };
120 |
121 | export const onRenderBody = (
122 | { setHeadComponents, setHtmlAttributes, setPreBodyComponents, pathname },
123 | {
124 | analytics,
125 | canonicalBaseUrl,
126 | pathIdentifier = "/amp/",
127 | relCanonicalPattern = "{{canonicalBaseUrl}}{{pathname}}",
128 | useAmpClientIdApi = false
129 | }
130 | ) => {
131 | const isAmp = pathname && pathname.indexOf(pathIdentifier) > -1;
132 | if (isAmp) {
133 | setHtmlAttributes({ amp: "" });
134 | setHeadComponents([
135 | ,
144 | useAmpClientIdApi ? (
145 |
146 | ) : (
147 |
148 | )
149 | ]);
150 | setPreBodyComponents([
151 | analytics != undefined ? (
152 |
159 | {typeof analytics.config === "string" ? (
160 |
161 | ) : (
162 |
170 | )}
171 |
172 | ) : (
173 |
174 | )
175 | ]);
176 | }
177 | };
178 |
179 | export const replaceRenderer = (
180 | { bodyComponent, replaceBodyHTMLString, setHeadComponents, pathname },
181 | { pathIdentifier = "/amp/" }
182 | ) => {
183 | const defaults = {
184 | image: {
185 | width: 640,
186 | height: 475,
187 | layout: "responsive"
188 | },
189 | twitter: {
190 | width: "390",
191 | height: "330",
192 | layout: "responsive"
193 | },
194 | iframe: {
195 | width: 640,
196 | height: 475,
197 | layout: "responsive"
198 | }
199 | };
200 | const headComponents = [];
201 | const isAmp = pathname && pathname.indexOf(pathIdentifier) > -1;
202 | if (isAmp) {
203 | const bodyHTML = renderToString(bodyComponent);
204 | const dom = new JSDOM(bodyHTML);
205 | const document = dom.window.document;
206 |
207 | // convert images to amp-img or amp-anim
208 | const images = [].slice.call(document.getElementsByTagName("img"));
209 | images.forEach(image => {
210 | let ampImage;
211 | if (image.src && image.src.indexOf(".gif") > -1) {
212 | ampImage = document.createElement("amp-anim");
213 | headComponents.push({ name: "amp-anim", version: "0.1" });
214 | } else {
215 | ampImage = document.createElement("amp-img");
216 | }
217 | const attributes = Object.keys(image.attributes);
218 | const includedAttributes = attributes.map(key => {
219 | const attribute = image.attributes[key];
220 | ampImage.setAttribute(attribute.name, attribute.value);
221 | return attribute.name;
222 | });
223 | Object.keys(defaults.image).forEach(key => {
224 | if (includedAttributes && includedAttributes.indexOf(key) === -1) {
225 | ampImage.setAttribute(key, defaults.image[key]);
226 | }
227 | });
228 | image.parentNode.replaceChild(ampImage, image);
229 | });
230 |
231 | // convert twitter posts to amp-twitter
232 | const twitterPosts = [].slice.call(
233 | document.getElementsByClassName("twitter-tweet")
234 | );
235 | twitterPosts.forEach(post => {
236 | headComponents.push({ name: "amp-twitter", version: "0.1" });
237 | const ampTwitter = document.createElement("amp-twitter");
238 | const attributes = Object.keys(post.attributes);
239 | const includedAttributes = attributes.map(key => {
240 | const attribute = post.attributes[key];
241 | ampTwitter.setAttribute(attribute.name, attribute.value);
242 | return attribute.name;
243 | });
244 | Object.keys(defaults.twitter).forEach(key => {
245 | if (includedAttributes && includedAttributes.indexOf(key) === -1) {
246 | ampTwitter.setAttribute(key, defaults.twitter[key]);
247 | }
248 | });
249 | // grab the last link in the tweet for the twee id
250 | const links = [].slice.call(post.getElementsByTagName("a"));
251 | const link = links[links.length - 1];
252 | const hrefArr = link.href.split("/");
253 | const id = hrefArr[hrefArr.length - 1].split("?")[0];
254 | ampTwitter.setAttribute("data-tweetid", id);
255 | // clone the original blockquote for a placeholder
256 | const _post = post.cloneNode(true);
257 | _post.setAttribute("placeholder", "");
258 | ampTwitter.appendChild(_post);
259 | post.parentNode.replaceChild(ampTwitter, post);
260 | });
261 |
262 | // convert iframes to amp-iframe or amp-youtube
263 | const iframes = [].slice.call(document.getElementsByTagName("iframe"));
264 | iframes.forEach(iframe => {
265 | let ampIframe;
266 | let attributes;
267 | if (iframe.src && iframe.src.indexOf("youtube.com/embed/") > -1) {
268 | headComponents.push({ name: "amp-youtube", version: "0.1" });
269 | ampIframe = document.createElement("amp-youtube");
270 | const src = iframe.src.split("/");
271 | const id = src[src.length - 1].split("?")[0];
272 | ampIframe.setAttribute("data-videoid", id);
273 | const placeholder = document.createElement("amp-img");
274 | placeholder.setAttribute(
275 | "src",
276 | `https://i.ytimg.com/vi/${id}/mqdefault.jpg`
277 | );
278 | placeholder.setAttribute("placeholder", "");
279 | placeholder.setAttribute("layout", "fill");
280 | ampIframe.appendChild(placeholder);
281 |
282 | const forbidden = ["allow", "allowfullscreen", "frameborder", "src"];
283 | attributes = Object.keys(iframe.attributes).filter(key => {
284 | const attribute = iframe.attributes[key];
285 | return !forbidden.includes(attribute.name);
286 | });
287 | } else {
288 | headComponents.push({ name: "amp-iframe", version: "0.1" });
289 | ampIframe = document.createElement("amp-iframe");
290 | attributes = Object.keys(iframe.attributes);
291 | }
292 |
293 | const includedAttributes = attributes.map(key => {
294 | const attribute = iframe.attributes[key];
295 | ampIframe.setAttribute(attribute.name, attribute.value);
296 | return attribute.name;
297 | });
298 | Object.keys(defaults.iframe).forEach(key => {
299 | if (includedAttributes && includedAttributes.indexOf(key) === -1) {
300 | ampIframe.setAttribute(key, defaults.iframe[key]);
301 | }
302 | });
303 | iframe.parentNode.replaceChild(ampIframe, iframe);
304 | });
305 | setHeadComponents(
306 | Array.from(new Set(headComponents)).map((component, i) => (
307 |
308 |
315 |
316 | ))
317 | );
318 | replaceBodyHTMLString(document.body.children[0].outerHTML);
319 | }
320 | };
321 |
--------------------------------------------------------------------------------