├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github └── workflows │ ├── codeql-analysis.yml │ ├── dependency-review.yml │ ├── lint.yml │ └── nodejs.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── app.json ├── lib ├── auto-config.js ├── config.js ├── handler.js ├── main.js ├── sites.js └── utils.js ├── package-lock.json ├── package.json ├── renovate.json ├── sample.env └── test ├── .eslintrc ├── handler.spec.js └── test.env /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = lf 5 | insert_final_newline = true 6 | indent_style = space 7 | indent_size = 2 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /coverage/**/* 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@voxpelli", 3 | "root": true 4 | } 5 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | schedule: 9 | - cron: '0 10 * * 0' 10 | 11 | jobs: 12 | analyze: 13 | uses: voxpelli/ghatemplates/.github/workflows/codeql-analysis.yml@main 14 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | name: 'Dependency Review' 2 | 3 | on: [pull_request] 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | dependency-review: 10 | uses: voxpelli/ghatemplates/.github/workflows/dependency-review.yml@main 11 | 12 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Linting 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - '*' 9 | pull_request: 10 | branches: 11 | - main 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | lint: 18 | uses: voxpelli/ghatemplates/.github/workflows/lint.yml@main 19 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - '*' 9 | pull_request: 10 | branches: 11 | - main 12 | 13 | permissions: 14 | contents: read 15 | 16 | jobs: 17 | test: 18 | uses: voxpelli/ghatemplates/.github/workflows/test.yml@main 19 | with: 20 | node-versions: '14,16,18,20' 21 | os: 'ubuntu-latest' 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Basic ones 2 | /coverage 3 | /docs 4 | /node_modules 5 | /.env 6 | /.nyc_output 7 | 8 | # When using npm, skip the yarn.lock 9 | /yarn.lock 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | All notable changes to this project will be documented in this file. 3 | This project adheres to [Semantic Versioning](http://semver.org/). 4 | 5 | ## 0.7.0 – YYYY-MM-DD 6 | 7 | * **Breaking** – now requires at least Node.js 8.x 8 | 9 | ## 0.6.1 – 2018-01-20 10 | 11 | * **Feature** – layout names are now configurable and can also be disabled. Fixes #29 12 | 13 | ## 0.6.0 – 2017-05-16 14 | 15 | * **Breaking** – the format for configuring content-dependent categories have changed, now a JSON array 16 | * **Feature** – permalink styles and filename styles can now be content-dependent just like categories 17 | 18 | ## 0.5.0 – 2017-05-14 19 | 20 | * **Feature** – post file names are now configurable 21 | * **Feature** – media file names are now configurable 22 | * **Bug fix** – corrected resolving of site options 23 | * **Bug fix** – corrected combined use of `MICROPUB_SITES_JSON` and `MICROPUB_SITE_URL` 24 | * **Updated** – dependencies has been updated 25 | 26 | ## 0.4.4 – 2017-02-14 27 | 28 | * **Improvement** – includes updated `micropub-express` module that supports the [now standard](https://github.com/voxpelli/node-micropub-express/issues/7) `create` IndieAuth scope, that eg. Quill [now uses](https://github.com/aaronpk/Quill/commit/eab1a65f63f227bae126a554e3bf93aa05c70695) 29 | * **Updated** – dependencies has been updated 30 | 31 | ## 0.4.3 – 2017-01-28 32 | 33 | * **Feature** – support `config` queries and include syndication targets in it 34 | 35 | ## 0.4.2 – 2017-01-28 36 | 37 | * **Feature** – configurable syndication targets, see `sample.env` and #9 38 | * **Improvement** – using new [microformat-express](https://github.com/voxpelli/node-micropub-express) version that is more spec compliant: JSON by default in query responses, better errors and support for space separated scopes 39 | * **Updated** – dependencies has been updated 40 | 41 | ## 0.4.1 – 2017-01-21 42 | 43 | * **Improvement** – using new `format-microformat` version that by default publish all posts in the past by 15 seconds to avoid time sync issues with build servers 44 | * **Improvement** – now includes a Yarn lock file 45 | * **Updated** – dependencies has been updated 46 | 47 | ## 0.4.0 - 2016-08-17 48 | 49 | * **Feature** – derviced categories are now configurable 50 | * **Somewhat breaking** – this project now requires Node.js 6.x 51 | 52 | ## 0.3.1 - 2016-07-30 53 | 54 | * **Bug fix** – the caching of the auto-configuration didn't work 55 | 56 | ## 0.3.0 - 2016-07-30 57 | 58 | * **Feature** – permalinks are now configurable 59 | * **Feature** – this project now autoconfigures eg. the permalink style based on a sites `_config.yml` file 60 | * **Somewhat breaking** – as this project now respects the actual permalink configuration it will now default to other permalink styles than before 61 | 62 | ## 0.2.0 - 2016-07-06 63 | 64 | - Initial public release 65 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Pelle Wessman 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Micropub to GitHub 2 | 3 | [![IndieWeb](https://img.shields.io/badge/indie-web-FFB100?labelColor=FF5C01)](https://indieweb.org/) 4 | [![js-semistandard-style](https://img.shields.io/badge/code%20style-semistandard-brightgreen.svg)](https://github.com/voxpelli/eslint-config) 5 | [![Follow @voxpelli@mastodon.social](https://img.shields.io/mastodon/follow/109247025527949675?domain=https%3A%2F%2Fmastodon.social&style=social)](https://mastodon.social/@voxpelli) 6 | 7 | An endpoint that accepts [Micropub](http://micropub.net/) requests, formats them into [Jekyll](http://jekyllrb.com/) posts before pushing them to a configured GitHub repository. This enables updating a Jekyll blog through a [Micropub client](https://indieweb.org/Micropub/Clients). 8 | 9 | ### _Early alpha_ 10 | 11 | Supported: 12 | * Creation of posts 13 | * Uploading of media 14 | * Replacing an existing post with a new version 15 | 16 | Unsupported: 17 | * Partial update 18 | * Deletes 19 | 20 | ## Requirements 21 | 22 | Requires at least Node.js 14.0.0. 23 | 24 | ## Installation 25 | 26 | Install as a normal Node.js application. Add the required [configuration](#configuration) values via environment variables or similar mechanism. Or deploy to Heroku: 27 | 28 | [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy?template=https://github.com/voxpelli/webpage-micropub-to-github) 29 | 30 | ## Endpoint discovery 31 | 32 | Once deployed, your Micropub endpoint can be found at `/micropub/main` e.g. `https://example.com/micropub/main`. 33 | 34 | If you specified more than one site using the `MICROPUB_SITES_JSON` variable, then each endpoint will be available under the name of its respective key, i.e. `/micropub/key-name`. 35 | 36 | To enable automatic discovery for your [Micropub endpoint](https://indieweb.org/micropub#Endpoint_Discovery) and [token endpoint](https://indieweb.org/obtaining-an-access-token#Discovery), you will need to add the following values to your site's ``: 37 | 38 | ``` 39 | 40 | 41 | ``` 42 | 43 | ## Configuration 44 | 45 | ### Required values 46 | 47 | The following variables are required to enable a Micropub client to push content to your GitHub repository. 48 | 49 | Variable | Description 50 | -------- | ----------- 51 | `MICROPUB_TOKEN_ENDPOINT` | URL to verify Micropub token. Example: `https://tokens.indieauth.com/token` 52 | `MICROPUB_TOKEN_ME` | URL to identify Micropub user. Example: `https://johndoe.example` 53 | `MICROPUB_GITHUB_TOKEN` | [GitHub access token](https://github.com/settings/tokens) to grant access to repository. Example: `12345abcde67890fghij09876klmno54321pqrst` 54 | `MICROPUB_GITHUB_USER` | Username/organisation that owns repository. Example: `johndoe` 55 | `MICROPUB_SITE_GITHUB_REPO` | GitHub repository in which site files are found. Example: `johndoe.github.io` 56 | `MICROPUB_SITE_URL` | URL where site is published. Example: `https://johndoe.example` 57 | 58 | ### Syndication 59 | 60 | The following variables can be used to set [syndication target(s)](https://www.w3.org/TR/micropub/#syndication-targets). 61 | 62 | Variable | Description 63 | -------- | ----------- 64 | `MICROPUB_SITE_SYNDICATE_TO_UID` | Unique identifier of syndication target. Example: `https://social.example/johndoe` 65 | `MICROPUB_SITE_SYNDICATE_TO_NAME` | User readable name of syndication target. Example: `@johndoe on Example Social Network` 66 | `MICROPUB_SITE_SYNDICATE_TO` | Complex syndication target. Provided as a JSON array, e.g.: `[{"uid":"https://social.example/johndoe","name":"@johndoe on Example Social Network","service":{"name":"Example Social Network","url":"https://social.example/","photo":"https://social.example/icon.png"},"user":{"name":"johndoe","url":"https://social.example/johndoe","photo":"https://social.example/johndoe/photo.jpg"}}]`. Not compatible with `MICROPUB_SITES_JSON`. 67 | 68 | ### Output style 69 | 70 | The following variables allow you to configure the name and destination for files pushed to your repository. These variables will also accept conditional values ([described below](#conditional-values)). 71 | 72 | Variable | Description 73 | -------- | ----------- 74 | `MICROPUB_FILENAME_STYLE` | File name and path for post. Example: `_posts/:year-:month-:day-:slug` 75 | `MICROPUB_MEDIA_FILES_STYLE` | File name and path for media files. Example: `media/:year-:month-:slug/:filesslug` 76 | `MICROPUB_PERMALINK_STYLE` | [Jekyll permalink style](http://jekyllrb.com/docs/permalinks/). Example: `/:categories/:year/:month/:title/` 77 | `MICROPUB_LAYOUT_NAME` | The name of the Jekyll layout to use for the posts. Set to `false` to have no layout be added. Defaults to `microblogpost` 78 | `MICROPUB_OPTION_DERIVE_CATEGORY` | Override the default category 79 | `MICROPUB_GITHUB_BRANCH` | Branch to use for pushes. Useful to test out if things end up where you want them to. Example: `micropub` 80 | 81 | #### Complex output styles 82 | 83 | These configuration options can all be given different values for different types of content by setting up conditions under which each configuration applies. See [conditional values](#conditional-values). 84 | 85 | ### Complex configuration 86 | 87 | Variable | Description 88 | -------- | ----------- 89 | `MICROPUB_SITES_JSON` | Complex settings and/or multiple sites (including their syndication targets) provided as JSON, e.g.: `'{"site1":{"url":"https://site1.example/","github":{"repo":"site1"},"token":[{"endpoint":"https://tokens.indieauth.com/token","me":"https://site1.example/"}]},"site2":{"url":"http://site2.example/","github":{"repo":"site2"},"token":[{"endpoint":"https://tokens.indieauth.com/token","me":"http://site2.example/"}]}}'` 90 | `MICROPUB_OPTION_NO_AUTO_CONFIGURE` | Auto-configure permalink status from the Jekyll repo config. Boolean 91 | `MICROPUB_OPTION_DERIVE_LANGUAGES` | Comma separated list of language codes to auto-detect. Example `eng,swe` 92 | `MICROPUB_HOST` | Domain name to enforce. Will redirect requests to all other domain names and IP addresses that the endpoint can be accessed on. 93 | `MICROPUB_ENCODE_HTML` | (_non-standard_) Option to opt out of HTML-encoding of text content if set to `false`. Defaults to `true`. 94 | 95 | ### Conditional values 96 | 97 | Conditions are set up by assessing the environment variables using a JSON object of the format: 98 | 99 | ```json 100 | [ 101 | { 102 | "condition": "bookmark OR name", 103 | "value": "value-one" 104 | }, 105 | { 106 | "condition": "bookmark OR name", 107 | "value": "value-two" 108 | } 109 | ] 110 | ``` 111 | 112 | Conditions are [fulfills expressions](https://github.com/voxpelli/node-fulfills#condition-syntax) that apply to the properties of the document being saved. Pretty much any property that can be inserted into a YAML front matter can be matched against. All values explicitly set in the Micropub request are available, but some defaults and derived values may not be available, depending on the option configured. 113 | 114 | _Please [open an issue](https://github.com/voxpelli/webpage-micropub-to-github/issues/new) and let me know what conditions you would like to set up._ 115 | 116 | ## Modules used 117 | 118 | * [micropub-express](https://github.com/voxpelli/node-micropub-express) – an [Express](http://expressjs.com/) Micropub endpoint that accepts and verifies Micropub requests and calls a callback with a parsed `micropubDocument`. 119 | * [format-microformat](https://github.com/voxpelli/node-format-microformat) – a module that takes a `micropubDocument` as its input, and converts this data into a standard that can be published elsewhere. Currently supports the Jekyll format. 120 | * [github-publish](https://github.com/voxpelli/node-github-publish) – a module that takes a filename and content and publishes it to a GitHub repository. The formatted data generated by `format-microformat` can be published to a Jekyll blog hosted on a GitHub, or a [GitHub Pages](https://pages.github.com/) site. 121 | 122 | ## Related 123 | 124 | * [My 2015 in IndieWeb](http://voxpelli.com/2016/03/my-2015-in-indieweb/) – post from 2016-03-12 by @voxpelli 125 | * [miklb/jekyll-indieweb](https://github.com/miklb/jekyll-indieweb) – a Jekyll theme built with the IndieWeb in mind 126 | * [voxpelli/voxpelli.github.com](https://github.com/voxpelli/voxpelli.github.com) – first Jekyll blog to use this Micropub endpoint 127 | * [webmention.herokuapp.com](https://webmention.herokuapp.com/) – another IndieWeb project suited for Jekyll, this one for [Webmention](https://indieweb.org/webmention) 128 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Micropub to GitHub", 3 | "description": "An endpoint that accepts Micropub requests, formats them into Jekyll posts and pushes them to a configured GitHub repository.", 4 | "keywords": [ 5 | "indieweb", 6 | "micropub", 7 | "api", 8 | "jekyll" 9 | ], 10 | "repository": "https://github.com/voxpelli/webpage-micropub-to-github", 11 | "env": { 12 | "MICROPUB_GITHUB_TOKEN": { 13 | "description": "An access token for the GitHub API. Get one at: https://github.com/settings/tokens", 14 | "required": true 15 | }, 16 | "MICROPUB_GITHUB_USER": { 17 | "description": "The GitHub user to which the configured repositories belongs.", 18 | "required": true 19 | }, 20 | "MICROPUB_GITHUB_BRANCH": { 21 | "description": "Branch to use when pushing. Leave empty/blank to use default.", 22 | "required": false 23 | }, 24 | 25 | "MICROPUB_SITE_GITHUB_REPO": { 26 | "description": "The name of your GitHub repository.", 27 | "required": true 28 | }, 29 | "MICROPUB_SITE_URL": { 30 | "description": "The URL to your site.", 31 | "required": true 32 | }, 33 | 34 | "MICROPUB_TOKEN_ENDPOINT": { 35 | "description": "The token endpoint to verify the micropub token against. See: https://indieweb.org/token-endpoint", 36 | "required": true, 37 | "value": "https://tokens.indieauth.com/token" 38 | }, 39 | "MICROPUB_TOKEN_ME": { 40 | "description": "Your personal domain name that the micropub token should represent. Defaults to site URL.", 41 | "required": false 42 | }, 43 | 44 | "MICROPUB_OPTION_DERIVE_LANGUAGES": { 45 | "description": "A comma separated list of languages to autodetect. Eg: eng,swe", 46 | "required": false 47 | } 48 | }, 49 | "buildpacks": [ 50 | { 51 | "url": "heroku/nodejs" 52 | } 53 | ] 54 | } 55 | -------------------------------------------------------------------------------- /lib/auto-config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const yaml = require('js-yaml'); 4 | 5 | const cache = {}; 6 | 7 | const setCache = (key, value, maxAge) => { 8 | cache[JSON.stringify(key)] = { 9 | value, 10 | expire: Date.now() + maxAge 11 | }; 12 | }; 13 | 14 | const getCache = (key, value, maxAge) => { 15 | key = JSON.stringify(key); 16 | 17 | if (!cache[key]) { 18 | return; 19 | } else if (cache[key].expire < Date.now()) { 20 | delete cache[key]; 21 | return; 22 | } 23 | 24 | return cache[key].value; 25 | }; 26 | 27 | module.exports = function (publisher) { 28 | const cacheKey = [publisher.user, publisher.repo, publisher.branch]; 29 | const cache = getCache(cacheKey); 30 | 31 | if (cache) { 32 | return cache.then(options => Object.assign({}, options)); 33 | } 34 | 35 | const lookup = publisher.retrieve('_config.yml') 36 | .then(result => yaml.safeLoad(result.content || '')) 37 | .then(config => { 38 | const options = {}; 39 | 40 | config = config || {}; 41 | 42 | if (config.permalink) { 43 | options.permalinkStyle = config.permalink; 44 | } 45 | 46 | return options; 47 | }); 48 | 49 | setCache(cacheKey, lookup, 60 * 60 * 1000); 50 | 51 | return lookup.then(options => Object.assign({}, options)); 52 | }; 53 | -------------------------------------------------------------------------------- /lib/config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { deepFreeze } = require('./utils'); 4 | 5 | const env = process.env; 6 | const pkg = require('../package.json'); 7 | 8 | let prefix = 'MICROPUB_'; 9 | 10 | prefix = env[prefix + 'PREFIX'] || prefix; 11 | 12 | // Load the dotenv file 13 | if (process.env.DOTENV_FILE) { 14 | const dotEnvResult = require('dotenv').config({ 15 | path: process.env.DOTENV_FILE 16 | }); 17 | if (dotEnvResult.error) { throw dotEnvResult.error; } 18 | } else { 19 | // Fail silently if we assume default dotenv file location 20 | require('dotenv').config(); 21 | } 22 | 23 | /** 24 | * @template T 25 | * @param {T} value 26 | * @param {boolean} defaultToValue 27 | * @returns {T|any[]|Object} 28 | */ 29 | const parseJSON = (value, defaultToValue) => { 30 | let result; 31 | 32 | if (value) { 33 | try { 34 | result = JSON.parse(value); 35 | } catch (err) {} 36 | } 37 | 38 | if (!result && defaultToValue) { 39 | return value; 40 | } 41 | }; 42 | 43 | const parseJSONList = (value) => [].concat(parseJSON(value) || []); 44 | 45 | const config = { 46 | version: pkg.version, 47 | env: env.NODE_ENV || 'production', 48 | port: env.PORT || 8080, 49 | host: env[prefix + 'HOST'], 50 | github: { 51 | user: env[prefix + 'GITHUB_USER'], 52 | token: env[prefix + 'GITHUB_TOKEN'], 53 | branch: env[prefix + 'GITHUB_BRANCH'] 54 | }, 55 | site: { 56 | url: env[prefix + 'SITE_URL'], 57 | repo: env[prefix + 'SITE_GITHUB_REPO'], 58 | syndicateToUid: env[prefix + 'SITE_SYNDICATE_TO_UID'], 59 | syndicateToName: env[prefix + 'SITE_SYNDICATE_TO_NAME'], 60 | syndicateTo: parseJSONList(env[prefix + 'SITE_SYNDICATE_TO']) 61 | }, 62 | sites: parseJSON(env[prefix + 'SITES_JSON']) || false, 63 | token: env[prefix + 'TOKEN_ENDPOINT'] 64 | ? [{ 65 | endpoint: env[prefix + 'TOKEN_ENDPOINT'], 66 | me: env[prefix + 'TOKEN_ME'] || env[prefix + 'SITE_URL'] 67 | }] 68 | : [], 69 | handlerOptions: { 70 | noAutoConfigure: !!env[prefix + 'OPTION_NO_AUTO_CONFIGURE'], 71 | deriveCategory: parseJSON(env[prefix + 'OPTION_DERIVE_CATEGORY']) || false, 72 | deriveLanguages: (env[prefix + 'OPTION_DERIVE_LANGUAGES'] || '').split(',').filter(item => !!item), 73 | layoutName: parseJSON(env[prefix + 'LAYOUT_NAME'], true), 74 | filenameStyle: parseJSON(env[prefix + 'FILENAME_STYLE'], true), 75 | mediaFilesStyle: parseJSON(env[prefix + 'MEDIA_FILES_STYLE'], true), 76 | permalinkStyle: parseJSON(env[prefix + 'PERMALINK_STYLE'], true), 77 | encodeHTML: parseJSON(env[prefix + 'ENCODE_HTML']) 78 | } 79 | }; 80 | 81 | config.userAgent = pkg.name + '/' + config.version + ' (' + pkg.homepage + ')'; 82 | 83 | module.exports = deepFreeze(config); 84 | -------------------------------------------------------------------------------- /lib/handler.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const GitHubPublisher = require('github-publish'); 4 | const MicropubFormatter = require('format-microformat'); 5 | const fulfills = require('fulfills'); 6 | 7 | const autoConfigure = require('./auto-config'); 8 | 9 | const removeEmptyValues = function (obj, exclude = []) { 10 | const result = {}; 11 | Object.keys(obj).forEach(key => { 12 | if (obj[key] || exclude.includes(key)) { 13 | result[key] = obj[key]; 14 | } 15 | }); 16 | return result; 17 | }; 18 | 19 | const matchPropertiesToConditions = function (conditions, properties) { 20 | let result; 21 | 22 | conditions.some(({ condition, value }) => { 23 | if (fulfills(properties, condition)) { 24 | result = value; 25 | return true; 26 | } 27 | }); 28 | 29 | return result; 30 | }; 31 | 32 | module.exports = function (githubTarget, micropubDocument, siteUrl, options) { 33 | options = removeEmptyValues(options || {}, ['layoutName', 'encodeHTML']); 34 | 35 | const publisher = new GitHubPublisher(githubTarget.token, githubTarget.user, githubTarget.repo, githubTarget.branch); 36 | 37 | let force = false; 38 | 39 | let categoryDeriver; 40 | 41 | if (options.deriveCategory) { 42 | categoryDeriver = (properties) => matchPropertiesToConditions(options.deriveCategory, properties); 43 | } 44 | 45 | return Promise.resolve( 46 | options.noAutoConfigure 47 | ? options 48 | : autoConfigure(publisher).then(autoConfig => Object.assign(autoConfig, options)) 49 | ) 50 | .then(options => { 51 | // Resolve any condition based options to functions that will resolve values based on the conditions 52 | [ 53 | 'permalinkStyle', 54 | 'filenameStyle', 55 | 'mediaFilesStyle', 56 | 'layoutName' 57 | ].forEach(key => { 58 | // Save the original value as we will need it when matching the properties to the conditions 59 | const value = options[key]; 60 | 61 | if (Array.isArray(value)) { 62 | options[key] = (properties) => matchPropertiesToConditions(value, properties); 63 | } 64 | }); 65 | 66 | return options; 67 | }) 68 | .then(options => new MicropubFormatter({ 69 | relativeTo: siteUrl, 70 | deriveCategory: categoryDeriver, 71 | deriveLanguages: options.deriveLanguages, 72 | permalinkStyle: options.permalinkStyle, 73 | filenameStyle: options.filenameStyle, 74 | filesStyle: options.mediaFilesStyle, 75 | layoutName: options.layoutName, 76 | encodeHTML: options.encodeHTML 77 | })) 78 | .then(formatter => formatter.formatAll(micropubDocument)) 79 | .then(formatted => { 80 | if (formatted.raw.url === formatted.url) { 81 | force = true; 82 | } 83 | 84 | return Promise.all( 85 | (formatted.files || []).map(file => { 86 | return publisher.publish(file.filename, file.buffer, { 87 | force, 88 | message: 'uploading media' 89 | }) 90 | .then(result => { 91 | // TODO: Do something more than just logging 92 | if (!result) { console.log('Failed to upload media'); } 93 | }); 94 | }) 95 | ) 96 | .then(() => formatted); 97 | }) 98 | .then(formatted => { 99 | let category = formatted.raw.derived.category || 'article'; 100 | 101 | if (category === 'social') { 102 | category = 'social interaction'; 103 | } 104 | 105 | if (options.verbose) { 106 | console.log('Formatted content:\n' + formatted.content); 107 | } 108 | 109 | return publisher.publish(formatted.filename, formatted.content, { 110 | force, 111 | message: 'uploading ' + category 112 | }) 113 | .then(function (result) { 114 | return result ? formatted.url : false; 115 | }); 116 | }); 117 | }; 118 | -------------------------------------------------------------------------------- /lib/main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const express = require('express'); 4 | const logger = require('bunyan-adaptor')(); 5 | const micropub = require('micropub-express'); 6 | 7 | const handler = require('./handler'); 8 | 9 | const config = require('./config'); 10 | const supportedSites = require('./sites'); 11 | 12 | const app = express(); 13 | 14 | app.disable('x-powered-by'); 15 | app.set('env', config.env); 16 | app.set('port', config.port); 17 | // Activate when on Heroku? 18 | // app.enable('trust proxy'); 19 | 20 | if (config.host) { 21 | app.use((req, res, next) => { 22 | let portSuffix = (req.get('x-forwarded-port') || req.app.get('port')) + ''; 23 | portSuffix = (portSuffix === '80' ? '' : ':' + portSuffix); 24 | if (req.hostname + portSuffix === config.host) { return next(); } 25 | res.redirect(307, req.protocol + '://' + config.host + req.url); 26 | }); 27 | } 28 | 29 | app.param('targetsite', (req, res, next, id) => { 30 | if (supportedSites[id]) { 31 | req.targetsite = supportedSites[id]; 32 | next(); 33 | } else { 34 | res.sendStatus(404); 35 | } 36 | }); 37 | 38 | app.use('/micropub/:targetsite', micropub({ 39 | logger, 40 | userAgent: config.userAgent, 41 | tokenReference: req => (req.targetsite.token || []).concat(config.token), 42 | queryHandler: (q, req) => { 43 | if (q === 'config') { 44 | const config = {}; 45 | 46 | if (req.targetsite.syndicateTo) { config['syndicate-to'] = req.targetsite.syndicateTo; } 47 | 48 | return config; 49 | } else if (q === 'syndicate-to') { 50 | return req.targetsite.syndicateTo ? { 'syndicate-to': req.targetsite.syndicateTo } : undefined; 51 | } 52 | }, 53 | handler: (micropubDocument, req) => { 54 | logger.debug({ micropubDocument: JSON.stringify(micropubDocument, null, 2), date: Date().toString() }, 'Received a Micropub document'); 55 | 56 | const options = Object.assign({}, config.handlerOptions, req.targetsite.options || {}); 57 | 58 | return handler( 59 | Object.assign({}, config.github, req.targetsite.github), 60 | micropubDocument, 61 | req.targetsite.url, 62 | options 63 | ).then(function (url) { 64 | if (url) { 65 | return { url }; 66 | } 67 | }); 68 | } 69 | })); 70 | 71 | // TODO: Add a proper graceful shutdown mechanism here? Probably still needed in Express 4 – it probably needs to be 72 | app.listen(config.port); 73 | 74 | logger.info('Started and listens on ' + config.port); 75 | -------------------------------------------------------------------------------- /lib/sites.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const { deepFreeze } = require('./utils'); 4 | 5 | const config = require('./config'); 6 | 7 | const sites = Object.assign({}, config.sites || {}); 8 | 9 | if (config.site.url) { 10 | const { 11 | url, 12 | repo, 13 | syndicateTo, 14 | syndicateToUid, 15 | syndicateToName 16 | } = config.site; 17 | 18 | sites.main = { 19 | url, 20 | github: { repo }, 21 | syndicateTo 22 | }; 23 | 24 | if (syndicateToUid) { 25 | sites.main.syndicateTo = [ 26 | { 27 | uid: syndicateToUid, 28 | name: syndicateToName 29 | } 30 | ]; 31 | } 32 | } 33 | 34 | module.exports = deepFreeze(sites); 35 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const deepFreeze = function (obj) { 4 | Object.keys(obj).forEach(key => { 5 | const item = obj[key]; 6 | if (typeof item === 'object' && !Object.isFrozen(item)) { 7 | obj[key] = deepFreeze(item); 8 | } 9 | }); 10 | return Object.freeze(obj); 11 | }; 12 | 13 | module.exports = { 14 | deepFreeze 15 | }; 16 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "micropub-to-github", 3 | "version": "0.6.1", 4 | "license": "MIT", 5 | "private": true, 6 | "description": "An endpoint that accepts Micropub requests, formats them into Jekyll posts and pushes them to a configured GitHub repository.", 7 | "author": "Pelle Wessman (http://kodfabrik.se/)", 8 | "homepage": "https://github.com/voxpelli/webpage-micropub-to-github", 9 | "repository": { 10 | "type": "git", 11 | "url": "git://github.com/voxpelli/webpage-micropub-to-github.git" 12 | }, 13 | "main": "./lib/main", 14 | "directories": { 15 | "lib": "./lib" 16 | }, 17 | "scripts": { 18 | "check:dependency-check": "dependency-check 'lib/**/*.js' 'test/**/*.js' --no-dev", 19 | "check:installed-check": "installed-check --engine-no-dev", 20 | "check:lint": "eslint .", 21 | "check": "run-p check:*", 22 | "start": "node .", 23 | "sync-gh-actions": "ghat", 24 | "test:mocha": "NODE_ENV=test DOTENV_FILE=test/test.env nyc --reporter=lcov --reporter text mocha 'test/**/*.spec.js'", 25 | "test-ci": "run-s test:*", 26 | "test": "run-s check test:*" 27 | }, 28 | "husky": { 29 | "hooks": { 30 | "pre-push": "npm test" 31 | } 32 | }, 33 | "engines": { 34 | "node": ">=14.0.0" 35 | }, 36 | "devDependencies": { 37 | "@voxpelli/eslint-config": "^4.0.0", 38 | "chai": "^4.2.0", 39 | "chai-as-promised": "^7.1.1", 40 | "dependency-check": "^4.1.0", 41 | "eslint": "^6.8.0", 42 | "eslint-config-standard": "^14.1.0", 43 | "eslint-plugin-import": "^2.18.2", 44 | "eslint-plugin-jsdoc": "^21.0.0", 45 | "eslint-plugin-node": "^11.0.0", 46 | "eslint-plugin-promise": "^4.2.1", 47 | "eslint-plugin-standard": "^4.0.0", 48 | "ghat": "^0.14.0", 49 | "husky": "^4.3.8", 50 | "installed-check": "^7.0.0", 51 | "mocha": "^7.0.1", 52 | "nock": "^11.7.2", 53 | "npm-run-all2": "^6.0.5", 54 | "nyc": "^15.0.0", 55 | "sinon": "^8.1.1" 56 | }, 57 | "dependencies": { 58 | "bunyan-adaptor": "^4.0.0", 59 | "dotenv": "^8.2.0", 60 | "express": "^4.17.1", 61 | "format-microformat": "^0.11.1", 62 | "fulfills": "^2.1.0", 63 | "github-publish": "^3.0.0", 64 | "js-yaml": "^3.12.1", 65 | "micropub-express": "^0.8.1" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "github>voxpelli/renovate-config:app" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /sample.env: -------------------------------------------------------------------------------- 1 | # See README.md for list of available configuration options 2 | 3 | MICROPUB_TOKEN_ENDPOINT="https://tokens.indieauth.com/token" 4 | MICROPUB_TOKEN_ME="https://johndoe.example" 5 | 6 | MICROPUB_GITHUB_TOKEN="12345abcde67890fghij09876klmno54321pqrst" 7 | MICROPUB_GITHUB_USER="johndoe" 8 | 9 | MICROPUB_SITE_URL="https://johndoe.example" 10 | MICROPUB_SITE_GITHUB_REPO="johndoe.github.io" 11 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /test/handler.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const chai = require('chai'); 4 | const chaiAsPromised = require('chai-as-promised'); 5 | const nock = require('nock'); 6 | const sinon = require('sinon'); 7 | 8 | chai.use(chaiAsPromised); 9 | chai.should(); 10 | 11 | describe('Handler', () => { 12 | const handler = require('../lib/handler.js'); 13 | let handlerConfig; 14 | let clock; 15 | 16 | const basicTest = function ({ content, message, finalUrl, filename, contentInput } = {}) { 17 | const token = 'abc123'; 18 | const user = 'username'; 19 | const repo = 'repo'; 20 | 21 | filename = filename || '_posts/2015-06-30-awesomeness-is-awesome.md'; 22 | 23 | const path = '/repos/' + user + '/' + repo + '/contents/' + filename; 24 | 25 | const encodedContent = Buffer.from(content || ( 26 | '---\n' + 27 | 'layout: micropubpost\n' + 28 | 'date: \'2015-06-30T14:19:45.000Z\'\n' + 29 | 'title: awesomeness is awesome\n' + 30 | 'lang: en\n' + 31 | 'slug: awesomeness-is-awesome\n' + 32 | '---\n' + 33 | 'hello world\n' 34 | )).toString('base64'); 35 | 36 | const mock = nock('https://api.github.com/') 37 | .matchHeader('authorization', val => val && val[0] === 'Bearer ' + token) 38 | .put(path, { 39 | message: message || 'uploading article', 40 | content: encodedContent 41 | }) 42 | .reply(201, { content: { sha: 'abc123' } }); 43 | 44 | return handler( 45 | { 46 | token, 47 | user, 48 | repo 49 | }, 50 | { 51 | type: ['h-entry'], 52 | properties: { 53 | content: [contentInput || 'hello world'], 54 | name: ['awesomeness is awesome'], 55 | lang: ['en'] 56 | } 57 | }, 58 | 'http://example.com/foo/', 59 | handlerConfig 60 | ) 61 | .then(url => { 62 | mock.done(); 63 | url.should.equal(finalUrl || 'http://example.com/foo/2015/06/awesomeness-is-awesome/'); 64 | }); 65 | }; 66 | 67 | beforeEach(() => { 68 | nock.cleanAll(); 69 | nock.disableNetConnect(); 70 | clock = sinon.useFakeTimers(1435674000000); 71 | handlerConfig = { 72 | // Enable to help with debugging of wrongly formatted content 73 | // verbose: true, 74 | noAutoConfigure: true, 75 | permalinkStyle: '/:categories/:year/:month/:title/' 76 | }; 77 | }); 78 | 79 | afterEach(() => { 80 | clock.restore(); 81 | sinon.restore(); 82 | if (!nock.isDone()) { 83 | throw new Error('pending nock mocks: ' + nock.pendingMocks()); 84 | } 85 | }); 86 | 87 | describe('main', () => { 88 | it('should format and send content', () => { 89 | return basicTest(); 90 | }); 91 | 92 | it('should upload files prior to content', () => { 93 | const token = 'abc123'; 94 | const user = 'username'; 95 | const repo = 'repo'; 96 | const repoPath = '/repos/' + user + '/' + repo + '/contents/'; 97 | const mediaFilename = 'foo.jpg'; 98 | 99 | const fileContent = Buffer.from('abc123'); 100 | 101 | const encodedContent = Buffer.from( 102 | '---\n' + 103 | 'layout: micropubpost\n' + 104 | 'date: \'2015-06-30T14:19:45.000Z\'\n' + 105 | 'title: awesomeness is awesome\n' + 106 | 'lang: en\n' + 107 | 'slug: awesomeness-is-awesome\n' + 108 | 'mf-photo:\n' + 109 | ' - \'http://example.com/foo/media/2015-06-awesomeness-is-awesome/' + mediaFilename + '\'\n' + 110 | '---\n' + 111 | 'hello world\n' 112 | ); 113 | 114 | const mock = nock('https://api.github.com/') 115 | .matchHeader('authorization', val => val && val[0] === 'Bearer ' + token) 116 | // Upload of the media 117 | .put(repoPath + 'media/2015-06-awesomeness-is-awesome/' + mediaFilename, { 118 | // TODO: Change this commit message to at least be "new media" instead 119 | message: 'uploading media', 120 | content: fileContent.toString('base64') 121 | }) 122 | .reply(201, { content: { sha: 'abc123' } }) 123 | // Upload of the content 124 | .put(repoPath + '_posts/2015-06-30-awesomeness-is-awesome.md', { 125 | message: 'uploading article', 126 | content: encodedContent.toString('base64') 127 | }) 128 | .reply(201, { content: { sha: 'abc123' } }); 129 | 130 | return handler( 131 | { 132 | token, 133 | user, 134 | repo 135 | }, { 136 | type: ['h-entry'], 137 | properties: { 138 | content: ['hello world'], 139 | name: ['awesomeness is awesome'], 140 | lang: ['en'] 141 | }, 142 | files: { 143 | photo: [{ filename: 'foo.jpg', buffer: fileContent }] 144 | } 145 | }, 146 | 'http://example.com/foo/', 147 | handlerConfig 148 | ) 149 | .then(url => { 150 | mock.done(); 151 | url.should.equal('http://example.com/foo/2015/06/awesomeness-is-awesome/'); 152 | }); 153 | }); 154 | 155 | it('should override existing content if matching URL', () => { 156 | const token = 'abc123'; 157 | const user = 'username'; 158 | const repo = 'repo'; 159 | const path = '/repos/' + user + '/' + repo + '/contents/_posts/2015-06-30-awesomeness-is-awesome.md'; 160 | const sha = 'abc123'; 161 | 162 | const encodedContent = Buffer.from( 163 | '---\n' + 164 | 'layout: micropubpost\n' + 165 | 'date: \'2015-06-30T14:19:45.000Z\'\n' + 166 | 'title: awesomeness is awesome\n' + 167 | 'lang: en\n' + 168 | 'slug: awesomeness-is-awesome\n' + 169 | '---\n' + 170 | 'hello world\n' 171 | ); 172 | const base64 = encodedContent.toString('base64'); 173 | 174 | const mock = nock('https://api.github.com/') 175 | .put(path, { 176 | message: 'uploading article', 177 | content: base64 178 | }) 179 | .reply(422, {}) 180 | 181 | .get(path) 182 | .reply(200, { sha }) 183 | 184 | .put(path, { 185 | message: 'uploading article', 186 | content: base64, 187 | sha 188 | }) 189 | .reply(201, { content: { sha: 'xyz789' } }); 190 | 191 | return handler( 192 | { 193 | token, 194 | user, 195 | repo 196 | }, { 197 | type: ['h-entry'], 198 | url: 'http://example.com/foo/2015/06/awesomeness-is-awesome/', 199 | properties: { 200 | content: ['hello world'], 201 | name: ['awesomeness is awesome'], 202 | lang: ['en'] 203 | } 204 | }, 205 | 'http://example.com/foo/', 206 | handlerConfig 207 | ) 208 | .then(url => { 209 | mock.done(); 210 | url.should.equal('http://example.com/foo/2015/06/awesomeness-is-awesome/'); 211 | }); 212 | }); 213 | 214 | it('should not override existing content if no matching URL', () => { 215 | const token = 'abc123'; 216 | const user = 'username'; 217 | const repo = 'repo'; 218 | const path = '/repos/' + user + '/' + repo + '/contents/_posts/2015-06-30-awesomeness-is-awesome.md'; 219 | 220 | const encodedContent = Buffer.from( 221 | '---\n' + 222 | 'layout: micropubpost\n' + 223 | 'date: \'2015-06-30T14:19:45.000Z\'\n' + 224 | 'title: awesomeness is awesome\n' + 225 | 'lang: en\n' + 226 | 'slug: awesomeness-is-awesome\n' + 227 | '---\n' + 228 | 'hello world\n' 229 | ); 230 | const base64 = encodedContent.toString('base64'); 231 | 232 | const mock = nock('https://api.github.com/') 233 | .put(path, { 234 | message: 'uploading article', 235 | content: base64 236 | }) 237 | .reply(422, {}); 238 | 239 | return handler( 240 | { 241 | token, 242 | user, 243 | repo 244 | }, { 245 | type: ['h-entry'], 246 | properties: { 247 | content: ['hello world'], 248 | name: ['awesomeness is awesome'], 249 | lang: ['en'] 250 | } 251 | }, 252 | 'http://example.com/foo/', 253 | handlerConfig 254 | ) 255 | .then(url => { 256 | mock.done(); 257 | url.should.equal(false); 258 | }); 259 | }); 260 | 261 | it('should set a custom commit message when formatter returns a category', () => { 262 | const token = 'abc123'; 263 | const user = 'username'; 264 | const repo = 'repo'; 265 | const path = '/repos/' + user + '/' + repo + '/contents/_posts/2015-06-30-51585.md'; 266 | 267 | const encodedContent = Buffer.from( 268 | '---\n' + 269 | 'layout: micropubpost\n' + 270 | 'date: \'2015-06-30T14:19:45.000Z\'\n' + 271 | 'title: \'\'\n' + 272 | 'lang: en\n' + 273 | 'slug: \'51585\'\n' + 274 | 'category: social\n' + 275 | '---\n' + 276 | 'hello world\n' 277 | ); 278 | const base64 = encodedContent.toString('base64'); 279 | 280 | const mock = nock('https://api.github.com/') 281 | .matchHeader('authorization', val => val && val[0] === 'Bearer ' + token) 282 | .put(path, { 283 | message: 'uploading social interaction', 284 | content: base64 285 | }) 286 | .reply(201, { content: { sha: 'abc123' } }); 287 | 288 | return handler( 289 | { 290 | token, 291 | user, 292 | repo 293 | }, { 294 | type: ['h-entry'], 295 | properties: { 296 | content: ['hello world'], 297 | lang: ['en'] 298 | } 299 | }, 300 | 'http://example.com/foo/', 301 | handlerConfig 302 | ) 303 | .then(url => { 304 | mock.done(); 305 | url.should.equal('http://example.com/foo/social/2015/06/51585/'); 306 | }); 307 | }); 308 | 309 | it('should format HTML to Markdown and send content', () => { 310 | const token = 'abc123'; 311 | const user = 'username'; 312 | const repo = 'repo'; 313 | const path = '/repos/' + user + '/' + repo + '/contents/_posts/2015-06-30-awesomeness-is-awesome.md'; 314 | 315 | const encodedContent = Buffer.from( 316 | '---\n' + 317 | 'layout: micropubpost\n' + 318 | 'date: \'2015-06-30T14:19:45.000Z\'\n' + 319 | 'title: awesomeness is awesome\n' + 320 | 'lang: en\n' + 321 | 'slug: awesomeness-is-awesome\n' + 322 | '---\n' + 323 | '**hello world**\n' 324 | ); 325 | 326 | const mock = nock('https://api.github.com/') 327 | .matchHeader('authorization', val => val && val[0] === 'Bearer ' + token) 328 | .put(path, { 329 | message: 'uploading article', 330 | content: encodedContent.toString('base64') 331 | }) 332 | .reply(201, { content: { sha: 'abc123' } }); 333 | 334 | return handler( 335 | { 336 | token, 337 | user, 338 | repo 339 | }, { 340 | type: ['h-entry'], 341 | properties: { 342 | content: [{ 343 | html: 'hello world', 344 | value: 'hello world' 345 | }], 346 | name: ['awesomeness is awesome'], 347 | lang: ['en'] 348 | } 349 | }, 350 | 'http://example.com/foo/', 351 | handlerConfig 352 | ) 353 | .then(url => { 354 | mock.done(); 355 | url.should.equal('http://example.com/foo/2015/06/awesomeness-is-awesome/'); 356 | }); 357 | }); 358 | 359 | it('should support category deriving', () => { 360 | handlerConfig.deriveCategory = [ 361 | { value: 'foo', condition: 'abc = 123 AND foo = bar' }, 362 | { value: 'xyz', condition: 'content[] = "hello world"' } 363 | ]; 364 | 365 | return basicTest({ 366 | content: ( 367 | '---\n' + 368 | 'layout: micropubpost\n' + 369 | 'date: \'2015-06-30T14:19:45.000Z\'\n' + 370 | 'title: awesomeness is awesome\n' + 371 | 'lang: en\n' + 372 | 'slug: awesomeness-is-awesome\n' + 373 | 'category: xyz\n' + 374 | '---\n' + 375 | 'hello world\n' 376 | ), 377 | message: 'uploading xyz', 378 | finalUrl: 'http://example.com/foo/xyz/2015/06/awesomeness-is-awesome/' 379 | }); 380 | }); 381 | 382 | it('should support custom layout deriving', () => { 383 | handlerConfig.layoutName = [ 384 | { value: 'foo', condition: 'abc = 123 AND foo = bar' }, 385 | { value: 'xyz', condition: 'content[] = "hello world"' } 386 | ]; 387 | 388 | return basicTest({ 389 | content: ( 390 | '---\n' + 391 | 'layout: xyz\n' + 392 | 'date: \'2015-06-30T14:19:45.000Z\'\n' + 393 | 'title: awesomeness is awesome\n' + 394 | 'lang: en\n' + 395 | 'slug: awesomeness-is-awesome\n' + 396 | '---\n' + 397 | 'hello world\n' 398 | ) 399 | }); 400 | }); 401 | 402 | it('should support simple custom layout', () => { 403 | handlerConfig.layoutName = 'simple'; 404 | 405 | return basicTest({ 406 | content: ( 407 | '---\n' + 408 | 'layout: simple\n' + 409 | 'date: \'2015-06-30T14:19:45.000Z\'\n' + 410 | 'title: awesomeness is awesome\n' + 411 | 'lang: en\n' + 412 | 'slug: awesomeness-is-awesome\n' + 413 | '---\n' + 414 | 'hello world\n' 415 | ) 416 | }); 417 | }); 418 | 419 | it('should support layout-less', () => { 420 | handlerConfig.layoutName = false; 421 | 422 | return basicTest({ 423 | content: ( 424 | '---\n' + 425 | 'date: \'2015-06-30T14:19:45.000Z\'\n' + 426 | 'title: awesomeness is awesome\n' + 427 | 'lang: en\n' + 428 | 'slug: awesomeness-is-awesome\n' + 429 | '---\n' + 430 | 'hello world\n' 431 | ) 432 | }); 433 | }); 434 | 435 | it('should support callback based permalink style', () => { 436 | handlerConfig.permalinkStyle = [ 437 | { value: 'first/:slug', condition: 'content[] = "hello world"' }, 438 | { value: 'second/:slug', condition: 'abc = 123 AND foo = bar' } 439 | ]; 440 | 441 | return basicTest({ 442 | content: ( 443 | '---\n' + 444 | 'layout: micropubpost\n' + 445 | 'date: \'2015-06-30T14:19:45.000Z\'\n' + 446 | 'title: awesomeness is awesome\n' + 447 | 'lang: en\n' + 448 | 'slug: awesomeness-is-awesome\n' + 449 | '---\n' + 450 | 'hello world\n' 451 | ), 452 | finalUrl: 'http://example.com/foo/first/awesomeness-is-awesome' 453 | }); 454 | }); 455 | 456 | it('should support callback based filename style', () => { 457 | handlerConfig.filenameStyle = [ 458 | { value: 'first/:slug', condition: 'abc = 123 AND foo = bar' }, 459 | { value: 'second/:slug', condition: 'content[] = "hello world"' } 460 | ]; 461 | 462 | return basicTest({ 463 | content: ( 464 | '---\n' + 465 | 'layout: micropubpost\n' + 466 | 'date: \'2015-06-30T14:19:45.000Z\'\n' + 467 | 'title: awesomeness is awesome\n' + 468 | 'lang: en\n' + 469 | 'slug: awesomeness-is-awesome\n' + 470 | '---\n' + 471 | 'hello world\n' 472 | ), 473 | filename: 'second/awesomeness-is-awesome.md' 474 | }); 475 | }); 476 | 477 | it('should correctly HTML-encode text input', () => { 478 | return basicTest({ 479 | contentInput: 'world < hello', 480 | content: ( 481 | '---\n' + 482 | 'layout: micropubpost\n' + 483 | 'date: \'2015-06-30T14:19:45.000Z\'\n' + 484 | 'title: awesomeness is awesome\n' + 485 | 'lang: en\n' + 486 | 'slug: awesomeness-is-awesome\n' + 487 | '---\n' + 488 | 'world < hello\n' 489 | ) 490 | }); 491 | }); 492 | 493 | it('should support HTML-encode opt out', () => { 494 | handlerConfig.encodeHTML = false; 495 | 496 | return basicTest({ 497 | contentInput: 'world < hello', 498 | content: ( 499 | '---\n' + 500 | 'layout: micropubpost\n' + 501 | 'date: \'2015-06-30T14:19:45.000Z\'\n' + 502 | 'title: awesomeness is awesome\n' + 503 | 'lang: en\n' + 504 | 'slug: awesomeness-is-awesome\n' + 505 | '---\n' + 506 | 'world < hello\n' 507 | ) 508 | }); 509 | }); 510 | }); 511 | }); 512 | -------------------------------------------------------------------------------- /test/test.env: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/voxpelli/webpage-micropub-to-github/f71d6dc3aa8549ee0036118d794867c6dc706607/test/test.env --------------------------------------------------------------------------------