1
0
Fork 0

wip(backend): Work on generating og images

This commit is contained in:
Joost De Cock 2021-12-29 17:02:14 +01:00
parent d3095e3354
commit 8a2447f775
6 changed files with 638 additions and 7 deletions

419
artwork/og/template.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 35 KiB

View file

@ -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"
}
}

View file

@ -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

View 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;

View file

@ -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 }

View 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);
}