├── .npmignore ├── .babelrc ├── .gitignore ├── apple-news-article ├── embeds │ └── image.jpg └── header-with-image │ └── image.jpg ├── lib ├── format-date.js ├── heading.js ├── paragraph.js ├── format-url.js ├── blockquote.js ├── bundle-images.js ├── block-list.js ├── embed.js ├── header.js ├── text-items.js ├── index.js └── default-styles.js ├── .travis.yml ├── LICENSE ├── test ├── bundle-images-test.js └── index.js ├── package.json ├── README.md └── api-test └── index.js /.npmignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015"] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .nyc_output 4 | apple-news-article/**/*.json 5 | -------------------------------------------------------------------------------- /apple-news-article/embeds/image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/micnews/article-json-to-apple-news/HEAD/apple-news-article/embeds/image.jpg -------------------------------------------------------------------------------- /apple-news-article/header-with-image/image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/micnews/article-json-to-apple-news/HEAD/apple-news-article/header-with-image/image.jpg -------------------------------------------------------------------------------- /lib/format-date.js: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | 3 | export default function (date) { 4 | return moment(date).utc().format('YYYY-MM-DDTHH:mm:ss[Z]'); 5 | } 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | env: 2 | - CC=clang CXX=clang++ npm_config_clang=1 3 | language: node_js 4 | before_install: 5 | - npm install -g npm@2 6 | node_js: 7 | - '0.10' 8 | - '4' 9 | - '5' 10 | -------------------------------------------------------------------------------- /lib/heading.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import renderTextItems from './text-items'; 3 | 4 | export default function (headerType, children) { 5 | assert(headerType >= 1 && headerType <= 6); 6 | 7 | return renderTextItems(`heading${headerType}`, children, { 8 | layout: 'bodyLayout' 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /lib/paragraph.js: -------------------------------------------------------------------------------- 1 | import renderTextItems from './text-items'; 2 | 3 | export default function (children) { 4 | return renderTextItems('body', children, { 5 | appendNewline: true, 6 | boldTextStyle: 'bodyBoldStyle', 7 | italicTextStyle: 'bodyItalicStyle', 8 | boldItalicTextStyle: 'bodyBoldItalicStyle', 9 | linkTextStyle: 'bodyLinkTextStyle', 10 | layout: 'bodyLayout' 11 | }); 12 | } 13 | -------------------------------------------------------------------------------- /lib/format-url.js: -------------------------------------------------------------------------------- 1 | import startsWith from 'lodash.startswith'; 2 | 3 | export default function (url) { 4 | if (!url) { 5 | return ''; 6 | } 7 | 8 | let t = String(url).trim(); 9 | if (!t) { 10 | return ''; 11 | } 12 | 13 | if (startsWith(t, '//')) { 14 | t = 'https:' + t; 15 | } 16 | 17 | if (!startsWith(t, 'http://') && !startsWith(t, 'https://')) { 18 | t = 'http://' + t; 19 | } 20 | 21 | return encodeURI(t); 22 | } 23 | -------------------------------------------------------------------------------- /lib/blockquote.js: -------------------------------------------------------------------------------- 1 | import renderTextItems from './text-items'; 2 | 3 | export default function (children) { 4 | const components = children 5 | .filter(Boolean) 6 | .filter(element => element.type === 'paragraph' && element.children && element.children.length > 0) 7 | .map(element => renderTextItems('quote', element.children, { 8 | textStyle: 'quoteTextStyle', 9 | layout: 'quoteTextLayout', 10 | style: 'quoteTextStyle' 11 | })) 12 | .filter(Boolean); 13 | 14 | return { 15 | role: 'container', 16 | layout: 'quoteLayout', 17 | style: 'quoteStyle', 18 | components 19 | }; 20 | } 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Mic Networks 2 | 3 | This software is released under the MIT license: 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/bundle-images-test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import 'babel-core/register'; 3 | import bundleImages from '../lib/bundle-images'; 4 | 5 | test('bundleImages() simple', t => { 6 | const components = [{ 7 | role: 'photo', 8 | URL: 'http://example.com/beep-boop.jpg' 9 | }]; 10 | const expectedComponents = [{ 11 | role: 'photo', 12 | URL: 'bundle://image-0.jpg' 13 | }]; 14 | const expectedBundlesToUrls = { 15 | 'image-0.jpg': 'http://example.com/beep-boop.jpg' 16 | }; 17 | const actualBundlesToUrls = bundleImages(components); 18 | const actualComponents = components; 19 | 20 | t.deepEqual(actualBundlesToUrls, expectedBundlesToUrls); 21 | t.deepEqual(actualComponents, expectedComponents); 22 | }); 23 | 24 | test('bundleImages() on bundled image', t => { 25 | const components = [{ 26 | role: 'photo', 27 | URL: 'bundle://beep-boop.jpg' 28 | }]; 29 | const expectedComponents = [{ 30 | role: 'photo', 31 | URL: 'bundle://beep-boop.jpg' 32 | }]; 33 | const expectedBundlesToUrls = {}; 34 | const actualBundlesToUrls = bundleImages(components); 35 | const actualComponents = components; 36 | 37 | t.deepEqual(actualBundlesToUrls, expectedBundlesToUrls); 38 | t.deepEqual(actualComponents, expectedComponents); 39 | }); 40 | -------------------------------------------------------------------------------- /lib/bundle-images.js: -------------------------------------------------------------------------------- 1 | import walk from 'walk-apple-news-format'; 2 | import startsWith from 'lodash.startswith'; 3 | import {extname} from 'path'; 4 | 5 | export default components => { 6 | const bundlesToUrls = {}; 7 | const urlsToBundles = {}; 8 | let count = 0; 9 | 10 | function handleURL (URL) { 11 | if (!URL) { 12 | return null; 13 | } 14 | 15 | if (startsWith(URL, 'bundle://')) { 16 | return null; 17 | } 18 | 19 | if (!urlsToBundles[URL]) { 20 | const filename = `image-${count++}${extname(URL)}`; 21 | urlsToBundles[URL] = filename; 22 | bundlesToUrls[filename] = URL; 23 | } 24 | 25 | return 'bundle://' + urlsToBundles[URL]; 26 | } 27 | 28 | walk({components}, component => { 29 | const {role, URL, style} = component; 30 | 31 | if (role === 'photo') { 32 | const bundleFilename = handleURL(URL); 33 | if (bundleFilename) { 34 | component.URL = bundleFilename; 35 | } 36 | } 37 | 38 | if (Object(style) === style && style.fill && 39 | style.fill.type === 'image' && style.fill.URL) { 40 | const bundleFilename = handleURL(style.fill.URL); 41 | if (bundleFilename) { 42 | component.style.fill.URL = bundleFilename; 43 | } 44 | } 45 | }); 46 | 47 | return bundlesToUrls; 48 | }; 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "article-json-to-apple-news", 3 | "version": "4.0.4", 4 | "description": "Render article JSON in the Apple News format", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "lint": "semistandard | snazzy", 8 | "test": "ava && npm run lint", 9 | "api-test": "NODE_ENV=test ava api-test/index.js", 10 | "test:coverage": "nyc ava", 11 | "build": "babel lib --out-dir dist", 12 | "watch": "babel lib --out-dir dist --watch", 13 | "prepublish": "npm run build" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "git+https://github.com/micnews/article-json-to-apple-news.git" 18 | }, 19 | "author": "mic.com", 20 | "license": "MIT", 21 | "bugs": { 22 | "url": "https://github.com/micnews/article-json-to-apple-news/issues" 23 | }, 24 | "homepage": "https://github.com/micnews/article-json-to-apple-news#readme", 25 | "devDependencies": { 26 | "apple-news": "^1.0.0", 27 | "ava": "^0.17.0", 28 | "babel-cli": "^6.4.5", 29 | "babel-core": "^6.5.2", 30 | "babel-preset-es2015": "^6.3.13", 31 | "chalk": "^1.1.3", 32 | "mkdirp": "^0.5.1", 33 | "nyc": "^10.0.0", 34 | "semistandard": "^9.0.0", 35 | "snazzy": "^5.0.0" 36 | }, 37 | "dependencies": { 38 | "lodash.startswith": "^4.0.1", 39 | "moment": "^2.12.0", 40 | "object-assign": "^4.0.1", 41 | "walk-apple-news-format": "^1.0.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /lib/block-list.js: -------------------------------------------------------------------------------- 1 | import startsWith from 'lodash.startswith'; 2 | import renderParagraph from './paragraph'; 3 | import renderBlockquote from './blockquote'; 4 | import renderHeading from './heading'; 5 | import renderEmbed from './embed'; 6 | 7 | const types = { 8 | paragraph: ({children}) => renderParagraph(children), 9 | blockquote: ({children}) => renderBlockquote(children), 10 | header1: ({children}) => renderHeading(1, children), 11 | header2: ({children}) => renderHeading(2, children), 12 | header3: ({children}) => renderHeading(3, children), 13 | header4: ({children}) => renderHeading(4, children), 14 | header5: ({children}) => renderHeading(5, children), 15 | header6: ({children}) => renderHeading(6, children), 16 | embed: item => renderEmbed(item, { 17 | layout: 'embedLayout', 18 | style: 'embedStyle', 19 | mediaLayout: 'embedMediaLayout', 20 | mediaStyle: 'embedMediaStyle', 21 | captionLayout: 'embedCaptionLayout', 22 | captionTextStyle: 'embedCaptionTextStyle' 23 | }) 24 | }; 25 | 26 | const isText = type => type === 'paragraph' || startsWith(type, 'header'); 27 | 28 | const hasContent = ({children}) => 29 | children.some(child => child.type !== 'linebreak' && 30 | (child.content && child.content.trim())); 31 | 32 | function renderItem (item) { 33 | const {type} = item; 34 | if (!types[type]) { 35 | return null; 36 | } 37 | 38 | if (isText(type) && !hasContent(item)) { 39 | return null; 40 | } 41 | 42 | return types[type](item); 43 | } 44 | 45 | export default function ({body}) { 46 | return body 47 | .map(renderItem) 48 | .filter(Boolean); 49 | } 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # article-json-to-apple-news 2 | Render [article json](https://github.com/micnews/html-to-article-json#format) in [Apple News format](https://developer.apple.com/library/ios/documentation/General/Conceptual/Apple_News_Format_Ref/index.html) 3 | 4 | [![Build Status](https://travis-ci.org/micnews/article-json-to-apple-news.svg?branch=master)](https://travis-ci.org/micnews/article-json-to-apple-news) 5 | 6 | ## Usage 7 | 8 | ``` 9 | npm install article-json-to-apple-news 10 | ``` 11 | 12 | ```js 13 | const convertToAppleNews = require('article-json-to-apple-news'); 14 | const article = { 15 | title: 'Article Title', 16 | author: { 17 | name: 'John', 18 | href: 'http://example.com/john' 19 | }, 20 | publishedDate: new Date('2016-02-04T14:00:00Z'), 21 | body: [ 22 | { 23 | type: 'paragraph', 24 | children: [ 25 | { 26 | type: 'text', 27 | content: 'This is the text and ' 28 | }, 29 | { 30 | type: 'text', 31 | bold: true, 32 | content: 'some bold text ' 33 | }, 34 | { 35 | type: 'text', 36 | href: 'http://example.com', 37 | content: 'some link' 38 | } 39 | ] 40 | }, 41 | { 42 | type: 'embed', 43 | embedType: 'image', 44 | src: 'http://example/image.jpg', 45 | width: 300, 46 | height: 150 47 | } 48 | ] 49 | }; 50 | 51 | console.log(convertToAppleNews(article, { identifier: 'article-identifier' })); 52 | // { 53 | // article: , 54 | // bundlesToUrls: 55 | // } 56 | ``` 57 | 58 | ### Body format 59 | 60 | https://github.com/micnews/html-to-article-json#format 61 | 62 | ## License 63 | 64 | MIT 65 | -------------------------------------------------------------------------------- /lib/embed.js: -------------------------------------------------------------------------------- 1 | import renderTextItems from './text-items'; 2 | const twitterRegex = /^https?:\/\/twitter\.com\/([-a-zA-Z0-9+&@#%?=~_|!:,.;]+)\/status(es){0,1}\/(\d+)/; 3 | 4 | function formatTwitterUrl (url) { 5 | const match = url.match(twitterRegex); 6 | if (!match) { 7 | return ''; 8 | } 9 | 10 | return `https://twitter.com/${match[1]}/status/${match[3]}`; 11 | } 12 | 13 | function createTwitterEmbed ({url}) { 14 | const formatted = formatTwitterUrl(url); 15 | if (!formatted) { 16 | return null; 17 | } 18 | 19 | return { 20 | role: 'tweet', 21 | URL: formatted 22 | }; 23 | } 24 | 25 | const embeds = { 26 | instagram: ({id}) => ({ 27 | role: 'instagram', 28 | // use id to assure the url is correct 29 | URL: `https://instagram.com/p/${id}` 30 | }), 31 | twitter: createTwitterEmbed, 32 | youtube: ({youtubeId}) => ({ 33 | role: 'embedwebvideo', 34 | URL: `https://www.youtube.com/embed/${youtubeId}` 35 | }), 36 | video: ({src}) => ({ 37 | role: 'video', 38 | URL: src 39 | }), 40 | image: ({src}) => ({ 41 | role: 'photo', 42 | URL: src 43 | }) 44 | }; 45 | 46 | const render = (item, opts) => { 47 | opts = opts || {}; 48 | const embed = embeds[item.embedType](item); 49 | 50 | if (!embed) { 51 | return null; 52 | } 53 | 54 | if (opts.mediaStyle) { 55 | embed.style = opts.mediaStyle; 56 | } 57 | 58 | if (opts.mediaLayout) { 59 | embed.layout = opts.mediaLayout; 60 | } 61 | 62 | const result = { 63 | role: 'container', 64 | components: [embed] 65 | }; 66 | 67 | if (opts.layout) { 68 | result.layout = opts.layout; 69 | } 70 | 71 | if (opts.style) { 72 | result.style = opts.style; 73 | } 74 | 75 | if (embed.role === 'photo' && item.caption && item.caption.length > 0) { 76 | const captionOpts = { 77 | appendNewline: true, 78 | textStyle: opts.captionTextStyle 79 | }; 80 | 81 | if (opts.captionTextStyle) { 82 | captionOpts.textStyle = opts.captionTextStyle; 83 | } 84 | 85 | embed.caption = renderTextItems(null, item.caption, captionOpts); 86 | } 87 | 88 | return result; 89 | }; 90 | 91 | export default (item, opts) => embeds[item.embedType] && render(item, opts); 92 | -------------------------------------------------------------------------------- /lib/header.js: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | import renderEmbed from './embed'; 3 | import renderTextItems from './text-items'; 4 | 5 | function componentsWithoutEmbed (article, opts) { 6 | const {title, author: {name, href}, publishedDate} = article; 7 | const {dividerComponent, heroComponent} = opts; 8 | 9 | const titleComponent = { 10 | role: 'title', 11 | layout: 'titleLayout', 12 | text: title 13 | }; 14 | 15 | const bylineComponent = renderTextItems('byline', [{ 16 | type: 'text', 17 | content: 'By ' 18 | }, { 19 | type: 'text', 20 | content: name, 21 | href: href 22 | }, { 23 | type: 'text', 24 | content: ` ${moment(publishedDate).format('MMMM D, YYYY')}` 25 | }], { 26 | appendNewline: false, 27 | layout: 'bylineLayout', 28 | boldTextStyle: 'bodyBoldStyle', 29 | italicTextStyle: 'bodyItalicStyle', 30 | boldItalicTextStyle: 'bodyBoldItalicStyle', 31 | linkTextStyle: opts.bylineLinkTextStyle || 'bodyLinkTextStyle' 32 | }); 33 | 34 | // Remove title in case there is a custom hero component 35 | if (heroComponent) { 36 | if (dividerComponent) { 37 | return [bylineComponent, dividerComponent]; 38 | } 39 | 40 | return [bylineComponent]; 41 | } 42 | 43 | if (dividerComponent) { 44 | return [titleComponent, bylineComponent, dividerComponent]; 45 | } 46 | 47 | return [titleComponent, bylineComponent]; 48 | } 49 | 50 | const componentsWithEmbed = (article, opts) => 51 | [opts.heroComponent ? opts.heroComponent : renderEmbed(article.headerEmbed, { 52 | layout: 'headerEmbedLayout', 53 | style: 'headerEmbedStyle', 54 | mediaLayout: 'headerEmbedMediaLayout', 55 | mediaStyle: 'headerEmbedMediaStyle', 56 | captionLayout: 'headerCaptionLayout', 57 | captionTextStyle: 'headerCaptionTextStyle' 58 | })] 59 | .concat(componentsWithoutEmbed(article, opts)); 60 | 61 | export default (article, opts) => ({ 62 | role: 'header', 63 | layout: 'headerLayout', 64 | style: 'headerStyle', 65 | components: article.headerEmbed && article.headerEmbed.type === 'embed' 66 | ? componentsWithEmbed(article, opts) 67 | : (opts.heroComponent ? [opts.heroComponent] : []).concat(componentsWithoutEmbed(article, opts)) 68 | }); 69 | -------------------------------------------------------------------------------- /lib/text-items.js: -------------------------------------------------------------------------------- 1 | import formatUrl from './format-url'; 2 | import startsWith from 'lodash.startswith'; 3 | 4 | function getTextStyle (bold, italic, link, opts) { 5 | if (link) { 6 | return opts.linkTextStyle || opts.textStyle; 7 | } 8 | 9 | if (bold && italic) { 10 | return opts.boldItalicTextStyle || opts.textStyle; 11 | } 12 | 13 | if (bold) { 14 | return opts.boldTextStyle || opts.textStyle; 15 | } 16 | 17 | if (italic) { 18 | return opts.italicTextStyle || opts.textStyle; 19 | } 20 | 21 | return opts.textStyle; 22 | } 23 | 24 | function isMailto (href) { 25 | return startsWith(href, 'mailto:'); 26 | } 27 | 28 | export default function (role, items, opts = {}) { 29 | const inlineStyles = []; 30 | const additions = []; 31 | let content = ''; 32 | 33 | for (let i = 0; i < items.length; ++i) { 34 | const item = items[i]; 35 | 36 | if (item.type === 'text') { 37 | const styled = item.bold || item.italic || item.href; 38 | if (styled) { 39 | const ts = getTextStyle(item.bold, item.italic, !!item.href, opts); 40 | if (ts) { 41 | inlineStyles.push({ 42 | rangeStart: content.length, 43 | rangeLength: item.content.length, 44 | textStyle: ts 45 | }); 46 | } 47 | } 48 | 49 | if (item.href && !isMailto(item.href)) { 50 | additions.push({ 51 | type: 'link', 52 | rangeStart: content.length, 53 | rangeLength: item.content.length, 54 | URL: formatUrl(item.href) 55 | }); 56 | } 57 | 58 | content += item.content || ''; 59 | } 60 | 61 | if (item.type === 'linebreak') { 62 | content += '\n'; 63 | } 64 | } 65 | 66 | if (!content) { 67 | return null; 68 | } 69 | 70 | const obj = { 71 | text: content + (opts.appendNewline ? '\n' : ''), 72 | additions: additions, 73 | inlineTextStyles: inlineStyles 74 | }; 75 | 76 | if (role) { 77 | obj.role = role; 78 | } 79 | 80 | if (opts.layout) { 81 | obj.layout = opts.layout; 82 | } 83 | 84 | if (opts.textStyle) { 85 | obj.textStyle = opts.textStyle; 86 | } 87 | 88 | if (opts.style) { 89 | obj.style = opts.style; 90 | } 91 | 92 | return obj; 93 | } 94 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert'; 2 | import renderBlockList from './block-list'; 3 | import objectAssign from 'object-assign'; 4 | import defaultStyles from './default-styles'; 5 | import bundleImages from './bundle-images'; 6 | import renderHeader from './header'; 7 | import formatDate from './format-date'; 8 | import packageJson from '../package.json'; 9 | 10 | function convertToAppleNews (articleJson, opts) { 11 | assert(opts, 'opts required'); 12 | assert(opts.identifier, 'opts.identifier required'); 13 | assert(articleJson, 'articleJson required'); 14 | assert(articleJson.title, 'articleJson.title required'); 15 | assert(Array.isArray(articleJson.body), 'articleJson.body required to be an array'); 16 | 17 | const header = renderHeader(articleJson, opts); 18 | const components = renderBlockList(articleJson, opts); 19 | components.unshift(header); 20 | 21 | const bundlesToUrls = bundleImages(components); 22 | const article = { 23 | version: '1.0', 24 | identifier: opts.identifier, 25 | title: articleJson.title, 26 | language: articleJson.language || 'en', 27 | metadata: { 28 | generatorName: 'article-json-to-apple-news', 29 | generatorVersion: packageJson.version 30 | }, 31 | documentStyle: opts.documentStyle, 32 | layout: objectAssign({}, defaultStyles.layout, opts.layout), 33 | componentLayouts: objectAssign({}, defaultStyles.componentLayouts, opts.componentLayouts), 34 | componentTextStyles: objectAssign({}, defaultStyles.componentTextStyles, opts.componentTextStyles), 35 | componentStyles: objectAssign({}, defaultStyles.componentStyles, opts.componentStyles), 36 | textStyles: objectAssign({}, defaultStyles.textStyles, opts.textStyles), 37 | components 38 | }; 39 | 40 | if (articleJson.author && articleJson.author.name) { 41 | article.metadata.authors = [articleJson.author.name]; 42 | } 43 | 44 | if (articleJson.headerEmbed && articleJson.headerEmbed.type === 'embed' && 45 | articleJson.headerEmbed.embedType === 'image') { 46 | Object.keys(bundlesToUrls).forEach(function (key) { 47 | const value = bundlesToUrls[key]; 48 | if (value === articleJson.headerEmbed.src) { 49 | article.metadata.thumbnailURL = 'bundle://' + key; 50 | } 51 | }); 52 | } 53 | 54 | if (articleJson.modifiedDate) { 55 | article.metadata.dateModified = formatDate(articleJson.modifiedDate); 56 | } 57 | 58 | if (articleJson.publishedDate) { 59 | const published = formatDate(articleJson.publishedDate); 60 | article.metadata.datePublished = published; 61 | article.metadata.dateCreated = published; 62 | } 63 | 64 | if (opts.excerpt) { 65 | article.metadata.excerpt = String(opts.excerpt); 66 | } 67 | 68 | if (opts.canonicalURL) { 69 | article.metadata.canonicalURL = String(opts.canonicalURL); 70 | } 71 | 72 | if (opts.campaignData) { 73 | article.metadata.campaignData = opts.campaignData; 74 | } 75 | 76 | if (opts.keywords) { 77 | article.metadata.keywords = opts.keywords; 78 | } 79 | 80 | return {article, bundlesToUrls}; 81 | } 82 | 83 | module.exports = convertToAppleNews; 84 | -------------------------------------------------------------------------------- /lib/default-styles.js: -------------------------------------------------------------------------------- 1 | const fontNames = { 2 | regular: 'HelveticaNeue', 3 | italic: 'HelveticaNeue-Italic', 4 | bold: 'HelveticaNeue-Bold', 5 | boldItalic: 'HelveticaNeue-BoldItalic' 6 | }; 7 | 8 | export default { 9 | componentTextStyles: { 10 | 'default-body': { 11 | fontName: fontNames.regular, 12 | fontSize: 18 13 | }, 14 | 'default-quote': { 15 | fontName: fontNames.italic, 16 | fontSize: 26, 17 | textAlignment: 'center' 18 | }, 19 | 'default-byline': { 20 | fontName: fontNames.regular, 21 | fontSize: 13 22 | }, 23 | 'default-caption': { 24 | fontName: fontNames.regular, 25 | fontSize: 10 26 | }, 27 | 'default-title': { 28 | fontName: fontNames.bold, 29 | fontSize: 36 30 | }, 31 | 'default-heading1': { 32 | fontName: fontNames.bold, 33 | fontSize: 32 34 | }, 35 | 'default-heading2': { 36 | fontName: fontNames.bold, 37 | fontSize: 24 38 | }, 39 | 'default-heading3': { 40 | fontName: fontNames.bold, 41 | fontSize: 19 42 | }, 43 | 'default-heading4': { 44 | fontName: fontNames.bold, 45 | fontSize: 16 46 | }, 47 | 'default-heading5': { 48 | fontName: fontNames.bold, 49 | fontSize: 13 50 | }, 51 | 'default-heading6': { 52 | fontName: fontNames.bold, 53 | fontSize: 11 54 | }, 55 | headerCaptionTextStyle: {}, 56 | embedCaptionTextStyle: {}, 57 | quoteStyle: {}, 58 | quoteTextStyle: {} 59 | }, 60 | textStyles: { 61 | bodyBoldStyle: { 62 | fontName: fontNames.bold 63 | }, 64 | bodyItalicStyle: { 65 | fontName: fontNames.italic 66 | }, 67 | bodyBoldItalicStyle: { 68 | fontName: fontNames.boldItalic 69 | }, 70 | bodyLinkTextStyle: { 71 | textColor: '#48BFEE', 72 | underline: true 73 | }, 74 | headerCaptionTextStyle: {}, 75 | embedCaptionTextStyle: {}, 76 | quoteTextStyle: {} 77 | }, 78 | componentStyles: { 79 | headerStyle: {}, 80 | embedStyle: {}, 81 | embedMediaStyle: {}, 82 | headerEmbedStyle: {}, 83 | headerEmbedMediaStyle: {}, 84 | quoteStyle: {}, 85 | quoteTextStyle: {} 86 | }, 87 | componentLayouts: { 88 | bodyLayout: { 89 | columnStart: 1, 90 | columnSpan: 6, 91 | contentInset: { 92 | left: true, 93 | right: true 94 | } 95 | }, 96 | titleLayout: {}, 97 | bylineLayout: {}, 98 | quoteLayout: { 99 | columnStart: 1, 100 | columnSpan: 6, 101 | contentInset: { 102 | left: true, 103 | right: true, 104 | bottom: true 105 | } 106 | }, 107 | quoteTextLayout: {}, 108 | headerLayout: { 109 | ignoreDocumentMargin: true 110 | }, 111 | paragraphLayout: {}, 112 | embedLayout: {}, 113 | embedMediaLayout: {}, 114 | headerEmbedLayout: { 115 | ignoreDocumentMargin: true 116 | }, 117 | headerEmbedMediaLayout: { 118 | ignoreDocumentMargin: true 119 | }, 120 | headerCaptionLayout: {}, 121 | embedCaptionLayout: {} 122 | }, 123 | layout: { 124 | columns: 8, 125 | width: 1024, 126 | margin: 0, 127 | gutter: 40 128 | } 129 | }; 130 | -------------------------------------------------------------------------------- /api-test/index.js: -------------------------------------------------------------------------------- 1 | import 'babel-core/register'; 2 | import createClient from 'apple-news'; 3 | import assert from 'assert'; 4 | import test from 'ava'; 5 | import chalk from 'chalk'; 6 | import toAppleNews from '../lib'; 7 | 8 | assert(process.env.NODE_ENV === 'test', 'should run in the test environment'); 9 | assert(process.env.API_ID, 'API_ID environment variable required'); 10 | assert(process.env.API_SECRET, 'API_SECRET environment variable required'); 11 | assert(process.env.CHANNEL_ID, 'CHANNEL_ID environment variable required'); 12 | 13 | const printResponses = process.env.PRINT_RESPONSES; 14 | const channelId = process.env.CHANNEL_ID; 15 | const client = createClient({ 16 | apiId: process.env.API_ID, 17 | apiSecret: process.env.API_SECRET 18 | }); 19 | 20 | function PUBLISH_TEST (name, articleJson) { 21 | test(name, t => { 22 | return new Promise((resolve, reject) => { 23 | const randomId = String(Math.random().toFixed(7)).split('.')[1]; 24 | const apn = toAppleNews(articleJson, { identifier: 'TEST-ID-' + randomId }); 25 | client.createArticle({ 26 | channelId: channelId, 27 | article: apn.article, 28 | bundleFiles: apn.bundleFiles, 29 | isPreview: true 30 | }, function (err, data) { 31 | if (err) { 32 | return reject(err); 33 | } 34 | 35 | console.log(chalk.green('++', data.id)); 36 | 37 | if (printResponses) { 38 | console.log(JSON.stringify(data, null, 2)); 39 | } 40 | 41 | if (!data || !data.id) { 42 | return reject(new Error('failed to create an article')); 43 | } 44 | 45 | client.deleteArticle({ articleId: data.id }, function (err2) { 46 | if (err2) { 47 | console.log(`Article deletion error, please delete ID ${data.id} manually`, err2); 48 | return reject(err2); 49 | } 50 | 51 | console.log(chalk.red('--', data.id)); 52 | return resolve(data); 53 | }); 54 | }); 55 | }); 56 | }); 57 | } 58 | 59 | PUBLISH_TEST('simple article', { 60 | title: 'Article Title', 61 | author: { 62 | name: 'David Hipsterson', 63 | href: 'http://mic.com' 64 | }, 65 | publishedDate: new Date('2016-02-04T14:00:00Z'), 66 | body: [ 67 | { type: 'header1', children: [{ type: 'text', content: 'header 1 text' }, { type: 'text', content: '1', bold: true }] }, 68 | { type: 'header2', children: [{ type: 'text', content: 'header 2 text' }] }, 69 | { type: 'header3', children: [{ type: 'text', content: 'header 3 text' }] }, 70 | { type: 'header4', children: [{ type: 'text', content: 'header 4 text' }] }, 71 | { type: 'header5', children: [{ type: 'text', content: 'header 5 text' }] }, 72 | { type: 'header6', children: [{ type: 'text', content: 'header 6 text' }] }, 73 | { type: 'paragraph', 74 | children: [ 75 | { type: 'text', href: 'http://mic.com', content: 'link' }, 76 | { type: 'linebreak' }, 77 | { type: 'text', content: 'normal text ' }, 78 | { type: 'text', bold: true, content: 'bold text ' }, 79 | { type: 'text', italic: true, content: 'italic text ' }, 80 | { type: 'text', bold: true, italic: true, content: 'bold italic text ' }, 81 | { type: 'text', mark: true, content: 'marked text' }, 82 | { type: 'text', mark: true, markClass: 'marker1' } 83 | ] 84 | }, 85 | { 86 | type: 'embed', 87 | embedType: 'video', 88 | src: 'http://thumbs.mic.com/OGYxNzk0YzUyYiMvRDNVUlRpM1FwU3pXMVROQ0ltYURLT29GWmtNPS9maWx0ZXJzOmZpbHRlcnM6Z2lmdigpL2h0dHA6Ly90aHVtYnMubWljLmNvbS9Nak5rTWpjMk9EUTRNeU12U1Zkb1luaEpWM0puVkhwWFUyeFdZMEZsVnkxcFZrRkdPRFIzUFM5bWFYUXRhVzR2T1RBd2VEa3dNQzltYVd4MFpYSnpPbTV2WDNWd2MyTmhiR1VvS1RweGRXRnNhWFI1S0Rnd0tTOW9kSFJ3T2k4dmFXMWhaMlZ6TG0xcFl5NWpiMjB2YUdoblltVnBZMnhyZG5obE1uWnBlWFZwY25JME5XMDRibWgwWjJ0Mk0yaDZabTUxWm0xa2NXbDNaakZ6Y2pVd1kyTnhPR0poY0hKMk0zUTFOV0k1ZHk1bmFXWS5naWY.gif' 89 | }, 90 | { 91 | type: 'embed', 92 | embedType: 'twitter', 93 | url: 'https://twitter.com/Kevunn/status/724060483213385729/photo/1', 94 | caption: [] 95 | }, 96 | { 97 | type: 'embed', 98 | embedType: 'twitter', 99 | url: 'https://twitter.com/invalid-url', 100 | caption: [] 101 | }, 102 | { 103 | type: 'blockquote', 104 | children: [ 105 | { 106 | type: 'paragraph', 107 | children: [ 108 | { type: 'text', content: 'block quote text ' }, 109 | { type: 'text', content: ' and a link', href: 'http://mic.com' }, 110 | { type: 'text', content: ' and a link', href: 'http://mic.com ' }, // space in href 111 | { type: 'text', content: ' and another link', href: 'mic.com ' } // link without protocol 112 | ] 113 | } 114 | ] 115 | }, 116 | { type: 'paragraph', 117 | children: [ 118 | { type: 'text', content: 'other text' } 119 | ] 120 | }, 121 | { type: 'paragraph', children: [{ type: 'text', mark: true }] } 122 | ] 123 | }); 124 | -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import 'babel-core/register'; 3 | import toAppleNews from '../lib'; 4 | import fs from 'fs'; 5 | import mkdirp from 'mkdirp'; 6 | import path from 'path'; 7 | import packageJson from '../package.json'; 8 | 9 | const writeAppleNewsArticle = (apn, name) => { 10 | mkdirp.sync(path.resolve(__dirname, '..', 'apple-news-article', name)); 11 | fs.writeFileSync(path.resolve(__dirname, '..', 'apple-news-article', name, 'article.json'), 12 | JSON.stringify(apn, null, 2)); 13 | }; 14 | 15 | test('apple news format', t => { 16 | const data = { 17 | title: 'Article Title', 18 | author: { 19 | name: 'David Hipsterson', 20 | href: 'http://mic.com' 21 | }, 22 | publishedDate: new Date('2016-02-04T14:00:00Z'), 23 | body: [ 24 | { type: 'header1', children: [{ type: 'text', content: 'header 1 text' }, { type: 'text', content: '1', bold: true }] }, 25 | { type: 'header2', children: [{ type: 'text', content: 'header 2 text' }] }, 26 | { type: 'header3', children: [{ type: 'text', content: 'header 3 text' }] }, 27 | { type: 'header4', children: [{ type: 'text', content: 'header 4 text' }] }, 28 | { type: 'header5', children: [{ type: 'text', content: 'header 5 text' }] }, 29 | { type: 'header6', children: [{ type: 'text', content: 'header 6 text' }] }, 30 | { type: 'paragraph', 31 | children: [ 32 | { type: 'text', href: '//mic.com', content: 'link' }, 33 | { type: 'linebreak' }, 34 | { type: 'text', content: 'normal text ' }, 35 | { type: 'text', bold: true, content: 'bold text ' }, 36 | { type: 'text', italic: true, content: 'italic text ' }, 37 | { type: 'text', bold: true, italic: true, content: 'bold italic text ' }, 38 | { type: 'text', mark: true, content: 'marked text' }, 39 | { type: 'text', mark: true, markClass: 'marker1' }, 40 | { type: 'text', href: 'http://mic.com', content: 'link2' }, 41 | { type: 'text', href: ' https://mic.com', content: 'link3' }, 42 | { type: 'text', href: 'https://en.wikipedia.org/wiki/Crêpe', content: 'link4' }, 43 | { type: 'text', href: 'https://example.com/and-space-after ', content: 'link5' }, 44 | { type: 'text', href: ' example.com/no-protocol ', content: 'link6' }, 45 | { type: 'text', href: 'mailto:example@example.com', content: 'link7' } 46 | ] 47 | }, 48 | { type: 'blockquote', 49 | children: [ 50 | { 51 | type: 'paragraph', 52 | children: [ 53 | { type: 'text', content: 'block quote text' } 54 | ] 55 | } 56 | ] 57 | }, 58 | { type: 'paragraph', 59 | children: [ 60 | { type: 'text', content: 'other text' } 61 | ] 62 | }, 63 | { type: 'paragraph', children: [{ type: 'text', mark: true }] } 64 | ] 65 | }; 66 | 67 | const {article} = toAppleNews(data, {identifier: '100'}); 68 | t.is(article.version, '1.0'); 69 | t.is(article.identifier, '100'); 70 | t.is(article.title, 'Article Title'); 71 | 72 | const expected = { 73 | componentTextStyles: { 74 | 'default-body': { 75 | fontName: 'HelveticaNeue', 76 | fontSize: 18 77 | }, 78 | 'default-quote': { 79 | fontName: 'HelveticaNeue-Italic', 80 | fontSize: 26, 81 | textAlignment: 'center' 82 | }, 83 | 'default-byline': { 84 | fontName: 'HelveticaNeue', 85 | fontSize: 13 86 | }, 87 | 'default-caption': { 88 | fontName: 'HelveticaNeue', 89 | fontSize: 10 90 | }, 91 | 'default-title': { 92 | fontName: 'HelveticaNeue-Bold', 93 | fontSize: 36 94 | }, 95 | 'default-heading1': { 96 | fontName: 'HelveticaNeue-Bold', 97 | fontSize: 32 98 | }, 99 | 'default-heading2': { 100 | fontName: 'HelveticaNeue-Bold', 101 | fontSize: 24 102 | }, 103 | 'default-heading3': { 104 | fontName: 'HelveticaNeue-Bold', 105 | fontSize: 19 106 | }, 107 | 'default-heading4': { 108 | fontName: 'HelveticaNeue-Bold', 109 | fontSize: 16 110 | }, 111 | 'default-heading5': { 112 | fontName: 'HelveticaNeue-Bold', 113 | fontSize: 13 114 | }, 115 | 'default-heading6': { 116 | fontName: 'HelveticaNeue-Bold', 117 | fontSize: 11 118 | }, 119 | headerCaptionTextStyle: {}, 120 | embedCaptionTextStyle: {}, 121 | quoteStyle: {}, 122 | quoteTextStyle: {} 123 | }, 124 | textStyles: { 125 | bodyBoldStyle: { 126 | fontName: 'HelveticaNeue-Bold' 127 | }, 128 | bodyItalicStyle: { 129 | fontName: 'HelveticaNeue-Italic' 130 | }, 131 | bodyBoldItalicStyle: { 132 | fontName: 'HelveticaNeue-BoldItalic' 133 | }, 134 | bodyLinkTextStyle: { 135 | textColor: '#48BFEE', 136 | underline: true 137 | }, 138 | headerCaptionTextStyle: {}, 139 | embedCaptionTextStyle: {}, 140 | quoteTextStyle: {} 141 | }, 142 | components: [ 143 | { 144 | role: 'header', 145 | layout: 'headerLayout', 146 | style: 'headerStyle', 147 | components: [ 148 | { 149 | role: 'title', 150 | layout: 'titleLayout', 151 | text: 'Article Title' 152 | }, 153 | { 154 | text: 'By David Hipsterson February 4, 2016', 155 | additions: [{ 156 | type: 'link', 157 | rangeStart: 3, 158 | rangeLength: 16, 159 | URL: 'http://mic.com' 160 | }], 161 | inlineTextStyles: [{ 162 | rangeStart: 3, 163 | rangeLength: 16, 164 | textStyle: 'bodyLinkTextStyle' 165 | }], 166 | role: 'byline', 167 | layout: 'bylineLayout' 168 | } 169 | ] 170 | }, 171 | { 172 | role: 'heading1', 173 | text: 'header 1 text1', 174 | additions: [], 175 | inlineTextStyles: [], 176 | layout: 'bodyLayout' 177 | }, 178 | { 179 | role: 'heading2', 180 | text: 'header 2 text', 181 | additions: [], 182 | inlineTextStyles: [], 183 | layout: 'bodyLayout' 184 | }, 185 | { 186 | role: 'heading3', 187 | text: 'header 3 text', 188 | additions: [], 189 | inlineTextStyles: [], 190 | layout: 'bodyLayout' 191 | }, 192 | { 193 | role: 'heading4', 194 | text: 'header 4 text', 195 | additions: [], 196 | inlineTextStyles: [], 197 | layout: 'bodyLayout' 198 | }, 199 | { 200 | role: 'heading5', 201 | text: 'header 5 text', 202 | additions: [], 203 | inlineTextStyles: [], 204 | layout: 'bodyLayout' 205 | }, 206 | { 207 | role: 'heading6', 208 | text: 'header 6 text', 209 | additions: [], 210 | inlineTextStyles: [], 211 | layout: 'bodyLayout' 212 | }, 213 | { 214 | role: 'body', 215 | text: 'link\nnormal text bold text italic text bold italic text marked textlink2link3link4link5link6link7\n', 216 | additions: [ 217 | { 218 | 'type': 'link', 219 | 'rangeStart': 0, 220 | 'rangeLength': 4, 221 | 'URL': 'https://mic.com' 222 | }, 223 | { 224 | 'type': 'link', 225 | 'rangeStart': 67, 226 | 'rangeLength': 5, 227 | 'URL': 'http://mic.com' 228 | }, 229 | { 230 | 'type': 'link', 231 | 'rangeStart': 72, 232 | 'rangeLength': 5, 233 | 'URL': 'https://mic.com' 234 | }, 235 | { 236 | 'type': 'link', 237 | 'rangeStart': 77, 238 | 'rangeLength': 5, 239 | 'URL': 'https://en.wikipedia.org/wiki/Cr%C3%AApe' 240 | }, 241 | { 242 | 'type': 'link', 243 | 'rangeStart': 82, 244 | 'rangeLength': 5, 245 | 'URL': 'https://example.com/and-space-after' 246 | }, 247 | { 248 | 'type': 'link', 249 | 'rangeStart': 87, 250 | 'rangeLength': 5, 251 | 'URL': 'http://example.com/no-protocol' 252 | } 253 | ], 254 | 'inlineTextStyles': [ 255 | { 256 | 'rangeStart': 0, 257 | 'rangeLength': 4, 258 | 'textStyle': 'bodyLinkTextStyle' 259 | }, 260 | { 261 | 'rangeStart': 17, 262 | 'rangeLength': 10, 263 | 'textStyle': 'bodyBoldStyle' 264 | }, 265 | { 266 | 'rangeStart': 27, 267 | 'rangeLength': 12, 268 | 'textStyle': 'bodyItalicStyle' 269 | }, 270 | { 271 | 'rangeStart': 39, 272 | 'rangeLength': 17, 273 | 'textStyle': 'bodyBoldItalicStyle' 274 | }, 275 | { 276 | 'rangeStart': 67, 277 | 'rangeLength': 5, 278 | 'textStyle': 'bodyLinkTextStyle' 279 | }, 280 | { 281 | 'rangeStart': 72, 282 | 'rangeLength': 5, 283 | 'textStyle': 'bodyLinkTextStyle' 284 | }, 285 | { 286 | 'rangeStart': 77, 287 | 'rangeLength': 5, 288 | 'textStyle': 'bodyLinkTextStyle' 289 | }, 290 | { 291 | 'rangeStart': 82, 292 | 'rangeLength': 5, 293 | 'textStyle': 'bodyLinkTextStyle' 294 | }, 295 | { 296 | 'rangeStart': 87, 297 | 'rangeLength': 5, 298 | 'textStyle': 'bodyLinkTextStyle' 299 | }, 300 | { 301 | 'rangeStart': 92, 302 | 'rangeLength': 5, 303 | 'textStyle': 'bodyLinkTextStyle' 304 | } 305 | ], 306 | layout: 'bodyLayout' 307 | }, 308 | { 309 | role: 'container', 310 | layout: 'quoteLayout', 311 | style: 'quoteStyle', 312 | components: [{ 313 | role: 'quote', 314 | text: 'block quote text', 315 | additions: [], 316 | inlineTextStyles: [], 317 | textStyle: 'quoteTextStyle', 318 | layout: 'quoteTextLayout', 319 | style: 'quoteTextStyle' 320 | }] 321 | }, 322 | { 323 | role: 'body', 324 | text: 'other text\n', 325 | additions: [], 326 | inlineTextStyles: [], 327 | layout: 'bodyLayout' 328 | } 329 | ], 330 | layout: { 331 | ignoreDocumentMargin: true 332 | } 333 | 334 | }; 335 | t.deepEqual(expected.components, article.components); 336 | t.deepEqual(expected.componentTextStyles, article.componentTextStyles); 337 | t.deepEqual(expected.textStyles, article.textStyles); 338 | 339 | // write test article for the preview 340 | writeAppleNewsArticle(article, 'text'); 341 | }); 342 | 343 | test('unknown element type', t => { 344 | const data = { 345 | title: 'Article Title', 346 | author: { 347 | name: 'David Hipsterson' 348 | }, 349 | publishedDate: new Date('2016-02-04T14:00:00Z'), 350 | body: [ 351 | { type: 'unknown-element', children: [] } 352 | ] 353 | }; 354 | 355 | const {article} = toAppleNews(data, {identifier: '100'}); 356 | t.true(article.components.length === 1); 357 | }); 358 | 359 | test('embeds', t => { 360 | const data = { 361 | title: 'embeds', 362 | author: { 363 | name: 'David Hipsterson' 364 | }, 365 | publishedDate: new Date('2016-02-04T14:00:00Z'), 366 | body: [ 367 | { 368 | type: 'embed', 369 | embedType: 'instagram', 370 | id: 'BDvcE47g6Ed', 371 | caption: [ 372 | { type: 'text', href: 'http://mic.com', content: 'link' }, 373 | { type: 'linebreak' }, 374 | { type: 'text', content: 'normal text ' }, 375 | { type: 'text', bold: true, content: 'bold text ' }, 376 | { type: 'text', italic: true, content: 'italic text ' }, 377 | { type: 'text', bold: true, italic: true, content: 'bold italic text ' }, 378 | { type: 'text', mark: true, content: 'marked text' }, 379 | { type: 'text', mark: true, markClass: 'marker1' } 380 | ] 381 | }, 382 | { 383 | type: 'embed', 384 | embedType: 'twitter', 385 | url: 'https://twitter.com/randal_olson/status/709090467821064196', 386 | caption: [ 387 | { type: 'text', href: 'http://mic.com', content: 'link' }, 388 | { type: 'linebreak' }, 389 | { type: 'text', content: 'normal text ' }, 390 | { type: 'text', bold: true, content: 'bold text ' }, 391 | { type: 'text', italic: true, content: 'italic text ' }, 392 | { type: 'text', bold: true, italic: true, content: 'bold italic text ' }, 393 | { type: 'text', mark: true, content: 'marked text' }, 394 | { type: 'text', mark: true, markClass: 'marker1' } 395 | ] 396 | }, 397 | { 398 | type: 'embed', 399 | embedType: 'twitter', 400 | url: 'https://twitter.com/Kevunn/status/724060483213385729/photo/1', 401 | caption: [] 402 | }, 403 | { 404 | type: 'embed', 405 | embedType: 'twitter', 406 | url: 'https://twitter.com/invalid-url', 407 | caption: [] 408 | }, 409 | { 410 | type: 'embed', 411 | embedType: 'youtube', 412 | youtubeId: 'oo6D4MXrJ5c', 413 | caption: [ 414 | { type: 'text', href: 'http://mic.com', content: 'link' }, 415 | { type: 'linebreak' }, 416 | { type: 'text', content: 'normal text ' }, 417 | { type: 'text', bold: true, content: 'bold text ' }, 418 | { type: 'text', italic: true, content: 'italic text ' }, 419 | { type: 'text', bold: true, italic: true, content: 'bold italic text ' }, 420 | { type: 'text', mark: true, content: 'marked text' }, 421 | { type: 'text', mark: true, markClass: 'marker1' } 422 | ] 423 | }, 424 | { 425 | type: 'embed', 426 | embedType: 'image', 427 | src: 'bundle://image.jpg', 428 | caption: [ 429 | { type: 'text', href: 'http://mic.com', content: 'link' }, 430 | { type: 'linebreak' }, 431 | { type: 'text', content: 'normal text ' }, 432 | { type: 'text', bold: true, content: 'bold text ' }, 433 | { type: 'text', italic: true, content: 'italic text ' }, 434 | { type: 'text', bold: true, italic: true, content: 'bold italic text ' }, 435 | { type: 'text', mark: true, content: 'marked text' }, 436 | { type: 'text', mark: true, markClass: 'marker1' } 437 | ] 438 | }, 439 | { 440 | type: 'embed', 441 | embedType: 'video', 442 | src: 'http://mic.com/video.gif' 443 | } 444 | ] 445 | }; 446 | const {article} = toAppleNews(data, {identifier: '100'}); 447 | const actual = article; 448 | writeAppleNewsArticle(actual, 'embeds'); 449 | 450 | const caption = { 451 | text: 'link\nnormal text bold text italic text bold italic text marked text\n', 452 | textStyle: 'embedCaptionTextStyle', 453 | additions: [ 454 | { 455 | 'type': 'link', 456 | 'rangeStart': 0, 457 | 'rangeLength': 4, 458 | 'URL': 'http://mic.com' 459 | } 460 | ], 461 | inlineTextStyles: [ 462 | { 463 | rangeStart: 0, 464 | rangeLength: 4, 465 | textStyle: 'embedCaptionTextStyle' 466 | }, 467 | { 468 | rangeStart: 17, 469 | rangeLength: 10, 470 | textStyle: 'embedCaptionTextStyle' 471 | }, 472 | { 473 | rangeStart: 27, 474 | rangeLength: 12, 475 | textStyle: 'embedCaptionTextStyle' 476 | }, 477 | { 478 | rangeStart: 39, 479 | rangeLength: 17, 480 | textStyle: 'embedCaptionTextStyle' 481 | } 482 | ] 483 | }; 484 | 485 | const expectedComponents = [ 486 | { 487 | role: 'container', 488 | components: [ 489 | { 490 | role: 'instagram', 491 | URL: 'https://instagram.com/p/BDvcE47g6Ed', 492 | style: 'embedMediaStyle', 493 | layout: 'embedMediaLayout' 494 | } 495 | ], 496 | layout: 'embedLayout', 497 | style: 'embedStyle' 498 | }, 499 | { 500 | role: 'container', 501 | components: [ 502 | { 503 | role: 'tweet', 504 | URL: 'https://twitter.com/randal_olson/status/709090467821064196', 505 | style: 'embedMediaStyle', 506 | layout: 'embedMediaLayout' 507 | } 508 | ], 509 | layout: 'embedLayout', 510 | style: 'embedStyle' 511 | }, 512 | { 513 | role: 'container', 514 | components: [ 515 | { 516 | role: 'tweet', 517 | URL: 'https://twitter.com/Kevunn/status/724060483213385729', 518 | style: 'embedMediaStyle', 519 | layout: 'embedMediaLayout' 520 | } 521 | ], 522 | layout: 'embedLayout', 523 | style: 'embedStyle' 524 | }, 525 | { 526 | role: 'container', 527 | components: [ 528 | { 529 | role: 'embedwebvideo', 530 | URL: 'https://www.youtube.com/embed/oo6D4MXrJ5c', 531 | style: 'embedMediaStyle', 532 | layout: 'embedMediaLayout' 533 | } 534 | ], 535 | layout: 'embedLayout', 536 | style: 'embedStyle' 537 | }, 538 | { 539 | role: 'container', 540 | components: [ 541 | { 542 | role: 'photo', 543 | URL: 'bundle://image.jpg', 544 | style: 'embedMediaStyle', 545 | layout: 'embedMediaLayout', 546 | caption 547 | } 548 | ], 549 | layout: 'embedLayout', 550 | style: 'embedStyle' 551 | }, 552 | { 553 | role: 'container', 554 | components: [ 555 | { 556 | role: 'video', 557 | URL: 'http://mic.com/video.gif', 558 | style: 'embedMediaStyle', 559 | layout: 'embedMediaLayout' 560 | } 561 | ], 562 | layout: 'embedLayout', 563 | style: 'embedStyle' 564 | } 565 | ]; 566 | 567 | const actualBodyComponents = actual.components; 568 | actualBodyComponents.shift(); 569 | t.deepEqual(actualBodyComponents, expectedComponents); 570 | }); 571 | 572 | test('images', t => { 573 | const expectedComponents = [ 574 | { 575 | role: 'container', 576 | components: [{role: 'photo', URL: 'bundle://image-0.jpg', style: 'embedMediaStyle', layout: 'embedMediaLayout'}], 577 | layout: 'embedLayout', 578 | style: 'embedStyle' 579 | }, 580 | { 581 | role: 'container', 582 | components: [{role: 'photo', URL: 'bundle://image-1.png', style: 'embedMediaStyle', layout: 'embedMediaLayout'}], 583 | layout: 'embedLayout', 584 | style: 'embedStyle' 585 | }, 586 | { 587 | role: 'container', 588 | components: [{role: 'photo', URL: 'bundle://image-0.jpg', style: 'embedMediaStyle', layout: 'embedMediaLayout'}], 589 | layout: 'embedLayout', 590 | style: 'embedStyle' 591 | } 592 | ]; 593 | const expectedBundlesToUrls = { 594 | 'image-0.jpg': 'http://example.com/image.jpg', 595 | 'image-1.png': 'http://example.com/beep-boop.png' 596 | }; 597 | const input = { 598 | title: 'foo', 599 | author: { 600 | name: 'David Hipsterson' 601 | }, 602 | publishedDate: new Date('2016-02-04T14:00:00Z'), 603 | body: [ 604 | { 605 | type: 'embed', 606 | embedType: 'image', 607 | src: 'http://example.com/image.jpg', 608 | caption: [] 609 | }, 610 | { 611 | type: 'embed', 612 | embedType: 'image', 613 | src: 'http://example.com/beep-boop.png', 614 | caption: [] 615 | }, 616 | { 617 | type: 'embed', 618 | embedType: 'image', 619 | src: 'http://example.com/image.jpg', 620 | caption: [] 621 | } 622 | ] 623 | }; 624 | const {article, bundlesToUrls} = toAppleNews(input, {identifier: '100'}); 625 | const actualBodyComponents = article.components; 626 | actualBodyComponents.shift(); 627 | 628 | t.deepEqual(bundlesToUrls, expectedBundlesToUrls); 629 | t.deepEqual(actualBodyComponents, expectedComponents); 630 | }); 631 | 632 | test('header with image', t => { 633 | const data = { 634 | title: 'Beep boop', 635 | author: { 636 | name: 'Sergii Iefremov', 637 | href: 'http://mic.com/' 638 | }, 639 | publishedDate: new Date('1985-03-22T14:00:00Z'), 640 | headerEmbed: { 641 | type: 'embed', 642 | embedType: 'image', 643 | src: 'bundle://image.jpg', 644 | caption: [ 645 | { type: 'text', content: 'normal text' } 646 | ] 647 | }, 648 | body: [] 649 | }; 650 | const {article} = toAppleNews(data, {identifier: '100'}); 651 | const actual = article.components[0]; 652 | const expected = { 653 | role: 'header', 654 | layout: 'headerLayout', 655 | style: 'headerStyle', 656 | components: [{ 657 | role: 'container', 658 | components: [{ 659 | role: 'photo', 660 | URL: 'bundle://image.jpg', 661 | style: 'headerEmbedMediaStyle', 662 | layout: 'headerEmbedMediaLayout', 663 | caption: { 664 | text: 'normal text\n', 665 | additions: [], 666 | inlineTextStyles: [], 667 | textStyle: 'headerCaptionTextStyle' 668 | } 669 | }], 670 | layout: 'headerEmbedLayout', 671 | style: 'headerEmbedStyle' 672 | }, { 673 | role: 'title', 674 | layout: 'titleLayout', 675 | text: 'Beep boop' 676 | }, { 677 | text: 'By Sergii Iefremov March 22, 1985', 678 | additions: [{ 679 | type: 'link', 680 | rangeStart: 3, 681 | rangeLength: 15, 682 | URL: 'http://mic.com/' 683 | }], 684 | inlineTextStyles: [{ 685 | rangeStart: 3, 686 | rangeLength: 15, 687 | textStyle: 'bodyLinkTextStyle' 688 | }], 689 | role: 'byline', 690 | layout: 'bylineLayout' 691 | }] 692 | }; 693 | 694 | writeAppleNewsArticle(article, 'header-with-image'); 695 | t.deepEqual(actual, expected); 696 | }); 697 | 698 | test('empty text element should not be rendered', t => { 699 | const data = { 700 | title: 'Article Title', 701 | author: { 702 | name: 'David Hipsterson' 703 | }, 704 | publishedDate: new Date('2016-02-04T14:00:00Z'), 705 | body: [ 706 | { 707 | type: 'paragraph', 708 | children: [ 709 | { type: 'text', content: '' }, 710 | { type: 'other', content: 'a' }, 711 | { type: 'text' } 712 | ] 713 | } 714 | ] 715 | }; 716 | 717 | const {article} = toAppleNews(data, {identifier: '100'}); 718 | t.true(article.components.length === 1); 719 | }); 720 | 721 | test('custom hero component and image hero embed', t => { 722 | const data = { 723 | title: 'Beep boop', 724 | author: { 725 | name: 'Sergii Iefremov', 726 | href: 'http://mic.com/' 727 | }, 728 | publishedDate: new Date('1985-03-22T14:00:00Z'), 729 | headerEmbed: { 730 | type: 'embed', 731 | embedType: 'image', 732 | src: 'bundle://image.jpg', 733 | caption: [ 734 | { type: 'text', content: 'normal text' } 735 | ] 736 | }, 737 | body: [] 738 | }; 739 | 740 | const customHero = { 741 | role: 'container', 742 | text: 'custom' 743 | }; 744 | 745 | const {article} = toAppleNews(data, { 746 | identifier: '100', 747 | heroComponent: customHero 748 | }); 749 | 750 | const actual = article.components[0]; 751 | const expected = { 752 | role: 'header', 753 | layout: 'headerLayout', 754 | style: 'headerStyle', 755 | components: [ 756 | customHero, { 757 | text: 'By Sergii Iefremov March 22, 1985', 758 | additions: [{ 759 | type: 'link', 760 | rangeStart: 3, 761 | rangeLength: 15, 762 | URL: 'http://mic.com/' 763 | }], 764 | inlineTextStyles: [{ 765 | rangeStart: 3, 766 | rangeLength: 15, 767 | textStyle: 'bodyLinkTextStyle' 768 | }], 769 | role: 'byline', 770 | layout: 'bylineLayout' 771 | } 772 | ] 773 | }; 774 | 775 | writeAppleNewsArticle(article, 'header-with-image'); 776 | t.deepEqual(actual, expected); 777 | }); 778 | 779 | test('custom hero component and no hero embed', t => { 780 | const data = { 781 | title: 'Beep boop', 782 | author: { 783 | name: 'Sergii Iefremov', 784 | href: 'http://mic.com/' 785 | }, 786 | publishedDate: new Date('1985-03-22T14:00:00Z'), 787 | body: [] 788 | }; 789 | 790 | const customHero = { 791 | role: 'container', 792 | text: 'custom' 793 | }; 794 | 795 | const {article} = toAppleNews(data, { 796 | identifier: '100', 797 | heroComponent: customHero 798 | }); 799 | 800 | const actual = article.components[0]; 801 | const expected = { 802 | role: 'header', 803 | layout: 'headerLayout', 804 | style: 'headerStyle', 805 | components: [ 806 | customHero, { 807 | text: 'By Sergii Iefremov March 22, 1985', 808 | additions: [{ 809 | type: 'link', 810 | rangeStart: 3, 811 | rangeLength: 15, 812 | URL: 'http://mic.com/' 813 | }], 814 | inlineTextStyles: [{ 815 | rangeStart: 3, 816 | rangeLength: 15, 817 | textStyle: 'bodyLinkTextStyle' 818 | }], 819 | role: 'byline', 820 | layout: 'bylineLayout' 821 | } 822 | ] 823 | }; 824 | 825 | writeAppleNewsArticle(article, 'header-with-image'); 826 | t.deepEqual(actual, expected); 827 | }); 828 | 829 | test('metadata', t => { 830 | const data = { 831 | title: 'Article Title', 832 | author: { 833 | name: 'David Hipsterson' 834 | }, 835 | headerEmbed: { 836 | type: 'embed', 837 | embedType: 'image', 838 | src: 'http://example.com/hero.jpg' 839 | }, 840 | publishedDate: new Date('2016-02-04T14:00:00Z'), 841 | modifiedDate: new Date('2010-01-04T14:00:00Z'), 842 | body: [] 843 | }; 844 | 845 | const {article, bundlesToUrls} = toAppleNews(data, { 846 | identifier: '100', 847 | excerpt: 'This is cool article', 848 | canonicalURL: 'https://example.com/100', 849 | campaignData: { 850 | key: 'value', 851 | key2: 'value2' 852 | }, 853 | keywords: ['cool', 'article'] 854 | }); 855 | t.is(article.metadata.datePublished, '2016-02-04T14:00:00Z'); 856 | t.is(article.metadata.dateCreated, '2016-02-04T14:00:00Z'); 857 | t.is(article.metadata.dateModified, '2010-01-04T14:00:00Z'); 858 | t.deepEqual(article.metadata.authors, ['David Hipsterson']); 859 | t.is(article.metadata.generatorName, 'article-json-to-apple-news'); 860 | t.is(article.metadata.generatorVersion, packageJson.version); 861 | t.is(article.metadata.thumbnailURL, 'bundle://image-0.jpg'); 862 | t.deepEqual(bundlesToUrls, { 'image-0.jpg': 'http://example.com/hero.jpg' }); 863 | t.is(article.metadata.excerpt, 'This is cool article'); 864 | t.is(article.metadata.canonicalURL, 'https://example.com/100'); 865 | t.deepEqual(article.metadata.campaignData, { 866 | key: 'value', 867 | key2: 'value2' 868 | }); 869 | t.deepEqual(article.metadata.keywords, ['cool', 'article']); 870 | }); 871 | --------------------------------------------------------------------------------