diff --git a/config.json.example b/config.json.example index 30bd3ced..11422652 100644 --- a/config.json.example +++ b/config.json.example @@ -3,7 +3,8 @@ "db": { "dialect": "sqlite", "storage": ":memory:" - } + }, + "linkifyHeaderStyle": "gfm" }, "development": { "loglevel": "debug", @@ -13,7 +14,8 @@ "db": { "dialect": "sqlite", "storage": "./db.codimd.sqlite" - } + }, + "linkifyHeaderStyle": "gfm" }, "production": { "domain": "localhost", @@ -127,6 +129,7 @@ "plantuml": { "server": "https://www.plantuml.com/plantuml" - } + }, + "linkifyHeaderStyle": "gfm" } } diff --git a/lib/config/default.js b/lib/config/default.js index 93de2599..e3aee6ca 100644 --- a/lib/config/default.js +++ b/lib/config/default.js @@ -162,5 +162,19 @@ module.exports = { allowEmailRegister: true, allowGravatar: true, allowPDFExport: true, - openID: false + openID: false, + // linkifyHeaderStyle - How is a header text converted into a link id. + // Header Example: "3.1. Good Morning my Friend! - Do you have 5$?" + // * 'keep-case' is the legacy CodiMD value. + // Generated id: "31-Good-Morning-my-Friend---Do-you-have-5" + // * 'lower-case' is the same like legacy (see above), but converted to lower-case. + // Generated id: "#31-good-morning-my-friend---do-you-have-5" + // * 'gfm' _GitHub-Flavored Markdown_ style as described here: + // https://gist.github.com/asabaylus/3071099#gistcomment-1593627 + // It works like 'lower-case', but making sure the ID is unique. + // This is What GitHub, GitLab and (hopefully) most other tools use. + // Generated id: "31-good-morning-my-friend---do-you-have-5" + // 2nd appearance: "31-good-morning-my-friend---do-you-have-5-1" + // 3rd appearance: "31-good-morning-my-friend---do-you-have-5-2" + linkifyHeaderStyle: 'keep-case' } diff --git a/lib/config/environment.js b/lib/config/environment.js index de573e18..905f0e0d 100644 --- a/lib/config/environment.js +++ b/lib/config/environment.js @@ -135,5 +135,6 @@ module.exports = { allowEmailRegister: toBooleanConfig(process.env.CMD_ALLOW_EMAIL_REGISTER), allowGravatar: toBooleanConfig(process.env.CMD_ALLOW_GRAVATAR), allowPDFExport: toBooleanConfig(process.env.CMD_ALLOW_PDF_EXPORT), - openID: toBooleanConfig(process.env.CMD_OPENID) + openID: toBooleanConfig(process.env.CMD_OPENID), + linkifyHeaderStyle: process.env.CMD_LINKIFY_HEADER_STYLE } diff --git a/lib/web/statusRouter.js b/lib/web/statusRouter.js index f8c1f6cf..f1de7dbf 100644 --- a/lib/web/statusRouter.js +++ b/lib/web/statusRouter.js @@ -99,7 +99,8 @@ statusRouter.get('/config', function (req, res) { version: config.fullversion, plantumlServer: config.plantuml.server, DROPBOX_APP_KEY: config.dropbox.appKey, - allowedUploadMimeTypes: config.allowedUploadMimeTypes + allowedUploadMimeTypes: config.allowedUploadMimeTypes, + linkifyHeaderStyle: config.linkifyHeaderStyle } res.set({ 'Cache-Control': 'private', // only cache by client diff --git a/public/js/extra.js b/public/js/extra.js index 03f04664..1282b801 100644 --- a/public/js/extra.js +++ b/public/js/extra.js @@ -168,11 +168,11 @@ export function renderTags (view) { } function slugifyWithUTF8 (text) { - // remove html tags and trim spaces + // remove HTML tags and trim spaces let newText = stripTags(text.toString().trim()) - // replace all spaces in between to dashes + // replace space between words with dashes newText = newText.replace(/\s+/g, '-') - // slugify string to make it valid for attribute + // slugify string to make it valid as an attribute newText = newText.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, '') return newText } @@ -859,6 +859,36 @@ const anchorForId = id => { return anchor } +const createHeaderId = (headerContent, headerIds = null) => { + // to escape characters not allow in css and humanize + const slug = slugifyWithUTF8(headerContent) + let id + if (window.linkifyHeaderStyle === 'keep-case') { + id = slug + } else if (window.linkifyHeaderStyle === 'lower-case') { + // to make compatible with GitHub, GitLab, Pandoc and many more + id = slug.toLowerCase() + } else if (window.linkifyHeaderStyle === 'gfm') { + // see GitHub implementation reference: + // https://gist.github.com/asabaylus/3071099#gistcomment-1593627 + // it works like 'lower-case', but ... + const idBase = slug.toLowerCase() + id = idBase + if (headerIds !== null) { + // ... making sure the id is unique + let i = 1 + while (headerIds.has(id)) { + id = idBase + '-' + i + i++ + } + headerIds.add(id) + } + } else { + throw new Error('Unknown linkifyHeaderStyle value "' + window.linkifyHeaderStyle + '"') + } + return id +} + const linkifyAnchors = (level, containingElement) => { const headers = containingElement.getElementsByTagName(`h${level}`) @@ -866,9 +896,7 @@ const linkifyAnchors = (level, containingElement) => { const header = headers[i] if (header.getElementsByClassName('anchor').length === 0) { if (typeof header.id === 'undefined' || header.id === '') { - // to escape characters not allow in css and humanize - const id = slugifyWithUTF8(getHeaderContent(header)) - header.id = id + header.id = createHeaderId(getHeaderContent(header)) } if (!(typeof header.id === 'undefined' || header.id === '')) { header.insertBefore(anchorForId(header.id), header.firstChild) @@ -894,20 +922,43 @@ function getHeaderContent (header) { return headerHTML[0].innerHTML } +function changeHeaderId ($header, id, newId) { + $header.attr('id', newId) + const $headerLink = $header.find(`> a.anchor[href="#${id}"]`) + $headerLink.attr('href', `#${newId}`) + $headerLink.attr('title', newId) +} + export function deduplicatedHeaderId (view) { + // headers contained in the last change const headers = view.find(':header.raw').removeClass('raw').toArray() - for (let i = 0; i < headers.length; i++) { - const id = $(headers[i]).attr('id') - if (!id) continue - const duplicatedHeaders = view.find(`:header[id="${id}"]`).toArray() - for (let j = 0; j < duplicatedHeaders.length; j++) { - if (duplicatedHeaders[j] !== headers[i]) { - const newId = id + j - const $duplicatedHeader = $(duplicatedHeaders[j]) - $duplicatedHeader.attr('id', newId) - const $headerLink = $duplicatedHeader.find(`> a.anchor[href="#${id}"]`) - $headerLink.attr('href', `#${newId}`) - $headerLink.attr('title', newId) + if (headers.length === 0) { + return + } + if (window.linkifyHeaderStyle === 'gfm') { + // consistent with GitHub, GitLab, Pandoc & co. + // all headers contained in the document, in order of appearance + const allHeaders = view.find(`:header`).toArray() + // list of finaly assigned header IDs + const headerIds = new Set() + for (let j = 0; j < allHeaders.length; j++) { + const $header = $(allHeaders[j]) + const id = $header.attr('id') + const newId = createHeaderId(getHeaderContent($header), headerIds) + changeHeaderId($header, id, newId) + } + } else { + // the legacy way + for (let i = 0; i < headers.length; i++) { + const id = $(headers[i]).attr('id') + if (!id) continue + const duplicatedHeaders = view.find(`:header[id="${id}"]`).toArray() + for (let j = 0; j < duplicatedHeaders.length; j++) { + if (duplicatedHeaders[j] !== headers[i]) { + const newId = id + j + const $header = $(duplicatedHeaders[j]) + changeHeaderId($header, id, newId) + } } } } diff --git a/public/js/lib/common/constant.ejs b/public/js/lib/common/constant.ejs index 7821329d..057c92c8 100644 --- a/public/js/lib/common/constant.ejs +++ b/public/js/lib/common/constant.ejs @@ -6,4 +6,6 @@ window.plantumlServer = '<%- plantumlServer %>' window.allowedUploadMimeTypes = <%- JSON.stringify(allowedUploadMimeTypes) %> +window.linkifyHeaderStyle = '<%- linkifyHeaderStyle %>' + window.DROPBOX_APP_KEY = '<%- DROPBOX_APP_KEY %>'