├── .gitignore ├── .czrc ├── .github └── FUNDING.yml ├── examples ├── website │ ├── gatsby-browser.js │ ├── static │ │ └── favicon.ico │ ├── src │ │ ├── components │ │ │ ├── details.js │ │ │ ├── gatsby-logo.js │ │ │ ├── masonry.js │ │ │ └── menu.js │ │ ├── pages │ │ │ └── 404.js │ │ ├── templates │ │ │ └── page.js │ │ ├── gatsby-plugin-theme-ui │ │ │ └── index.js │ │ └── layouts │ │ │ └── index.js │ ├── .gitignore │ ├── package.json │ └── gatsby-config.js └── basic │ ├── src │ ├── pages │ │ └── 404.js │ └── templates │ │ └── page.js │ ├── gatsby-config.js │ ├── package.json │ └── .gitignore ├── gatsby-ssr.js ├── index.js ├── gatsby-browser.js ├── logo.png ├── .commitlintrc.js ├── .husky ├── pre-commit └── commit-msg ├── .prettierrc ├── utils ├── google-docs-context.js ├── wrap-page-element.js ├── write-document-to-tests.js ├── constants.js ├── create-schema.js ├── generate-token.js ├── get-image-url-parameters.js ├── wait.js ├── google-document-types.js ├── google-docs.js ├── create-pages.js ├── source-nodes.js ├── update-images.js ├── google-drive.js └── google-document.js ├── .lintstagedrc ├── .travis.yml ├── .editorconfig ├── gatsby-node.js ├── .releaserc.js ├── .eslintrc.js ├── __tests__ ├── converter-markdown.js ├── converter-object.js ├── options.js ├── __snapshots__ │ ├── converter-markdown.js.snap │ ├── options.js.snap │ └── converter-object.js.snap └── documents │ ├── empty.json │ ├── special-chars.json │ ├── breaks.json │ ├── french.json │ ├── poem.json │ └── links.json ├── LICENCE.md ├── index.d.ts ├── package.json ├── CHANGELOG.md └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | -------------------------------------------------------------------------------- /.czrc: -------------------------------------------------------------------------------- 1 | { 2 | "path": "cz-conventional-changelog" 3 | } 4 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | custom: https://paypal.me/cedricdelpoux 2 | -------------------------------------------------------------------------------- /examples/website/gatsby-browser.js: -------------------------------------------------------------------------------- 1 | import "prismjs/themes/prism.css" 2 | -------------------------------------------------------------------------------- /gatsby-ssr.js: -------------------------------------------------------------------------------- 1 | export {wrapPageElement} from "./utils/wrap-page-element" 2 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | export {GoogleDocsContext} from "./utils/google-docs-context" 2 | -------------------------------------------------------------------------------- /gatsby-browser.js: -------------------------------------------------------------------------------- 1 | export {wrapPageElement} from "./utils/wrap-page-element" 2 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedricdelpoux/gatsby-source-google-docs/HEAD/logo.png -------------------------------------------------------------------------------- /.commitlintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["@commitlint/config-conventional"], 3 | } 4 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install lint-staged 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "bracketSpacing": false, 3 | "semi": false, 4 | "trailingComma": "es5" 5 | } 6 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit $1 5 | -------------------------------------------------------------------------------- /utils/google-docs-context.js: -------------------------------------------------------------------------------- 1 | import {createContext} from "react" 2 | 3 | export const GoogleDocsContext = createContext() 4 | -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.js": [ 3 | "yarn lint" 4 | ], 5 | "*.{css,js,md}": [ 6 | "prettier --write" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /examples/website/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cedricdelpoux/gatsby-source-google-docs/HEAD/examples/website/static/favicon.ico -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 17.2 3 | 4 | before_install: 5 | - yarn global add codecov 6 | 7 | after_success: 8 | - cat ./coverage/lcov.info | ./node_modules/.bin/codecov 9 | 10 | branches: 11 | only: 12 | - master 13 | -------------------------------------------------------------------------------- /examples/website/src/components/details.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | export const Details = ({label, children}) => { 4 | return ( 5 |
6 | {label} 7 | {children} 8 |
9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | indent_style = space 9 | indent_size = 2 10 | 11 | [*.md] 12 | indent_size = 4 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /gatsby-node.js: -------------------------------------------------------------------------------- 1 | const {createPages} = require("./utils/create-pages") 2 | const {createSchema} = require("./utils/create-schema") 3 | const {sourceNodes} = require("./utils/source-nodes") 4 | 5 | exports.createPages = createPages 6 | exports.createSchemaCustomization = createSchema 7 | exports.sourceNodes = sourceNodes 8 | -------------------------------------------------------------------------------- /examples/basic/src/pages/404.js: -------------------------------------------------------------------------------- 1 | import {Link} from "gatsby" 2 | import React from "react" 3 | 4 | const Page404 = () => { 5 | return ( 6 |
7 | 8 | 9 | 10 |

{"404"}

11 |
12 | ) 13 | } 14 | 15 | export default Page404 16 | -------------------------------------------------------------------------------- /examples/website/src/pages/404.js: -------------------------------------------------------------------------------- 1 | import {Link} from "gatsby" 2 | import React from "react" 3 | 4 | const Page404 = () => { 5 | return ( 6 |
7 | 8 | 9 | 10 |

{"404"}

11 |
12 | ) 13 | } 14 | 15 | export default Page404 16 | -------------------------------------------------------------------------------- /utils/wrap-page-element.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | import {GoogleDocsContext} from "./google-docs-context" 4 | 5 | export const wrapPageElement = ({element, props}) => { 6 | return ( 7 | 8 | {element} 9 | 10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /utils/write-document-to-tests.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs") 2 | const path = require("path") 3 | const _kebabCase = require("lodash/kebabCase") 4 | 5 | exports.writeDocumentToTests = (googleDocument) => { 6 | fs.writeFileSync( 7 | path.join( 8 | process.cwd(), 9 | "..", 10 | "..", 11 | "__tests__", 12 | "documents", 13 | `${_kebabCase(googleDocument.document.title)}.json` 14 | ), 15 | JSON.stringify(googleDocument.document) 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /.releaserc.js: -------------------------------------------------------------------------------- 1 | { 2 | plugins: [ 3 | "@semantic-release/commit-analyzer", 4 | "@semantic-release/release-notes-generator", 5 | "@semantic-release/changelog", 6 | "@semantic-release/npm", 7 | [ 8 | "@semantic-release/git", 9 | { 10 | assets: ["package.json", "CHANGELOG.md"], 11 | message: 12 | "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}", 13 | }, 14 | ], 15 | "@semantic-release/github", 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /utils/constants.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ENV_TOKEN_VAR: "GOOGLE_DOCS_TOKEN", 3 | DEFAULT_OPTIONS: { 4 | createPages: false, 5 | debug: false, 6 | demoteHeadings: true, 7 | folder: undefined, 8 | imagesOptions: undefined, 9 | keepDefaultStyle: false, 10 | pageContext: [], 11 | skipCodes: false, 12 | skipFootnotes: false, 13 | skipHeadings: false, 14 | skipImages: false, 15 | skipLists: false, 16 | skipQuotes: false, 17 | skipTables: false, 18 | }, 19 | DEFAULT_TEMPLATE: "page", 20 | } 21 | -------------------------------------------------------------------------------- /examples/website/src/components/gatsby-logo.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | 3 | export const GatsbyLogo = () => ( 4 | 10 | 11 | 15 | 16 | ) 17 | -------------------------------------------------------------------------------- /examples/website/src/components/masonry.js: -------------------------------------------------------------------------------- 1 | import React from "react" 2 | export const Masonry = ({children}) => { 3 | return ( 4 |
11 | {React.Children.map(children, (child) => 12 | React.cloneElement(child, { 13 | style: { 14 | ...child.props.style, 15 | breakInside: "avoid", 16 | marginBottom: 10, 17 | }, 18 | }) 19 | )} 20 |
21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /utils/create-schema.js: -------------------------------------------------------------------------------- 1 | exports.createSchema = ({actions}) => { 2 | const {createTypes} = actions 3 | const typeDefs = ` 4 | type Cover { 5 | title: String 6 | alt: String 7 | image: File @link 8 | } 9 | 10 | type BreadcrumbItem { 11 | name: String! 12 | slug: String! 13 | } 14 | 15 | type GoogleDocs implements Node { 16 | slug: String! 17 | path: String! 18 | breadcrumb: [BreadcrumbItem!]! 19 | template: String 20 | cover: Cover 21 | related: [GoogleDocs!] @link 22 | images: [File] @link 23 | } 24 | ` 25 | createTypes(typeDefs) 26 | } 27 | -------------------------------------------------------------------------------- /utils/generate-token.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | /* eslint-disable no-console */ 3 | 4 | const GoogleOAuth2 = require("google-oauth2-env-vars") 5 | 6 | async function generateToken() { 7 | const googleOAuth2 = new GoogleOAuth2({ 8 | scope: [ 9 | "https://www.googleapis.com/auth/documents.readonly", 10 | "https://www.googleapis.com/auth/drive.readonly", 11 | ], 12 | token: "GOOGLE_DOCS_TOKEN", 13 | apis: ["docs.googleapis.com", "drive.googleapis.com"], 14 | }) 15 | 16 | await googleOAuth2.generateEnvVars() 17 | 18 | console.log("") 19 | console.log("Enjoy `gatsby-source-google-docs` plugin") 20 | 21 | process.exit() 22 | } 23 | 24 | generateToken() 25 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | es6: true, 5 | node: true, 6 | jest: true, 7 | }, 8 | parserOptions: { 9 | sourceType: "module", 10 | ecmaVersion: 2019, 11 | }, 12 | plugins: ["jest"], 13 | extends: ["eslint:recommended", "plugin:react/recommended", "prettier"], 14 | settings: { 15 | react: { 16 | version: "detect", 17 | }, 18 | }, 19 | rules: { 20 | "no-unused-vars": "warn", 21 | "no-control-regex": 0, 22 | "react/prop-types": "off", 23 | "react/display-name": "off", 24 | "react/no-unknown-property": [ 25 | "error", 26 | {ignore: ["sx"]} /* theme-ui in examples */, 27 | ], 28 | }, 29 | } 30 | -------------------------------------------------------------------------------- /__tests__/converter-markdown.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs") 2 | const path = require(`path`) 3 | 4 | const {GoogleDocument} = require("../utils/google-document") 5 | 6 | const documentsPath = path.join(__dirname, "documents") 7 | const filenames = fs.readdirSync(documentsPath) 8 | 9 | filenames.forEach(function (filename) { 10 | const filepath = path.join(documentsPath, filename) 11 | const file = fs.readFileSync(filepath, "utf8") 12 | const document = JSON.parse(file) 13 | const googleDocument = new GoogleDocument({document}) 14 | 15 | test(`Document "${googleDocument.document.title}" to Markdown`, () => { 16 | const markdown = googleDocument.toMarkdown() 17 | expect(markdown).toMatchSnapshot() 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /__tests__/converter-object.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs") 2 | const path = require(`path`) 3 | 4 | const {GoogleDocument} = require("../utils/google-document") 5 | 6 | const documentsPath = path.join(__dirname, "documents") 7 | const filenames = fs.readdirSync(documentsPath) 8 | 9 | filenames.forEach(function (filename) { 10 | const filepath = path.join(documentsPath, filename) 11 | const file = fs.readFileSync(filepath, "utf8") 12 | const document = JSON.parse(file) 13 | const googleDocument = new GoogleDocument({document}) 14 | 15 | test(`Document "${googleDocument.document.title}" to Object`, () => { 16 | const {cover, elements} = googleDocument 17 | 18 | expect({ 19 | cover, 20 | elements, 21 | }).toMatchSnapshot() 22 | }) 23 | }) 24 | -------------------------------------------------------------------------------- /utils/get-image-url-parameters.js: -------------------------------------------------------------------------------- 1 | const numberMax = 16384 2 | const isSizeValid = (number) => 3 | number && Number.isInteger(number) && number > 0 && number < numberMax 4 | 5 | const getImageUrlParameters = (pluginOptions) => { 6 | const {imagesOptions} = pluginOptions 7 | 8 | if (!imagesOptions) return "" 9 | 10 | const {width, height, crop} = imagesOptions 11 | const widthParam = isSizeValid(width) && `w${width}` 12 | const heightParam = isSizeValid(height) && `h${height}` 13 | const cropParam = crop && crop === true && "c" 14 | const optionsArray = [widthParam, heightParam, cropParam].filter(Boolean) 15 | 16 | if (optionsArray.length === 0) return "" 17 | 18 | return `=${optionsArray.join("-")}` 19 | } 20 | 21 | module.exports = { 22 | getImageUrlParameters, 23 | } 24 | -------------------------------------------------------------------------------- /examples/basic/gatsby-config.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config() 2 | 3 | module.exports = { 4 | plugins: [ 5 | { 6 | resolve: require.resolve(`../..`), 7 | options: { 8 | // https://drive.google.com/drive/folders/1YJWX_FRoVusp-51ztedm6HSZqpbJA3ag 9 | folder: "1YJWX_FRoVusp-51ztedm6HSZqpbJA3ag", 10 | createPages: true, 11 | skipImages: false, 12 | debug: true, 13 | }, 14 | }, 15 | "gatsby-plugin-image", 16 | "gatsby-plugin-sharp", 17 | "gatsby-transformer-sharp", 18 | { 19 | resolve: "gatsby-transformer-remark", 20 | options: { 21 | plugins: [ 22 | "gatsby-remark-unwrap-images", 23 | "gatsby-remark-images", 24 | "gatsby-remark-gifs", 25 | ], 26 | }, 27 | }, 28 | ], 29 | } 30 | -------------------------------------------------------------------------------- /examples/basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gatsby-source-google-docs-basic", 3 | "private": true, 4 | "scripts": { 5 | "build": "gatsby build", 6 | "dev": "gatsby develop", 7 | "docs2test": "NODE_ENV=DOCS_TO_TESTS yarn dev", 8 | "serve": "gatsby serve", 9 | "clean": "gatsby clean", 10 | "token": "../../utils/generate-token.js" 11 | }, 12 | "dependencies": { 13 | "gatsby": "^4.25.7", 14 | "gatsby-image": "^3.11.0", 15 | "gatsby-plugin-image": "^2.13.0", 16 | "gatsby-plugin-sharp": "^4.13.0", 17 | "gatsby-remark-gifs": "^1.1.0", 18 | "gatsby-remark-images": "^6.13.0", 19 | "gatsby-remark-unwrap-images": "^1.0.2", 20 | "gatsby-transformer-remark": "^5.13.0", 21 | "gatsby-transformer-sharp": "^4.13.0", 22 | "react": "link:../../node_modules/react", 23 | "react-dom": "link:../../node_modules/react-dom" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /LICENCE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Cédric Delpoux 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 | -------------------------------------------------------------------------------- /examples/basic/src/templates/page.js: -------------------------------------------------------------------------------- 1 | import {Link, graphql} from "gatsby" 2 | import {GatsbyImage, getImage} from "gatsby-plugin-image" 3 | import React from "react" 4 | 5 | const TemplatePage = ({ 6 | data: { 7 | page: { 8 | name, 9 | cover, 10 | childMarkdownRemark: {html}, 11 | }, 12 | }, 13 | }) => { 14 | return ( 15 | <> 16 | 17 | 18 | 19 |

{name}

20 | {/* 21 | To add a cover: 22 | Add an image in your Google Doc first page header 23 | https://support.google.com/docs/answer/86629 24 | */} 25 | {cover && } 26 |
27 | 28 | ) 29 | } 30 | 31 | export default TemplatePage 32 | 33 | export const pageQuery = graphql` 34 | query Page($path: String!) { 35 | page: googleDocs(slug: {eq: $path}) { 36 | name 37 | cover { 38 | image { 39 | childImageSharp { 40 | gatsbyImageData 41 | } 42 | } 43 | } 44 | childMarkdownRemark { 45 | html 46 | } 47 | } 48 | } 49 | ` 50 | -------------------------------------------------------------------------------- /examples/website/src/templates/page.js: -------------------------------------------------------------------------------- 1 | import {graphql} from "gatsby" 2 | import {GatsbyImage, getImage} from "gatsby-plugin-image" 3 | import {MDXRenderer} from "gatsby-plugin-mdx" 4 | import React from "react" 5 | import {Themed} from "theme-ui" 6 | /** @jsx jsx */ 7 | import {jsx} from "theme-ui" 8 | 9 | const H1 = Themed.h1 10 | 11 | const PageTemplate = ({ 12 | data: { 13 | page: {name, cover, childMdx}, 14 | }, 15 | }) => { 16 | return ( 17 | 18 |

{name}

19 | {/* 20 | To add a cover: 21 | Add an image in your Google Doc first page header 22 | https://support.google.com/docs/answer/86629 23 | */} 24 | {cover && } 25 | {childMdx.body} 26 |
27 | ) 28 | } 29 | 30 | export default PageTemplate 31 | 32 | export const pageQuery = graphql` 33 | query Page($path: String!) { 34 | page: googleDocs(slug: {eq: $path}) { 35 | name 36 | cover { 37 | image { 38 | childImageSharp { 39 | gatsbyImageData(placeholder: BLURRED) 40 | } 41 | } 42 | } 43 | childMdx { 44 | body 45 | } 46 | } 47 | } 48 | ` 49 | -------------------------------------------------------------------------------- /examples/basic/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # dotenv environment variable files 55 | .env* 56 | 57 | # gatsby files 58 | .cache/ 59 | public 60 | 61 | # Mac files 62 | .DS_Store 63 | 64 | # Yarn 65 | yarn-error.log 66 | .pnp/ 67 | .pnp.js 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | -------------------------------------------------------------------------------- /examples/website/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # dotenv environment variable files 55 | .env* 56 | 57 | # gatsby files 58 | .cache/ 59 | public 60 | 61 | # Mac files 62 | .DS_Store 63 | 64 | # Yarn 65 | yarn-error.log 66 | .pnp/ 67 | .pnp.js 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | import {drive_v3} from "googleapis" 2 | 3 | export interface Options { 4 | /** 5 | * folder ID can be found in Google Drive URLs 6 | * https://drive.google.com/drive/folders/FOLDER_ID 7 | */ 8 | folder: string 9 | // 10 | //--- 11 | // All the following options are OPTIONAL 12 | //--- 13 | // 14 | /** 15 | * To add default fields values 16 | */ 17 | createPages?: boolean 18 | /** h1 -> h2, h2 -> h3, ... */ 19 | demoteHeadings?: boolean 20 | /** 21 | * To exclude some folder in the tree 22 | * It can be folder names or IDs 23 | */ 24 | exclude?: string[] 25 | /** 26 | * For a better stack trace and more information 27 | * Usefull when you open a issue to report a bug 28 | */ 29 | debug?: boolean 30 | } 31 | 32 | export interface DocumentFile extends drive_v3.Schema$File { 33 | mimeType: "application/vnd.google-apps.document" 34 | } 35 | 36 | export interface RawFolder extends drive_v3.Schema$File { 37 | mimeType: "application/vnd.google-apps.folder" 38 | } 39 | 40 | export interface Metadata extends DocumentFile { 41 | id?: DocumentFile["id"] 42 | name: string 43 | slug: string 44 | path: string 45 | description?: string | object 46 | cover: { 47 | image: any 48 | title: any 49 | alt: any 50 | } 51 | markdown: string 52 | breadcrumb: object[] 53 | } 54 | -------------------------------------------------------------------------------- /utils/wait.js: -------------------------------------------------------------------------------- 1 | function sleep(ms) { 2 | return new Promise((resolve) => { 3 | setTimeout(() => resolve(), ms) 4 | }) 5 | } 6 | 7 | function reload(requestsPerInterval, interval, state) { 8 | const throughput = requestsPerInterval / interval 9 | const now = Date.now() 10 | const reloadTime = now - state.lastReload 11 | const reloadedRequests = reloadTime * throughput 12 | const newAvalableRequests = state.availableRequests + reloadedRequests 13 | 14 | return { 15 | ...state, 16 | lastReload: now, 17 | availableRequests: Math.min(newAvalableRequests, requestsPerInterval), 18 | } 19 | } 20 | 21 | function wait(requestsPerInterval, interval) { 22 | const timePerRequest = interval / requestsPerInterval 23 | 24 | let state = { 25 | lastReload: Date.now(), 26 | availableRequests: requestsPerInterval, 27 | } 28 | 29 | async function waitRequest(requests = 1) { 30 | if (requests > requestsPerInterval) { 31 | throw new Error( 32 | "Requests can not be greater than the number of requests per interval" 33 | ) 34 | } 35 | 36 | state = reload(requestsPerInterval, interval, state) 37 | 38 | const requestsToWait = Math.max(0, requests - state.availableRequests) 39 | const wait = Math.ceil(requestsToWait * timePerRequest) 40 | 41 | state.availableRequests -= requests 42 | await sleep(wait) 43 | return wait 44 | } 45 | 46 | return waitRequest 47 | } 48 | 49 | exports.wait = wait 50 | -------------------------------------------------------------------------------- /examples/website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gatsby-source-google-docs-website", 3 | "private": true, 4 | "scripts": { 5 | "build": "gatsby build", 6 | "clean": "gatsby clean", 7 | "deploy": "gatsby build --prefix-paths && gh-pages -d public", 8 | "dev": "gatsby develop", 9 | "serve": "gatsby serve", 10 | "token": "../../utils/generate-token.js" 11 | }, 12 | "dependencies": { 13 | "@emotion/react": "^11.9.0", 14 | "@mdx-js/mdx": "^1.6.22", 15 | "@mdx-js/react": "^1.6.22", 16 | "gatsby": "^4.13.1", 17 | "gatsby-plugin-catch-links": "^4.13.0", 18 | "gatsby-plugin-eslint": "^4.0.2", 19 | "gatsby-plugin-image": "^2.13.0", 20 | "gatsby-plugin-layout": "^3.13.0", 21 | "gatsby-plugin-mdx": "^3.15.2", 22 | "gatsby-plugin-mdx-embed": "^1.0.0", 23 | "gatsby-plugin-react-svg": "^3.1.0", 24 | "gatsby-plugin-sharp": "^4.25.1", 25 | "gatsby-plugin-theme-ui": "^0.14.5", 26 | "gatsby-plugin-webfonts": "^2.2.2", 27 | "gatsby-remark-gifs": "^1.1.0", 28 | "gatsby-remark-images": "^6.13.0", 29 | "gatsby-remark-prismjs": "^6.13.0", 30 | "gatsby-remark-unwrap-images": "^1.0.2", 31 | "gatsby-transformer-remark": "^5.25.1", 32 | "gatsby-transformer-sharp": "^4.13.0", 33 | "mdx-embed": "^1.0.0", 34 | "prismjs": "^1.28.0", 35 | "react": "link:../node_modules/react", 36 | "react-dom": "link:../node_modules/react-dom", 37 | "react-icons": "^4.3.1", 38 | "theme-ui": "^0.14.5" 39 | }, 40 | "devDependencies": { 41 | "eslint-loader": "^4.0.2", 42 | "gh-pages": "^3.2.3" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /examples/website/gatsby-config.js: -------------------------------------------------------------------------------- 1 | require("dotenv").config() 2 | 3 | const DEV = process.env.NODE_ENV === "development" 4 | 5 | module.exports = { 6 | pathPrefix: "/gatsby-source-google-docs", 7 | plugins: [ 8 | { 9 | // resolve: "gatsby-source-google-docs", 10 | resolve: require.resolve(`../..`), 11 | options: { 12 | // https://drive.google.com/drive/folders/1YJWX_FRoVusp-51ztedm6HSZqpbJA3ag 13 | folder: "1YJWX_FRoVusp-51ztedm6HSZqpbJA3ag", 14 | // -------- 15 | // Optional 16 | // -------- 17 | debug: true, 18 | createPages: true, 19 | // skipImages: DEV ? true : false, 20 | imagesOptions: { 21 | width: DEV ? 512 : 1024, 22 | }, 23 | }, 24 | }, 25 | { 26 | resolve: "gatsby-plugin-webfonts", 27 | options: { 28 | fonts: { 29 | google: [ 30 | { 31 | family: "Quicksand", 32 | variants: ["400", "700"], 33 | fontDisplay: "fallback", 34 | }, 35 | ], 36 | }, 37 | formats: ["woff2"], 38 | usePreload: true, 39 | }, 40 | }, 41 | // "gatsby-plugin-tailwindcss", 42 | "gatsby-plugin-catch-links", 43 | "gatsby-plugin-react-svg", 44 | "gatsby-plugin-eslint", 45 | "gatsby-plugin-layout", 46 | "gatsby-plugin-theme-ui", 47 | "gatsby-plugin-image", 48 | "gatsby-plugin-sharp", 49 | "gatsby-transformer-sharp", 50 | "gatsby-plugin-mdx-embed", 51 | { 52 | resolve: `gatsby-plugin-mdx`, 53 | options: { 54 | gatsbyRemarkPlugins: [ 55 | "gatsby-remark-unwrap-images", 56 | "gatsby-remark-images", 57 | "gatsby-remark-gifs", 58 | "gatsby-remark-prismjs", 59 | ], 60 | }, 61 | }, 62 | ], 63 | } 64 | -------------------------------------------------------------------------------- /utils/google-document-types.js: -------------------------------------------------------------------------------- 1 | const _get = require("lodash/get") 2 | 3 | // If the table has only one cell 4 | // and the monospace font "Consolas" is applied everywhere 5 | exports.isCodeBlocks = (table) => { 6 | const hasOneCell = table.rows === 1 && table.columns === 1 7 | 8 | if (!hasOneCell) { 9 | return false 10 | } 11 | 12 | const firstRow = table.tableRows[0] 13 | const firstCell = firstRow.tableCells[0] 14 | const hasMonospaceFont = firstCell.content.every(({paragraph}) => 15 | paragraph.elements.every(({textRun}) => { 16 | const content = textRun.content 17 | .replace(/\n/g, "") 18 | .replace(/\x0B/g, "") //eslint-disable-line no-control-regex 19 | .trim() 20 | const isEmpty = content === "" 21 | const fontFamily = _get(textRun, [ 22 | "textStyle", 23 | "weightedFontFamily", 24 | "fontFamily", 25 | ]) 26 | const hasConsolasFont = fontFamily === "Consolas" 27 | 28 | return isEmpty || hasConsolasFont 29 | }) 30 | ) 31 | 32 | return hasMonospaceFont 33 | } 34 | 35 | // If the table has only one cell 36 | // and the content is surrounded by smart quotes: “quote” 37 | exports.isQuote = (table) => { 38 | const hasOneCell = table.rows === 1 && table.columns === 1 39 | 40 | if (!hasOneCell) { 41 | return false 42 | } 43 | 44 | const firstRow = table.tableRows[0] 45 | const firstCell = firstRow.tableCells[0] 46 | const { 47 | 0: firstContent, 48 | [firstCell.content.length - 1]: lastContent, 49 | } = firstCell.content 50 | const startText = firstContent.paragraph.elements[0].textRun.content 51 | const lastText = lastContent.paragraph.elements[0].textRun.content 52 | const startsWithQuote = startText.replace(/\n/g, "").startsWith("“") 53 | const endsWithQuote = lastText.replace(/\n/g, "").endsWith("”") 54 | 55 | return startsWithQuote && endsWithQuote 56 | } 57 | -------------------------------------------------------------------------------- /utils/google-docs.js: -------------------------------------------------------------------------------- 1 | const {google} = require("googleapis") 2 | const GoogleOAuth2 = require("google-oauth2-env-vars") 3 | 4 | const {ENV_TOKEN_VAR} = require("./constants") 5 | const {GoogleDocument} = require("./google-document") 6 | const {writeDocumentToTests} = require("./write-document-to-tests") 7 | const {fetchFiles} = require("./google-drive") 8 | 9 | async function fetchDocument(id) { 10 | const googleOAuth2 = new GoogleOAuth2({ 11 | token: ENV_TOKEN_VAR, 12 | }) 13 | const auth = await googleOAuth2.getAuth() 14 | 15 | const res = await google.docs({version: "v1", auth}).documents.get({ 16 | documentId: id, 17 | }) 18 | 19 | if (!res.data) { 20 | throw new Error("Empty Data") 21 | } 22 | 23 | return res.data 24 | } 25 | 26 | /** @param {import('..').Options} options */ 27 | async function fetchDocuments({options, reporter}) { 28 | const timer = reporter.activityTimer(`source-google-docs: documents`) 29 | 30 | if (options.debug) { 31 | timer.start() 32 | timer.setStatus("fetching documents") 33 | } 34 | 35 | const documentsProperties = await fetchFiles(options) 36 | const links = documentsProperties.reduce( 37 | (acc, properties) => ({...acc, [properties.id]: properties.slug}), 38 | {} 39 | ) 40 | 41 | const googleDocuments = await Promise.all( 42 | documentsProperties.map(async (properties) => { 43 | const document = await fetchDocument(properties.id) 44 | const googleDocument = new GoogleDocument({ 45 | document, 46 | properties, 47 | options, 48 | links, 49 | }) 50 | 51 | if (process.env.NODE_ENV === "DOCS_TO_TESTS") { 52 | writeDocumentToTests(googleDocument) 53 | } 54 | 55 | return googleDocument 56 | }) 57 | ) 58 | 59 | if (process.env.NODE_ENV === "DOCS_TO_TESTS") { 60 | process.exit() 61 | } 62 | 63 | if (options.debug) { 64 | timer.setStatus(googleDocuments.length + " documents fetched") 65 | timer.end() 66 | } 67 | 68 | return googleDocuments 69 | } 70 | 71 | module.exports = { 72 | fetchDocuments, 73 | } 74 | -------------------------------------------------------------------------------- /utils/create-pages.js: -------------------------------------------------------------------------------- 1 | const {existsSync: exists} = require("fs") 2 | const {resolve} = require("path") 3 | 4 | const {DEFAULT_TEMPLATE} = require("./constants.js") 5 | 6 | const getComponentPath = (template) => 7 | template.includes(".") 8 | ? resolve(`src/templates/${template}`) 9 | : resolve(`src/templates/${template}.js`) 10 | 11 | exports.createPages = async ( 12 | {graphql, actions: {createPage}, reporter}, 13 | pluginOptions 14 | ) => { 15 | if (!pluginOptions.createPages) return 16 | 17 | const fields = pluginOptions.pageContext || [] 18 | 19 | const result = await graphql( 20 | ` 21 | { 22 | allGoogleDocs(filter: {page: {ne: false}}) { 23 | nodes { 24 | slug 25 | template 26 | ${fields.join(" ")} 27 | } 28 | } 29 | } 30 | ` 31 | ) 32 | 33 | if (result.errors) { 34 | reporter.panic(result.errors) 35 | } 36 | 37 | try { 38 | const {allGoogleDocs} = result.data 39 | const defaultComponent = exists(getComponentPath(DEFAULT_TEMPLATE)) 40 | ? getComponentPath(DEFAULT_TEMPLATE) 41 | : null 42 | 43 | if (allGoogleDocs) { 44 | allGoogleDocs.nodes.forEach(({slug, template, ...context}) => { 45 | let component = defaultComponent 46 | 47 | if (template && exists(getComponentPath(template))) { 48 | component = getComponentPath(template) 49 | } 50 | 51 | if (!component) { 52 | const defaultTemplateError = `Default template "${DEFAULT_TEMPLATE}" not found.` 53 | 54 | if (template) { 55 | throw new Error( 56 | `template "${template}" not found. ${defaultTemplateError}` 57 | ) 58 | } 59 | 60 | throw new Error( 61 | `missing template for "${slug}". ${defaultTemplateError}` 62 | ) 63 | } 64 | 65 | createPage({ 66 | path: slug, 67 | component, 68 | context, 69 | }) 70 | }) 71 | } 72 | } catch (e) { 73 | reporter.panic(`source-google-docs: ` + e.message) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gatsby-source-google-docs", 3 | "version": "2.4.3", 4 | "description": "Gatsby plugin to use Google Docs as a data source", 5 | "repository": "https://github.com/cedricdelpoux/gatsby-source-google-docs", 6 | "author": "Cédric Delpoux ", 7 | "license": "MIT", 8 | "keywords": [ 9 | "gatsby", 10 | "gatsby-plugin", 11 | "gatsby-source", 12 | "gatsby-source-plugin", 13 | "google", 14 | "google-docs", 15 | "docs", 16 | "drive" 17 | ], 18 | "main": "index.js", 19 | "files": [ 20 | "index.d.ts", 21 | "gatsby-browser.js", 22 | "gatsby-node.js", 23 | "gatsby-ssr.js", 24 | "utils" 25 | ], 26 | "dependencies": { 27 | "gatsby-source-filesystem": "5.8.0", 28 | "google-oauth2-env-vars": "^1.4.0", 29 | "googleapis": "114.0.0", 30 | "json2md": "^2.0.0", 31 | "lodash": "^4.17.21", 32 | "yamljs": "^0.3.0" 33 | }, 34 | "devDependencies": { 35 | "@commitlint/cli": "^17.5.0", 36 | "@commitlint/config-conventional": "^17.4.4", 37 | "@semantic-release/changelog": "^6.0.3", 38 | "@semantic-release/commit-analyzer": "^9.0.2", 39 | "@semantic-release/git": "^10.0.1", 40 | "@semantic-release/github": "^8.0.7", 41 | "@semantic-release/npm": "^10.0.2", 42 | "@semantic-release/release-notes-generator": "^10.0.3", 43 | "cz-conventional-changelog": "3.3.0", 44 | "eslint": "^8.36.0", 45 | "eslint-config-prettier": "^8.8.0", 46 | "eslint-plugin-jest": "^27.2.1", 47 | "eslint-plugin-react": "^7.32.2", 48 | "husky": "^8.0.3", 49 | "jest": "^29.5.0", 50 | "lint-staged": "^13.2.0", 51 | "prettier": "2.8.7", 52 | "react": "^18.2.0", 53 | "react-dom": "^18.2.0", 54 | "semantic-release": "^21.0.0" 55 | }, 56 | "bin": { 57 | "gatsby-source-google-docs-token": "./utils/generate-token.js" 58 | }, 59 | "scripts": { 60 | "cz": "git-cz", 61 | "lint": "eslint *.js utils examples/**/src", 62 | "test": "jest --coverage", 63 | "prepublishOnly": "yarn lint && yarn test", 64 | "semantic-release": "semantic-release", 65 | "prepare": "husky install" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /examples/website/src/components/menu.js: -------------------------------------------------------------------------------- 1 | import {graphql, useStaticQuery} from "gatsby" 2 | import {MDXRenderer} from "gatsby-plugin-mdx" 3 | import React from "react" 4 | /** @jsx jsx */ 5 | import {jsx} from "theme-ui" 6 | 7 | export const Menu = ({open, onClose}) => { 8 | const data = useStaticQuery(graphql` 9 | query MenuQuery { 10 | menu: googleDocs(name: {eq: "Menu"}) { 11 | childMdx { 12 | body 13 | } 14 | } 15 | } 16 | `) 17 | 18 | return ( 19 | 20 | {/* Sidebar Overlay */} 21 |
38 | {" "} 39 |
40 | {/* Sidebar */} 41 |
h5": { 55 | textAlign: "left", 56 | py: 1, 57 | px: 2, 58 | mt: 2, 59 | }, 60 | "& > p": { 61 | px: 3, 62 | }, 63 | "& > ul": { 64 | m: 0, 65 | p: 0, 66 | "& > li": { 67 | "& > a": { 68 | display: "block", 69 | py: 1, 70 | px: 3, 71 | "&:hover": { 72 | bg: "secondary", 73 | color: "white", 74 | }, 75 | }, 76 | }, 77 | }, 78 | }} 79 | > 80 | {data.menu.childMdx.body} 81 |
82 |
83 | ) 84 | } 85 | -------------------------------------------------------------------------------- /examples/website/src/gatsby-plugin-theme-ui/index.js: -------------------------------------------------------------------------------- 1 | const gradientBackground = (theme) => 2 | `linear-gradient(to right, ${theme.colors.primary}, ${theme.colors.secondary})` 3 | 4 | const theme = { 5 | space: [0, 8, 16, 32, 64], 6 | radii: [0, 10, 20], 7 | breakpoints: ["500px", "800px", "1080px"], 8 | fontSizes: [14, 16, 20, 24, 32, 64, 80], 9 | colors: { 10 | text: "#333333", 11 | background: "#FFFFFF", 12 | primary: "#6F2B9F", 13 | secondary: "#4d90fe", 14 | white: "#FFFFFF", 15 | grey: "#F3F3F3", 16 | modes: { 17 | dark: { 18 | text: "#fff", 19 | background: "#1e2334", 20 | }, 21 | }, 22 | }, 23 | buttons: { 24 | primary: { 25 | backgroundImage: gradientBackground, 26 | py: 2, 27 | px: 4, 28 | border: 0, 29 | borderRadius: 1, 30 | cursor: "pointer", 31 | outline: "none", 32 | color: "white", 33 | backgroundSize: "200% auto", 34 | transition: "0.2s", 35 | "&:hover": { 36 | backgroundPosition: "right center", 37 | }, 38 | }, 39 | }, 40 | textStyles: { 41 | heading: { 42 | margin: 0, 43 | textAlign: "center", 44 | WebkitBackgroundClip: "text", 45 | WebkitTextFillColor: "transparent;", 46 | MozBackgroundClip: "text", 47 | MozTextFillColor: "transparent", 48 | backgroundImage: gradientBackground, 49 | }, 50 | }, 51 | styles: { 52 | root: { 53 | backgroundImage: gradientBackground, 54 | padding: [2, 3], 55 | fontFamily: "Quicksand", 56 | }, 57 | a: { 58 | textDecoration: "none", 59 | color: "secondary", 60 | }, 61 | h1: { 62 | variant: "textStyles.heading", 63 | fontSize: [4, 5, 6], 64 | }, 65 | h2: { 66 | variant: "textStyles.heading", 67 | fontSize: [3, 3, 4], 68 | }, 69 | h3: { 70 | variant: "textStyles.heading", 71 | fontSize: 3, 72 | }, 73 | h4: { 74 | variant: "textStyles.heading", 75 | fontSize: 2, 76 | }, 77 | h5: { 78 | variant: "textStyles.heading", 79 | fontSize: 1, 80 | }, 81 | h6: { 82 | variant: "textStyles.heading", 83 | fontSize: 0, 84 | }, 85 | }, 86 | } 87 | 88 | export default theme 89 | -------------------------------------------------------------------------------- /utils/source-nodes.js: -------------------------------------------------------------------------------- 1 | const _merge = require("lodash/merge") 2 | 3 | const {fetchDocuments} = require("./google-docs") 4 | const {DEFAULT_OPTIONS} = require("./constants") 5 | const {updateImages} = require("./update-images") 6 | 7 | exports.sourceNodes = async ( 8 | { 9 | actions: {createNode}, 10 | createContentDigest, 11 | reporter, 12 | store, 13 | cache, 14 | createNodeId, 15 | }, 16 | pluginOptions 17 | ) => { 18 | const options = _merge({}, DEFAULT_OPTIONS, pluginOptions) 19 | 20 | if (!options.folder) { 21 | if (options.folders && options.folders.length > 0) { 22 | reporter.warn( 23 | `source-google-docs: "folders" option will be deprecated in the next version, please use "folder" option instead` 24 | ) 25 | Object.assign(options, { 26 | folder: options.folders[0], 27 | }) 28 | } else { 29 | reporter.warn(`source-google-docs: Missing "folder" option`) 30 | return 31 | } 32 | } 33 | 34 | try { 35 | const timer = reporter.activityTimer(`source-google-docs`) 36 | timer.start() 37 | timer.setStatus("fetching Google Docs documents") 38 | 39 | const googleDocuments = await fetchDocuments({options, reporter}) 40 | let imagesCount = 0 41 | 42 | for (let googleDocument of googleDocuments) { 43 | const {document, properties, cover, related} = googleDocument 44 | const markdown = googleDocument.toMarkdown() 45 | 46 | const node = { 47 | ...properties, 48 | document, 49 | cover, 50 | markdown, 51 | related, 52 | } 53 | 54 | timer.setStatus(`fetching "${node.name}" images`) 55 | 56 | const documentImagesCount = await updateImages({ 57 | node, 58 | createNode, 59 | store, 60 | cache, 61 | createNodeId, 62 | reporter, 63 | options, 64 | }) 65 | 66 | imagesCount += documentImagesCount 67 | 68 | createNode({ 69 | ...node, 70 | internal: { 71 | type: "GoogleDocs", 72 | mediaType: "text/markdown", 73 | content: node.markdown, 74 | contentDigest: createContentDigest(node.markdown), 75 | }, 76 | dir: process.cwd(), // To make gatsby-remark-images works 77 | }) 78 | } 79 | 80 | timer.setStatus( 81 | `${googleDocuments.length} documents and ${imagesCount} images fetched` 82 | ) 83 | 84 | timer.end() 85 | 86 | return 87 | } catch (e) { 88 | if (options.debug) { 89 | reporter.panic("source-google-docs: ", e) 90 | } else { 91 | reporter.panic(`source-google-docs: ${e.message}`) 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /__tests__/options.js: -------------------------------------------------------------------------------- 1 | const documentLinks = require("./documents/links.json") 2 | const documentTexts = require("./documents/texts.json") 3 | const documentImages = require("./documents/images.json") 4 | const documentFootnotes = require("./documents/footnotes.json") 5 | const documentLists = require("./documents/lists.json") 6 | const documentQuotes = require("./documents/quotes.json") 7 | const documentCodes = require("./documents/codes.json") 8 | const documentTables = require("./documents/tables.json") 9 | const {GoogleDocument} = require("../utils/google-document") 10 | 11 | test(`"KeepDefaultStyle" option`, () => { 12 | const options = {keepDefaultStyle: true} 13 | const googleDocument = new GoogleDocument({document: documentTexts, options}) 14 | expect(googleDocument.toMarkdown()).toMatchSnapshot() 15 | }) 16 | 17 | test(`"DemoteHeading" option enabled`, () => { 18 | const options = {demoteHeadings: true} 19 | const googleDocument = new GoogleDocument({document: documentTexts, options}) 20 | expect(googleDocument.toMarkdown()).toMatchSnapshot() 21 | }) 22 | 23 | test(`"DemoteHeading" option disabled`, () => { 24 | const options = {demoteHeadings: false} 25 | const googleDocument = new GoogleDocument({document: documentTexts, options}) 26 | expect(googleDocument.toMarkdown()).toMatchSnapshot() 27 | }) 28 | 29 | test(`Crosslinks between documents`, () => { 30 | const links = { 31 | [documentLinks.documentId]: "/relative-path", 32 | ["unknow"]: "/404", 33 | } 34 | 35 | const googleDocument = new GoogleDocument({ 36 | document: documentLinks, 37 | links, 38 | }) 39 | const documentObject = googleDocument.toMarkdown() 40 | expect(documentObject).toMatchSnapshot() 41 | }) 42 | 43 | test(`Skip headings`, () => { 44 | const options = { 45 | skipHeadings: true, 46 | } 47 | const googleDocument = new GoogleDocument({document: documentTexts, options}) 48 | const documentObject = googleDocument.toMarkdown() 49 | expect(documentObject).toMatchSnapshot() 50 | }) 51 | 52 | test(`Skip images`, () => { 53 | const options = { 54 | skipImages: true, 55 | } 56 | const googleDocument = new GoogleDocument({document: documentImages, options}) 57 | const documentObject = googleDocument.toMarkdown() 58 | expect(documentObject).toMatchSnapshot() 59 | }) 60 | 61 | test(`Skip footnotes`, () => { 62 | const options = { 63 | skipFootnotes: true, 64 | } 65 | const googleDocument = new GoogleDocument({ 66 | document: documentFootnotes, 67 | options, 68 | }) 69 | const documentObject = googleDocument.toMarkdown() 70 | expect(documentObject).toMatchSnapshot() 71 | }) 72 | 73 | test(`Skip lists`, () => { 74 | const options = { 75 | skipLists: true, 76 | } 77 | const googleDocument = new GoogleDocument({document: documentLists, options}) 78 | const documentObject = googleDocument.toMarkdown() 79 | expect(documentObject).toMatchSnapshot() 80 | }) 81 | 82 | test(`Skip quotes`, () => { 83 | const options = { 84 | skipQuotes: true, 85 | } 86 | const googleDocument = new GoogleDocument({document: documentQuotes, options}) 87 | const documentObject = googleDocument.toMarkdown() 88 | expect(documentObject).toMatchSnapshot() 89 | }) 90 | 91 | test(`Skip codes`, () => { 92 | const options = { 93 | skipCodes: true, 94 | } 95 | const googleDocument = new GoogleDocument({document: documentCodes, options}) 96 | const documentObject = googleDocument.toMarkdown() 97 | expect(documentObject).toMatchSnapshot() 98 | }) 99 | 100 | test(`Skip tables`, () => { 101 | const options = { 102 | skipTables: true, 103 | } 104 | const googleDocument = new GoogleDocument({document: documentTables, options}) 105 | const documentObject = googleDocument.toMarkdown() 106 | expect(documentObject).toMatchSnapshot() 107 | }) 108 | -------------------------------------------------------------------------------- /utils/update-images.js: -------------------------------------------------------------------------------- 1 | const _get = require("lodash/get") 2 | const _kebabCase = require("lodash/kebabCase") 3 | const {createRemoteFileNode} = require("gatsby-source-filesystem") 4 | 5 | const {getImageUrlParameters} = require("./get-image-url-parameters") 6 | 7 | const IMAGE_URL_REGEX = 8 | /https:\/\/[a-z0-9-]*.googleusercontent\.com\/(?:docs\/)?[a-zA-Z0-9_=-]*/ 9 | const MD_URL_TITLE_REGEX = new RegExp( 10 | `(${IMAGE_URL_REGEX.source}) "([^)]*)"`, 11 | "g" 12 | ) 13 | 14 | exports.updateImages = async ({ 15 | node, 16 | createNode, 17 | store, 18 | cache, 19 | createNodeId, 20 | reporter, 21 | options, 22 | }) => { 23 | if (_get(options, "skipImages") === true) return 24 | 25 | const hasCover = node.cover && IMAGE_URL_REGEX.test(node.cover.image) 26 | const imageUrlParams = getImageUrlParameters(options) 27 | const googleImagesIterator = node.markdown.matchAll(MD_URL_TITLE_REGEX) 28 | const googleImages = [...googleImagesIterator] 29 | const googleImagesCount = hasCover 30 | ? googleImages.length + 1 31 | : googleImages.length 32 | 33 | if (googleImagesCount === 0) { 34 | return 0 35 | } 36 | 37 | const timer = reporter.activityTimer( 38 | `source-google-docs: "${node.name}" document` 39 | ) 40 | 41 | if (options.debug) { 42 | timer.start() 43 | } 44 | 45 | let imagesFetchedCount = 0 46 | 47 | if (options.debug) { 48 | timer.setStatus(`${imagesFetchedCount}/${googleImagesCount} images fetched`) 49 | } 50 | 51 | if (hasCover) { 52 | let fileNode 53 | try { 54 | const {image, title} = node.cover 55 | const url = image + imageUrlParams 56 | 57 | fileNode = await createRemoteFileNode({ 58 | url, 59 | parentNodeId: node.id, 60 | createNode, 61 | createNodeId, 62 | cache, 63 | store, 64 | name: title ? _kebabCase(title) : _kebabCase(node.name) + "-" + 0, 65 | reporter, 66 | }) 67 | 68 | imagesFetchedCount++ 69 | 70 | if (options.debug) { 71 | timer.setStatus( 72 | `${imagesFetchedCount}/${googleImagesCount} images fetched` 73 | ) 74 | } 75 | } catch (e) { 76 | reporter.warn(`source-google-docs: ${e}`) 77 | } 78 | 79 | if (fileNode) { 80 | node.cover.image = fileNode.id 81 | } 82 | } 83 | 84 | if (Array.isArray(googleImages)) { 85 | const filesNodes = await Promise.all( 86 | googleImages.map(async (image, i) => { 87 | const [, url, title] = image 88 | let fileNode 89 | try { 90 | fileNode = await createRemoteFileNode({ 91 | url: url + imageUrlParams, 92 | parentNodeId: node.id, 93 | createNode, 94 | createNodeId, 95 | cache, 96 | store, 97 | name: title 98 | ? _kebabCase(title) 99 | : _kebabCase(node.name) + "-" + (i + 1), 100 | reporter, 101 | }) 102 | 103 | imagesFetchedCount++ 104 | 105 | if (options.debug) { 106 | timer.setStatus( 107 | `${imagesFetchedCount}/${googleImagesCount} images fetched` 108 | ) 109 | } 110 | } catch (e) { 111 | reporter.warn(`source-google-docs: ${e}`) 112 | } 113 | 114 | return fileNode 115 | }) 116 | ) 117 | 118 | filesNodes 119 | .filter((fileNode) => fileNode) 120 | .forEach((fileNode) => { 121 | const imageUrl = fileNode.url.replace(imageUrlParams, "") 122 | node.markdown = node.markdown.replace( 123 | new RegExp(imageUrl, "g"), 124 | fileNode.relativePath 125 | ) 126 | }) 127 | 128 | const imagesIds = filesNodes 129 | .filter((fileNode) => fileNode) 130 | .map((fileNode) => fileNode.id) 131 | 132 | node.images = imagesIds 133 | 134 | if (options.debug) { 135 | timer.end() 136 | } 137 | } 138 | 139 | return imagesFetchedCount 140 | } 141 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 2.2.0 4 | 5 | - Added 6 | 7 | - Gatsby v4 support 8 | 9 | - Updated 10 | 11 | - `createRemoteFileNode` is now called from `sourceNodes` instead of `onCreatedNode` 12 | - images are named using the convention "[DOCUMENT_NAME]-[IMAGE_INDEX]" if no title found 13 | 14 | - Removed 15 | - depracated MarkdownNodes cover support 16 | 17 | ## 2.1.0 18 | 19 | - Added 20 | - Use images titles for files names 21 | - Handle soft lines breaks 22 | 23 | ## 2.0.0 24 | 25 | - Added 26 | 27 | - Features: 28 | 29 | - Gatsby v3 support 30 | - Gatsby Cloud support 31 | - MDX support 32 | - Script `gatsby-source-google-docs-token` for token generation 33 | - Crosslinks between documents 34 | - Related content 35 | - Metadata from YAML Google Drive descriptions 36 | 37 | - Formats 38 | 39 | - Underline support 40 | - Blockquote support 41 | - Superscript support 42 | - Subscript support 43 | - Code Blocks support 44 | - Inline Code support 45 | - Footnotes support 46 | - Font size support 47 | - Foreground color support 48 | - Background color support 49 | - Horizontal tabulations 50 | - Vertical tabulations 51 | 52 | - Options: 53 | 54 | - `createPages` 55 | - `pageContext` 56 | - `demoteHeadings` 57 | - `folder` 58 | - `keepDefaultStyle` 59 | - `imagesOptions` 60 | - `skipCodes` 61 | - `skipFootnotes` 62 | - `skipHeadings` 63 | - `skipImages` 64 | - `skipLists` 65 | - `skipQuotes` 66 | - `skipTables` 67 | 68 | - Updated 69 | 70 | - Breadcrumb is now an array of `{name, path}` 71 | 72 | - Deleted 73 | - Options: 74 | - `config` 75 | - `fieldsMapper` 76 | - `fieldsDefault` 77 | - `folders` 78 | - `updateMetadata` 79 | 80 | ## 1.14.0 81 | 82 | - Added: tables styling by @justinsunho 83 | 84 | ## 1.13.0 85 | 86 | - Added: `token` option 87 | 88 | ## 1.12.0 89 | 90 | - Added: `timeBetweenCalls` option 91 | - Added: `debug` option 92 | 93 | ## 1.11.0 94 | 95 | - Added: Transform subtitles to blockquotes 96 | - Fixed: Remove unwanted spaces before punctuation 97 | 98 | ## 1.10.0 99 | 100 | - Added: Allow for google doc token env variable by @justinsunho 101 | 102 | ## 1.9.0 103 | 104 | - Added: `convertImgToNode` config option by @victsant 105 | 106 | ## 1.8.0 107 | 108 | - Added: Enable team drives by @victsant 109 | 110 | ## 1.7.0 111 | 112 | - Added: `fieldsDefault` option 113 | - Updated: Improve Google drive API calls number 114 | - Updated: Dependencies 115 | 116 | ## 1.6.1 117 | 118 | - Removed: Automatic `slug` field generation 119 | 120 | ## 1.6.0 121 | 122 | - Added: Support for Google Drive trees 123 | - Added: `path` frontmatter with Google Drive tree 124 | - Added: `slug` field from custom slug or Google Drive path 125 | - Updated: files structure 126 | 127 | ## 1.5.0 128 | 129 | - Added: Support for images titles by @dmouse 130 | - Fixed: Table headers by @dmouse 131 | - Updated: Jest Snapshot 132 | 133 | ## 1.4.0 134 | 135 | - Added: Add support for font styles bold, italic, strikethrough by @KyleAMathews 136 | 137 | ## 1.3.0 138 | 139 | - Added: Snapshot test by @KyleAMathews 140 | 141 | ## 1.2.0 142 | 143 | - Added: Support for documents extra data using `Google Drive` description field 144 | 145 | ## 1.1.0 146 | 147 | - Added: Support for nested lists by @horaklukas 148 | - Added: Support for ordered lists by @horaklukas 149 | - Added: Support for inlined hypertext links by @horaklukas 150 | - Fixed: Putting list items into the list they belong to by @horaklukas 151 | - Fixed: Splitting one line headings or texts into more lines by @horaklukas 152 | 153 | ## 1.0.1 154 | 155 | - Fixed: Ensure `fields` config is optional by @davidhartsough 156 | 157 | ## 1.0.0 158 | 159 | - Added: `foldersIds` option 160 | - Added: `fields` option 161 | - Added: `fieldsMapper` option 162 | - Removed: `documents` option 163 | - Updated: Default permissions to read document from Google Drive folders 164 | 165 | ## 0.1.0 166 | 167 | - Added: Headings support 168 | - Added: Texts support 169 | - Added: Images support 170 | - Added: Lists support 171 | -------------------------------------------------------------------------------- /examples/website/src/layouts/index.js: -------------------------------------------------------------------------------- 1 | import {MDXProvider} from "@mdx-js/react" 2 | import {useLocation} from "@reach/router" 3 | import {Link} from "gatsby" 4 | import {useEffect, useState} from "react" 5 | import {RiMenuLine, RiMoonLine, RiSunLine} from "react-icons/ri" 6 | /** @jsx jsx */ 7 | import {Button, Themed, jsx, useColorMode} from "theme-ui" 8 | 9 | import {Details} from "../components/details" 10 | import {GatsbyLogo} from "../components/gatsby-logo" 11 | import {Masonry} from "../components/masonry" 12 | import {Menu} from "../components/menu" 13 | 14 | const LayoutIndex = ({children}) => { 15 | const [isMenuOpen, setIsMenuOpen] = useState(false) 16 | const [colorMode, setColorMode] = useColorMode() 17 | const location = useLocation() 18 | 19 | useEffect(() => { 20 | setIsMenuOpen(false) 21 | }, [location]) 22 | 23 | return ( 24 |
34 |
44 |
setIsMenuOpen(!isMenuOpen)} 52 | onKeyDown={() => setIsMenuOpen(!isMenuOpen)} 53 | role="button" 54 | tabIndex="0" 55 | > 56 | 57 |
58 | 67 | {"gatsby-source-google-docs"} 68 | 69 |
{ 77 | setColorMode(colorMode === "default" ? "dark" : "default") 78 | }} 79 | > 80 | {colorMode === "default" ? : } 81 |
82 |
83 |
93 | setIsMenuOpen(false)} /> 94 |
p": { 97 | mb: 0, 98 | }, 99 | "& > * + *:not(h1):not(h2):not(h3)": { 100 | mt: 2, 101 | }, 102 | "& > * + h1, & > * + h2, & > * + h3": { 103 | mt: 3, 104 | }, 105 | "& > table": { 106 | width: "100%", 107 | border: "1px solid", 108 | borderColor: "grey", 109 | "& td, & th": { 110 | p: 1, 111 | border: "1px solid", 112 | borderColor: "grey", 113 | }, 114 | }, 115 | "& a": { 116 | color: "secondary", 117 | "&:hover": { 118 | textDecoration: "underline", 119 | }, 120 | }, 121 | }} 122 | > 123 | 126 | {children} 127 | 128 |
129 | {location.pathname !== "/" && ( 130 | 131 | 132 | 133 | )} 134 |
135 |
143 |
144 | Made by{" "} 145 | Cédric Delpoux 146 |
147 |
148 | Source code available on{" "} 149 | 150 | Github 151 | 152 |
153 |
154 |
155 | ) 156 | } 157 | 158 | export default LayoutIndex 159 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/converter-markdown.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Document "Breaks" to Markdown 1`] = `""`; 4 | 5 | exports[`Document "Codes" to Markdown 1`] = ` 6 | " 7 | Type \`gatsby-source-google-docs-token\` to generate a token 8 | 9 | \`\`\`js 10 | 11 | const foo = "bar" 12 | 13 | module.exports = { 14 | plugins: [ 15 | "gatsby-source-google-docs", 16 | ], 17 | } 18 | \`\`\` 19 | 20 | \`\`\` 21 | module.exports = { 22 | plugins: [ 23 | "gatsby-source-google-docs", 24 | ], 25 | } 26 | \`\`\` 27 | " 28 | `; 29 | 30 | exports[`Document "Cover" to Markdown 1`] = ` 31 | "--- 32 | cover: 33 | image: 'https://lh3.googleusercontent.com/nDkE8IlmeehGxZUwGXvxn_dMEnZGZBFAp0i3p_07UC9Lj_V_wfNCBh3FbcHiknyR_aNXue63CmGPi9dTykd9gqobAu88AgHq1af0ag81US_ykXwb-gm04enjY_feYOfPTD3Biq1fk03IKvZ8RA' 34 | title: 'Cover title' 35 | alt: 'Cover description' 36 | --- 37 | " 38 | `; 39 | 40 | exports[`Document "Empty" to Markdown 1`] = `""`; 41 | 42 | exports[`Document "Footnotes" to Markdown 1`] = ` 43 | " 44 | Text with a footnote[^1] and an other one[^2] 45 | 46 | [^1]: Footnote 1 description 47 | 48 | [^2]: Footnote 2 description 49 | " 50 | `; 51 | 52 | exports[`Document "French" to Markdown 1`] = ` 53 | " 54 | Je m'appelle **Cédric**, j'habite à Toulouse _(France)_ 🇫🇷 55 | " 56 | `; 57 | 58 | exports[`Document "Horizontal rule" to Markdown 1`] = ` 59 | " 60 | Text followed by horizontal rule 61 | 62 | 63 |
64 | 65 | 66 | and some other text 67 | " 68 | `; 69 | 70 | exports[`Document "Images" to Markdown 1`] = ` 71 | "![](https://lh6.googleusercontent.com/pZBSuNhNbnEaBQZSfpSZPp8k2Obc7TSMFrhUx5pvcMP9v3EkBnn7EQsnKftFH6HaFWKDZR_zUlusaPQ6DULiSTIsUHd1PW5hdmuNaF1cVifWwB3reUL2spqfO7qyWGjT6x4J1MkwtmDKayvKgw "") 72 | " 73 | `; 74 | 75 | exports[`Document "Links" to Markdown 1`] = ` 76 | " 77 | [to self](https://docs.google.com/document/d/1UuBxtIEEVh98wyBR9fMmLqzJEkmNlAMMoC4SEhprHfQ) 78 | 79 | 80 | [to self with user id](https://docs.google.com/document/u/1/d/1UuBxtIEEVh98wyBR9fMmLqzJEkmNlAMMoC4SEhprHfQ) 81 | 82 | 83 | [to self with edit](https://docs.google.com/document/d/1UuBxtIEEVh98wyBR9fMmLqzJEkmNlAMMoC4SEhprHfQ/edit) 84 | 85 | 86 | [to self with preview](https://docs.google.com/document/d/1UuBxtIEEVh98wyBR9fMmLqzJEkmNlAMMoC4SEhprHfQ/preview) 87 | 88 | 89 | [unknown url](https://docs.google.com/document/d/unknown) 90 | " 91 | `; 92 | 93 | exports[`Document "Lists" to Markdown 1`] = ` 94 | " 95 | - List **item** 1 96 | 1. Sublist item 1 97 | 2. Sublist item 2 98 | 3. Sublist item 3 99 | 100 | - List item 2 101 | - List item 3 102 | 1. Sub list item 1 103 | 1. Sub sub list item 1 104 | 2. Sub sub list item 2 with ![](https://lh4.googleusercontent.com/IqgS1qr6oFq7jaAsQbCkfJsuwBg5DnKfnjTuB49VbHQr0ZN8T1UjgRDoiQzPws-hWgJcRDyujobZmOEF7BAHwceM6kTrXC_2dj-E38kNxmz8EW59q1ZvsM0C9cUxytASTReIN13NA8fcFA8zIA "")image 105 | 106 | 2. Sub list item 2 107 | 3. Sub list item 3 108 | 109 | - List item 4 110 | - List item 5 111 | " 112 | `; 113 | 114 | exports[`Document "Poem" to Markdown 1`] = ` 115 | " 116 | The poem 117 | 118 | 119 | may have 120 | 121 | 122 | strange whitespace 123 | 124 | 125 | patterns. 126 | " 127 | `; 128 | 129 | exports[`Document "Quotes" to Markdown 1`] = ` 130 | "> "The way to get started is to quit talking and begin doing." 131 | " 132 | `; 133 | 134 | exports[`Document "Special characters" to Markdown 1`] = ` 135 | " 136 | 19980006564 - 0081 137 | " 138 | `; 139 | 140 | exports[`Document "Tables" to Markdown 1`] = ` 141 | "| Col 1 | Col 2 | Col 3 | 142 | | ----- | ----- | ----- | 143 | | Col 1 line 1 | Col 2 line 1 | Col 3 line 1 | 144 | | Col 1 line 2 | Col 2 line 2 | Col 3 line 2 | 145 | " 146 | `; 147 | 148 | exports[`Document "Texts" to Markdown 1`] = ` 149 | "## Title level 1 150 | 151 | ### Title level 2 152 | 153 | #### Title level 3 154 | 155 | ##### Title level 4 156 | 157 | ###### Title level 5 158 | 159 | ###### Title level 6 160 | 161 | ## Title 162 | 163 | ### Subtitle 164 | 165 | 166 | **bold** 167 | 168 | 169 | _italic_ 170 | 171 | 172 | underline 173 | 174 | 175 | ~~strikethrough~~ 176 | 177 | 178 | superscript 179 | 180 | 181 | subscript 182 | 183 | 184 | **_boldItalic_** 185 | 186 | 187 | text with **space bold after** and _space italic before_. 188 | 189 | 190 | indented text 191 | 192 | 193 | vertical
indent 194 | 195 | 196 | [Link](https://github.com/cedricdelpoux/gatsby-source-google-docs) 197 | 198 | 199 | Text bigger 200 | 201 | 202 | Text Coloredredgreenblue 203 | 204 | 205 | Text with background colorredgreenblue 206 | " 207 | `; 208 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/options.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`"DemoteHeading" option disabled 1`] = ` 4 | "# Title level 1 5 | 6 | ## Title level 2 7 | 8 | ### Title level 3 9 | 10 | #### Title level 4 11 | 12 | ##### Title level 5 13 | 14 | ###### Title level 6 15 | 16 | # Title 17 | 18 | ## Subtitle 19 | 20 | 21 | **bold** 22 | 23 | 24 | _italic_ 25 | 26 | 27 | underline 28 | 29 | 30 | ~~strikethrough~~ 31 | 32 | 33 | superscript 34 | 35 | 36 | subscript 37 | 38 | 39 | **_boldItalic_** 40 | 41 | 42 | text with **space bold after** and _space italic before_. 43 | 44 | 45 | indented text 46 | 47 | 48 | vertical
indent 49 | 50 | 51 | [Link](https://github.com/cedricdelpoux/gatsby-source-google-docs) 52 | 53 | 54 | Text bigger 55 | 56 | 57 | Text Coloredredgreenblue 58 | 59 | 60 | Text with background colorredgreenblue 61 | " 62 | `; 63 | 64 | exports[`"DemoteHeading" option enabled 1`] = ` 65 | "## Title level 1 66 | 67 | ### Title level 2 68 | 69 | #### Title level 3 70 | 71 | ##### Title level 4 72 | 73 | ###### Title level 5 74 | 75 | ###### Title level 6 76 | 77 | ## Title 78 | 79 | ### Subtitle 80 | 81 | 82 | **bold** 83 | 84 | 85 | _italic_ 86 | 87 | 88 | underline 89 | 90 | 91 | ~~strikethrough~~ 92 | 93 | 94 | superscript 95 | 96 | 97 | subscript 98 | 99 | 100 | **_boldItalic_** 101 | 102 | 103 | text with **space bold after** and _space italic before_. 104 | 105 | 106 | indented text 107 | 108 | 109 | vertical
indent 110 | 111 | 112 | [Link](https://github.com/cedricdelpoux/gatsby-source-google-docs) 113 | 114 | 115 | Text bigger 116 | 117 | 118 | Text Coloredredgreenblue 119 | 120 | 121 | Text with background colorredgreenblue 122 | " 123 | `; 124 | 125 | exports[`"KeepDefaultStyle" option 1`] = ` 126 | "## **Title level 1** 127 | 128 | ### **Title level 2** 129 | 130 | #### **Title level 3** 131 | 132 | ##### Title level 4 133 | 134 | ###### Title level 5 135 | 136 | ###### _Title level 6_ 137 | 138 | ## Title 139 | 140 | ### _Subtitle_ 141 | 142 | 143 | **bold** 144 | 145 | 146 | _italic_ 147 | 148 | 149 | underline 150 | 151 | 152 | ~~strikethrough~~ 153 | 154 | 155 | superscript 156 | 157 | 158 | subscript 159 | 160 | 161 | **_boldItalic_** 162 | 163 | 164 | text with **space bold after** and _space italic before_. 165 | 166 | 167 | indented text 168 | 169 | 170 | vertical
indent 171 | 172 | 173 | [Link](https://github.com/cedricdelpoux/gatsby-source-google-docs) 174 | 175 | 176 | Text bigger 177 | 178 | 179 | Text Coloredredgreenblue 180 | 181 | 182 | Text with background colorredgreenblue 183 | " 184 | `; 185 | 186 | exports[`Crosslinks between documents 1`] = ` 187 | " 188 | [to self](/relative-path) 189 | 190 | 191 | [to self with user id](/relative-path) 192 | 193 | 194 | [to self with edit](/relative-path) 195 | 196 | 197 | [to self with preview](/relative-path) 198 | 199 | 200 | [unknown url](https://docs.google.com/document/d/unknown) 201 | " 202 | `; 203 | 204 | exports[`Skip codes 1`] = ` 205 | " 206 | Type gatsby-source-google-docs-token to generate a token 207 | " 208 | `; 209 | 210 | exports[`Skip footnotes 1`] = ` 211 | " 212 | Text with a footnote and an other one 213 | " 214 | `; 215 | 216 | exports[`Skip headings 1`] = ` 217 | " 218 | **bold** 219 | 220 | 221 | _italic_ 222 | 223 | 224 | underline 225 | 226 | 227 | ~~strikethrough~~ 228 | 229 | 230 | superscript 231 | 232 | 233 | subscript 234 | 235 | 236 | **_boldItalic_** 237 | 238 | 239 | text with **space bold after** and _space italic before_. 240 | 241 | 242 | indented text 243 | 244 | 245 | vertical
indent 246 | 247 | 248 | [Link](https://github.com/cedricdelpoux/gatsby-source-google-docs) 249 | 250 | 251 | Text bigger 252 | 253 | 254 | Text Coloredredgreenblue 255 | 256 | 257 | Text with background colorredgreenblue 258 | " 259 | `; 260 | 261 | exports[`Skip images 1`] = `""`; 262 | 263 | exports[`Skip lists 1`] = `""`; 264 | 265 | exports[`Skip quotes 1`] = `""`; 266 | 267 | exports[`Skip tables 1`] = `""`; 268 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/converter-object.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Document "Breaks" to Object 1`] = ` 4 | { 5 | "cover": null, 6 | "elements": [], 7 | } 8 | `; 9 | 10 | exports[`Document "Codes" to Object 1`] = ` 11 | { 12 | "cover": null, 13 | "elements": [ 14 | { 15 | "type": "p", 16 | "value": "Type \`gatsby-source-google-docs-token\` to generate a token", 17 | }, 18 | { 19 | "type": "code", 20 | "value": { 21 | "content": [ 22 | "", 23 | "const foo = "bar"", 24 | "", 25 | "module.exports = {", 26 | " plugins: [", 27 | " "gatsby-source-google-docs",", 28 | " ],", 29 | "}", 30 | ], 31 | "language": "js", 32 | }, 33 | }, 34 | { 35 | "type": "code", 36 | "value": { 37 | "content": [ 38 | "module.exports = {", 39 | " plugins: [", 40 | " "gatsby-source-google-docs",", 41 | " ],", 42 | "}", 43 | ], 44 | "language": null, 45 | }, 46 | }, 47 | ], 48 | } 49 | `; 50 | 51 | exports[`Document "Cover" to Object 1`] = ` 52 | { 53 | "cover": { 54 | "alt": "Cover description", 55 | "image": "https://lh3.googleusercontent.com/nDkE8IlmeehGxZUwGXvxn_dMEnZGZBFAp0i3p_07UC9Lj_V_wfNCBh3FbcHiknyR_aNXue63CmGPi9dTykd9gqobAu88AgHq1af0ag81US_ykXwb-gm04enjY_feYOfPTD3Biq1fk03IKvZ8RA", 56 | "title": "Cover title", 57 | }, 58 | "elements": [], 59 | } 60 | `; 61 | 62 | exports[`Document "Empty" to Object 1`] = ` 63 | { 64 | "cover": null, 65 | "elements": [], 66 | } 67 | `; 68 | 69 | exports[`Document "Footnotes" to Object 1`] = ` 70 | { 71 | "cover": null, 72 | "elements": [ 73 | { 74 | "type": "p", 75 | "value": "Text with a footnote[^1] and an other one[^2]", 76 | }, 77 | { 78 | "type": "footnote", 79 | "value": { 80 | "number": "1", 81 | "text": " Footnote 1 description", 82 | }, 83 | }, 84 | { 85 | "type": "footnote", 86 | "value": { 87 | "number": "2", 88 | "text": " Footnote 2 description", 89 | }, 90 | }, 91 | ], 92 | } 93 | `; 94 | 95 | exports[`Document "French" to Object 1`] = ` 96 | { 97 | "cover": null, 98 | "elements": [ 99 | { 100 | "type": "p", 101 | "value": "Je m'appelle **Cédric**, j'habite à Toulouse _(France)_ 🇫🇷", 102 | }, 103 | ], 104 | } 105 | `; 106 | 107 | exports[`Document "Horizontal rule" to Object 1`] = ` 108 | { 109 | "cover": null, 110 | "elements": [ 111 | { 112 | "type": "p", 113 | "value": "Text followed by horizontal rule ", 114 | }, 115 | { 116 | "type": "p", 117 | "value": "
", 118 | }, 119 | { 120 | "type": "p", 121 | "value": "and some other text", 122 | }, 123 | ], 124 | } 125 | `; 126 | 127 | exports[`Document "Images" to Object 1`] = ` 128 | { 129 | "cover": null, 130 | "elements": [ 131 | { 132 | "type": "img", 133 | "value": { 134 | "alt": "", 135 | "source": "https://lh6.googleusercontent.com/pZBSuNhNbnEaBQZSfpSZPp8k2Obc7TSMFrhUx5pvcMP9v3EkBnn7EQsnKftFH6HaFWKDZR_zUlusaPQ6DULiSTIsUHd1PW5hdmuNaF1cVifWwB3reUL2spqfO7qyWGjT6x4J1MkwtmDKayvKgw", 136 | "title": "", 137 | }, 138 | }, 139 | ], 140 | } 141 | `; 142 | 143 | exports[`Document "Links" to Object 1`] = ` 144 | { 145 | "cover": null, 146 | "elements": [ 147 | { 148 | "type": "p", 149 | "value": "[to self](https://docs.google.com/document/d/1UuBxtIEEVh98wyBR9fMmLqzJEkmNlAMMoC4SEhprHfQ)", 150 | }, 151 | { 152 | "type": "p", 153 | "value": "[to self with user id](https://docs.google.com/document/u/1/d/1UuBxtIEEVh98wyBR9fMmLqzJEkmNlAMMoC4SEhprHfQ)", 154 | }, 155 | { 156 | "type": "p", 157 | "value": "[to self with edit](https://docs.google.com/document/d/1UuBxtIEEVh98wyBR9fMmLqzJEkmNlAMMoC4SEhprHfQ/edit)", 158 | }, 159 | { 160 | "type": "p", 161 | "value": "[to self with preview](https://docs.google.com/document/d/1UuBxtIEEVh98wyBR9fMmLqzJEkmNlAMMoC4SEhprHfQ/preview)", 162 | }, 163 | { 164 | "type": "p", 165 | "value": "[unknown url](https://docs.google.com/document/d/unknown)", 166 | }, 167 | ], 168 | } 169 | `; 170 | 171 | exports[`Document "Lists" to Object 1`] = ` 172 | { 173 | "cover": null, 174 | "elements": [ 175 | { 176 | "type": "ul", 177 | "value": [ 178 | "List **item** 1", 179 | { 180 | "type": "ol", 181 | "value": [ 182 | "Sublist item 1", 183 | "Sublist item 2", 184 | "Sublist item 3", 185 | ], 186 | }, 187 | "List item 2", 188 | "List item 3", 189 | { 190 | "type": "ol", 191 | "value": [ 192 | "Sub list item 1", 193 | { 194 | "type": "ol", 195 | "value": [ 196 | "Sub sub list item 1", 197 | "Sub sub list item 2 with ![](https://lh4.googleusercontent.com/IqgS1qr6oFq7jaAsQbCkfJsuwBg5DnKfnjTuB49VbHQr0ZN8T1UjgRDoiQzPws-hWgJcRDyujobZmOEF7BAHwceM6kTrXC_2dj-E38kNxmz8EW59q1ZvsM0C9cUxytASTReIN13NA8fcFA8zIA "")image", 198 | ], 199 | }, 200 | "Sub list item 2", 201 | "Sub list item 3", 202 | ], 203 | }, 204 | "List item 4", 205 | "List item 5", 206 | ], 207 | }, 208 | ], 209 | } 210 | `; 211 | 212 | exports[`Document "Poem" to Object 1`] = ` 213 | { 214 | "cover": null, 215 | "elements": [ 216 | { 217 | "type": "p", 218 | "value": "The poem", 219 | }, 220 | { 221 | "type": "p", 222 | "value": " may have", 223 | }, 224 | { 225 | "type": "p", 226 | "value": " strange whitespace", 227 | }, 228 | { 229 | "type": "p", 230 | "value": "patterns.", 231 | }, 232 | ], 233 | } 234 | `; 235 | 236 | exports[`Document "Quotes" to Object 1`] = ` 237 | { 238 | "cover": null, 239 | "elements": [ 240 | { 241 | "type": "blockquote", 242 | "value": ""The way to get started is to quit talking and begin doing."", 243 | }, 244 | ], 245 | } 246 | `; 247 | 248 | exports[`Document "Special characters" to Object 1`] = ` 249 | { 250 | "cover": null, 251 | "elements": [ 252 | { 253 | "type": "p", 254 | "value": "19980006564 - 0081", 255 | }, 256 | ], 257 | } 258 | `; 259 | 260 | exports[`Document "Tables" to Object 1`] = ` 261 | { 262 | "cover": null, 263 | "elements": [ 264 | { 265 | "type": "table", 266 | "value": { 267 | "headers": [ 268 | "Col 1", 269 | "Col 2", 270 | "Col 3", 271 | ], 272 | "rows": [ 273 | [ 274 | "Col 1 line 1", 275 | "Col 2 line 1", 276 | "Col 3 line 1", 277 | ], 278 | [ 279 | "Col 1 line 2", 280 | "Col 2 line 2", 281 | "Col 3 line 2", 282 | ], 283 | ], 284 | }, 285 | }, 286 | ], 287 | } 288 | `; 289 | 290 | exports[`Document "Texts" to Object 1`] = ` 291 | { 292 | "cover": null, 293 | "elements": [ 294 | { 295 | "type": "h2", 296 | "value": "Title level 1", 297 | }, 298 | { 299 | "type": "h3", 300 | "value": "Title level 2", 301 | }, 302 | { 303 | "type": "h4", 304 | "value": "Title level 3", 305 | }, 306 | { 307 | "type": "h5", 308 | "value": "Title level 4", 309 | }, 310 | { 311 | "type": "h6", 312 | "value": "Title level 5", 313 | }, 314 | { 315 | "type": "h6", 316 | "value": "Title level 6", 317 | }, 318 | { 319 | "type": "h2", 320 | "value": "Title", 321 | }, 322 | { 323 | "type": "h3", 324 | "value": "Subtitle", 325 | }, 326 | { 327 | "type": "p", 328 | "value": "**bold**", 329 | }, 330 | { 331 | "type": "p", 332 | "value": "_italic_", 333 | }, 334 | { 335 | "type": "p", 336 | "value": "underline", 337 | }, 338 | { 339 | "type": "p", 340 | "value": "~~strikethrough~~", 341 | }, 342 | { 343 | "type": "p", 344 | "value": "superscript", 345 | }, 346 | { 347 | "type": "p", 348 | "value": "subscript", 349 | }, 350 | { 351 | "type": "p", 352 | "value": "**_boldItalic_**", 353 | }, 354 | { 355 | "type": "p", 356 | "value": "text with **space bold after** and _space italic before_.", 357 | }, 358 | { 359 | "type": "p", 360 | "value": " indented text", 361 | }, 362 | { 363 | "type": "p", 364 | "value": "vertical
indent", 365 | }, 366 | { 367 | "type": "p", 368 | "value": "[Link](https://github.com/cedricdelpoux/gatsby-source-google-docs)", 369 | }, 370 | { 371 | "type": "p", 372 | "value": "Text bigger", 373 | }, 374 | { 375 | "type": "p", 376 | "value": "Text Coloredredgreenblue", 377 | }, 378 | { 379 | "type": "p", 380 | "value": "Text with background colorredgreenblue", 381 | }, 382 | ], 383 | } 384 | `; 385 | -------------------------------------------------------------------------------- /utils/google-drive.js: -------------------------------------------------------------------------------- 1 | const {google} = require("googleapis") 2 | const _kebabCase = require("lodash/kebabCase") 3 | const _chunk = require("lodash/chunk") 4 | const _flatten = require("lodash/flatten") 5 | const GoogleOAuth2 = require("google-oauth2-env-vars") 6 | const yamljs = require("yamljs") 7 | 8 | const {ENV_TOKEN_VAR} = require("./constants") 9 | const {wait} = require("./wait") 10 | 11 | const MIME_TYPE_DOCUMENT = "application/vnd.google-apps.document" 12 | const MIME_TYPE_FOLDER = "application/vnd.google-apps.folder" 13 | 14 | /** 15 | * @template T 16 | * @param {T[]} arr 17 | * @param {number} count 18 | * @returns {T[][]} 19 | */ 20 | function evenlyChunk(arr, count) { 21 | const chunks = Math.ceil(arr.length / count) 22 | if (chunks <= 1) { 23 | return [arr] 24 | } 25 | return _chunk(arr, Math.ceil(arr.length / chunks)) 26 | } 27 | 28 | const getMetadataFromDescription = (description) => { 29 | const metadata = {} 30 | 31 | if (description) { 32 | try { 33 | // Try to convert description from YAML 34 | const descriptionObject = yamljs.parse(description) 35 | if (typeof descriptionObject !== "string") { 36 | Object.assign(metadata, descriptionObject) 37 | } 38 | } catch (e) { 39 | // Description field is not valid YAML 40 | // Do not throw an error 41 | } 42 | } 43 | 44 | return metadata 45 | } 46 | 47 | const getTreeMetadata = (tree, file) => { 48 | let name = file.name 49 | let breadcrumb = [] 50 | let slug = "" 51 | let path = "" 52 | 53 | tree.forEach((item) => { 54 | const nameSlugified = _kebabCase(item.name) 55 | 56 | path += `/${nameSlugified}` 57 | 58 | if (item.skip !== true) { 59 | slug += `/${nameSlugified}` 60 | breadcrumb.push({ 61 | name: item.name, 62 | slug, 63 | }) 64 | } 65 | }) 66 | 67 | // /folder/index -> /folder 68 | if (file.index) { 69 | const folder = breadcrumb.pop() 70 | if (folder && file.name === "index") { 71 | name = folder.name 72 | } 73 | } else { 74 | const nameSlugified = _kebabCase(name) 75 | 76 | path += `/${nameSlugified}` 77 | slug += `/${nameSlugified}` 78 | } 79 | 80 | // Metadata slug from "description" 81 | if (file.slug) { 82 | slug = file.slug 83 | } 84 | 85 | // Root 86 | if (slug === "") { 87 | slug = "/" 88 | } 89 | 90 | breadcrumb.push({ 91 | name, 92 | slug, 93 | }) 94 | 95 | return { 96 | name, 97 | breadcrumb, 98 | path, 99 | slug, 100 | } 101 | } 102 | 103 | /** 104 | * @param {object} options 105 | * @param {Partial} options.metadata 106 | * @param {Record=} options.defaults 107 | */ 108 | const updateFile = ({file, folder}) => { 109 | Object.assign(file, { 110 | exclude: false, 111 | page: true, 112 | index: file.name === "index", 113 | date: file.createdTime, 114 | ...folder.metadata, 115 | }) 116 | 117 | // Transform description into metadata if description is YAML 118 | const metadata = getMetadataFromDescription(file.description) 119 | Object.assign(file, metadata) 120 | 121 | // Breadcrumb, slug, path 122 | Object.assign(file, getTreeMetadata(folder.tree, file)) 123 | 124 | return file 125 | } 126 | 127 | async function getGoogleDrive() { 128 | const googleOAuth2 = new GoogleOAuth2({ 129 | token: ENV_TOKEN_VAR, 130 | }) 131 | const auth = await googleOAuth2.getAuth() 132 | 133 | return google.drive({version: "v3", auth}) 134 | } 135 | 136 | /** 137 | * @typedef DocumentFetchParent 138 | * @property {string | null} id 139 | * @property {string[]} breadcrumb 140 | * @property {string} path 141 | */ 142 | 143 | /** 144 | * @typedef FetchDocumentsOptions 145 | * @property {import('googleapis').drive_v3.Drive} drive 146 | * @property {DocumentFetchParent[]} parents 147 | */ 148 | 149 | // 10 per 1.5 seconds. 150 | const rateLimit = wait(10, 1500) 151 | const BATCH_SIZE = 100 152 | /** 153 | * @param {import('..').Options & FetchDocumentsOptions} options 154 | * @returns {Promise<(import('..').DocumentFile & { path: string })[]>} 155 | */ 156 | async function fetchDocumentsFiles({drive, parents, options}) { 157 | if (parents.length > BATCH_SIZE) { 158 | return _flatten( 159 | await Promise.all( 160 | evenlyChunk(parents, BATCH_SIZE).map((parents) => 161 | fetchDocumentsFiles({ 162 | drive, 163 | parents, 164 | options, 165 | }) 166 | ) 167 | ) 168 | ) 169 | } 170 | 171 | const waited = await rateLimit() 172 | if (options.debug && waited > 1000) { 173 | const waitingTime = (waited / 1000).toFixed(1) 174 | console.info( 175 | `source-google-docs: rate limit reach. waiting ${waitingTime}s` 176 | ) 177 | } 178 | 179 | const parentQuery = 180 | parents.length === 1 && parents[0].id === null 181 | ? false 182 | : parents.map((p) => `'${p.id}' in parents`).join(" or ") 183 | 184 | const query = { 185 | includeItemsFromAllDrives: true, 186 | supportsAllDrives: true, 187 | q: `${ 188 | parentQuery ? `(${parentQuery}) and ` : "" 189 | }(mimeType='${MIME_TYPE_FOLDER}' or mimeType='${MIME_TYPE_DOCUMENT}') and trashed = false`, 190 | fields: `nextPageToken,files(id, mimeType, name, description, createdTime, modifiedTime, starred, parents)`, 191 | } 192 | 193 | const res = await drive.files.list(query) 194 | 195 | /** @param {typeof res.data.files} files */ 196 | const collectDocuments = (files) => 197 | files 198 | .filter( 199 | /** @returns {file is import("..").DocumentFile} */ 200 | (file) => file.mimeType === MIME_TYPE_DOCUMENT 201 | ) 202 | .map((file) => { 203 | const parentIds = file.parents && new Set(file.parents) 204 | const folder = parentIds && parents.find((p) => parentIds.has(p.id)) 205 | return updateFile({ 206 | folder, 207 | file, 208 | }) 209 | }) 210 | .filter((file) => !file.exclude) 211 | let documents = collectDocuments(res.data.files) 212 | 213 | /** @param {typeof res.data.files} files */ 214 | const collectParents = (files) => { 215 | return files 216 | .filter((file) => file.mimeType === MIME_TYPE_FOLDER) 217 | .map((folder) => { 218 | const parentIds = folder.parents && new Set(folder.parents) 219 | const parent = parentIds && parents.find((p) => parentIds.has(p.id)) 220 | const metadata = getMetadataFromDescription(folder.description) 221 | const tree = [ 222 | ...parent.tree, 223 | { 224 | name: folder.name, 225 | skip: metadata.skip || false, 226 | }, 227 | ] 228 | 229 | // we don't want to spread "skip" folder metadata to documents 230 | if (metadata.skip) { 231 | delete metadata.skip 232 | } 233 | 234 | return { 235 | id: folder.id, 236 | tree, 237 | metadata: { 238 | ...parent.metadata, 239 | ...metadata, 240 | }, 241 | } 242 | }) 243 | .filter((folder) => !folder.exclude) 244 | } 245 | let nextParents = collectParents(res.data.files) 246 | 247 | if (!res.data.nextPageToken) { 248 | if (nextParents.length === 0) { 249 | return documents 250 | } 251 | const documentsInFolders = await fetchDocumentsFiles({ 252 | drive, 253 | parents: nextParents, 254 | options, 255 | }) 256 | return [...documents, ...documentsInFolders] 257 | } 258 | 259 | /** @type {typeof documents} */ 260 | let documentsInFolders = [] 261 | 262 | const fetchOneParentsBatch = async () => { 263 | // process one batch of children while continuing on with pages 264 | const parentBatch = nextParents.slice(0, BATCH_SIZE) 265 | nextParents = nextParents.slice(BATCH_SIZE) 266 | const results = await fetchDocumentsFiles({ 267 | drive, 268 | parents: parentBatch, 269 | options, 270 | }) 271 | documentsInFolders = [...documentsInFolders, ...results] 272 | } 273 | 274 | /** @param {string} nextPageToken */ 275 | const fetchNextPage = async (nextPageToken) => { 276 | await rateLimit() 277 | const nextRes = await drive.files.list({ 278 | ...query, 279 | pageToken: nextPageToken, 280 | }) 281 | documents = [...documents, ...collectDocuments(nextRes.data.files)] 282 | nextParents = [...nextParents, ...collectParents(nextRes.data.files)] 283 | 284 | if (!nextRes.data.nextPageToken) { 285 | if (nextParents.length === 0) { 286 | return documents 287 | } 288 | const finalDocumentsInFolders = await fetchDocumentsFiles({ 289 | drive, 290 | parents: nextParents, 291 | options, 292 | }) 293 | return [...documents, ...documentsInFolders, ...finalDocumentsInFolders] 294 | } 295 | 296 | const nextPagePromise = fetchNextPage(nextRes.data.nextPageToken) 297 | if (nextParents.length < BATCH_SIZE) { 298 | return nextPagePromise 299 | } 300 | return (await Promise.all([nextPagePromise, fetchOneParentsBatch()]))[0] 301 | } 302 | return fetchNextPage(res.data.nextPageToken) 303 | } 304 | 305 | /** @param {import('..').Options} pluginOptions */ 306 | async function fetchFiles({folder, ...options}) { 307 | const drive = await getGoogleDrive() 308 | 309 | const res = await drive.files.get({ 310 | fileId: folder, 311 | fields: "description", 312 | supportsAllDrives: true, 313 | }) 314 | 315 | const documentsFiles = await fetchDocumentsFiles({ 316 | drive, 317 | parents: [ 318 | { 319 | id: folder, 320 | tree: [], 321 | metadata: getMetadataFromDescription(res.data.description), 322 | }, 323 | ], 324 | options, 325 | }) 326 | 327 | return documentsFiles 328 | } 329 | 330 | module.exports = { 331 | fetchFiles, 332 | } 333 | -------------------------------------------------------------------------------- /__tests__/documents/empty.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Empty", 3 | "body": { 4 | "content": [ 5 | { 6 | "endIndex": 1, 7 | "sectionBreak": { 8 | "sectionStyle": { 9 | "columnSeparatorStyle": "NONE", 10 | "contentDirection": "LEFT_TO_RIGHT", 11 | "sectionType": "CONTINUOUS" 12 | } 13 | } 14 | }, 15 | { 16 | "startIndex": 1, 17 | "endIndex": 2, 18 | "paragraph": { 19 | "elements": [ 20 | { 21 | "startIndex": 1, 22 | "endIndex": 2, 23 | "textRun": {"content": "\n", "textStyle": {}} 24 | } 25 | ], 26 | "paragraphStyle": { 27 | "namedStyleType": "NORMAL_TEXT", 28 | "direction": "LEFT_TO_RIGHT" 29 | } 30 | } 31 | }, 32 | { 33 | "startIndex": 2, 34 | "endIndex": 3, 35 | "paragraph": { 36 | "elements": [ 37 | { 38 | "startIndex": 2, 39 | "endIndex": 3, 40 | "textRun": {"content": "", "textStyle": {}} 41 | } 42 | ], 43 | "paragraphStyle": { 44 | "headingId": "h.4u3anmqa57uf", 45 | "namedStyleType": "HEADING_1", 46 | "direction": "LEFT_TO_RIGHT", 47 | "avoidWidowAndOrphan": true 48 | } 49 | } 50 | } 51 | ] 52 | }, 53 | "documentStyle": { 54 | "background": {"color": {}}, 55 | "pageNumberStart": 1, 56 | "marginTop": {"magnitude": 72, "unit": "PT"}, 57 | "marginBottom": {"magnitude": 72, "unit": "PT"}, 58 | "marginRight": {"magnitude": 72, "unit": "PT"}, 59 | "marginLeft": {"magnitude": 72, "unit": "PT"}, 60 | "pageSize": { 61 | "height": {"magnitude": 841.8897637795277, "unit": "PT"}, 62 | "width": {"magnitude": 595.2755905511812, "unit": "PT"} 63 | }, 64 | "marginHeader": {"magnitude": 36, "unit": "PT"}, 65 | "marginFooter": {"magnitude": 36, "unit": "PT"}, 66 | "useCustomHeaderFooterMargins": true 67 | }, 68 | "namedStyles": { 69 | "styles": [ 70 | { 71 | "namedStyleType": "NORMAL_TEXT", 72 | "textStyle": { 73 | "bold": false, 74 | "italic": false, 75 | "underline": false, 76 | "strikethrough": false, 77 | "smallCaps": false, 78 | "backgroundColor": {}, 79 | "foregroundColor": {"color": {"rgbColor": {}}}, 80 | "fontSize": {"magnitude": 11, "unit": "PT"}, 81 | "weightedFontFamily": {"fontFamily": "Arial", "weight": 400}, 82 | "baselineOffset": "NONE" 83 | }, 84 | "paragraphStyle": { 85 | "namedStyleType": "NORMAL_TEXT", 86 | "alignment": "START", 87 | "lineSpacing": 115, 88 | "direction": "LEFT_TO_RIGHT", 89 | "spacingMode": "COLLAPSE_LISTS", 90 | "spaceAbove": {"unit": "PT"}, 91 | "spaceBelow": {"unit": "PT"}, 92 | "borderBetween": { 93 | "color": {}, 94 | "width": {"unit": "PT"}, 95 | "padding": {"unit": "PT"}, 96 | "dashStyle": "SOLID" 97 | }, 98 | "borderTop": { 99 | "color": {}, 100 | "width": {"unit": "PT"}, 101 | "padding": {"unit": "PT"}, 102 | "dashStyle": "SOLID" 103 | }, 104 | "borderBottom": { 105 | "color": {}, 106 | "width": {"unit": "PT"}, 107 | "padding": {"unit": "PT"}, 108 | "dashStyle": "SOLID" 109 | }, 110 | "borderLeft": { 111 | "color": {}, 112 | "width": {"unit": "PT"}, 113 | "padding": {"unit": "PT"}, 114 | "dashStyle": "SOLID" 115 | }, 116 | "borderRight": { 117 | "color": {}, 118 | "width": {"unit": "PT"}, 119 | "padding": {"unit": "PT"}, 120 | "dashStyle": "SOLID" 121 | }, 122 | "indentFirstLine": {"unit": "PT"}, 123 | "indentStart": {"unit": "PT"}, 124 | "indentEnd": {"unit": "PT"}, 125 | "keepLinesTogether": false, 126 | "keepWithNext": false, 127 | "avoidWidowAndOrphan": false, 128 | "shading": {"backgroundColor": {}} 129 | } 130 | }, 131 | { 132 | "namedStyleType": "HEADING_1", 133 | "textStyle": { 134 | "bold": true, 135 | "foregroundColor": { 136 | "color": { 137 | "rgbColor": { 138 | "red": 0.10980392, 139 | "green": 0.27058825, 140 | "blue": 0.5294118 141 | } 142 | } 143 | }, 144 | "fontSize": {"magnitude": 16, "unit": "PT"}, 145 | "weightedFontFamily": {"fontFamily": "Trebuchet MS", "weight": 400} 146 | }, 147 | "paragraphStyle": { 148 | "headingId": "h.4u3anmqa57uf", 149 | "namedStyleType": "HEADING_1", 150 | "direction": "LEFT_TO_RIGHT", 151 | "spacingMode": "COLLAPSE_LISTS", 152 | "spaceAbove": {"magnitude": 10, "unit": "PT"}, 153 | "indentFirstLine": {"magnitude": 36, "unit": "PT"}, 154 | "indentStart": {"magnitude": 36, "unit": "PT"}, 155 | "keepLinesTogether": false, 156 | "keepWithNext": false, 157 | "avoidWidowAndOrphan": false 158 | } 159 | }, 160 | { 161 | "namedStyleType": "HEADING_2", 162 | "textStyle": { 163 | "bold": true, 164 | "foregroundColor": { 165 | "color": { 166 | "rgbColor": { 167 | "red": 0.23921569, 168 | "green": 0.52156866, 169 | "blue": 0.7764706 170 | } 171 | } 172 | }, 173 | "fontSize": {"magnitude": 13, "unit": "PT"}, 174 | "weightedFontFamily": {"fontFamily": "Trebuchet MS", "weight": 400} 175 | }, 176 | "paragraphStyle": { 177 | "headingId": "h.yh37ranpban7", 178 | "namedStyleType": "HEADING_2", 179 | "direction": "LEFT_TO_RIGHT", 180 | "spacingMode": "COLLAPSE_LISTS", 181 | "spaceAbove": {"magnitude": 10, "unit": "PT"}, 182 | "indentFirstLine": {"magnitude": 72, "unit": "PT"}, 183 | "indentStart": {"magnitude": 72, "unit": "PT"}, 184 | "keepLinesTogether": false, 185 | "keepWithNext": false, 186 | "avoidWidowAndOrphan": false 187 | } 188 | }, 189 | { 190 | "namedStyleType": "HEADING_3", 191 | "textStyle": { 192 | "bold": true, 193 | "foregroundColor": { 194 | "color": { 195 | "rgbColor": { 196 | "red": 0.42745098, 197 | "green": 0.61960787, 198 | "blue": 0.92156863 199 | } 200 | } 201 | }, 202 | "fontSize": {"magnitude": 12, "unit": "PT"}, 203 | "weightedFontFamily": {"fontFamily": "Trebuchet MS", "weight": 400} 204 | }, 205 | "paragraphStyle": { 206 | "headingId": "h.x2p4yxmolykq", 207 | "namedStyleType": "HEADING_3", 208 | "direction": "LEFT_TO_RIGHT", 209 | "spacingMode": "COLLAPSE_LISTS", 210 | "spaceAbove": {"magnitude": 8, "unit": "PT"}, 211 | "indentFirstLine": {"magnitude": 36, "unit": "PT"}, 212 | "indentStart": {"magnitude": 36, "unit": "PT"}, 213 | "keepLinesTogether": false, 214 | "keepWithNext": false, 215 | "avoidWidowAndOrphan": false 216 | } 217 | }, 218 | { 219 | "namedStyleType": "HEADING_4", 220 | "textStyle": { 221 | "underline": true, 222 | "foregroundColor": { 223 | "color": {"rgbColor": {"red": 0.4, "green": 0.4, "blue": 0.4}} 224 | }, 225 | "fontSize": {"magnitude": 11, "unit": "PT"}, 226 | "weightedFontFamily": {"fontFamily": "Trebuchet MS", "weight": 400} 227 | }, 228 | "paragraphStyle": { 229 | "namedStyleType": "NORMAL_TEXT", 230 | "direction": "LEFT_TO_RIGHT", 231 | "spacingMode": "COLLAPSE_LISTS", 232 | "spaceAbove": {"magnitude": 8, "unit": "PT"}, 233 | "spaceBelow": {"unit": "PT"}, 234 | "keepLinesTogether": false, 235 | "keepWithNext": false, 236 | "avoidWidowAndOrphan": false 237 | } 238 | }, 239 | { 240 | "namedStyleType": "HEADING_5", 241 | "textStyle": { 242 | "foregroundColor": { 243 | "color": {"rgbColor": {"red": 0.4, "green": 0.4, "blue": 0.4}} 244 | }, 245 | "fontSize": {"magnitude": 11, "unit": "PT"}, 246 | "weightedFontFamily": {"fontFamily": "Trebuchet MS", "weight": 400} 247 | }, 248 | "paragraphStyle": { 249 | "namedStyleType": "NORMAL_TEXT", 250 | "direction": "LEFT_TO_RIGHT", 251 | "spacingMode": "COLLAPSE_LISTS", 252 | "spaceAbove": {"magnitude": 8, "unit": "PT"}, 253 | "spaceBelow": {"unit": "PT"}, 254 | "keepLinesTogether": false, 255 | "keepWithNext": false, 256 | "avoidWidowAndOrphan": false 257 | } 258 | }, 259 | { 260 | "namedStyleType": "HEADING_6", 261 | "textStyle": { 262 | "italic": true, 263 | "foregroundColor": { 264 | "color": {"rgbColor": {"red": 0.4, "green": 0.4, "blue": 0.4}} 265 | }, 266 | "fontSize": {"magnitude": 11, "unit": "PT"}, 267 | "weightedFontFamily": {"fontFamily": "Trebuchet MS", "weight": 400} 268 | }, 269 | "paragraphStyle": { 270 | "namedStyleType": "NORMAL_TEXT", 271 | "direction": "LEFT_TO_RIGHT", 272 | "spacingMode": "COLLAPSE_LISTS", 273 | "spaceAbove": {"magnitude": 8, "unit": "PT"}, 274 | "spaceBelow": {"unit": "PT"}, 275 | "keepLinesTogether": false, 276 | "keepWithNext": false, 277 | "avoidWidowAndOrphan": false 278 | } 279 | }, 280 | { 281 | "namedStyleType": "TITLE", 282 | "textStyle": { 283 | "fontSize": {"magnitude": 21, "unit": "PT"}, 284 | "weightedFontFamily": {"fontFamily": "Trebuchet MS", "weight": 400} 285 | }, 286 | "paragraphStyle": { 287 | "namedStyleType": "NORMAL_TEXT", 288 | "direction": "LEFT_TO_RIGHT", 289 | "spacingMode": "COLLAPSE_LISTS", 290 | "spaceAbove": {"unit": "PT"}, 291 | "spaceBelow": {"unit": "PT"}, 292 | "keepLinesTogether": false, 293 | "keepWithNext": false, 294 | "avoidWidowAndOrphan": false 295 | } 296 | }, 297 | { 298 | "namedStyleType": "SUBTITLE", 299 | "textStyle": { 300 | "italic": true, 301 | "foregroundColor": { 302 | "color": {"rgbColor": {"red": 0.4, "green": 0.4, "blue": 0.4}} 303 | }, 304 | "fontSize": {"magnitude": 13, "unit": "PT"}, 305 | "weightedFontFamily": {"fontFamily": "Trebuchet MS", "weight": 400} 306 | }, 307 | "paragraphStyle": { 308 | "namedStyleType": "NORMAL_TEXT", 309 | "direction": "LEFT_TO_RIGHT", 310 | "spacingMode": "COLLAPSE_LISTS", 311 | "spaceAbove": {"unit": "PT"}, 312 | "spaceBelow": {"magnitude": 10, "unit": "PT"}, 313 | "keepLinesTogether": false, 314 | "keepWithNext": false, 315 | "avoidWidowAndOrphan": false 316 | } 317 | } 318 | ] 319 | }, 320 | "revisionId": "ALm37BVb1qhhqmHfXfwaXtPOqtAYzP3wDZLeiRq-OvGKaONFNOSkbw9F12Q3uAsj1oCPXF7vPf7fDefXBXxPAg", 321 | "suggestionsViewMode": "SUGGESTIONS_INLINE", 322 | "documentId": "1OEyYKhzFz3pcFQMmI8i8Uqs1knm_AIAQJli1hkj5CWA" 323 | } 324 | -------------------------------------------------------------------------------- /__tests__/documents/special-chars.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Special characters", 3 | "body": { 4 | "content": [ 5 | { 6 | "endIndex": 1, 7 | "sectionBreak": { 8 | "sectionStyle": { 9 | "columnSeparatorStyle": "NONE", 10 | "contentDirection": "LEFT_TO_RIGHT", 11 | "sectionType": "CONTINUOUS" 12 | } 13 | } 14 | }, 15 | { 16 | "startIndex": 1, 17 | "endIndex": 20, 18 | "paragraph": { 19 | "elements": [ 20 | { 21 | "startIndex": 1, 22 | "endIndex": 19, 23 | "textRun": { 24 | "content": "19980006564 - 0081", 25 | "textStyle": { 26 | "backgroundColor": { 27 | "color": {"rgbColor": {"red": 1, "green": 1, "blue": 1}} 28 | }, 29 | "foregroundColor": { 30 | "color": { 31 | "rgbColor": {"red": 0.2, "green": 0.2, "blue": 0.2} 32 | } 33 | }, 34 | "weightedFontFamily": { 35 | "fontFamily": "Times New Roman", 36 | "weight": 400 37 | } 38 | } 39 | } 40 | }, 41 | { 42 | "startIndex": 19, 43 | "endIndex": 20, 44 | "textRun": {"content": "\n", "textStyle": {}} 45 | } 46 | ], 47 | "paragraphStyle": { 48 | "namedStyleType": "NORMAL_TEXT", 49 | "direction": "LEFT_TO_RIGHT", 50 | "avoidWidowAndOrphan": true 51 | } 52 | } 53 | } 54 | ] 55 | }, 56 | "documentStyle": { 57 | "background": {"color": {}}, 58 | "pageNumberStart": 1, 59 | "marginTop": {"magnitude": 72, "unit": "PT"}, 60 | "marginBottom": {"magnitude": 72, "unit": "PT"}, 61 | "marginRight": {"magnitude": 72, "unit": "PT"}, 62 | "marginLeft": {"magnitude": 72, "unit": "PT"}, 63 | "pageSize": { 64 | "height": {"magnitude": 841.8897637795277, "unit": "PT"}, 65 | "width": {"magnitude": 595.2755905511812, "unit": "PT"} 66 | }, 67 | "marginHeader": {"magnitude": 36, "unit": "PT"}, 68 | "marginFooter": {"magnitude": 36, "unit": "PT"}, 69 | "useCustomHeaderFooterMargins": true 70 | }, 71 | "namedStyles": { 72 | "styles": [ 73 | { 74 | "namedStyleType": "NORMAL_TEXT", 75 | "textStyle": { 76 | "bold": false, 77 | "italic": false, 78 | "underline": false, 79 | "strikethrough": false, 80 | "smallCaps": false, 81 | "backgroundColor": {}, 82 | "foregroundColor": {"color": {"rgbColor": {}}}, 83 | "fontSize": {"magnitude": 11, "unit": "PT"}, 84 | "weightedFontFamily": {"fontFamily": "Arial", "weight": 400}, 85 | "baselineOffset": "NONE" 86 | }, 87 | "paragraphStyle": { 88 | "namedStyleType": "NORMAL_TEXT", 89 | "alignment": "START", 90 | "lineSpacing": 115, 91 | "direction": "LEFT_TO_RIGHT", 92 | "spacingMode": "COLLAPSE_LISTS", 93 | "spaceAbove": {"unit": "PT"}, 94 | "spaceBelow": {"unit": "PT"}, 95 | "borderBetween": { 96 | "color": {}, 97 | "width": {"unit": "PT"}, 98 | "padding": {"unit": "PT"}, 99 | "dashStyle": "SOLID" 100 | }, 101 | "borderTop": { 102 | "color": {}, 103 | "width": {"unit": "PT"}, 104 | "padding": {"unit": "PT"}, 105 | "dashStyle": "SOLID" 106 | }, 107 | "borderBottom": { 108 | "color": {}, 109 | "width": {"unit": "PT"}, 110 | "padding": {"unit": "PT"}, 111 | "dashStyle": "SOLID" 112 | }, 113 | "borderLeft": { 114 | "color": {}, 115 | "width": {"unit": "PT"}, 116 | "padding": {"unit": "PT"}, 117 | "dashStyle": "SOLID" 118 | }, 119 | "borderRight": { 120 | "color": {}, 121 | "width": {"unit": "PT"}, 122 | "padding": {"unit": "PT"}, 123 | "dashStyle": "SOLID" 124 | }, 125 | "indentFirstLine": {"unit": "PT"}, 126 | "indentStart": {"unit": "PT"}, 127 | "indentEnd": {"unit": "PT"}, 128 | "keepLinesTogether": false, 129 | "keepWithNext": false, 130 | "avoidWidowAndOrphan": false, 131 | "shading": {"backgroundColor": {}} 132 | } 133 | }, 134 | { 135 | "namedStyleType": "HEADING_1", 136 | "textStyle": { 137 | "bold": true, 138 | "foregroundColor": { 139 | "color": { 140 | "rgbColor": { 141 | "red": 0.10980392, 142 | "green": 0.27058825, 143 | "blue": 0.5294118 144 | } 145 | } 146 | }, 147 | "fontSize": {"magnitude": 16, "unit": "PT"}, 148 | "weightedFontFamily": {"fontFamily": "Trebuchet MS", "weight": 400} 149 | }, 150 | "paragraphStyle": { 151 | "headingId": "h.4u3anmqa57uf", 152 | "namedStyleType": "HEADING_1", 153 | "direction": "LEFT_TO_RIGHT", 154 | "spacingMode": "COLLAPSE_LISTS", 155 | "spaceAbove": {"magnitude": 10, "unit": "PT"}, 156 | "indentFirstLine": {"magnitude": 36, "unit": "PT"}, 157 | "indentStart": {"magnitude": 36, "unit": "PT"}, 158 | "keepLinesTogether": false, 159 | "keepWithNext": false, 160 | "avoidWidowAndOrphan": false 161 | } 162 | }, 163 | { 164 | "namedStyleType": "HEADING_2", 165 | "textStyle": { 166 | "bold": true, 167 | "foregroundColor": { 168 | "color": { 169 | "rgbColor": { 170 | "red": 0.23921569, 171 | "green": 0.52156866, 172 | "blue": 0.7764706 173 | } 174 | } 175 | }, 176 | "fontSize": {"magnitude": 13, "unit": "PT"}, 177 | "weightedFontFamily": {"fontFamily": "Trebuchet MS", "weight": 400} 178 | }, 179 | "paragraphStyle": { 180 | "headingId": "h.yh37ranpban7", 181 | "namedStyleType": "HEADING_2", 182 | "direction": "LEFT_TO_RIGHT", 183 | "spacingMode": "COLLAPSE_LISTS", 184 | "spaceAbove": {"magnitude": 10, "unit": "PT"}, 185 | "indentFirstLine": {"magnitude": 72, "unit": "PT"}, 186 | "indentStart": {"magnitude": 72, "unit": "PT"}, 187 | "keepLinesTogether": false, 188 | "keepWithNext": false, 189 | "avoidWidowAndOrphan": false 190 | } 191 | }, 192 | { 193 | "namedStyleType": "HEADING_3", 194 | "textStyle": { 195 | "bold": true, 196 | "foregroundColor": { 197 | "color": { 198 | "rgbColor": { 199 | "red": 0.42745098, 200 | "green": 0.61960787, 201 | "blue": 0.92156863 202 | } 203 | } 204 | }, 205 | "fontSize": {"magnitude": 12, "unit": "PT"}, 206 | "weightedFontFamily": {"fontFamily": "Trebuchet MS", "weight": 400} 207 | }, 208 | "paragraphStyle": { 209 | "headingId": "h.x2p4yxmolykq", 210 | "namedStyleType": "HEADING_3", 211 | "direction": "LEFT_TO_RIGHT", 212 | "spacingMode": "COLLAPSE_LISTS", 213 | "spaceAbove": {"magnitude": 8, "unit": "PT"}, 214 | "indentFirstLine": {"magnitude": 36, "unit": "PT"}, 215 | "indentStart": {"magnitude": 36, "unit": "PT"}, 216 | "keepLinesTogether": false, 217 | "keepWithNext": false, 218 | "avoidWidowAndOrphan": false 219 | } 220 | }, 221 | { 222 | "namedStyleType": "HEADING_4", 223 | "textStyle": { 224 | "underline": true, 225 | "foregroundColor": { 226 | "color": {"rgbColor": {"red": 0.4, "green": 0.4, "blue": 0.4}} 227 | }, 228 | "fontSize": {"magnitude": 11, "unit": "PT"}, 229 | "weightedFontFamily": {"fontFamily": "Trebuchet MS", "weight": 400} 230 | }, 231 | "paragraphStyle": { 232 | "namedStyleType": "NORMAL_TEXT", 233 | "direction": "LEFT_TO_RIGHT", 234 | "spacingMode": "COLLAPSE_LISTS", 235 | "spaceAbove": {"magnitude": 8, "unit": "PT"}, 236 | "spaceBelow": {"unit": "PT"}, 237 | "keepLinesTogether": false, 238 | "keepWithNext": false, 239 | "avoidWidowAndOrphan": false 240 | } 241 | }, 242 | { 243 | "namedStyleType": "HEADING_5", 244 | "textStyle": { 245 | "foregroundColor": { 246 | "color": {"rgbColor": {"red": 0.4, "green": 0.4, "blue": 0.4}} 247 | }, 248 | "fontSize": {"magnitude": 11, "unit": "PT"}, 249 | "weightedFontFamily": {"fontFamily": "Trebuchet MS", "weight": 400} 250 | }, 251 | "paragraphStyle": { 252 | "namedStyleType": "NORMAL_TEXT", 253 | "direction": "LEFT_TO_RIGHT", 254 | "spacingMode": "COLLAPSE_LISTS", 255 | "spaceAbove": {"magnitude": 8, "unit": "PT"}, 256 | "spaceBelow": {"unit": "PT"}, 257 | "keepLinesTogether": false, 258 | "keepWithNext": false, 259 | "avoidWidowAndOrphan": false 260 | } 261 | }, 262 | { 263 | "namedStyleType": "HEADING_6", 264 | "textStyle": { 265 | "italic": true, 266 | "foregroundColor": { 267 | "color": {"rgbColor": {"red": 0.4, "green": 0.4, "blue": 0.4}} 268 | }, 269 | "fontSize": {"magnitude": 11, "unit": "PT"}, 270 | "weightedFontFamily": {"fontFamily": "Trebuchet MS", "weight": 400} 271 | }, 272 | "paragraphStyle": { 273 | "namedStyleType": "NORMAL_TEXT", 274 | "direction": "LEFT_TO_RIGHT", 275 | "spacingMode": "COLLAPSE_LISTS", 276 | "spaceAbove": {"magnitude": 8, "unit": "PT"}, 277 | "spaceBelow": {"unit": "PT"}, 278 | "keepLinesTogether": false, 279 | "keepWithNext": false, 280 | "avoidWidowAndOrphan": false 281 | } 282 | }, 283 | { 284 | "namedStyleType": "TITLE", 285 | "textStyle": { 286 | "fontSize": {"magnitude": 21, "unit": "PT"}, 287 | "weightedFontFamily": {"fontFamily": "Trebuchet MS", "weight": 400} 288 | }, 289 | "paragraphStyle": { 290 | "namedStyleType": "NORMAL_TEXT", 291 | "direction": "LEFT_TO_RIGHT", 292 | "spacingMode": "COLLAPSE_LISTS", 293 | "spaceAbove": {"unit": "PT"}, 294 | "spaceBelow": {"unit": "PT"}, 295 | "keepLinesTogether": false, 296 | "keepWithNext": false, 297 | "avoidWidowAndOrphan": false 298 | } 299 | }, 300 | { 301 | "namedStyleType": "SUBTITLE", 302 | "textStyle": { 303 | "italic": true, 304 | "foregroundColor": { 305 | "color": {"rgbColor": {"red": 0.4, "green": 0.4, "blue": 0.4}} 306 | }, 307 | "fontSize": {"magnitude": 13, "unit": "PT"}, 308 | "weightedFontFamily": {"fontFamily": "Trebuchet MS", "weight": 400} 309 | }, 310 | "paragraphStyle": { 311 | "namedStyleType": "NORMAL_TEXT", 312 | "direction": "LEFT_TO_RIGHT", 313 | "spacingMode": "COLLAPSE_LISTS", 314 | "spaceAbove": {"unit": "PT"}, 315 | "spaceBelow": {"magnitude": 10, "unit": "PT"}, 316 | "keepLinesTogether": false, 317 | "keepWithNext": false, 318 | "avoidWidowAndOrphan": false 319 | } 320 | } 321 | ] 322 | }, 323 | "revisionId": "ALm37BUU25s-rHtMUlnUj9ro0jBXgcyjya398DVLlgxcKa3sg1ObBSj8XkdZ_lRor9TNteOujkFmRgcQ0r8HMg", 324 | "suggestionsViewMode": "SUGGESTIONS_INLINE", 325 | "documentId": "17hER2IFEwyIiliW1DGT3OOHaJrX_7mVyfID6HcLYib8" 326 | } 327 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

gatsby-source-google-docs

3 |
4 |

5 | gatsby-source-google-docs 6 |

7 |
8 | 9 | [![Npm][badge-npm]][npm] 10 | [![Build Status][badge-build]][travis] 11 | [![Coverage][badge-coverage]][codecov] 12 | [![Downloads][badge-downloads]][npm] 13 | [![PRs welcome][badge-prs]](#contributing) 14 | [![MIT license][badge-licence]](./LICENCE.md) 15 | [![Paypal][badge-paypal]](https://paypal.me/cedricdelpoux) 16 | 17 |
18 | 19 | --- 20 | 21 | `gatsby-source-google-docs` is a [Gatsby](https://www.gatsbyjs.org/) plugin to use [Google Docs](https://docs.google.com/) as a data source. 22 | 23 |

Why use Google Docs to write your content ? 24 | 25 | - 🖋 Best online WYSIWYG editor 26 | - 🖥 Desktop web app 27 | - 📱 Mobile app 28 | - 🛩 Offline redaction 29 | - 🔥 No need for external CMS 30 | - ✅ No more content in your source code 31 | 32 |

33 | 34 | ## Features 35 | 36 | - **Google Docs** formatting options (headings, bullets, tables, images...) 37 | - `MDX` support to use `` in your documents 38 | - **Gatsby** v3 & v4 support 39 | - `gatsby-plugin-image` and `gatsby-image` support 40 | - Code blocs support 41 | - **Gatsby Cloud** support 42 | - Slug generation from **Google Drive** tree 43 | - Crosslinks between pages 44 | - Related content 45 | - Custom metadata to enhance documents 46 | 47 | ## Documentation 48 | 49 | To preview what you can do, please checkout [the documentation website](https://cedricdelpoux.github.io/gatsby-source-google-docs/). 50 | 51 | - 👨🏻‍💻 [Source code](/examples/website) 52 | - 🗂 [Google Docs content](https://drive.google.com/drive/folders/1YJWX_FRoVusp-51ztedm6HSZqpbJA3ag) 53 | 54 | > 💯 100% content of the website is from Google Docs. Please suggest edits to improve it. 55 | 56 | ## Installation 57 | 58 | Download `gatsby-source-google-docs` and `gatsby-transformer-remark` (or `gatsby-plugin-mdx` for [advanced usage](/examples/website)) 59 | 60 | ```shell 61 | yarn add gatsby-source-google-docs gatsby-transformer-remark 62 | ``` 63 | 64 | - `gatsby-source-google-docs` transform **Google Docs** to **Markdown** 65 | - `gatsby-transformer-remark` transform **Markdown** to **HTML** 66 | - `gatsby-plugin-mdx` transform **Markdown** to **MDX** 67 | 68 | ## Token generation 69 | 70 | The package needs 3 `.env` variables. 71 | 72 |

Preview variables 73 | 74 | ```dotenv 75 | GOOGLE_OAUTH_CLIENT_ID=2...m.apps.googleusercontent.com 76 | GOOGLE_OAUTH_CLIENT_SECRET=Q...axL 77 | GOOGLE_DOCS_TOKEN={"access_token":"ya...J0","refresh_token":"1..mE","scope":"https://www.googleapis.com/auth/drive.metadata.readonly https://www.googleapis.com/auth/documents.readonly","token_type":"Bearer","expiry_date":1598284554759} 78 | ``` 79 | 80 |

81 | 82 | `gatsby-source-google-docs` expose a script to generate it. 83 | 84 | - Open a terminal at the root of your project 85 | - Type the following command 86 | 87 | ```shell 88 | npx gatsby-source-google-docs-token 89 | ``` 90 | 91 | ## Usage 92 | 93 | ### Organize your documents 94 | 95 | Go to your [Google Drive](https://drive.google.com/drive/), create a folder and put some documents inside it. 96 | 97 | ```js 98 | ↳ 🗂 Root folder `template: page` 99 | ↳ 🗂 en `locale: en` `skip: true` 100 | ↳ 📝 Home `template: home` 101 | ↳ 📝 About 102 | ↳ 🗂 Posts `template: post` 103 | ↳ 🗂 Drafts `exclude: true` 104 | ↳ 📝 Draft 1 105 | ↳ 📝 My year 2020 `date: 2021-01-01` 106 | ↳ 📝 Post 2 `slug: /custom/slug` `template: special-post` 107 | ↳ 🗂 fr `locale: fr` 108 | ↳ 📝 Accueil `template: home` 109 | ``` 110 | 111 |

🤡 How to enhance documents with metadata? 112 | 113 | - Fill the document (or folder) `description` field in Google Drive with a `YAML` object 114 | 115 | ```yaml 116 | locale: fr 117 | template: post 118 | category: Category Name 119 | tags: [tag1, tag2] 120 | slug: /custom-slug 121 | date: 2019-01-01 122 | ``` 123 | 124 | > There are special metadata 125 | > 126 | > - For folders: 127 | > - `exclude: true`: Exclude the folder and its documents 128 | > - `skip: true`: Remove the folder from slug but keep its documents 129 | > - For documents: 130 | > - `index:true`: Use document as the folder index 131 | > - `page: false`: Prevent page creation when `createPages` option is set to `true` 132 | 133 | - Spread metadata into the tree using folders metadata. 134 | 135 | > ⬆️ For the tree example above: 136 | > 137 | > - Every node will have `template: page` defined as default excepts if you redefine it later. 138 | > - You need to create 3 different templates: `page` (default), `home`, `post`. [Checkout the example template](./example/src/templates/page.js) 139 | > - "en" folder will be removed from slug because of `skip: true` 140 | 141 | - Exclude folders and documents using `exclude: true`. Perfect to keep drafts documents. One you want to publish a page, juste move the document one level up. 142 | 143 | > ⬆️ For the tree example above: 144 | > 145 | > - Documents under `Drafts` will be exclude because of `exclude: true`. 146 | 147 | - Every metadata will be available in `GoogleDocs` nodes and you can use everywhere in you `Gatsby` site 148 | 149 |

150 | 151 |

🌄 How to add cover? 152 | 153 | Add an image in your [Google Document first page header](https://support.google.com/docs/answer/86629) 154 | 155 |

156 | 157 |

🍞 How to add slug and breadcrumb? 158 | 159 | `slug` and `breadcrumb` fields add automatically generated using the folders tree structure and transformed using `kebab-case`. 160 | 161 | > ⬆️ For the tree example above: 162 | > The `GoogleDocs` node for document `My year 2020` 163 | > 164 | > ```js 165 | > { 166 | > path: "/en/posts/my-year-2020" // Original Google Drive path 167 | > slug: "/posts/my-year-2020" // `en` is out because of `skip: true` 168 | > breadcrumb: [ 169 | > {name: "Posts", slug: "/posts"}, 170 | > {name: "My year 2020", slug: "/posts/my-year-2020"}, 171 | > ], 172 | > template: "post" ,// src/templates/post.js 173 | > locale: "fr", 174 | > date: "2021-01-01" // Fixed date ! 175 | > } 176 | > ``` 177 | > 178 | > The `GoogleDocs` node for document `Post 2` will have a custom slug 179 | > 180 | > ```js 181 | > { 182 | > path: "/en/posts/post-2" 183 | > slug: "/custom/slug" 184 | > breadcrumb: [ 185 | > {name: "Posts", slug: "/posts"}, 186 | > {name: "Post 2", slug: "/custom/slug"}, 187 | > ], 188 | > template: "special-post", // src/templates/special-post.js 189 | > locale: "en", 190 | > date: "2020-09-12" // Google Drive document creation date 191 | > } 192 | > ``` 193 | 194 | You also can add metadata (`locale`, `date`, `template`, ...) to your documents. 195 | 196 |

197 | 198 | ### Add the plugin to your `gatsby-config.js` file 199 | 200 | | Option | Required | Type | Default | Example | 201 | | ---------------- | -------- | ------- | ------- | -------------- | 202 | | folder | `true` | String | `null` | `"1Tn1dCbIc"` | 203 | | createPages | `false` | Boolean | `false` | `true` | 204 | | pageContext | `false` | Array | `[]` | `["locale"]` | 205 | | demoteHeadings | `false` | Boolean | `true` | `false` | 206 | | imagesOptions | `false` | Object | `null` | `{width: 512}` | 207 | | keepDefaultStyle | `false` | Boolean | `false` | `true` | 208 | | skipCodes | `false` | Boolean | `false` | `true` | 209 | | skipFootnotes | `false` | Boolean | `false` | `true` | 210 | | skipHeadings | `false` | Boolean | `false` | `true` | 211 | | skipImages | `false` | Boolean | `false` | `true` | 212 | | skipLists | `false` | Boolean | `false` | `true` | 213 | | skipQuotes | `false` | Boolean | `false` | `true` | 214 | | skipTables | `false` | Boolean | `false` | `true` | 215 | | debug | `false` | Boolean | `false` | `true` | 216 | 217 | ```js 218 | module.exports = { 219 | plugins: [ 220 | { 221 | resolve: "gatsby-source-google-docs", 222 | options: { 223 | // https://drive.google.com/drive/folders/FOLDER_ID 224 | folder: "FOLDER_ID", 225 | createPages: true, 226 | }, 227 | }, 228 | "gatsby-transformer-remark", 229 | // 230 | // OR "gatsby-plugin-mdx" for advanced usage using MDX 231 | // 232 | // You need some transformations? 233 | // Checkout https://www.gatsbyjs.com/plugins/?=gatsby-remark 234 | // And pick-up some plugins 235 | ], 236 | } 237 | ``` 238 | 239 |

📷 How to use images ? 240 | 241 | `gatsby-plugin-sharp`, `gatsby-transformer-sharp` and `gatsby-remark-images` are required if you want to take advantage of [gatsby-image blur-up technique](https://using-gatsby-image.gatsbyjs.org/blur-up/). 242 | 243 | ```shell 244 | yarn add gatsby-plugin-sharp gatsby-transformer-sharp gatsby-remark-images 245 | ``` 246 | 247 | ```js 248 | module.exports = { 249 | plugins: [ 250 | "gatsby-source-google-docs", 251 | "gatsby-plugin-sharp", 252 | "gatsby-transformer-sharp", 253 | { 254 | resolve: "gatsby-transformer-remark", 255 | options: { 256 | plugins: ["gatsby-remark-images"], 257 | }, 258 | }, 259 | ], 260 | } 261 | ``` 262 | 263 |

264 | 265 |

⚛️ How to use codes blocks ? 266 | 267 | Use [Code Blocks](https://gsuite.google.com/marketplace/app/code_blocks/100740430168) Google Docs extension to format your code blocks. 268 | 269 | To specify the lang, you need to add a fist line in your code block following the format `lang:javascript`. 270 | 271 | To get Syntax highlighting, I recommend using `prismjs` but it's not mandatory. 272 | 273 | ```shell 274 | yarn add gatsby-remark-prismjs prismjs 275 | ``` 276 | 277 | Add the `gatsby-remark-prismjs` plugin to your `gatsby-config.js` 278 | 279 | ```js 280 | module.exports = { 281 | plugins: [ 282 | "gatsby-source-google-docs", 283 | { 284 | resolve: "gatsby-transformer-remark", 285 | options: { 286 | plugins: ["gatsby-remark-prismjs"], 287 | }, 288 | }, 289 | ], 290 | } 291 | ``` 292 | 293 | Import a `prismjs` theme in your `gatsby-browser.js` 294 | 295 | ```js 296 | require("prismjs/themes/prism.css") 297 | ``` 298 | 299 |

300 | 301 | ### Create templates and pages 302 | 303 | Using `createPages: true` option, pages will be created automatically. 304 | You need to create templates and define wich template to use using `YAML` metadata. 305 | 306 | > You can set `page: false` metadata for a document to prevent a page creation 307 | 308 | Checkout the [example template](./example/src/templates/page.js) and adapt it to your needs. 309 | 310 | > You can use `pageContext` option if you need extra data into the context of your pages. 311 | 312 |

How to create pages manualy? 313 | 314 | If you prefer to create pages manualy, checkout the [createPages API](./src/utils/create-pages.js) et adapt it to your needs. 315 | 316 |

317 | 318 | ### Trigger production builds 319 | 320 | - Go to [Google Drive example folder](https://drive.google.com/drive/folders/1YJWX_FRoVusp-51ztedm6HSZqpbJA3ag) 321 | - Make a copy of **Trigger Gatsby Build** file using `Right Click -> Make a copy` 322 | - Open your copy and update the **Build Webhook URL** in `A2` 323 | - Click the **Deploy** button to trigger a new build 324 | 325 | > This method works with any hosting services: Gatsby Cloud, Netlify... 326 | 327 | ## Showcase 328 | 329 | You are using `gatsby-source-google-docs` for your website? Thank you! 330 | Please add the link bellow: 331 | 332 | - [documentation](https://cedricdelpoux.github.io/gatsby-source-google-docs/) 333 | - [cedricdelpoux](https://cedricdelpoux.fr/en) 334 | 335 | ## Contributing 336 | 337 | - ⇄ Pull/Merge requests and ★ Stars are always welcome. 338 | - For bugs and feature requests, please [create an issue][github-issue]. 339 | 340 | [badge-paypal]: https://img.shields.io/badge/sponsor-PayPal-3b7bbf.svg?style=flat-square 341 | [badge-npm]: https://img.shields.io/npm/v/gatsby-source-google-docs.svg?style=flat-square 342 | [badge-downloads]: https://img.shields.io/npm/dt/gatsby-source-google-docs.svg?style=flat-square 343 | [badge-build]: https://img.shields.io/travis/cedricdelpoux/gatsby-source-google-docs/master?style=flat-square 344 | [badge-coverage]: https://img.shields.io/codecov/c/github/cedricdelpoux/gatsby-source-google-docs/master.svg?style=flat-square 345 | [badge-licence]: https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square 346 | [badge-prs]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square 347 | [npm]: https://www.npmjs.org/package/gatsby-source-google-docs 348 | [travis]: https://travis-ci.com/cedricdelpoux/gatsby-source-google-docs 349 | [codecov]: https://codecov.io/gh/cedricdelpoux/gatsby-source-google-docs 350 | [github-issue]: https://github.com/cedricdelpoux/gatsby-source-google-docs/issues/new 351 | -------------------------------------------------------------------------------- /__tests__/documents/breaks.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Breaks", 3 | "body": { 4 | "content": [ 5 | { 6 | "endIndex": 1, 7 | "sectionBreak": { 8 | "sectionStyle": { 9 | "columnSeparatorStyle": "NONE", 10 | "contentDirection": "LEFT_TO_RIGHT", 11 | "sectionType": "CONTINUOUS" 12 | } 13 | } 14 | }, 15 | { 16 | "startIndex": 1, 17 | "endIndex": 2, 18 | "paragraph": { 19 | "elements": [ 20 | { 21 | "startIndex": 1, 22 | "endIndex": 2, 23 | "textRun": {"content": "\n", "textStyle": {}} 24 | } 25 | ], 26 | "paragraphStyle": { 27 | "namedStyleType": "NORMAL_TEXT", 28 | "direction": "LEFT_TO_RIGHT" 29 | } 30 | } 31 | }, 32 | { 33 | "startIndex": 2, 34 | "endIndex": 3, 35 | "sectionBreak": { 36 | "sectionStyle": { 37 | "columnSeparatorStyle": "NONE", 38 | "contentDirection": "LEFT_TO_RIGHT", 39 | "sectionType": "NEXT_PAGE" 40 | } 41 | } 42 | }, 43 | { 44 | "startIndex": 3, 45 | "endIndex": 5, 46 | "paragraph": { 47 | "elements": [ 48 | {"startIndex": 3, "endIndex": 4, "pageBreak": {"textStyle": {}}}, 49 | { 50 | "startIndex": 4, 51 | "endIndex": 5, 52 | "textRun": {"content": "\n", "textStyle": {}} 53 | } 54 | ], 55 | "paragraphStyle": { 56 | "namedStyleType": "NORMAL_TEXT", 57 | "direction": "LEFT_TO_RIGHT" 58 | } 59 | } 60 | }, 61 | { 62 | "startIndex": 5, 63 | "endIndex": 6, 64 | "paragraph": { 65 | "elements": [ 66 | { 67 | "startIndex": 5, 68 | "endIndex": 6, 69 | "textRun": {"content": "\n", "textStyle": {}} 70 | } 71 | ], 72 | "paragraphStyle": { 73 | "namedStyleType": "NORMAL_TEXT", 74 | "direction": "LEFT_TO_RIGHT" 75 | } 76 | } 77 | }, 78 | { 79 | "startIndex": 6, 80 | "endIndex": 7, 81 | "sectionBreak": { 82 | "sectionStyle": { 83 | "columnSeparatorStyle": "NONE", 84 | "contentDirection": "LEFT_TO_RIGHT", 85 | "sectionType": "CONTINUOUS" 86 | } 87 | } 88 | }, 89 | { 90 | "startIndex": 7, 91 | "endIndex": 8, 92 | "paragraph": { 93 | "elements": [ 94 | { 95 | "startIndex": 7, 96 | "endIndex": 8, 97 | "textRun": {"content": "\n", "textStyle": {}} 98 | } 99 | ], 100 | "paragraphStyle": { 101 | "namedStyleType": "NORMAL_TEXT", 102 | "direction": "LEFT_TO_RIGHT" 103 | } 104 | } 105 | } 106 | ] 107 | }, 108 | "documentStyle": { 109 | "background": {"color": {}}, 110 | "pageNumberStart": 1, 111 | "marginTop": {"magnitude": 72, "unit": "PT"}, 112 | "marginBottom": {"magnitude": 72, "unit": "PT"}, 113 | "marginRight": {"magnitude": 72, "unit": "PT"}, 114 | "marginLeft": {"magnitude": 72, "unit": "PT"}, 115 | "pageSize": { 116 | "height": {"magnitude": 841.8897637795277, "unit": "PT"}, 117 | "width": {"magnitude": 595.2755905511812, "unit": "PT"} 118 | }, 119 | "marginHeader": {"magnitude": 36, "unit": "PT"}, 120 | "marginFooter": {"magnitude": 36, "unit": "PT"}, 121 | "useCustomHeaderFooterMargins": true 122 | }, 123 | "namedStyles": { 124 | "styles": [ 125 | { 126 | "namedStyleType": "NORMAL_TEXT", 127 | "textStyle": { 128 | "bold": false, 129 | "italic": false, 130 | "underline": false, 131 | "strikethrough": false, 132 | "smallCaps": false, 133 | "backgroundColor": {}, 134 | "foregroundColor": {"color": {"rgbColor": {}}}, 135 | "fontSize": {"magnitude": 11, "unit": "PT"}, 136 | "weightedFontFamily": {"fontFamily": "Arial", "weight": 400}, 137 | "baselineOffset": "NONE" 138 | }, 139 | "paragraphStyle": { 140 | "namedStyleType": "NORMAL_TEXT", 141 | "alignment": "START", 142 | "lineSpacing": 115, 143 | "direction": "LEFT_TO_RIGHT", 144 | "spacingMode": "COLLAPSE_LISTS", 145 | "spaceAbove": {"unit": "PT"}, 146 | "spaceBelow": {"unit": "PT"}, 147 | "borderBetween": { 148 | "color": {}, 149 | "width": {"unit": "PT"}, 150 | "padding": {"unit": "PT"}, 151 | "dashStyle": "SOLID" 152 | }, 153 | "borderTop": { 154 | "color": {}, 155 | "width": {"unit": "PT"}, 156 | "padding": {"unit": "PT"}, 157 | "dashStyle": "SOLID" 158 | }, 159 | "borderBottom": { 160 | "color": {}, 161 | "width": {"unit": "PT"}, 162 | "padding": {"unit": "PT"}, 163 | "dashStyle": "SOLID" 164 | }, 165 | "borderLeft": { 166 | "color": {}, 167 | "width": {"unit": "PT"}, 168 | "padding": {"unit": "PT"}, 169 | "dashStyle": "SOLID" 170 | }, 171 | "borderRight": { 172 | "color": {}, 173 | "width": {"unit": "PT"}, 174 | "padding": {"unit": "PT"}, 175 | "dashStyle": "SOLID" 176 | }, 177 | "indentFirstLine": {"unit": "PT"}, 178 | "indentStart": {"unit": "PT"}, 179 | "indentEnd": {"unit": "PT"}, 180 | "keepLinesTogether": false, 181 | "keepWithNext": false, 182 | "avoidWidowAndOrphan": false, 183 | "shading": {"backgroundColor": {}} 184 | } 185 | }, 186 | { 187 | "namedStyleType": "HEADING_1", 188 | "textStyle": { 189 | "bold": true, 190 | "foregroundColor": { 191 | "color": { 192 | "rgbColor": { 193 | "red": 0.10980392, 194 | "green": 0.27058825, 195 | "blue": 0.5294118 196 | } 197 | } 198 | }, 199 | "fontSize": {"magnitude": 16, "unit": "PT"}, 200 | "weightedFontFamily": {"fontFamily": "Trebuchet MS", "weight": 400} 201 | }, 202 | "paragraphStyle": { 203 | "headingId": "h.4u3anmqa57uf", 204 | "namedStyleType": "HEADING_1", 205 | "direction": "LEFT_TO_RIGHT", 206 | "spacingMode": "COLLAPSE_LISTS", 207 | "spaceAbove": {"magnitude": 10, "unit": "PT"}, 208 | "indentFirstLine": {"magnitude": 36, "unit": "PT"}, 209 | "indentStart": {"magnitude": 36, "unit": "PT"}, 210 | "keepLinesTogether": false, 211 | "keepWithNext": false, 212 | "avoidWidowAndOrphan": false 213 | } 214 | }, 215 | { 216 | "namedStyleType": "HEADING_2", 217 | "textStyle": { 218 | "bold": true, 219 | "foregroundColor": { 220 | "color": { 221 | "rgbColor": { 222 | "red": 0.23921569, 223 | "green": 0.52156866, 224 | "blue": 0.7764706 225 | } 226 | } 227 | }, 228 | "fontSize": {"magnitude": 13, "unit": "PT"}, 229 | "weightedFontFamily": {"fontFamily": "Trebuchet MS", "weight": 400} 230 | }, 231 | "paragraphStyle": { 232 | "headingId": "h.yh37ranpban7", 233 | "namedStyleType": "HEADING_2", 234 | "direction": "LEFT_TO_RIGHT", 235 | "spacingMode": "COLLAPSE_LISTS", 236 | "spaceAbove": {"magnitude": 10, "unit": "PT"}, 237 | "indentFirstLine": {"magnitude": 72, "unit": "PT"}, 238 | "indentStart": {"magnitude": 72, "unit": "PT"}, 239 | "keepLinesTogether": false, 240 | "keepWithNext": false, 241 | "avoidWidowAndOrphan": false 242 | } 243 | }, 244 | { 245 | "namedStyleType": "HEADING_3", 246 | "textStyle": { 247 | "bold": true, 248 | "foregroundColor": { 249 | "color": { 250 | "rgbColor": { 251 | "red": 0.42745098, 252 | "green": 0.61960787, 253 | "blue": 0.92156863 254 | } 255 | } 256 | }, 257 | "fontSize": {"magnitude": 12, "unit": "PT"}, 258 | "weightedFontFamily": {"fontFamily": "Trebuchet MS", "weight": 400} 259 | }, 260 | "paragraphStyle": { 261 | "headingId": "h.x2p4yxmolykq", 262 | "namedStyleType": "HEADING_3", 263 | "direction": "LEFT_TO_RIGHT", 264 | "spacingMode": "COLLAPSE_LISTS", 265 | "spaceAbove": {"magnitude": 8, "unit": "PT"}, 266 | "indentFirstLine": {"magnitude": 36, "unit": "PT"}, 267 | "indentStart": {"magnitude": 36, "unit": "PT"}, 268 | "keepLinesTogether": false, 269 | "keepWithNext": false, 270 | "avoidWidowAndOrphan": false 271 | } 272 | }, 273 | { 274 | "namedStyleType": "HEADING_4", 275 | "textStyle": { 276 | "underline": true, 277 | "foregroundColor": { 278 | "color": {"rgbColor": {"red": 0.4, "green": 0.4, "blue": 0.4}} 279 | }, 280 | "fontSize": {"magnitude": 11, "unit": "PT"}, 281 | "weightedFontFamily": {"fontFamily": "Trebuchet MS", "weight": 400} 282 | }, 283 | "paragraphStyle": { 284 | "namedStyleType": "NORMAL_TEXT", 285 | "direction": "LEFT_TO_RIGHT", 286 | "spacingMode": "COLLAPSE_LISTS", 287 | "spaceAbove": {"magnitude": 8, "unit": "PT"}, 288 | "spaceBelow": {"unit": "PT"}, 289 | "keepLinesTogether": false, 290 | "keepWithNext": false, 291 | "avoidWidowAndOrphan": false 292 | } 293 | }, 294 | { 295 | "namedStyleType": "HEADING_5", 296 | "textStyle": { 297 | "foregroundColor": { 298 | "color": {"rgbColor": {"red": 0.4, "green": 0.4, "blue": 0.4}} 299 | }, 300 | "fontSize": {"magnitude": 11, "unit": "PT"}, 301 | "weightedFontFamily": {"fontFamily": "Trebuchet MS", "weight": 400} 302 | }, 303 | "paragraphStyle": { 304 | "namedStyleType": "NORMAL_TEXT", 305 | "direction": "LEFT_TO_RIGHT", 306 | "spacingMode": "COLLAPSE_LISTS", 307 | "spaceAbove": {"magnitude": 8, "unit": "PT"}, 308 | "spaceBelow": {"unit": "PT"}, 309 | "keepLinesTogether": false, 310 | "keepWithNext": false, 311 | "avoidWidowAndOrphan": false 312 | } 313 | }, 314 | { 315 | "namedStyleType": "HEADING_6", 316 | "textStyle": { 317 | "italic": true, 318 | "foregroundColor": { 319 | "color": {"rgbColor": {"red": 0.4, "green": 0.4, "blue": 0.4}} 320 | }, 321 | "fontSize": {"magnitude": 11, "unit": "PT"}, 322 | "weightedFontFamily": {"fontFamily": "Trebuchet MS", "weight": 400} 323 | }, 324 | "paragraphStyle": { 325 | "namedStyleType": "NORMAL_TEXT", 326 | "direction": "LEFT_TO_RIGHT", 327 | "spacingMode": "COLLAPSE_LISTS", 328 | "spaceAbove": {"magnitude": 8, "unit": "PT"}, 329 | "spaceBelow": {"unit": "PT"}, 330 | "keepLinesTogether": false, 331 | "keepWithNext": false, 332 | "avoidWidowAndOrphan": false 333 | } 334 | }, 335 | { 336 | "namedStyleType": "TITLE", 337 | "textStyle": { 338 | "fontSize": {"magnitude": 21, "unit": "PT"}, 339 | "weightedFontFamily": {"fontFamily": "Trebuchet MS", "weight": 400} 340 | }, 341 | "paragraphStyle": { 342 | "namedStyleType": "NORMAL_TEXT", 343 | "direction": "LEFT_TO_RIGHT", 344 | "spacingMode": "COLLAPSE_LISTS", 345 | "spaceAbove": {"unit": "PT"}, 346 | "spaceBelow": {"unit": "PT"}, 347 | "keepLinesTogether": false, 348 | "keepWithNext": false, 349 | "avoidWidowAndOrphan": false 350 | } 351 | }, 352 | { 353 | "namedStyleType": "SUBTITLE", 354 | "textStyle": { 355 | "italic": true, 356 | "foregroundColor": { 357 | "color": {"rgbColor": {"red": 0.4, "green": 0.4, "blue": 0.4}} 358 | }, 359 | "fontSize": {"magnitude": 13, "unit": "PT"}, 360 | "weightedFontFamily": {"fontFamily": "Trebuchet MS", "weight": 400} 361 | }, 362 | "paragraphStyle": { 363 | "namedStyleType": "NORMAL_TEXT", 364 | "direction": "LEFT_TO_RIGHT", 365 | "spacingMode": "COLLAPSE_LISTS", 366 | "spaceAbove": {"unit": "PT"}, 367 | "spaceBelow": {"magnitude": 10, "unit": "PT"}, 368 | "keepLinesTogether": false, 369 | "keepWithNext": false, 370 | "avoidWidowAndOrphan": false 371 | } 372 | } 373 | ] 374 | }, 375 | "revisionId": "ALm37BVFUi1tyP2saQFTvPhPpjrX4Nl9gkb5T_6RSqjSO0h-lyjBNv3KUjtPaipCScVs7h4ZyWmAdjyHaQTtmA", 376 | "suggestionsViewMode": "SUGGESTIONS_INLINE", 377 | "documentId": "19VJJx0vGGNm0x-GmG4tw0s1Tk0Ezxyx-elORaNeGZLs" 378 | } 379 | -------------------------------------------------------------------------------- /__tests__/documents/french.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "French", 3 | "body": { 4 | "content": [ 5 | { 6 | "endIndex": 1, 7 | "sectionBreak": { 8 | "sectionStyle": { 9 | "columnSeparatorStyle": "NONE", 10 | "contentDirection": "LEFT_TO_RIGHT", 11 | "sectionType": "CONTINUOUS" 12 | } 13 | } 14 | }, 15 | { 16 | "startIndex": 1, 17 | "endIndex": 56, 18 | "paragraph": { 19 | "elements": [ 20 | { 21 | "startIndex": 1, 22 | "endIndex": 14, 23 | "textRun": {"content": "Je m'appelle ", "textStyle": {}} 24 | }, 25 | { 26 | "startIndex": 14, 27 | "endIndex": 20, 28 | "textRun": {"content": "Cédric", "textStyle": {"bold": true}} 29 | }, 30 | { 31 | "startIndex": 20, 32 | "endIndex": 42, 33 | "textRun": {"content": ", j'habite à Toulouse ", "textStyle": {}} 34 | }, 35 | { 36 | "startIndex": 42, 37 | "endIndex": 50, 38 | "textRun": {"content": "(France)", "textStyle": {"italic": true}} 39 | }, 40 | { 41 | "startIndex": 50, 42 | "endIndex": 56, 43 | "textRun": {"content": " 🇫🇷\n", "textStyle": {}} 44 | } 45 | ], 46 | "paragraphStyle": { 47 | "namedStyleType": "NORMAL_TEXT", 48 | "direction": "LEFT_TO_RIGHT" 49 | } 50 | } 51 | } 52 | ] 53 | }, 54 | "headers": { 55 | "kix.1ahtg9vtgmel": { 56 | "headerId": "kix.1ahtg9vtgmel", 57 | "content": [ 58 | { 59 | "endIndex": 1, 60 | "paragraph": { 61 | "elements": [ 62 | {"endIndex": 1, "textRun": {"content": "\n", "textStyle": {}}} 63 | ], 64 | "paragraphStyle": { 65 | "namedStyleType": "NORMAL_TEXT", 66 | "direction": "LEFT_TO_RIGHT" 67 | } 68 | } 69 | } 70 | ] 71 | }, 72 | "kix.tb1914qxojd3": { 73 | "headerId": "kix.tb1914qxojd3", 74 | "content": [ 75 | { 76 | "endIndex": 1, 77 | "paragraph": { 78 | "elements": [ 79 | {"endIndex": 1, "textRun": {"content": "\n", "textStyle": {}}} 80 | ], 81 | "paragraphStyle": { 82 | "headingId": "h.c2m8jqfxedns", 83 | "namedStyleType": "HEADING_1", 84 | "direction": "LEFT_TO_RIGHT" 85 | } 86 | } 87 | } 88 | ] 89 | } 90 | }, 91 | "footers": { 92 | "kix.j1vlyiu8ewv2": { 93 | "footerId": "kix.j1vlyiu8ewv2", 94 | "content": [ 95 | { 96 | "endIndex": 1, 97 | "paragraph": { 98 | "elements": [ 99 | {"endIndex": 1, "textRun": {"content": "\n", "textStyle": {}}} 100 | ], 101 | "paragraphStyle": { 102 | "namedStyleType": "NORMAL_TEXT", 103 | "direction": "LEFT_TO_RIGHT" 104 | } 105 | } 106 | } 107 | ] 108 | } 109 | }, 110 | "documentStyle": { 111 | "background": {"color": {}}, 112 | "defaultHeaderId": "kix.1ahtg9vtgmel", 113 | "firstPageHeaderId": "kix.tb1914qxojd3", 114 | "firstPageFooterId": "kix.j1vlyiu8ewv2", 115 | "useFirstPageHeaderFooter": true, 116 | "pageNumberStart": 1, 117 | "marginTop": {"magnitude": 72, "unit": "PT"}, 118 | "marginBottom": {"magnitude": 72, "unit": "PT"}, 119 | "marginRight": {"magnitude": 72, "unit": "PT"}, 120 | "marginLeft": {"magnitude": 72, "unit": "PT"}, 121 | "pageSize": { 122 | "height": {"magnitude": 841.8897637795277, "unit": "PT"}, 123 | "width": {"magnitude": 595.2755905511812, "unit": "PT"} 124 | }, 125 | "marginHeader": {"magnitude": 36, "unit": "PT"}, 126 | "marginFooter": {"magnitude": 36, "unit": "PT"}, 127 | "useCustomHeaderFooterMargins": true 128 | }, 129 | "namedStyles": { 130 | "styles": [ 131 | { 132 | "namedStyleType": "NORMAL_TEXT", 133 | "textStyle": { 134 | "bold": false, 135 | "italic": false, 136 | "underline": false, 137 | "strikethrough": false, 138 | "smallCaps": false, 139 | "backgroundColor": {}, 140 | "foregroundColor": {"color": {"rgbColor": {}}}, 141 | "fontSize": {"magnitude": 11, "unit": "PT"}, 142 | "weightedFontFamily": {"fontFamily": "Arial", "weight": 400}, 143 | "baselineOffset": "NONE" 144 | }, 145 | "paragraphStyle": { 146 | "namedStyleType": "NORMAL_TEXT", 147 | "alignment": "START", 148 | "lineSpacing": 115, 149 | "direction": "LEFT_TO_RIGHT", 150 | "spacingMode": "COLLAPSE_LISTS", 151 | "spaceAbove": {"unit": "PT"}, 152 | "spaceBelow": {"unit": "PT"}, 153 | "borderBetween": { 154 | "color": {}, 155 | "width": {"unit": "PT"}, 156 | "padding": {"unit": "PT"}, 157 | "dashStyle": "SOLID" 158 | }, 159 | "borderTop": { 160 | "color": {}, 161 | "width": {"unit": "PT"}, 162 | "padding": {"unit": "PT"}, 163 | "dashStyle": "SOLID" 164 | }, 165 | "borderBottom": { 166 | "color": {}, 167 | "width": {"unit": "PT"}, 168 | "padding": {"unit": "PT"}, 169 | "dashStyle": "SOLID" 170 | }, 171 | "borderLeft": { 172 | "color": {}, 173 | "width": {"unit": "PT"}, 174 | "padding": {"unit": "PT"}, 175 | "dashStyle": "SOLID" 176 | }, 177 | "borderRight": { 178 | "color": {}, 179 | "width": {"unit": "PT"}, 180 | "padding": {"unit": "PT"}, 181 | "dashStyle": "SOLID" 182 | }, 183 | "indentFirstLine": {"unit": "PT"}, 184 | "indentStart": {"unit": "PT"}, 185 | "indentEnd": {"unit": "PT"}, 186 | "keepLinesTogether": false, 187 | "keepWithNext": false, 188 | "avoidWidowAndOrphan": true, 189 | "shading": {"backgroundColor": {}} 190 | } 191 | }, 192 | { 193 | "namedStyleType": "HEADING_1", 194 | "textStyle": {"fontSize": {"magnitude": 20, "unit": "PT"}}, 195 | "paragraphStyle": { 196 | "namedStyleType": "NORMAL_TEXT", 197 | "direction": "LEFT_TO_RIGHT", 198 | "spaceAbove": {"magnitude": 20, "unit": "PT"}, 199 | "spaceBelow": {"magnitude": 6, "unit": "PT"}, 200 | "keepLinesTogether": true, 201 | "keepWithNext": true 202 | } 203 | }, 204 | { 205 | "namedStyleType": "HEADING_2", 206 | "textStyle": { 207 | "bold": false, 208 | "fontSize": {"magnitude": 16, "unit": "PT"} 209 | }, 210 | "paragraphStyle": { 211 | "namedStyleType": "NORMAL_TEXT", 212 | "direction": "LEFT_TO_RIGHT", 213 | "spaceAbove": {"magnitude": 18, "unit": "PT"}, 214 | "spaceBelow": {"magnitude": 6, "unit": "PT"}, 215 | "keepLinesTogether": true, 216 | "keepWithNext": true 217 | } 218 | }, 219 | { 220 | "namedStyleType": "HEADING_3", 221 | "textStyle": { 222 | "bold": false, 223 | "foregroundColor": { 224 | "color": { 225 | "rgbColor": { 226 | "red": 0.2627451, 227 | "green": 0.2627451, 228 | "blue": 0.2627451 229 | } 230 | } 231 | }, 232 | "fontSize": {"magnitude": 14, "unit": "PT"} 233 | }, 234 | "paragraphStyle": { 235 | "namedStyleType": "NORMAL_TEXT", 236 | "direction": "LEFT_TO_RIGHT", 237 | "spaceAbove": {"magnitude": 16, "unit": "PT"}, 238 | "spaceBelow": {"magnitude": 4, "unit": "PT"}, 239 | "keepLinesTogether": true, 240 | "keepWithNext": true 241 | } 242 | }, 243 | { 244 | "namedStyleType": "HEADING_4", 245 | "textStyle": { 246 | "foregroundColor": { 247 | "color": {"rgbColor": {"red": 0.4, "green": 0.4, "blue": 0.4}} 248 | }, 249 | "fontSize": {"magnitude": 12, "unit": "PT"} 250 | }, 251 | "paragraphStyle": { 252 | "namedStyleType": "NORMAL_TEXT", 253 | "direction": "LEFT_TO_RIGHT", 254 | "spaceAbove": {"magnitude": 14, "unit": "PT"}, 255 | "spaceBelow": {"magnitude": 4, "unit": "PT"}, 256 | "keepLinesTogether": true, 257 | "keepWithNext": true 258 | } 259 | }, 260 | { 261 | "namedStyleType": "HEADING_5", 262 | "textStyle": { 263 | "foregroundColor": { 264 | "color": {"rgbColor": {"red": 0.4, "green": 0.4, "blue": 0.4}} 265 | }, 266 | "fontSize": {"magnitude": 11, "unit": "PT"} 267 | }, 268 | "paragraphStyle": { 269 | "namedStyleType": "NORMAL_TEXT", 270 | "direction": "LEFT_TO_RIGHT", 271 | "spaceAbove": {"magnitude": 12, "unit": "PT"}, 272 | "spaceBelow": {"magnitude": 4, "unit": "PT"}, 273 | "keepLinesTogether": true, 274 | "keepWithNext": true 275 | } 276 | }, 277 | { 278 | "namedStyleType": "HEADING_6", 279 | "textStyle": { 280 | "italic": true, 281 | "foregroundColor": { 282 | "color": {"rgbColor": {"red": 0.4, "green": 0.4, "blue": 0.4}} 283 | }, 284 | "fontSize": {"magnitude": 11, "unit": "PT"} 285 | }, 286 | "paragraphStyle": { 287 | "namedStyleType": "NORMAL_TEXT", 288 | "direction": "LEFT_TO_RIGHT", 289 | "spaceAbove": {"magnitude": 12, "unit": "PT"}, 290 | "spaceBelow": {"magnitude": 4, "unit": "PT"}, 291 | "keepLinesTogether": true, 292 | "keepWithNext": true 293 | } 294 | }, 295 | { 296 | "namedStyleType": "TITLE", 297 | "textStyle": {"fontSize": {"magnitude": 26, "unit": "PT"}}, 298 | "paragraphStyle": { 299 | "namedStyleType": "NORMAL_TEXT", 300 | "direction": "LEFT_TO_RIGHT", 301 | "spaceAbove": {"unit": "PT"}, 302 | "spaceBelow": {"magnitude": 3, "unit": "PT"}, 303 | "keepLinesTogether": true, 304 | "keepWithNext": true 305 | } 306 | }, 307 | { 308 | "namedStyleType": "SUBTITLE", 309 | "textStyle": { 310 | "italic": false, 311 | "foregroundColor": { 312 | "color": {"rgbColor": {"red": 0.4, "green": 0.4, "blue": 0.4}} 313 | }, 314 | "fontSize": {"magnitude": 15, "unit": "PT"}, 315 | "weightedFontFamily": {"fontFamily": "Arial", "weight": 400} 316 | }, 317 | "paragraphStyle": { 318 | "namedStyleType": "NORMAL_TEXT", 319 | "direction": "LEFT_TO_RIGHT", 320 | "spaceAbove": {"unit": "PT"}, 321 | "spaceBelow": {"magnitude": 16, "unit": "PT"}, 322 | "keepLinesTogether": true, 323 | "keepWithNext": true 324 | } 325 | } 326 | ] 327 | }, 328 | "lists": { 329 | "kix.2ndctxh20b9f": { 330 | "listProperties": { 331 | "nestingLevels": [ 332 | { 333 | "bulletAlignment": "START", 334 | "glyphSymbol": "●", 335 | "glyphFormat": "%0", 336 | "indentFirstLine": {"magnitude": 18, "unit": "PT"}, 337 | "indentStart": {"magnitude": 36, "unit": "PT"}, 338 | "textStyle": {"underline": false}, 339 | "startNumber": 1 340 | }, 341 | { 342 | "bulletAlignment": "START", 343 | "glyphSymbol": "○", 344 | "glyphFormat": "%1", 345 | "indentFirstLine": {"magnitude": 54, "unit": "PT"}, 346 | "indentStart": {"magnitude": 72, "unit": "PT"}, 347 | "textStyle": {"underline": false}, 348 | "startNumber": 1 349 | }, 350 | { 351 | "bulletAlignment": "START", 352 | "glyphSymbol": "■", 353 | "glyphFormat": "%2", 354 | "indentFirstLine": {"magnitude": 90, "unit": "PT"}, 355 | "indentStart": {"magnitude": 108, "unit": "PT"}, 356 | "textStyle": {"underline": false}, 357 | "startNumber": 1 358 | }, 359 | { 360 | "bulletAlignment": "START", 361 | "glyphSymbol": "●", 362 | "glyphFormat": "%3", 363 | "indentFirstLine": {"magnitude": 126, "unit": "PT"}, 364 | "indentStart": {"magnitude": 144, "unit": "PT"}, 365 | "textStyle": {"underline": false}, 366 | "startNumber": 1 367 | }, 368 | { 369 | "bulletAlignment": "START", 370 | "glyphSymbol": "○", 371 | "glyphFormat": "%4", 372 | "indentFirstLine": {"magnitude": 162, "unit": "PT"}, 373 | "indentStart": {"magnitude": 180, "unit": "PT"}, 374 | "textStyle": {"underline": false}, 375 | "startNumber": 1 376 | }, 377 | { 378 | "bulletAlignment": "START", 379 | "glyphSymbol": "■", 380 | "glyphFormat": "%5", 381 | "indentFirstLine": {"magnitude": 198, "unit": "PT"}, 382 | "indentStart": {"magnitude": 216, "unit": "PT"}, 383 | "textStyle": {"underline": false}, 384 | "startNumber": 1 385 | }, 386 | { 387 | "bulletAlignment": "START", 388 | "glyphSymbol": "●", 389 | "glyphFormat": "%6", 390 | "indentFirstLine": {"magnitude": 234, "unit": "PT"}, 391 | "indentStart": {"magnitude": 252, "unit": "PT"}, 392 | "textStyle": {"underline": false}, 393 | "startNumber": 1 394 | }, 395 | { 396 | "bulletAlignment": "START", 397 | "glyphSymbol": "○", 398 | "glyphFormat": "%7", 399 | "indentFirstLine": {"magnitude": 270, "unit": "PT"}, 400 | "indentStart": {"magnitude": 288, "unit": "PT"}, 401 | "textStyle": {"underline": false}, 402 | "startNumber": 1 403 | }, 404 | { 405 | "bulletAlignment": "START", 406 | "glyphSymbol": "■", 407 | "glyphFormat": "%8", 408 | "indentFirstLine": {"magnitude": 306, "unit": "PT"}, 409 | "indentStart": {"magnitude": 324, "unit": "PT"}, 410 | "textStyle": {"underline": false}, 411 | "startNumber": 1 412 | } 413 | ] 414 | } 415 | } 416 | }, 417 | "revisionId": "ALm37BUJaO2mOgrManmcUvfm44nAAFpzJ9C6dHTAUcCcppdy2yg_uiU8Ugtj5aEmvN3DlAcrWfEIgkgQ-nFedg", 418 | "suggestionsViewMode": "SUGGESTIONS_INLINE", 419 | "documentId": "1BM_DE77JGmhxa4XrXXaDYuAjQGVjf3MCupETAgbUCQg" 420 | } 421 | -------------------------------------------------------------------------------- /__tests__/documents/poem.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Poem", 3 | "body": { 4 | "content": [ 5 | { 6 | "endIndex": 1, 7 | "sectionBreak": { 8 | "sectionStyle": { 9 | "columnSeparatorStyle": "NONE", 10 | "contentDirection": "LEFT_TO_RIGHT", 11 | "sectionType": "CONTINUOUS" 12 | } 13 | } 14 | }, 15 | { 16 | "startIndex": 1, 17 | "endIndex": 10, 18 | "paragraph": { 19 | "elements": [ 20 | { 21 | "startIndex": 1, 22 | "endIndex": 10, 23 | "textRun": {"content": "The poem\n", "textStyle": {}} 24 | } 25 | ], 26 | "paragraphStyle": { 27 | "namedStyleType": "NORMAL_TEXT", 28 | "direction": "LEFT_TO_RIGHT" 29 | } 30 | } 31 | }, 32 | { 33 | "startIndex": 10, 34 | "endIndex": 34, 35 | "paragraph": { 36 | "elements": [ 37 | { 38 | "startIndex": 10, 39 | "endIndex": 34, 40 | "textRun": { 41 | "content": " may have\n", 42 | "textStyle": {} 43 | } 44 | } 45 | ], 46 | "paragraphStyle": { 47 | "namedStyleType": "NORMAL_TEXT", 48 | "direction": "LEFT_TO_RIGHT" 49 | } 50 | } 51 | }, 52 | { 53 | "startIndex": 34, 54 | "endIndex": 67, 55 | "paragraph": { 56 | "elements": [ 57 | { 58 | "startIndex": 34, 59 | "endIndex": 67, 60 | "textRun": { 61 | "content": " strange whitespace\n", 62 | "textStyle": {} 63 | } 64 | } 65 | ], 66 | "paragraphStyle": { 67 | "namedStyleType": "NORMAL_TEXT", 68 | "direction": "LEFT_TO_RIGHT" 69 | } 70 | } 71 | }, 72 | { 73 | "startIndex": 67, 74 | "endIndex": 77, 75 | "paragraph": { 76 | "elements": [ 77 | { 78 | "startIndex": 67, 79 | "endIndex": 77, 80 | "textRun": {"content": "patterns.\n", "textStyle": {}} 81 | } 82 | ], 83 | "paragraphStyle": { 84 | "namedStyleType": "NORMAL_TEXT", 85 | "direction": "LEFT_TO_RIGHT" 86 | } 87 | } 88 | } 89 | ] 90 | }, 91 | "headers": { 92 | "kix.1ahtg9vtgmel": { 93 | "headerId": "kix.1ahtg9vtgmel", 94 | "content": [ 95 | { 96 | "endIndex": 1, 97 | "paragraph": { 98 | "elements": [ 99 | {"endIndex": 1, "textRun": {"content": "\n", "textStyle": {}}} 100 | ], 101 | "paragraphStyle": { 102 | "namedStyleType": "NORMAL_TEXT", 103 | "direction": "LEFT_TO_RIGHT" 104 | } 105 | } 106 | } 107 | ] 108 | }, 109 | "kix.tb1914qxojd3": { 110 | "headerId": "kix.tb1914qxojd3", 111 | "content": [ 112 | { 113 | "endIndex": 1, 114 | "paragraph": { 115 | "elements": [ 116 | {"endIndex": 1, "textRun": {"content": "\n", "textStyle": {}}} 117 | ], 118 | "paragraphStyle": { 119 | "headingId": "h.c2m8jqfxedns", 120 | "namedStyleType": "HEADING_1", 121 | "direction": "LEFT_TO_RIGHT" 122 | } 123 | } 124 | } 125 | ] 126 | } 127 | }, 128 | "footers": { 129 | "kix.j1vlyiu8ewv2": { 130 | "footerId": "kix.j1vlyiu8ewv2", 131 | "content": [ 132 | { 133 | "endIndex": 1, 134 | "paragraph": { 135 | "elements": [ 136 | {"endIndex": 1, "textRun": {"content": "\n", "textStyle": {}}} 137 | ], 138 | "paragraphStyle": { 139 | "namedStyleType": "NORMAL_TEXT", 140 | "direction": "LEFT_TO_RIGHT" 141 | } 142 | } 143 | } 144 | ] 145 | } 146 | }, 147 | "documentStyle": { 148 | "background": {"color": {}}, 149 | "defaultHeaderId": "kix.1ahtg9vtgmel", 150 | "firstPageHeaderId": "kix.tb1914qxojd3", 151 | "firstPageFooterId": "kix.j1vlyiu8ewv2", 152 | "useFirstPageHeaderFooter": true, 153 | "pageNumberStart": 1, 154 | "marginTop": {"magnitude": 72, "unit": "PT"}, 155 | "marginBottom": {"magnitude": 72, "unit": "PT"}, 156 | "marginRight": {"magnitude": 72, "unit": "PT"}, 157 | "marginLeft": {"magnitude": 72, "unit": "PT"}, 158 | "pageSize": { 159 | "height": {"magnitude": 841.8897637795277, "unit": "PT"}, 160 | "width": {"magnitude": 595.2755905511812, "unit": "PT"} 161 | }, 162 | "marginHeader": {"magnitude": 36, "unit": "PT"}, 163 | "marginFooter": {"magnitude": 36, "unit": "PT"}, 164 | "useCustomHeaderFooterMargins": true 165 | }, 166 | "namedStyles": { 167 | "styles": [ 168 | { 169 | "namedStyleType": "NORMAL_TEXT", 170 | "textStyle": { 171 | "bold": false, 172 | "italic": false, 173 | "underline": false, 174 | "strikethrough": false, 175 | "smallCaps": false, 176 | "backgroundColor": {}, 177 | "foregroundColor": {"color": {"rgbColor": {}}}, 178 | "fontSize": {"magnitude": 11, "unit": "PT"}, 179 | "weightedFontFamily": {"fontFamily": "Arial", "weight": 400}, 180 | "baselineOffset": "NONE" 181 | }, 182 | "paragraphStyle": { 183 | "namedStyleType": "NORMAL_TEXT", 184 | "alignment": "START", 185 | "lineSpacing": 115, 186 | "direction": "LEFT_TO_RIGHT", 187 | "spacingMode": "COLLAPSE_LISTS", 188 | "spaceAbove": {"unit": "PT"}, 189 | "spaceBelow": {"unit": "PT"}, 190 | "borderBetween": { 191 | "color": {}, 192 | "width": {"unit": "PT"}, 193 | "padding": {"unit": "PT"}, 194 | "dashStyle": "SOLID" 195 | }, 196 | "borderTop": { 197 | "color": {}, 198 | "width": {"unit": "PT"}, 199 | "padding": {"unit": "PT"}, 200 | "dashStyle": "SOLID" 201 | }, 202 | "borderBottom": { 203 | "color": {}, 204 | "width": {"unit": "PT"}, 205 | "padding": {"unit": "PT"}, 206 | "dashStyle": "SOLID" 207 | }, 208 | "borderLeft": { 209 | "color": {}, 210 | "width": {"unit": "PT"}, 211 | "padding": {"unit": "PT"}, 212 | "dashStyle": "SOLID" 213 | }, 214 | "borderRight": { 215 | "color": {}, 216 | "width": {"unit": "PT"}, 217 | "padding": {"unit": "PT"}, 218 | "dashStyle": "SOLID" 219 | }, 220 | "indentFirstLine": {"unit": "PT"}, 221 | "indentStart": {"unit": "PT"}, 222 | "indentEnd": {"unit": "PT"}, 223 | "keepLinesTogether": false, 224 | "keepWithNext": false, 225 | "avoidWidowAndOrphan": true, 226 | "shading": {"backgroundColor": {}} 227 | } 228 | }, 229 | { 230 | "namedStyleType": "HEADING_1", 231 | "textStyle": {"fontSize": {"magnitude": 20, "unit": "PT"}}, 232 | "paragraphStyle": { 233 | "namedStyleType": "NORMAL_TEXT", 234 | "direction": "LEFT_TO_RIGHT", 235 | "spaceAbove": {"magnitude": 20, "unit": "PT"}, 236 | "spaceBelow": {"magnitude": 6, "unit": "PT"}, 237 | "keepLinesTogether": true, 238 | "keepWithNext": true 239 | } 240 | }, 241 | { 242 | "namedStyleType": "HEADING_2", 243 | "textStyle": { 244 | "bold": false, 245 | "fontSize": {"magnitude": 16, "unit": "PT"} 246 | }, 247 | "paragraphStyle": { 248 | "namedStyleType": "NORMAL_TEXT", 249 | "direction": "LEFT_TO_RIGHT", 250 | "spaceAbove": {"magnitude": 18, "unit": "PT"}, 251 | "spaceBelow": {"magnitude": 6, "unit": "PT"}, 252 | "keepLinesTogether": true, 253 | "keepWithNext": true 254 | } 255 | }, 256 | { 257 | "namedStyleType": "HEADING_3", 258 | "textStyle": { 259 | "bold": false, 260 | "foregroundColor": { 261 | "color": { 262 | "rgbColor": { 263 | "red": 0.2627451, 264 | "green": 0.2627451, 265 | "blue": 0.2627451 266 | } 267 | } 268 | }, 269 | "fontSize": {"magnitude": 14, "unit": "PT"} 270 | }, 271 | "paragraphStyle": { 272 | "namedStyleType": "NORMAL_TEXT", 273 | "direction": "LEFT_TO_RIGHT", 274 | "spaceAbove": {"magnitude": 16, "unit": "PT"}, 275 | "spaceBelow": {"magnitude": 4, "unit": "PT"}, 276 | "keepLinesTogether": true, 277 | "keepWithNext": true 278 | } 279 | }, 280 | { 281 | "namedStyleType": "HEADING_4", 282 | "textStyle": { 283 | "foregroundColor": { 284 | "color": {"rgbColor": {"red": 0.4, "green": 0.4, "blue": 0.4}} 285 | }, 286 | "fontSize": {"magnitude": 12, "unit": "PT"} 287 | }, 288 | "paragraphStyle": { 289 | "namedStyleType": "NORMAL_TEXT", 290 | "direction": "LEFT_TO_RIGHT", 291 | "spaceAbove": {"magnitude": 14, "unit": "PT"}, 292 | "spaceBelow": {"magnitude": 4, "unit": "PT"}, 293 | "keepLinesTogether": true, 294 | "keepWithNext": true 295 | } 296 | }, 297 | { 298 | "namedStyleType": "HEADING_5", 299 | "textStyle": { 300 | "foregroundColor": { 301 | "color": {"rgbColor": {"red": 0.4, "green": 0.4, "blue": 0.4}} 302 | }, 303 | "fontSize": {"magnitude": 11, "unit": "PT"} 304 | }, 305 | "paragraphStyle": { 306 | "namedStyleType": "NORMAL_TEXT", 307 | "direction": "LEFT_TO_RIGHT", 308 | "spaceAbove": {"magnitude": 12, "unit": "PT"}, 309 | "spaceBelow": {"magnitude": 4, "unit": "PT"}, 310 | "keepLinesTogether": true, 311 | "keepWithNext": true 312 | } 313 | }, 314 | { 315 | "namedStyleType": "HEADING_6", 316 | "textStyle": { 317 | "italic": true, 318 | "foregroundColor": { 319 | "color": {"rgbColor": {"red": 0.4, "green": 0.4, "blue": 0.4}} 320 | }, 321 | "fontSize": {"magnitude": 11, "unit": "PT"} 322 | }, 323 | "paragraphStyle": { 324 | "namedStyleType": "NORMAL_TEXT", 325 | "direction": "LEFT_TO_RIGHT", 326 | "spaceAbove": {"magnitude": 12, "unit": "PT"}, 327 | "spaceBelow": {"magnitude": 4, "unit": "PT"}, 328 | "keepLinesTogether": true, 329 | "keepWithNext": true 330 | } 331 | }, 332 | { 333 | "namedStyleType": "TITLE", 334 | "textStyle": {"fontSize": {"magnitude": 26, "unit": "PT"}}, 335 | "paragraphStyle": { 336 | "namedStyleType": "NORMAL_TEXT", 337 | "direction": "LEFT_TO_RIGHT", 338 | "spaceAbove": {"unit": "PT"}, 339 | "spaceBelow": {"magnitude": 3, "unit": "PT"}, 340 | "keepLinesTogether": true, 341 | "keepWithNext": true 342 | } 343 | }, 344 | { 345 | "namedStyleType": "SUBTITLE", 346 | "textStyle": { 347 | "italic": false, 348 | "foregroundColor": { 349 | "color": {"rgbColor": {"red": 0.4, "green": 0.4, "blue": 0.4}} 350 | }, 351 | "fontSize": {"magnitude": 15, "unit": "PT"}, 352 | "weightedFontFamily": {"fontFamily": "Arial", "weight": 400} 353 | }, 354 | "paragraphStyle": { 355 | "namedStyleType": "NORMAL_TEXT", 356 | "direction": "LEFT_TO_RIGHT", 357 | "spaceAbove": {"unit": "PT"}, 358 | "spaceBelow": {"magnitude": 16, "unit": "PT"}, 359 | "keepLinesTogether": true, 360 | "keepWithNext": true 361 | } 362 | } 363 | ] 364 | }, 365 | "lists": { 366 | "kix.2ndctxh20b9f": { 367 | "listProperties": { 368 | "nestingLevels": [ 369 | { 370 | "bulletAlignment": "START", 371 | "glyphSymbol": "●", 372 | "glyphFormat": "%0", 373 | "indentFirstLine": {"magnitude": 18, "unit": "PT"}, 374 | "indentStart": {"magnitude": 36, "unit": "PT"}, 375 | "textStyle": {"underline": false}, 376 | "startNumber": 1 377 | }, 378 | { 379 | "bulletAlignment": "START", 380 | "glyphSymbol": "○", 381 | "glyphFormat": "%1", 382 | "indentFirstLine": {"magnitude": 54, "unit": "PT"}, 383 | "indentStart": {"magnitude": 72, "unit": "PT"}, 384 | "textStyle": {"underline": false}, 385 | "startNumber": 1 386 | }, 387 | { 388 | "bulletAlignment": "START", 389 | "glyphSymbol": "■", 390 | "glyphFormat": "%2", 391 | "indentFirstLine": {"magnitude": 90, "unit": "PT"}, 392 | "indentStart": {"magnitude": 108, "unit": "PT"}, 393 | "textStyle": {"underline": false}, 394 | "startNumber": 1 395 | }, 396 | { 397 | "bulletAlignment": "START", 398 | "glyphSymbol": "●", 399 | "glyphFormat": "%3", 400 | "indentFirstLine": {"magnitude": 126, "unit": "PT"}, 401 | "indentStart": {"magnitude": 144, "unit": "PT"}, 402 | "textStyle": {"underline": false}, 403 | "startNumber": 1 404 | }, 405 | { 406 | "bulletAlignment": "START", 407 | "glyphSymbol": "○", 408 | "glyphFormat": "%4", 409 | "indentFirstLine": {"magnitude": 162, "unit": "PT"}, 410 | "indentStart": {"magnitude": 180, "unit": "PT"}, 411 | "textStyle": {"underline": false}, 412 | "startNumber": 1 413 | }, 414 | { 415 | "bulletAlignment": "START", 416 | "glyphSymbol": "■", 417 | "glyphFormat": "%5", 418 | "indentFirstLine": {"magnitude": 198, "unit": "PT"}, 419 | "indentStart": {"magnitude": 216, "unit": "PT"}, 420 | "textStyle": {"underline": false}, 421 | "startNumber": 1 422 | }, 423 | { 424 | "bulletAlignment": "START", 425 | "glyphSymbol": "●", 426 | "glyphFormat": "%6", 427 | "indentFirstLine": {"magnitude": 234, "unit": "PT"}, 428 | "indentStart": {"magnitude": 252, "unit": "PT"}, 429 | "textStyle": {"underline": false}, 430 | "startNumber": 1 431 | }, 432 | { 433 | "bulletAlignment": "START", 434 | "glyphSymbol": "○", 435 | "glyphFormat": "%7", 436 | "indentFirstLine": {"magnitude": 270, "unit": "PT"}, 437 | "indentStart": {"magnitude": 288, "unit": "PT"}, 438 | "textStyle": {"underline": false}, 439 | "startNumber": 1 440 | }, 441 | { 442 | "bulletAlignment": "START", 443 | "glyphSymbol": "■", 444 | "glyphFormat": "%8", 445 | "indentFirstLine": {"magnitude": 306, "unit": "PT"}, 446 | "indentStart": {"magnitude": 324, "unit": "PT"}, 447 | "textStyle": {"underline": false}, 448 | "startNumber": 1 449 | } 450 | ] 451 | } 452 | } 453 | }, 454 | "revisionId": "ALm37BXFZMyF6sAvtQkSYTvfnNMV8sGuiYDNrLQDSwG6crTJFRUN9OK3Ph4-6T-pUkz8nYz1m3tG8qoRK1icFA", 455 | "suggestionsViewMode": "SUGGESTIONS_INLINE", 456 | "documentId": "1BM_DE77JGmhxa4XrXXaDYuAjQGVjf3MCupETAgbUCQg" 457 | } 458 | -------------------------------------------------------------------------------- /utils/google-document.js: -------------------------------------------------------------------------------- 1 | const json2md = require("json2md") 2 | const yamljs = require("yamljs") 3 | const _get = require("lodash/get") 4 | const _repeat = require("lodash/repeat") 5 | const _merge = require("lodash/merge") 6 | 7 | const {isCodeBlocks, isQuote} = require("./google-document-types") 8 | const {DEFAULT_OPTIONS} = require("./constants") 9 | 10 | const HORIZONTAL_TAB_CHAR = "\x09" 11 | const GOOGLE_DOCS_INDENT = 18 12 | 13 | class GoogleDocument { 14 | constructor({document, properties = {}, options = {}, links = {}}) { 15 | this.document = document 16 | this.links = links 17 | this.properties = properties 18 | this.options = _merge({}, DEFAULT_OPTIONS, options) 19 | 20 | this.cover = null 21 | this.elements = [] 22 | this.headings = [] 23 | this.footnotes = {} 24 | this.related = [] 25 | 26 | // Keep the class scope in loops 27 | this.formatText = this.formatText.bind(this) 28 | this.normalizeElement = this.normalizeElement.bind(this) 29 | 30 | this.process() 31 | } 32 | 33 | formatText(el, {inlineImages = false, namedStyleType = "NORMAL_TEXT"} = {}) { 34 | if (el.inlineObjectElement) { 35 | const image = this.getImage(el) 36 | if (image) { 37 | if (inlineImages) { 38 | return `![${image.alt}](${image.source} "${image.title}")` 39 | } 40 | this.elements.push({ 41 | type: "img", 42 | value: image, 43 | }) 44 | } 45 | } 46 | 47 | // Person tag 48 | if (el.person) { 49 | return el.person.personProperties.name 50 | } 51 | 52 | // Rich link 53 | if (el.richLink) { 54 | const props = el.richLink.richLinkProperties 55 | return `[${props.title}](${props.uri})` 56 | } 57 | 58 | if (!el.textRun || !el.textRun.content || !el.textRun.content.trim()) { 59 | return "" 60 | } 61 | 62 | let before = "" 63 | let after = "" 64 | let text = el.textRun.content 65 | .replace(/\n$/, "") // Remove new lines 66 | .replace(/“|”/g, '"') // Replace smart quotes by double quotes 67 | .replace(/\u000b/g, "
") // Replace soft lines breaks, vertical tabs 68 | const contentMatch = text.match(/^(\s*)(\S+(?:[ \t\v]*\S+)*)(\s*)$/) // Match "text", "before" and "after" 69 | 70 | if (contentMatch) { 71 | before = contentMatch[1] 72 | text = contentMatch[2] 73 | after = contentMatch[3] 74 | } 75 | 76 | const defaultStyle = this.getTextStyle(namedStyleType) 77 | const textStyle = el.textRun.textStyle 78 | const style = this.options.keepDefaultStyle 79 | ? _merge({}, defaultStyle, textStyle) 80 | : textStyle 81 | 82 | const { 83 | backgroundColor, 84 | baselineOffset, 85 | bold, 86 | fontSize, 87 | foregroundColor, 88 | italic, 89 | link, 90 | strikethrough, 91 | underline, 92 | weightedFontFamily: {fontFamily} = {}, 93 | } = style 94 | 95 | const isInlineCode = fontFamily === "Consolas" 96 | if (isInlineCode) { 97 | if (this.options.skipCodes) return text 98 | 99 | return "`" + text + "`" 100 | } 101 | 102 | const styles = [] 103 | 104 | text = text.replace(/\*/g, "\\*") // Prevent * to be bold 105 | text = text.replace(/_/g, "\\_") // Prevent _ to be italic 106 | 107 | if (baselineOffset === "SUPERSCRIPT") { 108 | text = `${text}` 109 | } 110 | 111 | if (baselineOffset === "SUBSCRIPT") { 112 | text = `${text}` 113 | } 114 | 115 | if (underline && !link) { 116 | text = `${text}` 117 | } 118 | 119 | if (italic) { 120 | text = `_${text}_` 121 | } 122 | 123 | if (bold) { 124 | text = `**${text}**` 125 | } 126 | 127 | if (strikethrough) { 128 | text = `~~${text}~~` 129 | } 130 | 131 | if (fontSize) { 132 | const em = (fontSize.magnitude / this.bodyFontSize).toFixed(2) 133 | if (em !== "1.00") { 134 | styles.push(`font-size:${em}em`) 135 | } 136 | } 137 | 138 | if (_get(foregroundColor, ["color", "rgbColor"]) && !link) { 139 | const {rgbColor} = foregroundColor.color 140 | const red = Math.round((rgbColor.red || 0) * 255) 141 | const green = Math.round((rgbColor.green || 0) * 255) 142 | const blue = Math.round((rgbColor.blue || 0) * 255) 143 | if (red !== 0 || green !== 0 || blue !== 0) { 144 | styles.push(`color:rgb(${red}, ${green}, ${blue})`) 145 | } 146 | } 147 | 148 | if (_get(backgroundColor, ["color", "rgbColor"]) && !link) { 149 | const {rgbColor} = backgroundColor.color 150 | const red = Math.round((rgbColor.red || 0) * 255) 151 | const green = Math.round((rgbColor.green || 0) * 255) 152 | const blue = Math.round((rgbColor.blue || 0) * 255) 153 | styles.push(`background-color:rgb(${red}, ${green}, ${blue})`) 154 | } 155 | 156 | if (styles.length > 0) { 157 | text = `${text}` 158 | } 159 | 160 | if (link) { 161 | return `${before}[${text}](${link.url})${after}` 162 | } 163 | 164 | return before + text + after 165 | } 166 | 167 | getTextStyle(type) { 168 | const documentStyles = _get(this.document, ["namedStyles", "styles"]) 169 | 170 | if (!documentStyles) return {} 171 | 172 | const style = documentStyles.find((style) => style.namedStyleType === type) 173 | return style.textStyle 174 | } 175 | 176 | getImage(el) { 177 | if (this.options.skipImages) return 178 | 179 | const {inlineObjects} = this.document 180 | 181 | if (!inlineObjects || !el.inlineObjectElement) { 182 | return 183 | } 184 | 185 | const inlineObject = inlineObjects[el.inlineObjectElement.inlineObjectId] 186 | const embeddedObject = inlineObject.inlineObjectProperties.embeddedObject 187 | 188 | return { 189 | source: embeddedObject.imageProperties.contentUri, 190 | title: embeddedObject.title || "", 191 | alt: embeddedObject.description || "", 192 | } 193 | } 194 | 195 | processCover() { 196 | const {headers, documentStyle} = this.document 197 | const firstPageHeaderId = _get(documentStyle, ["firstPageHeaderId"]) 198 | 199 | if (!firstPageHeaderId) { 200 | return 201 | } 202 | 203 | const headerElement = _get(headers[firstPageHeaderId], [ 204 | "content", 205 | 0, 206 | "paragraph", 207 | "elements", 208 | 0, 209 | ]) 210 | 211 | const image = this.getImage(headerElement) 212 | 213 | if (image) { 214 | this.cover = { 215 | image: image.source, 216 | title: image.title, 217 | alt: image.alt, 218 | } 219 | } 220 | } 221 | 222 | getTableCellContent(content) { 223 | return content 224 | .map((contentElement) => { 225 | const hasParagraph = contentElement.paragraph 226 | 227 | if (!hasParagraph) return "" 228 | return contentElement.paragraph.elements.map(this.formatText).join("") 229 | }) 230 | .join("") 231 | } 232 | 233 | indentText(text, level) { 234 | return `${_repeat(HORIZONTAL_TAB_CHAR, level)}${text}` 235 | } 236 | 237 | stringifyContent(tagContent) { 238 | return tagContent.join("").replace(/\n$/, "") 239 | } 240 | 241 | appendToList({list, listItem, elementLevel, level}) { 242 | const lastItem = list[list.length - 1] 243 | 244 | if (listItem.level > level) { 245 | if (typeof lastItem === "object") { 246 | this.appendToList({ 247 | list: lastItem.value, 248 | listItem, 249 | elementLevel, 250 | level: level + 1, 251 | }) 252 | } else { 253 | list.push({ 254 | type: listItem.tag, 255 | value: [listItem.text], 256 | }) 257 | } 258 | } else { 259 | list.push(listItem.text) 260 | } 261 | } 262 | 263 | getListTag(listId, level) { 264 | const glyph = _get(this.document, [ 265 | "lists", 266 | listId, 267 | "listProperties", 268 | "nestingLevels", 269 | level, 270 | "glyphType", 271 | ]) 272 | 273 | return glyph ? "ol" : "ul" 274 | } 275 | 276 | processList(paragraph, index) { 277 | if (this.options.skipLists) return 278 | 279 | const prevListId = _get(this.document, [ 280 | "body", 281 | "content", 282 | index - 1, 283 | "paragraph", 284 | "bullet", 285 | "listId", 286 | ]) 287 | const isPrevList = prevListId === paragraph.bullet.listId 288 | const prevList = _get(this.elements, [this.elements.length - 1, "value"]) 289 | const text = this.stringifyContent( 290 | paragraph.elements.map((el) => this.formatText(el, {inlineImages: true})) 291 | ) 292 | 293 | if (isPrevList && Array.isArray(prevList)) { 294 | const {nestingLevel} = paragraph.bullet 295 | 296 | if (nestingLevel) { 297 | this.appendToList({ 298 | list: prevList, 299 | listItem: { 300 | text, 301 | level: nestingLevel, 302 | tag: this.getListTag(paragraph.bullet.listId, prevList.length), 303 | }, 304 | level: 0, 305 | }) 306 | } else { 307 | prevList.push(text) 308 | } 309 | } else { 310 | this.elements.push({ 311 | type: this.getListTag(paragraph.bullet.listId, 0), 312 | value: [text], 313 | }) 314 | } 315 | } 316 | 317 | processParagraph(paragraph, index) { 318 | const tags = { 319 | HEADING_1: "h1", 320 | HEADING_2: "h2", 321 | HEADING_3: "h3", 322 | HEADING_4: "h4", 323 | HEADING_5: "h5", 324 | HEADING_6: "h6", 325 | NORMAL_TEXT: "p", 326 | SUBTITLE: "h2", 327 | TITLE: "h1", 328 | } 329 | const namedStyleType = paragraph.paragraphStyle.namedStyleType 330 | const tag = tags[namedStyleType] 331 | const isHeading = tag.startsWith("h") 332 | 333 | // Lists 334 | if (paragraph.bullet) { 335 | this.processList(paragraph, index) 336 | return 337 | } 338 | 339 | let tagContentArray = [] 340 | 341 | paragraph.elements.forEach((el) => { 342 | if (el.pageBreak) { 343 | return 344 | } 345 | 346 | //
347 | else if (el.horizontalRule) { 348 | tagContentArray.push("
") 349 | } 350 | 351 | // Footnotes 352 | else if (el.footnoteReference) { 353 | if (this.options.skipFootnotes) return 354 | 355 | tagContentArray.push(`[^${el.footnoteReference.footnoteNumber}]`) 356 | this.footnotes[el.footnoteReference.footnoteId] = 357 | el.footnoteReference.footnoteNumber 358 | } 359 | 360 | // Headings 361 | else if (isHeading) { 362 | if (this.options.skipHeadings) return 363 | 364 | const text = this.formatText(el, { 365 | namedStyleType, 366 | }) 367 | 368 | if (text) { 369 | tagContentArray.push(text) 370 | } 371 | } 372 | 373 | // Texts 374 | else { 375 | const text = this.formatText(el) 376 | 377 | if (text) { 378 | tagContentArray.push(text) 379 | } 380 | } 381 | }) 382 | 383 | if (tagContentArray.length === 0) return 384 | 385 | let content = this.stringifyContent(tagContentArray) 386 | let tagIndentLevel = 0 387 | 388 | if (paragraph.paragraphStyle.indentStart) { 389 | const {magnitude} = paragraph.paragraphStyle.indentStart 390 | tagIndentLevel = Math.round(magnitude / GOOGLE_DOCS_INDENT) 391 | } 392 | 393 | if (tagIndentLevel > 0) { 394 | content = this.indentText(content, tagIndentLevel) 395 | } 396 | 397 | this.elements.push({ 398 | type: tag, 399 | value: content, 400 | }) 401 | 402 | if (isHeading) { 403 | this.headings.push({tag, text: content, index: this.elements.length - 1}) 404 | } 405 | } 406 | 407 | processQuote(table) { 408 | if (this.options.skipQuotes) return 409 | 410 | const firstRow = table.tableRows[0] 411 | const firstCell = firstRow.tableCells[0] 412 | const quote = this.getTableCellContent(firstCell.content) 413 | const blockquote = quote.replace(/“|”/g, "") // Delete smart-quotes 414 | 415 | this.elements.push({type: "blockquote", value: blockquote}) 416 | } 417 | 418 | processCode(table) { 419 | if (this.options.skipCodes) return 420 | 421 | const firstRow = table.tableRows[0] 422 | const firstCell = firstRow.tableCells[0] 423 | const codeContent = firstCell.content 424 | .map(({paragraph}) => 425 | paragraph.elements.map((el) => el.textRun.content).join("") 426 | ) 427 | .join("") // Transform to string 428 | .replace(/\x0B/g, "\n") // Replace vertical tabs 429 | .replace(/^\n|\n$/g, "") // Remove new lines characters at the beginning and end of line 430 | .split("\n") // Transform to array 431 | 432 | // "".split() -> [""] 433 | if (codeContent.length === 1 && codeContent[0] === "") return 434 | 435 | let lang = null 436 | const langMatch = codeContent[0].match(/^\s*lang:\s*(.*)$/) 437 | 438 | if (langMatch) { 439 | codeContent.shift() 440 | lang = langMatch[1] 441 | } 442 | 443 | this.elements.push({ 444 | type: "code", 445 | value: { 446 | language: lang, 447 | content: codeContent, 448 | }, 449 | }) 450 | } 451 | 452 | processTable(table) { 453 | if (this.options.skipTables) return 454 | 455 | const [thead, ...tbody] = table.tableRows 456 | 457 | this.elements.push({ 458 | type: "table", 459 | value: { 460 | headers: thead.tableCells.map(({content}) => 461 | this.getTableCellContent(content) 462 | ), 463 | rows: tbody.map((row) => 464 | row.tableCells.map(({content}) => this.getTableCellContent(content)) 465 | ), 466 | }, 467 | }) 468 | } 469 | 470 | processFootnotes() { 471 | if (this.options.skipFootnotes) return 472 | 473 | const footnotes = [] 474 | const documentFootnotes = this.document.footnotes 475 | 476 | if (!documentFootnotes) return 477 | 478 | Object.entries(documentFootnotes).forEach(([, value]) => { 479 | const paragraphElements = value.content[0].paragraph.elements 480 | const tagContentArray = paragraphElements.map(this.formatText) 481 | const tagContentString = this.stringifyContent(tagContentArray) 482 | 483 | footnotes.push({ 484 | type: "footnote", 485 | value: { 486 | number: this.footnotes[value.footnoteId], 487 | text: tagContentString, 488 | }, 489 | }) 490 | }) 491 | 492 | footnotes.sort( 493 | (footnote1, footnote2) => 494 | parseInt(footnote1.value.number) - parseInt(footnote2.value.number) 495 | ) 496 | 497 | this.elements.push(...footnotes) 498 | } 499 | 500 | processDemoteHeadings() { 501 | this.headings.forEach((heading) => { 502 | const levelevel = Number(heading.tag.substring(1)) 503 | const newLevel = levelevel < 6 ? levelevel + 1 : levelevel 504 | this.elements[heading.index] = {type: "h" + newLevel, value: heading.text} 505 | }) 506 | } 507 | 508 | processInternalLinks() { 509 | if (Object.keys(this.links).length > 0) { 510 | const elementsStringified = JSON.stringify(this.elements) 511 | 512 | const elementsStringifiedWithRelativePaths = elementsStringified.replace( 513 | /https:\/\/docs.google.com\/document\/(?:u\/\d+\/)?d\/([a-zA-Z0-9_-]+)(?:\/edit|\/preview)?/g, 514 | (match, id) => { 515 | if (this.links[id]) { 516 | this.related.push(id) 517 | return this.links[id] 518 | } 519 | 520 | return match 521 | } 522 | ) 523 | 524 | this.elements = JSON.parse(elementsStringifiedWithRelativePaths) 525 | } 526 | } 527 | 528 | process() { 529 | this.bodyFontSize = _get( 530 | this.getTextStyle("NORMAL_TEXT"), 531 | "fontSize.magnitude" 532 | ) 533 | 534 | this.processCover() 535 | 536 | this.document.body.content.forEach( 537 | ({paragraph, table, sectionBreak, tableOfContents}, i) => { 538 | // Unsupported elements 539 | if (sectionBreak || tableOfContents) { 540 | return 541 | } 542 | 543 | if (table) { 544 | // Quotes 545 | if (isQuote(table)) { 546 | this.processQuote(table) 547 | } 548 | 549 | // Code Blocks 550 | else if (isCodeBlocks(table)) { 551 | this.processCode(table) 552 | } 553 | 554 | // Tables 555 | else { 556 | this.processTable(table) 557 | } 558 | } 559 | 560 | // Paragraphs 561 | else { 562 | this.processParagraph(paragraph, i) 563 | } 564 | } 565 | ) 566 | 567 | // Footnotes 568 | this.processFootnotes() 569 | 570 | // h1 -> h2, h2 -> h3, ... 571 | if (this.options.demoteHeadings === true) { 572 | this.processDemoteHeadings() 573 | } 574 | 575 | this.processInternalLinks() 576 | } 577 | 578 | normalizeElement(element) { 579 | if (element.type && element.value) { 580 | return {[element.type]: this.normalizeElement(element.value)} 581 | } 582 | 583 | if (Array.isArray(element)) { 584 | return element.map(this.normalizeElement) 585 | } 586 | 587 | return element 588 | } 589 | 590 | toMarkdown() { 591 | const frontmatter = { 592 | ...this.properties, 593 | ...(this.cover ? {cover: this.cover} : {}), 594 | } 595 | const json = this.elements.map(this.normalizeElement) 596 | const markdownContent = json2md(json) 597 | const markdownFrontmatter = 598 | Object.keys(frontmatter).length > 0 599 | ? `---\n${yamljs.stringify(frontmatter)}---\n` 600 | : "" 601 | 602 | return `${markdownFrontmatter}${markdownContent}` 603 | } 604 | } 605 | 606 | // Add extra converter for footnotes 607 | json2md.converters.footnote = function (footnote) { 608 | return `[^${footnote.number}]:${footnote.text}` 609 | } 610 | 611 | module.exports = { 612 | GoogleDocument: GoogleDocument, 613 | } 614 | -------------------------------------------------------------------------------- /__tests__/documents/links.json: -------------------------------------------------------------------------------- 1 | { 2 | "title": "Links", 3 | "body": { 4 | "content": [ 5 | { 6 | "endIndex": 1, 7 | "sectionBreak": { 8 | "sectionStyle": { 9 | "columnSeparatorStyle": "NONE", 10 | "contentDirection": "LEFT_TO_RIGHT", 11 | "sectionType": "CONTINUOUS" 12 | } 13 | } 14 | }, 15 | { 16 | "startIndex": 1, 17 | "endIndex": 9, 18 | "paragraph": { 19 | "elements": [ 20 | { 21 | "startIndex": 1, 22 | "endIndex": 8, 23 | "textRun": { 24 | "content": "to self", 25 | "textStyle": { 26 | "underline": true, 27 | "foregroundColor": { 28 | "color": { 29 | "rgbColor": { 30 | "red": 0.06666667, 31 | "green": 0.33333334, 32 | "blue": 0.8 33 | } 34 | } 35 | }, 36 | "link": { 37 | "url": "https://docs.google.com/document/d/1UuBxtIEEVh98wyBR9fMmLqzJEkmNlAMMoC4SEhprHfQ" 38 | } 39 | } 40 | } 41 | }, 42 | { 43 | "startIndex": 8, 44 | "endIndex": 9, 45 | "textRun": { 46 | "content": "\n", 47 | "textStyle": {} 48 | } 49 | } 50 | ], 51 | "paragraphStyle": { 52 | "namedStyleType": "NORMAL_TEXT", 53 | "direction": "LEFT_TO_RIGHT" 54 | } 55 | } 56 | }, 57 | { 58 | "startIndex": 9, 59 | "endIndex": 10, 60 | "paragraph": { 61 | "elements": [ 62 | { 63 | "startIndex": 9, 64 | "endIndex": 10, 65 | "textRun": { 66 | "content": "\n", 67 | "textStyle": {} 68 | } 69 | } 70 | ], 71 | "paragraphStyle": { 72 | "namedStyleType": "NORMAL_TEXT", 73 | "direction": "LEFT_TO_RIGHT" 74 | } 75 | } 76 | }, 77 | { 78 | "startIndex": 10, 79 | "endIndex": 31, 80 | "paragraph": { 81 | "elements": [ 82 | { 83 | "startIndex": 10, 84 | "endIndex": 30, 85 | "textRun": { 86 | "content": "to self with user id", 87 | "textStyle": { 88 | "underline": true, 89 | "foregroundColor": { 90 | "color": { 91 | "rgbColor": { 92 | "red": 0.06666667, 93 | "green": 0.33333334, 94 | "blue": 0.8 95 | } 96 | } 97 | }, 98 | "link": { 99 | "url": "https://docs.google.com/document/u/1/d/1UuBxtIEEVh98wyBR9fMmLqzJEkmNlAMMoC4SEhprHfQ" 100 | } 101 | } 102 | } 103 | }, 104 | { 105 | "startIndex": 30, 106 | "endIndex": 31, 107 | "textRun": { 108 | "content": "\n", 109 | "textStyle": {} 110 | } 111 | } 112 | ], 113 | "paragraphStyle": { 114 | "namedStyleType": "NORMAL_TEXT", 115 | "direction": "LEFT_TO_RIGHT" 116 | } 117 | } 118 | }, 119 | { 120 | "startIndex": 31, 121 | "endIndex": 32, 122 | "paragraph": { 123 | "elements": [ 124 | { 125 | "startIndex": 31, 126 | "endIndex": 32, 127 | "textRun": { 128 | "content": "\n", 129 | "textStyle": {} 130 | } 131 | } 132 | ], 133 | "paragraphStyle": { 134 | "namedStyleType": "NORMAL_TEXT", 135 | "direction": "LEFT_TO_RIGHT" 136 | } 137 | } 138 | }, 139 | { 140 | "startIndex": 32, 141 | "endIndex": 50, 142 | "paragraph": { 143 | "elements": [ 144 | { 145 | "startIndex": 32, 146 | "endIndex": 49, 147 | "textRun": { 148 | "content": "to self with edit", 149 | "textStyle": { 150 | "underline": true, 151 | "foregroundColor": { 152 | "color": { 153 | "rgbColor": { 154 | "red": 0.06666667, 155 | "green": 0.33333334, 156 | "blue": 0.8 157 | } 158 | } 159 | }, 160 | "link": { 161 | "url": "https://docs.google.com/document/d/1UuBxtIEEVh98wyBR9fMmLqzJEkmNlAMMoC4SEhprHfQ/edit" 162 | } 163 | } 164 | } 165 | }, 166 | { 167 | "startIndex": 49, 168 | "endIndex": 50, 169 | "textRun": { 170 | "content": "\n", 171 | "textStyle": {} 172 | } 173 | } 174 | ], 175 | "paragraphStyle": { 176 | "namedStyleType": "NORMAL_TEXT", 177 | "direction": "LEFT_TO_RIGHT" 178 | } 179 | } 180 | }, 181 | { 182 | "startIndex": 50, 183 | "endIndex": 51, 184 | "paragraph": { 185 | "elements": [ 186 | { 187 | "startIndex": 50, 188 | "endIndex": 51, 189 | "textRun": { 190 | "content": "\n", 191 | "textStyle": {} 192 | } 193 | } 194 | ], 195 | "paragraphStyle": { 196 | "namedStyleType": "NORMAL_TEXT", 197 | "direction": "LEFT_TO_RIGHT" 198 | } 199 | } 200 | }, 201 | { 202 | "startIndex": 51, 203 | "endIndex": 72, 204 | "paragraph": { 205 | "elements": [ 206 | { 207 | "startIndex": 51, 208 | "endIndex": 71, 209 | "textRun": { 210 | "content": "to self with preview", 211 | "textStyle": { 212 | "underline": true, 213 | "foregroundColor": { 214 | "color": { 215 | "rgbColor": { 216 | "red": 0.06666667, 217 | "green": 0.33333334, 218 | "blue": 0.8 219 | } 220 | } 221 | }, 222 | "link": { 223 | "url": "https://docs.google.com/document/d/1UuBxtIEEVh98wyBR9fMmLqzJEkmNlAMMoC4SEhprHfQ/preview" 224 | } 225 | } 226 | } 227 | }, 228 | { 229 | "startIndex": 71, 230 | "endIndex": 72, 231 | "textRun": { 232 | "content": "\n", 233 | "textStyle": {} 234 | } 235 | } 236 | ], 237 | "paragraphStyle": { 238 | "namedStyleType": "NORMAL_TEXT", 239 | "direction": "LEFT_TO_RIGHT" 240 | } 241 | } 242 | }, 243 | { 244 | "startIndex": 73, 245 | "endIndex": 93, 246 | "paragraph": { 247 | "elements": [ 248 | { 249 | "startIndex": 51, 250 | "endIndex": 71, 251 | "textRun": { 252 | "content": "unknown url", 253 | "textStyle": { 254 | "underline": true, 255 | "foregroundColor": { 256 | "color": { 257 | "rgbColor": { 258 | "red": 0.06666667, 259 | "green": 0.33333334, 260 | "blue": 0.8 261 | } 262 | } 263 | }, 264 | "link": { 265 | "url": "https://docs.google.com/document/d/unknown" 266 | } 267 | } 268 | } 269 | }, 270 | { 271 | "startIndex": 71, 272 | "endIndex": 72, 273 | "textRun": { 274 | "content": "\n", 275 | "textStyle": {} 276 | } 277 | } 278 | ], 279 | "paragraphStyle": { 280 | "namedStyleType": "NORMAL_TEXT", 281 | "direction": "LEFT_TO_RIGHT" 282 | } 283 | } 284 | } 285 | ] 286 | }, 287 | "documentStyle": { 288 | "background": { 289 | "color": {} 290 | }, 291 | "pageNumberStart": 1, 292 | "marginTop": { 293 | "magnitude": 72, 294 | "unit": "PT" 295 | }, 296 | "marginBottom": { 297 | "magnitude": 72, 298 | "unit": "PT" 299 | }, 300 | "marginRight": { 301 | "magnitude": 72, 302 | "unit": "PT" 303 | }, 304 | "marginLeft": { 305 | "magnitude": 72, 306 | "unit": "PT" 307 | }, 308 | "pageSize": { 309 | "height": { 310 | "magnitude": 792, 311 | "unit": "PT" 312 | }, 313 | "width": { 314 | "magnitude": 612, 315 | "unit": "PT" 316 | } 317 | }, 318 | "marginHeader": { 319 | "magnitude": 36, 320 | "unit": "PT" 321 | }, 322 | "marginFooter": { 323 | "magnitude": 36, 324 | "unit": "PT" 325 | }, 326 | "useCustomHeaderFooterMargins": true 327 | }, 328 | "namedStyles": { 329 | "styles": [ 330 | { 331 | "namedStyleType": "NORMAL_TEXT", 332 | "textStyle": { 333 | "bold": false, 334 | "italic": false, 335 | "underline": false, 336 | "strikethrough": false, 337 | "smallCaps": false, 338 | "backgroundColor": {}, 339 | "foregroundColor": { 340 | "color": { 341 | "rgbColor": {} 342 | } 343 | }, 344 | "fontSize": { 345 | "magnitude": 11, 346 | "unit": "PT" 347 | }, 348 | "weightedFontFamily": { 349 | "fontFamily": "Arial", 350 | "weight": 400 351 | }, 352 | "baselineOffset": "NONE" 353 | }, 354 | "paragraphStyle": { 355 | "namedStyleType": "NORMAL_TEXT", 356 | "alignment": "START", 357 | "lineSpacing": 115, 358 | "direction": "LEFT_TO_RIGHT", 359 | "spacingMode": "COLLAPSE_LISTS", 360 | "spaceAbove": { 361 | "unit": "PT" 362 | }, 363 | "spaceBelow": { 364 | "unit": "PT" 365 | }, 366 | "borderBetween": { 367 | "color": {}, 368 | "width": { 369 | "unit": "PT" 370 | }, 371 | "padding": { 372 | "unit": "PT" 373 | }, 374 | "dashStyle": "SOLID" 375 | }, 376 | "borderTop": { 377 | "color": {}, 378 | "width": { 379 | "unit": "PT" 380 | }, 381 | "padding": { 382 | "unit": "PT" 383 | }, 384 | "dashStyle": "SOLID" 385 | }, 386 | "borderBottom": { 387 | "color": {}, 388 | "width": { 389 | "unit": "PT" 390 | }, 391 | "padding": { 392 | "unit": "PT" 393 | }, 394 | "dashStyle": "SOLID" 395 | }, 396 | "borderLeft": { 397 | "color": {}, 398 | "width": { 399 | "unit": "PT" 400 | }, 401 | "padding": { 402 | "unit": "PT" 403 | }, 404 | "dashStyle": "SOLID" 405 | }, 406 | "borderRight": { 407 | "color": {}, 408 | "width": { 409 | "unit": "PT" 410 | }, 411 | "padding": { 412 | "unit": "PT" 413 | }, 414 | "dashStyle": "SOLID" 415 | }, 416 | "indentFirstLine": { 417 | "unit": "PT" 418 | }, 419 | "indentStart": { 420 | "unit": "PT" 421 | }, 422 | "indentEnd": { 423 | "unit": "PT" 424 | }, 425 | "keepLinesTogether": false, 426 | "keepWithNext": false, 427 | "avoidWidowAndOrphan": true, 428 | "shading": { 429 | "backgroundColor": {} 430 | } 431 | } 432 | }, 433 | { 434 | "namedStyleType": "HEADING_1", 435 | "textStyle": { 436 | "fontSize": { 437 | "magnitude": 20, 438 | "unit": "PT" 439 | } 440 | }, 441 | "paragraphStyle": { 442 | "namedStyleType": "NORMAL_TEXT", 443 | "direction": "LEFT_TO_RIGHT", 444 | "spaceAbove": { 445 | "magnitude": 20, 446 | "unit": "PT" 447 | }, 448 | "spaceBelow": { 449 | "magnitude": 6, 450 | "unit": "PT" 451 | }, 452 | "keepLinesTogether": true, 453 | "keepWithNext": true 454 | } 455 | }, 456 | { 457 | "namedStyleType": "HEADING_2", 458 | "textStyle": { 459 | "bold": true, 460 | "underline": true 461 | }, 462 | "paragraphStyle": { 463 | "headingId": "h.crrpgju8ws84", 464 | "namedStyleType": "HEADING_2", 465 | "direction": "LEFT_TO_RIGHT", 466 | "spaceAbove": { 467 | "magnitude": 18, 468 | "unit": "PT" 469 | }, 470 | "spaceBelow": { 471 | "magnitude": 6, 472 | "unit": "PT" 473 | }, 474 | "keepLinesTogether": true, 475 | "keepWithNext": true 476 | } 477 | }, 478 | { 479 | "namedStyleType": "HEADING_3", 480 | "textStyle": { 481 | "bold": true 482 | }, 483 | "paragraphStyle": { 484 | "headingId": "h.yrkcs963nbig", 485 | "namedStyleType": "HEADING_3", 486 | "direction": "LEFT_TO_RIGHT", 487 | "spaceAbove": { 488 | "magnitude": 16, 489 | "unit": "PT" 490 | }, 491 | "spaceBelow": { 492 | "magnitude": 4, 493 | "unit": "PT" 494 | }, 495 | "keepLinesTogether": true, 496 | "keepWithNext": true 497 | } 498 | }, 499 | { 500 | "namedStyleType": "HEADING_4", 501 | "textStyle": { 502 | "underline": true 503 | }, 504 | "paragraphStyle": { 505 | "headingId": "h.2pgom3u1ze8f", 506 | "namedStyleType": "HEADING_4", 507 | "direction": "LEFT_TO_RIGHT", 508 | "spaceAbove": { 509 | "magnitude": 14, 510 | "unit": "PT" 511 | }, 512 | "spaceBelow": { 513 | "magnitude": 4, 514 | "unit": "PT" 515 | }, 516 | "keepLinesTogether": true, 517 | "keepWithNext": true 518 | } 519 | }, 520 | { 521 | "namedStyleType": "HEADING_5", 522 | "textStyle": { 523 | "foregroundColor": { 524 | "color": { 525 | "rgbColor": { 526 | "red": 0.4, 527 | "green": 0.4, 528 | "blue": 0.4 529 | } 530 | } 531 | }, 532 | "fontSize": { 533 | "magnitude": 11, 534 | "unit": "PT" 535 | } 536 | }, 537 | "paragraphStyle": { 538 | "namedStyleType": "NORMAL_TEXT", 539 | "direction": "LEFT_TO_RIGHT", 540 | "spaceAbove": { 541 | "magnitude": 12, 542 | "unit": "PT" 543 | }, 544 | "spaceBelow": { 545 | "magnitude": 4, 546 | "unit": "PT" 547 | }, 548 | "keepLinesTogether": true, 549 | "keepWithNext": true 550 | } 551 | }, 552 | { 553 | "namedStyleType": "HEADING_6", 554 | "textStyle": { 555 | "italic": true, 556 | "foregroundColor": { 557 | "color": { 558 | "rgbColor": { 559 | "red": 0.4, 560 | "green": 0.4, 561 | "blue": 0.4 562 | } 563 | } 564 | }, 565 | "fontSize": { 566 | "magnitude": 11, 567 | "unit": "PT" 568 | } 569 | }, 570 | "paragraphStyle": { 571 | "namedStyleType": "NORMAL_TEXT", 572 | "direction": "LEFT_TO_RIGHT", 573 | "spaceAbove": { 574 | "magnitude": 12, 575 | "unit": "PT" 576 | }, 577 | "spaceBelow": { 578 | "magnitude": 4, 579 | "unit": "PT" 580 | }, 581 | "keepLinesTogether": true, 582 | "keepWithNext": true 583 | } 584 | }, 585 | { 586 | "namedStyleType": "TITLE", 587 | "textStyle": { 588 | "bold": true, 589 | "fontSize": { 590 | "magnitude": 18, 591 | "unit": "PT" 592 | } 593 | }, 594 | "paragraphStyle": { 595 | "headingId": "h.v0sy8ume7683", 596 | "namedStyleType": "TITLE", 597 | "alignment": "CENTER", 598 | "direction": "LEFT_TO_RIGHT", 599 | "spaceBelow": { 600 | "magnitude": 3, 601 | "unit": "PT" 602 | }, 603 | "keepLinesTogether": true, 604 | "keepWithNext": true 605 | } 606 | }, 607 | { 608 | "namedStyleType": "SUBTITLE", 609 | "textStyle": { 610 | "italic": false, 611 | "foregroundColor": { 612 | "color": { 613 | "rgbColor": { 614 | "red": 0.4, 615 | "green": 0.4, 616 | "blue": 0.4 617 | } 618 | } 619 | }, 620 | "fontSize": { 621 | "magnitude": 15, 622 | "unit": "PT" 623 | }, 624 | "weightedFontFamily": { 625 | "fontFamily": "Arial", 626 | "weight": 400 627 | } 628 | }, 629 | "paragraphStyle": { 630 | "namedStyleType": "NORMAL_TEXT", 631 | "direction": "LEFT_TO_RIGHT", 632 | "spaceAbove": { 633 | "unit": "PT" 634 | }, 635 | "spaceBelow": { 636 | "magnitude": 16, 637 | "unit": "PT" 638 | }, 639 | "keepLinesTogether": true, 640 | "keepWithNext": true 641 | } 642 | } 643 | ] 644 | }, 645 | "revisionId": "ALm37BX_6dRY7ymQxjaUV7wPyC3GsVLNFxPNp7tlKNo6tshN9jZCFXNFhN0kAnzlUJi8684QZ9uDa4I72wQq7w", 646 | "suggestionsViewMode": "SUGGESTIONS_INLINE", 647 | "documentId": "1UuBxtIEEVh98wyBR9fMmLqzJEkmNlAMMoC4SEhprHfQ" 648 | } 649 | --------------------------------------------------------------------------------