diff --git a/artwork/og/template.svg b/artwork/og/template.svg new file mode 100644 index 00000000000..6bf949cd4e2 --- /dev/null +++ b/artwork/og/template.svg @@ -0,0 +1,419 @@ + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + 3intro_1 + 3title_1 + 3title_2 + 3title_3 + 3intro_2 + + + 1intro_1 + 1title_1 + 1intro_2 + + + + 2intro_1 + 2title_1 + 2title_2 + 2intro_2 + + + FreeSewing + + + + + + + + + + + + + + + + + sub_1 | sub_2 + lead_1 + + diff --git a/packages/backend/package.json b/packages/backend/package.json index badc681eb32..97ca78d17a0 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -3,8 +3,8 @@ "version": "2.19.6", "description": "The freesewing.org backend", "private": true, - "main": "dist/index.js", - "module": "dist/index.mjs", + "main": "build/main.js", + "module": "build/main.mjs", "scripts": { "precommit": "npm run pretty && lint-staged", "patch": "npm version patch -m ':bookmark: v%s' && npm run build", @@ -50,6 +50,7 @@ "formidable": "1.2.1", "jsonwebtoken": "8.3.0", "jszip": "3.1.5", + "mdast-util-to-string": "2", "mongoose": "5.3.3", "mongoose-bcrypt": "1.6.0", "mongoose-encryption": "2.0.1", @@ -57,11 +58,13 @@ "passport": "0.4.0", "passport-jwt": "4.0.0", "query-string": "6.2.0", + "remark": "13", + "remark-parse": "^9.0.0", + "remark-plain-text": "^0.2.0", "rimraf": "2.6.2", - "sharp": "0.21.0" + "sharp": "^0.29.3" }, "devDependencies": { - "backpack-core": "0.7.0", - "nodemon": "1.18.6" + "backpack-core": "0.7.0" } } diff --git a/packages/backend/src/config/index.js b/packages/backend/src/config/index.js index 94db4069bcc..802d5b9f973 100644 --- a/packages/backend/src/config/index.js +++ b/packages/backend/src/config/index.js @@ -52,7 +52,18 @@ const config = { tokenUri: "https://oauth2.googleapis.com/token", dataUri: "https://people.googleapis.com/v1/people/me?personFields=emailAddresses,names,photos" } - } + }, + og: { + template: ["..", "..", "artwork", "og", "template.svg"], + chars: { + title_1: 18, + title_2: 19, + title_3: 20, + intro: 34, + sub: 42 + } + }, + strapi: 'https://posts.freesewing.org', } export default config diff --git a/packages/backend/src/controllers/og.js b/packages/backend/src/controllers/og.js new file mode 100644 index 00000000000..f09fefc1a67 --- /dev/null +++ b/packages/backend/src/controllers/og.js @@ -0,0 +1,186 @@ +import config from "../config"; +import { log } from "../utils"; +import sharp from 'sharp'; +import fs from "fs"; +import path from "path"; +import axios from 'axios' +import remark from 'remark' +import remarkParse from 'remark-parse' +import toString from 'mdast-util-to-string' +import { Buffer } from 'buffer' + +// Sites for which we generate images +const sites = ['dev', 'org'] +// Langauges for which we generate images +const languages = ['en', 'fr', 'de', 'es', 'nl' ] + +// Load template once at startup +const template = fs.readFileSync( + path.resolve(...config.og.template), + 'utf-8' +) + +/* Turns markdown into a syntax tree */ +/* Helper method to extract intro from markdown */ +const introFromMarkdown = async (md, slug) => { + const tree = await remark().use(remarkParse).parse(md) + if (tree.children[0].type !== 'paragraph') + console.log('Markdown does not start with paragraph', slug) + + return toString(tree.children[0]) +} + +/* Helper method to load dev blog post */ +const loadDevBlogPost = async (slug) => { + const result = await axios.get( + `${config.strapi}/blogposts?_locale=en&dev_eq=true&slug_eq=${slug}` + ) + if (result.data) return { + title: titleAsLines(result.data[0].title), + intro: introAsLines(await introFromMarkdown(result.data[0].body, slug)), + sub: [ + result.data[0].author.displayname, + new Date(result.data[0].published_at).toString().split(' ').slice(0,4).join(' '), + ], + lead: 'Developer Blog', + } + + return false +} + +/* Find longest possible place to split a string */ +const splitLine = (line, chars) => { + const words = line.split(' ') + if (words[0].length > chars) { + // Force a word break + return [ line.slice(0, chars-1)+'-', line.slice(chars-1) ] + } + // Glue chunks together until it's too long + let firstLine = '' + let max = false + for (const word of words) { + if (!max && `${firstLine}${word}`.length <= chars) firstLine += `${word} ` + else max = true + } + + return [ firstLine, words.join(' ').slice(firstLine.length) ] +} + +/* Divide title into lines to fit on image */ +const titleAsLines = title => { + // Does it fit on one line? + if (title.length <= config.og.chars.title_1) return [title] + // Does it fit on two lines? + let lines = splitLine(title, config.og.chars.title_1) + if (lines[1].length <= config.og.chars.title_2) return lines + // Three lines it is + return [ lines[0], ...splitLine(lines[1], config.og.chars.title_2) ] +} + +/* Divive intro into lines to fit on image */ +const introAsLines = intro => { + // Does it fit on one line? + if (intro.length <= config.og.chars.intro) return [intro] + // Two lines it is + return splitLine(intro, config.og.chars.intro) +} +// Get title and intro +const getMetaData = { + dev: async (page) => { + const data = {} + const chunks = page.split('/') + if (chunks.length === 0) return { + title: 'FreeSewing FIXME', + intro: "FreeSewing's fixme", + sub: ['freesewing.dev', '/fixme'], + } + if (chunks.length === 1) { + if (chunks[0] === 'blog') return { + title: titleAsLines('FreeSewing Development Blog'), + intro: introAsLines("FreeSewing's blog for developers and contributors"), + sub: ['freesewing.dev', '/blog'], + } + } + if (chunks.length === 2 && chunks[0] === 'blog') { + return await loadDevBlogPost(chunks[1]) + } + }, + org: page => ({}) +} + +/* Hide unused placeholders */ +const hidePlaceholders = list => { + let svg = template + for (const i of list) { + svg = svg + .replace(`${i}title_1`, '') + .replace(`${i}title_2`, '') + .replace(`${i}title_3`, '') + .replace(`${i}intro_1`, '') + .replace(`${i}intro_2`, '') + } + + return svg +} + +/* Place text in SVG template */ +const decorateSvg = data => { + let svg + // Single title line + if (data.title.length === 1) { + svg = hidePlaceholders([2,3]) + .replace(`1title_1`, data.title[0]) + .replace(`1intro_1`, data.intro[0] || '') + .replace(`1intro_2`, data.intro[1] || '') + } + // Double title line + else if (data.title.length === 2) { + svg = hidePlaceholders([1,3]) + .replace(`2title_1`, data.title[0]) + .replace(`2title_2`, data.title[1]) + .replace(`2intro_1`, data.intro[0] || '') + .replace(`2intro_2`, data.intro[1] || '') + } + // Triple title line + else if (data.title.length === 3) { + svg = hidePlaceholders([1,2]) + .replace(`3title_1`, data.title[0]) + .replace(`3title_2`, data.title[1]) + .replace(`3title_3`, data.title[2]) + .replace(`3intro_1`, data.intro[0] || '') + .replace(`3intro_2`, data.intro[1] || '') + } + + return svg + .replace('sub_1', data.sub[0]) + .replace('sub_2', data.sub[1]) + .replace('lead_1', data.lead) +} + +/* This generates open graph images */ + +function OgController() { } + +OgController.prototype.image = async function (req, res) { + // Extract path parameters + const { lang='en', site='dev' } = req.params + const page = req.params["0"] + if (sites.indexOf(site) === -1) return res.send({error: 'sorry'}) + if (languages.indexOf(lang) === -1) return res.send({error: 'sorry'}) + + // Load meta data + const data = await getMetaData[site](page) + // Inject into SVG + const svg = decorateSvg(data) + // Turn into PNG + sharp(Buffer.from(svg, 'utf-8')) + .resize({ width: 1200 }) + .toBuffer((err, data, info) => { + if (err) console.log(err) + return res.type('png').send(data) + }) +} + + + +export default OgController; diff --git a/packages/backend/src/routes/index.js b/packages/backend/src/routes/index.js index 9e2405a6e3c..838df0e94aa 100644 --- a/packages/backend/src/routes/index.js +++ b/packages/backend/src/routes/index.js @@ -5,5 +5,6 @@ import model from "./model"; import referral from "./referral"; import user from "./user"; import auth from "./auth"; +import og from "./og"; -export default { comment, user, draft, model, referral, confirmation, auth } +export default { comment, user, draft, model, referral, confirmation, auth, og } diff --git a/packages/backend/src/routes/og.js b/packages/backend/src/routes/og.js new file mode 100644 index 00000000000..8431f15e453 --- /dev/null +++ b/packages/backend/src/routes/og.js @@ -0,0 +1,11 @@ +import Controller from "../controllers/og"; + +// Note: Og = Open graph. See https://ogp.me/ +const Og = new Controller(); + +export default (app, passport) => { + + // Load open graph image (requires no authentication) + app.get("/og-img/:lang/:site/*", Og.image); + +}