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