mirror of
https://github.com/overleaf/overleaf.git
synced 2024-11-21 20:47:08 -05:00
Merge pull request #3314 from overleaf/jpa-i18n-safe-html-substitute
[misc] i18n: safe html substitute GitOrigin-RevId: be74605d24084b419324509a403933cf71ed1c8a
This commit is contained in:
parent
2e7e64578f
commit
120df0bfa2
3 changed files with 210 additions and 2 deletions
|
@ -0,0 +1,46 @@
|
||||||
|
const pug = require('pug-runtime')
|
||||||
|
|
||||||
|
const SPLIT_REGEX = /<(\d+)>(.*?)<\/\1>/g
|
||||||
|
|
||||||
|
function render(locale, components) {
|
||||||
|
const output = []
|
||||||
|
function addPlainText(text) {
|
||||||
|
if (!text) return
|
||||||
|
output.push(pug.escape(text))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'PRE<0>INNER</0>POST' -> ['PRE', '0', 'INNER', 'POST']
|
||||||
|
// '<0>INNER</0>' -> ['', '0', 'INNER', '']
|
||||||
|
// '<0></0>' -> ['', '0', '', '']
|
||||||
|
// '<0>INNER</0><0>INNER2</0>' -> ['', '0', 'INNER', '', '0', 'INNER2', '']
|
||||||
|
// '<0><1>INNER</1></0>' -> ['', '0', '<1>INNER</1>', '']
|
||||||
|
// 'PLAIN TEXT' -> ['PLAIN TEXT']
|
||||||
|
// NOTE: a test suite is verifying these cases: SafeHTMLSubstituteTests
|
||||||
|
const chunks = locale.split(SPLIT_REGEX)
|
||||||
|
|
||||||
|
// extract the 'PRE' chunk
|
||||||
|
addPlainText(chunks.shift())
|
||||||
|
|
||||||
|
while (chunks.length) {
|
||||||
|
// each batch consists of three chunks: ['0', 'INNER', 'POST']
|
||||||
|
const [idx, innerChunk, intermediateChunk] = chunks.splice(0, 3)
|
||||||
|
|
||||||
|
const component = components[idx]
|
||||||
|
const componentName =
|
||||||
|
typeof component === 'string' ? component : component.name
|
||||||
|
// pug is doing any necessary escaping on attribute values
|
||||||
|
const attributes = (component.attrs && pug.attrs(component.attrs)) || ''
|
||||||
|
output.push(
|
||||||
|
`<${componentName + attributes}>`,
|
||||||
|
...render(innerChunk, components),
|
||||||
|
`</${componentName}>`
|
||||||
|
)
|
||||||
|
addPlainText(intermediateChunk)
|
||||||
|
}
|
||||||
|
return output.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
SPLIT_REGEX,
|
||||||
|
render
|
||||||
|
}
|
|
@ -12,6 +12,7 @@ const Features = require('./Features')
|
||||||
const AuthenticationController = require('../Features/Authentication/AuthenticationController')
|
const AuthenticationController = require('../Features/Authentication/AuthenticationController')
|
||||||
const PackageVersions = require('./PackageVersions')
|
const PackageVersions = require('./PackageVersions')
|
||||||
const Modules = require('./Modules')
|
const Modules = require('./Modules')
|
||||||
|
const SafeHTMLSubstitute = require('../Features/Helpers/SafeHTMLSubstitution')
|
||||||
|
|
||||||
let webpackManifest
|
let webpackManifest
|
||||||
if (!IS_DEV_ENV) {
|
if (!IS_DEV_ENV) {
|
||||||
|
@ -176,10 +177,15 @@ module.exports = function(webRouter, privateApiRouter, publicApiRouter) {
|
||||||
})
|
})
|
||||||
|
|
||||||
webRouter.use(function(req, res, next) {
|
webRouter.use(function(req, res, next) {
|
||||||
res.locals.translate = function(key, vars) {
|
res.locals.translate = function(key, vars, components) {
|
||||||
vars = vars || {}
|
vars = vars || {}
|
||||||
vars.appName = Settings.appName
|
vars.appName = Settings.appName
|
||||||
return req.i18n.translate(key, vars)
|
const locale = req.i18n.translate(key, vars)
|
||||||
|
if (components) {
|
||||||
|
return SafeHTMLSubstitute.render(locale, components)
|
||||||
|
} else {
|
||||||
|
return locale
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Don't include the query string parameters, otherwise Google
|
// Don't include the query string parameters, otherwise Google
|
||||||
// treats ?nocdn=true as the canonical version
|
// treats ?nocdn=true as the canonical version
|
||||||
|
|
|
@ -0,0 +1,156 @@
|
||||||
|
const { expect } = require('chai')
|
||||||
|
const SandboxedModule = require('sandboxed-module')
|
||||||
|
const MODULE_PATH = require('path').join(
|
||||||
|
__dirname,
|
||||||
|
'../../../../app/src/Features/Helpers/SafeHTMLSubstitution.js'
|
||||||
|
)
|
||||||
|
|
||||||
|
describe('SafeHTMLSubstitution', function() {
|
||||||
|
let SafeHTMLSubstitution
|
||||||
|
before(function() {
|
||||||
|
SafeHTMLSubstitution = SandboxedModule.require(MODULE_PATH)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('SPLIT_REGEX', function() {
|
||||||
|
const CASES = {
|
||||||
|
'PRE<0>INNER</0>POST': ['PRE', '0', 'INNER', 'POST'],
|
||||||
|
'<0>INNER</0>': ['', '0', 'INNER', ''],
|
||||||
|
'<0></0>': ['', '0', '', ''],
|
||||||
|
'<0>INNER</0><0>INNER2</0>': ['', '0', 'INNER', '', '0', 'INNER2', ''],
|
||||||
|
'<0><1>INNER</1></0>': ['', '0', '<1>INNER</1>', ''],
|
||||||
|
'PLAIN TEXT': ['PLAIN TEXT']
|
||||||
|
}
|
||||||
|
Object.entries(CASES).forEach(([input, output]) => {
|
||||||
|
it(`should parse "${input}" as expected`, function() {
|
||||||
|
expect(input.split(SafeHTMLSubstitution.SPLIT_REGEX)).to.deep.equal(
|
||||||
|
output
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('render', function() {
|
||||||
|
describe('substitution', function() {
|
||||||
|
it('should substitute a single component', function() {
|
||||||
|
expect(
|
||||||
|
SafeHTMLSubstitution.render('<0>good</0>', [{ name: 'b' }])
|
||||||
|
).to.equal('<b>good</b>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should substitute a single component as string', function() {
|
||||||
|
expect(SafeHTMLSubstitution.render('<0>good</0>', ['b'])).to.equal(
|
||||||
|
'<b>good</b>'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should substitute a single component twice', function() {
|
||||||
|
expect(
|
||||||
|
SafeHTMLSubstitution.render('<0>one</0><0>two</0>', [{ name: 'b' }])
|
||||||
|
).to.equal('<b>one</b><b>two</b>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should substitute two components', function() {
|
||||||
|
expect(
|
||||||
|
SafeHTMLSubstitution.render('<0>one</0><1>two</1>', [
|
||||||
|
{ name: 'b' },
|
||||||
|
{ name: 'i' }
|
||||||
|
])
|
||||||
|
).to.equal('<b>one</b><i>two</i>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should substitute a single component with a class', function() {
|
||||||
|
expect(
|
||||||
|
SafeHTMLSubstitution.render('<0>text</0>', [
|
||||||
|
{
|
||||||
|
name: 'b',
|
||||||
|
attrs: {
|
||||||
|
class: 'magic'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
|
).to.equal('<b class="magic">text</b>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should substitute two nested components', function() {
|
||||||
|
expect(
|
||||||
|
SafeHTMLSubstitution.render('<0><1>nested</1></0>', [
|
||||||
|
{ name: 'b' },
|
||||||
|
{ name: 'i' }
|
||||||
|
])
|
||||||
|
).to.equal('<b><i>nested</i></b>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle links', function() {
|
||||||
|
expect(
|
||||||
|
SafeHTMLSubstitution.render('<0>Go to Login</0>', [
|
||||||
|
{ name: 'a', attrs: { href: 'https://www.overleaf.com/login' } }
|
||||||
|
])
|
||||||
|
).to.equal('<a href="https://www.overleaf.com/login">Go to Login</a>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not complain about too many components', function() {
|
||||||
|
expect(
|
||||||
|
SafeHTMLSubstitution.render('<0>good</0>', [
|
||||||
|
{ name: 'b' },
|
||||||
|
{ name: 'i' },
|
||||||
|
{ name: 'u' }
|
||||||
|
])
|
||||||
|
).to.equal('<b>good</b>')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('pug.escape', function() {
|
||||||
|
it('should handle plain text', function() {
|
||||||
|
expect(SafeHTMLSubstitution.render('plain text')).to.equal('plain text')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should keep a simple string delimiter', function() {
|
||||||
|
expect(SafeHTMLSubstitution.render("'")).to.equal(`'`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should escape double quotes', function() {
|
||||||
|
expect(SafeHTMLSubstitution.render('"')).to.equal(`"`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should escape &', function() {
|
||||||
|
expect(SafeHTMLSubstitution.render('&')).to.equal(`&`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should escape <', function() {
|
||||||
|
expect(SafeHTMLSubstitution.render('<')).to.equal(`<`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should escape >', function() {
|
||||||
|
expect(SafeHTMLSubstitution.render('>')).to.equal(`>`)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should escape html', function() {
|
||||||
|
expect(SafeHTMLSubstitution.render('<b>bad</b>')).to.equal(
|
||||||
|
'<b>bad</b>'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('escape around substitutions', function() {
|
||||||
|
it('should escape text inside a component', function() {
|
||||||
|
expect(
|
||||||
|
SafeHTMLSubstitution.render('<0><i>inner</i></0>', [{ name: 'b' }])
|
||||||
|
).to.equal('<b><i>inner</i></b>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should escape text in front of a component', function() {
|
||||||
|
expect(
|
||||||
|
SafeHTMLSubstitution.render('<i>PRE</i><0>inner</0>', [{ name: 'b' }])
|
||||||
|
).to.equal('<i>PRE</i><b>inner</b>')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should escape text after of a component', function() {
|
||||||
|
expect(
|
||||||
|
SafeHTMLSubstitution.render('<0>inner</0><i>POST</i>', [
|
||||||
|
{ name: 'b' }
|
||||||
|
])
|
||||||
|
).to.equal('<b>inner</b><i>POST</i>')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
Loading…
Reference in a new issue