Merge pull request #1677 from hackmdio/release/2.4.0

Release 2.4.0
This commit is contained in:
Yukai Huang 2021-05-11 19:24:04 +08:00 committed by GitHub
commit 2e468db210
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 575 additions and 863 deletions

View File

@ -71,3 +71,5 @@ If you set your `user.name` and `user.email` git configs, you can sign your
commit automatically with `git commit -s`. You can also use git [aliases](https://git-scm.com/book/tr/v2/Git-Basics-Git-Aliases) commit automatically with `git commit -s`. You can also use git [aliases](https://git-scm.com/book/tr/v2/Git-Basics-Git-Aliases)
like `git config --global alias.ci 'commit -s'`. Now you can commit with like `git config --global alias.ci 'commit -s'`. Now you can commit with
`git ci` and the commit will be signed. `git ci` and the commit will be signed.
[dcofile]: https://github.com/hackmdio/codimd/blob/develop/contribute/developer-certificate-of-origin

View File

@ -4,6 +4,7 @@ CodiMD
[![build status][travis-image]][travis-url] [![build status][travis-image]][travis-url]
[![version][github-version-badge]][github-release-page] [![version][github-version-badge]][github-release-page]
[![Gitter][gitter-image]][gitter-url] [![Gitter][gitter-image]][gitter-url]
[![Matrix][matrix-image]][matrix-url]
[![POEditor][poeditor-image]][poeditor-url] [![POEditor][poeditor-image]][poeditor-url]
CodiMD lets you collaborate in real-time with markdown. CodiMD lets you collaborate in real-time with markdown.
@ -99,3 +100,5 @@ To stay up to date with your installation it's recommended to subscribe the [rel
[github-release-feed]: https://github.com/hackmdio/codimd/releases.atom [github-release-feed]: https://github.com/hackmdio/codimd/releases.atom
[poeditor-image]: https://img.shields.io/badge/POEditor-translate-blue.svg [poeditor-image]: https://img.shields.io/badge/POEditor-translate-blue.svg
[poeditor-url]: https://poeditor.com/join/project/q0nuPWyztp [poeditor-url]: https://poeditor.com/join/project/q0nuPWyztp
[matrix-image]: https://img.shields.io/matrix/hackmdio_hackmd:gitter.im?color=blue&logo=matrix
[matrix-url]: https://matrix.to/#/#hackmdio_hackmd:gitter.im

1
app.js
View File

@ -194,6 +194,7 @@ app.set('view engine', 'ejs')
app.locals.useCDN = config.useCDN app.locals.useCDN = config.useCDN
app.locals.serverURL = config.serverURL app.locals.serverURL = config.serverURL
app.locals.sourceURL = config.sourceURL app.locals.sourceURL = config.sourceURL
app.locals.privacyPolicyURL = config.privacyPolicyURL
app.locals.allowAnonymous = config.allowAnonymous app.locals.allowAnonymous = config.allowAnonymous
app.locals.allowAnonymousEdits = config.allowAnonymousEdits app.locals.allowAnonymousEdits = config.allowAnonymousEdits
app.locals.permission = config.permission app.locals.permission = config.permission

View File

@ -143,6 +143,10 @@
"CMD_ALLOW_PDF_EXPORT": { "CMD_ALLOW_PDF_EXPORT": {
"description": "Enable or disable PDF exports", "description": "Enable or disable PDF exports",
"required": false "required": false
},
"PGSSLMODE": {
"description": "Enforce PG SSL mode",
"value": "require"
} }
}, },
"addons": [ "addons": [

View File

@ -5,9 +5,22 @@ const config = require('../config')
const logger = require('../logger') const logger = require('../logger')
exports.setReturnToFromReferer = function setReturnToFromReferer (req) { exports.setReturnToFromReferer = function setReturnToFromReferer (req) {
var referer = req.get('referer')
if (!req.session) req.session = {} if (!req.session) req.session = {}
var referer = req.get('referer')
var refererSearchParams = new URLSearchParams(new URL(referer).search)
var nextURL = refererSearchParams.get('next')
if (nextURL) {
var isRelativeNextURL = nextURL.indexOf('://') === -1 && !nextURL.startsWith('//')
if (isRelativeNextURL) {
req.session.returnTo = (new URL(nextURL, config.serverURL)).toString()
} else {
req.session.returnTo = config.serverURL
}
} else {
req.session.returnTo = referer req.session.returnTo = referer
}
} }
exports.passportGeneralCallback = function callback (accessToken, refreshToken, profile, done) { exports.passportGeneralCallback = function callback (accessToken, refreshToken, profile, done) {

View File

@ -37,6 +37,7 @@ module.exports = {
defaultPermission: 'editable', defaultPermission: 'editable',
dbURL: '', dbURL: '',
db: {}, db: {},
privacyPolicyURL: '',
// ssl path // ssl path
sslKeyPath: '', sslKeyPath: '',
sslCertPath: '', sslCertPath: '',
@ -188,5 +189,6 @@ module.exports = {
// 2nd appearance: "31-good-morning-my-friend---do-you-have-5-1" // 2nd appearance: "31-good-morning-my-friend---do-you-have-5-1"
// 3rd appearance: "31-good-morning-my-friend---do-you-have-5-2" // 3rd appearance: "31-good-morning-my-friend---do-you-have-5-2"
linkifyHeaderStyle: 'keep-case', linkifyHeaderStyle: 'keep-case',
autoVersionCheck: true autoVersionCheck: true,
defaultTocDepth: 3
} }

View File

@ -35,6 +35,7 @@ module.exports = {
sessionSecret: process.env.CMD_SESSION_SECRET, sessionSecret: process.env.CMD_SESSION_SECRET,
sessionLife: toIntegerConfig(process.env.CMD_SESSION_LIFE), sessionLife: toIntegerConfig(process.env.CMD_SESSION_LIFE),
responseMaxLag: toIntegerConfig(process.env.CMD_RESPONSE_MAX_LAG), responseMaxLag: toIntegerConfig(process.env.CMD_RESPONSE_MAX_LAG),
privacyPolicyURL: process.env.CMD_PRIVACY_POLICY_URL,
imageUploadType: process.env.CMD_IMAGE_UPLOAD_TYPE, imageUploadType: process.env.CMD_IMAGE_UPLOAD_TYPE,
imgur: { imgur: {
clientID: process.env.CMD_IMGUR_CLIENTID clientID: process.env.CMD_IMGUR_CLIENTID
@ -147,5 +148,6 @@ module.exports = {
openID: toBooleanConfig(process.env.CMD_OPENID), openID: toBooleanConfig(process.env.CMD_OPENID),
defaultUseHardbreak: toBooleanConfig(process.env.CMD_DEFAULT_USE_HARD_BREAK), defaultUseHardbreak: toBooleanConfig(process.env.CMD_DEFAULT_USE_HARD_BREAK),
linkifyHeaderStyle: process.env.CMD_LINKIFY_HEADER_STYLE, linkifyHeaderStyle: process.env.CMD_LINKIFY_HEADER_STYLE,
autoVersionCheck: toBooleanConfig(process.env.CMD_AUTO_VERSION_CHECK) autoVersionCheck: toBooleanConfig(process.env.CMD_AUTO_VERSION_CHECK),
defaultTocDepth: toIntegerConfig(process.env.CMD_DEFAULT_TOC_DEPTH)
} }

View File

@ -92,20 +92,22 @@ module.exports = function (sequelize, DataTypes) {
return new Promise(function (resolve, reject) { return new Promise(function (resolve, reject) {
// if no content specified then use default note // if no content specified then use default note
if (!note.content) { if (!note.content) {
var body = null let filePath = config.defaultNotePath
let filePath = null
if (!note.alias) { if (note.alias) {
filePath = config.defaultNotePath const notePathInDocPath = path.join(config.docsPath, path.basename(note.alias) + '.md')
} else { if (Note.checkFileExist(notePathInDocPath)) {
filePath = path.join(config.docsPath, note.alias + '.md') filePath = notePathInDocPath
} }
}
if (Note.checkFileExist(filePath)) { if (Note.checkFileExist(filePath)) {
var fsCreatedTime = moment(fs.statSync(filePath).ctime) const noteInFS = readFileSystemNote(filePath)
body = fs.readFileSync(filePath, 'utf8') note.title = noteInFS.title
note.title = Note.parseNoteTitle(body) note.content = noteInFS.content
note.content = body
if (filePath !== config.defaultNotePath) { if (filePath !== config.defaultNotePath) {
note.createdAt = fsCreatedTime note.createdAt = noteInFS.lastchangeAt
note.lastchangeAt = noteInFS.lastchangeAt
} }
} }
} }
@ -196,6 +198,29 @@ module.exports = function (sequelize, DataTypes) {
}) })
}) })
} }
async function syncNote (noteInFS, note) {
const contentLength = noteInFS.content.length
let note2 = await note.update({
title: noteInFS.title,
content: noteInFS.content,
lastchangeAt: noteInFS.lastchangeAt
})
const revision = await sequelize.models.Revision.saveNoteRevisionAsync(note2)
// update authorship on after making revision of docs
const patch = dmp.patch_fromText(revision.patch)
const operations = Note.transformPatchToOperations(patch, contentLength)
let authorship = note2.authorship
for (let i = 0; i < operations.length; i++) {
authorship = Note.updateAuthorshipByOperation(operations[i], null, authorship)
}
note2 = await note.update({
authorship: authorship
})
return note2.id
}
Note.parseNoteId = function (noteId, callback) { Note.parseNoteId = function (noteId, callback) {
async.series({ async.series({
parseNoteIdByAlias: function (_callback) { parseNoteIdByAlias: function (_callback) {
@ -204,65 +229,35 @@ module.exports = function (sequelize, DataTypes) {
where: { where: {
alias: noteId alias: noteId
} }
}).then(function (note) { }).then(async function (note) {
if (note) { const filePath = path.join(config.docsPath, path.basename(noteId) + '.md')
const filePath = path.join(config.docsPath, noteId + '.md')
if (Note.checkFileExist(filePath)) { if (Note.checkFileExist(filePath)) {
try {
if (note) {
// if doc in filesystem have newer modified time than last change time // if doc in filesystem have newer modified time than last change time
// then will update the doc in db // then will update the doc in db
var fsModifiedTime = moment(fs.statSync(filePath).mtime) const noteInFS = readFileSystemNote(filePath)
var dbModifiedTime = moment(note.lastchangeAt || note.createdAt) if (shouldSyncNote(note, noteInFS)) {
var body = fs.readFileSync(filePath, 'utf8') const noteId = await syncNote(noteInFS, note)
var contentLength = body.length return callback(null, noteId)
var title = Note.parseNoteTitle(body)
if (fsModifiedTime.isAfter(dbModifiedTime) && note.content !== body) {
note.update({
title: title,
content: body,
lastchangeAt: fsModifiedTime
}).then(function (note) {
sequelize.models.Revision.saveNoteRevision(note, function (err, revision) {
if (err) return _callback(err, null)
// update authorship on after making revision of docs
var patch = dmp.patch_fromText(revision.patch)
var operations = Note.transformPatchToOperations(patch, contentLength)
var authorship = note.authorship
for (let i = 0; i < operations.length; i++) {
authorship = Note.updateAuthorshipByOperation(operations[i], null, authorship)
}
note.update({
authorship: authorship
}).then(function (note) {
return callback(null, note.id)
}).catch(function (err) {
return _callback(err, null)
})
})
}).catch(function (err) {
return _callback(err, null)
})
} else {
return callback(null, note.id)
} }
} else { } else {
return callback(null, note.id) // create new note with alias, and will sync md file in beforeCreateHook
} const note = await Note.create({
} else {
var filePath = path.join(config.docsPath, noteId + '.md')
if (Note.checkFileExist(filePath)) {
Note.create({
alias: noteId, alias: noteId,
owner: null, owner: null,
permission: 'locked' permission: 'locked'
}).then(function (note) {
return callback(null, note.id)
}).catch(function (err) {
return _callback(err, null)
}) })
} else { return callback(null, note.id)
}
} catch (err) {
return _callback(err, null)
}
}
if (!note) {
return _callback(null, null) return _callback(null, null)
} }
} return callback(null, note.id)
}).catch(function (err) { }).catch(function (err) {
return _callback(err, null) return _callback(err, null)
}) })
@ -589,5 +584,21 @@ module.exports = function (sequelize, DataTypes) {
return operations return operations
} }
function readFileSystemNote (filePath) {
const fsModifiedTime = moment(fs.statSync(filePath).mtime)
const content = fs.readFileSync(filePath, 'utf8')
return {
lastchangeAt: fsModifiedTime,
title: Note.parseNoteTitle(content),
content: content
}
}
function shouldSyncNote (note, noteInFS) {
const dbModifiedTime = moment(note.lastchangeAt || note.createdAt)
return noteInFS.lastchangeAt.isAfter(dbModifiedTime) && note.content !== noteInFS.content
}
return Note return Note
} }

View File

@ -6,6 +6,7 @@ var moment = require('moment')
var childProcess = require('child_process') var childProcess = require('child_process')
var shortId = require('shortid') var shortId = require('shortid')
var path = require('path') var path = require('path')
var util = require('util')
var Op = Sequelize.Op var Op = Sequelize.Op
@ -296,6 +297,7 @@ module.exports = function (sequelize, DataTypes) {
return callback(err, null) return callback(err, null)
}) })
} }
Revision.saveNoteRevisionAsync = util.promisify(Revision.saveNoteRevision)
Revision.finishSaveNoteRevision = function (note, revision, callback) { Revision.finishSaveNoteRevision = function (note, revision, callback) {
note.update({ note.update({
savedAt: revision.updatedAt savedAt: revision.updatedAt

View File

@ -268,7 +268,7 @@ const deleteNote = async (req, res) => {
} }
const updateNote = async (req, res) => { const updateNote = async (req, res) => {
if (req.isAuthenticated()) { if (req.isAuthenticated() || config.allowAnonymousEdits) {
const noteId = await Note.parseNoteIdAsync(req.params.noteId) const noteId = await Note.parseNoteIdAsync(req.params.noteId)
try { try {
const note = await Note.findOne({ const note = await Note.findOne({
@ -294,7 +294,7 @@ const updateNote = async (req, res) => {
lastchangeAt: now, lastchangeAt: now,
authorship: [ authorship: [
[ [
req.user.id, req.isAuthenticated() ? req.user.id : null,
0, 0,
content.length, content.length,
now, now,
@ -308,7 +308,9 @@ const updateNote = async (req, res) => {
return errorInternalError(req, res) return errorInternalError(req, res)
} }
updateHistory(req.user.id, note.id, content) if (req.isAuthenticated()) {
updateHistory(req.user.id, noteId, content)
}
Revision.saveNoteRevision(note, (err, revision) => { Revision.saveNoteRevision(note, (err, revision) => {
if (err) { if (err) {
@ -321,7 +323,7 @@ const updateNote = async (req, res) => {
}) })
}) })
} catch (err) { } catch (err) {
logger.error(err) logger.error(err.stack)
logger.error('Update note failed: Internal Error.') logger.error('Update note failed: Internal Error.')
return errorInternalError(req, res) return errorInternalError(req, res)
} }

View File

@ -32,8 +32,10 @@ function errorForbidden (req, res) {
if (req.user) { if (req.user) {
responseError(res, '403', 'Forbidden', 'oh no.') responseError(res, '403', 'Forbidden', 'oh no.')
} else { } else {
var nextURL = new URL('', config.serverURL)
nextURL.search = new URLSearchParams({ next: req.originalUrl })
req.flash('error', 'You are not allowed to access this page. Maybe try logging in?') req.flash('error', 'You are not allowed to access this page. Maybe try logging in?')
res.redirect(config.serverURL + '/') res.redirect(nextURL.toString())
} }
} }

View File

@ -41,7 +41,8 @@ exports.getConfig = (req, res) => {
allowedUploadMimeTypes: config.allowedUploadMimeTypes, allowedUploadMimeTypes: config.allowedUploadMimeTypes,
defaultUseHardbreak: config.defaultUseHardbreak, defaultUseHardbreak: config.defaultUseHardbreak,
linkifyHeaderStyle: config.linkifyHeaderStyle, linkifyHeaderStyle: config.linkifyHeaderStyle,
useCDN: config.useCDN useCDN: config.useCDN,
defaultTocDepth: config.defaultTocDepth
} }
res.set({ res.set({
'Cache-Control': 'private', // only cache by client 'Cache-Control': 'private', // only cache by client

864
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -27,7 +27,7 @@
"coverage:ci": "nyc mocha --no-color -R dot --require intelli-espower-loader --exit --recursive ./test", "coverage:ci": "nyc mocha --no-color -R dot --require intelli-espower-loader --exit --recursive ./test",
"test": "npm run-script lint && npm run-script jsonlint && npm run-script coverage", "test": "npm run-script lint && npm run-script jsonlint && npm run-script coverage",
"test:ci": "npm run-script lint && npm run-script jsonlint && npm run-script coverage:ci", "test:ci": "npm run-script lint && npm run-script jsonlint && npm run-script coverage:ci",
"postinstall": "bin/heroku" "heroku-postbuild": "npm run build && ./bin/heroku"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3-node": "0.1.0-preview.2", "@aws-sdk/client-s3-node": "0.1.0-preview.2",
@ -105,7 +105,7 @@
"ws": "~7.1.1" "ws": "~7.1.1"
}, },
"devDependencies": { "devDependencies": {
"@hackmd/codemirror": "~5.49.8", "@hackmd/codemirror": "~5.57.7",
"@hackmd/emojify.js": "^2.1.0", "@hackmd/emojify.js": "^2.1.0",
"@hackmd/idle-js": "~1.0.1", "@hackmd/idle-js": "~1.0.1",
"@hackmd/js-sequence-diagrams": "~0.0.1-alpha.3", "@hackmd/js-sequence-diagrams": "~0.0.1-alpha.3",
@ -163,7 +163,8 @@
"markdown-it-ruby": "^0.1.1", "markdown-it-ruby": "^0.1.1",
"markdown-it-sub": "~1.0.0", "markdown-it-sub": "~1.0.0",
"markdown-it-sup": "~1.0.0", "markdown-it-sup": "~1.0.0",
"markdownlint": "^0.17.0", "markdownlint": "^0.22.0",
"markdownlint-rule-helpers": "^0.13.0",
"markmap-lib": "^0.4.2", "markmap-lib": "^0.4.2",
"mathjax": "~2.7.5", "mathjax": "~2.7.5",
"mermaid": "~8.6.4", "mermaid": "~8.6.4",
@ -173,7 +174,7 @@
"nyc": "~14.0.0", "nyc": "~14.0.0",
"optimize-css-assets-webpack-plugin": "~5.0.0", "optimize-css-assets-webpack-plugin": "~5.0.0",
"papaparse": "^5.2.0", "papaparse": "^5.2.0",
"pdfobject": "~2.1.1", "pdfobject": "~2.2.4",
"plantuml-encoder": "^1.2.5", "plantuml-encoder": "^1.2.5",
"power-assert": "~1.6.1", "power-assert": "~1.6.1",
"prismjs": "^1.17.1", "prismjs": "^1.17.1",

View File

@ -263,6 +263,32 @@
padding-right: 40px; padding-right: 40px;
} }
.ui-toc-dropdown .nav .nav>li>ul>li>ul>li>a {
padding-top: 1px;
padding-bottom: 1px;
padding-left: 50px;
font-size: 12px;
font-weight: 400;
}
.ui-toc-dropdown[dir='rtl'] .nav .nav>li>ul>li>ul>li>a {
padding-right: 50px;
}
.ui-toc-dropdown .nav .nav>li>ul>li>ul>li>ul>li>a {
padding-top: 1px;
padding-bottom: 1px;
padding-left: 60px;
font-size: 12px;
font-weight: 400;
}
.ui-toc-dropdown[dir='rtl'] .nav .nav>li>ul>li>ul>li>ul>li>a {
padding-right: 60px;
}
.ui-toc-dropdown .nav .nav>li>a:focus,.ui-toc-dropdown .nav .nav>li>a:hover { .ui-toc-dropdown .nav .nav>li>a:focus,.ui-toc-dropdown .nav .nav>li>a:hover {
padding-left: 29px; padding-left: 29px;
} }
@ -279,6 +305,22 @@
padding-right: 39px; padding-right: 39px;
} }
.ui-toc-dropdown .nav .nav>li>ul>li>ul>li>a:focus,.ui-toc-dropdown .nav .nav>li>ul>li>ul>li>a:hover {
padding-left: 49px;
}
.ui-toc-dropdown[dir='rtl'] .nav .nav>li>ul>li>ul>li>a:focus,.ui-toc-dropdown[dir='rtl'] .nav .nav>li>ul>li>ul>li>a:hover {
padding-right: 49px;
}
.ui-toc-dropdown .nav .nav>li>ul>li>ul>li>ul>li>a:focus,.ui-toc-dropdown .nav .nav>li>ul>li>ul>li>ul>li>a:hover {
padding-left: 59px;
}
.ui-toc-dropdown[dir='rtl'] .nav .nav>li>ul>li>ul>li>ul>li>a:focus,.ui-toc-dropdown[dir='rtl'] .nav .nav>li>ul>li>ul>li>ul>li>a:hover {
padding-right: 59px;
}
.ui-toc-dropdown .nav .nav>.active:focus>a,.ui-toc-dropdown .nav .nav>.active:hover>a,.ui-toc-dropdown .nav .nav>.active>a { .ui-toc-dropdown .nav .nav>.active:focus>a,.ui-toc-dropdown .nav .nav>.active:hover>a,.ui-toc-dropdown .nav .nav>.active>a {
padding-left: 28px; padding-left: 28px;
font-weight: 500; font-weight: 500;
@ -297,6 +339,24 @@
padding-right: 38px; padding-right: 38px;
} }
.ui-toc-dropdown .nav .nav>.active>.nav>.active>.nav>.active:focus>a,.ui-toc-dropdown .nav .nav>.active>.nav>.active>.nav>.active:hover>a,.ui-toc-dropdown .nav .nav>.active>.nav>.active>.nav>.active>a {
padding-left: 48px;
font-weight: 500;
}
.ui-toc-dropdown[dir='rtl'] .nav .nav>.active>.nav>.active>.nav>.active:focus>a,.ui-toc-dropdown[dir='rtl'] .nav .nav>.active>.nav>.active>.nav>.active:hover>a,.ui-toc-dropdown[dir='rtl'] .nav .nav>.active>.active>.nav>.nav>.active>a {
padding-right: 48px;
}
.ui-toc-dropdown .nav .nav>.active>.nav>.active>.nav>.active>.nav>.active:focus>a,.ui-toc-dropdown .nav .nav>.active>.nav>.active>.nav>.active>.nav>.active:hover>a,.ui-toc-dropdown .nav .nav>.active>.nav>.active>.nav>.active>.nav>.active>a {
padding-left: 58px;
font-weight: 500;
}
.ui-toc-dropdown[dir='rtl'] .nav .nav>.active>.nav>.active>.nav>.active>.nav>.active:focus>a,.ui-toc-dropdown[dir='rtl'] .nav .nav>.active>.nav>.active>.nav>.active>.nav>.active:hover>a,.ui-toc-dropdown[dir='rtl'] .nav .nav>.active>.active>.nav>.nav>.active>.nav>.active>a {
padding-right: 58px;
}
/* support japanese font */ /* support japanese font */
.markdown-body[lang^="ja"] { .markdown-body[lang^="ja"] {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Helvetica, Arial, "Hiragino Kaku Gothic Pro", "ヒラギノ角ゴ Pro W3", Osaka, Meiryo, "メイリオ", "MS Gothic", " ゴシック", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Helvetica, Arial, "Hiragino Kaku Gothic Pro", "ヒラギノ角ゴ Pro W3", Osaka, Meiryo, "メイリオ", "MS Gothic", " ゴシック", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";

View File

@ -82,7 +82,8 @@ View
## Table of Contents: ## Table of Contents:
You can look at the bottom right section of the view area, there is a _ToC_ button <i class="fa fa-bars"></i>. You can look at the bottom right section of the view area, there is a _ToC_ button <i class="fa fa-bars"></i>.
Pressing that button will show you a current _Table of Contents_, and will highlight which section you're at. Pressing that button will show you a current _Table of Contents_, and will highlight which section you're at.
ToCs support up to **three header levels**. ToCs support up to **five header levels**, the **default** is **set to three**. The maxLevel can be set for each note by using
[YAML Metadata](./yaml-metadata)
## Permalink ## Permalink
Every header will automatically add a permalink on the right side. Every header will automatically add a permalink on the right side.
@ -133,12 +134,19 @@ You can provide advanced note information to set the browser behavior (visit abo
- GA: set to use Google Analytics - GA: set to use Google Analytics
- disqus: set to use Disqus - disqus: set to use Disqus
- slideOptions: setup slide mode options - slideOptions: setup slide mode options
- toc: set options of the Table of Contents.
## ToC: ## ToC:
Use the syntax `[TOC]` to embed table of content into your note. Use the syntax `[TOC]` to embed table of content into your note. By default, three header levels are displayed. This can also be specified by using [YAML Metadata](./yaml-metadata).
[TOC] [TOC]
You can also specify the number of header levels by specifying the `maxLevel` like this: `[TOC maxLevel=1]`
[TOC maxLevel=1]
## Emoji ## Emoji
You can type any emoji like this :smile: :smiley: :cry: :wink: You can type any emoji like this :smile: :smiley: :cry: :wink:
> See full emoji list [here](http://www.emoji-cheat-sheet.com/). > See full emoji list [here](http://www.emoji-cheat-sheet.com/).

View File

@ -1,6 +1,44 @@
Release Notes Release Notes
=== ===
<i class="fa fa-tag"></i> 2.4.0 Papilio maraho <i class="fa fa-clock-o"></i> 2021-05-11
---
<div style="text-align: center; margin-bottom: 1em;">
<img src="https://upload.wikimedia.org/wikipedia/commons/thumb/3/32/Papilio_maraho_male_ventral_view_20160423.jpg/569px-Papilio_maraho_male_ventral_view_20160423.jpg" width="600">
<small style="display: block;">Papilio maraho</small>
</div>
> Papilio maraho is a species of butterfly in the family Papilionidae. It is endemic to Taiwan.
> \- Wikipedia [Papilio maraho](https://en.wikipedia.org/wiki/Papilio_maraho)
[Check out the complete release note][v2_4_0]. Thank you CodiMD community and all our contributors. ❤️
[v2_4_0]: https://hackmd.io/@codimd/release-notes/%2F%40codimd%2Fv2_4_0
## Enhancements
- Support autofix linter errors
- Support anonymous updates via API
- Support mediawiki export format in pandoc export
- Add some help strings to Prometheus metrics
- Allow more syntax highlight modes in editor
- Support TOC level customization
- Follow Google guidelines to use Google OAuth
## Fixes
- Vimeo won't show up due to the jsonp callback data unable be parsed with jQuery
- Fix slide mode stored XSS
- Enforce PG ssl require mode on heroku
- Webpack exclude path should support windows path
- Free url can read any md in file system
- Use encoded noteId when calling updateHistory
## Docs
- Add matrix badge and links to README [#1629](https://github.com/hackmdio/codimd/pull/1629) [@a-andreyev](https://github.com/a-andreyev)
<i class="fa fa-tag"></i> 2.3.1 Isoetes taiwanensis <i class="fa fa-clock-o"></i> 2021-01-04 <i class="fa fa-tag"></i> 2.3.1 Isoetes taiwanensis <i class="fa fa-clock-o"></i> 2021-01-04
--- ---

View File

@ -139,6 +139,22 @@ This option allows you to enable Disqus with your shortname.
disqus: codimd disqus: codimd
``` ```
toc
---
This option allows you to set options regarding the table of contents (toc). Currently, its only option is to set the maxDepth.
**Notice: always use two spaces as indention in YAML metadata!**
> **maxDepth:**
> default: not set (whioch will show everything until level 3 (h1 -- h3))
> max: 5 (as defined by md-toc.js)
**Example**
type type
--- ---
This option allows you to switch the document view to the slide preview, to simplify live editing of presentations. This option allows you to switch the document view to the slide preview, to simplify live editing of presentations.

View File

@ -260,6 +260,23 @@ if (typeof window.mermaid !== 'undefined' && window.mermaid) {
} }
} }
function jsonp (url, callback) {
const callbackName = 'jsonp_callback_' + Math.round(1000000000 * Math.random())
window[callbackName] = function (data) {
delete window[callbackName]
document.body.removeChild(script)
callback(data)
}
const script = document.createElement('script')
script.src = url + (url.indexOf('?') >= 0 ? '&' : '?') + 'callback=' + callbackName
document.body.appendChild(script)
script.onerror = function (e) {
console.error(e)
script.remove()
}
}
// dynamic event or object binding here // dynamic event or object binding here
export function finishView (view) { export function finishView (view) {
// todo list // todo list
@ -304,17 +321,11 @@ export function finishView (view) {
imgPlayiframe(this, '//player.vimeo.com/video/') imgPlayiframe(this, '//player.vimeo.com/video/')
}) })
.each((key, value) => { .each((key, value) => {
$.ajax({ jsonp(`//vimeo.com/api/v2/video/${$(value).attr('data-videoid')}.json`, function (data) {
type: 'GET',
url: `//vimeo.com/api/v2/video/${$(value).attr('data-videoid')}.json`,
jsonp: 'callback',
dataType: 'jsonp',
success (data) {
const thumbnailSrc = data[0].thumbnail_large const thumbnailSrc = data[0].thumbnail_large
const image = `<img src="${thumbnailSrc}" />` const image = `<img src="${thumbnailSrc}" />`
$(value).prepend(image) $(value).prepend(image)
if (window.viewAjaxCallback) window.viewAjaxCallback() if (window.viewAjaxCallback) window.viewAjaxCallback()
}
}) })
}) })
// gist // gist
@ -597,9 +608,11 @@ export function finishView (view) {
const url = $(value).attr('data-pdfurl') const url = $(value).attr('data-pdfurl')
const inner = $('<div></div>') const inner = $('<div></div>')
$(this).append(inner) $(this).append(inner)
setTimeout(() => {
PDFObject.embed(url, inner, { PDFObject.embed(url, inner, {
height: '400px' height: '400px'
}) })
}, 1)
}) })
// syntax highlighting // syntax highlighting
view.find('code.raw').removeClass('raw') view.find('code.raw').removeClass('raw')
@ -864,8 +877,12 @@ export function generateToc (id) {
const target = $(`#${id}`) const target = $(`#${id}`)
target.html('') target.html('')
/* eslint-disable no-unused-vars */ /* eslint-disable no-unused-vars */
var tocOptions = md.meta.toc || {}
var maxLevel = (typeof tocOptions.maxLevel === 'number' && tocOptions.maxLevel > 0) ? tocOptions.maxLevel : window.defaultTocDepth
var toc = new window.Toc('doc', { var toc = new window.Toc('doc', {
level: 3, level: maxLevel,
top: -1, top: -1,
class: 'toc', class: 'toc',
ulClass: 'nav', ulClass: 'nav',
@ -1063,11 +1080,20 @@ export function renderTOC (view) {
const target = $(`#${id}`) const target = $(`#${id}`)
target.html('') target.html('')
/* eslint-disable no-unused-vars */ /* eslint-disable no-unused-vars */
const specificDepth = parseInt(toc.data('toc-depth'))
var tocOptions = md.meta.toc || {}
var yamlMaxDepth = (typeof tocOptions.maxLevel === 'number' && tocOptions.maxLevel > 0) ? tocOptions.maxLevel : window.defaultTocDepth
var maxLevel = specificDepth || yamlMaxDepth
const TOC = new window.Toc('doc', { const TOC = new window.Toc('doc', {
level: 3, level: maxLevel,
top: -1, top: -1,
class: 'toc', class: 'toc',
targetId: id, targetId: id,
data: { tocDepth: specificDepth },
process: getHeaderContent process: getHeaderContent
}) })
/* eslint-enable no-unused-vars */ /* eslint-enable no-unused-vars */
@ -1322,9 +1348,12 @@ const gistPlugin = new Plugin(
// TOC // TOC
const tocPlugin = new Plugin( const tocPlugin = new Plugin(
// regexp to match // regexp to match
/^\[TOC\]$/i, /^\[TOC(|\s*maxLevel=\d+?)\]$/i,
(match, utils) => '<div class="toc"></div>' (match, utils) => {
const tocDepth = match[1].split(/[?&=]+/)[1]
return `<div class="toc" data-toc-depth="${tocDepth}"></div>`
}
) )
// slideshare // slideshare
const slidesharePlugin = new Plugin( const slidesharePlugin = new Plugin(

View File

@ -13,3 +13,5 @@ window.linkifyHeaderStyle = '<%- linkifyHeaderStyle %>'
window.DROPBOX_APP_KEY = '<%- DROPBOX_APP_KEY %>' window.DROPBOX_APP_KEY = '<%- DROPBOX_APP_KEY %>'
window.USE_CDN = <%- useCDN %> window.USE_CDN = <%- useCDN %>
window.defaultTocDepth = <%- defaultTocDepth %>

View File

@ -1,9 +1,23 @@
# HELP online_notes Number of currently used notes
# TYPE online_notes gauge
online_notes <%- onlineNotes %> online_notes <%- onlineNotes %>
# HELP online_users Number of online users
# TYPE online_users gauge
online_users <%- onlineUsers %> online_users <%- onlineUsers %>
# HELP distinct_online_users Number of distinct online users
# TYPE distinct_online_users gauge
distinct_online_users <%- distinctOnlineUsers %> distinct_online_users <%- distinctOnlineUsers %>
# HELP notes_count Total count of notes
# TYPE notes_count gauge
notes_count <%- notesCount %> notes_count <%- notesCount %>
# HELP registered_users Number of registered users
# TYPE registered_users gauge
registered_users <%- registeredUsers %> registered_users <%- registeredUsers %>
# HELP online_registered_users Number of online registered users
# TYPE online_registered_users gauge
online_registered_users <%- onlineRegisteredUsers %> online_registered_users <%- onlineRegisteredUsers %>
# HELP distinct_online_registered_users Number of distinct online registered users
# TYPE distinct_online_registered_users gauge
distinct_online_registered_users <%- distinctOnlineRegisteredUsers %> distinct_online_registered_users <%- distinctOnlineRegisteredUsers %>
is_connection_busy <%- isConnectionBusy ? 1 : 0 %> is_connection_busy <%- isConnectionBusy ? 1 : 0 %>
connection_socket_queue_length <%- connectionSocketQueueLength %> connection_socket_queue_length <%- connectionSocketQueueLength %>

View File

@ -6,7 +6,7 @@ import * as utils from './utils'
import config from './config' import config from './config'
import statusBarTemplate from './statusbar.html' import statusBarTemplate from './statusbar.html'
import toolBarTemplate from './toolbar.html' import toolBarTemplate from './toolbar.html'
import './markdown-lint' import { linterOptions } from './markdown-lint'
import CodeMirrorSpellChecker, { supportLanguages, supportLanguageCodes } from './spellcheck' import CodeMirrorSpellChecker, { supportLanguages, supportLanguageCodes } from './spellcheck'
import { initTableEditor } from './table-editor' import { initTableEditor } from './table-editor'
import { availableThemes } from './constants' import { availableThemes } from './constants'
@ -140,6 +140,42 @@ export default class Editor {
} }
} }
CodeMirror.defineMode('c', function (config, modeConfig) {
return CodeMirror.overlayMode(CodeMirror.getMode(config, 'text/x-csrc'), ignoreOverlay)
})
CodeMirror.defineMode('cpp', function (config, modeConfig) {
return CodeMirror.overlayMode(CodeMirror.getMode(config, 'text/x-c++src'), ignoreOverlay)
})
CodeMirror.defineMode('java', function (config, modeConfig) {
return CodeMirror.overlayMode(CodeMirror.getMode(config, 'text/x-java'), ignoreOverlay)
})
CodeMirror.defineMode('csharp', function (config, modeConfig) {
return CodeMirror.overlayMode(CodeMirror.getMode(config, 'text/x-csharp'), ignoreOverlay)
})
CodeMirror.defineMode('objectivec', function (config, modeConfig) {
return CodeMirror.overlayMode(CodeMirror.getMode(config, 'text/x-objectivec'), ignoreOverlay)
})
CodeMirror.defineMode('scala', function (config, modeConfig) {
return CodeMirror.overlayMode(CodeMirror.getMode(config, 'text/x-scala'), ignoreOverlay)
})
CodeMirror.defineMode('kotlin', function (config, modeConfig) {
return CodeMirror.overlayMode(CodeMirror.getMode(config, 'text/x-kotlin'), ignoreOverlay)
})
CodeMirror.defineMode('json', function (config, modeConfig) {
return CodeMirror.overlayMode(CodeMirror.getMode(config, 'application/json'), ignoreOverlay)
})
CodeMirror.defineMode('jsonld', function (config, modeConfig) {
return CodeMirror.overlayMode(CodeMirror.getMode(config, 'application/ld+json'), ignoreOverlay)
})
CodeMirror.defineMode('bash', function (config, modeConfig) {
return CodeMirror.overlayMode(CodeMirror.getMode(config, 'text/x-sh'), ignoreOverlay)
})
CodeMirror.defineMode('ocaml', function (config, modeConfig) {
return CodeMirror.overlayMode(CodeMirror.getMode(config, 'text/x-ocaml'), ignoreOverlay)
})
CodeMirror.defineMode('csvpreview', function (config, modeConfig) {
return CodeMirror.overlayMode(CodeMirror.getMode(config, 'csv'), ignoreOverlay)
})
CodeMirror.defineMode('vega', function (config, modeConfig) { CodeMirror.defineMode('vega', function (config, modeConfig) {
return CodeMirror.overlayMode(CodeMirror.getMode(config, 'application/ld+json'), ignoreOverlay) return CodeMirror.overlayMode(CodeMirror.getMode(config, 'application/ld+json'), ignoreOverlay)
}) })
@ -674,7 +710,7 @@ export default class Editor {
this.editor.setOption('gutters', gutters.filter(g => g !== lintGutter)) this.editor.setOption('gutters', gutters.filter(g => g !== lintGutter))
Cookies.remove('linter') Cookies.remove('linter')
} }
this.editor.setOption('lint', enable) this.editor.setOption('lint', enable ? linterOptions : false)
} }
setLinter () { setLinter () {
@ -685,7 +721,7 @@ export default class Editor {
} }
linterToggle.click(() => { linterToggle.click(() => {
const lintEnable = this.editor.getOption('lint') const lintEnable = !!this.editor.getOption('lint')
this.toggleLinter.bind(this)(!lintEnable) this.toggleLinter.bind(this)(!lintEnable)
updateLinterStatus(!lintEnable) updateLinterStatus(!lintEnable)
}) })

View File

@ -3,6 +3,9 @@
// load CM lint plugin explicitly // load CM lint plugin explicitly
import '@hackmd/codemirror/addon/lint/lint' import '@hackmd/codemirror/addon/lint/lint'
import '@hackmd/codemirror/addon/hint/show-hint.css'
import helpers from 'markdownlint-rule-helpers'
window.markdownit = require('markdown-it') window.markdownit = require('markdown-it')
// eslint-disable-next-line // eslint-disable-next-line
require('script-loader!markdownlint'); require('script-loader!markdownlint');
@ -26,10 +29,11 @@ require('script-loader!markdownlint');
} }
return { return {
messageHTML: `${ruleNames.join('/')}: ${ruleDescription}`, messageHTML: `${ruleNames.join('/')}: ${ruleDescription} <small>markdownlint(${ruleNames[0]})</small>`,
severity: 'error', severity: 'error',
from: CodeMirror.Pos(lineNumber, start), from: CodeMirror.Pos(lineNumber, start),
to: CodeMirror.Pos(lineNumber, end) to: CodeMirror.Pos(lineNumber, end),
__error: error
} }
}) })
} }
@ -37,11 +41,84 @@ require('script-loader!markdownlint');
CodeMirror.registerHelper('lint', 'markdown', validator) CodeMirror.registerHelper('lint', 'markdown', validator)
}) })
export const linterOptions = {
fixedTooltip: true,
contextmenu: annotations => {
const singleFixMenus = annotations
.map(annotation => {
const error = annotation.__error
const ruleNameAlias = error.ruleNames.join('/')
if (annotation.__error.fixInfo) {
return {
content: `Click to fix this violoation of ${ruleNameAlias}`,
onClick () {
const doc = window.editor.doc
const fixInfo = normalizeFixInfo(error.fixInfo, error.lineNumber)
const line = fixInfo.lineNumber - 1
const lineContent = doc.getLine(line) || ''
const fixedText = helpers.applyFix(lineContent, fixInfo, '\n')
let from = { line, ch: 0 }
let to = { line, ch: lineContent ? lineContent.length : 0 }
if (typeof fixedText === 'string') {
doc.replaceRange(fixedText, from, to)
} else {
if (fixInfo.lineNumber === 1) {
if (doc.lineCount() > 1) {
const nextLineStart = doc.indexFromPos({
line: to.line + 1,
ch: 0
})
to = doc.posFromIndex(nextLineStart)
}
} else {
const previousLineEnd = doc.indexFromPos(from) - 1
from = doc.posFromIndex(previousLineEnd)
}
doc.replaceRange('', from, to)
}
}
}
} else {
return {
content: `Click for more information about ${ruleNameAlias}`,
onClick () {
window.open(error.ruleInformation, '_blank')
}
}
}
})
return singleFixMenus
}
}
function lint (content) { function lint (content) {
const { content: errors } = window.markdownlint.sync({ const { content: errors } = window.markdownlint.sync({
strings: { strings: {
content content
} },
resultVersion: 3
}) })
return errors return errors
} }
// Taken from https://github.com/DavidAnson/markdownlint/blob/2a9274ece586514ba3e2819cec3eb74312dc1b84/helpers/helpers.js#L611
/**
* Normalizes the fields of a RuleOnErrorFixInfo instance.
*
* @param {Object} fixInfo RuleOnErrorFixInfo instance.
* @param {number} [lineNumber] Line number.
* @returns {Object} Normalized RuleOnErrorFixInfo instance.
*/
function normalizeFixInfo (fixInfo, lineNumber) {
return {
lineNumber: fixInfo.lineNumber || lineNumber,
editColumn: fixInfo.editColumn || 1,
deleteCount: fixInfo.deleteCount || 0,
insertText: fixInfo.insertText || ''
}
}

View File

@ -103,7 +103,7 @@ import { md } from './extra'
// prevent script end tags in the content from interfering // prevent script end tags in the content from interfering
// with parsing // with parsing
content = content.replace(/<\/script>/g, SCRIPT_END_PLACEHOLDER) content = content.replace(/<\/script>/gi, SCRIPT_END_PLACEHOLDER)
return '<script type="text/template">' + content + '</script>' return '<script type="text/template">' + content + '</script>'
} }

View File

@ -80,6 +80,8 @@ const defaultOptions = {
} }
var options = meta.slideOptions || {} var options = meta.slideOptions || {}
// delete dependencies to avoid import user defined external resources
delete options.dependencies
if (Object.hasOwnProperty.call(options, 'spotlight')) { if (Object.hasOwnProperty.call(options, 'spotlight')) {
defaultOptions.dependencies.push({ defaultOptions.dependencies.push({

View File

@ -71,3 +71,11 @@
background-position: right bottom; background-position: right bottom;
width: 100%; height: 100%; width: 100%; height: 100%;
} }
.CodeMirror-hints {
background: #333;
}
.CodeMirror-hint {
color: white;
}

View File

@ -2,6 +2,8 @@
/** /**
* md-toc.js v1.0.2 * md-toc.js v1.0.2
* https://github.com/yijian166/md-toc.js * https://github.com/yijian166/md-toc.js
*
* Adapted to accept data attributes
*/ */
(function (window) { (function (window) {
@ -15,6 +17,7 @@
this.tocTop = parseInt(options.top) || 0 this.tocTop = parseInt(options.top) || 0
this.elChilds = this.el.children this.elChilds = this.el.children
this.process = options['process'] this.process = options['process']
this.data = options.data || {}
if (!this.elChilds.length) return if (!this.elChilds.length) return
this._init() this._init()
} }
@ -123,6 +126,9 @@
this.toc = document.createElement('div') this.toc = document.createElement('div')
this.toc.innerHTML = this.tocContent this.toc.innerHTML = this.tocContent
this.toc.setAttribute('class', this.tocClass) this.toc.setAttribute('class', this.tocClass)
if (this.data.tocDepth) {
this.toc.dataset.tocDepth = this.data.tocDepth
}
if (!this.options.targetId) { if (!this.options.targetId) {
this.el.appendChild(this.toc) this.el.appendChild(this.toc)
} else { } else {

View File

@ -77,7 +77,7 @@
</a> </a>
</div> </div>
<div class="col-md-4 inner"> <div class="col-md-4 inner">
<a href="<%- serverURL %>/features#Slide-Modee"> <a href="<%- serverURL %>/features#Slide-Mode">
<i class="fa fa-tv fa-3x"></i> <i class="fa fa-tv fa-3x"></i>
<h4><%= __('Support slide mode') %></h4> <h4><%= __('Support slide mode') %></h4>
</a> </a>
@ -156,6 +156,7 @@
<h6 class="social-foot"> <h6 class="social-foot">
<%- __('Follow us on %s and %s.', '<a href="https://github.com/hackmdio/CodiMD" target="_blank" rel="noopener"><i class="fa fa-github"></i> GitHub</a>, <a href="https://gitter.im/hackmdio/hackmd" target="_blank" rel="noopener"><i class="fa fa-comments"></i> Gitter</a>', '<a href="https://poeditor.com/join/project/q0nuPWyztp" target="_blank" rel="noopener"><i class="fa fa-globe"></i> POEditor</a>') %> <%- __('Follow us on %s and %s.', '<a href="https://github.com/hackmdio/CodiMD" target="_blank" rel="noopener"><i class="fa fa-github"></i> GitHub</a>, <a href="https://gitter.im/hackmdio/hackmd" target="_blank" rel="noopener"><i class="fa fa-comments"></i> Gitter</a>', '<a href="https://poeditor.com/join/project/q0nuPWyztp" target="_blank" rel="noopener"><i class="fa fa-globe"></i> POEditor</a>') %>
</h6> </h6>
<% if(privacyPolicyURL && privacyPolicyURL.length > 0) { %><p><a href="<%- privacyPolicyURL %>"><%= __('Privacy Policy') %></a></p><% } %>
</div> </div>
</div> </div>
</div> </div>

View File

@ -21,6 +21,7 @@
<option value="rtf">Rich Text Format (.rtf)</option> <option value="rtf">Rich Text Format (.rtf)</option>
<option value="textile">Textile</option> <option value="textile">Textile</option>
<option value="docx">Word (.docx)</option> <option value="docx">Word (.docx)</option>
<option value="mediawiki">Mediawiki</option>
</select> </select>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">

View File

@ -44,8 +44,8 @@
</a> </a>
<% } %> <% } %>
<% if (authProviders.google) { %> <% if (authProviders.google) { %>
<a href="<%- serverURL %>/auth/google" class="btn btn-lg btn-block btn-login-method btn-google"> <a href="<%- serverURL %>/auth/google" class="btn btn-lg btn-block" style="color: #fff; background-color: #4285F4; vertical-align: middle; font-size: 14px; font-family: Roboto;">
<i class="fa fa-google"></i> <%= __('Sign in via %s', 'Google') %> <svg aria-hidden="true" width="34" height="34" viewBox="-8 -8 34 34" style="vertical-align: middle; margin-right: 16; background-color=#fff;"><path d="M16.51 8H8.98v3h4.3c-.18 1-.74 1.48-1.6 2.04v2.01h2.6a7.8 7.8 0 002.38-5.88c0-.57-.05-.66-.15-1.18z" fill="#4285F4"></path><path d="M8.98 17c2.16 0 3.97-.72 5.3-1.94l-2.6-2a4.8 4.8 0 01-7.18-2.54H1.83v2.07A8 8 0 008.98 17z" fill="#34A853"></path><path d="M4.5 10.52a4.8 4.8 0 010-3.04V5.41H1.83a8 8 0 000 7.18l2.67-2.07z" fill="#FBBC05"></path><path d="M8.98 4.18c1.17 0 2.23.4 3.06 1.2l2.3-2.3A8 8 0 001.83 5.4L4.5 7.49a4.77 4.77 0 014.48-3.3z" fill="#EA4335"></path></svg> <%= __('Sign in via %s', 'Google') %>
</a> </a>
<% } %> <% } %>
<% if (authProviders.saml) { %> <% if (authProviders.saml) { %>

View File

@ -443,7 +443,10 @@ module.exports = {
}, { }, {
test: /\.js$/, test: /\.js$/,
use: [{ loader: 'babel-loader' }], use: [{ loader: 'babel-loader' }],
exclude: [/node_modules/, /public\/vendor/] exclude: [
path.resolve(__dirname, 'node_modules'),
path.resolve(__dirname, 'public/vendor')
]
}, { }, {
test: /\.css$/, test: /\.css$/,
use: [ use: [
@ -522,7 +525,8 @@ module.exports = {
}] }]
}, },
node: { node: {
fs: 'empty' fs: 'empty',
os: 'empty'
}, },
stats: { stats: {