95 | « {mdx.frontmatter.title}
96 |
97 | 98 | {formatPostDate(mdx.frontmatter.date)} 99 | {` • ${formatReadingTime(mdx.timeToRead)}`} 100 |
101 |58 | Written by {author} 59 | {shortBio ? ` ${shortBio}` : ''}.{` `} 60 | {social.twitter ? ( 61 | 62 | You should follow them on Twitter. 63 | 64 | ) : null} 65 |
66 |
22 | preProps.children.props.name === 'code'
23 | ) {
24 | // we have a situation
25 | const {
26 | children: codeString,
27 | props: { className, ...props },
28 | } = preProps.children.props
29 |
30 | return {
31 | codeString: codeString.trim(),
32 | language: className && className.split('-')[1],
33 | ...props,
34 | }
35 | }
36 | if (preProps.children && typeof preProps.children === 'string') {
37 | const { children, className, ...props } = preProps
38 | return {
39 | codeString: children.trim(),
40 | language: className && className.split('-')[1],
41 | ...props,
42 | }
43 | }
44 | return undefined
45 | }
46 |
47 | const InlineCode = ({ codeString, language /* , ...props */ }) => {
48 | return (
49 |
55 | {({ className, style, tokens, getLineProps, getTokenProps }) => (
56 |
57 | {tokens.map((line, i) => (
58 |
59 | {line.map((token, key) => (
60 |
61 | ))}
62 |
63 | ))}
64 |
65 | )}
66 |
67 | )
68 | }
69 |
70 | const Code = ({ codeString, language /* , ...props */ }) => {
71 | return (
72 |
78 | {({ className, style, tokens, getLineProps, getTokenProps }) => (
79 |
80 | {tokens.map((line, i) => (
81 |
82 | {line.map((token, key) => (
83 |
84 | ))}
85 |
86 | ))}
87 |
88 | )}
89 |
90 | )
91 | }
92 |
93 | export const PrismjsReplacement = preProps => {
94 | const props = preToCodeBlock(preProps)
95 | // if there's a codeString and some props, we passed the test
96 | if (props) {
97 | return
98 | }
99 | // it's possible to have a pre without a code in it
100 | return
101 | }
102 |
103 | export const PrismjsReplacementInline = preProps => {
104 | const props = preToCodeBlock(preProps)
105 | // if there's a codeString and some props, we passed the test
106 | if (props) {
107 | return
108 | }
109 | // it's possible to have a pre without a code in it
110 | return
111 | }
112 |
--------------------------------------------------------------------------------
/gatsby-theme/src/components/embed.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsx-a11y/iframe-has-title */
2 | import React, { useRef, useEffect } from 'react'
3 | import Styled from '@emotion/styled'
4 |
5 | const Container = Styled.div`
6 | position: relative;
7 | `
8 |
9 | const ImageForRatio = Styled.img`
10 | display: block;
11 | height: auto;
12 | width: 100%;
13 | `
14 |
15 | const IframeWithRatio = Styled.iframe`
16 | height: 100%;
17 | left: 0;
18 | position: absolute;
19 | top: 0;
20 | width: 100%;
21 | `
22 |
23 | function Embed({ aspectRatio, src, caption }) {
24 | const iframeRef = useRef(null)
25 |
26 | useEffect(() => {
27 | const iframe = iframeRef.current
28 | if (!iframe) {
29 | return
30 | }
31 |
32 | let doc = iframe.document
33 | if (iframe.contentDocument) doc = iframe.contentDocument
34 | else if (iframe.contentWindow) doc = iframe.contentWindow.document
35 |
36 | const gistScript = ``
37 | const styles = ''
38 | const elementId = src.replace('https://gist.github.com/', '')
39 | const resizeScript = `onload="parent.document.getElementById('${elementId}').style.height=document.body.scrollHeight + 'px'"`
40 | const iframeHtml = ` ${styles}${gistScript}`
41 |
42 | doc.open()
43 | doc.writeln(iframeHtml)
44 | doc.close()
45 | }, [iframeRef, src])
46 |
47 | if (src && src.match(/^https:\/\/gist.github.com/)) {
48 | return (
49 |
50 |
57 | {caption ? {caption} : null}
58 |
59 | )
60 | }
61 |
62 | return (
63 |
64 |
65 |
72 |
73 |
74 | {caption ? {caption} : null}
75 |
76 | )
77 | }
78 |
79 | export default Embed
80 |
--------------------------------------------------------------------------------
/gatsby-theme/src/components/layout.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Global } from '@emotion/core'
3 |
4 | import theme from '../theme'
5 |
6 | const Layout = ({ children }) => (
7 |
8 |
181 | {children}
182 |
183 | )
184 |
185 | export default Layout
186 |
--------------------------------------------------------------------------------
/gatsby-theme/src/components/main-bio.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useStaticQuery, graphql } from 'gatsby'
3 | import Image from 'gatsby-image'
4 | import Styled from '@emotion/styled'
5 | import theme from '../theme'
6 |
7 | const socialURLs = {
8 | twitter: 'https://twitter.com',
9 | github: 'https://github.com',
10 | facebook: 'https://facebook.com',
11 | medium: 'https://medium.com',
12 | linkedin: 'https://linkedin.com/in',
13 | instagram: 'https://instagram.com',
14 | }
15 |
16 | const socialIcons = {
17 | twitter: (
18 |
21 | ),
22 | github: (
23 |
26 | ),
27 | facebook: (
28 |
31 | ),
32 | medium: (
33 |
36 | ),
37 | linkedin: (
38 |
41 | ),
42 | instagram: (
43 |
46 | ),
47 | }
48 |
49 | const Container = Styled.div`
50 | display: flex;
51 | margin-top: 10em;
52 | margin-bottom: 4.375rem;
53 |
54 | @media (max-width: 1024px) {
55 | flex-wrap: wrap;
56 | margin-top: 4.5em;
57 | }
58 | `
59 |
60 | const Title = Styled.h1`
61 | margin-bottom: 0.875rem;
62 | margin-top: 0;
63 | `
64 |
65 | const SocialLinks = Styled.ul`
66 | margin-bottom: 0.875rem;
67 |
68 | list-style-type: none;
69 | margin-bottom: 0;
70 | padding-left: 0;
71 |
72 | & > li {
73 | display: inline-block;
74 | margin-right: 1.5rem;
75 | margin-bottom: 0;
76 | }
77 |
78 | & > li:last-child {
79 | margin-right: 0;
80 | }
81 |
82 | & svg {
83 | width: 16px;
84 | height: 16px;
85 | }
86 |
87 | & svg path {
88 | fill: ${theme.colors.grey};
89 | }
90 |
91 | & svg:hover path {
92 | fill: ${theme.colors.primary};
93 | }
94 | `
95 |
96 | const Wrapper = Styled.div`
97 | flex: 0 0 80%;
98 | margin-right: 1.5rem;
99 |
100 | @media (max-width: 1024px) {
101 | order: 1;
102 | flex: 0 0 100%;
103 | margin-right: 0;
104 | }
105 | `
106 |
107 | const WebmentionLink = Styled.a`
108 | text-decoration: none;
109 | letter-spacing: 0;
110 | `
111 |
112 | const Avatar = Styled(Image)`
113 | margin-bottom: 0;
114 | min-width: 150px;
115 | border-radius: 100%;
116 | border: 8px solid ${theme.colors.primary};
117 | `
118 |
119 | function Bio() {
120 | const { site, avatar } = useStaticQuery(
121 | graphql`
122 | query MainBioQuery {
123 | avatar: file(absolutePath: { regex: "/avatar.png/" }) {
124 | childImageSharp {
125 | fixed(width: 150, height: 150, quality: 90) {
126 | base64
127 | width
128 | height
129 | src
130 | srcSet
131 | }
132 | }
133 | }
134 | site {
135 | siteMetadata {
136 | author
137 | bio
138 | siteUrl
139 | social {
140 | twitter
141 | github
142 | facebook
143 | medium
144 | linkedin
145 | instagram
146 | }
147 | }
148 | }
149 | }
150 | `
151 | )
152 |
153 | const { author, social, bio, siteUrl } = site.siteMetadata
154 |
155 | return (
156 |
157 |
158 |
159 | {author}
160 |
161 |
162 | {Object.keys(social).map(s =>
163 | social[s] ? (
164 |
165 |
170 | {socialIcons[s]}
171 |
172 |
173 | ) : null
174 | )}
175 |
176 | {bio}
177 |
178 |
185 |
186 | )
187 | }
188 |
189 | export default Bio
190 |
--------------------------------------------------------------------------------
/gatsby-theme/src/components/pills.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Styled from '@emotion/styled'
3 | import { capitalize } from '../utils/string'
4 | import theme from '../theme'
5 |
6 | const Pill = Styled.span`
7 | font-size: 1.5rem;
8 | display: inline-flex;
9 | align-items: center;
10 | padding: 0 1rem;
11 | height: 2.5rem;
12 | line-height: 2.5rem;
13 | margin: 0.2rem;
14 | margin-right: 10px;
15 | border-radius: 1.25rem;
16 | background-color: ${props =>
17 | theme.colors.categories[props.category]
18 | ? theme.colors.categories[props.category].background
19 | : theme.colors.primary};
20 | color: ${props =>
21 | theme.colors.categories[props.category]
22 | ? theme.colors.categories[props.category].text
23 | : theme.colors.muted};
24 |
25 | $:not(:first-child) {
26 | margin-left: 0;
27 | }
28 | `
29 |
30 | const Pills = ({ items }) => {
31 | return (
32 |
33 | {(items || []).map(item => (
34 |
35 | {capitalize(item)}
36 |
37 | ))}
38 |
39 | )
40 | }
41 |
42 | export default Pills
43 |
--------------------------------------------------------------------------------
/gatsby-theme/src/components/responses.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 | import Styled from '@emotion/styled'
3 |
4 | import { formatPostDate } from '../utils/dates'
5 | import theme from '../theme'
6 |
7 | const AvatarWrapper = Styled.div`
8 | display: flex;
9 | margin-bottom: 1rem;
10 | `
11 |
12 | const AvatarImage = Styled.img`
13 | height: 36px;
14 | width: 36px;
15 | margin-right: 0.875rem;
16 | margin-bottom: 0;
17 | border-radius: 100%;
18 | `
19 |
20 | const Time = Styled.time`
21 | margin: 0;
22 | font-size: 15px;
23 | color: ${theme.colors.grey};
24 | `
25 |
26 | const AuthorNameWrapper = Styled.p`
27 | margin: 0;
28 | line-height: 0.6px;
29 | `
30 |
31 | const AuthorName = Styled.a`
32 | font-size: 16px;
33 | text-decoration: none;
34 | line-height: 0;
35 | color: ${theme.colors.text};
36 | `
37 |
38 | function Avatar({ author, published }) {
39 | return (
40 |
41 |
42 |
43 |
44 |
49 | {author.name}
50 |
51 |
52 |
53 |
54 |
55 | )
56 | }
57 |
58 | const ShowReponses = Styled.button`
59 | cursor: pointer;
60 | width: 100%;
61 | color: ${theme.colors.text};
62 | border: 1px solid ${theme.colors.primary};
63 | border-radius: 3px;
64 | padding: 20px;
65 | text-align: center;
66 | font-size: 14px;
67 | `
68 |
69 | const Wrapper = Styled.ul`
70 | list-style: none;
71 | padding: 0;
72 |
73 | & li {
74 | box-shadow: ${theme.shadows.box};
75 | border: 1px solid ${theme.colors.separator};
76 | padding: 15px 20px;
77 | }
78 |
79 | & li + li {
80 | margin-top: 1.5rem;
81 | }
82 | `
83 |
84 | const VideoContent = Styled.div`
85 | text-align: center;
86 |
87 | & video {
88 | max-width: 100%;
89 | }
90 | `
91 |
92 | const ResponseLink = Styled.a`
93 | text-decoration: none;
94 | `
95 |
96 | function Responses({ responses }) {
97 | const [showingResponses, setShowingResponses] = useState(false)
98 |
99 | if (!responses.length) {
100 | return null
101 | }
102 |
103 | if (!showingResponses) {
104 | return (
105 | setShowingResponses(true)}>
106 | See {responses.length} response{responses.length > 1 ? 's' : ''}
107 |
108 | )
109 | }
110 |
111 | return (
112 |
113 | {responses.map(response => (
114 |
115 |
120 |
121 | {response.content && response.content.text}
122 |
123 | {response.video && response.video.length
124 | ? response.video.map(v => (
125 |
132 | ))
133 | : null}
134 |
135 |
136 |
137 | ))}
138 |
139 | )
140 | }
141 |
142 | export default Responses
143 |
--------------------------------------------------------------------------------
/gatsby-theme/src/components/section.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Styled from '@emotion/styled'
3 |
4 | const Wrapper = Styled.section`
5 | ${props =>
6 | props.centered
7 | ? `display: flex;
8 | align-items: center;
9 | justify-content: center;
10 | flex-direction: column;`
11 | : ''}
12 | `
13 |
14 | const Container = Styled.div`
15 | max-width: ${props => (props.big ? '960px' : '700px')};
16 | margin: 0 auto;
17 | width: 80%;
18 | `
19 |
20 | const Section = ({ name, centered, children, big }) => {
21 | return (
22 |
23 | {children}
24 |
25 | )
26 | }
27 |
28 | export default Section
29 |
--------------------------------------------------------------------------------
/gatsby-theme/src/components/seo.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Helmet from 'react-helmet'
3 | import { useStaticQuery, graphql } from 'gatsby'
4 |
5 | function SEO({
6 | description,
7 | lang,
8 | meta,
9 | keywords,
10 | title,
11 | canonicalLink,
12 | image,
13 | }) {
14 | const { site, avatar } = useStaticQuery(
15 | graphql`
16 | query {
17 | avatar: file(absolutePath: { regex: "/avatar.png/" }) {
18 | childImageSharp {
19 | fixed(width: 150, height: 150, quality: 90) {
20 | src
21 | }
22 | }
23 | }
24 | site {
25 | siteMetadata {
26 | siteUrl
27 | title
28 | description
29 | author
30 | social {
31 | twitter
32 | }
33 | }
34 | }
35 | }
36 | `
37 | )
38 |
39 | const fullURL = path =>
40 | path ? `${site.siteMetadata.siteUrl}${path}` : site.siteUrl
41 |
42 | const metaDescription = description || site.siteMetadata.description
43 | const metaTitle = title || site.siteMetadata.title
44 |
45 | // If no image is provided lets use the avatar
46 | const socialImage = image || avatar.childImageSharp.fixed.src
47 |
48 | return (
49 | 0
110 | ? {
111 | name: `keywords`,
112 | content: keywords.join(`, `),
113 | }
114 | : []
115 | )
116 | .concat(meta)}
117 | link={[].concat(
118 | canonicalLink
119 | ? {
120 | rel: `canonical`,
121 | href: canonicalLink,
122 | }
123 | : []
124 | )}
125 | />
126 | )
127 | }
128 |
129 | SEO.defaultProps = {
130 | lang: `en`,
131 | meta: [],
132 | keywords: [],
133 | description: ``,
134 | }
135 |
136 | export default SEO
137 |
--------------------------------------------------------------------------------
/gatsby-theme/src/components/wrap-root-element.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { MDXProvider } from '@mdx-js/react'
3 | import Embed from './embed'
4 | import {
5 | PrismjsReplacementInline,
6 | PrismjsReplacement,
7 | } from './code-highlighting'
8 |
9 | export const wrapRootElement = ({ element }) => (
10 |
17 | {element}
18 |
19 | )
20 |
--------------------------------------------------------------------------------
/gatsby-theme/src/pages/404.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | import Layout from '../components/layout'
4 | import SEO from '../components/seo'
5 |
6 | const NotFoundPage = () => (
7 |
8 |
9 | NOT FOUND
10 | You just hit a route that doesn't exist... the sadness.
11 |
12 | )
13 |
14 | export default NotFoundPage
15 |
--------------------------------------------------------------------------------
/gatsby-theme/src/pages/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { graphql, Link } from 'gatsby'
3 | import Styled from '@emotion/styled'
4 |
5 | import Layout from '../components/layout'
6 | import SEO from '../components/seo'
7 | import Section from '../components/section'
8 | import Pills from '../components/pills'
9 | import MainBio from '../components/main-bio'
10 | import { formatPostDate, formatReadingTime } from '../utils/dates'
11 |
12 | const BlogListing = Styled(Link)`
13 | display: block;
14 | margin-top: 2em;
15 | margin-bottom: 2em;
16 | text-decoration: none;
17 |
18 | & p {
19 | margin-top: 1rem;
20 | }
21 |
22 | & h1 {
23 | margin-bottom: 1rem;
24 | }
25 | `
26 |
27 | const BlogIndexPage = ({ data: { allMdx } }) => (
28 |
29 |
30 |
31 |
32 |
33 |
34 | {allMdx.nodes.map(post => (
35 |
36 |
37 | {post.frontmatter.title}
38 |
39 | {formatPostDate(post.frontmatter.date)}
40 | {` • ${formatReadingTime(post.timeToRead)}`}
41 |
42 |
43 | {post.frontmatter.description}
44 |
45 |
46 | ))}
47 |
48 | )
49 |
50 | export default BlogIndexPage
51 |
52 | export const query = graphql`
53 | query BlogIndex {
54 | allMdx(
55 | filter: { fields: { published: { eq: true } } }
56 | sort: { fields: [frontmatter___date], order: DESC }
57 | ) {
58 | nodes {
59 | fields {
60 | slug
61 | }
62 | timeToRead
63 | frontmatter {
64 | title
65 | description
66 | categories
67 | date(formatString: "MMMM DD, YYYY")
68 | }
69 | }
70 | }
71 | }
72 | `
73 |
--------------------------------------------------------------------------------
/gatsby-theme/src/templates/blog-post.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Link, graphql } from 'gatsby'
3 | import { MDXRenderer } from 'gatsby-plugin-mdx'
4 | import Styled from '@emotion/styled'
5 |
6 | import SEO from '../components/seo'
7 | import Section from '../components/section'
8 | import Pills from '../components/pills'
9 | import Bio from '../components/bio'
10 | import Embed from '../components/embed'
11 | import Responses from '../components/responses'
12 | import Layout from '../components/layout'
13 | import { formatPostDate, formatReadingTime } from '../utils/dates'
14 | import theme from '../theme'
15 | import withDefaults from '../../utils/default-options'
16 |
17 | const BackLink = Styled(Link)`
18 | text-decoration: none;
19 | font-size: 48px;
20 | color: ${theme.colors.primary};
21 | margin-left: -40px;
22 | `
23 |
24 | const Header = Styled.header`
25 | margin-top: 5em;
26 | margin-bottom: 2em;
27 |
28 | & p {
29 | font-size: 0.75em;
30 | }
31 | `
32 |
33 | const Footer = Styled.footer`
34 | margin-top: 4em;
35 | text-align: left;
36 |
37 | & small {
38 | font-size: 0.8em;
39 | opacity: 0.7;
40 | }
41 |
42 | & small a {
43 | font-size: 17px;
44 | }
45 | `
46 |
47 | const Separator = Styled.hr`
48 | margin: 24px 0;
49 | border: 0;
50 | background: ${theme.colors.separator};
51 | height: 1px;
52 | `
53 |
54 | const Likes = Styled.a`
55 | float: right;
56 | text-decoration: none;
57 | margin-top: 0;
58 | `
59 |
60 | export default function PageTemplate({
61 | data: { mdx, site, allWebMentionEntry },
62 | pageContext,
63 | }) {
64 | const { previous, next, permalink, themeOptions } = pageContext
65 |
66 | const options = withDefaults(themeOptions)
67 |
68 | const webmentions = (allWebMentionEntry || {}).nodes || []
69 |
70 | const likes = webmentions.filter(x => x.wmProperty === 'like-of')
71 | const responses = webmentions.filter(x => x.wmProperty === 'in-reply-to')
72 |
73 | return (
74 |
75 |
91 |
92 |
93 |
94 |
95 | « {mdx.frontmatter.title}
96 |
97 |
98 | {formatPostDate(mdx.frontmatter.date)}
99 | {` • ${formatReadingTime(mdx.timeToRead)}`}
100 |
101 |
102 |
103 |
104 | {mdx.body}
105 |
106 |
174 |
175 |
176 | )
177 | }
178 |
179 | export const pageQuery = graphql`
180 | query BlogPostQuery($id: String, $permalink: String) {
181 | site {
182 | siteMetadata {
183 | siteUrl
184 | githubUrl
185 | }
186 | }
187 | mdx(id: { eq: $id }) {
188 | fields {
189 | slug
190 | }
191 | excerpt
192 | timeToRead
193 | frontmatter {
194 | title
195 | description
196 | categories
197 | date(formatString: "MMMM DD, YYYY")
198 | canonical_link
199 | }
200 | body
201 | }
202 | allWebMentionEntry(filter: { wmTarget: { eq: $permalink } }) {
203 | nodes {
204 | wmProperty
205 | wmId
206 | url
207 | wmReceived
208 | author {
209 | url
210 | photo
211 | name
212 | }
213 | content {
214 | text
215 | }
216 | video
217 | }
218 | }
219 | }
220 | `
221 |
--------------------------------------------------------------------------------
/gatsby-theme/src/theme.js:
--------------------------------------------------------------------------------
1 | const colors = {
2 | text: 'rgba(0, 0, 0, 0.84)',
3 | muted: 'rgba(0, 0, 0, 0.68)',
4 | grey: 'rgba(0, 0, 0, 0.54)',
5 | background: '#ffffff',
6 | codeBackground: 'rgb(243, 243, 243)',
7 | primary: 'lavender',
8 | boxShadow: 'rgba(0, 0, 0, 0.04)',
9 | separator: 'rgba(0, 0, 0, 0.09)',
10 | categories: {
11 | // the background colors of post's categories
12 | },
13 | }
14 |
15 | const fonts = {
16 | sansSerif:
17 | "-apple-system, BlinkMacSystemFont, Avenir, 'Avenir Next', 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif",
18 | monospace:
19 | "'SFMono-Regular', Menlo, Monaco, 'Courier New', Courier, monospace",
20 | }
21 |
22 | const shadows = {
23 | box: `0 1px 4px ${colors.boxShadow}`,
24 | }
25 |
26 | module.exports = {
27 | colors,
28 | fonts,
29 | shadows,
30 | highlighting: {
31 | plain: {
32 | color: colors.text,
33 | backgroundColor: colors.codeBackground,
34 | },
35 | styles: [
36 | {
37 | types: ['comment', 'prolog', 'doctype', 'cdata'],
38 | style: {
39 | color: '#999988',
40 | fontStyle: 'italic',
41 | },
42 | },
43 | {
44 | types: ['namespace'],
45 | style: {
46 | opacity: 0.7,
47 | },
48 | },
49 | {
50 | types: ['string', 'attr-value'],
51 | style: {
52 | color: '#e3116c',
53 | },
54 | },
55 | {
56 | types: ['punctuation', 'operator'],
57 | style: {
58 | color: '#393A34',
59 | },
60 | },
61 | {
62 | types: [
63 | 'entity',
64 | 'url',
65 | 'symbol',
66 | 'number',
67 | 'boolean',
68 | 'variable',
69 | 'constant',
70 | 'property',
71 | 'regex',
72 | 'inserted',
73 | ],
74 | style: {
75 | color: '#36acaa',
76 | },
77 | },
78 | {
79 | types: ['atrule', 'keyword', 'attr-name', 'selector'],
80 | style: {
81 | color: '#00a4db',
82 | },
83 | },
84 | {
85 | types: ['function', 'deleted', 'tag'],
86 | style: {
87 | color: '#d73a49',
88 | },
89 | },
90 | {
91 | types: ['function-variable'],
92 | style: {
93 | color: '#6f42c1',
94 | },
95 | },
96 | {
97 | types: ['tag', 'selector', 'keyword'],
98 | style: {
99 | color: '#00009f',
100 | },
101 | },
102 | ],
103 | },
104 | }
105 |
--------------------------------------------------------------------------------
/gatsby-theme/src/utils/dates.js:
--------------------------------------------------------------------------------
1 | export function formatReadingTime(minutes) {
2 | const cups = Math.round(minutes / 5)
3 | if (cups > 5) {
4 | return `${new Array(Math.round(cups / Math.E))
5 | .fill('🍱')
6 | .join('')} ${minutes} min read`
7 | }
8 | return `${new Array(cups || 1).fill('☕️').join('')} ${minutes} min read`
9 | }
10 |
11 | export function formatPostDate(date) {
12 | if (typeof Date.prototype.toLocaleDateString !== 'function') {
13 | return date
14 | }
15 |
16 | return new Date(date).toLocaleDateString('en', {
17 | day: 'numeric',
18 | month: 'long',
19 | year: 'numeric',
20 | })
21 | }
22 |
--------------------------------------------------------------------------------
/gatsby-theme/src/utils/string.js:
--------------------------------------------------------------------------------
1 | export function capitalize(str) {
2 | const head = str[0]
3 | const tail = str.slice(1)
4 | return `${head.toUpperCase()}${tail}`
5 | }
6 |
--------------------------------------------------------------------------------
/gatsby-theme/static/admin/config.yml:
--------------------------------------------------------------------------------
1 | backend:
2 | name: git-gateway
3 | branch: master
4 | media_folder: static/media
5 | public_folder: /media
6 | collections:
7 | - name: 'blog'
8 | label: 'Blog'
9 | folder: 'content'
10 | create: true
11 | path: '{{slug}}/index'
12 | media_folder: ''
13 | public_folder: ''
14 | fields:
15 | - { label: 'Title', name: 'title', widget: 'string' }
16 | - {
17 | label: 'Description',
18 | name: 'description',
19 | widget: 'string',
20 | required: false,
21 | }
22 | - { label: 'Publish Date', name: 'date', widget: 'datetime' }
23 | - {
24 | label: 'Published',
25 | name: 'published',
26 | widget: 'boolean',
27 | required: false,
28 | }
29 | - {
30 | label: 'Categories',
31 | name: 'categories',
32 | widget: 'list',
33 | collapsed: false,
34 | field: { label: 'Category', name: 'category', widget: 'string' },
35 | }
36 | - {
37 | label: 'Canonical Link',
38 | name: 'canonical_link',
39 | widget: 'string',
40 | required: false,
41 | }
42 | - {
43 | label: 'Redirect From',
44 | name: 'redirect_from',
45 | widget: 'list',
46 | collapsed: false,
47 | field: { label: 'Redirect', name: 'redirect', widget: 'string' },
48 | }
49 | - { label: 'Body', name: 'body', widget: 'markdown' }
50 |
--------------------------------------------------------------------------------
/gatsby-theme/static/prism-theme.css:
--------------------------------------------------------------------------------
1 | .token.comment,
2 | .token.prolog,
3 | .token.doctype,
4 | .token.cdata {
5 | color: #546a90;
6 | }
7 |
8 | .token.punctuation {
9 | color: rgb(119, 116, 156);
10 | }
11 |
12 | .namespace {
13 | opacity: 0.7;
14 | }
15 |
16 | .token.property,
17 | .token.tag,
18 | .token.constant,
19 | .token.symbol,
20 | .token.deleted {
21 | color: #f6416c;
22 | }
23 |
24 | .token.boolean,
25 | .token.number {
26 | color: #ffd400;
27 | }
28 |
29 | .token.selector,
30 | .token.attr-name,
31 | .token.string,
32 | .token.char,
33 | .token.inserted {
34 | color: #7984d1;
35 | }
36 |
37 | .token.builtin {
38 | color: #df85ff;
39 | }
40 |
41 | .token.operator,
42 | .token.entity,
43 | .token.url,
44 | .language-css .token.string,
45 | .style .token.string,
46 | .token.variable {
47 | color: #f8f8f2;
48 | }
49 |
50 | .token.atrule,
51 | .token.attr-value,
52 | .token.function {
53 | color: #ff7a60;
54 | }
55 |
56 | .token.class-name {
57 | color: #a875ff;
58 | }
59 |
60 | .token.keyword {
61 | color: #fdbed4;
62 | }
63 |
64 | .token.regex,
65 | .token.important {
66 | color: #ffb86c;
67 | }
68 |
69 | .token.important,
70 | .token.bold {
71 | font-weight: bold;
72 | }
73 |
74 | .token.italic {
75 | font-style: italic;
76 | }
77 |
78 | .token.entity {
79 | cursor: help;
80 | }
81 |
--------------------------------------------------------------------------------
/gatsby-theme/static/robot.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Disallow:
3 |
--------------------------------------------------------------------------------
/gatsby-theme/utils/default-options.js:
--------------------------------------------------------------------------------
1 | module.exports = themeOptions => {
2 | const pathPrefix = themeOptions.pathPrefix || ``
3 | const contentPath = themeOptions.contentPath || `content`
4 | const imagesPath = themeOptions.imagesPath || `src/images`
5 |
6 | const config = themeOptions.config || {}
7 |
8 | return {
9 | ...themeOptions,
10 | pathPrefix,
11 | contentPath,
12 | imagesPath,
13 | config,
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/lib/add-gatsby-files.js:
--------------------------------------------------------------------------------
1 | const fs = require('fs-extra')
2 | const path = require('path')
3 | const { withOutputPath } = require('./utils')
4 |
5 | module.exports.addGatsbyFiles = profile => {
6 | function replaceTemplate(content) {
7 | return content
8 | .replace(/{{ mediumUsername }}/g, profile.mediumUsername || '')
9 | .replace(/{{ authorName }}/g, profile.authorName || '')
10 | .replace(/{{ authorEmail }}/g, profile.authorEmail || '')
11 | .replace(/{{ twitterUsername }}/g, profile.twitterUsername || '')
12 | .replace(/{{ facebookUsername }}/g, profile.facebookUsername || '')
13 | .replace(/{{ bio }}/g, profile.bio || '')
14 | }
15 |
16 | function copyTemplate(fileName) {
17 | return fs
18 | .readFile(path.join(__dirname, `../gatsby-template/${fileName}`), 'utf8')
19 | .then(replaceTemplate)
20 | .then(content =>
21 | fs.writeFile(withOutputPath(profile, `./${fileName}`), content)
22 | )
23 | }
24 |
25 | return Promise.all([
26 | copyTemplate('README.md'),
27 | copyTemplate('package.json'),
28 | copyTemplate('netlify.toml'),
29 | copyTemplate('gatsby-node.js'),
30 | copyTemplate('gatsby-config.js'),
31 | copyTemplate('Dockerfile'),
32 | copyTemplate('config.js'),
33 | fs.writeFile(
34 | withOutputPath(profile, '.gitignore'),
35 | `# Logs
36 | logs
37 | *.log
38 | npm-debug.log*
39 | yarn-debug.log*
40 | yarn-error.log*
41 |
42 | # Runtime data
43 | pids
44 | *.pid
45 | *.seed
46 | *.pid.lock
47 |
48 | # Directory for instrumented libs generated by jscoverage/JSCover
49 | lib-cov
50 |
51 | # Coverage directory used by tools like istanbul
52 | coverage
53 |
54 | # nyc test coverage
55 | .nyc_output
56 |
57 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
58 | .grunt
59 |
60 | # Bower dependency directory (https://bower.io/)
61 | bower_components
62 |
63 | # node-waf configuration
64 | .lock-wscript
65 |
66 | # Compiled binary addons (http://nodejs.org/api/addons.html)
67 | build/Release
68 |
69 | # Dependency directories
70 | node_modules/
71 | jspm_packages/
72 |
73 | # Typescript v1 declaration files
74 | typings/
75 |
76 | # Optional npm cache directory
77 | .npm
78 |
79 | # Optional eslint cache
80 | .eslintcache
81 |
82 | # Optional REPL history
83 | .node_repl_history
84 |
85 | # Output of 'npm pack'
86 | *.tgz
87 |
88 | # dotenv environment variables file
89 | .env
90 |
91 | # gatsby files
92 | .cache/
93 | public
94 |
95 | # Mac files
96 | .DS_Store
97 |
98 | # Yarn
99 | yarn-error.log
100 | .pnp/
101 | .pnp.js
102 | # Yarn Integrity file
103 | .yarn-integrity
104 | `
105 | ),
106 | copyTemplate('.dockerignore'),
107 | ])
108 | }
109 |
--------------------------------------------------------------------------------
/lib/default-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/mathieudutour/medium-to-own-blog/c9e88263d592926c8fc0acda9ebecd940c43d3d7/lib/default-icon.png
--------------------------------------------------------------------------------
/lib/generate-md.js:
--------------------------------------------------------------------------------
1 | const { JSDOM } = require('jsdom')
2 | const { withOutputPath } = require('./utils')
3 | const {
4 | getMarkdownFromOnlinePost,
5 | getMarkdownFromLocalPost,
6 | } = require('./import-article-from-medium')
7 |
8 | module.exports.getMarkdownFromPost = async (
9 | profile,
10 | localContent,
11 | fileName
12 | ) => {
13 | try {
14 | const localDom = new JSDOM(localContent).window.document
15 |
16 | if (localDom.querySelector('.p-canonical')) {
17 | const canonicalLink = localDom
18 | .querySelector('.p-canonical')
19 | .attributes.getNamedItem('href').value
20 |
21 | await getMarkdownFromOnlinePost(
22 | withOutputPath(profile, './content'),
23 | canonicalLink,
24 | localDom
25 | )
26 | } else {
27 | await getMarkdownFromLocalPost(
28 | withOutputPath(profile, './content'),
29 | localDom
30 | )
31 | }
32 | } catch (err) {
33 | err.message = `Error parsing ${fileName}: ${err.message}`
34 | throw err
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/lib/get-profile.js:
--------------------------------------------------------------------------------
1 | const { JSDOM } = require('jsdom')
2 | const fs = require('fs-extra')
3 | const path = require('path')
4 | const Jimp = require('jimp')
5 | const { request, withOutputPath } = require('./utils')
6 |
7 | module.exports.getProfile = async zip => {
8 | const profile = {}
9 |
10 | const profileContent = await zip.file('profile/profile.html').async('text')
11 |
12 | const profileDom = new JSDOM(profileContent).window.document
13 | profileDom.querySelectorAll('li').forEach(l => {
14 | const { textContent } = l
15 | if (textContent.startsWith('Profile: @')) {
16 | profile.mediumUsername = textContent.replace('Profile: @', '')
17 | }
18 | if (textContent.startsWith('Display name: ')) {
19 | profile.authorName = textContent.replace('Display name: ', '')
20 | }
21 | if (textContent.startsWith('Email address: ')) {
22 | profile.authorEmail = textContent.replace('Email address: ', '')
23 | }
24 | if (textContent.startsWith('Twitter: @')) {
25 | profile.twitterUsername = textContent.replace('Twitter: @', '')
26 | }
27 | if (textContent.startsWith('Facebook: ')) {
28 | profile.facebookUsername = textContent.replace('Facebook: ', '')
29 | }
30 | })
31 |
32 | if (!profile.mediumUsername) {
33 | throw new Error(`Could not parse the Medium profile: \n${profileContent}`)
34 | }
35 |
36 | await fs.mkdirp(withOutputPath(profile, './content'))
37 | await fs.mkdirp(withOutputPath(profile, './src/images'))
38 |
39 | let avatarBuffer
40 |
41 | if (profileDom.querySelector('img.u-photo')) {
42 | const avatarUrl = profileDom
43 | .querySelector('img.u-photo')
44 | .attributes.getNamedItem('src').value
45 |
46 | avatarBuffer = await request(avatarUrl, { encoding: null }).catch(() => {})
47 | }
48 |
49 | if (!avatarBuffer) {
50 | avatarBuffer = await fs.readFile(path.join(__dirname, './default-icon.png'))
51 | }
52 |
53 | await fs.writeFile(
54 | withOutputPath(profile, './src/images/icon.png'),
55 | avatarBuffer
56 | )
57 |
58 | const jimp = await Jimp.read(avatarBuffer)
59 | await jimp
60 | .resize(150, 150)
61 | .writeAsync(withOutputPath(profile, './src/images/avatar.png'))
62 |
63 | const profileRemoteContent = await request(
64 | `https://medium.com/@${profile.mediumUsername}`
65 | ).catch(() => {})
66 |
67 | if (!profileRemoteContent) {
68 | profile.bio = ''
69 | } else {
70 | const profileRemoteDom = new JSDOM(profileRemoteContent).window.document
71 | profile.bio = profileRemoteDom
72 | .querySelector("meta[name='description']")
73 | .attributes.getNamedItem('content')
74 | .value.replace(`Read writing from ${profile.authorName} on Medium. `, '')
75 | .replace(
76 | ` Every day, ${profile.authorName} and thousands of other voices read, write, and share important stories on Medium.`,
77 | ''
78 | )
79 | }
80 |
81 | return profile
82 | }
83 |
--------------------------------------------------------------------------------
/lib/import-article-from-medium.js:
--------------------------------------------------------------------------------
1 | const querystring = require('querystring')
2 | const path = require('path')
3 | const fs = require('fs-extra')
4 | const { JSDOM } = require('jsdom')
5 | const TurndownService = require('turndown')
6 | const slugify = require('slugify')
7 | const { request } = require('./utils')
8 |
9 | // allowed mkdir character regex
10 | const directoryRegex = /[^\w\s$_+~()'!\-@]/g
11 |
12 | let untitledCounter = 0
13 | let imageDownloader = []
14 | const iframeParser = {}
15 |
16 | function replaceIframe(content, iframe, caption = '') {
17 | const source = iframe.attributes.getNamedItem('src').value
18 |
19 | if (iframeParser[source]) {
20 | // we already parsed an iframe pointing to the same thing
21 | // so return the result (or the placeholder if it isn't finished)
22 |
23 | if (!iframeParser[source].result && !iframeParser[source].caption) {
24 | iframeParser[source].caption = typeof caption !== 'string' ? '' : caption
25 | }
26 |
27 | return `\n\n${iframeParser[source].result ||
28 | iframeParser[source].placeholder ||
29 | ''}\n\n`
30 | }
31 |
32 | const placeholder = `Embed placeholder ${Math.random()}`
33 |
34 | let aspectRatioPlaceholder = iframe
35 |
36 | while (
37 | !aspectRatioPlaceholder.classList.contains('aspectRatioPlaceholder') &&
38 | aspectRatioPlaceholder.parentNode
39 | ) {
40 | aspectRatioPlaceholder = aspectRatioPlaceholder.parentNode
41 | }
42 |
43 | const aspectRatioFill =
44 | aspectRatioPlaceholder &&
45 | aspectRatioPlaceholder.querySelector('.aspectRatioPlaceholder-fill')
46 |
47 | const aspectRatio = aspectRatioFill
48 | ? parseFloat(aspectRatioFill.style.paddingBottom) / 100
49 | : 1
50 |
51 | iframeParser[source] = {
52 | caption: typeof caption !== 'string' ? '' : caption,
53 | placeholder,
54 | promise: request(`https://medium.com${source}`)
55 | .then(body => {
56 | const iframeDom = new JSDOM(body).window.document
57 | const nestedIframe = iframeDom.querySelector('iframe')
58 |
59 | if (!nestedIframe) {
60 | // check if it's a gist
61 | const gist = iframeDom.querySelector(
62 | 'script[src^="https://gist.github.com"]'
63 | )
64 |
65 | if (gist) {
66 | return {
67 | src: gist.attributes.getNamedItem('src').value,
68 | aspectRatio,
69 | }
70 | }
71 |
72 | // remove the placeholder if we can't find the source
73 | return {
74 | error: true,
75 | }
76 | }
77 |
78 | // something like https://cdn.embedly.com/widgets/media.html?src=https%3A%2F%2Fwww.youtube.com%2Fembed%2Fcz1t_oo6k9c%3Ffeature%3Doembed&url=http%3A%2F%2Fwww.youtube.com%2Fwatch%3Fv%3Dcz1t_oo6k9c&image=https%3A%2F%2Fi.ytimg.com%2Fvi%2Fcz1t_oo6k9c%2Fhqdefault.jpg&key=a19fcc184b9711e1b4764040d3dc5c07&type=text%2Fhtml&schema=youtube
79 | const nestedSource = nestedIframe.attributes.getNamedItem('src').value
80 | const query = querystring.parse(nestedSource.split('?')[1])
81 |
82 | return {
83 | src: query.src,
84 | url: query.url,
85 | }
86 | })
87 | .catch(() => ({ error: true })),
88 | }
89 |
90 | return `\n\n${placeholder}\n\n`
91 | }
92 |
93 | const config = {
94 | headingStyle: 'atx',
95 | hr: '---',
96 | bulletListMarker: '-',
97 | codeBlockStyle: 'fenced',
98 | blankReplacement(content, node) {
99 | if (node.nodeName === 'FIGURE') {
100 | const iframe = node.querySelector('iframe')
101 | if (iframe) {
102 | return replaceIframe('', iframe)
103 | }
104 | }
105 | if (node.nodeName === 'IFRAME') {
106 | return replaceIframe('', node)
107 | }
108 | return node.isBlock ? '\n\n' : ''
109 | },
110 | }
111 | const td = new TurndownService(config)
112 |
113 | td.addRule('iframe', {
114 | filter: ['iframe', 'IFRAME'],
115 | replacement: replaceIframe,
116 | })
117 |
118 | // parsing figure and figcaption for markdown
119 | td.addRule('figure', {
120 | filter: 'figure',
121 | replacement(content, node) {
122 | const iframe = node.querySelector('iframe')
123 | if (iframe) {
124 | return replaceIframe('', iframe, content.split('\n')[2])
125 | }
126 |
127 | // eslint-disable-next-line prefer-const
128 | let [, , element, , caption] = content.split('\n')
129 | if (caption) {
130 | // the caption
131 | element = [element.slice(0, 2), caption, element.slice(2)].join('')
132 | }
133 |
134 | return element
135 | },
136 | })
137 |
138 | // parsing code block
139 | td.addRule('code-blocks', {
140 | filter: ['pre'],
141 | replacement(content, node) {
142 | let string = ``
143 | if (!node.classList.contains('graf-after--pre')) {
144 | string += '```\n'
145 | } else {
146 | string += '\n\n'
147 | }
148 |
149 | // replace all the `
` to maintain code formatting
150 | node.querySelectorAll('br').forEach(child => child.replaceWith('\n'))
151 |
152 | string += node.textContent
153 | string += '\n'
154 |
155 | if (
156 | !node.nextElementSibling ||
157 | node.nextElementSibling.nodeName !== 'PRE'
158 | ) {
159 | string += '```'
160 | }
161 |
162 | return string
163 | },
164 | })
165 |
166 | // some `code` has siblings inside `pre`
167 | td.addRule('code', {
168 | filter(node) {
169 | const isCodeBlock = node.parentNode.nodeName === 'PRE'
170 |
171 | return node.nodeName === 'CODE' && !isCodeBlock
172 | },
173 | replacement(content) {
174 | if (!content.trim()) return ''
175 |
176 | let delimiter = '`'
177 | let leadingSpace = ''
178 | let trailingSpace = ''
179 | const matches = content.match(/`+/gm)
180 | if (matches) {
181 | if (/^`/.test(content)) leadingSpace = ' '
182 | if (/`$/.test(content)) trailingSpace = ' '
183 | while (matches.indexOf(delimiter) !== -1) delimiter += '`'
184 | }
185 |
186 | return delimiter + leadingSpace + content + trailingSpace + delimiter
187 | },
188 | })
189 |
190 | // override the default image rule to download the image from the medium CDN
191 | td.addRule('image', {
192 | filter: 'img',
193 |
194 | replacement(content, node) {
195 | const alt = node.alt || ''
196 | let src = node.getAttribute('src') || ''
197 |
198 | if (/^https:\/\/cdn-images.*\.medium\.com/.test(src)) {
199 | const cdnURL = src
200 | const filename = `asset-${imageDownloader.length + 1}${path.extname(src)}`
201 | src = `./${filename}`
202 | imageDownloader.push(
203 | request(cdnURL, { encoding: null })
204 | .then(body => ({ body, filename }))
205 | .catch(() => ({})) // we will just ignore the error
206 | )
207 | }
208 |
209 | const title = node.title || ''
210 | const titlePart = title ? ` "${title}"` : ''
211 | return src ? `` : ''
212 | },
213 | })
214 |
215 | async function writePost(contentFolder, slug, md, metadata) {
216 | const frontmatter = `---
217 | title: ${JSON.stringify(metadata.title)}
218 | description: ${JSON.stringify(metadata.description)}
219 | date: "${metadata.date}"
220 | categories: ${
221 | metadata.categories
222 | ? `
223 | ${metadata.categories.map(c => ` - ${c}`).join('\n')}
224 | `
225 | : '[]'
226 | }
227 | published: ${metadata.published ? 'true' : 'false'}${
228 | metadata.canonicalLink
229 | ? `
230 | canonical_link: ${metadata.canonicalLink}`
231 | : ''
232 | }${
233 | metadata.redirect
234 | ? `
235 | redirect_from:
236 | - /${metadata.redirect}`
237 | : ''
238 | }
239 | ---
240 |
241 | `
242 |
243 | await fs.mkdirp(path.join(contentFolder, `./${slug}`))
244 |
245 | await Promise.all(
246 | imageDownloader
247 | .map(p =>
248 | p.then(({ body, filename }) => {
249 | if (body) {
250 | fs.writeFile(
251 | path.join(contentFolder, `./${slug}/${filename}`),
252 | body
253 | )
254 | }
255 | })
256 | )
257 | .concat(
258 | Object.keys(iframeParser)
259 | .filter(k => iframeParser[k].promise)
260 | .map(k => {
261 | const { promise, placeholder, caption } = iframeParser[k]
262 | return promise.then(({ src, aspectRatio }) => {
263 | const result = ``
264 | iframeParser[k] = { result }
265 | if (src) {
266 | md = md.replace(new RegExp(placeholder, 'g'), result)
267 | } else if (placeholder) {
268 | // remove the placeholder if we can't find the source
269 | md = md.replace(new RegExp(placeholder, 'g'), '')
270 | }
271 | })
272 | })
273 | )
274 | )
275 |
276 | await fs.writeFile(
277 | path.join(contentFolder, `./${slug}/index.md`),
278 | `${frontmatter}${md}\n`
279 | )
280 |
281 | return slug
282 | }
283 |
284 | module.exports.getMarkdownFromOnlinePost = async (
285 | contentFolder,
286 | canonicalLink,
287 | localDom
288 | ) => {
289 | imageDownloader = []
290 |
291 | const metadata = {}
292 | let md = ''
293 |
294 | const onlineContent = await request(canonicalLink)
295 | const onlineDom = new JSDOM(onlineContent).window.document
296 |
297 | if (onlineDom.querySelector('.a.b.c')) {
298 | // we are in trouble. Medium recently changed their css and now
299 | // all the classes are random letters.
300 | // we could potentially try to map the letters to the proper classes
301 | // but the letters might change on every deployment depending on their
302 | // build system
303 | // so we are just going to bail out and parse the local file while printing
304 | // a warning saying that it might be a response and that there won't be tags
305 |
306 | if (!localDom) {
307 | return undefined
308 | }
309 |
310 | const redirect = canonicalLink
311 | ? path.basename(decodeURI(canonicalLink))
312 | : undefined
313 |
314 | const title =
315 | (
316 | localDom.querySelector('.p-name') || { textContent: '' }
317 | ).textContent.trim() || `Untitled Draft ${++untitledCounter}`
318 |
319 | const slug = title
320 | ? slugify(title, { remove: directoryRegex, lower: true })
321 | : redirect || ''
322 |
323 | return module.exports.getMarkdownFromLocalPost(
324 | contentFolder,
325 | localDom,
326 | canonicalLink
327 | )
328 | }
329 |
330 | if (
331 | onlineDom.querySelector('.postArticle--response') ||
332 | !onlineDom.querySelector('.postArticle-content')
333 | ) {
334 | // that's a response to another article
335 | // so we will ignore that
336 | return undefined
337 | }
338 |
339 | const tags = Array.from(onlineDom.querySelectorAll('.js-postTags li'))
340 |
341 | const titleElement = onlineDom.querySelector('.graf--title')
342 |
343 | // some articles might not have a title
344 | const title = titleElement ? titleElement.textContent : ''
345 |
346 | const redirect = path.basename(decodeURI(canonicalLink))
347 |
348 | const slug = title
349 | ? slugify(title, { remove: directoryRegex, lower: true })
350 | : redirect
351 |
352 | // remove some extra stuff from the html
353 | if (titleElement) {
354 | titleElement.remove()
355 | }
356 | if (onlineDom.querySelector('.section-divider')) {
357 | onlineDom.querySelector('.section-divider').remove()
358 | }
359 | if (onlineDom.querySelector('.js-postMetaLockup')) {
360 | onlineDom.querySelector('.js-postMetaLockup').remove()
361 | }
362 |
363 | md = td.turndown(onlineDom.querySelector('.postArticle-content'))
364 |
365 | const canonicalMeta = onlineDom.querySelector("link[rel='canonical']")
366 |
367 | metadata.title = title
368 | metadata.description = onlineDom
369 | .querySelector("meta[name='description']")
370 | .attributes.getNamedItem('content').value
371 | metadata.date = onlineDom
372 | .querySelector("meta[property='article:published_time']")
373 | .attributes.getNamedItem('content').value
374 | metadata.categories = tags.map(t => t.textContent)
375 | metadata.published = true
376 | metadata.canonicalLink = canonicalMeta
377 | ? canonicalMeta.attributes.getNamedItem('href').value
378 | : canonicalLink
379 | metadata.redirect = redirect
380 |
381 | return writePost(contentFolder, slug, md, metadata)
382 | }
383 |
384 | module.exports.getMarkdownFromLocalPost = async (
385 | contentFolder,
386 | localDom,
387 | canonicalLink
388 | ) => {
389 | imageDownloader = []
390 |
391 | const metadata = {}
392 | let md = ''
393 |
394 | const redirect = canonicalLink
395 | ? path.basename(decodeURI(canonicalLink))
396 | : undefined
397 |
398 | const title =
399 | (
400 | localDom.querySelector('.p-name') || { textContent: '' }
401 | ).textContent.trim() || `Untitled Draft ${++untitledCounter}`
402 |
403 | const slug = title
404 | ? slugify(title, { remove: directoryRegex, lower: true })
405 | : redirect || ''
406 |
407 | // remove some extra stuff from the html
408 | if (localDom.querySelector('.p-name')) {
409 | localDom.querySelector('.p-name').remove()
410 | }
411 | if (localDom.querySelector('.graf--title')) {
412 | localDom.querySelector('.graf--title').remove()
413 | }
414 | if (localDom.querySelector('.graf--subtitle')) {
415 | localDom.querySelector('.graf--subtitle').remove()
416 | }
417 | if (localDom.querySelector('.section-divider')) {
418 | localDom.querySelector('.section-divider').remove()
419 | }
420 |
421 | md = td.turndown(localDom.querySelector('.e-content'))
422 |
423 | metadata.title = title
424 | metadata.description = (
425 | localDom.querySelector('.p-summary[data-field="subtitle"]') || {
426 | textContent: '',
427 | }
428 | ).textContent.trim()
429 | metadata.date = localDom.querySelector('.dt-published')
430 | ? localDom
431 | .querySelector('.dt-published')
432 | .attributes.getNamedItem('datetime').value
433 | : new Date().toISOString()
434 | metadata.published = !!canonicalLink
435 | metadata.canonicalLink = canonicalLink
436 | metadata.redirect = redirect
437 |
438 | return writePost(contentFolder, slug, md, metadata)
439 | }
440 |
--------------------------------------------------------------------------------
/lib/utils.js:
--------------------------------------------------------------------------------
1 | const request = require('request')
2 | const { exec } = require('child_process')
3 | const path = require('path')
4 |
5 | module.exports.request = (url, options) => {
6 | return new Promise((resolve, reject) => {
7 | request.get(url, options, (err, response, body) => {
8 | if (err || response.statusCode !== 200) {
9 | reject(err || new Error(body))
10 | return
11 | }
12 | resolve(body)
13 | })
14 | })
15 | }
16 |
17 | module.exports.exec = (command, options) => {
18 | return new Promise((resolve, reject) => {
19 | exec(command, options, (err, stdout, stderr) => {
20 | if (err) {
21 | err.stdout = stdout
22 | err.stderr = stderr
23 | reject(err)
24 | return
25 | }
26 | resolve({ stdout, stderr })
27 | })
28 | })
29 | }
30 |
31 | module.exports.withOutputPath = (profile, filePath) => {
32 | return filePath
33 | ? path.join(process.cwd(), `${profile.mediumUsername}-blog`, filePath)
34 | : path.join(process.cwd(), `${profile.mediumUsername}-blog`)
35 | }
36 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "medium-to-own-blog",
3 | "version": "0.4.4",
4 | "description": "Switch from Medium to your own blog in a few minutes",
5 | "bin": {
6 | "medium-to-own-blog": "bin/medium-to-own-blog.js",
7 | "parse-medium-article": "bin/import-medium-article.js"
8 | },
9 | "main": "index.js",
10 | "files": [
11 | "/gatsby-template",
12 | "/bin",
13 | "/lib"
14 | ],
15 | "scripts": {
16 | "test": "node ./bin/medium-to-own-blog.js"
17 | },
18 | "author": "Mathieu Dutour ",
19 | "license": "MIT",
20 | "devDependencies": {
21 | "babel-eslint": "^10.1.0",
22 | "eslint": "^6.8.0",
23 | "eslint-config-airbnb": "^18.1.0",
24 | "eslint-config-prettier": "^6.10.0",
25 | "eslint-plugin-import": "^2.20.1",
26 | "eslint-plugin-jsx-a11y": "^6.2.3",
27 | "eslint-plugin-prettier": "^3.1.2",
28 | "eslint-plugin-react": "^7.19.0",
29 | "eslint-plugin-react-hooks": "^2.5.0",
30 | "prettier": "^1.19.1"
31 | },
32 | "prettier": {
33 | "proseWrap": "never",
34 | "singleQuote": true,
35 | "trailingComma": "es5",
36 | "semi": false
37 | },
38 | "dependencies": {
39 | "fs-extra": "^8.1.0",
40 | "inquirer": "^7.1.0",
41 | "jimp": "^0.9.5",
42 | "jsdom": "^16.2.1",
43 | "jszip": "^3.2.2",
44 | "ora": "^4.0.3",
45 | "request": "^2.88.2",
46 | "slugify": "^1.4.0",
47 | "turndown": "^6.0.0",
48 | "xml-js": "^1.6.11"
49 | }
50 | }
51 |
--------------------------------------------------------------------------------