wip(backend): Work on generating og images
This commit is contained in:
parent
d3095e3354
commit
8a2447f775
6 changed files with 638 additions and 7 deletions
419
artwork/og/template.svg
Normal file
419
artwork/og/template.svg
Normal file
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 35 KiB |
|
@ -3,8 +3,8 @@
|
||||||
"version": "2.19.6",
|
"version": "2.19.6",
|
||||||
"description": "The freesewing.org backend",
|
"description": "The freesewing.org backend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"main": "dist/index.js",
|
"main": "build/main.js",
|
||||||
"module": "dist/index.mjs",
|
"module": "build/main.mjs",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"precommit": "npm run pretty && lint-staged",
|
"precommit": "npm run pretty && lint-staged",
|
||||||
"patch": "npm version patch -m ':bookmark: v%s' && npm run build",
|
"patch": "npm version patch -m ':bookmark: v%s' && npm run build",
|
||||||
|
@ -50,6 +50,7 @@
|
||||||
"formidable": "1.2.1",
|
"formidable": "1.2.1",
|
||||||
"jsonwebtoken": "8.3.0",
|
"jsonwebtoken": "8.3.0",
|
||||||
"jszip": "3.1.5",
|
"jszip": "3.1.5",
|
||||||
|
"mdast-util-to-string": "2",
|
||||||
"mongoose": "5.3.3",
|
"mongoose": "5.3.3",
|
||||||
"mongoose-bcrypt": "1.6.0",
|
"mongoose-bcrypt": "1.6.0",
|
||||||
"mongoose-encryption": "2.0.1",
|
"mongoose-encryption": "2.0.1",
|
||||||
|
@ -57,11 +58,13 @@
|
||||||
"passport": "0.4.0",
|
"passport": "0.4.0",
|
||||||
"passport-jwt": "4.0.0",
|
"passport-jwt": "4.0.0",
|
||||||
"query-string": "6.2.0",
|
"query-string": "6.2.0",
|
||||||
|
"remark": "13",
|
||||||
|
"remark-parse": "^9.0.0",
|
||||||
|
"remark-plain-text": "^0.2.0",
|
||||||
"rimraf": "2.6.2",
|
"rimraf": "2.6.2",
|
||||||
"sharp": "0.21.0"
|
"sharp": "^0.29.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"backpack-core": "0.7.0",
|
"backpack-core": "0.7.0"
|
||||||
"nodemon": "1.18.6"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -52,7 +52,18 @@ const config = {
|
||||||
tokenUri: "https://oauth2.googleapis.com/token",
|
tokenUri: "https://oauth2.googleapis.com/token",
|
||||||
dataUri: "https://people.googleapis.com/v1/people/me?personFields=emailAddresses,names,photos"
|
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
|
export default config
|
||||||
|
|
186
packages/backend/src/controllers/og.js
Normal file
186
packages/backend/src/controllers/og.js
Normal file
|
@ -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;
|
|
@ -5,5 +5,6 @@ import model from "./model";
|
||||||
import referral from "./referral";
|
import referral from "./referral";
|
||||||
import user from "./user";
|
import user from "./user";
|
||||||
import auth from "./auth";
|
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 }
|
||||||
|
|
11
packages/backend/src/routes/og.js
Normal file
11
packages/backend/src/routes/og.js
Normal file
|
@ -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);
|
||||||
|
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue