mirror of
https://github.com/Luzifer/wiki.git
synced 2024-12-20 10:31:18 +00:00
Initial implementation
This commit is contained in:
parent
2d2ec2472b
commit
9ff77362aa
17 changed files with 25886 additions and 0 deletions
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
data
|
||||||
|
src/node_modules
|
||||||
|
wiki
|
66
frontend/app.js
Normal file
66
frontend/app.js
Normal file
File diff suppressed because one or more lines are too long
21
frontend/index.html
Normal file
21
frontend/index.html
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<title>Wiki</title>
|
||||||
|
|
||||||
|
<div id="app"></div>
|
||||||
|
|
||||||
|
<!-- Loading prism from external not to pack ALL languages -->
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1/themes/prism.min.css">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs@1/themes/prism-okaidia.min.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/prismjs@1/prism.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/prismjs@1/plugins/autoloader/prism-autoloader.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/prismjs@1/plugins/line-numbers/prism-line-numbers.min.js"></script>
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@5.10.1/css/all.css">
|
||||||
|
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</html>
|
||||||
|
|
15
go.mod
Normal file
15
go.mod
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
module github.com/Luzifer/wiki
|
||||||
|
|
||||||
|
go 1.12
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/Luzifer/go_helpers v2.8.1+incompatible // indirect
|
||||||
|
github.com/Luzifer/go_helpers/v2 v2.9.1
|
||||||
|
github.com/Luzifer/rconfig/v2 v2.2.1
|
||||||
|
github.com/gorilla/mux v1.7.3
|
||||||
|
github.com/gosimple/slug v1.6.0
|
||||||
|
github.com/pkg/errors v0.8.1
|
||||||
|
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be // indirect
|
||||||
|
github.com/sirupsen/logrus v1.4.2
|
||||||
|
gopkg.in/yaml.v2 v2.2.2
|
||||||
|
)
|
36
go.sum
Normal file
36
go.sum
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
github.com/Luzifer/go_helpers v2.8.1+incompatible h1:9YvrAn7pU2viK5vRpAnI+0gyz+Tw8rxWHVIYHi642zk=
|
||||||
|
github.com/Luzifer/go_helpers v2.8.1+incompatible/go.mod h1:5yUSe0FS7lIx1Uzmt0R3tdPFrSSaPfiCqaIA6u0Zn4Y=
|
||||||
|
github.com/Luzifer/go_helpers/v2 v2.9.1 h1:MVUOlD6tJ2m/iTF0hllnI/QVZH5kI+TikUm1WRGg/c4=
|
||||||
|
github.com/Luzifer/go_helpers/v2 v2.9.1/go.mod h1:ZnWxPjyCdQ4rZP3kNiMSUW/7FigU1X9Rz8XopdJ5ZCU=
|
||||||
|
github.com/Luzifer/rconfig/v2 v2.2.1 h1:zcDdLQlnlzwcBJ8E0WFzOkQE1pCMn3EbX0dFYkeTczg=
|
||||||
|
github.com/Luzifer/rconfig/v2 v2.2.1/go.mod h1:OKIX0/JRZrPJ/ZXXWklQEFXA6tBfWaljZbW37w+sqBw=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/gorilla/mux v1.7.3 h1:gnP5JzjVOuiZD07fKKToCAOjS0yOpj/qPETTXCCS6hw=
|
||||||
|
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||||
|
github.com/gosimple/slug v1.6.0 h1:jB/X2muqD2+ABdGF0YLJukfS1ppeTFfLxU757UE6K7c=
|
||||||
|
github.com/gosimple/slug v1.6.0/go.mod h1:ER78kgg1Mv0NQGlXiDe57DpCyfbNywXXZ9mIorhxAf0=
|
||||||
|
github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf/go.mod h1:hyb9oH7vZsitZCiBt0ZvifOrB+qc8PS5IiilCIb87rg=
|
||||||
|
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
|
github.com/leekchan/gtf v0.0.0-20190214083521-5fba33c5b00b/go.mod h1:thNruaSwydMhkQ8dXzapABF9Sc1Tz08ZBcDdgott9RA=
|
||||||
|
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
||||||
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be h1:ta7tUOvsPHVHGom5hKW5VXNc2xZIkfCKP8iaqOyYtUQ=
|
||||||
|
github.com/rainycape/unidecode v0.0.0-20150907023854-cb7f23ec59be/go.mod h1:MIDFMn7db1kT65GmV94GzpX9Qdi7N/pQlwb+AN8wh+Q=
|
||||||
|
github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4=
|
||||||
|
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||||
|
github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg=
|
||||||
|
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||||
|
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
|
||||||
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
|
golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc=
|
||||||
|
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/validator.v2 v2.0.0-20180514200540-135c24b11c19 h1:WB265cn5OpO+hK3pikC9hpP1zI/KTwmyMFKloW9eOVc=
|
||||||
|
gopkg.in/validator.v2 v2.0.0-20180514200540-135c24b11c19/go.mod h1:o4V0GXN9/CAmCsvJ0oXYZvrZOe7syiDZSN1GWGZTGzc=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
149
main.go
Normal file
149
main.go
Normal file
|
@ -0,0 +1,149 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
//go:generate go-bindata -pkg $GOPACKAGE -o assets.go -modtime 1 -md5checksum ./frontend/...
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/gosimple/slug"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
httpHelper "github.com/Luzifer/go_helpers/v2/http"
|
||||||
|
"github.com/Luzifer/rconfig/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
cfg = struct {
|
||||||
|
DataDir string `flag:"data-dir" default:"./data/" description:"Directory to store data to"`
|
||||||
|
EnableGit bool `flag:"enable-git" default:"false" description:"Enable git management of the data dir"`
|
||||||
|
Listen string `flag:"listen" default:":3000" description:"Port/IP to listen on"`
|
||||||
|
LogLevel string `flag:"log-level" default:"info" description:"Log level (debug, info, warn, error, fatal)"`
|
||||||
|
VersionAndExit bool `flag:"version" default:"false" description:"Prints current version and exits"`
|
||||||
|
}{}
|
||||||
|
|
||||||
|
version = "dev"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rconfig.AutoEnv(true)
|
||||||
|
if err := rconfig.ParseAndValidate(&cfg); err != nil {
|
||||||
|
log.Fatalf("Unable to parse commandline options: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.VersionAndExit {
|
||||||
|
fmt.Printf("wiki %s\n", version)
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if l, err := log.ParseLevel(cfg.LogLevel); err != nil {
|
||||||
|
log.WithError(err).Fatal("Unable to parse log level")
|
||||||
|
} else {
|
||||||
|
log.SetLevel(l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
r := mux.NewRouter()
|
||||||
|
|
||||||
|
r.HandleFunc("/_content/{page}", handlePageRead).Methods(http.MethodGet)
|
||||||
|
r.HandleFunc("/_content/{page}", handlePageWrite).Methods(http.MethodPost)
|
||||||
|
|
||||||
|
r.NotFoundHandler = http.HandlerFunc(handleIndexPage)
|
||||||
|
|
||||||
|
var handler http.Handler = r
|
||||||
|
handler = httpHelper.GzipHandler(handler)
|
||||||
|
handler = httpHelper.NewHTTPLogHandler(handler)
|
||||||
|
|
||||||
|
http.ListenAndServe(cfg.Listen, handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleIndexPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/app.js" {
|
||||||
|
r.URL.Path = "/index.html"
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
filename = path.Join("frontend", r.URL.Path)
|
||||||
|
src io.Reader
|
||||||
|
)
|
||||||
|
|
||||||
|
if _, err := os.Stat(filename); err == nil {
|
||||||
|
f, err := os.Open(filename)
|
||||||
|
if err != nil {
|
||||||
|
log.WithError(err).Error("Unable to open base asset")
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
src = f
|
||||||
|
} else if asset, err := Asset(filename); err == nil {
|
||||||
|
src = bytes.NewReader(asset)
|
||||||
|
} else {
|
||||||
|
log.WithField("asset", filename).Error("Asset not found in frontend dir or bundled assets")
|
||||||
|
http.Error(w, "Not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", mime.TypeByExtension(path.Ext(filename)))
|
||||||
|
io.Copy(w, src)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handlePageRead(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var vars = mux.Vars(r)
|
||||||
|
|
||||||
|
file, err := loadStoredFile(sanitizeFilename(vars["page"]))
|
||||||
|
switch err {
|
||||||
|
|
||||||
|
case nil:
|
||||||
|
// All okay, render follows
|
||||||
|
|
||||||
|
case errFileNotFound:
|
||||||
|
http.Error(w, "Page not yet exists", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
|
||||||
|
default:
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
|
||||||
|
if err := json.NewEncoder(w).Encode(file); err != nil {
|
||||||
|
log.WithError(err).Error("Unable to marshal file for JSON")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handlePageWrite(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var (
|
||||||
|
vars = mux.Vars(r)
|
||||||
|
file = &storedFile{}
|
||||||
|
)
|
||||||
|
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(file); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := file.Save(sanitizeFilename(vars["page"])); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeFilename(page string) string {
|
||||||
|
return path.Join(
|
||||||
|
cfg.DataDir,
|
||||||
|
strings.Join([]string{slug.Make(page), "md"}, "."),
|
||||||
|
)
|
||||||
|
}
|
88
src/.eslintrc.js
Normal file
88
src/.eslintrc.js
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
// https://eslint.org/docs/user-guide/configuring
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
'root': true,
|
||||||
|
'parserOptions': {
|
||||||
|
parser: 'babel-eslint',
|
||||||
|
sourceType: 'module',
|
||||||
|
},
|
||||||
|
'env': {
|
||||||
|
node: true,
|
||||||
|
},
|
||||||
|
'extends': [
|
||||||
|
/*
|
||||||
|
* https://github.com/vuejs/eslint-plugin-vue#priority-a-essential-error-prevention
|
||||||
|
* consider switching to `plugin:vue/strongly-recommended` or `plugin:vue/recommended` for stricter rules.
|
||||||
|
*/
|
||||||
|
'plugin:vue/recommended',
|
||||||
|
// https://github.com/standard/standard/blob/master/docs/RULES-en.md
|
||||||
|
'eslint:recommended',
|
||||||
|
],
|
||||||
|
// required to lint *.vue files
|
||||||
|
'plugins': ['vue'],
|
||||||
|
'globals': {
|
||||||
|
process: true,
|
||||||
|
Prism: true,
|
||||||
|
},
|
||||||
|
// add your custom rules here
|
||||||
|
'rules': {
|
||||||
|
'array-bracket-newline': ['error', { multiline: true }],
|
||||||
|
'array-bracket-spacing': ['error'],
|
||||||
|
'arrow-body-style': ['error', 'as-needed'],
|
||||||
|
'arrow-parens': ['error', 'as-needed'],
|
||||||
|
'arrow-spacing': ['error', { before: true, after: true }],
|
||||||
|
'block-spacing': ['error'],
|
||||||
|
'brace-style': ['error', '1tbs'],
|
||||||
|
'comma-dangle': ['error', 'always-multiline'], // Apply Contentflow rules
|
||||||
|
'comma-spacing': ['error'],
|
||||||
|
'comma-style': ['error', 'last'],
|
||||||
|
'curly': ['error'],
|
||||||
|
'dot-location': ['error', 'property'],
|
||||||
|
'dot-notation': ['error'],
|
||||||
|
'eol-last': ['error', 'always'],
|
||||||
|
'eqeqeq': ['error', 'always', { 'null': 'ignore' }],
|
||||||
|
'func-call-spacing': ['error', 'never'],
|
||||||
|
'function-paren-newline': ['error', 'multiline'],
|
||||||
|
'generator-star-spacing': ['off'], // allow async-await
|
||||||
|
'implicit-arrow-linebreak': ['error'],
|
||||||
|
'indent': ['error', 2],
|
||||||
|
'key-spacing': ['error', { beforeColon: false, afterColon: true, mode: 'strict' }],
|
||||||
|
'keyword-spacing': ['error'],
|
||||||
|
'linebreak-style': ['error', 'unix'],
|
||||||
|
'lines-between-class-members': ['error'],
|
||||||
|
'multiline-comment-style': ['warn'],
|
||||||
|
'newline-per-chained-call': ['error'],
|
||||||
|
'no-console': ['off'],
|
||||||
|
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', // allow debugger during development
|
||||||
|
'no-else-return': ['error'],
|
||||||
|
'no-extra-parens': ['error'],
|
||||||
|
'no-implicit-coercion': ['error'],
|
||||||
|
'no-lonely-if': ['error'],
|
||||||
|
'no-multiple-empty-lines': ['warn', { max: 2, maxEOF: 0, maxBOF: 0 }],
|
||||||
|
'no-multi-spaces': ['error'],
|
||||||
|
'no-trailing-spaces': ['error'],
|
||||||
|
'no-unneeded-ternary': ['error'],
|
||||||
|
'no-useless-return': ['error'],
|
||||||
|
'no-whitespace-before-property': ['error'],
|
||||||
|
'object-curly-newline': ['error', { consistent: true }],
|
||||||
|
'object-curly-spacing': ['error', 'always'],
|
||||||
|
'object-shorthand': ['error'],
|
||||||
|
'padded-blocks': ['error', 'never'],
|
||||||
|
'prefer-arrow-callback': ['error'],
|
||||||
|
'prefer-const': ['error'],
|
||||||
|
'prefer-object-spread': ['error'],
|
||||||
|
'prefer-template': ['error'],
|
||||||
|
'quote-props': ['error', 'consistent-as-needed', { keywords: true }],
|
||||||
|
'quotes': ['error', 'single', { allowTemplateLiterals: true }],
|
||||||
|
'semi': ['error', 'never'],
|
||||||
|
'space-before-blocks': ['error', 'always'],
|
||||||
|
'spaced-comment': ['warn', 'always'],
|
||||||
|
'space-infix-ops': ['error'],
|
||||||
|
'space-in-parens': ['error', 'never'],
|
||||||
|
'space-unary-ops': ['error', { words: true, nonwords: false }],
|
||||||
|
'switch-colon-spacing': ['error'],
|
||||||
|
'unicode-bom': ['error', 'never'],
|
||||||
|
'wrap-iife': ['error'],
|
||||||
|
'yoda': ['error'],
|
||||||
|
},
|
||||||
|
}
|
24
src/app.vue
Normal file
24
src/app.vue
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<b-navbar
|
||||||
|
type="dark"
|
||||||
|
variant="primary"
|
||||||
|
class="mb-4"
|
||||||
|
>
|
||||||
|
<b-navbar-brand
|
||||||
|
href="/"
|
||||||
|
@click.prevent="$router.push({ name: 'home' })"
|
||||||
|
>
|
||||||
|
Wiki
|
||||||
|
</b-navbar-brand>
|
||||||
|
</b-navbar>
|
||||||
|
|
||||||
|
<router-view />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'App',
|
||||||
|
}
|
||||||
|
</script>
|
89
src/edit.vue
Normal file
89
src/edit.vue
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
<template>
|
||||||
|
<b-container>
|
||||||
|
<b-row>
|
||||||
|
<b-col>
|
||||||
|
<textarea ref="editor" />
|
||||||
|
|
||||||
|
<b-btn
|
||||||
|
variant="primary"
|
||||||
|
@click="save"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</b-btn>
|
||||||
|
</b-col>
|
||||||
|
</b-row>
|
||||||
|
</b-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import axios from 'axios'
|
||||||
|
import EasyMDE from 'easymde'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'Edit',
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
editor: null,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
'$route'(to, from) {
|
||||||
|
if (to.params.page === from.params.page) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loadPage(to.params.page)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
if (!this.editor) {
|
||||||
|
this.editor = new EasyMDE({
|
||||||
|
element: this.$refs.editor,
|
||||||
|
forceSync: true,
|
||||||
|
indentWithTabs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
window.editor = this.editor
|
||||||
|
}
|
||||||
|
this.loadPage(this.$route.params.page)
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
loadPage(pageName) {
|
||||||
|
console.debug(`Loading ${pageName}...`)
|
||||||
|
axios.get(`/_content/${pageName}`)
|
||||||
|
.then(resp => {
|
||||||
|
this.editor.codemirror.setValue(resp.data.content)
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
if (err.response && err.response.status === 404) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.error(err)
|
||||||
|
// FIXME: Show error
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
save() {
|
||||||
|
axios.post(`/_content/${this.$route.params.page}`, {
|
||||||
|
content: this.$refs.editor.value,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
this.$router.push({ name: 'view', params: { page: this.$route.params.page } })
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error(err)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.editor-toolbar {
|
||||||
|
background-color: #ccc;
|
||||||
|
}
|
||||||
|
</style>
|
47
src/main.js
Normal file
47
src/main.js
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
import Vue from 'vue'
|
||||||
|
import VueRouter from 'vue-router'
|
||||||
|
|
||||||
|
import BootstrapVue from 'bootstrap-vue'
|
||||||
|
|
||||||
|
import 'bootstrap/dist/css/bootstrap.css'
|
||||||
|
import 'bootstrap-vue/dist/bootstrap-vue.css'
|
||||||
|
import 'bootswatch/dist/darkly/bootstrap.css'
|
||||||
|
import 'easymde/dist/easymde.min.css'
|
||||||
|
|
||||||
|
import App from './app.vue'
|
||||||
|
import View from './view.vue'
|
||||||
|
import Edit from './edit.vue'
|
||||||
|
|
||||||
|
Vue.use(BootstrapVue)
|
||||||
|
Vue.use(VueRouter)
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
{
|
||||||
|
component: View,
|
||||||
|
name: 'view',
|
||||||
|
path: '/:page',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
component: Edit,
|
||||||
|
name: 'edit',
|
||||||
|
path: '/:page/edit',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'home',
|
||||||
|
path: '/',
|
||||||
|
redirect: '/Home',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = new VueRouter({
|
||||||
|
mode: 'history',
|
||||||
|
routes,
|
||||||
|
})
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
el: '#app',
|
||||||
|
components: { App },
|
||||||
|
data: { },
|
||||||
|
render: createElement => createElement('app'),
|
||||||
|
router,
|
||||||
|
})
|
8560
src/package-lock.json
generated
Normal file
8560
src/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
43
src/package.json
Normal file
43
src/package.json
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
{
|
||||||
|
"devDependencies": {
|
||||||
|
"babel-core": "^6.26.3",
|
||||||
|
"babel-eslint": "^10.0.1",
|
||||||
|
"babel-loader": "^7.1.5",
|
||||||
|
"babel-preset-env": "^1.7.0",
|
||||||
|
"css-loader": "^1.0.0",
|
||||||
|
"easymde": "^2.7.0",
|
||||||
|
"eslint": "^5.13.0",
|
||||||
|
"eslint-config-standard": "^12.0.0",
|
||||||
|
"eslint-friendly-formatter": "^4.0.1",
|
||||||
|
"eslint-loader": "^2.1.1",
|
||||||
|
"eslint-plugin-import": "^2.13.0",
|
||||||
|
"eslint-plugin-node": "^8.0.1",
|
||||||
|
"eslint-plugin-promise": "^4.0.1",
|
||||||
|
"eslint-plugin-standard": "^4.0.0",
|
||||||
|
"eslint-plugin-vue": "^5.2.3",
|
||||||
|
"node-sass": "^4.9.2",
|
||||||
|
"sass-loader": "^7.0.3",
|
||||||
|
"style-loader": "^0.21.0",
|
||||||
|
"vue-loader": "^15.7.0",
|
||||||
|
"vue-markdown": "^2.2.4",
|
||||||
|
"vue-router": "^3.0.7",
|
||||||
|
"vue-template-compiler": "^2.6.10",
|
||||||
|
"webpack": "^4.16.2",
|
||||||
|
"webpack-cli": "^3.1.0",
|
||||||
|
"webpack-dev-middleware": "^1.4.0",
|
||||||
|
"webpack-hot-middleware": "^2.9.1"
|
||||||
|
},
|
||||||
|
"name": "ots",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "webpack -p"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^0.19.0",
|
||||||
|
"bootstrap": "^4.3.1",
|
||||||
|
"bootstrap-vue": "^2.0.0-rc.19",
|
||||||
|
"bootswatch": "^4.3.1",
|
||||||
|
"popper.js": "^1.15.0",
|
||||||
|
"vue": "^2.6.10"
|
||||||
|
}
|
||||||
|
}
|
113
src/view.vue
Normal file
113
src/view.vue
Normal file
|
@ -0,0 +1,113 @@
|
||||||
|
<template>
|
||||||
|
<b-container>
|
||||||
|
<b-row>
|
||||||
|
<b-col
|
||||||
|
ref="content"
|
||||||
|
class="relAnchor"
|
||||||
|
>
|
||||||
|
<b-btn
|
||||||
|
class="editBtn"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
:to="{ name: 'edit', params: { page: $route.params.page } }"
|
||||||
|
>
|
||||||
|
<i class="fas fa-edit" />
|
||||||
|
</b-btn>
|
||||||
|
<vue-markdown
|
||||||
|
:source="content"
|
||||||
|
:prerender="prerender"
|
||||||
|
@rendered="rendered"
|
||||||
|
/>
|
||||||
|
</b-col>
|
||||||
|
</b-row>
|
||||||
|
</b-container>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import axios from 'axios'
|
||||||
|
import VueMarkdown from 'vue-markdown'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'View',
|
||||||
|
|
||||||
|
components: {
|
||||||
|
VueMarkdown,
|
||||||
|
},
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
content: '',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
watch: {
|
||||||
|
'$route'(to, from) {
|
||||||
|
if (to.params.page === from.params.page) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loadPage(to.params.page)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
mounted() {
|
||||||
|
this.loadPage(this.$route.params.page)
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
intLinkClick(evt) {
|
||||||
|
const link = evt.target
|
||||||
|
this.$router.push({ name: 'view', params: { page: link.dataset.page } })
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
|
||||||
|
loadPage(pageName) {
|
||||||
|
console.debug(`Loading ${pageName}...`)
|
||||||
|
axios.get(`/_content/${pageName}`)
|
||||||
|
.then(resp => {
|
||||||
|
this.content = resp.data.content
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
if (err.response && err.response.status === 404) {
|
||||||
|
this.$router.push({ name: 'edit', params: { page: pageName } })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.error(err)
|
||||||
|
// FIXME: Show error
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
prerender(mdtext) {
|
||||||
|
// replace [[Internal]] links
|
||||||
|
mdtext = mdtext.replace(/\[\[([^\]]+)\]\]/, '<a class="intLink" data-page="$1" href="$1">$1</a>')
|
||||||
|
|
||||||
|
return mdtext
|
||||||
|
},
|
||||||
|
|
||||||
|
rendered() {
|
||||||
|
// Give the DOM a moment to update before manipulating further
|
||||||
|
window.setTimeout(() => {
|
||||||
|
// Add listeners to internal links
|
||||||
|
const links = this.$refs.content.getElementsByClassName('intLink')
|
||||||
|
for (const link of links) {
|
||||||
|
link.onclick = this.intLinkClick
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight code blocks
|
||||||
|
Prism.highlightAll()
|
||||||
|
}, 100)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.editBtn {
|
||||||
|
position: absolute;
|
||||||
|
right: 5px;
|
||||||
|
top: 5px;
|
||||||
|
}
|
||||||
|
.relAnchor {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
</style>
|
54
src/webpack.config.js
Normal file
54
src/webpack.config.js
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
const path = require('path')
|
||||||
|
const webpack = require('webpack')
|
||||||
|
|
||||||
|
const VueLoaderPlugin = require('vue-loader/lib/plugin')
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
entry: './main.js',
|
||||||
|
output: {
|
||||||
|
filename: 'app.js',
|
||||||
|
path: path.resolve(__dirname, '..', 'frontend'),
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
new webpack.optimize.OccurrenceOrderPlugin(),
|
||||||
|
new webpack.DefinePlugin({
|
||||||
|
'process.env': {
|
||||||
|
NODE_ENV: JSON.stringify('production'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new VueLoaderPlugin(),
|
||||||
|
],
|
||||||
|
optimization: {
|
||||||
|
minimize: true,
|
||||||
|
},
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
|
||||||
|
{
|
||||||
|
test: /\.(s?)css$/,
|
||||||
|
use: [
|
||||||
|
'style-loader',
|
||||||
|
'css-loader',
|
||||||
|
'sass-loader',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
test: /\.js$/,
|
||||||
|
exclude: /(node_modules|bower_components)/,
|
||||||
|
use: {
|
||||||
|
loader: 'babel-loader',
|
||||||
|
options: {
|
||||||
|
presets: [['env', { targets: { browsers: ['>0.25%', 'not ie 11', 'not op_mini all'] } }]],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
{
|
||||||
|
test: /\.vue$/,
|
||||||
|
loader: 'vue-loader',
|
||||||
|
},
|
||||||
|
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
99
storage.go
Normal file
99
storage.go
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"gopkg.in/yaml.v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
const yamlDelimiter = `---`
|
||||||
|
|
||||||
|
var errFileNotFound = errors.New("Specified file was not found")
|
||||||
|
|
||||||
|
type storedFile struct {
|
||||||
|
Meta map[string]interface{} `json:"meta"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadStoredFile(filename string) (*storedFile, error) {
|
||||||
|
if _, err := os.Stat(filename); err != nil {
|
||||||
|
return nil, errFileNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := ioutil.ReadFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "Unable to read file")
|
||||||
|
}
|
||||||
|
|
||||||
|
return storedFileFromString(string(content))
|
||||||
|
}
|
||||||
|
|
||||||
|
func storedFileFromString(content string) (*storedFile, error) {
|
||||||
|
// Look at first line and see whether this file has a metadata part
|
||||||
|
lines := strings.Split(strings.TrimSpace(content), "\n")
|
||||||
|
if len(lines) == 0 {
|
||||||
|
// Empty file
|
||||||
|
return &storedFile{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
metadata []string
|
||||||
|
contentStart int
|
||||||
|
)
|
||||||
|
|
||||||
|
if lines[0] == yamlDelimiter {
|
||||||
|
// This file has a metadata part
|
||||||
|
for i := 1; i < len(lines); i++ {
|
||||||
|
if lines[i] == yamlDelimiter {
|
||||||
|
contentStart = i + 1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata = append(metadata, lines[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
file := &storedFile{
|
||||||
|
Content: strings.TrimSpace(strings.Join(lines[contentStart:], "\n")),
|
||||||
|
Meta: map[string]interface{}{},
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(metadata) > 0 {
|
||||||
|
if err := yaml.NewDecoder(strings.NewReader(strings.Join(metadata, "\n"))).Decode(&file.Meta); err != nil {
|
||||||
|
return nil, errors.Wrap(err, "Unable to parse metadata part")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s storedFile) GetMetaString(key string) string {
|
||||||
|
if v, ok := s.Meta[key].(string); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s storedFile) Save(filename string) error {
|
||||||
|
f, err := os.Create(filename)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(err, "Unable to create file")
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
if len(s.Meta) > 0 {
|
||||||
|
fmt.Fprintln(f, yamlDelimiter)
|
||||||
|
if err := yaml.NewEncoder(f).Encode(s.Meta); err != nil {
|
||||||
|
return errors.Wrap(err, "Unable to write metadata")
|
||||||
|
}
|
||||||
|
fmt.Fprintln(f, yamlDelimiter)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintln(f, s.Content)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
53
storage_test.go
Normal file
53
storage_test.go
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestStoredFileParse(t *testing.T) {
|
||||||
|
var (
|
||||||
|
err error
|
||||||
|
file string
|
||||||
|
sFile *storedFile
|
||||||
|
)
|
||||||
|
|
||||||
|
// Case: Proper file with header
|
||||||
|
|
||||||
|
file = `
|
||||||
|
---
|
||||||
|
key: value
|
||||||
|
---
|
||||||
|
|
||||||
|
# Header
|
||||||
|
|
||||||
|
content
|
||||||
|
`
|
||||||
|
|
||||||
|
sFile, err = storedFileFromString(file)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parsing of proper file errored: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sFile.Content != "# Header\n\ncontent" {
|
||||||
|
t.Errorf("Content did not match expectation: %q", sFile.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sFile.Meta) != 1 || sFile.GetMetaString("key") != "value" {
|
||||||
|
t.Errorf("Metadata did not match expectation: %#v", sFile.Meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case: No header
|
||||||
|
|
||||||
|
file = "# Header\n\ncontent"
|
||||||
|
|
||||||
|
sFile, err = storedFileFromString(file)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parsing of proper file errored: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if sFile.Content != "# Header\n\ncontent" {
|
||||||
|
t.Errorf("Content did not match expectation: %q", sFile.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(sFile.Meta) != 0 {
|
||||||
|
t.Errorf("Metadata did not match expectation: %#v", sFile.Meta)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue