├── .nvmrc ├── app ├── sites │ └── claydemo │ │ ├── media │ │ ├── mask.svg │ │ ├── favicon.ico │ │ ├── facebook-square.svg │ │ ├── twitter.svg │ │ └── logo.svg │ │ ├── static │ │ └── robots.txt │ │ ├── bootstrap.yml │ │ ├── config.yml │ │ └── index.js ├── .eslintignore ├── .prettierignore ├── components │ ├── divider │ │ ├── bootstrap.yml │ │ ├── schema.yml │ │ └── template.hbs │ ├── meta-url │ │ ├── bootstrap.yml │ │ ├── template.handlebars │ │ ├── model.js │ │ └── schema.yml │ ├── footer │ │ ├── bootstrap.yaml │ │ ├── template.hbs │ │ └── schema.yaml │ ├── meta-site │ │ ├── bootstrap.yml │ │ ├── schema.yml │ │ └── template.handlebars │ ├── meta-authors │ │ ├── bootstrap.yml │ │ ├── template.handlebars │ │ ├── schema.yml │ │ └── model.js │ ├── meta-image │ │ ├── bootstrap.yml │ │ ├── template.hbs │ │ └── schema.yml │ ├── meta-keywords │ │ ├── bootstrap.yml │ │ ├── schema.yml │ │ ├── template.handlebars │ │ └── model.js │ ├── header │ │ ├── bootstrap.yml │ │ ├── model.js │ │ ├── client.js │ │ ├── schema.yaml │ │ └── template.hbs │ ├── tags │ │ ├── bootstrap.yml │ │ ├── client.js │ │ ├── schema.yaml │ │ ├── model.js │ │ └── template.hbs │ ├── meta-description │ │ ├── bootstrap.yml │ │ ├── template.handlebars │ │ └── schema.yml │ ├── list │ │ ├── bootstrap.yml │ │ ├── template.hbs │ │ ├── model.js │ │ └── schema.yml │ ├── meta-icons │ │ ├── bootstrap.yml │ │ ├── template.handlebars │ │ └── schema.yml │ ├── meta-title │ │ ├── bootstrap.yml │ │ ├── template.handlebars │ │ ├── model.js │ │ └── schema.yml │ ├── paragraph │ │ ├── bootstrap.yml │ │ ├── template.hbs │ │ ├── model.js │ │ └── schema.yml │ ├── subheader │ │ ├── bootstrap.yml │ │ ├── media │ │ │ └── icon-link.svg │ │ ├── template.hbs │ │ ├── model.js │ │ └── schema.yml │ ├── image │ │ ├── bootstrap.yml │ │ ├── media │ │ │ ├── ar-square.svg │ │ │ ├── ar-vertical.svg │ │ │ ├── ar-horizontal.svg │ │ │ ├── ar-deep-vertical.svg │ │ │ ├── size-breakout.svg │ │ │ ├── size-inline.svg │ │ │ └── size-inset.svg │ │ ├── schema.yml │ │ ├── model.js │ │ └── template.hbs │ ├── code-sample │ │ ├── bootstrap.yml │ │ ├── template.hbs │ │ ├── model.js │ │ └── schema.yml │ ├── pull-quote │ │ ├── bootstrap.yml │ │ ├── template.hbs │ │ └── schema.yml │ └── article │ │ ├── media │ │ ├── icon-square.svg │ │ ├── icon-vertical.svg │ │ ├── icon-horizontal.svg │ │ ├── warning.svg │ │ ├── icon-feed-horizontal.svg │ │ ├── icon-feature.svg │ │ ├── icon-special-feature.svg │ │ ├── icon-feed-square.svg │ │ ├── icon-feed-noimg.svg │ │ ├── icon-inline.svg │ │ ├── icon-inset.svg │ │ ├── icon-feed-thumb.svg │ │ └── icon-sponsored-info.svg │ │ ├── bootstrap.yml │ │ ├── template.hbs │ │ ├── model.js │ │ └── schema.yaml ├── .prettierrc ├── styleguides │ ├── claydemo │ │ └── fonts │ │ │ ├── Grotesk-Medium.woff2 │ │ │ ├── Grotesk-SemiBold.woff2 │ │ │ └── GroteskNarrow-SemiBold.woff2 │ └── _default │ │ ├── components │ │ ├── divider.css │ │ ├── paragraph.css │ │ ├── code-sample.css │ │ ├── divider_short.css │ │ ├── tags.css │ │ ├── subheader.css │ │ ├── pull-quote.css │ │ ├── footer.css │ │ ├── header.css │ │ ├── list.css │ │ ├── container-rail.css │ │ ├── image.css │ │ └── article.css │ │ ├── common │ │ └── _vars.css │ │ └── layouts │ │ ├── layout.css │ │ └── layout-simple.css ├── claycli.config.js ├── layouts │ ├── layout-simple │ │ ├── bootstrap.yml │ │ ├── schema.yml │ │ └── template.hbs │ └── layout │ │ ├── schema.yml │ │ └── template.hbs ├── .stylelintrc.yml ├── nodemon.json ├── services │ ├── kiln │ │ ├── index.js │ │ └── plugins │ │ │ └── word-count.js │ ├── startup │ │ ├── amphora-renderers.js │ │ ├── amphora-core.js │ │ └── index.js │ ├── client │ │ ├── buffer.js │ │ ├── db.js │ │ ├── popup.js │ │ └── sharePopup.js │ ├── universal │ │ ├── styles.js │ │ ├── callout.js │ │ ├── article-timestamp.js │ │ ├── helpers.js │ │ ├── byline.js │ │ ├── truncate.js │ │ ├── log.js │ │ ├── format-time.js │ │ ├── word-count.js │ │ ├── sanitize.js │ │ ├── rest.js │ │ └── utils.js │ └── server │ │ ├── buffer.js │ │ ├── publish-url.js │ │ ├── db.js │ │ ├── resolve-media.js │ │ └── publish-utils.js ├── app.js ├── .env.sample ├── package.json └── .eslintrc.js ├── Dockerfile ├── sample_users.yml ├── bootstrap-starter-data ├── _lists.yml ├── _pages.yml ├── _layouts.yml └── _components.yml ├── .github ├── PULL_REQUEST_TEMPLATE.md └── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md ├── .dockerignore ├── nginx └── configs │ └── default.conf ├── elasticsearch └── config │ ├── elasticsearch.yml │ └── logging.yml ├── Makefile ├── LICENSE ├── docs └── clay-access-key.md ├── .gitignore ├── .circleci └── config.yml ├── docker-compose.yml └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 12 2 | -------------------------------------------------------------------------------- /app/sites/claydemo/media/mask.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/sites/claydemo/media/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/.eslintignore: -------------------------------------------------------------------------------- 1 | public/* 2 | node_modules/ 3 | -------------------------------------------------------------------------------- /app/.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | public 3 | -------------------------------------------------------------------------------- /app/sites/claydemo/static/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: 2 | Disallow: 3 | -------------------------------------------------------------------------------- /app/components/divider/bootstrap.yml: -------------------------------------------------------------------------------- 1 | _components: 2 | divider: 3 | title: '' 4 | -------------------------------------------------------------------------------- /app/components/meta-url/bootstrap.yml: -------------------------------------------------------------------------------- 1 | _components: 2 | meta-url: 3 | url: '' 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:12 2 | 3 | WORKDIR /usr/local/src/ 4 | COPY app/ ./ 5 | RUN npm i 6 | -------------------------------------------------------------------------------- /app/components/footer/bootstrap.yaml: -------------------------------------------------------------------------------- 1 | _components: 2 | footer: 3 | footerLinks: [] 4 | -------------------------------------------------------------------------------- /app/components/meta-site/bootstrap.yml: -------------------------------------------------------------------------------- 1 | _components: 2 | meta-site: 3 | siteName: '' 4 | -------------------------------------------------------------------------------- /app/components/meta-authors/bootstrap.yml: -------------------------------------------------------------------------------- 1 | _components: 2 | meta-authors: 3 | authors: [] 4 | -------------------------------------------------------------------------------- /app/components/meta-image/bootstrap.yml: -------------------------------------------------------------------------------- 1 | _components: 2 | meta-image: 3 | imageUrl: '' 4 | -------------------------------------------------------------------------------- /app/components/meta-keywords/bootstrap.yml: -------------------------------------------------------------------------------- 1 | _components: 2 | meta-keywords: 3 | tags: [] 4 | -------------------------------------------------------------------------------- /app/components/header/bootstrap.yml: -------------------------------------------------------------------------------- 1 | _components: 2 | header: 3 | componentVariation: header 4 | -------------------------------------------------------------------------------- /app/components/tags/bootstrap.yml: -------------------------------------------------------------------------------- 1 | _components: 2 | tags: 3 | items: [] 4 | normalizedTags: [] 5 | -------------------------------------------------------------------------------- /app/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "none", 3 | "singleQuote": true, 4 | "printWidth": 100 5 | } 6 | -------------------------------------------------------------------------------- /app/components/meta-description/bootstrap.yml: -------------------------------------------------------------------------------- 1 | _components: 2 | meta-description: 3 | description: '' 4 | -------------------------------------------------------------------------------- /app/components/list/bootstrap.yml: -------------------------------------------------------------------------------- 1 | _components: 2 | list: 3 | items: [] 4 | sass: '' 5 | listType: '' 6 | -------------------------------------------------------------------------------- /app/components/meta-icons/bootstrap.yml: -------------------------------------------------------------------------------- 1 | _components: 2 | meta-icons: 3 | siteName: '' 4 | favicon: '' 5 | -------------------------------------------------------------------------------- /app/components/meta-title/bootstrap.yml: -------------------------------------------------------------------------------- 1 | _components: 2 | meta-title: 3 | title: '' 4 | kilnTitle: '' 5 | -------------------------------------------------------------------------------- /app/components/paragraph/bootstrap.yml: -------------------------------------------------------------------------------- 1 | _components: 2 | paragraph: 3 | componentVariation: paragraph 4 | text: '' 5 | -------------------------------------------------------------------------------- /app/components/subheader/bootstrap.yml: -------------------------------------------------------------------------------- 1 | _components: 2 | subheader: 3 | text: '' 4 | type: 'h2' 5 | link: '' 6 | -------------------------------------------------------------------------------- /app/components/image/bootstrap.yml: -------------------------------------------------------------------------------- 1 | _components: 2 | image: 3 | imageUrl: '' 4 | imageCaption: '' 5 | imageAlt: '' 6 | -------------------------------------------------------------------------------- /app/components/code-sample/bootstrap.yml: -------------------------------------------------------------------------------- 1 | _components: 2 | code-sample: 3 | code: '' 4 | language: 'javascript' 5 | html: '' 6 | -------------------------------------------------------------------------------- /app/components/pull-quote/bootstrap.yml: -------------------------------------------------------------------------------- 1 | _components: 2 | pull-quote: 3 | quote: '' 4 | hasQuoteMarks: false 5 | attribution: '' 6 | -------------------------------------------------------------------------------- /app/components/paragraph/template.hbs: -------------------------------------------------------------------------------- 1 |

{{{ text }}}

2 | -------------------------------------------------------------------------------- /app/styleguides/claydemo/fonts/Grotesk-Medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clay/clay-starter/HEAD/app/styleguides/claydemo/fonts/Grotesk-Medium.woff2 -------------------------------------------------------------------------------- /app/components/meta-description/template.handlebars: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /app/styleguides/claydemo/fonts/Grotesk-SemiBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clay/clay-starter/HEAD/app/styleguides/claydemo/fonts/Grotesk-SemiBold.woff2 -------------------------------------------------------------------------------- /app/components/article/media/icon-square.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/components/article/media/icon-vertical.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/styleguides/claydemo/fonts/GroteskNarrow-SemiBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/clay/clay-starter/HEAD/app/styleguides/claydemo/fonts/GroteskNarrow-SemiBold.woff2 -------------------------------------------------------------------------------- /app/components/article/media/icon-horizontal.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/components/meta-authors/template.handlebars: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /sample_users.yml: -------------------------------------------------------------------------------- 1 | # Below is a sample user object for an admin user 2 | _users: 3 | - 4 | username: admin 5 | password: clay 6 | provider: local 7 | auth: admin 8 | -------------------------------------------------------------------------------- /app/components/meta-icons/template.handlebars: -------------------------------------------------------------------------------- 1 | 2 | {{! Favicon }} 3 | 4 | -------------------------------------------------------------------------------- /app/sites/claydemo/bootstrap.yml: -------------------------------------------------------------------------------- 1 | _components: 2 | # Required to have a Kiln instance for edit mode 3 | clay-kiln: 4 | instances: 5 | general: 6 | enabled: true 7 | -------------------------------------------------------------------------------- /app/styleguides/_default/components/divider.css: -------------------------------------------------------------------------------- 1 | $divider_color: #767676; 2 | 3 | .divider { 4 | border-top: 1px solid $divider_color; 5 | margin: 0 0 20px; 6 | width: 100%; 7 | } 8 | -------------------------------------------------------------------------------- /app/styleguides/_default/components/paragraph.css: -------------------------------------------------------------------------------- 1 | $black: #000; 2 | $serif-stack: Georgia, serif; 3 | 4 | .paragraph { 5 | color: $black; 6 | font: 18px/1.5 $serif-stack; 7 | margin: 0 0 1em; 8 | } 9 | -------------------------------------------------------------------------------- /app/styleguides/_default/components/code-sample.css: -------------------------------------------------------------------------------- 1 | @import 'prismjs/themes/prism.css'; 2 | 3 | /* To use different theme. All theme css in prismjs/themes/xxxxx */ 4 | /* @import 'prismjs/themes/prism-dark.css'; */ 5 | -------------------------------------------------------------------------------- /app/components/article/media/warning.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/claycli.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | babelTargets: { browsers: '> 0.25%, not dead' }, 5 | autoprefixerOptions: { browsers: ['last 2 versions', 'ie >= 9', 'ios >= 7', 'android >= 4.4.2'] } 6 | }; 7 | -------------------------------------------------------------------------------- /app/components/article/bootstrap.yml: -------------------------------------------------------------------------------- 1 | _components: 2 | article: 3 | authors: [] 4 | byline: [] 5 | dateUpdated: false 6 | content: [] 7 | tags: 8 | _ref: /_components/tags 9 | normalizedTags: [] 10 | -------------------------------------------------------------------------------- /bootstrap-starter-data/_lists.yml: -------------------------------------------------------------------------------- 1 | _lists: 2 | new-pages: 3 | - id: articles 4 | title: '1. Articles' 5 | children: 6 | - id: new-standard 7 | title: Article 8 | tags: [] 9 | authors: [] 10 | -------------------------------------------------------------------------------- /app/components/article/media/icon-feed-horizontal.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/components/article/media/icon-feature.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/components/meta-image/template.hbs: -------------------------------------------------------------------------------- 1 | 2 | {{#if imageUrl}} 3 | 4 | 5 | {{/if}} 6 | -------------------------------------------------------------------------------- /app/layouts/layout-simple/bootstrap.yml: -------------------------------------------------------------------------------- 1 | _layouts: 2 | layout-simple: 3 | head: head 4 | headLayout: [] 5 | top: top 6 | header: header 7 | main: main 8 | bottom: [] 9 | kilnInternals: 10 | - 11 | _ref: /_components/clay-kiln 12 | -------------------------------------------------------------------------------- /app/.stylelintrc.yml: -------------------------------------------------------------------------------- 1 | plugins: 2 | - stylelint-order 3 | rules: 4 | declaration-no-important: [true, {severity: warning}] 5 | max-nesting-depth: 2 6 | order/order: [dollar-variables, at-variables, declarations, rules, at-rules] 7 | order/properties-alphabetical-order: true 8 | -------------------------------------------------------------------------------- /app/components/article/media/icon-special-feature.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/components/image/media/ar-square.svg: -------------------------------------------------------------------------------- 1 | Aspect Ratio 1:1 -------------------------------------------------------------------------------- /app/components/image/media/ar-vertical.svg: -------------------------------------------------------------------------------- 1 | Aspect Ratio 4:5 -------------------------------------------------------------------------------- /app/components/article/media/icon-feed-square.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/components/image/media/ar-horizontal.svg: -------------------------------------------------------------------------------- 1 | Aspect Ratio 6:4 -------------------------------------------------------------------------------- /app/components/paragraph/model.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const sanitize = require('../../services/universal/sanitize'); 4 | 5 | module.exports.save = function(ref, data) { 6 | data.text = sanitize.validateTagContent(sanitize.toSmartText(data.text || '')); 7 | 8 | return data; 9 | }; 10 | -------------------------------------------------------------------------------- /app/sites/claydemo/config.yml: -------------------------------------------------------------------------------- 1 | name: ${CLAY_SITE_NAME} 2 | host: ${CLAY_SITE_HOST} 3 | path: ${CLAY_SITE_PATH} 4 | assetDir: public 5 | assetPath: ${CLAY_SITE_PATH} 6 | port: ${CLAY_SITE_PORT} 7 | protocol: ${CLAY_SITE_PROTOCOL} 8 | shortKey: ${CLAY_SITE_SHORTKEY} 9 | styleguide: claydemo 10 | -------------------------------------------------------------------------------- /app/components/article/media/icon-feed-noimg.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/components/article/media/icon-inline.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/components/divider/schema.yml: -------------------------------------------------------------------------------- 1 | _description: | 2 | A simple divider between other components. Can provide an optional title for the divider. 3 | 4 | title: 5 | _label: Divider Title 6 | _placeholder: 7 | height: 20px 8 | text: Divider Title 9 | _has: 10 | input: inline 11 | -------------------------------------------------------------------------------- /app/components/meta-url/template.handlebars: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{#if date}} 5 | 6 | {{/if}} 7 | -------------------------------------------------------------------------------- /app/components/image/media/ar-deep-vertical.svg: -------------------------------------------------------------------------------- 1 | Aspect Ratio 4:6 -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## What does this PR do? 2 | 3 | [Issue/Story](LINK_TO_STORY) 4 | 5 | ### Why are we doing this? Any context or related work? 6 | 7 | #### Where should a reviewer start? 8 | 9 | ### Manual testing steps? 10 | 11 | ##### Screenshots 12 | 13 | ### Additional Context 14 | -------------------------------------------------------------------------------- /app/components/header/model.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports.save = function(uri, data) { 4 | if (data.siteLogo) { 5 | data.isSVGString = 6 | data.siteLogo 7 | .trim() 8 | .substr(0, 4) 9 | .toLowerCase() === ' -------------------------------------------------------------------------------- /app/components/subheader/media/icon-link.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/components/article/media/icon-feed-thumb.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/components/footer/template.hbs: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /app/components/subheader/template.hbs: -------------------------------------------------------------------------------- 1 | <{{type}} class="{{ componentVariation }}" data-editable="subheader" data-uri="{{default _ref _self}}" id={{subheaderId}}> 2 | {{{text}}} 3 | {{#if text}} 4 | 5 | {{{ read 'public/media/components/subheader/icon-link.svg' }}} 6 | 7 | {{/if}} 8 | 9 | -------------------------------------------------------------------------------- /app/components/image/media/size-breakout.svg: -------------------------------------------------------------------------------- 1 | Width Large -------------------------------------------------------------------------------- /app/components/image/media/size-inline.svg: -------------------------------------------------------------------------------- 1 | Width Column Width -------------------------------------------------------------------------------- /app/components/meta-keywords/schema.yml: -------------------------------------------------------------------------------- 1 | _description: | 2 | Adds `article:tag` (schema.org) tags to the head. 3 | 4 | tags: 5 | _label: Tags 6 | _display: settings 7 | _subscribe: keywords 8 | _has: 9 | - simple-list 10 | - label 11 | - 12 | fn: description 13 | value: Array of tags. These will become comma-separated in the template. 14 | -------------------------------------------------------------------------------- /app/components/subheader/model.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const sanitize = require('../../services/universal/sanitize'); 4 | 5 | module.exports.save = function(ref, data) { 6 | data.text = sanitize.validateTagContent(sanitize.toSmartText(data.text || '')); 7 | data.subheaderId = `${data.subheaderId || data.text}`.trim().replace(/\s+/g, '-'); 8 | return data; 9 | }; 10 | -------------------------------------------------------------------------------- /app/components/meta-title/template.handlebars: -------------------------------------------------------------------------------- 1 | 2 | {{#if @root.locals.edit}} 3 | Editing: {{ default title 'New Page' }} 4 | {{ else }} 5 | {{ title }} 6 | {{/if}} 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/components/divider/template.hbs: -------------------------------------------------------------------------------- 1 |
2 | {{!-- Check that we're dealing with the base variation --}} 3 | {{#if (compare componentVariation "===" "divider")}} 4 | {{#ifAny title @root.locals.edit}} 5 |

{{ title }}

6 | {{/ifAny}} 7 | {{/if}} 8 |
9 | -------------------------------------------------------------------------------- /app/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "ext": "css js yaml yml handlebars hbs vue", 3 | "ignore": [ 4 | "*.test.js" 5 | ], 6 | "max-old-space-size": 256, 7 | "max-semi-space-size": 2, 8 | "restartable": "rs", 9 | "watch": [ 10 | "app.js", 11 | "components", 12 | "layouts", 13 | "global", 14 | "search", 15 | "services", 16 | "styleguides", 17 | "sites" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /app/components/list/template.hbs: -------------------------------------------------------------------------------- 1 |
2 | <{{listType}} class="list-items"> 3 | {{#each items}} 4 |
  • {{{ text }}}
  • 5 | {{/each}} 6 | 7 | 8 | {{#if css}} 9 | 10 | {{/if}} 11 |
    12 | -------------------------------------------------------------------------------- /app/components/code-sample/template.hbs: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | {{#if html}} 4 | {{!-- code tag should be in the same line as pre tab, it will break the style if not --}} 5 |
    {{{html}}}
    6 | {{/if}} 7 |
    8 |
    9 | -------------------------------------------------------------------------------- /app/components/pull-quote/template.hbs: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /app/components/image/media/size-inset.svg: -------------------------------------------------------------------------------- 1 | Width Inset -------------------------------------------------------------------------------- /app/components/meta-image/schema.yml: -------------------------------------------------------------------------------- 1 | _description: | 2 | Receives an image and renders it in the head 3 | 4 | imageUrl: 5 | _label: Image Url 6 | _display: settings 7 | _subscribe: pageMetaImage 8 | _has: 9 | - 10 | fn: text 11 | type: url 12 | - label 13 | - 14 | fn: description 15 | value: URL of an image to show in the feeds. Usually the first image in an article. 16 | -------------------------------------------------------------------------------- /app/components/meta-keywords/template.handlebars: -------------------------------------------------------------------------------- 1 | 2 | {{#if @root.locals.edit}} 3 | 4 | 5 | {{else}} 6 | 7 | 8 | {{/if}} 9 | -------------------------------------------------------------------------------- /app/components/meta-site/schema.yml: -------------------------------------------------------------------------------- 1 | _description: | 2 | Adds site-specific tags to the head: 3 | 4 | * `og:site_name` 5 | * `og:type` 6 | * `article:publisher` (for facebook) 7 | 8 | siteName: 9 | _display: settings 10 | _label: Site Name 11 | _has: 12 | - text 13 | - label 14 | - required 15 | - 16 | fn: description 17 | value: should be a human-readable site name, which will appear in Google search results and Facebook posts 18 | -------------------------------------------------------------------------------- /app/components/meta-keywords/model.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _isEmpty = require('lodash/isEmpty'), 4 | _isObject = require('lodash/isObject'), 5 | _head = require('lodash/head'); 6 | 7 | module.exports.save = function(ref, data) { 8 | // convert array of {text: string} objects into regular array of strings 9 | if (!_isEmpty(data.tags) && _isObject(_head(data.tags))) { 10 | data.tags = data.tags.map(tag => tag.text); 11 | } 12 | 13 | return data; 14 | }; 15 | -------------------------------------------------------------------------------- /app/components/code-sample/model.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const Prism = require('prismjs'); 3 | 4 | require('prismjs/components/prism-yaml'); 5 | 6 | module.exports.save = (uri, data) => { 7 | // Adds manual spaces, Kiln codemirror doesn't recognizes tab spaces 8 | data.code = data.code.replace(/\t/g, ' '); 9 | 10 | // Returns a highlighted HTML string 11 | data.html = Prism.highlight(data.code, Prism.languages[data.language], data.language); 12 | 13 | return data; 14 | }; 15 | -------------------------------------------------------------------------------- /app/components/meta-title/model.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const sanitize = require('../../services/universal/sanitize'); 4 | 5 | module.exports.save = (ref, data) => { 6 | data = sanitize.recursivelyStripSeperators(data); 7 | 8 | if (!data.kilnTitle) { 9 | data.kilnTitle = data.title; 10 | } else if (!data.title && data.kilnTitle) { 11 | // If the pagelist has title, but metatag is empty 12 | data.title = data.kilnTitle; 13 | } 14 | 15 | return data; 16 | }; 17 | -------------------------------------------------------------------------------- /app/components/code-sample/schema.yml: -------------------------------------------------------------------------------- 1 | _description: | 2 | A component for showing a code example. 3 | code: 4 | _label: Insert Code Snippet 5 | _placeholder: 6 | height: 10px 7 | text: Insert Code Snippet 8 | _has: 9 | input: codemirror 10 | mode: text/x-yaml 11 | 12 | language: 13 | _has: 14 | input: select 15 | options: 16 | - javascript 17 | - css 18 | - yaml 19 | 20 | _groups: 21 | settings: 22 | fields: 23 | - language 24 | -------------------------------------------------------------------------------- /app/components/meta-site/template.handlebars: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /app/sites/claydemo/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const publishing = require('../../services/server/publish-url'), 4 | mainComponentRefs = ['article']; 5 | 6 | module.exports.routes = [ 7 | { path: '/' }, 8 | { path: '/:year/:month/:name' }, 9 | { path: '/article/:name' } 10 | ]; 11 | 12 | // Resolve the url to publish to 13 | module.exports.resolvePublishUrl = [ 14 | // Simple url format 15 | (uri, data, locals) => publishing.getSlugUrl(data, locals, mainComponentRefs) 16 | ]; 17 | -------------------------------------------------------------------------------- /app/components/meta-authors/schema.yml: -------------------------------------------------------------------------------- 1 | _description: | 2 | Grabs author details and displays meta tags if they exist. 3 | 4 | authors: 5 | _label: Authors 6 | _display: settings 7 | _subscribe: pageAuthors 8 | _publish: kilnAuthors # set page authors in page list 9 | _has: 10 | - simple-list 11 | - label 12 | - 13 | fn: description 14 | value: Array of author names. These will become comma-separated in the template. For articles, this is generated by the authors. 15 | -------------------------------------------------------------------------------- /app/components/meta-description/schema.yml: -------------------------------------------------------------------------------- 1 | _description: | 2 | Adds `` tags to the head. 3 | 4 | description: 5 | _label: Description 6 | _subscribe: pageDescription 7 | _has: 8 | input: text 9 | help: Description of the content on this page, used by search engines. 10 | # validate: # TODO: make required when we have an approach for article metadata 11 | # required: true 12 | 13 | _groups: 14 | settings: 15 | fields: 16 | - description 17 | -------------------------------------------------------------------------------- /app/components/header/client.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const sharePopUp = require('../../services/client/sharePopup'); 4 | 5 | module.exports = () => { 6 | let canonicalEl = document.querySelector('link[rel="canonical"]'), 7 | canonicalURL = canonicalEl.getAttribute('href'), 8 | shareURL = canonicalURL.trim() || document.location.href; 9 | 10 | [...document.querySelectorAll('header.header .share-link')].forEach(shareLink => { 11 | new sharePopUp(shareLink, shareURL); 12 | }); 13 | }; 14 | -------------------------------------------------------------------------------- /app/sites/claydemo/media/facebook-square.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/services/kiln/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const props = ['inputs', 'modals', 'plugins', 'toolbarButtons', 'validators', 'transformers']; 4 | 5 | module.exports = () => { 6 | window.kiln = window.kiln || {}; // create global kiln if it doesn't exist 7 | window.kiln.helpers = require('../universal/helpers'); 8 | 9 | props.forEach(prop => { 10 | // create global properties if they don't exist 11 | window.kiln[prop] = window.kiln[prop] || {}; 12 | }); 13 | 14 | require('./plugins/word-count')(); 15 | }; 16 | -------------------------------------------------------------------------------- /app/styleguides/_default/common/_vars.css: -------------------------------------------------------------------------------- 1 | /* Fonts */ 2 | 3 | $serif-stack: Georgia, serif; 4 | $sans-serif-stack: Grotesk, sans-serif; 5 | $sans-serif-narrow-stack: GroteskNarrow, sans-serif; 6 | 7 | 8 | /* Colors */ 9 | 10 | $background-primary-color: #fff; 11 | $black: #000; 12 | $blue: #0086be; 13 | $light-gray: #b1b1b1; 14 | $light-text: #707070; 15 | $medium-gray: #404040; 16 | $text-black: #111; 17 | 18 | 19 | /* Wrapper */ 20 | $wrapperMaxWidth: 600px; 21 | 22 | 23 | $sm: 768px; 24 | $md: 992px; 25 | $lg: 1180px; 26 | -------------------------------------------------------------------------------- /app/components/tags/client.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = el => { 4 | const moreButton = el.querySelector('.more'); 5 | 6 | if (moreButton) { 7 | moreButton.addEventListener('click', e => { 8 | const button = e.target, 9 | hiddenTags = el.querySelectorAll('li.hidden'); 10 | 11 | hiddenTags.forEach(function(hiddenTag) { 12 | hiddenTag.classList.remove('hidden'); 13 | }); 14 | 15 | button.parentNode.removeChild(button); 16 | e.preventDefault(); 17 | }); 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /app/components/meta-url/model.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * set component canonical url and date if they're passed in through the locals 5 | * @param {object} data 6 | * @param {object} [locals] 7 | */ 8 | function setFromLocals(data, locals) { 9 | if (locals && locals.publishUrl) { 10 | data.url = locals.publishUrl; 11 | } 12 | 13 | if (locals && locals.date) { 14 | data.date = locals.date; 15 | } 16 | } 17 | 18 | module.exports.save = (ref, data, locals) => { 19 | setFromLocals(data, locals); 20 | return data; 21 | }; 22 | -------------------------------------------------------------------------------- /nginx/configs/default.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80 default_server; 3 | server_name _; 4 | resolver 127.0.0.11 valid=10s; 5 | 6 | location / { 7 | proxy_set_header Host $host; 8 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 9 | proxy_set_header X-Forwarded-Host $host; 10 | proxy_set_header X-Forwarded-Proto $scheme; 11 | proxy_set_header X-Real-IP $remote_addr; 12 | proxy_redirect off; 13 | set $target "http://clay:3001"; 14 | proxy_pass $target; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/components/meta-icons/schema.yml: -------------------------------------------------------------------------------- 1 | _description: | 2 | A component that adds a favicon to the head. 3 | 4 | siteName: 5 | _label: Site Name 6 | _has: 7 | input: text 8 | validate: 9 | required: true 10 | help: Name of the current site (for Apple Mobile Web App) 11 | 12 | favicon: 13 | _label: Favicon 14 | _has: 15 | input: text 16 | type: url 17 | validate: 18 | required: true 19 | help: Old-school favicon (.ico file) 20 | 21 | _groups: 22 | settings: 23 | fields: 24 | - favicon (Web Standard Icons) 25 | -------------------------------------------------------------------------------- /app/sites/claydemo/media/twitter.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /app/services/startup/amphora-renderers.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const pkg = require('../../package.json'), 4 | amphoraHtml = require('amphora-html'), 5 | helpers = require('../universal/helpers'), 6 | resolveMediaService = require('../server/resolve-media'); 7 | 8 | amphoraHtml.configureRender({ 9 | editAssetTags: true, 10 | cacheBuster: pkg.version 11 | }); 12 | 13 | amphoraHtml.addResolveMedia(resolveMediaService); 14 | amphoraHtml.addHelpers(helpers); 15 | amphoraHtml.addEnvVars(require('../../client-env.json')); 16 | 17 | module.exports = { 18 | default: 'html', 19 | html: amphoraHtml 20 | }; 21 | -------------------------------------------------------------------------------- /app/app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const pkg = require('./package.json'), 4 | express = require('express'), 5 | logger = require('./services/universal/log'), 6 | startup = require('./services/startup'), 7 | port = process.env.PORT || 3001, 8 | ip = process.env.IP_ADDRESS || '0.0.0.0'; 9 | 10 | logger.init(pkg.version); 11 | let log = logger.setup({ file: __filename }); 12 | 13 | startup(express()) 14 | .then(router => { 15 | router.listen(port, ip); 16 | log('info', `Clay listening on ${ip}:${port} (process ${process.pid})`); 17 | }) 18 | .catch(error => { 19 | log('error', error.message, { stack: error.stack }); 20 | }); 21 | -------------------------------------------------------------------------------- /app/services/client/buffer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Base-64 encode a string 5 | * 6 | * @param {String} string 7 | * @return {String} 8 | */ 9 | function encode(string) { 10 | if (typeof string !== 'string') { 11 | return string; 12 | } 13 | 14 | return window.btoa(string); 15 | } 16 | 17 | /** 18 | * Decode a Base-64 string to UTF-8 19 | * 20 | * @param {String} string 21 | * @return {String} 22 | */ 23 | function decode(string) { 24 | if (typeof string !== 'string') { 25 | return string; 26 | } 27 | 28 | return window.atob(string); 29 | } 30 | 31 | module.exports.encode = encode; 32 | module.exports.decode = decode; 33 | -------------------------------------------------------------------------------- /elasticsearch/config/elasticsearch.yml: -------------------------------------------------------------------------------- 1 | cluster.name: "docker-cluster" 2 | cluster.routing.allocation.disk.threshold_enabled: false 3 | 4 | network.host: 0.0.0.0 5 | 6 | # minimum_master_nodes need to be explicitly set when bound on a public IP 7 | # set to 1 to allow single node clusters 8 | # Details: https://github.com/elastic/elasticsearch/pull/17288 9 | discovery.zen.minimum_master_nodes: 1 10 | discovery.type: 'single-node' 11 | 12 | http.cors.enabled : true 13 | http.cors.allow-origin: "*" 14 | http.cors.allow-methods : OPTIONS, HEAD, GET, POST, PUT, DELETE 15 | http.cors.allow-headers : "X-Requested-With,X-Auth-Token,Content-Type, Content-Length, Authorization" 16 | -------------------------------------------------------------------------------- /app/components/meta-url/schema.yml: -------------------------------------------------------------------------------- 1 | _description: | 2 | Adds `` and `article:published_time` tags to the head. 3 | 4 | url: 5 | _label: Canonical URL 6 | _display: settings 7 | _has: 8 | - 9 | fn: text 10 | type: url 11 | - label 12 | - 13 | fn: description 14 | value: URL of the current page. For articles, this is generated from the canonical url. 15 | 16 | date: 17 | _label: Published Date 18 | _display: settings 19 | _subscribe: publishDate 20 | _has: 21 | - text 22 | - label 23 | - 24 | fn: description 25 | value: Published timestamp. For articles, this is set automatically. 26 | -------------------------------------------------------------------------------- /app/services/universal/styles.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const postcss = require('postcss'), 4 | nested = require('postcss-nested'), 5 | safe = require('postcss-safe-parser'), 6 | csso = require('postcss-csso'), 7 | simpleVars = require('postcss-simple-vars'); 8 | 9 | /** 10 | * render scoped css using postcss 11 | * @param {string} uri uri of component 12 | * @param {string} styles custom style 13 | * @returns {string} css scoped style 14 | */ 15 | function render(uri, styles) { 16 | return postcss([nested, simpleVars, csso]) 17 | .process(`[data-uri="${uri}"] { ${styles} }`, { parser: safe }) 18 | .then(result => result.css); 19 | } 20 | 21 | module.exports.render = render; 22 | -------------------------------------------------------------------------------- /app/components/meta-title/schema.yml: -------------------------------------------------------------------------------- 1 | _description: | 2 | Adds `` tags to the head. 3 | 4 | title: 5 | _label: Title 6 | _display: settings 7 | _subscribe: pageTitle 8 | _has: 9 | - text 10 | - label 11 | - required 12 | - 13 | fn: description 14 | value: Page title, used to populate all tags. For articles, this is generated from the primary headline. 15 | 16 | kilnTitle: 17 | _label: Kiln Title 18 | _display: settings 19 | _subscribe: pageListTitle 20 | _publish: kilnTitle 21 | _has: 22 | - text 23 | - label 24 | - required 25 | - 26 | fn: description 27 | value: Value is pulled from ogTitle but can be set manually or published too. 28 | -------------------------------------------------------------------------------- /app/services/server/buffer.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * Base-64 encode a string 5 | * 6 | * @param {String} string 7 | * @return {String} 8 | */ 9 | function encode(string) { 10 | if (typeof string !== 'string') { 11 | return string; 12 | } 13 | 14 | return Buffer.from(string, 'utf8').toString('base64'); 15 | } 16 | 17 | /** 18 | * Decode a Base-64 string to UTF-8 19 | * 20 | * @param {String} string 21 | * @return {String} 22 | */ 23 | function decode(string) { 24 | if (typeof string !== 'string') { 25 | return string; 26 | } 27 | 28 | return Buffer.from(string, 'base64').toString('utf8'); 29 | } 30 | 31 | module.exports.encode = encode; 32 | module.exports.decode = decode; 33 | -------------------------------------------------------------------------------- /app/components/meta-authors/model.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _get = require('lodash/get'); 4 | 5 | module.exports.save = (ref, data) => { 6 | data.authors = data.authors || []; 7 | // Normalize "authors" value; if saved from a Kiln form, it will be of the form 8 | // [{text: string}]. 9 | data.authors = data.authors.map(author => 10 | typeof author === 'string' ? author : _get(author, 'text', '') 11 | ); 12 | 13 | return data; 14 | }; 15 | 16 | module.exports.render = (ref, data) => { 17 | // Transforms "authors" value into form [{text: string}] so it can be edited in 18 | // simple-list Kiln field. 19 | data.authors = data.authors.map(author => ({ text: author })); 20 | return data; 21 | }; 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Is your feature request related to a problem? Please describe. 11 | 12 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 13 | 14 | ## Describe the solution you'd like 15 | 16 | A clear and concise description of what you want to happen. 17 | 18 | ## Describe alternatives you've considered 19 | 20 | A clear and concise description of any alternative solutions or features you've considered. 21 | 22 | ## Additional context 23 | 24 | Add any other context or screenshots about the feature request here. 25 | -------------------------------------------------------------------------------- /app/components/footer/schema.yaml: -------------------------------------------------------------------------------- 1 | _description: | 2 | footer 3 | 4 | footerLinks: 5 | _label: Footer Links 6 | _has: 7 | input: complex-list 8 | props: 9 | - 10 | prop: url 11 | _label: Url 12 | _has: 13 | input: text 14 | type: url 15 | validate: 16 | required: true 17 | - 18 | prop: text 19 | _label: Link Text 20 | _has: 21 | input: text 22 | enforceMaxlength: true 23 | validate: 24 | required: true 25 | max: 40 26 | maxMessage: This field must be 40 or fewer characters long 27 | 28 | _groups: 29 | settings: 30 | fields: 31 | - footerLinks 32 | -------------------------------------------------------------------------------- /app/components/subheader/schema.yml: -------------------------------------------------------------------------------- 1 | _description: | 2 | A simple subheader component. 3 | 4 | type: 5 | _label: Type 6 | _has: 7 | input: radio 8 | options: 9 | - name: 'small (h4)' 10 | value: 'h4' 11 | - name: 'medium (h3)' 12 | value: 'h3' 13 | - name: 'large (h2)' 14 | value: 'h2' 15 | text: 16 | _label: Subheader Text 17 | _has: 18 | input: text 19 | subheaderId: 20 | _label: Hash (Use subheader text when is empty) 21 | _has: 22 | input: text 23 | 24 | _groups: 25 | subheader: 26 | fields: 27 | - text 28 | - type 29 | - subheaderId 30 | _placeholder: 31 | text: Subheader Text 32 | height: 20px 33 | ifEmpty: text 34 | -------------------------------------------------------------------------------- /app/components/paragraph/schema.yml: -------------------------------------------------------------------------------- 1 | _description: | 2 | A simple paragraph component. 3 | 4 | text: 5 | _placeholder: 6 | height: 50px 7 | text: New Paragraph 8 | required: true 9 | _has: 10 | input: inline 11 | type: multi-component 12 | pseudoBullet: true 13 | buttons: 14 | - bold 15 | - italic 16 | - strike 17 | - link 18 | paste: 19 | - 20 | match: <h[1-9]>(.*?)</h[1-9]> 21 | component: subheader 22 | field: text 23 | - 24 | match: '[~\-_]{1,10}\s?([\w\s]+?)\s?[~\-_]{1,10}' 25 | component: divider 26 | field: title 27 | - 28 | match: (.*) 29 | component: paragraph 30 | field: text 31 | -------------------------------------------------------------------------------- /app/services/universal/callout.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _includes = require('lodash/includes'); 4 | 5 | function isVideo(contentData) { 6 | return ( 7 | contentData.featureTypes && 8 | (contentData.featureTypes['Video-Original'] || 9 | contentData.featureTypes['Video-Aggregation'] || 10 | contentData.featureTypes['Video-Original News']) 11 | ); 12 | } 13 | 14 | function isGallery(contentData) { 15 | return contentData.tags && _includes(contentData.tags, 'gallery'); 16 | } 17 | 18 | function getCalloutType(contentData) { 19 | if (isVideo(contentData)) { 20 | return 'video'; 21 | } 22 | 23 | if (isGallery(contentData)) { 24 | return 'gallery'; 25 | } 26 | 27 | return ''; 28 | } 29 | 30 | module.exports = getCalloutType; 31 | -------------------------------------------------------------------------------- /app/.env.sample: -------------------------------------------------------------------------------- 1 | IP_ADDRESS=0.0.0.0 2 | PORT=3001 3 | 4 | CLAY_PROVIDER=local 5 | CLAY_ACCESS_KEY=accesskey 6 | ENABLE_GZIP=true 7 | 8 | INLINE_EDIT_STYLES=true 9 | INLINE_EDIT_SCRIPTS=true 10 | STATIC_ASSET_MAX_AGE=0 11 | LOG=info 12 | CLAY_LOG_PRETTY=true 13 | 14 | REDIS_SESSION_HOST=redis://redis:6379 15 | CLAY_BUS_HOST=redis://redis:6379 16 | 17 | ELASTIC_HOST=http://elasticsearch:9200 18 | ELASTIC_PREFIX=local 19 | 20 | CLAY_STORAGE_POSTGRES_HOST=postgres 21 | CLAY_STORAGE_POSTGRES_CACHE_ENABLED=true 22 | CLAY_STORAGE_POSTGRES_CACHE_HOST=redis://redis:6379 23 | 24 | CLAY_SCHEDULING_ENABLED=true 25 | 26 | CLAY_SITE_NAME="Clay Demo" 27 | CLAY_SITE_HOST="localhost" 28 | CLAY_SITE_SHORTKEY="cd" 29 | CLAY_SITE_PORT=80 30 | CLAY_SITE_PROTOCOL="http" 31 | CLAY_SITE_PATH="''" 32 | -------------------------------------------------------------------------------- /app/components/image/schema.yml: -------------------------------------------------------------------------------- 1 | description: | 2 | In Article Single Image 3 | 4 | imageUrl: 5 | _label: URL 6 | _has: 7 | input: text 8 | type: url 9 | validate: 10 | required: true 11 | help: Must be an image URL 12 | clickUrl: 13 | _label: Click URL 14 | _has: 15 | input: text 16 | type: url 17 | help: If the image should be hyperlinked, add that URL here 18 | imageAlt: 19 | _label: Alt Text 20 | _has: 21 | input: text 22 | help: Alternative text for screen readers 23 | 24 | _groups: 25 | settings: 26 | fields: 27 | - imageUrl (Image) 28 | - imageAlt (Image) 29 | - clickUrl (Click Through URL) 30 | _placeholder: 31 | text: New Image 32 | height: 250px 33 | ifEmpty: imageUrl 34 | -------------------------------------------------------------------------------- /app/components/article/media/icon-sponsored-info.svg: -------------------------------------------------------------------------------- 1 | <svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><g fill="#CCC" fill-rule="evenodd"><path d="M7.852 13.819a5.974 5.974 0 0 1-5.968-5.967 5.974 5.974 0 0 1 5.968-5.968 5.974 5.974 0 0 1 5.967 5.968 5.974 5.974 0 0 1-5.967 5.967M7.852 0C3.522 0 0 3.522 0 7.852c0 4.329 3.523 7.851 7.852 7.851 4.329 0 7.851-3.522 7.851-7.851C15.703 3.522 12.181 0 7.852 0"/><path d="M7.696 10.144a.853.853 0 0 0-.85.85c0 .466.384.849.85.849.467 0 .85-.383.85-.85a.853.853 0 0 0-.85-.85M7.748 4.176c-1.264 0-2.425.901-2.425 1.917 0 .414.311.632.674.632 1.005 0 .487-1.368 1.813-1.368.622 0 .995.331.995.891 0 1.015-1.814 1.295-1.814 2.528 0 .332.218.694.664.694.683 0 .601-.507.85-.87.33-.487 1.874-1.005 1.874-2.352 0-1.461-1.305-2.072-2.631-2.072"/></g></svg> 2 | -------------------------------------------------------------------------------- /app/styleguides/_default/components/tags.css: -------------------------------------------------------------------------------- 1 | .tags { 2 | .title { 3 | display: inline; 4 | font: 12px / 16px Courier; 5 | letter-spacing: 3px; 6 | text-transform: uppercase; 7 | } 8 | 9 | .tags-list { 10 | display: inline; 11 | font: 12px / 16px Courier; 12 | letter-spacing: 3px; 13 | padding: 0; 14 | text-transform: uppercase; 15 | 16 | .tags-list-item { 17 | display: inline; 18 | } 19 | 20 | .tags-list-item.hidden, 21 | .tags-list-item.invisible { 22 | display: none; 23 | } 24 | 25 | .tags-list-item a { 26 | color: #000; 27 | text-decoration: none; 28 | } 29 | 30 | .tags-list-item a:active, 31 | .tags-list-item a:hover, 32 | .tags-list-item a:focus { 33 | box-shadow: 0 1px 0 #0c71fa; 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /app/layouts/layout-simple/schema.yml: -------------------------------------------------------------------------------- 1 | _description: | 2 | A simple layout. 3 | 4 | head: 5 | _componentList: 6 | page: true 7 | include: 8 | - meta-site 9 | - meta-title 10 | - meta-url 11 | - meta-description 12 | - meta-keywords 13 | - meta-authors 14 | - meta-image 15 | 16 | headLayout: 17 | _componentList: 18 | include: 19 | - meta-site 20 | - meta-icons 21 | top: 22 | _componentList: 23 | include: 24 | - header 25 | 26 | main: 27 | _placeholder: 28 | text: Main Section 29 | height: 1000px 30 | _componentList: 31 | page: true 32 | fuzzy: true 33 | include: [] 34 | 35 | bottom: 36 | _componentList: 37 | include: 38 | - footer 39 | 40 | kilnInternals: 41 | _componentList: 42 | internals: true 43 | include: 44 | - clay-kiln 45 | -------------------------------------------------------------------------------- /app/components/list/model.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const striptags = require('striptags'), 4 | { has, isFieldEmpty } = require('../../services/universal/utils'), 5 | { render } = require('../../services/universal/styles'), 6 | { toSmartText } = require('../../services/universal/sanitize'); 7 | 8 | module.exports.save = function(uri, data) { 9 | const allowedTags = ['strong', 'em', 's', 'a', 'span']; 10 | 11 | data.listType = data.orderedList ? 'ol' : 'ul'; 12 | 13 | if (has(data.items)) { 14 | data.items.forEach(item => { 15 | item.text = toSmartText(striptags(item.text, allowedTags)); 16 | }); 17 | } 18 | 19 | if (isFieldEmpty(data.sass)) { 20 | delete data.css; 21 | 22 | return data; 23 | } else { 24 | return render(uri, data.sass).then(css => { 25 | data.css = css; 26 | 27 | return data; 28 | }); 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /app/services/universal/article-timestamp.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const moment = require('moment'); 4 | 5 | function getPrettyMonthAbrev(month) { 6 | switch (month) { 7 | case 'May': 8 | return month; 9 | break; 10 | case 'Jun': 11 | return 'June'; 12 | break; 13 | case 'Jul': 14 | return 'July'; 15 | break; 16 | case 'Sep': 17 | return 'Sept.'; 18 | break; 19 | default: 20 | return month + '.'; 21 | break; 22 | } 23 | } 24 | 25 | module.exports = date => { 26 | const mDate = moment(date), 27 | now = moment(); 28 | 29 | if (!mDate.isValid(date)) { 30 | return ''; 31 | } 32 | 33 | if (moment.duration(now.diff(mDate)).asDays() < 1) { 34 | return `${mDate.format('h:mm')} ${mDate.format('A')}`; 35 | } else { 36 | return `${getPrettyMonthAbrev(mDate.format('MMM'))} ${mDate.format('D, YYYY')}`; 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /app/services/server/publish-url.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const pubUtils = require('./publish-utils'); 4 | 5 | /** 6 | * Return the url for a page based on its main component's slug. 7 | * 8 | * @param {Object} pageData 9 | * @param {Object} locals 10 | * @param {Object} mainComponentRefs 11 | * @returns {Promise} 12 | */ 13 | function getSlugUrl(pageData, locals, mainComponentRefs) { 14 | const componentReference = pubUtils.getComponentReference(pageData, mainComponentRefs); 15 | 16 | if (!componentReference) { 17 | return Promise.reject(new Error('Could not find a main component on the page')); 18 | } 19 | 20 | return pubUtils.getMainComponentFromRef(componentReference, locals).then(mainComponent => { 21 | const urlOptions = pubUtils.getUrlOptions(mainComponent, locals); 22 | 23 | return pubUtils.slugUrlPattern(urlOptions); 24 | }); 25 | } 26 | 27 | module.exports.getSlugUrl = getSlugUrl; 28 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | start: 2 | docker-compose up 3 | 4 | rebuild: 5 | ssh-add -l > /dev/null 2>&1 || { eval "$$(ssh--agent)" && ssh-add; } 6 | export DOCKER_BUILDKIT=1 \ 7 | && docker build --progress=plain --ssh=default -t starter-internal . \ 8 | && docker-compose up 9 | 10 | stop: 11 | docker-compose stop 12 | 13 | burn: 14 | @echo "Stopping and removing all containers..." 15 | docker-compose down 16 | 17 | clear-data: 18 | docker-compose down --volumes 19 | 20 | clear-public: 21 | docker-compose exec clay rm -rf public/* 22 | docker-compose exec clay rm -f browserify-cache.json 23 | 24 | build: 25 | docker-compose exec clay npm run build 26 | 27 | bootstrap: 28 | cat ./bootstrap-starter-data/* | clay import -k starter -y localhost 29 | 30 | bootstrap-user: 31 | cat sample_users.yml | clay import -k starter -y localhost 32 | 33 | add-access-key: 34 | clay config --key starter accesskey 35 | 36 | default: start 37 | -------------------------------------------------------------------------------- /app/styleguides/_default/components/subheader.css: -------------------------------------------------------------------------------- 1 | $black: #000; 2 | $helvetica-stack: Helvetica, sans-serif; 3 | 4 | .subheader { 5 | color: $black; 6 | margin: 0 0 1em; 7 | 8 | h4& { 9 | font: bold 22px $helvetica-stack; 10 | line-height: 1.09; 11 | } 12 | 13 | h3& { 14 | font: bold 28px $helvetica-stack; 15 | line-height: 1.09; 16 | 17 | & > .link { 18 | padding: 3px 0 0 5px; 19 | } 20 | } 21 | 22 | h2& { 23 | font: bold 38px $helvetica-stack; 24 | line-height: 1.05; 25 | 26 | & > .link { 27 | padding: 0 0 0 5px; 28 | } 29 | } 30 | 31 | &:hover { 32 | & .link { 33 | opacity: 1; 34 | } 35 | } 36 | 37 | .link { 38 | opacity: 0; 39 | padding: 2px 0 0 5px; 40 | position: absolute; 41 | -moz-transition: opacity 150ms ease; 42 | -webkit-transition: opacity 150ms ease; 43 | transition: opacity 150ms ease; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /app/services/universal/helpers.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const formatTime = require('./format-time'), 4 | truncate = require('./truncate'); 5 | 6 | /** 7 | * Given a number or a string of a number, increment 8 | * the value and return it. 9 | * 10 | * @param {String|Number} index 11 | * @param {Number} inc 12 | * @returns {Number} 13 | */ 14 | function incrementIndex(index, inc = 1) { 15 | if (typeof index !== 'number') index = parseInt(index, 10); 16 | 17 | return index + inc; 18 | } 19 | 20 | module.exports = { 21 | incIndex: incrementIndex, 22 | byline: require('./byline'), 23 | secondsToISO: formatTime.secondsToISO, 24 | formatDateRange: formatTime.formatDateRange, 25 | isPublished24HrsAgo: formatTime.isPublished24HrsAgo, 26 | hrsOnlyTimestamp: formatTime.hrsOnlyTimestamp, 27 | articleTimestamp: require('./article-timestamp'), 28 | truncateText: truncate, 29 | calloutType: require('./callout') 30 | }; 31 | -------------------------------------------------------------------------------- /app/components/pull-quote/schema.yml: -------------------------------------------------------------------------------- 1 | _description: | 2 | Pull quote component for displaying text (often from the related article) and an optional attribution. 3 | 4 | quote: 5 | _label: Quote Text 6 | _has: 7 | input: wysiwyg 8 | styled: true 9 | buttons: 10 | - bold 11 | - italic 12 | - strike 13 | - link 14 | validate: 15 | required: true 16 | 17 | hasQuoteMarks: 18 | _label: Add Quotation Marks 19 | _has: 20 | input: checkbox 21 | 22 | attribution: 23 | _label: Attribution 24 | _has: 25 | input: wysiwyg 26 | buttons: 27 | - bold 28 | - italic 29 | - strike 30 | - link 31 | 32 | _groups: 33 | inlineGroup: 34 | fields: 35 | - quote 36 | - hasQuoteMarks 37 | - attribution 38 | _label: Pull Quote 39 | _placeholder: 40 | text: New Pull Quote 41 | height: 100px 42 | ifEmpty: quote AND attribution 43 | -------------------------------------------------------------------------------- /app/components/image/model.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _get = require('lodash/get'), 4 | defaultWidth = 'inline'; 5 | 6 | module.exports.render = function(uri, data) { 7 | return data; 8 | }; 9 | 10 | module.exports.save = function(uri, data) { 11 | const imageAspectRatio = _get(data, 'imageAspectRatio', null), 12 | imageAspectRatioFlexOverride = _get(data, 'imageAspectRatioFlexOverride', false), 13 | imageCaption = _get(data, 'imageCaption', null), 14 | imageCreditOverride = _get(data, 'imageCreditOverride', null), 15 | imageUrl = _get(data, 'imageUrl', null), 16 | imageWidth = _get(data, 'imageWidth', null) || defaultWidth, 17 | image = { 18 | imageAspectRatio, 19 | imageAspectRatioFlexOverride, 20 | imageCaption, 21 | imageCredit: imageCreditOverride, 22 | imageType: 'Photo', 23 | imageUrl, 24 | imageWidth 25 | }; 26 | 27 | return Object.assign(data, image); 28 | }; 29 | -------------------------------------------------------------------------------- /app/components/tags/schema.yaml: -------------------------------------------------------------------------------- 1 | _version: 1.0 2 | 3 | _description: | 4 | A component that handles tagging. 5 | 6 | * Tags function as a simple-list that displays on the article. 7 | * This component also informs the clay-meta-keywords. 8 | * A user can add and remove tags. 9 | * Suggested tags will populate based on alphabetical order and popularity. 10 | * Users can create new tags when populating this component. 11 | * A user can select a Feature Rubric (which displays at the top of the article) by double-clicking the intended tag. Changes will be visible if you refresh the page. 12 | 13 | items: 14 | _publish: keywords 15 | _placeholder: 16 | text: Tags 17 | height: 40px 18 | _has: 19 | input: simple-list 20 | autocomplete: 21 | list: tags 22 | allowRemove: true 23 | allowCreate: true 24 | 25 | normalizedTags: 26 | _publish: normalizedTags 27 | _has: 28 | help: List of tags with no alphanumeric characters 29 | -------------------------------------------------------------------------------- /app/services/server/db.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const db = require('amphora-storage-postgres'); 4 | 5 | /** 6 | * Replace or create a new value in the db. 7 | * 8 | * @param {string} ref 9 | * @param {function} fn 10 | * @returns {Promise} 11 | */ 12 | function update(ref, fn) { 13 | return db 14 | .get(ref) 15 | .catch(function() { 16 | // doesn't exist yet 17 | return null; 18 | }) 19 | .then(function(value) { 20 | return fn(value); 21 | }) 22 | .then(function(result) { 23 | if (result) { 24 | // must be object or array 25 | if (!(typeof result === 'object')) { 26 | throw new Error('Must be object'); 27 | } 28 | 29 | return db.put(ref, JSON.stringify(result)); 30 | } 31 | }); 32 | } 33 | 34 | module.exports.get = db.get; 35 | module.exports.put = db.put; 36 | module.exports.del = db.del; 37 | module.exports.getMeta = db.getMeta; 38 | module.exports.update = update; 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## Description 11 | 12 | A clear and concise description of what the bug is. 13 | 14 | ## Steps to Reproduce 15 | 16 | Steps to reproduce the behavior: 17 | 1. Go to '...' 18 | 2. Click on '....' 19 | 3. Scroll down to '....' 20 | 4. See error 21 | 22 | ## Expected Behavior 23 | 24 | A clear and concise description of what you expected to happen. 25 | 26 | ## Screenshots 27 | 28 | If applicable, add screenshots to help explain your problem. 29 | 30 | ## Specs 31 | 32 | ### Desktop 33 | 34 | - OS: [e.g. iOS] 35 | - Browser [e.g. chrome, safari] 36 | - Version [e.g. 22] 37 | 38 | ### Smartphone 39 | 40 | - Device: [e.g. iPhone6] 41 | - OS: [e.g. iOS8.1] 42 | - Browser [e.g. stock browser, safari] 43 | - Version [e.g. 22] 44 | 45 | ## Additional Context 46 | 47 | Add any other context about the problem here. 48 | -------------------------------------------------------------------------------- /app/styleguides/_default/components/pull-quote.css: -------------------------------------------------------------------------------- 1 | .pull-quote { 2 | margin: 0 0 20px; 3 | padding: 0 0 0; 4 | z-index: 1; 5 | 6 | & &-text { 7 | font: bold 28px/32px Georgia, serif; 8 | } 9 | 10 | /* Quotation marks */ 11 | & .has-quote-marks:before { 12 | content: '\201C'; 13 | margin: 0 0 0 -.5em; 14 | } 15 | 16 | & .has-quote-marks:after { 17 | content: '\201D'; 18 | } 19 | /* /Quotation marks */ 20 | 21 | & &-text, 22 | & &-attribution { 23 | color: #000; 24 | text-transform: none; 25 | } 26 | 27 | & &-attribution { 28 | display: block; 29 | font: italic 14px/1.5 Georgia, serif; 30 | margin: 0; 31 | padding: 6px 0 0; 32 | } 33 | 34 | @media screen and (min-width:768px) { 35 | float: left; 36 | margin: 0 40px 10px 0; 37 | position: relative; 38 | width: 260px; 39 | } 40 | 41 | @media screen and (min-width:1180px) { 42 | margin: 0 20px 20px 0; 43 | } 44 | 45 | @media print { 46 | display: none; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/services/client/db.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | /** 4 | * This is the client-side implementation of the DB services (../server/db). 5 | * 6 | * Currently it only exports two functions, `get` and `put`. 7 | * 8 | * These maintain the same function signatures as the server-side db 9 | * service. If you need more of the same services that the db service 10 | * exports then add them to this file and export it. If you are converting 11 | * more functions then they should maintain the same function signature 12 | * as closely as possible OR you will have to test which environment you're 13 | * running in inside the `model.js` 14 | */ 15 | 16 | const rest = require('../universal/rest'), 17 | utils = require('../universal/utils'); 18 | 19 | function get(ref, locals) { 20 | return rest.get(utils.uriToUrl(ref, locals)); 21 | } 22 | 23 | function put(ref, data, locals) { 24 | // Pass true for the authentication flag 25 | return rest.put(utils.uriToUrl(ref, locals), data, true); 26 | } 27 | 28 | module.exports.get = get; 29 | module.exports.put = put; 30 | -------------------------------------------------------------------------------- /app/services/kiln/plugins/word-count.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var wordCount = require('../../universal/word-count'); 4 | 5 | /** 6 | * update word count element after vue's current tick 7 | * @param {number} count 8 | */ 9 | function updateWordCount(count) { 10 | window.setTimeout(() => { 11 | var wordCountEl = document.querySelector('.word-count'); 12 | 13 | if (wordCountEl) { 14 | wordCountEl.innerHTML = 'Words: ' + count; 15 | } 16 | }, 0); 17 | } 18 | 19 | module.exports = () => { 20 | window.kiln.plugins['word-count'] = function(store) { 21 | // update word count whenever a paragraph, blockquote, article, etc is re-rendered 22 | store.subscribe(function(mutation, state) { 23 | var uri = mutation.payload && mutation.payload.uri; 24 | 25 | if ( 26 | mutation.type === 'PRELOAD_SUCCESS' || 27 | (mutation.type === 'RENDER_COMPONENT' && wordCount.isComponentWithWords(uri)) 28 | ) { 29 | updateWordCount(wordCount.count(state.components)); 30 | } 31 | }); 32 | }; 33 | }; 34 | -------------------------------------------------------------------------------- /app/services/startup/amphora-core.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const amphora = require('amphora'), 4 | renderers = require('./amphora-renderers'), 5 | healthCheck = require('@nymdev/health-check'), 6 | searchExists = () => 7 | require('amphora-search') 8 | .getInstance() 9 | .ping(), 10 | PROVIDERS = ['apikey', process.env.CLAY_PROVIDER]; 11 | 12 | function initAmphora(app, search, sessionStore) { 13 | return amphora({ 14 | app, 15 | renderers, 16 | providers: PROVIDERS, 17 | sessionStore, 18 | plugins: [search, require('amphora-schedule'), require('amphora-serve-static')], 19 | storage: require('amphora-storage-postgres'), 20 | eventBus: require('amphora-event-bus-redis') 21 | }).then(router => { 22 | router.use( 23 | healthCheck({ 24 | env: ['ELASTIC_HOST', 'MASTERMIND'], 25 | stats: { 26 | searchExists 27 | }, 28 | required: ['searchExists', 'ELASTIC_HOST'] 29 | }) 30 | ); 31 | 32 | return router; 33 | }); 34 | } 35 | 36 | module.exports = initAmphora; 37 | -------------------------------------------------------------------------------- /app/services/universal/byline.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _get = require('lodash/get'), 4 | _join = require('lodash/join'), 5 | _map = require('lodash/map'), 6 | _isObject = require('lodash/isObject'); 7 | 8 | /** 9 | * Comma separate a list of author strings 10 | * or simple-list objects 11 | * 12 | * @param {String[]} opts 13 | * @return {String} 14 | */ 15 | function formatSimpleByline(opts = {}) { 16 | const bylines = _get(opts.hash, 'bylines', []), 17 | authors = _map(bylines, author => (_isObject(author) ? author.text : author)); 18 | 19 | if (authors.length === 1) { 20 | return `<span>${authors[0]}</span>`; 21 | } else if (authors.length === 2) { 22 | return `<span>${authors[0]}</span><span class="and"> and </span><span>${authors[1]}</span>`; 23 | } else { 24 | return _join( 25 | _map(authors, (author, idx) => 26 | idx < authors.length - 1 27 | ? `<span>${author}, </span>` 28 | : `<span class="and">and </span><span>${author}</span>` 29 | ), 30 | '' 31 | ); 32 | } 33 | } 34 | 35 | module.exports = formatSimpleByline; 36 | -------------------------------------------------------------------------------- /bootstrap-starter-data/_pages.yml: -------------------------------------------------------------------------------- 1 | _pages: 2 | sample-article: 3 | layout: /_layouts/layout/instances/article 4 | main: 5 | - /_components/article/instances/sample 6 | head: [] 7 | new-standard: 8 | layout: /_layouts/layout/instances/article 9 | main: 10 | - /_components/article/instances/new-standard 11 | head: 12 | - /_components/meta-title/instances/new 13 | - /_components/meta-url/instances/new 14 | - /_components/meta-description/instances/new 15 | - /_components/meta-image/instances/new 16 | - /_components/meta-keywords/instances/new 17 | - /_components/meta-authors/instances/new 18 | homepage: 19 | url: / 20 | layout: /_layouts/layout-simple/instances/homepage 21 | head: 22 | - /_components/meta-title/instances/homepage 23 | - /_components/meta-url/instances/homepage 24 | - /_components/meta-description/instances/homepage 25 | - /_components/meta-image/instances/homepage 26 | - /_components/meta-keywords/instances/homepage 27 | - /_components/meta-authors/instances/homepage 28 | main: [] 29 | -------------------------------------------------------------------------------- /app/styleguides/_default/components/footer.css: -------------------------------------------------------------------------------- 1 | .footer { 2 | .logo-wrapper { 3 | margin: 0 auto 15px auto; 4 | width: 112px; 5 | } 6 | 7 | .follow-icons { 8 | margin: 0 0 7px; 9 | text-align: center; 10 | } 11 | 12 | .links { 13 | display: flex; 14 | flex-wrap: wrap; 15 | font: bold 14px / 39px GroteskNarrow, sans-serif; 16 | justify-content: center; 17 | letter-spacing: 4px; 18 | text-transform: uppercase; 19 | 20 | a { 21 | margin: 0 14px; 22 | text-decoration: none; 23 | } 24 | 25 | a:visited { 26 | color: black; 27 | } 28 | } 29 | 30 | .copyright { 31 | font: 500 9px / 25px Grotesk, sans-serif; 32 | letter-spacing: 3px; 33 | margin: 0 0 21px; 34 | text-align: center; 35 | text-transform: uppercase; 36 | } 37 | } 38 | 39 | @media screen and (min-width: 1180px) { 40 | .footer { 41 | .logo-wrapper { 42 | margin: 0 auto 22px auto; 43 | } 44 | 45 | .follow-icons { 46 | margin: 0 0 40px; 47 | } 48 | 49 | .links { 50 | font: 19px GroteskNarrow, sans-serif; 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Clay 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /app/layouts/layout/schema.yml: -------------------------------------------------------------------------------- 1 | _description: | 2 | A two column layout with special styling for our articles. Used for: 3 | 4 | head: 5 | _componentList: 6 | page: true 7 | include: 8 | - meta-site 9 | - meta-title 10 | - meta-url 11 | - meta-description 12 | - meta-keywords 13 | - meta-authors 14 | - meta-image 15 | 16 | headLayout: 17 | _componentList: 18 | include: 19 | - meta-site 20 | - meta-icons 21 | top: 22 | _componentList: 23 | include: [] 24 | pageHeader: 25 | _componentList: 26 | include: [] 27 | secondaryHeader: 28 | _componentList: 29 | include: [] 30 | 31 | main: 32 | _componentList: 33 | page: true 34 | include: 35 | - article 36 | primary: 37 | _componentList: 38 | include: [] 39 | secondary: 40 | _componentList: 41 | include: [] 42 | bottom: 43 | _componentList: 44 | include: 45 | - footer 46 | foot: 47 | _componentList: 48 | invisible: true 49 | include: 50 | - paragraph 51 | 52 | kilnInternals: 53 | _componentList: 54 | internals: true 55 | include: 56 | - clay-kiln 57 | -------------------------------------------------------------------------------- /bootstrap-starter-data/_layouts.yml: -------------------------------------------------------------------------------- 1 | _layouts: 2 | layout: 3 | instances: 4 | article: 5 | head: head 6 | headLayout: 7 | - 8 | _ref: /_components/meta-site/instances/article 9 | - 10 | _ref: /_components/meta-icons/instances/claydemo 11 | top: 12 | - 13 | _ref: /_components/header/instances/claydemo 14 | main: main 15 | bottom: 16 | - 17 | _ref: /_components/footer/instances/claydemo 18 | kilnInternals: 19 | - 20 | _ref: /_components/clay-kiln/instances/general 21 | layout-simple: 22 | instances: 23 | homepage: 24 | head: head 25 | headLayout: 26 | - 27 | _ref: /_components/meta-site/instances/homepage 28 | - 29 | _ref: /_components/meta-icons/instances/claydemo 30 | top: 31 | - 32 | _ref: /_components/header/instances/claydemo 33 | main: main 34 | bottom: 35 | - 36 | _ref: /_components/footer/instances/claydemo 37 | kilnInternals: 38 | - 39 | _ref: /_components/clay-kiln/instances/general 40 | -------------------------------------------------------------------------------- /app/services/universal/truncate.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const truncate = require('html-truncate'); 4 | 5 | /** 6 | * truncateText 7 | * 8 | * Truncates text or text + HTML at a specified limit without breaking up inner HTML tags. If the text is truncated, return a button with the shortened and full text 9 | * (expansion behavior should be handled in client-side js and showing/hiding should be handled in css) 10 | * 11 | * @param {String} innerText the contents of the element to expand. Can be text or a mix of HTML + text 12 | * @param {Number} limit where in the string to start truncation 13 | * @returns {String} truncated HTML 14 | */ 15 | function truncateText(innerText, limit) { 16 | const truncated = truncate(innerText, limit); 17 | let fullText; 18 | 19 | if (truncated.length !== innerText.length) { 20 | fullText = ` 21 | <div class="attribution truncated"> 22 | <span class="shortened">${truncated} <button class="more-trigger">more</button></span> 23 | <span class="full">${innerText}</span> 24 | </div> 25 | `; 26 | } else { 27 | fullText = ` 28 | <div class="attribution"> 29 | <span class="full">${innerText}</span> 30 | </div> 31 | `; 32 | } 33 | 34 | return fullText; 35 | } 36 | 37 | module.exports = truncateText; 38 | -------------------------------------------------------------------------------- /app/components/header/schema.yaml: -------------------------------------------------------------------------------- 1 | _description: | 2 | This is the site header at the top of every page. It displays the site logo, navigational links, account button, and search button. 3 | _confirmRemoval: true 4 | 5 | siteLogo: 6 | _label: Site Logo 7 | _has: 8 | input: text 9 | help: Either the URL of the image or an SVG string. 10 | 11 | logoUrl: 12 | _label: Logo URL 13 | _has: 14 | input: text 15 | type: url 16 | help: URL the Logo links to. 17 | 18 | tagline: 19 | _label: Tagline 20 | _has: 21 | input: text 22 | type: text 23 | help: To display on the homepage with the site logo. 24 | 25 | enableSocialButtons: 26 | _label: Enable Social Buttons 27 | _has: checkbox 28 | 29 | shareServices: 30 | _has: 31 | input: checkbox-group 32 | options: 33 | - 34 | name: Facebook 35 | value: facebook 36 | - 37 | name: Twitter 38 | value: twitter 39 | _reveal: 40 | field: enableSocialButtons 41 | operator: === 42 | value: true 43 | 44 | 45 | _groups: 46 | settings: 47 | fields: 48 | - siteLogo 49 | - logoUrl 50 | - tagline 51 | - enableSocialButtons 52 | - shareServices 53 | _placeholder: 54 | text: Header 55 | height: 100px 56 | ifEmpty: siteLogo AND tagline 57 | -------------------------------------------------------------------------------- /app/components/image/template.hbs: -------------------------------------------------------------------------------- 1 | <div data-uri="{{ default _ref _self }}" class="image-cmpt" data-editable="settings"> 2 | {{#if imageUrl}} 3 | <div class="image-container {{ if imageBorderToggle 'bordered' }}"> 4 | <div class="img-figure"> 5 | {{#if clickUrl}} 6 | <a class="image-link" href="{{clickUrl}}"> 7 | {{/if}} 8 | <div class="image-wrapper"> 9 | <img src="{{ imageUrl }}" class="img-data" data-src="{{ imageUrl }}" alt="{{ imageAlt }}"/> 10 | </div> 11 | {{#if clickUrl}} 12 | </a> 13 | {{/if}} 14 | </div> 15 | <script type="application/ld+json"> 16 | { 17 | "@context": "http://schema.org", 18 | "@type": "ImageObject", 19 | {{#if ./imageCredit}}"author": "{{ toPlainText ./imageCredit }}",{{/if}} 20 | {{#if ./imageCaption}}"caption": "{{ toPlainText ./imageCaption }}",{{/if}} 21 | "contentUrl": "{{ ./imageUrl }}" 22 | } 23 | </script> 24 | </div> 25 | <div itemprop="caption" class="image-cmpt-figcaption attribution"> 26 | {{#if imageCaption}}{{{ imageCaption }}}{{/if}} 27 | {{#if imageCredit}}<span class="credit">{{ capitalizeAll (if imageType imageType else='Photo') }}: {{{ imageCredit }}}</span>{{/if}} 28 | </div> 29 | {{/if}} 30 | </div> 31 | -------------------------------------------------------------------------------- /app/layouts/layout/template.hbs: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html lang="en" data-uri="{{ default _ref _self }}" data-layout-uri="{{ default _ref _layoutRef }}"> 3 | <head> 4 | <meta charset="utf-8"> 5 | <meta http-equiv="X-UA-Compatible" content="IE=edge"> 6 | <meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no"> 7 | {{#unless (includes (default _ref _self) "@published") }} 8 | <meta name="robots" content="noindex"> 9 | {{/unless}} 10 | 11 | {{! head components }} 12 | <!-- data-editable="head" --> 13 | {{> component-list head }} 14 | <!-- data-editable-end --> 15 | <!-- data-editable="headLayout" --> 16 | {{> component-list headLayout }} 17 | <!-- data-editable-end --> 18 | </head> 19 | <body class="layout"> 20 | <section class="top" data-editable="top" data-track-zone="top">{{> component-list top }}</section> 21 | <section class="wrapper"> 22 | <section class="main" data-editable="main" data-track-zone="main">{{> component-list main }}</section> 23 | </section> 24 | <footer class="bottom" data-editable="bottom" data-track-zone="bottom">{{> component-list bottom }}</footer> 25 | {{! invisible foot component list }} 26 | <div class="kiln-internals" data-editable="kilnInternals">{{> component-list kilnInternals }}</div> 27 | </body> 28 | </html> 29 | -------------------------------------------------------------------------------- /app/services/server/resolve-media.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const getDependencies = require('claycli/lib/cmd/compile/get-script-dependencies').getDependencies; 4 | 5 | /** 6 | * Update the `media` object based on parameters included 7 | * in the request for a page/component 8 | * 9 | * @param {Object} media 10 | * @param {Object} locals 11 | */ 12 | // Allow a higher complexity than normal 13 | /* eslint complexity: ["error", 9] */ 14 | function resolveMedia(media, locals) { 15 | const assetPath = locals.site.assetHost || locals.site.assetPath, 16 | stylesSource = locals.site.styleguide || locals.site.slug; 17 | 18 | // We're dealing with a page, let's include the site CSS, 19 | // and the scripts as needed 20 | media.styles.unshift('/css/_inlined-fonts.' + stylesSource + '.css'); 21 | 22 | if (locals.edit) { 23 | // edit mode - whole page 24 | // note: turning minify: false will link all model.js and dep files individually, 25 | // which we don't currently want to do (but is useful for debugging stuff on local dev envs) 26 | media.scripts = getDependencies(media.scripts, assetPath, { edit: true, minify: true }); 27 | media.styles.unshift('/css/_kiln-plugins.css'); 28 | } else { 29 | // view mode - whole page 30 | media.scripts = getDependencies(media.scripts, assetPath); 31 | } 32 | } 33 | 34 | module.exports = resolveMedia; 35 | -------------------------------------------------------------------------------- /app/styleguides/_default/components/header.css: -------------------------------------------------------------------------------- 1 | @import '_default/common/_vars.css'; 2 | 3 | header.header { 4 | margin: 0 auto; 5 | max-width: 100%; 6 | 7 | .header-inner { 8 | text-align: center; 9 | } 10 | 11 | .header__logo-link:focus { 12 | outline: none; 13 | } 14 | 15 | @media screen and (min-width: $lg) { 16 | margin: 27px auto 20px; 17 | max-width: $wrapperMaxWidth; 18 | } 19 | } 20 | 21 | 22 | .header__tagline { 23 | font-family: $sans-serif-stack; 24 | margin: 15px 0; 25 | } 26 | 27 | .header__logo-link, 28 | .header__title-logo{ 29 | display: inline-block; 30 | 31 | svg, .header__logo { 32 | max-width: 100%; 33 | width: max-content; 34 | } 35 | } 36 | 37 | .header__social-icons { 38 | align-items: center; 39 | display: flex; 40 | justify-content: center; 41 | 42 | svg { 43 | height: auto; 44 | width: 26px; 45 | } 46 | 47 | .share-link { 48 | margin-right: 20px; 49 | position: relative; 50 | 51 | span:last-child { 52 | border: none; 53 | clip: rect(0 0 0 0); 54 | height: 1px; 55 | margin: -1px; 56 | overflow: hidden; 57 | padding: 0; 58 | position: absolute; 59 | width: 1px; 60 | } 61 | } 62 | 63 | .share-link:last-child { 64 | margin-right: 0; 65 | } 66 | 67 | .share-link:focus { 68 | outline: none; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /app/services/client/popup.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | class service { 4 | getWindowSize() { 5 | const dualScreenLeft = window.screenLeft !== undefined ? window.screenLeft : screen.left, 6 | dualScreenTop = window.screenTop !== undefined ? window.screenTop : screen.top, 7 | width = window.innerWidth || document.documentElement.clientWidth || screen.width, 8 | height = window.innerHeight || document.documentElement.clientHeight || screen.height; 9 | 10 | return { dualScreenLeft, dualScreenTop, height, width }; 11 | } 12 | 13 | getCenterPosition(windowSize, dimensions) { 14 | const left = windowSize.width / 2 - dimensions.w / 2 + windowSize.dualScreenLeft, 15 | top = windowSize.height / 2 - dimensions.h / 2 + windowSize.dualScreenTop; 16 | 17 | return { left, top }; 18 | } 19 | 20 | /** 21 | * openPopup Window 22 | * @param {string} url - address of the popup page 23 | * @param {object} dimensions { w: width of popup, h: height of popup} 24 | */ 25 | openPopUp(url, dimensions) { 26 | const popupPosition = this.getCenterPosition(this.getWindowSize(), dimensions), 27 | params = `scrollbars=no,resizable=no,status=no,location=no,toolbar=no,menubar=no, 28 | width=${dimensions.w},height=${dimensions.h},left=${popupPosition.left},top=${popupPosition.top}`; 29 | 30 | window.open(url, 'popup', params); 31 | } 32 | } 33 | 34 | module.exports = new service(); 35 | -------------------------------------------------------------------------------- /app/layouts/layout-simple/template.hbs: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html lang="en" data-uri="{{ default _ref _self }}" data-layout-uri="{{ default _ref _layoutRef }}"> 3 | <head> 4 | <meta charset="utf-8"> 5 | <meta http-equiv="X-UA-Compatible" content="IE=edge"> 6 | <meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no"> 7 | {{#unless (includes (default _ref _self) "@published") }} 8 | <meta name="robots" content="noindex"> 9 | {{/unless}} 10 | 11 | {{! head components }} 12 | <!-- data-editable="head" --> 13 | {{> component-list head }} 14 | <!-- data-editable-end --> 15 | <!-- data-editable="headLayout" --> 16 | {{> component-list headLayout }} 17 | <!-- data-editable-end --> 18 | </head> 19 | <body class="layout-simple"> 20 | <div class="top" data-track-zone="top"> 21 | <div data-editable="top">{{> component-list top}}</div> 22 | </div> 23 | <main class="main" data-editable="main" data-track-zone="main"> 24 | {{> component-list main}} 25 | </main> 26 | <footer class="footer" data-editable="bottom" data-track-zone="bottom"> 27 | {{> component-list bottom}} 28 | </footer> 29 | <div class="foot" data-editable="foot" data-track-zone="foot">{{> component-list foot }}</div> 30 | <div class="kiln-internals" data-editable="kilnInternals">{{> component-list kilnInternals }}</div> 31 | </body> 32 | </html> 33 | -------------------------------------------------------------------------------- /app/components/list/schema.yml: -------------------------------------------------------------------------------- 1 | _description: | 2 | A simple and semantic list of text items which can take custom styling. 3 | 4 | items: 5 | _label: List Items 6 | _has: 7 | input: complex-list 8 | props: 9 | - prop: text 10 | _label: Text 11 | _has: 12 | input: wysiwyg 13 | styled: true 14 | buttons: 15 | - bold 16 | - italic 17 | - strike 18 | - link 19 | validate: 20 | required: true 21 | 22 | orderedList: 23 | _label: Ordered List 24 | _has: 25 | input: checkbox 26 | help: Select when the list items have a strict order. E.g. ranked items, or steps in a process 27 | 28 | customIndicator: 29 | _label: Use Custom Indicator 30 | _has: 31 | input: checkbox 32 | help: Use a custom list item indicator instead of the browser's default. This can be targeted in per-instance styles using '&.custom-indicator ul .text-list-item:before' 33 | 34 | sass: 35 | _label: Custom Styles 36 | _has: 37 | input: codemirror 38 | mode: text/x-scss 39 | help: Custom styles for this specific component, can be written in CSS/SASS. 40 | 41 | _groups: 42 | settings: 43 | fields: 44 | - items (List Items) 45 | - orderedList (Settings) 46 | - customIndicator (Settings) 47 | - sass (Settings) 48 | _placeholder: 49 | text: New List 50 | height: 30px 51 | ifEmpty: items 52 | -------------------------------------------------------------------------------- /app/styleguides/_default/components/list.css: -------------------------------------------------------------------------------- 1 | @import '_default/common/_vars.css'; 2 | 3 | $helvetica-full-stack: 16px / 1 Helvetica, sans-serif; 4 | $custom-indicator-red: #ec2c00; 5 | 6 | .list { 7 | clear: both; 8 | font: $helvetica-full-stack; 9 | margin: 15px 0; 10 | position: relative; 11 | 12 | & .list-items { 13 | counter-reset: listitem; 14 | margin: 0; 15 | } 16 | 17 | & .list-item { 18 | counter-increment: listitem; 19 | padding: 0 0 5px; 20 | } 21 | 22 | &.custom-indicator { 23 | clear: none; 24 | } 25 | 26 | &.custom-indicator .list-items { 27 | padding: 0; 28 | } 29 | 30 | &.custom-indicator .list-item { 31 | align-items: baseline; 32 | display: flex; 33 | list-style-type: none; 34 | position: relative; 35 | } 36 | 37 | &.custom-indicator .list-item:before { 38 | flex: 0 0 auto; 39 | margin: 0 12px 0 22px; 40 | } 41 | 42 | &.custom-indicator ul .list-item:before { 43 | background-color: $custom-indicator-red; 44 | border-radius: 50%; 45 | content: ''; 46 | height: 6px; 47 | position: relative; 48 | top: -3px; 49 | width: 6px; 50 | } 51 | 52 | &.custom-indicator ol .list-item:before { 53 | color: $custom-indicator-red; 54 | content: counter(listitem) '.'; 55 | font-weight: 700; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /app/components/header/template.hbs: -------------------------------------------------------------------------------- 1 | <header data-uri="{{ default _ref _self }}" class="{{componentVariation}} {{ pageType }}" data-editable="settings"> 2 | <div class="header-inner"> 3 | {{#if logoUrl}} 4 | <a class="header__logo-link" href="{{logoUrl}}"> 5 | {{/if}} 6 | <span class="header__title-logo"> 7 | {{#if isSVGString}} 8 | {{{siteLogo}}} 9 | {{else}} 10 | <img class="header__logo" src="{{siteLogo}}"> 11 | {{/if}} 12 | </span> 13 | {{#if logoUrl}} 14 | </a> 15 | {{/if}} 16 | {{#if tagline}} 17 | <p class="header__tagline">{{tagline}}</p> 18 | {{/if}} 19 | {{#if enableSocialButtons}} 20 | <div class="header__social-icons"> 21 | {{#if shareServices.facebook }} 22 | <a 23 | data-shareService="facebook" 24 | target="_blank" 25 | class="share-link facebook" 26 | title="Share on Facebook" 27 | aria-label="Share on Facebook" 28 | >{{{ read 'public/media/sites/claydemo/facebook-square.svg' }}}<span>Share</span></a> 29 | {{/if}} 30 | {{#if shareServices.twitter }} 31 | <a 32 | data-shareService="twitter" 33 | target="_blank" 34 | class="share-link twitter" 35 | title="Share on Twitter" 36 | aria-label="Share on Twitter" 37 | >{{{ read 'public/media/sites/claydemo/twitter.svg' }}}<span>Tweet</span></a> 38 | {{/if}} 39 | </div> 40 | {{/if}} 41 | </div> 42 | </header> 43 | -------------------------------------------------------------------------------- /docs/clay-access-key.md: -------------------------------------------------------------------------------- 1 | # Clay Access Key 2 | 3 | By default Clay authenticates non-GET requests to the API using an access token. This token is set by an environment variable in your local instance is required any time you try to write imformation. 4 | 5 | ## Setting Access Key 6 | 7 | To set an access key for your instance simply define the `CLAY_ACCESS_KEY` environment variable in your instance. When Amphora starts up it will read this value for any future requests. 8 | 9 | In this starter project [the value of the access key is defined here](https://github.com/clay/clay-starter/blob/master/app/.env#L5). 10 | 11 | ## Making HTTP Requests To Clay 12 | 13 | Whenever you make a request to a Clay endpoint make sure you add the following header to your requests: 14 | 15 | `Authorization: token <CLAY_ACCESS_KEY>` 16 | 17 | ## Clay CLI Import Command 18 | 19 | [Clay CLI's `import` command](https://github.com/clay/claycli#import) makes it easy to write starter data to Clay or move data between environments. This command requires you pass in the access token as part of the command or to pass in an alias to the key which is stored in your local `~/.clayconfig` file. [You can create aliases for keys with these instructions](https://github.com/clay/claycli#config). 20 | 21 | This project provides a Make command to run the configure step and set it to the default access key value declared in the `app/.env` file: `make add-access-key`. Be sure to run this command so that your local environment has the access key setup so you can use Clay CLI to make your workflow easier. 22 | -------------------------------------------------------------------------------- /app/components/tags/model.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const _map = require('lodash/map'), 3 | _assign = require('lodash/assign'), 4 | _set = require('lodash/set'), 5 | _includes = require('lodash/includes'), 6 | { removeNonAlphanumericCharacters } = require('../../services/universal/sanitize'), 7 | // invisible tags will be rendered to the page but never visible outside of edit mode 8 | invisibleTags = []; 9 | 10 | /** 11 | * Removes all non alphanumeric characters from the tags 12 | * @param {array} items 13 | * @returns {array} 14 | */ 15 | function normalizeTags(items = []) { 16 | return items.map(({ text }) => removeNonAlphanumericCharacters(text)).filter(Boolean); 17 | } 18 | 19 | /** 20 | * make sure all tags are lowercase and have trimmed whitespace 21 | * @param {array} items 22 | * @return {array} 23 | */ 24 | function clean(items) { 25 | return _map(items || [], function(item) { 26 | return _assign({}, item, { text: item.text.toLowerCase().trim() }); 27 | }); 28 | } 29 | 30 | /** 31 | * set an 'invisible' boolean on tags, if they're in the list above 32 | * @param {array} items 33 | * @return {array} 34 | */ 35 | function setInvisible(items) { 36 | return _map(items || [], function(item) { 37 | return _set(item, 'invisible', _includes(invisibleTags, item.text)); 38 | }); 39 | } 40 | 41 | module.exports.save = function(uri, data) { 42 | let { items } = data; 43 | 44 | items = clean(items); // first, make sure everything is lowercase and has trimmed whitespace 45 | data.normalizedTags = normalizeTags(items); 46 | items = setInvisible(items); // then figure out which tags should be invisible 47 | data.items = items; 48 | 49 | return data; 50 | }; 51 | -------------------------------------------------------------------------------- /app/services/universal/log.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const clayLog = require('clay-log'), 4 | _defaults = require('lodash/defaults'); 5 | var sitesLogInstance, // Populated after init 6 | navigatorReference; 7 | 8 | /** 9 | * Setup the logger 10 | * 11 | * @param {String} version 12 | * @param {Boolean} browser 13 | */ 14 | function init(version, browser) { 15 | var instanceMeta = {}; 16 | 17 | if (version) { 18 | instanceMeta.sitesVersion = version; 19 | } 20 | 21 | if (browser) { 22 | instanceMeta.browserVersion = navigatorReference.userAgent; 23 | } 24 | 25 | // Initialize the logger 26 | clayLog.init({ 27 | name: 'clay', 28 | meta: instanceMeta 29 | }); 30 | 31 | sitesLogInstance = clayLog.getLogger(); 32 | } 33 | 34 | /** 35 | * Call this function in specific files to get a logging 36 | * instance specific to that file. Handy for adding 37 | * the filename or any other file specific meta information 38 | * 39 | * @param {Object} meta 40 | * @return {Function} 41 | */ 42 | function setup(meta) { 43 | meta = _defaults({}, meta, { file: 'File not specified! Please declare a file' }); 44 | 45 | if (sitesLogInstance) { 46 | return clayLog.meta(meta, sitesLogInstance); 47 | } else { 48 | return console.log; 49 | } 50 | } 51 | 52 | // If we're in the browser, let's call initialize immediately 53 | // and use the global navigator object 54 | if (!(process.versions && process.versions.node)) { 55 | navigatorReference = navigator; 56 | init(null, true); 57 | } 58 | 59 | module.exports.init = init; 60 | module.exports.setup = setup; 61 | // For testing 62 | module.exports.assignNavigator = function(fakeNavigator) { 63 | navigatorReference = fakeNavigator; 64 | }; 65 | module.exports.assignLogInstance = function(fakeInstance) { 66 | sitesLogInstance = fakeInstance; 67 | }; 68 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | app/client-env.json 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # TypeScript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | 62 | # next.js build output 63 | .next 64 | 65 | .DS_Store 66 | .idea 67 | .vscode/ 68 | 69 | elasticsearch/data 70 | postgres/data 71 | redis/data 72 | 73 | app/browserify-cache.json 74 | 75 | lib-cov 76 | *.seed 77 | *.log 78 | *.csv 79 | *.dat 80 | *.out 81 | *.pid 82 | *.gz 83 | *.iml 84 | 85 | # client-side env variables 86 | client-env.json 87 | 88 | .eslintcache 89 | 90 | # public folder (compiled assets) 91 | app/public 92 | 93 | # client-side env variables (list compiled at compile-time, injected at runtime) 94 | app/client-env.json 95 | app/client-env.js 96 | 97 | package-lock.json 98 | 99 | app/.env 100 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | --- 2 | version: 2 3 | 4 | jobs: 5 | test_node10: 6 | docker: 7 | - image: circleci/node:10 8 | working_directory: ~/repo 9 | steps: 10 | - checkout 11 | - restore_cache: 12 | keys: 13 | - v3-node10-dependencies-{{ checksum "package.json" }} 14 | - v3-node10-dependencies- 15 | - run: cd app && npm install 16 | - save_cache: 17 | paths: 18 | - node_modules 19 | key: v3-node10-dependencies-{{ checksum "package.json" }} 20 | - run: cd app && npm run lint-js 21 | - run: cd app && npm run lint-css 22 | 23 | test_node12: 24 | docker: 25 | - image: circleci/node:12 26 | working_directory: ~/repo 27 | steps: 28 | - checkout 29 | - restore_cache: 30 | keys: 31 | - v3-node12-dependencies-{{ checksum "package.json" }} 32 | - v3-node12-dependencies- 33 | - run: cd app && npm install 34 | - save_cache: 35 | paths: 36 | - node_modules 37 | key: v3-node12-dependencies-{{ checksum "package.json" }} 38 | - run: cd app && npm run lint-js 39 | - run: cd app && npm run lint-css 40 | 41 | test_node14: 42 | docker: 43 | - image: circleci/node:14 44 | working_directory: ~/repo 45 | steps: 46 | - checkout 47 | - restore_cache: 48 | keys: 49 | - v3-node14-dependencies-{{ checksum "package.json" }} 50 | - v3-node14-dependencies- 51 | - run: cd app && npm install 52 | - save_cache: 53 | paths: 54 | - node_modules 55 | key: v3-node14-dependencies-{{ checksum "package.json" }} 56 | - run: cd app && npm run lint-js 57 | - run: cd app && npm run lint-css 58 | 59 | workflows: 60 | version: 2 61 | test: 62 | jobs: 63 | - test_node10 64 | - test_node12 65 | - test_node14 66 | -------------------------------------------------------------------------------- /app/components/tags/template.hbs: -------------------------------------------------------------------------------- 1 | {{~ set items 'invisibleTagsCount' 0 ~}} 2 | <div class="tags" data-uri="{{ default _ref _self }}" data-editable="items"> 3 | {{~#if items.length ~}} 4 | <h2 class="title">Tags:</h2> 5 | <ul class="tags-list"> 6 | {{! invisible tags first so that pseudo divider element does not appear after the last visible tag }} 7 | {{~#each items as |tag| ~}} 8 | {{~#if tag.invisible ~}} 9 | {{ set ../items 'invisibleTagsCount' (add ../items.invisibleTagsCount 1) }} 10 | <li class="tags-list-item {{ if @root.locals.edit 'invisible-in-edit-mode' else='invisible' }}"> 11 | <a href="//{{ @root.locals.site.host }}/tags/{{ urlencode (replace tag.text ' ' '-') }}/" 12 | class="tags-link">{{ tag.text }}</a> 13 | </li> 14 | {{~/if~}} 15 | {{~/each~}} 16 | 17 | {{! visible tags }} 18 | {{! original nunjucks logic appears less verbose, moved some logic out of tag for clarity }} 19 | {{~#each items as |tag| ~}} 20 | {{~#unless tag.invisible ~}} 21 | {{ set 'encodedTag' (replace (urlencode (replace (trim (lowercase tag.text)) ' ' '-')) "%2F" "/") }} 22 | <li class="tags-list-item{{~#ifAll (compare (subtract @index ../items.invisibleTagsCount) '>' 3) (compare (subtract ../items.length ../items.invisibleTagsCount) '>' 4)}} hidden{{/ifAll~}}"> 23 | <a aria-label="More articles tagged {{ tag.text }}" href="//{{ @root.locals.site.host }}/tags/{{ encodedTag }}/" 24 | class="tags-link">{{ tag.text }}</a>, 25 | </li> 26 | {{~/unless~}} 27 | {{~/each~}} 28 | 29 | {{~#if (compare (subtract items.length items.invisibleTagsCount) '>' 4) ~}} 30 | <li class="tags-list-item"> 31 | <a aria-label="More tags" class="tags-link more" href="#">More</a> 32 | </li> 33 | {{~/if~}} 34 | </ul> 35 | {{~/if~}} 36 | </div> 37 | -------------------------------------------------------------------------------- /app/services/client/sharePopup.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const popup = require('./popup'); 4 | 5 | class sharePopUp { 6 | /** 7 | * Create a Popup for share services 8 | * @param {Node} shareLink - html anchor tag 9 | * @param {string} shareURL - url of page to be shared 10 | */ 11 | constructor(shareLink, shareURL) { 12 | this.shareLink = shareLink; 13 | this.shareURL = shareURL; 14 | this.shareService = this.shareLink.getAttribute('data-shareService') || null; 15 | this.shareTitle = this.shareLink.getAttribute('title') || 'Clay Starter'; 16 | 17 | this.setDimensions(); 18 | this.addShareURL(); 19 | this.addClickHandler(); 20 | } 21 | 22 | addShareURL() { 23 | switch (this.shareService) { 24 | case 'twitter': 25 | this.shareLink.href = `https://twitter.com/share?text=${encodeURIComponent( 26 | this.shareTitle 27 | )}&url='${this.shareURL}?utm_source=tw&utm_medium=s3&utm_campaign=sharebutton-t`; 28 | break; 29 | case 'facebook': 30 | this.shareLink.href = `http://www.facebook.com/sharer/sharer.php?u=${ 31 | this.shareURL 32 | }?utm_source=fb&utm_medium=s3&utm_campaign=sharebutton-t`; 33 | break; 34 | default: 35 | } 36 | } 37 | 38 | addClickHandler() { 39 | this.shareLink.addEventListener('click', this.handleClick.bind(this)); 40 | } 41 | 42 | handleClick(e) { 43 | e.preventDefault(); 44 | 45 | const dimensions = this.popupDimensions[this.shareService] || this.popupDimensions.default; 46 | 47 | popup.openPopUp(this.shareLink.href, dimensions); 48 | } 49 | 50 | setDimensions() { 51 | this.popupDimensions = { 52 | default: { 53 | w: 520, 54 | h: 304 55 | }, 56 | facebook: { 57 | w: 520, 58 | h: 304 59 | }, 60 | twitter: { 61 | w: 550, 62 | h: 572 63 | } 64 | }; 65 | } 66 | } 67 | 68 | module.exports = sharePopUp; 69 | -------------------------------------------------------------------------------- /app/services/startup/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const pkg = require('../../package.json'), 4 | amphoraPkg = require('amphora/package.json'), 5 | kilnPkg = require('clay-kiln/package.json'), 6 | bodyParser = require('body-parser'), 7 | compression = require('compression'), 8 | session = require('express-session'), 9 | RedisStore = require('connect-redis')(session), 10 | amphoraSearch = require('amphora-search'), 11 | initCore = require('./amphora-core'), 12 | log = require('../universal/log').setup({ file: __filename }); 13 | 14 | function createSessionStore() { 15 | var sessionPrefix = process.env.REDIS_DB 16 | ? `${process.env.REDIS_DB}-clay-session:` 17 | : 'clay-session:', 18 | redisStore = new RedisStore({ 19 | url: process.env.REDIS_SESSION_HOST, 20 | prefix: sessionPrefix 21 | }); 22 | 23 | redisStore.setMaxListeners(0); 24 | 25 | return redisStore; 26 | } 27 | 28 | function setupApp(app) { 29 | var sessionStore; 30 | // Enable GZIP 31 | 32 | if (process.env.ENABLE_GZIP) { 33 | app.use(compression()); 34 | } 35 | 36 | // set app settings 37 | app.set('trust proxy', 1); 38 | app.set('strict routing', true); 39 | app.set('x-powered-by', false); 40 | app.use(function(req, res, next) { 41 | res.set( 42 | 'X-Powered-By', 43 | [ 44 | `clay v ${pkg.version}`, 45 | `amphora v ${amphoraPkg.version}`, 46 | `kiln v ${kilnPkg.version}` 47 | ].join('; ') 48 | ); 49 | next(); 50 | }); 51 | 52 | // nginx limit is also 1mb, so can't go higher without upping nginx 53 | app.use( 54 | bodyParser.json({ 55 | limit: '5mb' 56 | }) 57 | ); 58 | 59 | app.use( 60 | bodyParser.urlencoded({ 61 | limit: '5mb', 62 | extended: true 63 | }) 64 | ); 65 | 66 | sessionStore = createSessionStore(); 67 | 68 | return amphoraSearch().then(search => { 69 | log('info', `Using ElasticSearch at ${process.env.ELASTIC_HOST}`); 70 | 71 | return initCore(app, search, sessionStore); 72 | }); 73 | } 74 | 75 | module.exports = setupApp; 76 | -------------------------------------------------------------------------------- /app/services/universal/format-time.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var moment = require('moment'); 4 | 5 | /** 6 | * Format date <10-07-2017> to <October 7> 7 | * or <10-07-2017 && 11-08-2017> to <October 7-November 8, 2017> 8 | * 9 | * @param {string} dateFrom - Beginning date. 10 | * @param {string} dateTo - Ending date. 11 | * @param {string} [format] - Format for parsing date // Full Month Name, Day number, Full Year. 12 | * @returns {string} formatted Date. 13 | * 14 | * Note (c.g. 2017-11-22): Since we're not passing the hour moment 15 | * is returning a day less so for avoiding this we need to set 16 | * the hours in this case 24. 17 | */ 18 | function formatDateRange(dateFrom = '', dateTo = '', format = 'MMMM D, YYYY') { 19 | if (dateTo && dateFrom) { 20 | return `${moment(new Date(dateFrom).setHours(24)).format('MMMM D')}-${moment( 21 | new Date(dateTo).setHours(24) 22 | ).format(format)}`; 23 | } else if (!dateTo && dateFrom) { 24 | return `${moment(new Date(dateFrom).setHours(24)).format(format)}`; 25 | } else { 26 | return ''; 27 | } 28 | } 29 | 30 | function secondsToISO(seconds) { 31 | return moment.duration(seconds, 'seconds').toISOString(); 32 | } 33 | 34 | /** 35 | * Returns true if article was published within the past 24 hrs. 36 | * @function 37 | * @param {Object} date - The date the article was published. 38 | * @returns {boolean} 39 | */ 40 | function isPublished24HrsAgo(date) { 41 | let pubWithin24Hrs = false, 42 | articleDate = moment(new Date(date)).valueOf(), 43 | now = moment().valueOf(); 44 | 45 | if (now - articleDate <= 24 * 60 * 60 * 1000) { 46 | pubWithin24Hrs = true; 47 | } 48 | return pubWithin24Hrs; 49 | } 50 | 51 | /** 52 | * Returns "X hours ago" timestamp of when article was published 53 | * @function 54 | * @param {Object} date - The date the article was published. 55 | * @return {string} 56 | */ 57 | function hrsOnlyTimestamp(date) { 58 | return moment().format('H') - moment(date).format('H') + ' hours ago'; 59 | } 60 | 61 | module.exports.formatDateRange = formatDateRange; 62 | module.exports.secondsToISO = secondsToISO; 63 | module.exports.isPublished24HrsAgo = isPublished24HrsAgo; 64 | module.exports.hrsOnlyTimestamp = hrsOnlyTimestamp; 65 | -------------------------------------------------------------------------------- /app/services/universal/word-count.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var htmlWordCount = require('html-word-count'), 4 | // components with `text` property, and components that trigger a recount 5 | COMPONENTS_WITH_WORDS = { 6 | article: null, // just trigger recount 7 | 'article-sidebar': null, 8 | blockquote: 'text', // trigger recount, AND count text in this property 9 | paragraph: 'text', 10 | subheader: 'text', 11 | subsection: null 12 | }, 13 | { getComponentName } = require('clayutils'); 14 | 15 | /** 16 | * determine if mutation uri is a component that we care about 17 | * @param {string} uri 18 | * @return {Boolean} 19 | */ 20 | function isComponentWithWords(uri) { 21 | return COMPONENTS_WITH_WORDS[getComponentName(uri)] !== undefined; 22 | } 23 | 24 | /** 25 | * get the component field that contains the text we should count 26 | * @param {string} uri 27 | * @return {string|null} 28 | */ 29 | function getComponentField(uri) { 30 | return COMPONENTS_WITH_WORDS[getComponentName(uri)]; 31 | } 32 | 33 | /** 34 | * Given an object mapping component URIs to their data or an array of 35 | * components with _ref attributes, return an array of components in the latter 36 | * format. 37 | * @param {Object} cmptSrc 38 | * @return {Object[]} 39 | */ 40 | function normalizeCmptSrc(cmptSrc) { 41 | if (Array.isArray(cmptSrc)) { 42 | return cmptSrc; 43 | } else if (typeof cmptSrc === 'object') { 44 | return Object.keys(cmptSrc).map(key => Object.assign({}, cmptSrc[key], { _ref: key })); 45 | } 46 | return []; 47 | } 48 | 49 | /** 50 | * count words in components we care about 51 | * @param {Object|Object[]} components Object mapping URI to data or 52 | * array of cmpts with _ref 53 | * @return {number} 54 | */ 55 | function count(components) { 56 | return normalizeCmptSrc(components) 57 | .filter(cmpt => isComponentWithWords(cmpt._ref)) 58 | .map(cmpt => cmpt[getComponentField(cmpt._ref)]) 59 | .reduce((acc, fieldValue) => acc + htmlWordCount(fieldValue || ''), 0); 60 | } 61 | 62 | module.exports.count = count; 63 | module.exports.isComponentWithWords = isComponentWithWords; 64 | 65 | // for testing 66 | module.exports.setComponentsWithWords = i => (COMPONENTS_WITH_WORDS = i); 67 | -------------------------------------------------------------------------------- /elasticsearch/config/logging.yml: -------------------------------------------------------------------------------- 1 | # you can override this using by setting a system property, for example -Des.logger.level=DEBUG 2 | es.logger.level: INFO 3 | rootLogger: ${es.logger.level}, console, file 4 | logger: 5 | # log action execution errors for easier debugging 6 | action: DEBUG 7 | # reduce the logging for aws, too much is logged under the default INFO 8 | com.amazonaws: WARN 9 | org.apache.http: INFO 10 | 11 | # gateway 12 | #gateway: DEBUG 13 | #index.gateway: DEBUG 14 | 15 | # peer shard recovery 16 | #indices.recovery: DEBUG 17 | 18 | # discovery 19 | #discovery: TRACE 20 | 21 | index.search.slowlog: TRACE, index_search_slow_log_file 22 | index.indexing.slowlog: TRACE, index_indexing_slow_log_file 23 | 24 | additivity: 25 | index.search.slowlog: false 26 | index.indexing.slowlog: false 27 | 28 | appender: 29 | console: 30 | type: console 31 | layout: 32 | type: consolePattern 33 | conversionPattern: "[%d{ISO8601}][%-5p][%-25c] %m%n" 34 | 35 | file: 36 | type: dailyRollingFile 37 | file: ${path.logs}/${cluster.name}.log 38 | datePattern: "'.'yyyy-MM-dd" 39 | layout: 40 | type: pattern 41 | conversionPattern: "[%d{ISO8601}][%-5p][%-25c] %m%n" 42 | 43 | # Use the following log4j-extras RollingFileAppender to enable gzip compression of log files. 44 | # For more information see https://logging.apache.org/log4j/extras/apidocs/org/apache/log4j/rolling/RollingFileAppender.html 45 | #file: 46 | #type: extrasRollingFile 47 | #file: ${path.logs}/${cluster.name}.log 48 | #rollingPolicy: timeBased 49 | #rollingPolicy.FileNamePattern: ${path.logs}/${cluster.name}.log.%d{yyyy-MM-dd}.gz 50 | #layout: 51 | #type: pattern 52 | #conversionPattern: "[%d{ISO8601}][%-5p][%-25c] %m%n" 53 | 54 | index_search_slow_log_file: 55 | type: dailyRollingFile 56 | file: ${path.logs}/${cluster.name}_index_search_slowlog.log 57 | datePattern: "'.'yyyy-MM-dd" 58 | layout: 59 | type: pattern 60 | conversionPattern: "[%d{ISO8601}][%-5p][%-25c] %m%n" 61 | 62 | index_indexing_slow_log_file: 63 | type: dailyRollingFile 64 | file: ${path.logs}/${cluster.name}_index_indexing_slowlog.log 65 | datePattern: "'.'yyyy-MM-dd" 66 | layout: 67 | type: pattern 68 | conversionPattern: "[%d{ISO8601}][%-5p][%-25c] %m%n" 69 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | 4 | clay: 5 | build: . 6 | command: ["npm", "run", "start:dev"] 7 | expose: 8 | - 3001 9 | links: 10 | - elasticsearch 11 | - postgres 12 | - nginx 13 | - redis 14 | volumes: 15 | - ./app/.env:/usr/local/src/.env 16 | - ./app/app.js:/usr/local/src/app.js 17 | - ./app/claycli.config.js:/usr/local/src/claycli.config.js 18 | - ./app/components/:/usr/local/src/components/ 19 | - ./app/layouts/:/usr/local/src/layouts/ 20 | - ./app/nodemon.json:/usr/local/src/nodemon.json 21 | - ./app/package.json:/usr/local/src/package.json 22 | - ./app/services/:/usr/local/src/services/ 23 | - ./app/sites/:/usr/local/src/sites/ 24 | - ./app/styleguides/:/usr/local/src/styleguides/ 25 | 26 | ################################################ 27 | ## Local Sites Setup: ## 28 | ## Nginx, Elastic Search, Redis ## 29 | ################################################ 30 | nginx: 31 | image: nginx 32 | ports: 33 | - 80:80 34 | volumes: 35 | - ./nginx/configs/:/etc/nginx/conf.d/ 36 | 37 | # Event Bus, Postgres Cache and Session Store 38 | redis: 39 | image: redis 40 | ports: 41 | - 6379:6379 42 | volumes: 43 | - ./redis/config/redis.conf:/usr/local/etc/redis/redis.conf 44 | - redis:/data 45 | 46 | # You know, for search 47 | elasticsearch: 48 | image: docker.elastic.co/elasticsearch/elasticsearch-oss:6.6.0 49 | command: ["elasticsearch", "-Elogger.level=ERROR"] 50 | environment: 51 | - cluster.routing.allocation.disk.threshold_enabled="false" 52 | - cluster.routing.allocation.disk.watermark.flood_stage=10mb 53 | - cluster.routing.allocation.disk.watermark.high=200mb 54 | - cluster.routing.allocation.disk.watermark.low=50mb 55 | - discovery.type=single-node 56 | ports: 57 | - 9200:9200 58 | volumes: 59 | - ./elasticsearch/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml 60 | - elasticsearch:/usr/share/elasticsearch/data 61 | 62 | # Primary data store 63 | postgres: 64 | image: postgres 65 | restart: always 66 | environment: 67 | - POSTGRES_PASSWORD=example 68 | volumes: 69 | - postgres:/var/lib/postgresql/data 70 | ports: 71 | - 5432:5432 72 | 73 | volumes: 74 | elasticsearch: 75 | postgres: 76 | redis: 77 | -------------------------------------------------------------------------------- /app/components/article/template.hbs: -------------------------------------------------------------------------------- 1 | <article data-uri="{{ default _ref _self }}" class="{{ componentVariation }}{{ if @root.locals.edit ' editing' }}" role="main"> 2 | <header class="article-header"> 3 | <div class="lede-wrapper"> 4 | <div class="primary-area"> 5 | <div class="article-header-section"> 6 | {{! date and time }} 7 | <time class="article-timestamp" datetime="{{ date }}" itemprop="datePublished" data-editable="publishedDate"> 8 | {{#if date}} 9 | {{#if dateUpdated}} 10 | <span class="article-update">Updated </span> 11 | {{/if}} 12 | <span class="article-date">{{ articleTimestamp date }}</span> 13 | {{/if}} 14 | </time> 15 | </div> 16 | <div class="article-header-section"> 17 | {{! title }} 18 | <h1 class="headline-primary" data-editable="headline" itemprop="headline">{{{ headline }}}</h1> 19 | <div class="bylines"> 20 | {{! authors }} 21 | <span data-editable="bylines" class="primary-bylines"> 22 | {{{ byline bylines=byline }}} 23 | </span> 24 | </div> 25 | </div> 26 | </div> 27 | </div> 28 | </header> 29 | <section class="body"> 30 | {{#if @root.locals.edit}} 31 | <div class="lede-image-wrapper" data-editable="lede"> 32 | {{#if ledeUrl}} 33 | <img src="{{ ledeUrl }}" class="lede-image" data-src="{{ ledeUrl }}" alt="{{ ledeAlt }}"/> 34 | {{#if ledeCaption}} 35 | <div class="lede-image-data"> 36 | <div class="attribution"> 37 | {{{ ledeCaption }}} 38 | </div> 39 | </div> 40 | {{/if}} 41 | {{/if}} 42 | </div> 43 | {{/if}} 44 | <div class="article-content" data-editable="content" itemprop="articleBody"> 45 | {{#unless @root.locals.edit}} 46 | <div class="lede-image-wrapper"> 47 | {{#if ledeUrl}} 48 | <img src="{{ ledeUrl }}" class="lede-image" data-src="{{ ledeUrl }}" alt="{{ ledeAlt }}"/> 49 | {{#if ledeCaption}} 50 | <div class="lede-image-data"> 51 | <div class="attribution"> 52 | {{{ ledeCaption }}} 53 | </div> 54 | </div> 55 | {{/if}} 56 | {{/if}} 57 | </div> 58 | {{/unless}} 59 | {{! don't display generated annotatedTextAria in edit mode. }} 60 | {{#if @root.locals.edit}} 61 | {{> component-list content }} 62 | {{else}} 63 | {{> component-list (addAnnotatedTextAria content) }} 64 | {{/if}} 65 | </div> 66 | 67 | {{! Tags }} 68 | {{> tags tags}} 69 | </section> 70 | </article> 71 | -------------------------------------------------------------------------------- /app/styleguides/_default/layouts/layout.css: -------------------------------------------------------------------------------- 1 | @import '_default/common/_vars.css'; 2 | 3 | html { 4 | box-sizing: border-box; 5 | min-height: 100%; 6 | min-width: 320px; 7 | overflow-x: hidden; 8 | text-size-adjust: 100%; 9 | width: 100%; 10 | } 11 | 12 | *, 13 | :after, 14 | :before { 15 | box-sizing: inherit; 16 | -moz-osx-font-smoothing: grayscale; 17 | -webkit-font-smoothing: antialiased; 18 | text-rendering: optimizelegibility; 19 | } 20 | 21 | ::selection { 22 | background-color: #e7e7e7; 23 | } 24 | 25 | .layout a:focus, 26 | .layout button:focus, 27 | .layout input:focus, 28 | .layout select:focus, 29 | .layout textarea:focus { 30 | outline: dotted 1px; 31 | } 32 | 33 | .layout .kiln-field button:focus, 34 | .layout .kiln-field input:focus, 35 | .layout .kiln-field select:focus, 36 | .layout .kiln-field textarea:focus, 37 | .layout .kiln-wrapper button:focus, 38 | .layout .kiln-wrapper input:focus, 39 | .layout .kiln-wrapper select:focus, 40 | .layout .kiln-wrapper textarea:focus { 41 | outline: 0; 42 | } 43 | 44 | .layout { 45 | background-color: $background-primary-color; 46 | margin: 0; 47 | padding: 0; 48 | width: 100%; 49 | 50 | & > .page-header, 51 | & > .wrapper, 52 | & > .bottom { 53 | margin: auto; 54 | } 55 | 56 | & > .page-header, 57 | & > .top { 58 | margin: 20px auto; 59 | } 60 | 61 | & > .bottom { 62 | width: 100vw; 63 | } 64 | & > .wrapper { 65 | margin-right: auto; 66 | margin-left: auto; 67 | padding-right: 10px; 68 | padding-left: 10px; 69 | width: 100%; 70 | } 71 | } 72 | 73 | @media screen and (max-width: 767.9px) { 74 | .layout { 75 | & > .bottom, 76 | & > .wrapper { 77 | padding: 0 20px; 78 | } 79 | } 80 | } 81 | 82 | @media screen and (min-width: 768px) and (max-width: 1179.9px) { 83 | .layout{ 84 | & > .wrapper, 85 | & > .bottom { 86 | padding: 0 7vw; 87 | } 88 | 89 | & > .top { 90 | margin: 20px 50px; 91 | } 92 | 93 | & > .secondary > *:not(.ad) { 94 | margin: 0 7vw; 95 | } 96 | & > .wrapper { 97 | min-width: 600px; 98 | } 99 | } 100 | } 101 | 102 | @media screen and (min-width: 1180px) { 103 | .layout { 104 | & > .bottom { 105 | width: 1180px; 106 | } 107 | 108 | & > .bottom, 109 | & > .page-header, 110 | & > .top, 111 | & > .wrapper { 112 | margin: 0 auto; 113 | } 114 | 115 | & > .wrapper { 116 | align-items: center; 117 | display: flex; 118 | justify-content: center; 119 | max-width: $wrapperMaxWidth; 120 | } 121 | 122 | &.kiln-edit-mode .wrapper > .main { 123 | width: 100%; 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /app/styleguides/_default/components/container-rail.css: -------------------------------------------------------------------------------- 1 | $rail-width: 290px; /* desktop rail width, sized to fit 300x250/300x600 ad */ 2 | $gap: 39px; 3 | $black: #111; 4 | 5 | .container-rail, 6 | .container-rail > .container-main, 7 | .container-rail > .container-secondary { 8 | position: relative; 9 | } 10 | 11 | .container-rail > .container-secondary > .rail-inner-wrap { 12 | height: 100%; 13 | } 14 | 15 | .container-rail.top-border { 16 | border-top: 3px solid $black; 17 | } 18 | 19 | /* stacked up until desktop */ 20 | @media screen and (min-width:1180px) { 21 | .container-rail { 22 | display: flex; 23 | flex-flow: row nowrap; 24 | } 25 | 26 | .container-rail > .container-main { 27 | flex: 1 1 auto; 28 | } 29 | 30 | .container-rail > .container-secondary { 31 | flex: 0 0 $rail-width; 32 | margin-left: $gap; 33 | max-width: $rail-width; 34 | } 35 | 36 | .container-rail.lefty > .container-secondary { 37 | margin-left: 0; 38 | margin-right: $gap; 39 | } 40 | 41 | .container-rail > .container-secondary > .rail-inner-wrap { 42 | align-content: flex-start; 43 | display: flex; 44 | flex-flow: column nowrap; 45 | } 46 | 47 | /* if we support grid and we want a partial rail, switch to grid view */ 48 | /* default is rail on the right hand side */ 49 | @supports (display:grid) and (display:contents) { 50 | .container-rail.partial { 51 | column-gap: $gap; 52 | display: grid; 53 | grid-column-gap: $gap; 54 | grid-template-columns: 1fr $rail-width; 55 | 56 | & > .container-main { 57 | display: contents; 58 | grid-column: 1; 59 | grid-row: 1; 60 | } 61 | 62 | & > .container-secondary { 63 | display: contents; 64 | grid-column: 2; 65 | grid-row: 1; 66 | margin-left: 0; 67 | } 68 | 69 | /* set contents to full width - items are individually overriden in template */ 70 | & > .container-main > * { 71 | grid-column: 1/span 2; 72 | } 73 | 74 | & > .container-secondary > .rail-inner-wrap { 75 | grid-column: 2; 76 | } 77 | } 78 | 79 | /* version with rail on left hand side */ 80 | .container-rail.lefty.partial { 81 | grid-template-columns: $rail-width 1fr; 82 | 83 | & > .container-main { 84 | grid-column: 2; 85 | } 86 | 87 | & > .container-secondary { 88 | grid-column: 1; 89 | margin-right: 0; 90 | } 91 | 92 | & > .container-main > * { 93 | grid-column: 1/span 2; 94 | } 95 | 96 | & > .container-secondary > .rail-inner-wrap { 97 | grid-column: 1; 98 | } 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "clay-starter", 3 | "version": "0.0.1", 4 | "description": "Clay Demo's clay installation", 5 | "main": "app.js", 6 | "scripts": { 7 | "start": "node --max-old-space-size=256 --max-semi-space-size=2 app.js", 8 | "start:dev": "npm run check-elastic && nodemon --exec \"node -r dotenv/config\" app.js", 9 | "check-elastic": "while ! curl -sSI -o /dev/null elasticsearch:9200/_cat/indices; do sleep 3; done", 10 | "postinstall": "npm run build", 11 | "build": "CLAYCLI_COMPILE_MINIFIED_TEMPLATES=true npx clay compile --inlined --linked --reporter pretty", 12 | "watch": "npm run build -- --watch", 13 | "lint-css": "(for d in styleguides/* ; do stylelint --max-warnings 0 \"$d/components/*.css\" || exit; done)", 14 | "lint-js": "eslint . --cache app.js components search services sites" 15 | }, 16 | "author": "", 17 | "license": "MIT", 18 | "engines": { 19 | "node": ">=12" 20 | }, 21 | "dependencies": { 22 | "@nymdev/health-check": "^0.1.3", 23 | "amphora": "^7.10.0", 24 | "amphora-event-bus-redis": "0.0.1", 25 | "amphora-html": "4.1.0", 26 | "amphora-schedule": "1.0.2", 27 | "amphora-search": "7.4.0", 28 | "amphora-serve-static": "0.0.1", 29 | "amphora-storage-postgres": "1.2.0", 30 | "bluebird": "^3.5.3", 31 | "body-parser": "^1.18.2", 32 | "clay-kiln": "8.13.0", 33 | "clay-log": "^1.4.1", 34 | "claycli": "^4.0.0", 35 | "clayhandlebars": "5.0.1", 36 | "clayutils": "^2.1.0", 37 | "compression": "^1.7.2", 38 | "connect-redis": "^3.3.3", 39 | "date-fns": "^1.29.0", 40 | "express": "^4.16.3", 41 | "express-session": "^1.15.6", 42 | "fold-to-ascii": "^4.0.0", 43 | "glob": "^7.1.3", 44 | "he": "^1.1.1", 45 | "headline-quotes": "^2.1.1", 46 | "html-truncate": "^1.2.2", 47 | "html-word-count": "^2.0.0", 48 | "isomorphic-fetch": "^2.2.1", 49 | "jsonp-client": "^1.1.1", 50 | "lodash": "^4.17.13", 51 | "moment": "^2.24.0", 52 | "postcss-csso": "^3.0.0", 53 | "postcss-safe-parser": "^4.0.1", 54 | "prismjs": "^1.15.0", 55 | "speakingurl": "^14.0.1", 56 | "striptags": "^3.1.1", 57 | "typogr": "^0.6.7", 58 | "url-parse": "^1.4.0" 59 | }, 60 | "devDependencies": { 61 | "dotenv": "^6.2.0", 62 | "eslint": "4.18.2", 63 | "eslint-config-prettier": "^4.1.0", 64 | "eslint-plugin-html": "^3.2.0", 65 | "eslint-plugin-prettier": "^3.0.1", 66 | "husky": "^1.3.1", 67 | "lint-staged": "^8.1.5", 68 | "nodemon": "^1.18.7", 69 | "prettier": "^1.16.4", 70 | "stylelint": "^9.8.0", 71 | "stylelint-order": "^2.0.0" 72 | }, 73 | "husky": { 74 | "hooks": { 75 | "pre-commit": "lint-staged" 76 | } 77 | }, 78 | "lint-staged": { 79 | "*.{js,json,css}": [ 80 | "prettier --write", 81 | "git add" 82 | ] 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /app/components/article/model.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _get = require('lodash/get'), 4 | striptags = require('striptags'), 5 | dateFormat = require('date-fns/format'), 6 | dateParse = require('date-fns/parse'), 7 | utils = require('../../services/universal/utils'), 8 | has = utils.has, // convenience 9 | sanitize = require('../../services/universal/sanitize'); 10 | 11 | /** 12 | * only allow emphasis, italic, and strikethroughs in headlines 13 | * @param {string} oldHeadline 14 | * @returns {string} 15 | */ 16 | function stripHeadlineTags(oldHeadline) { 17 | const newHeadline = striptags(oldHeadline, ['em', 'i', 'strike']); 18 | 19 | // if any tags include a trailing space, shift it to outside the tag 20 | return newHeadline.replace(/ <\/(i|em|strike)>/g, '</$1> '); 21 | } 22 | 23 | /** 24 | * sanitize headline 25 | * @param {object} data 26 | */ 27 | function sanitizeInputs(data) { 28 | if (has(data.headline)) { 29 | data.headline = sanitize.toSmartHeadline(stripHeadlineTags(data.headline)); 30 | } 31 | } 32 | 33 | /** 34 | * set the publish date from the locals (even if it's already set), 35 | * and format it correctly 36 | * @param {object} data 37 | * @param {object} locals 38 | */ 39 | function formatDate(data, locals) { 40 | if (_get(locals, 'date')) { 41 | // if locals and locals.date exists, set the article date (overriding any date already set) 42 | data.date = dateFormat(locals.date); // ISO 8601 date string 43 | } else if (has(data.articleDate) || has(data.articleTime)) { 44 | // make sure both date and time are set. if the user only set one, set the other to today / right now 45 | data.articleDate = has(data.articleDate) 46 | ? data.articleDate 47 | : dateFormat(new Date(), 'YYYY-MM-DD'); 48 | data.articleTime = has(data.articleTime) ? data.articleTime : dateFormat(new Date(), 'HH:mm'); 49 | // generate the `date` data from these two fields 50 | data.date = dateFormat(dateParse(`${data.articleDate} ${data.articleTime}`)); // ISO 8601 date string 51 | } 52 | } 53 | 54 | /** 55 | * set the canonical url from the locals (even if it's already set) 56 | * @param {object} data 57 | * @param {object} locals 58 | */ 59 | function setCanonicalUrl(data, locals) { 60 | if (_get(locals, 'publishUrl')) { 61 | data.canonicalUrl = locals.publishUrl; 62 | } 63 | } 64 | 65 | /** 66 | * Set the feed image to the lede url if it isn't already set 67 | * @param {object} data 68 | */ 69 | function generateFeedImage(data) { 70 | if (data.ledeUrl) { 71 | data.feedImgUrl = data.ledeUrl; 72 | } 73 | } 74 | 75 | module.exports.save = function(uri, data, locals) { 76 | // first, let's get all the synchronous stuff out of the way: 77 | // sanitizing inputs, setting fields, etc 78 | sanitizeInputs(data); 79 | formatDate(data, locals); 80 | setCanonicalUrl(data, locals); 81 | generateFeedImage(data); 82 | 83 | return data; 84 | }; 85 | -------------------------------------------------------------------------------- /app/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parserOptions: { 3 | ecmaVersion: 6, 4 | ecmaFeatures: { 5 | arrowFunctions: true, 6 | blockBindings: true, 7 | forOf: true, 8 | objectLiteralDuplicateProperties: true, 9 | objectLiteralShorthandProperties: true, 10 | objectLiteralShorthandMethods: true, 11 | octalLiterals: true, 12 | binaryLiterals: true, 13 | templateStrings: true, 14 | generators: true, 15 | modules: false 16 | } 17 | }, 18 | plugins: ['html', 'prettier'], 19 | extends: ['plugin:prettier/recommended'], 20 | env: { 21 | browser: true, 22 | commonjs: true, 23 | mocha: true, 24 | es6: true, 25 | node: true 26 | }, 27 | // add global vars for chai, etc 28 | globals: { 29 | expect: false, 30 | chai: false, 31 | sinon: false, 32 | DS: false, 33 | FB: false, 34 | YT: false, // YouTube library 35 | googletag: false // GPT 36 | }, 37 | rules: { 38 | // possible errors 39 | 'valid-jsdoc': [ 40 | 1, 41 | { 42 | requireReturn: false, 43 | requireParamDescription: false, 44 | requireReturnDescription: false 45 | } 46 | ], 47 | // best practices 48 | complexity: 0, // Reconsider this in the future 49 | 'default-case': 2, 50 | 'guard-for-in': 2, 51 | 'no-alert': 1, 52 | 'no-floating-decimal': 1, 53 | 'no-self-compare': 2, 54 | 'no-throw-literal': 2, 55 | 'no-void': 2, 56 | 'quote-props': [2, 'as-needed'], 57 | 'vars-on-top': 2, 58 | 'wrap-iife': 2, 59 | // strict mode 60 | strict: [2, 'global'], 61 | // variables 62 | 'no-unused-vars': 2, 63 | 'no-undef': 2, 64 | // node.js 65 | 'handle-callback-err': [2, '^.*(e|E)rr'], 66 | 'no-mixed-requires': 0, 67 | 'no-new-require': 2, 68 | 'no-path-concat': 2, 69 | // stylistic issues 70 | 'brace-style': [ 71 | 2, 72 | '1tbs', 73 | { 74 | allowSingleLine: true 75 | } 76 | ], 77 | 'comma-style': [2, 'last'], 78 | 'comma-dangle': [2, 'never'], 79 | 'eol-last': [2, 'always'], 80 | 'max-nested-callbacks': [2, 5], 81 | 'newline-after-var': [2, 'always'], 82 | 'no-nested-ternary': 2, 83 | 'no-spaced-func': 0, 84 | 'no-trailing-spaces': 2, 85 | 'no-underscore-dangle': 0, 86 | 'no-unneeded-ternary': 1, 87 | 'one-var': 2, 88 | quotes: [2, 'single', 'avoid-escape'], 89 | semi: [2, 'always'], 90 | 'keyword-spacing': 2, 91 | 'space-before-blocks': [2, 'always'], 92 | 'space-infix-ops': [ 93 | 1, 94 | { 95 | int32Hint: false 96 | } 97 | ], 98 | 'spaced-comment': [2, 'always'], 99 | // es6 100 | 'generator-star-spacing': [2, 'before'], 101 | // legacy jshint rules 102 | 'max-depth': [2, 4], 103 | 'max-params': [2, 4] 104 | } 105 | }; 106 | -------------------------------------------------------------------------------- /app/components/article/schema.yaml: -------------------------------------------------------------------------------- 1 | _description: | 2 | A basic article component. 3 | 4 | _confirmRemoval: true 5 | 6 | # this is the headline that displays on the article itself 7 | headline: 8 | _label: Headline 9 | _publish: pageTitle # Listened to by the `meta-title` component 10 | _placeholder: 11 | height: 40px 12 | text: Headline 13 | required: true 14 | _has: 15 | input: inline 16 | buttons: 17 | - italic 18 | - strike 19 | - bold 20 | validate: 21 | required: true 22 | max: 100 23 | maxMessage: Headline must be 100 characters or fewer 24 | byline: 25 | _label: Byline 26 | _publish: pageAuthors 27 | _has: 28 | input: simple-list 29 | help: An array of Authors 30 | 31 | # users edit the published date from the articleDate and articleTime fields 32 | date: 33 | _label: Published Date 34 | _publish: publishDate 35 | _has: 36 | help: Date that is generated from the articleDate and articleTime fields 37 | articleDate: 38 | _label: First Published Date 39 | _has: 40 | input: datepicker 41 | help: Custom published date, if it should be different than the actual date the article was first published 42 | articleTime: 43 | _label: First Published Time 44 | _has: 45 | input: timepicker 46 | help: Custom published time 47 | dateUpdated: 48 | _label: Display Updated Date 49 | _has: 50 | input: checkbox 51 | help: Display "Updated On" in the article with the latest published date 52 | 53 | content: 54 | _label: Article Content 55 | _placeholder: 56 | text: Article Content 57 | height: 600px 58 | _componentList: 59 | include: 60 | - paragraph 61 | - subheader 62 | - divider 63 | - image 64 | - list 65 | - pull-quote 66 | - code-sample 67 | 68 | ledeUrl: 69 | _label: Lede Image URL 70 | _has: 71 | input: text 72 | type: url 73 | help: Image URL 74 | ledeAlt: 75 | _label: Lede Alt Text 76 | _has: 77 | input: text 78 | help: Alternative text for screen readers 79 | ledeCaption: 80 | _label: Lede Caption Text 81 | _has: 82 | input: wysiwyg 83 | buttons: 84 | - link 85 | - bold 86 | - italic 87 | 88 | _groups: 89 | publishedDate: 90 | fields: 91 | - articleDate 92 | - articleTime 93 | - dateUpdated 94 | _placeholder: 95 | text: Custom Published Date 96 | height: 30px 97 | ifEmpty: articleDate or articleTime 98 | bylines: 99 | fields: 100 | - byline 101 | _placeholder: 102 | text: Byline 103 | height: 30px 104 | ifEmpty: 'byline' 105 | lede: 106 | fields: 107 | - ledeUrl 108 | - ledeAlt 109 | - ledeCaption 110 | _placeholder: 111 | text: Lede 112 | height: 330px 113 | ifEmpty: ledeUrl 114 | 115 | # non-user-editable fields, set by model.js and used for pubsub 116 | canonicalUrl: 117 | _publish: url 118 | _has: 119 | help: Canonical URL of an article. Set when the article publishes. 120 | 121 | tags: 122 | _component: 123 | include: 124 | - tags 125 | normalizedTags: 126 | _subscribe: normalizedTags 127 | -------------------------------------------------------------------------------- /app/styleguides/_default/layouts/layout-simple.css: -------------------------------------------------------------------------------- 1 | @import '_default/common/_vars.css'; 2 | 3 | $white: #fff; 4 | 5 | body, 6 | html { 7 | width: 100%; 8 | } 9 | 10 | html { 11 | box-sizing: border-box; 12 | min-height: 100%; 13 | overflow-x: hidden; 14 | text-size-adjust: 100%; 15 | } 16 | 17 | body { 18 | margin: 0; 19 | padding: 0; 20 | } 21 | 22 | *, 23 | :after, 24 | :before { 25 | box-sizing: inherit; 26 | font-feature-settings: 'lnum'; 27 | -webkit-font-smoothing: antialiased; 28 | font-variant-numeric: lining-nums; 29 | text-rendering: optimizelegibility; 30 | } 31 | 32 | .layout-simple { 33 | background-color: $background-primary-color; 34 | box-sizing: border-box; 35 | margin: 0; 36 | min-height: 100vh; 37 | overflow-x: hidden; 38 | 39 | &.hidden { 40 | height: 0; 41 | overflow: hidden; 42 | } 43 | } 44 | 45 | .layout-simple a:focus, 46 | .layout-simple button:focus, 47 | .layout-simple input:focus, 48 | .layout-simple select:focus, 49 | .layout-simple textarea:focus { 50 | outline: dotted 1px; 51 | } 52 | 53 | .layout-simple .kiln-field button:focus, 54 | .layout-simple .kiln-field input:focus, 55 | .layout-simple .kiln-field select:focus, 56 | .layout-simple .kiln-field textarea:focus, 57 | .layout-simple .kiln-wrapper button:focus, 58 | .layout-simple .kiln-wrapper input:focus, 59 | .layout-simple .kiln-wrapper select:focus, 60 | .layout-simple .kiln-wrapper textarea:focus { 61 | outline: 0; 62 | } 63 | 64 | .layout-simple > .footer, 65 | .layout-simple > .main, 66 | .layout-simple > .page-header { 67 | clear: both; 68 | overflow-x: visible; 69 | } 70 | 71 | .layout-simple > .main { 72 | margin: 0 10px; 73 | position: relative; 74 | } 75 | 76 | .layout-simple > .page-header, 77 | .layout-simple > .top { 78 | margin: auto; 79 | position: relative; 80 | } 81 | 82 | @media screen and (min-width: 375px) { 83 | .layout-simple > .main { 84 | margin: 0 20px; 85 | } 86 | } 87 | 88 | @media screen and (min-width: 768px) { 89 | .layout-simple > .main { 90 | margin: 0 34px; 91 | padding-top: 20px; 92 | } 93 | } 94 | 95 | @media screen and (min-width: 1180px) { 96 | /* Overflow cannot be hidden at desktop because it prevents use of position:sticky on right rail ad */ 97 | /* This is needed on mobile because homepage elements overflow the container and otherwise cause horizontal scroll */ 98 | .layout-simple { 99 | overflow-x: visible; 100 | } 101 | 102 | .layout-simple > .top, 103 | .layout-simple > .page-header, 104 | .layout-simple > .main { 105 | clear: both; 106 | margin: 0 auto; 107 | max-width: 1100px; 108 | } 109 | 110 | .layout-simple > .main { 111 | padding-top: 30px; 112 | } 113 | 114 | .layout-simple .top.takeover-active { 115 | background-color: $white; 116 | max-width: 1140px; 117 | 118 | ~ .page-header { 119 | background-color: $white; 120 | max-width: 1140px; 121 | padding: 23px 0 0; 122 | } 123 | 124 | ~ .main { 125 | background-color: $white; 126 | max-width: 1140px; 127 | z-index: 1; 128 | } 129 | } 130 | 131 | .layout-simple > .global-nav-inner { 132 | margin: auto; 133 | width: 940px; 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Clay Starter 2 | 3 | > A basic starter for Clay 4 | 5 | ## Docs 6 | Documentation around Clay is being refined in cojunction with iteration on this starter. For beginning documentation about Clay and its data structures you can browse this link: https://claycms.gitbook.io/clay/ 7 | 8 | ## Requirements 9 | 10 | - [NodeJS](https://github.com/creationix/nvm) 11 | - [Docker For Mac](https://hub.docker.com/editions/community/docker-ce-desktop-mac) 12 | - [Clay CLI](https://github.com/clay/claycli) 13 | 14 | ## Assumptions 15 | 16 | - This repo uses Google for OAuth by default. To use your Google account to authenticate locally change the `username` field in `sample_users.yml` 17 | - You're running Node `8.12.0` or greater 18 | - You've installed [Clay CLI](https://github.com/clay/claycli) 19 | 20 | ## Setup 21 | 22 | Clone the repo and run the following commands: 23 | 24 | - `cp app/.env.sample app/.env` (This will create a file with the required env variables) 25 | - `make` (This will download the containers, run an `npm install` and start the app) 26 | - `make add-access-key` ([Please read this doc for more information about your Clay access key](docs/clay-access-key.md)) 27 | - `make bootstrap` (This command seeds some starting data) 28 | - `make bootstrap-user` (This command seeds a user from `sample_users.yml` file at the root of this project) 29 | 30 | You should be able to navigate to http://localhost/_pages/sample-article.html to see an article page render! 31 | 32 | ### Accessing The Edit UI 33 | 34 | The edit interface of Clay is a component itself called [`Kiln`](https://github.com/clay/clay-kiln)! To begin editing with the UI you'll need to make sure you've run `make boostrap-user` after replacing the sample user with your own credentials or using the default one. Here's an example of what can be included in the `sample_users.yml` file: 35 | 36 | ```yml 37 | _users: 38 | - 39 | username: admin 40 | password: clay 41 | provider: local 42 | auth: admin 43 | - 44 | username: <your full email address> # i.e.: user@clay.com 45 | provider: google # Can be either google, twitter, slack, ldap or cognito 46 | auth: admin # Can be either admin or write 47 | ``` 48 | 49 | Once you've done that, you can access edit mode from any page by adding `?edit=true` or by holding down `Shift` and typing `CLAY`. For example, navigating to http://localhost/_pages/sample-article.html?edit=true will grant you access to the edit interface. In the login screen, you can gain access to edit mode by setting `Username: admin` and `Password: clay`, or the credentials you set in `sample_users.yml`. 50 | 51 | ### Stopping & Clearing Out Data 52 | 53 | - `make burn` (Stops and removes all service containers) 54 | - `make clear-data` (Removes all local data) 55 | - `make clear-public` (Removes the `app/public` directory which is Express' static asset directory) 56 | 57 | ## What's Running? 58 | 59 | The project consists of four services running in Docker and Clay running on your host machine. 60 | 61 | - NGINX: locally it allows us to forward port 80 to Clay to make working with Clay easy. The current configuration is set to only route requests to `localhost`. If you want to change the host that clay listens to you'll need to update the NGINX config 62 | - Postgres: the primary data store for Clay data 63 | - Redis: locally this container is the cache for Postgres, the session store for PassportJS and the event bus for Clay 64 | - ElasticSearch: integrated for search functionality 65 | -------------------------------------------------------------------------------- /app/styleguides/_default/components/image.css: -------------------------------------------------------------------------------- 1 | @define-mixin imager $name, $imageWidth, $imageHeight, $inverseAspectRatio, $captionOffset: 0 { 2 | &.$(name) { 3 | padding-bottom: calc(($imageHeight/$imageWidth) * (100% - $(captionOffset)px)); 4 | width: calc(100% - $(captionOffset)px); 5 | } 6 | 7 | &.$(name).inset { 8 | padding-bottom: calc(($imageHeight/$imageWidth) * 100%); 9 | width: 100%; 10 | } 11 | 12 | &.$(name) .img-figure { 13 | width: calc(100% - $(captionOffset)px); 14 | 15 | &:before { 16 | padding-top: calc(($inverseAspectRatio) * 100%); 17 | } 18 | } 19 | 20 | &.$(name).inset .img-figure { 21 | width: 100%; 22 | } 23 | } 24 | 25 | /* 26 | Explicitly using a class name which does not match the component name as a class name just called `image` 27 | could have dangerous cascading issues. This will give some safety around accidentally influencing 28 | incorrect styles. 29 | */ 30 | .image-cmpt { 31 | display: flex; 32 | flex-direction: row; 33 | flex-wrap: wrap; 34 | margin: 40px 0; 35 | position: relative; 36 | z-index: 2; 37 | 38 | .image-link { 39 | color: #000; 40 | display: block; 41 | height: 100%; 42 | left: 0; 43 | position: absolute; 44 | top: 0; 45 | width: 100%; 46 | } 47 | 48 | &.inset { 49 | float: left; 50 | margin: 0 40px 0 -100px; 51 | width: 330px; 52 | } 53 | 54 | &.break-out { 55 | width: 900px; 56 | } 57 | 58 | &.square.inline, 59 | &.square.break-out, 60 | &.deep-vertical.inline, 61 | &.deep-vertical.break-out, 62 | &.vertical.inline, 63 | &.vertical.break-out { 64 | .image-cmpt-figcaption { 65 | align-self: flex-end; 66 | margin-left: 20px; 67 | max-width: 120px; 68 | word-break: break-word; 69 | } 70 | } 71 | 72 | &.horizontal { 73 | margin: 0 0 40px -100px; 74 | 75 | &.inset { 76 | margin: 0 40px 0 -100px; 77 | } 78 | } 79 | 80 | @media screen and (max-width: 1179.9px) { 81 | &.inset { 82 | margin: 0 40px 0 -60px; 83 | } 84 | 85 | &.horizontal { 86 | margin: 40px 0 40px -60px; 87 | } 88 | 89 | &.horizontal.inset { 90 | margin: 0 40px 0 -60px; 91 | } 92 | 93 | &.flex.break-out { 94 | margin: 0 0 40px -60px; 95 | } 96 | 97 | &.break-out { 98 | width: 700px; 99 | } 100 | } 101 | 102 | @media screen and (max-width: 767.9px) { 103 | margin: 24px 0; 104 | 105 | &.inset { 106 | float: none; 107 | margin: 24px 0; /* this is twice defined so that the precedence works with the table definition of this class */ 108 | width: 100%; 109 | } 110 | 111 | &.horizontal { 112 | margin: 24px 0; 113 | } 114 | 115 | &.horizontal.inset { 116 | margin: 24px 0; 117 | } 118 | 119 | &.flex.break-out { 120 | margin: 0; 121 | } 122 | 123 | &.break-out { 124 | width: 100%; 125 | } 126 | 127 | &.square.inline .image-cmpt-figcaption, 128 | &.square.break-out .image-cmpt-figcaption, 129 | &.deep-vertical.inline .image-cmpt-figcaption, 130 | &.deep-vertical.break-out .image-cmpt-figcaption, 131 | &.vertical.inline .image-cmpt-figcaption, 132 | &.vertical.break-out .image-cmpt-figcaption { 133 | margin: 6px 0 0; 134 | max-width: 100%; 135 | } 136 | } 137 | } 138 | 139 | .image-cmpt .image-container { 140 | .img-figure:before { 141 | content: ''; 142 | display: block; 143 | } 144 | 145 | .img-data { 146 | display: block; 147 | width: 100%; 148 | } 149 | 150 | &.bordered .img-data { 151 | border: 1px solid #bdbdbd; 152 | } 153 | 154 | &.flex { 155 | width: 100%; 156 | 157 | .img-figure { 158 | position: unset; 159 | } 160 | 161 | .image-wrapper { 162 | position: unset; 163 | } 164 | } 165 | 166 | @mixin imager horizontal, 700, 467, (4/6); 167 | @mixin imager square, 460, 460, (1/1), 140; 168 | @mixin imager vertical, 460, 575, (5/4), 140; 169 | @mixin imager deep-vertical, 460, 690, (6/4), 140; 170 | 171 | @media screen and (max-width: 767.9px) { 172 | @mixin imager horizontal, 335, 223, (4/6); 173 | @mixin imager square, 335, 335, (1/1); 174 | @mixin imager vertical, 335, 419, (5/4); 175 | @mixin imager deep-vertical, 335, 502, (6/4); 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /app/styleguides/_default/components/article.css: -------------------------------------------------------------------------------- 1 | $black: #000; 2 | $accent-color: #0c71fa; 3 | $attribution-gray: #767676; 4 | 5 | $fallback-stack: Helvetica, serif; 6 | $headline-stack: 'Helvetica', $fallback-stack; 7 | $grotesk-stack: Helvetica, $fallback-stack; 8 | $sans-serif-stack: Helvetica, Arial, sans-serif; 9 | $body-stack: Helvetica, serif; 10 | 11 | .article { 12 | counter-reset: annotated; 13 | margin: 20px 0 0; 14 | 15 | .article-header { 16 | margin: 0 0 20px; 17 | 18 | .bylines a.article-author:active, 19 | .bylines a.article-author:hover, 20 | .bylines a.article-author:focus { 21 | box-shadow: 0 1px 0 $accent-color; 22 | } 23 | 24 | .article-timestamp { 25 | color: #808080; 26 | font: 10px/1 $grotesk-stack; 27 | text-transform: uppercase; 28 | } 29 | 30 | .headline-primary { 31 | font-size: 48px; 32 | font-weight: bold; 33 | margin: 0 0 8px; 34 | } 35 | 36 | .headline-primary:last-child { 37 | margin: 0; 38 | } 39 | 40 | .bylines { 41 | font-size: 14px; 42 | letter-spacing: normal; 43 | } 44 | 45 | .bylines a { 46 | color: #808080; 47 | text-decoration: none; 48 | } 49 | } 50 | 51 | .lede-image-wrapper { 52 | margin-bottom: 20px 53 | } 54 | 55 | .lede-image-wrapper { 56 | img { 57 | display: block; 58 | margin-bottom: 7px; 59 | width: 100%; 60 | } 61 | 62 | .lede-image-data { 63 | position: relative; 64 | } 65 | } 66 | 67 | .attribution { 68 | color: $attribution-gray; 69 | font: 12px/16px $body-stack; 70 | 71 | a { 72 | box-shadow: 0 1px 0; 73 | color: inherit; 74 | text-decoration: none; 75 | } 76 | 77 | a:hover, 78 | a:focus { 79 | color: #ec2c00; 80 | } 81 | } 82 | 83 | .primary-area { 84 | position: relative; 85 | } 86 | 87 | .primary-area:before { 88 | content: ''; 89 | height: 100%; 90 | left: -20px; 91 | position: absolute; 92 | top: 0; 93 | width: 20px; 94 | } 95 | 96 | .counts { 97 | background: #f4f4f4; 98 | clear: both; 99 | font: 400 14px/16px $sans-serif-stack; 100 | margin: 20px 0; 101 | padding: 15px 20px; 102 | width: 100%; 103 | 104 | .initializing { 105 | color: #999; 106 | font-style: italic; 107 | } 108 | } 109 | } 110 | 111 | @media screen and (max-width: 767.9px) { 112 | .article { 113 | .lede-image-wrapper { 114 | margin: 0 0 24px; 115 | } 116 | } 117 | } 118 | 119 | @media screen and (min-width: 1180px) { 120 | .article { 121 | margin: 0; 122 | 123 | .lede-wrapper { 124 | align-items: stretch; 125 | display: flex; 126 | justify-content: space-between; 127 | margin: 0 0 26px; 128 | } 129 | 130 | .article-header .article-timestamp { 131 | font: 12px / 1.2 $grotesk-stack; 132 | letter-spacing: 2.56px; 133 | } 134 | 135 | .article-header .primary-area { 136 | display: flex; 137 | flex: 1 1 auto; 138 | flex-direction: column; 139 | justify-content: space-between; 140 | } 141 | 142 | .article-header .headline-primary { 143 | font-family: Helvetica; 144 | } 145 | 146 | .article-header .bylines .primary-bylines { 147 | font: 14px $headline-stack; 148 | } 149 | 150 | .article-content { 151 | margin: 0 auto; 152 | } 153 | 154 | .article-header.inset:not(.has-secondary-zone) .primary-area, 155 | .article-header.inline:not(.has-secondary-zone) .primary-area { 156 | max-width: calc(100% - 200px); 157 | } 158 | 159 | .article-header.inset.has-secondary-zone .primary-area, 160 | .article-header.inline.has-secondary-zone .primary-area { 161 | max-width: 720px; 162 | } 163 | 164 | /*inset lede styles*/ 165 | .lede-image-wrapper.inset { 166 | float: left; 167 | margin: 0 40px 12px -100px; 168 | width: 330px; 169 | } 170 | 171 | /*inline lede styles*/ 172 | .lede-image-wrapper.inline.square, 173 | .lede-image-wrapper.inline.horizontal { 174 | margin: 0 0 34px -100px; 175 | width: 700px; 176 | } 177 | 178 | .lede-image-wrapper.inline.vertical { 179 | margin: 0 0 34px; 180 | width: 600px; 181 | } 182 | } 183 | 184 | .kiln-edit-mode .article { 185 | .article-header { 186 | margin: 0; 187 | } 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /app/services/universal/sanitize.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const speakingurl = require('speakingurl'), 4 | he = require('he'), 5 | typogr = require('typogr'), 6 | headQuotes = require('headline-quotes'), 7 | striptags = require('striptags'), 8 | _isString = require('lodash/isString'), 9 | _isPlainObject = require('lodash/isPlainObject'), 10 | _isArray = require('lodash/isArray'), 11 | _mapValues = require('lodash/mapValues'), 12 | _toLower = require('lodash/toLower'), 13 | { fold } = require('fold-to-ascii'), 14 | NON_ALPHANUMERIC_RE = /[_\W]/g; 15 | 16 | /** 17 | * smarten headlines, curling quotes and replacing dashes and ellipses 18 | * @param {string} text 19 | * @returns {string} 20 | */ 21 | function toSmartHeadline(text) { 22 | return headQuotes(he.decode(text)) 23 | .replace('---', '—') // em-dash first 24 | .replace('--', '–') 25 | .replace('...', '…'); 26 | } 27 | 28 | /** 29 | * run typogr's smartypants on text, curling quotes and replacing dashes and ellipses 30 | * note: this is used for body text and teasers, NOT headlines 31 | * note: we have to decode quotes, then curl them, then decode them again 32 | * @param {string} text 33 | * @returns {string} 34 | */ 35 | function toSmartText(text) { 36 | return he.decode( 37 | typogr(he.decode(text)) 38 | .chain() 39 | .smartypants() 40 | .value() 41 | ); 42 | } 43 | 44 | /** 45 | * Removes all unicode from string 46 | * @param {string} str 47 | * @returns {string} 48 | */ 49 | function stripUnicode(str) { 50 | return str.replace(/[^A-Za-z 0-9\.,\?!@#\$%\^&\*\(\)-_=\+;:<>\/\\\|\}\{\[\]~]*/g, ''); 51 | } 52 | 53 | /** 54 | * remove all html stuff from a string 55 | * @param {string} str 56 | * @returns {string} 57 | */ 58 | function toPlainText(str) { 59 | // coerce all text into a string. Undefined stuff is just an empty string 60 | if (!_isString(str)) { 61 | return ''; 62 | } 63 | return he.decode(striptags(str.replace(/ /g, ' '))); 64 | } 65 | 66 | /** 67 | * remove EVERYTHING from the slug, then run it through speakingurl 68 | * @param {string} str 69 | * @returns {string} 70 | */ 71 | function cleanSlug(str) { 72 | return speakingurl(toPlainText(stripUnicode(str)), { 73 | custom: { 74 | _: '-' // convert underscores to hyphens 75 | } 76 | }); 77 | } 78 | 79 | /** 80 | * remove empty tags and rando whitespace 81 | * used when saving wysiwyg content 82 | * @param {string} str 83 | * @returns {string} 84 | */ 85 | function validateTagContent(str) { 86 | var noTags = striptags(str); 87 | 88 | // if a string ONLY contains tags, return emptystring. 89 | // this fixes some issues where browsers insert tags into empty 90 | // contenteditable elements, as well as some unrecoverable states where 91 | // users added rich text and then deleted it in a specific way that 92 | // preserved the tag, e.g. '<strong> </strong>' 93 | if (noTags === '' || noTags.match(/^\s+$/)) { 94 | return ''; 95 | } else { 96 | return str; // otherwise return the string with all tags and everything 97 | } 98 | } 99 | 100 | /** 101 | * Strip paragraph and line seperators from component data 102 | * @param {object|array|string} data 103 | * @returns {object|array|string} sanitized data 104 | */ 105 | function recursivelyStripSeperators(data) { 106 | if (_isPlainObject(data)) { 107 | return _mapValues(data, recursivelyStripSeperators); 108 | } else if (_isArray(data)) { 109 | return data.map(recursivelyStripSeperators); 110 | } else if (_isString(data)) { 111 | return data.replace(/(\u2028|\u2029)/g, ''); 112 | } 113 | return data; 114 | } 115 | 116 | /** 117 | * Removes all non alphanumeric characters from a string 118 | * @param {string} str 119 | * @returns {string} 120 | */ 121 | function removeNonAlphanumericCharacters(str = '') { 122 | return str.replace(NON_ALPHANUMERIC_RE, ''); 123 | } 124 | 125 | /** 126 | * normalizeName 127 | * 128 | * lowercases and converts alphabetic, numeric, and symbolic Unicode characters 129 | * which are not in the first 127 ASCII characters (the "Basic Latin" Unicode block) 130 | * into their ASCII equivalents 131 | * 132 | * @param {String} name a string to normalize 133 | * @returns {String} 134 | */ 135 | function normalizeName(name) { 136 | return fold(_toLower(name.trim())); 137 | } 138 | 139 | module.exports.toSmartHeadline = toSmartHeadline; 140 | module.exports.toSmartText = toSmartText; 141 | module.exports.stripUnicode = stripUnicode; 142 | module.exports.toPlainText = toPlainText; 143 | module.exports.cleanSlug = cleanSlug; 144 | module.exports.validateTagContent = validateTagContent; 145 | module.exports.recursivelyStripSeperators = recursivelyStripSeperators; 146 | module.exports.removeNonAlphanumericCharacters = removeNonAlphanumericCharacters; 147 | module.exports.normalizeName = normalizeName; 148 | -------------------------------------------------------------------------------- /bootstrap-starter-data/_components.yml: -------------------------------------------------------------------------------- 1 | _components: 2 | # meta components 3 | meta-title: 4 | instances: 5 | new: 6 | title: '' 7 | new-author: 8 | title: '' 9 | homepage: 10 | title: '' 11 | meta-url: 12 | instances: 13 | new: 14 | url: '' 15 | new-author: 16 | url: '' 17 | homepage: 18 | url: '' 19 | meta-description: 20 | instances: 21 | new: 22 | description: '' 23 | new-author: 24 | description: '' 25 | homepage: 26 | description: '' 27 | meta-icons: 28 | instances: 29 | claydemo: 30 | siteName: 'Clay Demo' 31 | favicon: https://localhost/media/sites/claydemo/favicon.ico 32 | meta-image: 33 | instances: 34 | new: 35 | imageUrl: '' 36 | new-author: 37 | imageUrl: '' 38 | tag: 39 | imageUrl: '' 40 | homepage: 41 | imageUrl: '' 42 | meta-authors: 43 | instances: 44 | new: 45 | authors: [] 46 | new-author: 47 | authors: [] 48 | homepage: 49 | authors: [] 50 | meta-keywords: 51 | instances: 52 | new: 53 | tags: [] 54 | new-author: 55 | tags: [] 56 | homepage: 57 | tags: [] 58 | meta-site: 59 | instances: 60 | article: 61 | twitter: '@claydemo' 62 | facebook: 'claydemo' 63 | facebookID: 'claydemo' 64 | vertical: '' 65 | siteName: Clay Demo 66 | ogType: website 67 | pageType: Article 68 | author: 69 | twitter: '@claydemo' 70 | facebook: 'claydemo' 71 | facebookID: 'claydemo' 72 | vertical: '' 73 | siteName: Clay Demo 74 | ogType: website 75 | pageType: Author 76 | tag: 77 | twitter: '@claydemo' 78 | facebook: 'claydemo' 79 | facebookID: 'claydemo' 80 | vertical: '' 81 | siteName: Clay Demo 82 | ogType: website 83 | pageType: Tag Page 84 | homepage: 85 | twitter: '@claydemo' 86 | facebook: 'claydemo' 87 | facebookID: 'claydemo' 88 | vertical: '' 89 | siteName: Clay Demo 90 | ogType: website 91 | pageType: Home Page 92 | 93 | # layout components 94 | header: 95 | instances: 96 | claydemo: 97 | componentVariation: header 98 | mobileCTA: Support Us 99 | mobileCTALink: 'https://www.google.com' 100 | footer: 101 | instances: 102 | claydemo: 103 | componentVariation: footer 104 | footerLinks: [] 105 | paragraph: 106 | instances: 107 | new: 108 | text: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nam vel suscipit quam. Phasellus sagittis nibh a purus euismod ultricies. Pellentesque tempus nec lacus in rhoncus. Sed ullamcorper augue ac augue ornare dictum. Nam nec imperdiet nisi, at dapibus magna. Aliquam id semper mi, et blandit ex. Quisque erat mi, commodo in ante vitae, efficitur pretium ligula. In consectetur diam vel sem dapibus, at mollis erat viverra. Cras dolor nisi, dignissim non semper eu, porta et turpis. Nam malesuada est justo, ac molestie ex auctor vitae. Nullam eget lectus vitae mauris cursus semper id eget sapien.' 109 | tags: 110 | instances: 111 | new: 112 | items: [] 113 | sample: 114 | items: 115 | - 116 | text: clay 117 | - 118 | text: cute puppy 119 | code-sample: 120 | instances: 121 | new: 122 | code: '' 123 | language: 'javascript' 124 | html: '' 125 | divider: 126 | instances: 127 | new: 128 | title: '' 129 | list: 130 | instances: 131 | new: 132 | items: [] 133 | sass: '' 134 | listType: '' 135 | pull-quote: 136 | instances: 137 | new: 138 | quote: '' 139 | hasQuoteMarks: false 140 | attribution: '' 141 | subheader: 142 | instances: 143 | new: 144 | text: '' 145 | type: 'h2' 146 | link: '' 147 | article: 148 | instances: 149 | new-standard: 150 | headline: '' 151 | feedImgUrl: '' 152 | ledeUrl: '' 153 | byline: 154 | - 155 | text: Clay 156 | ledeCaption: '' 157 | ledeCredit: '' 158 | slug: '' 159 | content: 160 | - 161 | _ref: /_components/paragraph/instances/new 162 | tags: 163 | _ref: /_components/tags/instances/new 164 | sample: 165 | headline: 'Clay Starter Article' 166 | feedImgUrl: 'https://s.abcnews.com/images/Lifestyle/puppy-ht-3-er-170907_4x3_992.jpg' 167 | ledeUrl: 'https://s.abcnews.com/images/Lifestyle/puppy-ht-3-er-170907_4x3_992.jpg' 168 | byline: 169 | - 170 | text: Clay 171 | ledeCaption: 'A cute puppy' 172 | ledeCredit: '' 173 | slug: '' 174 | content: 175 | - 176 | _ref: /_components/paragraph/instances/new 177 | tags: 178 | _ref: /_components/tags/instances/sample 179 | -------------------------------------------------------------------------------- /app/services/server/publish-utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const _ = require('lodash'), 4 | db = require('amphora-storage-postgres'), 5 | { getComponentName } = require('clayutils'), 6 | sanitize = require('../universal/sanitize'), 7 | utils = require('../universal/utils'), 8 | bluebird = require('bluebird'), 9 | log = require('../universal/log').setup({ file: __filename }), 10 | canonicalProtocol = 'http', // TODO: this is a HUGE assumption, make it not be an assumption 11 | canonicalPort = process.env.PORT || 3001; 12 | 13 | /** 14 | * Checks provided ref to determine whether it is a main component (article or lede-video) 15 | * @param {string} ref 16 | * @param {object} mainComponentNames 17 | * @returns {boolean} 18 | */ 19 | function isMainComponentReference(ref, mainComponentNames) { 20 | let match = false; 21 | 22 | if (_.isString(ref)) { 23 | _.each(mainComponentNames, componentRef => { 24 | if (getComponentName(ref) === componentRef) match = true; 25 | }); 26 | } 27 | 28 | return match; 29 | } 30 | 31 | /** 32 | * Gets the first reference to a main component within a page (if it exists) 33 | * @param {object} page 34 | * @param {object} mainComponentNames 35 | * @returns {string|undefined} 36 | */ 37 | function getComponentReference(page, mainComponentNames) { 38 | for (let key in page) { 39 | if (page.hasOwnProperty(key)) { 40 | let value = page[key]; 41 | 42 | if (Array.isArray(value)) { 43 | let result = _.find(value, o => isMainComponentReference(o, mainComponentNames)); 44 | 45 | if (result) { 46 | return result; 47 | } 48 | } 49 | } 50 | } 51 | 52 | // If we reach this point we didn't find one of the main components on the page 53 | // and an implicity `undefined` will be returned 54 | } 55 | 56 | /** 57 | * @param {object} mainComponent 58 | */ 59 | function guaranteeHeadline(mainComponent) { 60 | if (!mainComponent.headline) { 61 | throw new Error('Client: missing primary headline'); 62 | } 63 | } 64 | 65 | /** 66 | * Logic about which date to use for a published article 67 | * @param {object} latest 68 | * @param {object} [published] 69 | * @returns {string} 70 | */ 71 | function getPublishDate(latest, published) { 72 | if (_.isObject(latest) && latest.date) { 73 | // if we're given a date, use it 74 | return latest.date; 75 | } else if (_.isObject(published) && published.date) { 76 | // if there is only a date on the published version, use it 77 | return published.date; 78 | } else { 79 | return new Date().toISOString(); 80 | } 81 | } 82 | 83 | /** 84 | * @param {object} component 85 | * @param {object} publishedComponent 86 | * @param {object} locals 87 | */ 88 | function guaranteeLocalDate(component, publishedComponent, locals) { 89 | // if date is defined in the component, remember it. 90 | if (!locals.date) { 91 | locals.date = getPublishDate(component, publishedComponent); 92 | } 93 | } 94 | 95 | /** 96 | * gets a main component from the db by its ref, ensuring primary headline and date exist 97 | * @param {string} componentReference 98 | * @param {object} locals 99 | * @returns {Promise} 100 | */ 101 | function getMainComponentFromRef(componentReference, locals) { 102 | return bluebird 103 | .all([ 104 | db.get(componentReference).catch(error => { 105 | log('error', `Failure to fetch component at ${componentReference}`); 106 | throw error; 107 | }), 108 | db.get(componentReference + '@published').catch(_.noop) 109 | ]) 110 | .spread((component, publishedComponent) => { 111 | guaranteeHeadline(component); 112 | guaranteeLocalDate(component, publishedComponent, locals); 113 | return component; 114 | }); 115 | } 116 | 117 | /** 118 | * Return the URL prefix of a site. 119 | * @param {Object} site 120 | * @returns {String} 121 | */ 122 | function getUrlPrefix(site) { 123 | const proto = (site && site.proto) || canonicalProtocol, 124 | port = (site && site.port) || canonicalPort, 125 | urlPrefix = utils.uriToUrl(site.prefix, { site: { protocol: proto, port: port } }); 126 | 127 | return _.trimEnd(urlPrefix, '/'); // never has a trailing slash; newer lodash uses `trimEnd` 128 | } 129 | 130 | /** 131 | * returns an object to be consumed by url patterns 132 | * @param {object} component 133 | * @param {object} locals 134 | * @returns {{prefix: string, section: string, yyyy: string, mm: string, slug: string}} 135 | * @throws {Error} if there's no date, slug, or prefix 136 | */ 137 | function getUrlOptions(component, locals) { 138 | const urlOptions = {}; 139 | 140 | urlOptions.prefix = getUrlPrefix(locals.site); 141 | urlOptions.slug = component.slug || sanitize.cleanSlug(component.headline); 142 | 143 | if (!(locals.site && locals.date && urlOptions.slug)) { 144 | throw new Error( 145 | `Client: Cannot generate a canonical url at prefix: ${locals.site.prefix} slug: ${ 146 | urlOptions.slug 147 | } date: ${locals.date}` 148 | ); 149 | } 150 | 151 | return urlOptions; 152 | } 153 | 154 | module.exports.getComponentReference = getComponentReference; 155 | module.exports.getMainComponentFromRef = getMainComponentFromRef; 156 | module.exports.getUrlOptions = getUrlOptions; 157 | module.exports.getUrlPrefix = getUrlPrefix; 158 | module.exports.getPublishDate = getPublishDate; 159 | // URL patterns below need to be handled by the site's index.js 160 | module.exports.slugUrlPattern = o => `${o.prefix}/article/${o.slug}.html`; // http://localhost/article/x.html 161 | -------------------------------------------------------------------------------- /app/services/universal/rest.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const getJSONP = require('jsonp-client'), 3 | _defaults = require('lodash/defaults'); 4 | 5 | // global 6 | require('isomorphic-fetch'); 7 | 8 | /** 9 | * if you're doing api calls to Clay, authenticate on the server/client side 10 | * @param {object} payload 11 | * @return {object} 12 | */ 13 | function authenticate(payload) { 14 | // the access key is stringified at runtime 15 | payload.headers.Authorization = 'Token ' + process.env.CLAY_ACCESS_KEY; 16 | payload.credentials = 'same-origin'; 17 | return payload; 18 | } 19 | 20 | /** 21 | * add fake callback for the client-side code 22 | * @returns {string} 23 | */ 24 | function addFakeCallback() { 25 | return ('&callback=cb' + Math.random()).replace('.', ''); 26 | } 27 | 28 | /** 29 | * check status after doing http calls 30 | * note: this is necessary because fetch doesn't reject on errors, 31 | * only on network failure or incomplete requests 32 | * @param {object} res 33 | * @return {object} 34 | * @throws {Error} on non-2xx status 35 | */ 36 | function checkStatus(res) { 37 | if (res.status >= 200 && res.status < 300) { 38 | return res; 39 | } else { 40 | const error = new Error(res.statusText); 41 | 42 | error.response = res; 43 | throw error; 44 | } 45 | } 46 | 47 | /** 48 | * GET 49 | * @param {string} url 50 | * @param {object} opts See https://github.github.io/fetch/#options 51 | * @return {Promise} 52 | */ 53 | module.exports.get = function(url, opts) { 54 | const conf = _defaults({ method: 'GET' }, opts); 55 | 56 | return fetch(url, conf) 57 | .then(checkStatus) 58 | .then(function(res) { 59 | return res.json(); 60 | }); 61 | }; 62 | 63 | /** 64 | * GET JSONP (from a third-party api that requires jsonp) 65 | * @param {string} url 66 | * @return {Promise} 67 | */ 68 | module.exports.getJSONP = function(url) { 69 | return new Promise(function(resolve, reject) { 70 | // note: this handles its own status checking 71 | getJSONP(url + addFakeCallback(), function(err, res) { 72 | if (err) { 73 | reject(err); 74 | } else { 75 | resolve(res); 76 | } 77 | }); 78 | }); 79 | }; 80 | 81 | /** 82 | * GET HTML/text 83 | * @param {string} url 84 | * @return {Promise} 85 | */ 86 | module.exports.getHTML = function(url) { 87 | return fetch(url) 88 | .then(checkStatus) 89 | .then(function(res) { 90 | return res.text(); 91 | }); 92 | }; 93 | 94 | /** 95 | * PUT 96 | * @param {string} url 97 | * @param {object|array} data 98 | * @param {Boolean} isAuthenticated set to true if making PUT requests to Clay 99 | * @return {Promise} 100 | */ 101 | module.exports.put = function(url, data, isAuthenticated) { 102 | const payload = { 103 | method: 'PUT', 104 | headers: { 105 | 'Content-Type': 'application/json' 106 | }, 107 | body: JSON.stringify(data) 108 | }; 109 | 110 | if (isAuthenticated) { 111 | authenticate(payload); 112 | } 113 | 114 | return fetch(url, payload) 115 | .then(checkStatus) 116 | .then(function(res) { 117 | return res.json(); 118 | }); 119 | }; 120 | 121 | /** 122 | * PUT using a form 123 | * @param {string} url 124 | * @param {object|array} data 125 | * @param {Boolean} isAuthenticated set to true if making PUT requests to Clay 126 | * @return {Promise} 127 | */ 128 | module.exports.putForm = function(url, data = {}, isAuthenticated) { 129 | const formData = new FormData(), 130 | payload = {}; 131 | 132 | Object.keys(data).forEach(key => { 133 | formData.append(key, data[key]); 134 | }); 135 | 136 | payload.method = 'PUT'; 137 | payload.body = formData; 138 | 139 | if (isAuthenticated) { 140 | authenticate(payload); 141 | } 142 | 143 | return fetch(url, payload) 144 | .then(checkStatus) 145 | .then(function(res) { 146 | return res.json(); 147 | }); 148 | }; 149 | 150 | /** 151 | * POST 152 | * note: primarily used for elastic search 153 | * @param {string} url 154 | * @param {object|array} data 155 | * @param {Boolean} isAuthenticated set to true if making POST requests to Clay 156 | * @return {Promise} 157 | */ 158 | module.exports.post = function(url, data, isAuthenticated) { 159 | const payload = { 160 | method: 'POST', 161 | headers: { 162 | 'Content-Type': 'application/json' 163 | }, 164 | body: JSON.stringify(data) 165 | }; 166 | 167 | if (isAuthenticated) { 168 | authenticate(payload); 169 | } 170 | 171 | return fetch(url, payload) 172 | .then(checkStatus) 173 | .then(function(res) { 174 | return res.json(); 175 | }); 176 | }; 177 | 178 | module.exports.patch = function(url, data, isAuthenticated) { 179 | const payload = { 180 | method: 'PATCH', 181 | headers: { 182 | 'Content-Type': 'application/json' 183 | }, 184 | body: JSON.stringify(data) 185 | }; 186 | 187 | if (isAuthenticated) { 188 | authenticate(payload); 189 | } 190 | 191 | return fetch(url, payload) 192 | .then(checkStatus) 193 | .then(function(res) { 194 | return res.json(); 195 | }); 196 | }; 197 | 198 | /** 199 | * PURGE 200 | * primarily used for clearing cache in NGINX 201 | * @param {string} url 202 | * @return {Promise} 203 | */ 204 | module.exports.purge = function(url) { 205 | const payload = { 206 | method: 'PURGE', 207 | headers: { 208 | 'Content-Type': 'application/json', 209 | Method: 'PURGE' 210 | } 211 | }; 212 | 213 | return fetch(url, payload) 214 | .then(checkStatus) 215 | .then(function(res) { 216 | return res.json(); 217 | }); 218 | }; 219 | -------------------------------------------------------------------------------- /app/services/universal/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | const _isArray = require('lodash/isArray'), 3 | _isObject = require('lodash/isObject'), 4 | _isEmpty = require('lodash/isEmpty'), 5 | _isString = require('lodash/isString'), 6 | _isNull = require('lodash/isNull'), 7 | _isUndefined = require('lodash/isUndefined'), 8 | _get = require('lodash/get'), 9 | _parse = require('url-parse'), 10 | publishedVersionSuffix = '@published', 11 | kilnUrlParam = '¤tUrl='; 12 | 13 | /** 14 | * determine if a field is empty 15 | * @param {*} val 16 | * @return {Boolean} 17 | */ 18 | function isFieldEmpty(val) { 19 | if (_isArray(val) || _isObject(val)) { 20 | return _isEmpty(val); 21 | } else if (_isString(val)) { 22 | return val.length === 0; // emptystring is empty 23 | } else if (_isNull(val) || _isUndefined(val)) { 24 | return true; // null and undefined are empty 25 | } else { 26 | // numbers, booleans, etc are never empty 27 | return false; 28 | } 29 | } 30 | 31 | /** 32 | * convenience function to determine if a field exists and has a value 33 | * @param {*} val 34 | * @return {Boolean} 35 | */ 36 | function has(val) { 37 | return !isFieldEmpty(val); 38 | } 39 | 40 | /** 41 | * replace version in uri 42 | * e.g. when fetching @published data, or previous component data 43 | * @param {string} uri 44 | * @param {string} [version] defaults to latest 45 | * @return {string} 46 | */ 47 | function replaceVersion(uri, version) { 48 | if (!_isString(uri)) { 49 | throw new TypeError('Uri must be a string, not ' + typeof uri); 50 | } 51 | 52 | if (version) { 53 | uri = uri.split('@')[0] + '@' + version; 54 | } else { 55 | // no version is still a kind of version 56 | uri = uri.split('@')[0]; 57 | } 58 | 59 | return uri; 60 | } 61 | 62 | /** 63 | * generate a url from a uri (and some site data) 64 | * @param {string} uri 65 | * @param {object} locals 66 | * @return {string} 67 | */ 68 | function uriToUrl(uri, locals) { 69 | const protocol = _get(locals, 'site.protocol') || 'http', 70 | port = _get(locals, 'site.port'), 71 | parsed = _parse(`${protocol}://${uri}`); 72 | 73 | if (port !== 80) { 74 | parsed.set('port', port); 75 | } 76 | 77 | return parsed.href; 78 | } 79 | 80 | /** 81 | * generate a uri from a url 82 | * @param {string} url 83 | * @return {string} 84 | */ 85 | function urlToUri(url) { 86 | const parsed = _parse(url); 87 | 88 | return `${parsed.hostname}${parsed.pathname}`; 89 | } 90 | 91 | /** 92 | * Make sure start is defined and within a justifiable range 93 | * 94 | * @param {int} n 95 | * @returns {int} 96 | */ 97 | function formatStart(n) { 98 | var min = 0, 99 | max = 100000000; 100 | 101 | if (typeof n === 'undefined' || Number.isNaN(n) || n < min || n > max) { 102 | return 0; 103 | } else { 104 | return n; 105 | } 106 | } 107 | /* 108 | * 109 | * @param {object} locals 110 | * @param {string} [locals.site.protocol] 111 | * @param {string} locals.site.host 112 | * @param {string} [locals.site.port] 113 | * @param {string} [locals.site.path] 114 | * @returns {string} e.g. `http://localhost/somesite` 115 | */ 116 | function getSiteBaseUrl(locals) { 117 | const site = locals.site || {}, 118 | protocol = site.protocol || 'http', 119 | host = site.host, 120 | port = (site.port || '80').toString(), 121 | path = site.path || ''; 122 | 123 | return `${protocol}://${host}${port === '80' ? '' : ':' + port}${path}`; 124 | } 125 | 126 | /** 127 | * 128 | * @param {string} uri 129 | * @returns {boolean} 130 | */ 131 | function isPublishedVersion(uri) { 132 | return uri.indexOf(publishedVersionSuffix) === uri.length - 10; 133 | } 134 | 135 | /** 136 | * takes a uri and always returns the published version of that uri 137 | * @param {string} uri 138 | * @returns {string} 139 | */ 140 | function ensurePublishedVersion(uri) { 141 | return isPublishedVersion(uri) ? uri : uri.split('@')[0] + publishedVersionSuffix; 142 | } 143 | 144 | /** 145 | * checks if uri is an instance of a component 146 | * @param {string} uri 147 | * @returns {boolean} 148 | */ 149 | function isInstance(uri) { 150 | return uri.indexOf('/instances/') > -1; 151 | } 152 | 153 | /** 154 | * kiln sometimes stores the url in a query param 155 | * @param {string} url 156 | * @returns {string} 157 | */ 158 | function kilnUrlToPageUrl(url) { 159 | return url.indexOf(kilnUrlParam) > -1 ? decodeURIComponent(url.split(kilnUrlParam).pop()) : url; 160 | } 161 | 162 | /** 163 | * removes query params and hashes 164 | * e.g. `http://canonicalurl?utm-source=facebook#heading` becomes `http://canonicalurl` 165 | * @param {string} url 166 | * @returns {string} 167 | */ 168 | function urlToCanonicalUrl(url) { 169 | return kilnUrlToPageUrl(url) 170 | .split('?')[0] 171 | .split('#')[0]; 172 | } 173 | 174 | /** 175 | * prefixes a given elastic index depending on the current environment 176 | * e.g. `published-articles` becomes `local_published-articles` 177 | * @param {string} indexString 178 | * @returns {string} 179 | */ 180 | function prefixElasticIndex(indexString) { 181 | const prefix = process.env.ELASTIC_PREFIX; 182 | 183 | return prefix 184 | ? indexString 185 | .split(',') 186 | .map(index => `${prefix}_${index}`.trim()) 187 | .join(',') 188 | : indexString; 189 | } 190 | 191 | module.exports.isFieldEmpty = isFieldEmpty; 192 | module.exports.has = has; 193 | module.exports.replaceVersion = replaceVersion; 194 | module.exports.uriToUrl = uriToUrl; 195 | module.exports.urlToUri = urlToUri; 196 | module.exports.formatStart = formatStart; 197 | module.exports.getSiteBaseUrl = getSiteBaseUrl; 198 | module.exports.isPublishedVersion = isPublishedVersion; 199 | module.exports.ensurePublishedVersion = ensurePublishedVersion; 200 | module.exports.isInstance = isInstance; 201 | module.exports.urlToCanonicalUrl = urlToCanonicalUrl; 202 | module.exports.prefixElasticIndex = prefixElasticIndex; 203 | -------------------------------------------------------------------------------- /app/sites/claydemo/media/logo.svg: -------------------------------------------------------------------------------- 1 | <svg width="225" height="81" xmlns="http://www.w3.org/2000/svg"> 2 | <g fill="none" fill-rule="evenodd"> 3 | <path d="M87.6426136 70.8706169c-12.0309253 0-19.575-7.8297078-19.575-18.1439124V18.638474c0-10.31274348 7.5440747-18.14245127 19.575-18.14245127 12.0309254 0 18.8116074 7.82970779 18.8116074 18.14245127v6.3971591c0 1.3375812-1.146916 2.4823052-2.483036 2.4823052h-8.2124999c-1.3375812 0-2.4823052-1.144724-2.4823052-2.4823052V18.638474c0-3.151461-1.7189124-6.3978896-5.6337663-6.3978896-4.2976461 0-6.1107954 3.2464286-6.1107954 6.3978896v34.0882305c0 3.1514611 1.8131493 6.3986202 6.1107954 6.3986202 3.9148539 0 5.6337663-3.2464286 5.6337663-6.3986202v-6.397159c0-1.3361202 1.144724-2.4823052 2.4823052-2.4823052h8.2124999c1.33612 0 2.483036 1.146185 2.483036 2.4823052v6.397159c0 10.3142046-6.780682 18.1439124-18.8116074 18.1439124zm51.3270294-1.1461851h-25.971429c-1.338311 0-2.482305-1.1469156-2.482305-2.4823052V3.93311688c0-1.33758117 1.144724-2.38733766 2.482305-2.38733766h8.593831c.634822-.00657467 1.245536.24326299 1.694806.69180195.448539.44926948.698376 1.05998377.691802 1.69480519V55.5925325c0 1.3368506 1.143993 2.3873376 2.482305 2.3873376h12.508685c1.335389 0 2.481575 1.1454546 2.481575 2.4823052v6.7799513c.00073 1.3353896-1.146186 2.4823052-2.481575 2.4823052zm29.978328-50.1311688c-.094237-.6691559-.669156-1.0497565-1.146185-1.0497565-.47703 0-1.049757.3806006-1.049757 1.0497565l-3.055032 22.2494318c-.191396 1.3353896.666964 2.3873377 2.099513 2.3873377h4.10625c1.33612 0 2.101704-1.0512176 2.004545-2.3873377l-2.959334-22.2494318zm19.194399 50.1311688h-8.69026c-1.432548 0-2.673701-1.050487-2.865097-2.3887987l-2.006007-10.7897727c-.285633-1.3361201-1.526785-2.4823052-2.865097-2.4823052h-7.733279c-1.432549 0-2.673701 1.1461851-2.865098 2.4823052l-2.004545 10.7897727c-.286364 1.3383117-1.527516 2.3887987-2.865097 2.3887987h-8.688799c-1.432549 0-2.196672-1.050487-1.910309-2.3887987l13.559903-63.40324674c.285633-1.33758117 1.526786-2.38733766 2.959334-2.38733766h11.363962c1.33612 0 2.673701 1.04975649 2.960064 2.38733766l13.559173 63.40324674c.287094 1.3390422-.572728 2.3887987-1.908848 2.3887987zm23.847809-32.6578734c-.191397.6691559-.47703 1.6239448-.669156 2.4844968-.094237.8583604-.189935 1.6217532-.189935 2.2909091v25.4001623c0 1.3353896-1.050487 2.4823052-2.483036 2.4823052h-8.594562c-1.335389 0-2.481574-1.1469156-2.481574-2.4823052V41.7469968c0-.7648539-.094968-1.6239449-.286364-2.5780033-.095698-.7641234-.287094-1.6239448-.571997-2.1966721L184.585471 3.83668831c-.382792-1.2411526.286363-2.29090909 1.624675-2.29090909h7.830438c1.81315 0 2.959335.95478896 3.245698 2.29090909l6.015828 20.91258119c.190666.6691558.666965.9555195 1.049757.9555195.47776 0 .859091-.2863637 1.050487-.9555195l6.111526-20.91258119c.382792-1.33612013 1.337581-2.29090909 3.15073-2.29090909h7.926867c1.431088 0 2.099513 1.04975649 1.622484 2.29090909L211.990179 37.0665584z" fill="#A59B8B"/> 4 | <g transform="translate(0 1.461039)"> 5 | <path d="M31.9383117 49.6358766c-.0701299.2060065-.1848214.5771104-.3272727 1.0979708-.2344968.8532468-.466802 1.8321429-.6830358 2.925-1.4778409 7.4659091-1.4778409 15.7565747.9905845 24.076461.2615259.8897728 1.1900162 1.3931007 2.0717532 1.1293832.881737-.2651786 1.3828734-1.2002435 1.1198864-2.0885552-2.2967533-7.7362013-2.2967533-15.4921267-.9138799-22.4598215.1979708-1.0110389.412013-1.9110389.6260552-2.6861201.074513-.2688312.1409903-.5018669.2016234-.6969156.0328734-.1117695.0569805-.1818993.0664772-.2111201.2958604-.8751623-.1680194-1.8306818-1.0373376-2.1301948-.8729708-.2987825-1.8204546.1672889-2.117776 1.0446428h.0029221v-.0007305zm21.5335227 1.087013c.0102273.0284903.0328734.1000812.0664773.2118507.0613636.1943181.1285714.4266233.2008928.6939935.2133117.7758117.4273539 1.6765422.6275163 2.6861201 1.3806818 6.9691558 1.3806818 14.7243506-.9138799 22.462013-.2651786.8883117.2344968 1.8226461 1.1184253 2.0892857.881737.262987 1.8109578-.2410714 2.0746754-1.1293831 2.4669642-8.3228085 2.4669642-16.6142046.9883928-24.0793831-.2147727-1.0943182-.4485389-2.0717533-.6815747-2.925-.1431818-.5223215-.2571428-.8919643-.3272727-1.0972403-.2965909-.8773539-1.2477273-1.3441558-2.1163149-1.0453734-.8707792.299513-1.3339286 1.2564935-1.0373377 2.1331169zM3.61461039 52.4388799c.31339286-6.8398539 3.09155844-11.1090098 7.57183441-13.5051137 1.6378247-.8766233 3.4056818-1.4427759 5.1866883-1.7532467.6348214-.1095779 1.2301948-.1789773 1.7656656-.2176948.3331169-.0226461.5800325-.0299513.7232143-.0284903h-.0021916c.9197241.0153409 1.6794643-.7210227 1.6955357-1.6487825.0160715-.9277597-.715909-1.6911525-1.6356331-1.708685h-.0277597c-.2271916 0-.5595779.0073052-.9818182.0372565-.6472403.0445617-1.3543831.1278409-2.1068182.2586039-2.1068182.3681818-4.2070617 1.0380682-6.18311686 2.0958604-5.50081169 2.9439935-8.96712662 8.2709415-9.3375 16.3161526-.0409091.9255681.67061688 1.7116071 1.58961039 1.7532467.91972402.0438312 1.69918831-.6720779 1.74082792-1.5991071h.00146104zm86.09099021-.1541396c-.3703733-8.0459416-3.8344967-13.3721591-9.3375-16.3161526-1.9760551-1.0570617-4.0755681-1.7284091-6.1801948-2.0951299-.7531655-.130763-1.4610389-.2140422-2.1082792-.2586039-.4222402-.0299513-.7531656-.0372565-.9825487-.0372565h-.0248376c-.9211851.0175325-1.6546267.7809253-1.6385552 1.7086851.0160714.9270292.7758117 1.6641233 1.6955357 1.6480519-.0051137 0-.0051137 0 0 0 .1417208-.0021915.3879058.0051137.7195617.0270292.5383928.0372565 1.1323052.1081169 1.7678571.2191559 1.7788149.3112013 3.5488636.8751623 5.1866883 1.7532467 4.480276 2.3946429 7.2584416 6.6652598 7.572565 13.5051137.0416396.9255682.8218344 1.6422078 1.7408279 1.5983766.919724-.0423701 1.6297889-.8276786 1.5881493-1.7532468l.0007305.0007306z" fill="#C8B585"/> 6 | <path d="M44.6902597 64.4515422c20.4333604 0 36.9986202-6.6119318 36.9986202-27.1899351C81.6896104 16.6814123 61.8823052 0 44.6902597 0 27.4989448 0 7.69163961 16.6814123 7.69163961 37.2608766S24.2561688 64.4515422 44.6902597 64.4515422z" fill="#DDCFAC"/> 7 | <path d="M41.0391234 40.3794643c.2425324 1.5983766 1.6005682 2.7935065 3.2106331 2.7935065 1.611526 0 2.9710227-1.198052 3.2106331-2.7978896.0423701-.2768669-.1482954-.5325487-.4200487-.5719968-.2717532-.0445617-.5274351.1453734-.5676136.4193182-.1680195 1.1133117-1.1111202 1.9446429-2.2229708 1.9446429-1.1111201 0-2.0534903-.8306007-2.2222403-1.9402598-.0423701-.2739448-.2965909-.4624188-.5676136-.4207792-.2739448.0401786-.4616883.2987825-.4200487.5719967v.0014611h-.0007305z" fill="#907E59"/> 8 | <ellipse fill="#907E59" cx="27.6457792" cy="29.0681006" rx="2.66566558" ry="4.1961039"/> 9 | <ellipse fill="#907E59" cx="60.9866883" cy="29.0681006" rx="2.66566558" ry="4.1961039"/> 10 | <path d="M52.967776 1.24334416C65.4150974 6.92678571 76.1303571 19.8233766 76.1303571 34.8231331c0 20.2558442-16.3409902 26.7647727-36.4982142 26.7647727-8.2906656 0-15.9333604-1.1030844-22.0609578-3.8074675 6.7741071 4.9280844 16.6112824 6.8274351 27.5617694 6.8274351 20.4318994 0 36.9964286-6.6104708 36.9964286-27.1892046C82.1286526 19.9263799 67.8170455 5.25170455 52.967776 1.24334416" fill="#C8B585"/> 11 | </g> 12 | </g> 13 | </svg> 14 | --------------------------------------------------------------------------------