├── public
└── dist
│ └── .gitkeep
├── .babelrc
├── sample.env
├── circle.yml
├── .gitignore
├── src
├── share
│ ├── string.js
│ ├── markup
│ │ ├── test
│ │ │ ├── test_helper.js
│ │ │ ├── render.js
│ │ │ └── parser.js
│ │ ├── render.js
│ │ └── parser.js
│ ├── print-all-errors.js
│ ├── test
│ │ ├── string.js
│ │ └── route.js
│ ├── title.js
│ ├── debug.js
│ └── route.js
├── server
│ ├── test
│ │ ├── test_helper.js
│ │ ├── github.js
│ │ ├── app.js
│ │ └── cache.js
│ ├── socket
│ │ ├── index.js
│ │ ├── middleware.js
│ │ ├── related-pagelist.js
│ │ ├── room.js
│ │ ├── pagelist.js
│ │ └── page.js
│ ├── route
│ │ ├── middleware.js
│ │ ├── text.js
│ │ ├── feed.js
│ │ ├── index.js
│ │ └── auth.js
│ ├── lib
│ │ ├── github.js
│ │ └── cache.js
│ ├── views
│ │ ├── twitter-card.jsx
│ │ ├── index-static.jsx
│ │ └── index.jsx
│ ├── model
│ │ ├── index.js
│ │ ├── user.js
│ │ └── page.js
│ └── index.js
└── client
│ ├── socket
│ ├── title.js
│ ├── page.js
│ ├── pagelist.js
│ └── index.js
│ ├── reducer
│ ├── related-pagelist.js
│ ├── index.js
│ ├── socket.js
│ ├── pagelist.js
│ └── page.js
│ ├── middleware
│ ├── logger.js
│ ├── socket-connection.js
│ ├── document.js
│ ├── related-pagelist.js
│ ├── pagelist.js
│ ├── title.js
│ ├── index.js
│ ├── route.js
│ └── page.js
│ ├── line.js
│ ├── component
│ ├── route-link.jsx
│ ├── usericon.jsx
│ ├── socket-status.jsx
│ ├── page-date.jsx
│ ├── header.jsx
│ ├── code.jsx
│ ├── edit-tool.jsx
│ ├── longpress.jsx
│ ├── login.jsx
│ ├── title.jsx
│ ├── pagelist.jsx
│ ├── store-component.jsx
│ ├── syntax
│ │ ├── markup.js
│ │ └── decorator.js
│ ├── editor.jsx
│ └── editor-line.jsx
│ ├── index.js
│ ├── container
│ └── pagelist.jsx
│ ├── app.jsx
│ ├── action
│ └── index.js
│ └── styl
│ └── index.styl
├── run-server.js
├── .eslintrc.json
├── README.md
└── package.json
/public/dist/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [ "es2015", "stage-0", "react" ]
3 | }
--------------------------------------------------------------------------------
/sample.env:
--------------------------------------------------------------------------------
1 | GITHUB_CLIENT_ID=your-client-id
2 | GITHUB_CLIENT_SECRET=your-client-secret
3 |
--------------------------------------------------------------------------------
/circle.yml:
--------------------------------------------------------------------------------
1 | machine:
2 | node:
3 | version: 6.5
4 | test:
5 | override:
6 | - npm run test
7 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *~
2 | *#*
3 | .DS_Store
4 | node_modules/
5 | *.log
6 | *.log.*
7 | tmp/
8 | dist/
9 | .env
10 |
--------------------------------------------------------------------------------
/src/share/string.js:
--------------------------------------------------------------------------------
1 | export function camelize(str){
2 | return str.replace(/[\-_](.)/g, (_, c) => c.toUpperCase())
3 | }
4 |
--------------------------------------------------------------------------------
/src/server/test/test_helper.js:
--------------------------------------------------------------------------------
1 | export function delay(msec){
2 | return new Promise((resolve, reject) => {
3 | setTimeout(resolve, msec)
4 | })
5 | }
6 |
--------------------------------------------------------------------------------
/src/client/socket/title.js:
--------------------------------------------------------------------------------
1 | export default function use({io, store, action}){
2 |
3 | io.on("page:title:change", (page) => {
4 | const {wiki, title} = page
5 | action.route({wiki, title})
6 | })
7 |
8 | }
9 |
--------------------------------------------------------------------------------
/src/client/reducer/related-pagelist.js:
--------------------------------------------------------------------------------
1 | export default function relatedPageListReducer(state = [], action){
2 | switch(action.type){
3 | case "related-pagelist":
4 | state = action.value
5 | break
6 | }
7 | return state
8 | }
9 |
--------------------------------------------------------------------------------
/src/share/markup/test/test_helper.js:
--------------------------------------------------------------------------------
1 | import {assert} from "chai"
2 |
3 | assert.regExpEqual = function(a, b, msg){
4 | if(!(a instanceof RegExp && b instanceof RegExp)) throw new Error("Argument error: not RegExp")
5 | return this.equal(a.source, b.source, msg)
6 | }
7 |
--------------------------------------------------------------------------------
/src/client/middleware/logger.js:
--------------------------------------------------------------------------------
1 | const debug = require("../../share/debug")(__filename)
2 |
3 | export default store => next => action => {
4 | debug(`TYPE "${action.type}"`, "VALUE", action.value)
5 | const result = next(action)
6 | debug("STATE", store.getState())
7 | return result
8 | }
9 |
--------------------------------------------------------------------------------
/src/client/reducer/index.js:
--------------------------------------------------------------------------------
1 | import {combineReducers} from "redux"
2 | import page from "./page"
3 | import pagelist from "./pagelist"
4 | import relatedPagelist from './related-pagelist'
5 | import socket from "./socket"
6 |
7 | export default combineReducers({page, pagelist, relatedPagelist, socket})
8 |
--------------------------------------------------------------------------------
/src/client/reducer/socket.js:
--------------------------------------------------------------------------------
1 | export default function socketReducer(state = {}, action){
2 | switch(action.type){
3 | case "socket:connect":
4 | state.connecting = true
5 | break
6 | case "socket:disconnect":
7 | state.connecting = false
8 | break
9 | }
10 | return state
11 | }
12 |
--------------------------------------------------------------------------------
/src/server/test/github.js:
--------------------------------------------------------------------------------
1 | /* eslint-env mocha */
2 |
3 | import {assert} from "chai"
4 | import GitHub from "../lib/github"
5 |
6 | describe("GitHub", function(){
7 |
8 | const github = new GitHub("foobarbaz")
9 |
10 | it('should have method "getUser"', function(){
11 | assert.isFunction(github.getUser)
12 | })
13 |
14 | })
15 |
--------------------------------------------------------------------------------
/src/client/line.js:
--------------------------------------------------------------------------------
1 | import shortid from "shortid"
2 | import hasDom from "has-dom"
3 |
4 | export default class Line{
5 | constructor(opts){
6 | this.value = ""
7 | this.indent = 0
8 | this.user = hasDom() && window.user ? window.user.id : null
9 | this.id = shortid.generate()
10 | Object.assign(this, opts)
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/server/socket/index.js:
--------------------------------------------------------------------------------
1 | import {setUserContext} from "./middleware"
2 |
3 | import page from "./page"
4 | import list from "./pagelist"
5 | import relatedPagelist from "./related-pagelist"
6 |
7 | export function use(app){
8 |
9 | app.context.io.use(setUserContext)
10 |
11 | page(app)
12 | list(app)
13 | relatedPagelist(app)
14 |
15 | }
16 |
--------------------------------------------------------------------------------
/src/share/print-all-errors.js:
--------------------------------------------------------------------------------
1 | import hasDom from 'has-dom'
2 |
3 | if(hasDom()){
4 | window.addEventListener('unhandledrejection', reason => {
5 | console.error('Unhandled Rejection', reason)
6 | })
7 | }
8 | else{
9 | process.on('unhandledRejection', (err) => {
10 | console.error('Unhandled Rejection', err.stack || err)
11 | })
12 | }
13 |
--------------------------------------------------------------------------------
/run-server.js:
--------------------------------------------------------------------------------
1 | require("babel-polyfill");
2 |
3 | var path = './dist/server/';
4 | if(process.env.NODE_ENV !== "production"){
5 | path = './src/server/';
6 | require("babel-register");
7 | }
8 |
9 | console.log("load " + path);
10 | const server = require(path).server;
11 |
12 | // start server
13 | const port = process.env.PORT || 3000;
14 | server.listen(port);
15 | console.log(`server start => port:${port}`);
16 |
--------------------------------------------------------------------------------
/src/client/middleware/socket-connection.js:
--------------------------------------------------------------------------------
1 | export const stopEditOnSocketDisconnect = store => next => action => {
2 | if(action.type === "editline" || action.type === "page:title:startEdit"){
3 | const {connecting} = store.getState().socket
4 | if(!connecting) return
5 | }
6 | if(action.type === "socket:disconnect"){
7 | store.dispatch({type: "editline", value: null})
8 | store.dispatch({type: "page:title:cancelEdit"})
9 | }
10 | return next(action)
11 | }
12 |
--------------------------------------------------------------------------------
/src/share/test/string.js:
--------------------------------------------------------------------------------
1 | /* eslint-env mocha */
2 |
3 | import {camelize} from "../string"
4 | import {assert} from "chai"
5 |
6 | describe("string", function(){
7 |
8 | describe("camelize", function(){
9 | it("should convert hyphen to camel", function(){
10 | assert.equal(camelize("click-to-start"), "clickToStart")
11 | })
12 |
13 | it("should convert sneak to camel", function(){
14 | assert.equal(camelize("to_string"), "toString")
15 | })
16 | })
17 |
18 | })
19 |
--------------------------------------------------------------------------------
/src/client/middleware/document.js:
--------------------------------------------------------------------------------
1 | import {buildTitle} from "../../share/title"
2 |
3 | export const updateTitle = store => next => action => {
4 | const result = next(action)
5 | if(action.type === "route"){
6 | let {wiki, title} = store.getState().page
7 | document.title = buildTitle({wiki, title})
8 | }
9 | else if(/line|page/i.test(action.type)){
10 | let {wiki, title, lines} = store.getState().page
11 | document.title = buildTitle({wiki, title, lines})
12 | }
13 | return result
14 | }
15 |
--------------------------------------------------------------------------------
/src/client/reducer/pagelist.js:
--------------------------------------------------------------------------------
1 | import uniq from "lodash.uniq"
2 |
3 | export default function pageListReducer(state = [], action){
4 | switch(action.type){
5 | case "pagelist":
6 | state = action.value
7 | break
8 | case "pagelist:update":
9 | state.unshift(action.value)
10 | state = uniq(state)
11 | break
12 | case "pagelist:remove": {
13 | let {title} = action.value
14 | state = state.filter(page => page.title !== title)
15 | break
16 | }
17 | }
18 | return state
19 | }
20 |
--------------------------------------------------------------------------------
/src/server/test/app.js:
--------------------------------------------------------------------------------
1 | /* eslint-env mocha */
2 |
3 | import {server} from '../'
4 | import supertest from 'supertest'
5 | import {assert} from 'chai'
6 |
7 | describe('Semirara App', function () {
8 | const agent = supertest(server)
9 |
10 | it('redirect index to /general/hello', async function () {
11 | const res = await agent
12 | .get('/')
13 | .expect(302)
14 | .expect('Content-Type', /text/)
15 | assert.equal(res.header.location, '/general/hello')
16 | return res
17 | })
18 | })
19 |
--------------------------------------------------------------------------------
/src/client/component/route-link.jsx:
--------------------------------------------------------------------------------
1 | import React, {PropTypes} from 'react'
2 | import {buildPath} from "../../share/route"
3 |
4 | export default function RouteLink({action, route, children}){
5 | function onClick(e){
6 | e.preventDefault()
7 | e.stopPropagation()
8 | action.route(route)
9 | }
10 | return (
11 | {children}
12 | )
13 | }
14 |
15 | RouteLink.propTypes = {
16 | action: PropTypes.object,
17 | route: PropTypes.object.isRequired,
18 | children: PropTypes.object.isRequired
19 | }
20 |
--------------------------------------------------------------------------------
/src/client/middleware/related-pagelist.js:
--------------------------------------------------------------------------------
1 | import {io} from "../socket"
2 | import ioreq from "socket.io-request"
3 |
4 | export const getRelatedPageListOnRoute = store => next => async (action) => {
5 | if(action.type !== "route") return next(action)
6 | const result = next(action)
7 | const {wiki, title} = store.getState().page
8 | try{
9 | const relatedPagelist = await ioreq(io).request("get-related-pagelist", {wiki, title})
10 | store.dispatch({type: "related-pagelist", value: relatedPagelist})
11 | }
12 | catch(err){
13 | console.error(err.stack || err)
14 | }
15 | return result
16 | }
17 |
--------------------------------------------------------------------------------
/src/server/route/middleware.js:
--------------------------------------------------------------------------------
1 | const debug = require("../../share/debug")(__filename)
2 |
3 | import mongoose from "mongoose"
4 | const User = mongoose.model("User")
5 |
6 | export async function setUserContext(ctx, next){
7 | debug("set ctx.user")
8 | const session = ctx.cookies.get("session")
9 | if(!session) return await next()
10 | ctx.user = await User.findBySession(session)
11 | if(ctx.user) debug(`ctx.user="${ctx.user.github.login}"`)
12 | await next()
13 | }
14 |
15 | export async function ignoreFavicon(ctx, next){
16 | if(ctx.path === "/favicon.ico") return ctx.status = 404
17 | await next()
18 | }
19 |
--------------------------------------------------------------------------------
/src/server/route/text.js:
--------------------------------------------------------------------------------
1 | import Router from "koa-66"
2 | const router = new Router()
3 | export default router
4 |
5 | import mongoose from "mongoose"
6 | const Page = mongoose.model("Page")
7 |
8 | router.get("/text/:wiki/:title", async (ctx, next) => {
9 | const {wiki, title} = ctx.params
10 | const page = await Page.findOneByWikiTitle({wiki, title})
11 | if (!page) {
12 | ctx.status = 404
13 | ctx.body = ''
14 | return
15 | }
16 | ctx.body = [page.title]
17 | .concat(
18 | page.lines
19 | .map(line => ' '.repeat(line.indent) + line.value)
20 | ).join('\n')
21 | return
22 | })
23 |
--------------------------------------------------------------------------------
/src/client/middleware/pagelist.js:
--------------------------------------------------------------------------------
1 | import {io} from "../socket"
2 | import ioreq from "socket.io-request"
3 |
4 | export const getPageListOnRoute = store => next => async (action) => {
5 | if(action.type !== "route") return next(action)
6 | const _wiki = store.getState().page.wiki
7 | const result = next(action)
8 | const {wiki} = store.getState().page
9 | if(wiki !== _wiki){
10 | try{
11 | const pagelist = await ioreq(io).request("getpagelist", {wiki})
12 | store.dispatch({type: "pagelist", value: pagelist})
13 | }
14 | catch(err){
15 | console.error(err.stack || err)
16 | }
17 | }
18 | return result
19 | }
20 |
--------------------------------------------------------------------------------
/src/client/component/usericon.jsx:
--------------------------------------------------------------------------------
1 | import React, {Component} from "react"
2 |
3 | export default class UserIcon extends Component{
4 |
5 | static get propTypes(){
6 | return {
7 | id: React.PropTypes.number.isRequired,
8 | size: React.PropTypes.number.isRequired
9 | }
10 | }
11 |
12 | static get defaultProps(){
13 | return {
14 | size: 20
15 | }
16 | }
17 |
18 | get url(){
19 | return `https://avatars.githubusercontent.com/u/${this.props.id}?v=3&s=${this.props.size}`
20 | }
21 |
22 | render(){
23 | return
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/client/socket/page.js:
--------------------------------------------------------------------------------
1 | import ioreq from "socket.io-request"
2 |
3 | export default function use({io, store, action}){
4 |
5 | io.once("connect", () => {
6 | io.on("connect", async () => { // for next connect event
7 | const state = store.getState()
8 | const {wiki, title} = state.page
9 | try{
10 | const page = await ioreq(io).request("getpage", {wiki, title})
11 | action.setPage(page)
12 | }
13 | catch(err){
14 | console.error(err.stack || err)
15 | }
16 | })
17 | })
18 |
19 | io.on("page:lines", (page) => {
20 | if(!page.lines) return
21 | action.setPageLines(page)
22 | })
23 |
24 | }
25 |
--------------------------------------------------------------------------------
/src/client/component/socket-status.jsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import StoreComponent from "./store-component"
3 | import classnames from "classnames"
4 |
5 | export default class SocketStatus extends StoreComponent{
6 |
7 | mapState(state){
8 | return state.socket
9 | }
10 |
11 | render(){
12 | const {connecting} = this.state
13 | const className = classnames({
14 | "connect": connecting,
15 | "disconnect": !connecting
16 | })
17 | const text = connecting ? "connecting" : "disconnected"
18 | return (
19 |
20 | {text}
21 |
22 | )
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/share/title.js:
--------------------------------------------------------------------------------
1 | import {Parser} from './markup/parser'
2 |
3 | function renderToPlainText(node){
4 | return node.value || node.description || node.title
5 | }
6 |
7 | export function buildTitle({wiki, title, lines}){
8 | let subtitle
9 | if(lines && lines.length > 0){
10 | for(let line of lines){
11 | if(!(/https?:\/\//.test(line.value))){
12 | let nodes = Parser.parse(line.value.trim())
13 | subtitle = nodes.map(renderToPlainText).join('')
14 | if(subtitle) break
15 | }
16 | }
17 | }
18 |
19 | if(subtitle){
20 | return `${title}: ${subtitle} - ${wiki}`
21 | }
22 | else{
23 | return `${title} - ${wiki}`
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/client/component/page-date.jsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import StoreComponent from "./store-component"
3 | import "date-utils"
4 |
5 | export default class PageDate extends StoreComponent{
6 |
7 | mapState(state){
8 | return state.page
9 | }
10 |
11 | render(){
12 | let {createdAt, updatedAt} = this.state
13 | if(!createdAt || !updatedAt) return null
14 | createdAt = new Date(createdAt).toFormat("YYYY/MM/DD")
15 | updatedAt = new Date(updatedAt).toFormat("YYYY/MM/DD")
16 | let dateStr = createdAt === updatedAt ? `${createdAt}` : `${createdAt}〜${updatedAt}`
17 | return (
18 | {`(${dateStr})`}
19 | )
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/src/client/socket/pagelist.js:
--------------------------------------------------------------------------------
1 | import ioreq from "socket.io-request"
2 |
3 | export default function use({io, store, action}){
4 |
5 | io.once("connect", () => {
6 | io.on("connect", async () => { // for next connect event
7 | const {wiki} = store.getState().page
8 | try{
9 | const pagelist = await ioreq(io).request("getpagelist", {wiki})
10 | action.setPageList(pagelist)
11 | }
12 | catch(err){
13 | console.error(err.stack || err)
14 | }
15 | })
16 | })
17 |
18 | io.on("pagelist:update", ({title, image}) => {
19 | action.pagelistUpdate({title, image})
20 | })
21 |
22 | io.on("pagelist:remove", ({title}) => {
23 | action.pagelistRemove({title})
24 | })
25 |
26 | }
27 |
--------------------------------------------------------------------------------
/src/server/lib/github.js:
--------------------------------------------------------------------------------
1 | /* eslint no-console: 0 */
2 |
3 | const debug = require("../../share/debug")(__filename)
4 |
5 | import axios from "axios"
6 |
7 | export default class GitHub{
8 |
9 | constructor(token){
10 | this.token = token
11 | this.baseUrl = "https://api.github.com"
12 | }
13 |
14 | async get(path, params = {}){
15 | debug("get " + path)
16 | try{
17 | params.access_token = this.token
18 | const url = this.baseUrl + path
19 | const res = await axios.get(url, {params: params})
20 | debug(res)
21 | return res.data
22 | }
23 | catch(err){
24 | console.error(err)
25 | throw err
26 | }
27 | }
28 |
29 | getUser(){
30 | return this.get("/user")
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["eslint:recommended", "plugin:react/recommended"],
3 | "parser": "babel-eslint",
4 | "env":{
5 | "node": true,
6 | "browser": true,
7 | "es6": true
8 | },
9 | "plugins":[
10 | "react",
11 | "if-in-test"
12 | ],
13 | "rules":{
14 | "strict": [2, "global"],
15 | "semi": [2, "never"],
16 | "new-cap": 0,
17 | "comma-spacing": [2, {"before": false, "after": true}],
18 | "no-unused-vars": [1, {"args": "none"}],
19 | "no-constant-condition": 0,
20 | "no-shadow": 0,
21 | "no-loop-func": 0,
22 | "curly": 0,
23 | "no-constant-condition": 1,
24 | "no-console": [1, {"allow": ["warn", "error"]}],
25 | "if-in-test/if": [1, { "directory": ["test", "src/*/test", "src/**/test"] }]
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/server/socket/middleware.js:
--------------------------------------------------------------------------------
1 | const debug = require("../../share/debug")(__filename)
2 | import Cookie from "cookie"
3 |
4 | import mongoose from "mongoose"
5 | const User = mongoose.model("User")
6 |
7 | export async function setUserContext(socket, next){
8 | debug("set socket.user")
9 | try{
10 | const cookie = socket.request.headers.cookie
11 | if(!cookie) throw "cookie is not exists"
12 | const session = Cookie.parse(socket.request.headers.cookie).session
13 | if(!session) throw "session is not exists in cookie"
14 | socket.user = await User.findBySession(session)
15 | if(socket.user) debug(`socket.user=${socket.user.github.login}`)
16 | }
17 | catch(err){
18 | debug(err.stack || err)
19 | socket.disconnect()
20 | }
21 | return next()
22 | }
23 |
--------------------------------------------------------------------------------
/src/client/component/header.jsx:
--------------------------------------------------------------------------------
1 | import React, {Component} from "react"
2 |
3 | import EditTool from './edit-tool'
4 | import Login from "./login"
5 | import Title from "./title"
6 | import PageDate from "./page-date"
7 |
8 | export default class Header extends Component{
9 |
10 | static get propTypes(){
11 | return {
12 | store: React.PropTypes.object.isRequired
13 | }
14 | }
15 |
16 | render(){
17 | const {store} = this.props
18 | return(
19 |
28 | )
29 | }
30 |
31 | }
32 |
--------------------------------------------------------------------------------
/src/server/socket/related-pagelist.js:
--------------------------------------------------------------------------------
1 | const debug = require("../../share/debug")(__filename)
2 |
3 | import ioreq from "socket.io-request"
4 | import mongoose from "mongoose"
5 | const Page = mongoose.model("Page")
6 |
7 | export default function use(app){
8 |
9 | const io = app.context.io
10 |
11 | io.on("connection", (socket) => {
12 |
13 | ioreq(socket).response("get-related-pagelist", async (req, res) => {
14 | const {wiki, title} = req
15 | debug(req)
16 | try{
17 | const page = await Page.findOneByWikiTitle({wiki, title})
18 | const pages = await page.findRelatedPages()
19 | debug(pages)
20 | res(pages.map(({title, image}) => ({title, image})))
21 | }
22 | catch(err){
23 | console.error(err.stack || err)
24 | }
25 | })
26 |
27 | })
28 |
29 | }
30 |
--------------------------------------------------------------------------------
/src/share/debug.js:
--------------------------------------------------------------------------------
1 | // create debug instance from filename
2 |
3 | import Debug from 'debug'
4 | import path from 'path'
5 | import pkg from '../../package.json'
6 |
7 | const PATH_HEADS = [
8 | '/src/client',
9 | '/src/share',
10 | path.resolve('src/server'),
11 | path.resolve('dist/server'),
12 | path.resolve('src/share'),
13 | path.resolve('dist/share')
14 | ]
15 |
16 | function fileToName (filename) {
17 | if (typeof filename !== 'string') throw new Error('filename is not string')
18 | return filename
19 | .replace(new RegExp('^(' + PATH_HEADS.join('|') + ')'), pkg.name)
20 | .replace(/\..+$/, '') // remove file extension
21 | .replace(/\/index$/, '') // remove "index.js"
22 | .replace(/\//g, ':') // convert separator
23 | }
24 |
25 | module.exports = function createDebug (filename) {
26 | return Debug(fileToName(filename))
27 | }
28 |
--------------------------------------------------------------------------------
/src/server/views/twitter-card.jsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import {buildTitle} from "../../share/title"
3 |
4 | export default function TwitterCard({page}){
5 | const {wiki, title, lines, image} = page
6 | const description = lines.map(i => i.value)
7 | .join('')
8 | .replace(/https?:\/\/[^\s]+/g, "")
9 | .replace(/[\[\]]/g, "")
10 | .slice(0, 200)
11 |
12 | const imageCard = image ? : null
13 | return (
14 |
15 |
16 |
17 |
18 | {imageCard}
19 |
20 | )
21 | }
22 |
23 | TwitterCard.propTypes = {
24 | page: React.PropTypes.object.isRequired
25 | }
26 |
--------------------------------------------------------------------------------
/src/server/socket/room.js:
--------------------------------------------------------------------------------
1 | // socket.io room
2 | // each socket has one room - leave cunrrent room when join to other room, or disconnected.
3 |
4 | const debug = require("../../share/debug")(__filename)
5 |
6 | export default class Room{
7 | constructor(socket){
8 | this.socket = socket
9 | this.name = null
10 | socket.once("disconnect", () => {
11 | this.leave()
12 | this.socket = null
13 | })
14 | }
15 |
16 | // leave current room, then join new room.
17 | join(name){
18 | if(this.name === name) return
19 | this.leave()
20 | this.name = name
21 | this.socket.join(this.name)
22 | debug(`${this.socket.id} joins room ${this.name}`)
23 | }
24 |
25 | leave(){
26 | if(!this.name) return
27 | debug(`${this.socket.id} leaves room ${this.name}`)
28 | this.socket.leave(this.name)
29 | this.page = null
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/src/server/model/index.js:
--------------------------------------------------------------------------------
1 | import mongoose from "mongoose"
2 | mongoose.Promise = Promise
3 |
4 | import "./user"
5 | import "./page"
6 |
7 | const url =
8 | process.env.MONGODB_URL ||
9 | process.env.MONGOLAB_URI ||
10 | process.env.MONGOHQ_URL ||
11 | 'mongodb://localhost/semirara'
12 |
13 | export default {
14 | connect: function(){
15 | return mongoose.connect(url)
16 | }
17 | }
18 |
19 |
20 | export function ambiguous(query){
21 | for(let k in query){
22 | let v = query[k]
23 | if(typeof v === "string"){
24 | v = v.replace(/\s/g, "").split('').join(' ?') // spaces
25 | v = v.replace(/[\\\+\*\.\[\]\{\}\(\)\^\|]/g, c => `\\${c}`) // replace regex
26 | v = v.replace(" ??", " ?\\?")
27 | v = v.replace(/^\?/, "\\?")
28 | v = new RegExp(`^${v}$`, "i")
29 | query[k] = v
30 | }
31 | }
32 | return query
33 | }
34 |
--------------------------------------------------------------------------------
/src/client/middleware/title.js:
--------------------------------------------------------------------------------
1 | import {io} from "../socket/"
2 | import ioreq from "socket.io-request"
3 | import {validateTitle} from "../../share/route"
4 |
5 | export const onPageTitleSubmit = store => next => async (action) => {
6 | if(action.type !== "page:title:submit") return next(action)
7 | const {wiki, title, newTitle} = store.getState().page
8 | if(newTitle === title) return next(action)
9 | const valid = validateTitle(newTitle)
10 | if(valid.invalid){
11 | alert(valid.errors.join("\n"))
12 | return next(action)
13 | }
14 | const res = await ioreq(io).request("page:title:change", {title, wiki, newTitle})
15 | if(!res.error) return next(action)
16 | alert(res.error)
17 | return
18 | }
19 |
20 | export const cancelTitleEditOnRoute = store => next => action => {
21 | if(action.type !== "route") return next(action)
22 | store.dispatch({type: "page:title:cancelEdit"})
23 | return next(action)
24 | }
25 |
--------------------------------------------------------------------------------
/src/client/index.js:
--------------------------------------------------------------------------------
1 | import "babel-regenerator-runtime"
2 |
3 | import '../share/print-all-errors'
4 |
5 | import React from "react"
6 | import {render} from "react-dom"
7 | import {createStore, applyMiddleware, bindActionCreators} from "redux"
8 | import reducer from "./reducer/"
9 | import middlewares from "./middleware/"
10 | import * as actions from "./action/"
11 | import App from "./app"
12 | import Line from "./line"
13 |
14 | const store = createStore(
15 | reducer,
16 | {
17 | page: {
18 | lines: [ new Line ],
19 | editline: null
20 | },
21 | pagelist: [ ],
22 | relatedPagelist: [],
23 | socket: {
24 | connecting: false
25 | }
26 | },
27 | applyMiddleware(...middlewares)
28 | )
29 |
30 | render(, document.getElementById("app"))
31 |
32 | import socket from "./socket"
33 | socket({
34 | store,
35 | action: bindActionCreators(actions, store.dispatch)
36 | })
37 |
--------------------------------------------------------------------------------
/src/client/middleware/index.js:
--------------------------------------------------------------------------------
1 | import logger from "./logger"
2 | import {validateOnRoute, pushStateOnRoute, scrollTopOnRoute} from "./route"
3 | import {getPageOnRoute, sendPage, unsetEditLineOnRoute, removeEmptyLines, adjustEditLineOnPageLines} from "./page"
4 | import {getPageListOnRoute} from "./pagelist"
5 | import {getRelatedPageListOnRoute} from "./related-pagelist"
6 | import {onPageTitleSubmit, cancelTitleEditOnRoute} from "./title"
7 | import {stopEditOnSocketDisconnect} from "./socket-connection"
8 | import {updateTitle} from "./document"
9 |
10 | export default [ validateOnRoute, pushStateOnRoute, updateTitle, unsetEditLineOnRoute,
11 | getPageOnRoute, getPageListOnRoute, getRelatedPageListOnRoute,
12 | stopEditOnSocketDisconnect,
13 | removeEmptyLines, sendPage, adjustEditLineOnPageLines,
14 | onPageTitleSubmit, cancelTitleEditOnRoute,
15 | scrollTopOnRoute,
16 | logger ]
17 |
--------------------------------------------------------------------------------
/src/client/container/pagelist.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import StoreComponent from '../component/store-component'
3 | import PageList from '../component/pagelist'
4 |
5 | export default class PageListContainer extends StoreComponent {
6 | render () {
7 | const {wiki, title} = this.state.page
8 | const {pagelist, relatedPagelist} = this.state
9 | if (!wiki || !title) return null
10 | let related
11 | if (relatedPagelist && relatedPagelist.length > 0) {
12 | related = (
13 |
20 | )
21 | }
22 |
23 |
24 | return (
25 |
35 | )
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/client/component/code.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/no-danger */
2 |
3 | import React from "react"
4 |
5 | import hljs, {highlight, highlightAuto} from "highlight.js"
6 |
7 | const reverseAliases = {}
8 | for(let lang of hljs.listLanguages()){
9 | let aliases = hljs.getLanguage(lang).aliases
10 | if(aliases){
11 | for(let alias of aliases){
12 | reverseAliases[alias] = lang
13 | }
14 | }
15 | }
16 |
17 | export function getFullLanguage(lang){
18 | return reverseAliases[lang]
19 | }
20 |
21 | export default function Code({lang, children}){
22 | let __html
23 | try{
24 | __html = lang ? highlight(lang, children, true).value : highlightAuto(children).value
25 | }
26 | catch(err){
27 | if (!(/Unknown language/.test(err.message))) {
28 | console.error(err.stack || err)
29 | }
30 | return {children}
31 | }
32 | return
33 | }
34 |
35 | Code.propTypes = {
36 | lang: React.PropTypes.string,
37 | children: React.PropTypes.string.isRequired
38 | }
39 |
--------------------------------------------------------------------------------
/src/server/index.js:
--------------------------------------------------------------------------------
1 | /* eslint no-console: 0 */
2 |
3 | import '../share/print-all-errors'
4 |
5 | import model from "./model"
6 | model.connect().catch(console.error)
7 |
8 | import Koa from "koa"
9 | const app = new Koa
10 |
11 | import convert from "koa-convert"
12 | import koaStatic from "koa-static"
13 | app.use(convert(koaStatic("./public")))
14 |
15 | import {Server} from "http"
16 | const server = Server(app.callback())
17 |
18 | import SocketIO from "socket.io"
19 | app.context.io = SocketIO(server)
20 | import * as socket from "./socket"
21 | socket.use(app)
22 |
23 | import pkg from "../../package.json"
24 | app.name = pkg.name
25 |
26 | import logger from "koa-logger"
27 | app.use(logger())
28 |
29 | import router from "./route"
30 | app.use(router.routes())
31 |
32 | import react from "koa-react-view"
33 | import path from "path"
34 |
35 | react(app, {
36 | views: path.join(__dirname, "views"),
37 | extname: process.env.NODE_ENV === "production" ? ".js" : ".jsx",
38 | internals: false
39 | })
40 |
41 | module.exports = { app, server }
42 |
--------------------------------------------------------------------------------
/src/client/component/edit-tool.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import StoreComponent from './store-component'
3 | import hasDom from 'has-dom'
4 |
5 | export default class EditTool extends StoreComponent {
6 |
7 | constructor () {
8 | super()
9 | this.createNewPage = this.createNewPage.bind(this)
10 | }
11 |
12 | createNewPage (e) {
13 | e.preventDefault()
14 | const title = window.prompt('create new page')
15 | if (!title) return
16 | this.action.route({title})
17 | }
18 |
19 | render () {
20 | if (!hasDom()) return null
21 | const {wiki, title} = this.state.page
22 | return (
23 |
37 | )
38 | }
39 |
40 | }
41 |
--------------------------------------------------------------------------------
/src/client/socket/index.js:
--------------------------------------------------------------------------------
1 | import SocketIO from "socket.io-client"
2 | import {defaultRoute, parseRoute} from "../../share/route"
3 | import page from "./page"
4 | import pagelist from "./pagelist"
5 | import title from "./title"
6 |
7 | export const io = SocketIO()
8 |
9 | export default function use({store, action}){
10 |
11 | io.on("connect", () => {
12 | store.dispatch({type: "socket:connect"})
13 | })
14 |
15 | io.on("disconnect", () => {
16 | store.dispatch({type: "socket:disconnect"})
17 | })
18 |
19 | io.on("reconnect", () => {
20 | location.reload()
21 | })
22 |
23 | page({io, store, action})
24 | pagelist({io, store, action})
25 | title({io, store, action})
26 |
27 | var popStateTimeout
28 | window.addEventListener("popstate", (e) => {
29 | clearTimeout(popStateTimeout)
30 | popStateTimeout = setTimeout(() => {
31 | action.noPushStateRoute(Object.assign({}, defaultRoute, parseRoute()))
32 | }, 500)
33 | }, false)
34 |
35 | io.on("connect", () => {
36 | action.route(Object.assign({}, defaultRoute, parseRoute()))
37 | })
38 |
39 | }
40 |
--------------------------------------------------------------------------------
/src/share/markup/render.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 |
3 | export function renderToJSX(node){
4 | switch(node.type){
5 | case "text":
6 | return {node.value}
7 | case "strong":
8 | return {node.value}
9 | case "image":
10 | return
11 | case "external-link":
12 | return {node.link}
13 | case "external-link-with-description":
14 | return {node.description}
15 | case "external-link-with-image":
16 | return
17 | case "wiki-link":
18 | return {node.wiki}::
19 | case "wiki-title-link":
20 | return {`${node.wiki}::${node.title}`}
21 | case "inline-code":
22 | return {node.value}
23 | case "title-link":
24 | // require store state
25 | break
26 | }
27 | throw new Error(`unknown node type "${node.type}"`)
28 | }
29 |
--------------------------------------------------------------------------------
/src/client/component/longpress.jsx:
--------------------------------------------------------------------------------
1 | import React, {Component} from "react"
2 |
3 | export default class LongPress extends Component{
4 |
5 | static get propTypes(){
6 | return {
7 | children: React.PropTypes.node.isRequired,
8 | threshold: React.PropTypes.number,
9 | onLongPress: React.PropTypes.func.isRequired
10 | }
11 | }
12 |
13 | static get defaultProps(){
14 | return {
15 | threshold: 500
16 | }
17 | }
18 |
19 | constructor(){
20 | super()
21 | this.start = this.start.bind(this)
22 | this.stop = this.stop.bind(this)
23 | this.timeout = null
24 | }
25 |
26 | render(){
27 | return (
28 |
32 | {this.props.children}
33 |
34 | )
35 | }
36 |
37 | start(e){
38 | this.stop()
39 | this.timeout = setTimeout(this.props.onLongPress, this.props.threshold)
40 | }
41 |
42 | stop(e){
43 | if(!this.timeout) return
44 | clearTimeout(this.timeout)
45 | this.timeout = null
46 | }
47 |
48 | }
49 |
--------------------------------------------------------------------------------
/src/client/component/login.jsx:
--------------------------------------------------------------------------------
1 | import React, {Component} from "react"
2 | import UserIcon from "./usericon"
3 | import hasDom from "has-dom"
4 |
5 | export default class Login extends Component {
6 |
7 | static get propTypes(){
8 | return {
9 | user: React.PropTypes.object
10 | }
11 | }
12 |
13 | static get defaultProps(){
14 | return {
15 | user: hasDom() ? window.user : null
16 | }
17 | }
18 |
19 | render(){
20 | let element
21 | if(!this.props.user){
22 | element =
23 | }
24 | else{
25 | element = (
26 |
37 | )
38 | }
39 | return (
40 | {element}
41 | )
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/server/views/index-static.jsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import {createStore} from "redux"
3 | import App from "../../client/app"
4 | import TwitterCard from "./twitter-card"
5 | import {buildTitle} from "../../share/title"
6 |
7 | export default function IndexStaticHTML({user, app, state}){
8 |
9 | const store = createStore((state) => state, state)
10 | const {wiki, title, lines, image} = state.page
11 |
12 | return (
13 |
14 |
15 |
16 | {buildTitle({wiki, title, lines, image})}
17 |
18 |
19 |
20 |
21 |
22 |
25 |
26 |
27 | )
28 |
29 | }
30 |
31 | IndexStaticHTML.propTypes = {
32 | user: React.PropTypes.object.isRequired,
33 | app: React.PropTypes.object.isRequired,
34 | state: React.PropTypes.object.isRequired
35 | }
36 |
--------------------------------------------------------------------------------
/src/server/socket/pagelist.js:
--------------------------------------------------------------------------------
1 | const debug = require("../../share/debug")(__filename)
2 |
3 | import ioreq from "socket.io-request"
4 | import Room from "./room"
5 | import mongoose from "mongoose"
6 | const Page = mongoose.model("Page")
7 |
8 | export default function use(app){
9 |
10 | const io = app.context.io
11 |
12 | io.on("connection", (socket) => {
13 |
14 | const room = new Room(socket)
15 |
16 | ioreq(socket).response("getpagelist", async (req, res) => {
17 | const {wiki} = req
18 | room.join(wiki)
19 | try{
20 | const pages = await Page.findPagesByWiki(wiki)
21 | res(pages.map(({title, image}) => ({title, image})))
22 | }
23 | catch(err){
24 | console.error(err.stack || err)
25 | }
26 | })
27 |
28 | })
29 |
30 | Page.on("update", (page) => {
31 | const {wiki, title, image} = page
32 | debug(`update ${wiki}::${title}`)
33 | io.to(wiki).emit("pagelist:update", {title, image})
34 | })
35 |
36 | Page.on("remove", (page) => {
37 | const {wiki, title} = page
38 | debug(`remove ${wiki}::${title}`)
39 | io.to(wiki).emit("pagelist:remove", {title})
40 | })
41 | }
42 |
--------------------------------------------------------------------------------
/src/client/app.jsx:
--------------------------------------------------------------------------------
1 | const debug = require("../share/debug")(__filename)
2 | import React from "react"
3 | import StoreComponent from "./component/store-component"
4 | import Header from "./component/header"
5 | import Editor from "./component/editor"
6 | import PageListContainer from "./container/pagelist"
7 | import SocketStatus from "./component/socket-status"
8 | import hasDom from "has-dom"
9 |
10 | export default class App extends StoreComponent{
11 |
12 | mapState(state){
13 | return {}
14 | }
15 |
16 | constructor(){
17 | super()
18 | this.onClick = this.onClick.bind(this)
19 | }
20 |
21 | onClick(e){
22 | this.action.unsetEditline()
23 | this.action.cancelTitleEdit()
24 | }
25 |
26 | render(){
27 | debug("render()")
28 | const {store} = this.props
29 | const socketStatus = hasDom() ? : null
30 | return (
31 |
32 |
33 |
34 |
35 |
36 | {socketStatus}
37 |
38 |
39 |
40 | )
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/client/component/title.jsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import {findDOMNode} from "react-dom"
3 |
4 | import StoreComponent from "./store-component"
5 | import LongPress from "./longpress"
6 |
7 | export default class Title extends StoreComponent{
8 |
9 | mapState(state){
10 | return state.page
11 | }
12 |
13 | constructor(){
14 | super()
15 | this.onKeyDown = this.onKeyDown.bind(this)
16 | }
17 |
18 | render(){
19 | const {title, newTitle} = this.state
20 | if(typeof newTitle !== "string"){
21 | return (
22 | {title}
23 | )
24 | }
25 | else{
26 | return (
27 |
28 | this.action.changeTitle(e.target.value)}
32 | onKeyDown={this.onKeyDown}
33 | onClick={e => e.stopPropagation()}
34 | />
35 |
36 | )
37 | }
38 | }
39 |
40 | onKeyDown(e){
41 | if(e.keyCode === 13) this.action.submitTitle()
42 | }
43 |
44 | componentDidUpdate(){
45 | if(!this.state.newTitle) return
46 | findDOMNode(this.refs.input).focus()
47 | }
48 |
49 | }
50 |
--------------------------------------------------------------------------------
/src/server/test/cache.js:
--------------------------------------------------------------------------------
1 | /* eslint-env mocha */
2 |
3 | import {delay} from "./test_helper"
4 | import {assert} from "chai"
5 | import Cache from "../lib/cache"
6 |
7 |
8 | describe("Cache", function(){
9 |
10 | const cache = new Cache({prefix: "test", expire: 1})
11 |
12 | it('should have method "set"', function(){
13 | assert.isFunction(cache.set)
14 | })
15 |
16 | it('should have method "get"', function(){
17 | assert.isFunction(cache.get)
18 | })
19 |
20 | describe('set then get', function(){
21 |
22 | this.timeout(5000)
23 |
24 | it("should get same value", async function(){
25 | await cache.set("name", "shokai")
26 | const res = await cache.get("name")
27 | assert.equal(res, "shokai")
28 | })
29 |
30 | it("should expire in 1 sec", async function(){
31 | await cache.set("name", "shokai")
32 | await delay(1500) // wait for expire
33 | const res = await cache.get("name")
34 | assert.equal(res, null)
35 | })
36 |
37 | })
38 |
39 | describe('delete', function(){
40 | this.timeout(1000)
41 |
42 | it("should delete value", async function(){
43 | await cache.set("name", "shokai")
44 | await cache.delete("name")
45 | const name = await cache.get("name")
46 | assert.equal(name, null)
47 | })
48 | })
49 | })
50 |
--------------------------------------------------------------------------------
/src/server/views/index.jsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react/no-danger */
2 |
3 | import React from "react"
4 |
5 | export default function IndexHTML({user, app}){
6 |
7 | const script = `window.user = ${JSON.stringify(user)}; window.app = ${JSON.stringify(app)};`
8 |
9 | const cdnjs = (
10 |
11 |
12 |
13 |
14 |
15 |
16 | )
17 |
18 | return (
19 |
20 |
21 |
22 | {app.name}
23 |
24 |
25 |
26 |
27 |
28 | {process.env.NODE_ENV === "production" ? cdnjs : null}
29 |
30 |
31 |
32 | )
33 |
34 | }
35 |
36 | IndexHTML.propTypes = {
37 | user: React.PropTypes.object.isRequired,
38 | app: React.PropTypes.object.isRequired
39 | }
40 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # semirara
2 |
3 | - https://github.com/shokai/semirara
4 | - http://wiki.shokai.org
5 |
6 | [](https://circleci.com/gh/shokai/semirara)
7 |
8 |
9 | ## Requirements
10 | - Node.js
11 | - Memcached
12 | - MongoDB
13 |
14 | ## Setup
15 |
16 | [Register new app on GitHub](https://github.com/settings/applications/new)
17 |
18 | % cp sample.env .env
19 | % npm install
20 |
21 |
22 | ## Develop
23 |
24 | % npm run watch
25 | % npm run start:dev
26 |
27 |
28 | ## Deploy
29 |
30 | % NODE_ENV=production npm start
31 |
32 |
33 | ## Deploy on Heroku
34 |
35 | ### create app
36 |
37 | % heroku apps:create [app_name]
38 | % git push heroku master
39 |
40 | ### config
41 |
42 | % heroku config:add TZ=Asia/Tokyo
43 | % heroku config:set "DEBUG=semirara*,koa*"
44 | % heroku config:set NODE_ENV=production
45 | % heroku config:set GITHUB_CLIENT_ID=your-client-id
46 | % heroku config:set GITHUB_CLIENT_SECRET=your-client-secret
47 |
48 | ### enable MongoDB plug-in
49 |
50 | % heroku addons:create mongolab
51 | # or
52 | % heroku addons:create mongohq
53 |
54 | ### enable Memcached plug-in
55 |
56 | % heroku addons:create memcachier
57 |
58 | ### logs
59 |
60 | % heroku logs --num 300
61 | % heroku logs --tail
--------------------------------------------------------------------------------
/src/server/lib/cache.js:
--------------------------------------------------------------------------------
1 | const debug = require("../../share/debug")(__filename)
2 |
3 | import memjs from "memjs"
4 |
5 | const client = memjs.Client.create()
6 |
7 | export default class Cache{
8 |
9 | constructor({prefix, expire}){
10 | this.prefix = prefix || ""
11 | this.expire = expire || 60
12 | }
13 |
14 | key(key){
15 | return this.prefix + ":" + key
16 | }
17 |
18 | set(key, value, expire){
19 | return new Promise((resolve, reject) => {
20 | const _key = this.key(key)
21 | const _value = JSON.stringify(value)
22 | debug(`set ${_key}`)
23 | client.set(_key, _value, (err, val) => {
24 | if(err) return reject(err)
25 | resolve(val)
26 | }, expire || this.expire)
27 | })
28 | }
29 |
30 | get(key){
31 | return new Promise((resolve, reject) => {
32 | const _key = this.key(key)
33 | debug(`get ${_key}`)
34 | client.get(_key, (err, val) => {
35 | if(err) return reject(err)
36 | debug(`hit ${_key}`)
37 | resolve(JSON.parse(val))
38 | })
39 | })
40 | }
41 |
42 | delete(key){
43 | return new Promise((resolve, reject) => {
44 | const _key = this.key(key)
45 | debug(`delete ${_key}`)
46 | client.delete(_key, (err, success) => {
47 | if(err) return reject(err)
48 | resolve(success)
49 | })
50 | })
51 | }
52 |
53 | }
54 |
--------------------------------------------------------------------------------
/src/client/component/pagelist.jsx:
--------------------------------------------------------------------------------
1 | // const debug = require('../../share/debug')(__filename)
2 |
3 | import React, {Component, PropTypes} from "react"
4 | import RouteLink from './route-link'
5 | import classnames from "classnames"
6 |
7 | export default class PageList extends Component {
8 |
9 | static get propTypes () {
10 | return {
11 | wiki: PropTypes.string.isRequired,
12 | name: PropTypes.string.isRequired,
13 | title: PropTypes.string.isRequired,
14 | pagelist: PropTypes.object.isRequired,
15 | action: PropTypes.object.isRequired
16 | }
17 | }
18 |
19 | render(){
20 | const list = this.props.pagelist.map(({title, image}) => {
21 | const style = image ? {
22 | backgroundImage: `url("${image}")`
23 | } : {}
24 | const classNames = classnames({
25 | image,
26 | selected: title === this.props.title
27 | })
28 | return (
29 |
30 |
33 | {title}
34 |
35 |
36 | )
37 | })
38 | return (
39 |
40 |
{this.props.name}({list.length})
41 |
44 |
45 | )
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/src/client/middleware/route.js:
--------------------------------------------------------------------------------
1 | import {buildPath, validateWiki, validateTitle} from "../../share/route"
2 |
3 | export const validateOnRoute = store => next => action => {
4 | if(action.type !== "route") return next(action)
5 | const {wiki, title} = action.value
6 | if(wiki){
7 | let result = validateWiki(wiki)
8 | if(result.invalid){
9 | alert(result.errors)
10 | return
11 | }
12 | }
13 | if(title){
14 | let result = validateTitle(title)
15 | if(result.invalid){
16 | alert(result.errors)
17 | return
18 | }
19 | }
20 | return next(action)
21 | }
22 |
23 | export const pushStateOnRoute = store => next => action => {
24 | if(["route", "page"].indexOf(action.type) < 0){
25 | return next(action)
26 | }
27 | if(action.noPushState){
28 | delete action.noPushState
29 | return next(action)
30 | }
31 | const _state = store.getState()
32 | const _wiki = _state.page.wiki
33 | const _title = _state.page.title
34 | const result = next(action)
35 | const {wiki, title} = store.getState().page
36 | if(_title !== title || _wiki !== wiki){
37 | history.pushState({wiki, title}, document.title, buildPath({wiki, title}))
38 | }
39 | return result
40 | }
41 |
42 | export const scrollTopOnRoute = store => next => action => {
43 | if(action.type !== "route") return next(action)
44 | const result = next(action)
45 | window.scrollTo(0, 0)
46 | return result
47 | }
48 |
--------------------------------------------------------------------------------
/src/server/route/feed.js:
--------------------------------------------------------------------------------
1 | import Feed from "feed"
2 | import {renderToStaticMarkup} from "react-dom/server"
3 |
4 | import Router from "koa-66"
5 | const router = new Router()
6 | export default router
7 |
8 | import mongoose from "mongoose"
9 | const Page = mongoose.model("Page")
10 |
11 | import {createCompiler} from "../../client/component/syntax/markup"
12 | import {buildTitle} from "../../share/title"
13 | import {escape} from "lodash"
14 |
15 | router.get("/feed/:wiki", async (ctx, next) => {
16 | const {wiki} = ctx.params
17 | const pages = await Page.findNotEmpty({wiki}, null, {sort: {updatedAt: -1}}).limit(30)
18 | if(pages.length < 1) return ctx.status = 404
19 | const compiler = createCompiler({state: {
20 | page: {wiki}
21 | }})
22 | const feed = new Feed({
23 | title: wiki,
24 | link: ctx.request.protocol+"://"+ctx.request.host+"/"+wiki,
25 | description: wiki,
26 | updated: pages[0].updatedAt
27 | })
28 | for(let page of pages){
29 | let title = buildTitle(page)
30 | let link = ctx.request.protocol+"://"+ctx.request.host+"/"+wiki+"/"+page.title
31 | let description = page.lines
32 | .map(i =>
33 | " ".repeat(i.indent*2) +
34 | compiler(i.value)
35 | .map(elm => typeof elm === "string" ? escape(elm) : renderToStaticMarkup(elm))
36 | .join("")
37 | )
38 | .join("
")
39 | let date = page.updatedAt
40 | feed.addItem({title, link, description, date})
41 | }
42 | ctx.type = "application/rss+xml; charset=UTF-8"
43 | return ctx.body = feed.render("rss-2.0")
44 | })
45 |
--------------------------------------------------------------------------------
/src/client/component/store-component.jsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import {bindActionCreators} from "redux"
3 | import * as actions from "../action/"
4 | import Debug from "debug"
5 |
6 | export default class StoreComponent extends React.Component{
7 |
8 | static get propTypes(){
9 | return {
10 | store: React.PropTypes.object.isRequired
11 | }
12 | }
13 |
14 | mapState(state){
15 | return state
16 | }
17 |
18 | shouldComponentUpdate(nextProps, nextState){
19 | if(Object.keys(nextProps).length !== Object.keys(this.props).length ||
20 | Object.keys(nextState).length !== Object.keys(this.state).length) return true
21 | for(let k in nextState){
22 | if(typeof nextState[k] === "object" ||
23 | this.state[k] !== nextState[k]) return true
24 | }
25 | for(let k in nextProps){
26 | if(k !== "store" &&
27 | typeof nextProps[k] === "object" ||
28 | this.props[k] !== nextProps[k]) return true
29 | }
30 | return false
31 | }
32 |
33 | componentWillUnmount(){
34 | this.debug("componentWillUnmount()")
35 | this.unsubscribeStore()
36 | }
37 |
38 | componentDidMount(){
39 | this.debug("componentDidMount()")
40 | this.unsubscribeStore = this.store.subscribe(() => {
41 | this.setState(this.mapState(this.store.getState()))
42 | })
43 | }
44 |
45 | componentWillMount(){
46 | this.debug("componentWillMount()")
47 | this.store = this.props.store
48 | this.setState(this.mapState(this.store.getState()))
49 | this.action = bindActionCreators(actions, this.store.dispatch)
50 | }
51 |
52 | constructor(){
53 | super()
54 | this.state = {}
55 | this.debug = Debug("semirara:component:" + this.constructor.name.toLowerCase())
56 | this.debug("constructor()")
57 | }
58 |
59 | }
60 |
--------------------------------------------------------------------------------
/src/server/route/index.js:
--------------------------------------------------------------------------------
1 | import pkg from "../../../package.json"
2 | import Router from "koa-66"
3 | const router = new Router()
4 | export default router
5 |
6 | import {setUserContext, ignoreFavicon} from "./middleware"
7 | router.use(ignoreFavicon)
8 |
9 | import authRouter from "./auth"
10 | router.mount("/auth", authRouter)
11 |
12 | import feedRouter from "./feed"
13 | router.mount("/api", feedRouter)
14 |
15 | import textRouter from "./text"
16 | router.mount("/api", textRouter)
17 |
18 | router.use(setUserContext)
19 |
20 | import mongoose from "mongoose"
21 | const Page = mongoose.model("Page")
22 |
23 | import {buildPath, parseRoute, defaultRoute} from "../../share/route"
24 |
25 | router.get("/*", async (ctx, next) => {
26 | let renderParam = {
27 | user: null,
28 | app: {
29 | name: pkg.name
30 | }
31 | }
32 | if(ctx.user){
33 | renderParam.user = {
34 | id: ctx.user.github.id,
35 | name: ctx.user.github.login,
36 | icon: ctx.user.github.avatar_url
37 | }
38 | return ctx.render("index", renderParam)
39 | }
40 | else{
41 | const route = parseRoute(ctx.originalUrl)
42 | const {wiki, title} = route
43 | if(!wiki || !title){
44 | return ctx.redirect(buildPath(Object.assign({}, defaultRoute, route)))
45 | }
46 | const page = await Page.findOneByWikiTitle({wiki, title}) || new Page({wiki, title})
47 | if(page.title !== title || page.wiki !== wiki){
48 | return ctx.redirect(buildPath({wiki: page.wiki, title: page.title}))
49 | }
50 | const pagelist = (await Page.findPagesByWiki(wiki)).map(page => page.toHash())
51 | const relatedPagelist = (await page.findRelatedPages()).map(page => page.toHash())
52 | renderParam.state = {page: page.toHash(), pagelist, relatedPagelist}
53 | return ctx.render("index-static", renderParam)
54 | }
55 | })
56 |
--------------------------------------------------------------------------------
/src/share/markup/test/render.js:
--------------------------------------------------------------------------------
1 | /* eslint-env mocha */
2 |
3 | import {renderToJSX} from "../render"
4 | import {assert} from "chai"
5 |
6 | describe("renderToJSX(node)", function(){
7 |
8 | it("render type:text", function(){
9 | const vdom = renderToJSX({type: "text", value: "hello world"})
10 | assert.equal(vdom.type, "span")
11 | assert.equal(vdom.props.children, "hello world")
12 | })
13 |
14 | it("render type:strong", function(){
15 | const vdom = renderToJSX({type: "strong", value: "strong world"})
16 | assert.equal(vdom.type, "strong")
17 | assert.equal(vdom.props.children, "strong world")
18 | })
19 |
20 | it("render type:image", function(){
21 | const vdom = renderToJSX({type: "image", image: "http://example.com/foo.jpg"})
22 | assert.equal(vdom.type, "img")
23 | assert.equal(vdom.props.src, "http://example.com/foo.jpg")
24 | })
25 |
26 | it("render type:external-link", function(){
27 | const vdom = renderToJSX({type: "external-link", link: "http://shokai.org"})
28 | assert.equal(vdom.type, "a")
29 | assert.equal(vdom.props.href, "http://shokai.org")
30 | assert.equal(vdom.props.children, "http://shokai.org")
31 | })
32 |
33 | it("render type:external-link-with-description", function(){
34 | const vdom = renderToJSX({type: "external-link-with-description", link: "http://shokai.org", description: "橋本商会"})
35 | assert.equal(vdom.type, "a")
36 | assert.equal(vdom.props.href, "http://shokai.org")
37 | assert.equal(vdom.props.children, "橋本商会")
38 | })
39 |
40 | it("render type:external-link-with-image", function(){
41 | const vdom = renderToJSX({type: "external-link-with-image", link: "http://shokai.org", image: "http://example.com/foo.png"})
42 | assert.equal(vdom.type, "a")
43 | assert.equal(vdom.props.href, "http://shokai.org")
44 | assert.equal(vdom.props.children.type, "img")
45 | assert.equal(vdom.props.children.props.src, "http://example.com/foo.png")
46 | })
47 |
48 | })
49 |
--------------------------------------------------------------------------------
/src/server/model/user.js:
--------------------------------------------------------------------------------
1 | const debug = require("../../share/debug")(__filename)
2 |
3 | import GitHub from "../lib/github"
4 | import Cache from "../lib/cache"
5 | const sessionCache = new Cache({
6 | prefix: "session",
7 | expire: 60*60*24*14 // 14days
8 | })
9 | import mongoose from "mongoose"
10 |
11 | const userSchema = new mongoose.Schema({
12 | githubToken: {
13 | type: String
14 | },
15 | github: {
16 | type: Object
17 | },
18 | updatedAt: {
19 | type: Date,
20 | default: () => Date.now()
21 | },
22 | createdAt: {
23 | type: Date,
24 | default: () => Date.now()
25 | }
26 | })
27 |
28 | userSchema.pre("save", function(next){
29 | this.updatedAt = Date.now()
30 | next()
31 | })
32 |
33 | userSchema.post("save", function(user){
34 | debug(`save! ${user._id}`)
35 | })
36 |
37 | userSchema.methods.isLogin = function(){
38 | return typeof this.githubToken === "string" && this.githubToken.length > 0
39 | }
40 |
41 | userSchema.statics.createOrFindByGithubToken = async function(token){
42 | debug("create or find user by github token: " + token)
43 | const github = new GitHub(token)
44 | const userInfo = await github.getUser()
45 | debug(userInfo)
46 | const user =
47 | await User.findOne({"github.id": userInfo.id}) ||
48 | new User({github: userInfo})
49 | return user
50 | }
51 |
52 | userSchema.methods.setSession = async function(session){
53 | return sessionCache.set(session, this.github.id)
54 | }
55 |
56 | userSchema.statics.findBySession = async function(session){
57 | debug("find user by session: " + session)
58 | const githubId = await sessionCache.get(session)
59 | if(!githubId) return null
60 | return User.findOne({"github.id": githubId})
61 | }
62 |
63 | const User = mongoose.model('User', userSchema)
64 |
65 | export function deleteSession(session){
66 | if(!session || typeof session !== "string"){
67 | return Promise.reject("invalid session")
68 | }
69 | return sessionCache.delete(session)
70 | }
71 |
--------------------------------------------------------------------------------
/src/client/component/syntax/markup.js:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import {validateTitle, validateWiki, validateRoute} from "../../../share/route"
3 | import {renderToJSX} from "../../../share/markup/render"
4 | import {Parser} from "../../../share/markup/parser"
5 | import RouteLink from "../route-link"
6 | import Code from "../code"
7 |
8 | export function createCompiler({action, state}){
9 | const {wiki} = state.page
10 | const compiler = function(str){
11 | const nodes = Parser.parse(str)
12 | return nodes.map((node) => {
13 | switch(node.type){
14 | case "title-link": {
15 | let {title} = node
16 | if(validateTitle(title).invalid) return {node.source}
17 | return {title}
18 | }
19 | case "title-link-hash": {
20 | let {title} = node
21 | if(validateTitle(title).invalid) return {node.source}
22 | return (
23 |
24 | {" "}
25 | {`#${title}`}
26 | {" "}
27 |
28 | )
29 | }
30 | case "wiki-link": {
31 | let {wiki} = node
32 | if(validateWiki(wiki).invalid) return {node.source}
33 | return {wiki}::
34 | }
35 | case "wiki-title-link": {
36 | let {wiki, title} = node
37 | if(validateRoute({wiki, title}).invalid) return {node.source}
38 | return {`${wiki}::${title}`}
39 | }
40 | case "inline-code": {
41 | let code = node.value
42 | if(!(/^\-/.test(node.value))){
43 | code = {node.value}
44 | }
45 | return {code}
46 | }
47 | default:
48 | return renderToJSX(node)
49 | }
50 | })
51 | }
52 | compiler.displayName = "compiler"
53 | return compiler
54 | }
55 |
56 | createCompiler.propTypes = {
57 | action: React.PropTypes.object.isRequired,
58 | state: React.PropTypes.object.isRequired
59 | }
60 |
--------------------------------------------------------------------------------
/src/server/socket/page.js:
--------------------------------------------------------------------------------
1 | const debug = require("../../share/debug")(__filename)
2 |
3 | import {ambiguous} from "../model/"
4 | import ioreq from "socket.io-request"
5 |
6 | import mongoose from "mongoose"
7 | const Page = mongoose.model("Page")
8 |
9 | import Room from "./room"
10 |
11 | export default function use(app){
12 |
13 | const io = app.context.io
14 |
15 | io.on("connection", (socket) => {
16 | const room = new Room(socket)
17 |
18 | ioreq(socket).response("getpage", async (req, res) => {
19 | const {wiki, title} = req
20 | debug(`getpage ${wiki}::${title}`)
21 | try{
22 | room.join(`${wiki}::${title}`)
23 | const page = await Page.findOneByWikiTitle({wiki, title}) || new Page({wiki, title})
24 | const reverseLinkedPages = await page.findReverseLinkedPages()
25 | debug(reverseLinkedPages)
26 | res(page)
27 | }
28 | catch(err){
29 | console.error(err.stack || err)
30 | }
31 | })
32 |
33 | if(!socket.user) return
34 |
35 | // for Authorized User
36 | socket.on("page:lines", async (data, ack) => {
37 | debug("page:lines")
38 | const {title, wiki, lines} = data
39 | if(!title || !wiki || !lines) return ack({error: "prop incorrect"})
40 | try{
41 | room.join(`${wiki}::${title}`)
42 | socket.broadcast.to(room.name).emit("page:lines", {wiki, title, lines})
43 | const page = await Page.findOne(ambiguous({wiki, title})) || new Page({wiki, title})
44 | page.lines = lines
45 | page.saveLater()
46 | }
47 | catch(err){
48 | console.error(err.stack || err)
49 | }
50 | ack({success: "ok"})
51 | })
52 |
53 | ioreq(socket).response("page:title:change", async (req, res) => {
54 | const {wiki, title, newTitle} = req
55 | let _res
56 | try{
57 | const page = await Page.findOne({wiki, title})
58 | if(!page){
59 | return res("page not found")
60 | }
61 | _res = await page.rename(newTitle)
62 | }
63 | catch(err){
64 | console.error(err.stack || err)
65 | return res({error: err.message})
66 | }
67 | io.to(room.name).emit("page:title:change", {wiki, title: newTitle})
68 | return res({success: _res})
69 | })
70 |
71 | })
72 | }
73 |
--------------------------------------------------------------------------------
/src/client/action/index.js:
--------------------------------------------------------------------------------
1 | export function route({wiki, title}){
2 | return {type: "route", value: {wiki, title}}
3 | }
4 |
5 | export function noPushStateRoute({wiki, title}){
6 | return Object.assign(route({wiki, title}), {noPushState: true})
7 | }
8 |
9 | export function setPage(value){
10 | return {type: "page", value}
11 | }
12 |
13 | export function setPageLines({wiki, title, lines}){
14 | return {type: "page:lines", value: {wiki, title, lines}}
15 | }
16 |
17 | export function setPageList(value){
18 | return {type: "pagelist", value}
19 | }
20 |
21 | export function pagelistUpdate(value){
22 | return {type: "pagelist:update", value}
23 | }
24 |
25 | export function pagelistRemove(value){
26 | return {type: "pagelist:remove", value}
27 | }
28 |
29 | export function setEditline(value){
30 | return {type: "editline", value}
31 | }
32 |
33 | export function unsetEditline(){
34 | return {type: "editline", value: null}
35 | }
36 |
37 | export function updateLine(value){
38 | return {type: "updateLine", value}
39 | }
40 |
41 | export function insertNewLine(){
42 | return {type: "insertNewLine"}
43 | }
44 |
45 | export function swapLineUp(){
46 | return {type: "swapLine:up"}
47 | }
48 |
49 | export function swapLineDown(){
50 | return {type: "swapLine:down"}
51 | }
52 |
53 | export function swapBlockUp(){
54 | return {type: "swapBlock:up"}
55 | }
56 |
57 | export function swapBlockDown(){
58 | return {type: "swapBlock:down"}
59 | }
60 |
61 | export function editlineUp(){
62 | return {type: "editline:up"}
63 | }
64 |
65 | export function editlineDown(){
66 | return {type: "editline:down"}
67 | }
68 |
69 | export function indentIncrement(){
70 | return {type: "indent:increment"}
71 | }
72 |
73 | export function indentDecrement(){
74 | return {type: "indent:decrement"}
75 | }
76 |
77 | export function indentBlockIncrement(){
78 | return {type: "indentBlock:increment"}
79 | }
80 |
81 | export function indentBlockDecrement(){
82 | return {type: "indentBlock:decrement"}
83 | }
84 |
85 | export function insertMultiLines(lines){
86 | return {type: "insertMultiLines", value: lines}
87 | }
88 |
89 | export function startTitleEdit(){
90 | return {type: "page:title:startEdit"}
91 | }
92 |
93 | export function changeTitle(value){
94 | return {type: "page:title:change", value}
95 | }
96 |
97 | export function submitTitle(){
98 | return {type: "page:title:submit"}
99 | }
100 |
101 | export function cancelTitleEdit(){
102 | return {type: "page:title:cancelEdit"}
103 | }
104 |
--------------------------------------------------------------------------------
/src/server/route/auth.js:
--------------------------------------------------------------------------------
1 | /* eslint no-console: 0 */
2 |
3 | const debug = require("../../share/debug")(__filename)
4 | require("dotenv").config({path: ".env"})
5 |
6 | for(let key of ["GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET"]){
7 | if(typeof process.env[key] !== "string"){
8 | console.error(`ERROR: Env var "${key}" is missing`)
9 | }
10 | }
11 |
12 | import mongoose from "mongoose"
13 | const User = mongoose.model("User")
14 | import {deleteSession} from "../model/user"
15 | import querystring from "querystring"
16 | import axios from "axios"
17 | import md5 from "md5"
18 |
19 | import Cache from "../lib/cache"
20 | const loginStateCache = new Cache({prefix: "loginState", expire: 300}) // 5 minutes
21 |
22 | import Router from "koa-66"
23 | const router = new Router()
24 | export default router
25 |
26 | router.get("/logout", async (ctx, next) => {
27 | const session = ctx.cookies.get("session")
28 | deleteSession(session)
29 | ctx.cookies.set("session", null)
30 | ctx.redirect(ctx.headers.referer || "/")
31 | })
32 |
33 | router.get("/login", async (ctx, next) => {
34 | debug("/login")
35 | ctx.cookies.set("redirect", ctx.headers.referer)
36 | const state = md5(Date.now() + ctx.request.ip)
37 | loginStateCache.set(state, true)
38 | const fullUrl = ctx.request.protocol+"://"+ctx.request.host+ctx.request.path
39 | const query = querystring.stringify({
40 | client_id: process.env.GITHUB_CLIENT_ID,
41 | redirect_uri: fullUrl+"/callback",
42 | scope: "user",
43 | state: state
44 | })
45 | ctx.redirect(`https://github.com/login/oauth/authorize?${query}`)
46 | })
47 |
48 |
49 | router.get("/login/callback", async (ctx, next) => {
50 | debug("/login/callback")
51 | const code = ctx.query.code
52 | const state = ctx.query.state
53 | if(!state || !code){
54 | debug("invalid access")
55 | ctx.redirect(ctx.cookies.get("redirect") || "/")
56 | return
57 | }
58 | if(await loginStateCache.get(state) !== true){
59 | debug("invalid state")
60 | ctx.redirect(ctx.cookies.get("redirect") || "/")
61 | return
62 | }
63 |
64 | const res = await axios.post("https://github.com/login/oauth/access_token", {
65 | client_id: process.env.GITHUB_CLIENT_ID,
66 | client_secret: process.env.GITHUB_CLIENT_SECRET,
67 | code: code
68 | })
69 |
70 | const data = querystring.parse(res.data)
71 | debug(data)
72 | if(data.error){
73 | ctx.body = data
74 | ctx.status = 400
75 | return
76 | }
77 | debug("AccessToken: "+ data.access_token)
78 | const user = await User.createOrFindByGithubToken(data.access_token)
79 | const session = md5(ctx.request.ip + Date.now() + data.access_token)
80 | ctx.cookies.set('session', session, {maxAge: 1000*60*60*24*14}) // 14 days
81 | await user.setSession(session)
82 | user.save()
83 | ctx.redirect(ctx.cookies.get("redirect") || "/")
84 | })
85 |
--------------------------------------------------------------------------------
/src/share/route.js:
--------------------------------------------------------------------------------
1 | import hasDom from "has-dom"
2 |
3 | export const defaultRoute = {wiki: "general", title: "hello"}
4 |
5 | export function buildPath({wiki, title}){
6 | if (wiki && title) return `/${wiki}/${title}`
7 | if (wiki) return `/${wiki}`
8 | throw new Error(`invalid route /${wiki}/${title}`)
9 | }
10 |
11 | export function parseRoute(path){
12 | if(!path && hasDom()) path = location.href.replace(new RegExp("^"+location.origin), "")
13 | path = decodeURIComponent(path)
14 | const route = {}
15 | const m = path.match(/^\/([^\/]+)\/?$/) || path.match(/^\/([^\/]+)\/(.+)/)
16 | if(m){
17 | if(validateWiki(m[1]).valid) route.wiki = m[1]
18 | if(validateTitle(m[2]).valid) route.title = m[2]
19 | }
20 | return route
21 | }
22 |
23 | class ValidationResult{
24 | constructor(errors = []){
25 | this.errors = []
26 | }
27 | get valid(){
28 | return this.errors.length < 1
29 | }
30 | get invalid(){
31 | return !this.valid
32 | }
33 | }
34 |
35 | const blacklist = ["auth", "login", "logout", "config", "api", "slide"]
36 |
37 | // common rules for wiki & title
38 | export function validateName(name){
39 | const result = new ValidationResult()
40 |
41 | if(typeof name !== "string"){
42 | result.errors.push("name must be a String")
43 | return result
44 | }
45 |
46 | if(name.length < 1){
47 | result.errors.push("name is empty")
48 | }
49 |
50 | if(name.length > 64){
51 | result.errors.push("name is too long")
52 | }
53 |
54 | for(let s of blacklist){
55 | if(name.toLowerCase() === s){
56 | result.errors.push(`"${name}" is reserved for system`)
57 | }
58 | }
59 |
60 | if(name.trim() !== name){
61 | result.errors.push("name cannot have space at head or tail.")
62 | }
63 |
64 | for(let c of "#\n\r"){
65 | if(name.indexOf(c) > -1){
66 | result.errors.push(`name cannot contain "${c}"`)
67 | }
68 | }
69 |
70 | if(/::/.test(name)){
71 | result.errors.push(`name cannot contain "::"`)
72 | }
73 |
74 | try{
75 | if(decodeURIComponent(name) !== name){
76 | result.errors.push("name cannot contain URI encoded char")
77 | }
78 | }
79 | catch(err){
80 | result.errors.push(err.message)
81 | }
82 |
83 | return result
84 | }
85 |
86 | // validate page name
87 | export function validateTitle(name){
88 | const result = validateName(name)
89 | if(!result.valid) return result
90 |
91 | return result
92 | }
93 |
94 | // validate wiki name
95 | export function validateWiki(name){
96 | const result = validateName(name)
97 | if(!result.valid) return result
98 |
99 | if(/^\//.test(name)){
100 | result.errors.push(`wiki cannot start with "/"`)
101 | }
102 |
103 | for(let c of "/:"){
104 | if(name.indexOf(c) > -1){
105 | result.errors.push(`wiki cannot contain "${c}"`)
106 | }
107 | }
108 |
109 | return result
110 | }
111 |
112 | export function validateRoute({wiki, title}){
113 | const result = validateWiki(wiki)
114 | result.errors.push(...validateTitle(title).errors)
115 | return result
116 | }
117 |
--------------------------------------------------------------------------------
/src/share/test/route.js:
--------------------------------------------------------------------------------
1 | /* eslint-env mocha */
2 |
3 | import {assert} from "chai"
4 |
5 | import {buildPath, parseRoute, validateName, validateWiki, validateTitle, validateRoute} from "../route"
6 |
7 | describe("route - parser and validator of /wiki/title path", function(){
8 |
9 | describe("buildPath", function(){
10 |
11 | it("build path from route", function(){
12 | assert.equal(buildPath({wiki: "foo", title: "bar"}), "/foo/bar")
13 | })
14 |
15 | })
16 |
17 | describe("parseRoute", function(){
18 |
19 | it("parse path", function(){
20 | assert.deepEqual(parseRoute("/foo/bar"), {wiki: "foo", title: "bar"})
21 | assert.deepEqual(parseRoute("foo/bar"), {})
22 | assert.deepEqual(parseRoute("/foo/bar/baz"), {wiki: "foo", title: "bar/baz"})
23 | assert.deepEqual(parseRoute("/foo"), {wiki: "foo"})
24 | assert.deepEqual(parseRoute("/foo/"), {wiki: "foo"})
25 | })
26 |
27 | })
28 |
29 | describe("validateName - common rules for wiki & title", function(){
30 |
31 | it("invalid name", function(){
32 | assert(validateName(" head space").invalid)
33 | assert(validateName("tail space ").invalid)
34 | assert(validateName(null).invalid)
35 | assert(validateName(3).invalid)
36 | assert(validateName("").invalid)
37 | assert(validateName("#").invalid)
38 | assert(validateName("a"*65).invalid)
39 | assert(validateName("\n").invalid)
40 | assert(validateName("\r").invalid)
41 | assert(validateName("auth").invalid)
42 | assert(validateName("login").invalid)
43 | assert(validateName("logout").invalid)
44 | assert(validateName("api").invalid)
45 | assert(validateName("config").invalid)
46 | assert(validateName("%20").invalid)
47 | assert(validateName("%").invalid)
48 | assert(validateName("::").invalid)
49 | })
50 |
51 | it("valid name", function(){
52 | assert(validateName("shokai").valid)
53 | assert(validateName("ホルモン かずすけ").valid)
54 | assert(validateName("3").valid)
55 | assert(validateName("general").valid)
56 | assert(validateName("logoff").valid)
57 | assert(validateName("logon").valid)
58 | assert(validateName("?_^+-*").valid)
59 | })
60 | })
61 |
62 | describe("validate wiki name", function(){
63 |
64 | it("invalid wiki", function(){
65 | assert(validateWiki("sl/ash").invalid)
66 | assert(validateWiki("javascript:").invalid)
67 | })
68 |
69 | it("valid name", function(){
70 | assert(validateWiki("general").valid)
71 | })
72 |
73 | })
74 |
75 | describe("validate page title", function(){
76 |
77 | it("invalid title", function(){
78 | assert(validateTitle("").invalid)
79 | })
80 |
81 |
82 | it("valid title", function(){
83 | assert(validateTitle("general").valid)
84 | assert(validateTitle("sl/ash").valid)
85 | assert(validateTitle("javascript:").valid)
86 | })
87 |
88 | })
89 |
90 |
91 | describe("validate route", function(){
92 |
93 | it("invalid route", function(){
94 | assert(validateRoute({wiki: "ok"}).invalid)
95 | assert(validateRoute({title: "ok"}).invalid)
96 | assert(validateRoute({wiki: "general", title: "sh#arp"}).invalid)
97 | assert(validateRoute({wiki: "sl/ash", title: "hello"}).invalid)
98 | })
99 |
100 | it("invalid route", function(){
101 | assert(validateRoute({wiki: "general", title: "hello"}).valid)
102 | })
103 |
104 | })
105 |
106 | })
107 |
--------------------------------------------------------------------------------
/src/client/component/editor.jsx:
--------------------------------------------------------------------------------
1 | import React from "react"
2 | import StoreComponent from "./store-component"
3 | import EditorLine from "./editor-line"
4 | import {createCompiler} from "./syntax/markup"
5 | import {decorateLines} from "./syntax/decorator"
6 | import {range} from 'lodash'
7 |
8 | export default class Editor extends StoreComponent {
9 |
10 | constructor(){
11 | super()
12 | this.onKeyDown = this.onKeyDown.bind(this)
13 | this.onPaste = this.onPaste.bind(this)
14 | }
15 |
16 | componentWillMount(){
17 | super.componentWillMount()
18 | }
19 |
20 | mapState(state){
21 | return {page: state.page}
22 | }
23 |
24 | render(){
25 | this.debug("render()")
26 | const compiler = createCompiler({action: this.action, state: this.state})
27 | const {page} = this.state
28 | let lis
29 | if(page.lines.length < 1 && !page.editline){
30 | lis = [(
31 | this.action.setEditline(0)} />
32 | )]
33 | }
34 | else{
35 | const lines = decorateLines(page.lines)
36 | lis = range(0, lines.length).map(i => {
37 | let line = lines[i]
38 | return (
39 | this.action.setEditline(i)}
44 | onChange={this.action.updateLine}
45 | onKeyDown={this.onKeyDown}
46 | onPaste={this.onPaste} />
47 | )
48 | })
49 | }
50 | return (
51 |
54 | )
55 | }
56 |
57 | onKeyDown(e){
58 | const {action} = this
59 | switch(e.keyCode){
60 | case 27: // escape
61 | action.unsetEditline()
62 | break
63 | case 13: // enter
64 | action.insertNewLine()
65 | break
66 | case 40: // down
67 | if(e.ctrlKey) action.swapLineDown()
68 | else if(e.shiftKey) action.swapBlockDown()
69 | else action.editlineDown()
70 | break
71 | case 38: // up
72 | if(e.ctrlKey) action.swapLineUp()
73 | else if(e.shiftKey) action.swapBlockUp()
74 | else action.editlineUp()
75 | break
76 | case 78: // ctrl + N
77 | if(e.ctrlKey) action.editlineDown()
78 | break
79 | case 80:// ctrl + P
80 | if(e.ctrlKey) action.editlineUp()
81 | break
82 | case 37: // left
83 | if(e.ctrlKey) action.indentDecrement()
84 | else if(e.shiftKey) action.indentBlockDecrement()
85 | break
86 | case 39: // right
87 | if(e.ctrlKey) action.indentIncrement()
88 | else if(e.shiftKey) action.indentBlockIncrement()
89 | break
90 | case 32: // space
91 | if(e.target.selectionStart !== 0 ||
92 | e.target.selectionEnd !== 0) break
93 | e.preventDefault()
94 | action.indentIncrement()
95 | break
96 | case 8: // backspace
97 | if(e.target.selectionStart !== 0 ||
98 | e.target.selectionEnd !== 0) break
99 | e.preventDefault()
100 | action.indentDecrement()
101 | break
102 | }
103 | }
104 |
105 | onPaste(e){
106 | const lines = e.clipboardData.getData("Text").split(/[\r\n]/)
107 | if(lines.length < 2) return
108 | e.preventDefault()
109 | this.action.insertMultiLines(lines)
110 | }
111 |
112 | }
113 |
--------------------------------------------------------------------------------
/src/client/middleware/page.js:
--------------------------------------------------------------------------------
1 | import {io} from "../socket"
2 | import ioreq from "socket.io-request"
3 | import clone from "clone"
4 |
5 | export const getPageOnRoute = store => next => async (action) => {
6 | if(action.type !== "route") return next(action)
7 | const result = next(action)
8 | const {title, wiki} = store.getState().page
9 | try{
10 | const page = await ioreq(io).request("getpage", {wiki, title})
11 | store.dispatch({type: "page", value: page})
12 | }
13 | catch(err){
14 | console.error(err.stack || err)
15 | }
16 | return result
17 | }
18 |
19 | var _sendingPage = false
20 | var _nextSendPageData = null
21 | function _sendPage({title, wiki, lines}){
22 | if(!title || !wiki || !lines) return
23 | if(!_sendingPage){
24 | _nextSendPageData = null
25 | _sendingPage = true
26 | io.emit("page:lines", {title, wiki, lines}, ({error, success}) => {
27 | _sendingPage = false
28 | if(_nextSendPageData){
29 | _sendPage(_nextSendPageData)
30 | }
31 | })
32 | }
33 | else{
34 | _nextSendPageData = {title, wiki, lines}
35 | }
36 | }
37 |
38 | export const sendPage = store => next => action => {
39 | const targetActions = ["insertNewLine", "updateLine", "insertMultiLines",
40 | "swapLine:up", "swapLine:down",
41 | "swapBlock:up", "swapBlock:down",
42 | "indent:increment", "indent:decrement",
43 | "indentBlock:increment", "indentBlock:decrement"]
44 | if(targetActions.indexOf(action.type) < 0) return next(action)
45 | const _lines = clone(store.getState().page.lines)
46 | const result = next(action)
47 | const {title, wiki, lines} = store.getState().page
48 | if(lineChanged(_lines, lines)){
49 | _sendPage({title, wiki, lines})
50 | }
51 | return result
52 | }
53 |
54 | export const removeEmptyLines = store => next => action => {
55 | const result = next(action)
56 | const targetActions = ["editline:up", "editline:down", "insertMultiLines"]
57 | if((action.type === "editline" && action.value === null) ||
58 | targetActions.indexOf(action.type) > -1){
59 | store.dispatch({type: "removeEmptyLines"})
60 | }
61 | return result
62 | }
63 |
64 | export const unsetEditLineOnRoute = store => next => action => {
65 | if(action.type !== "route") return next(action)
66 | const result = next(action)
67 | store.dispatch({type: "editline", value: null})
68 | return result
69 | }
70 |
71 | export const adjustEditLineOnPageLines = store => next => action => {
72 | if(action.type !== "page:lines") return next(action)
73 | const _state = store.getState()
74 | const _editline = _state.page.editline
75 | if(!_editline) return next(action)
76 | const id = _state.page.lines[_editline].id
77 | const result = next(action)
78 | if(!id) return result
79 | const state = store.getState()
80 | let editline
81 | for(let i = 0; i < state.page.lines.length; i++){
82 | let line = state.page.lines[i]
83 | if(line.id === id){
84 | editline = i
85 | break
86 | }
87 | }
88 | if(!editline) return result
89 | if(editline !== _editline) store.dispatch({type: "editline", value: editline})
90 | return result
91 | }
92 |
93 |
94 | function lineChanged(lineA, lineB){
95 | if(lineA.length !== lineB.length) return true
96 | for(let i = 0; i < lineA.length; i++){
97 | let a = lineA[i]
98 | let b = lineB[i]
99 | if(a.value !== b.value ||
100 | a.indent !== b.indent) return true
101 | }
102 | return false
103 | }
104 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "name": "semirara",
4 | "version": "0.0.1",
5 | "description": "wiki",
6 | "main": "run-server.js",
7 | "engines": {
8 | "node": "6.x",
9 | "npm": "3.x"
10 | },
11 | "scripts": {
12 | "postinstall": "npm run build",
13 | "start": "node run-server.js",
14 | "start:dev": "DEBUG=semirara*,koa* node-dev run-server.js",
15 | "test": "npm run eslint && npm run mocha",
16 | "mocha": "mocha 'src/{*,**/*}/test/*.js' --compilers js:babel-register -r babel-polyfill",
17 | "eslint": "eslint 'src/*/{*,**/*}.{js,jsx}'",
18 | "browserify": "browserify --verbose --extension=.jsx -t [ babelify ] -t [ browserify-shim ] -g uglifyify src/client/index.js -o public/dist/index.js",
19 | "watchify": "watchify --verbose --debug --extension=.jsx -t [ babelify ] -p browserify-notify src/client/index.js -o public/dist/index.js",
20 | "build": "npm run babel && npm run browserify && npm run stylus",
21 | "stylus": "stylus -I node_modules/nib/lib -I node_modules/highlight.js/styles --include-css src/client/styl/index.styl -o public/dist/index.css",
22 | "babel": "babel src/ --out-dir dist/ --source-maps inline",
23 | "watch": "parallelshell 'chokidar \"src/**.{js,jsx}\" -c \"npm run test\"' 'npm run babel -- --watch' 'npm run watchify' 'npm run stylus -- --watch'",
24 | "clean": "rm -rf 'dist/*' 'public/dist/*'"
25 | },
26 | "keywords": [
27 | "wiki",
28 | "outline"
29 | ],
30 | "author": "Sho Hashimoto ",
31 | "license": "MIT",
32 | "dependencies": {
33 | "axios": "^0.11.0",
34 | "babel-cli": "^6.5.1",
35 | "babel-polyfill": "^6.5.0",
36 | "babel-preset-es2015": "^6.5.0",
37 | "babel-preset-react": "^6.5.0",
38 | "babel-preset-stage-0": "^6.5.0",
39 | "babel-regenerator-runtime": "^6.5.0",
40 | "babelify": "^7.2.0",
41 | "browserify": "^13.0.0",
42 | "browserify-shim": "^3.8.12",
43 | "classnames": "^2.2.3",
44 | "clone": "^1.0.2",
45 | "cookie": "^0.3.1",
46 | "date-utils": "^1.2.21",
47 | "debug": "^2.2.0",
48 | "dotenv": "^2.0.0",
49 | "feed": "^0.3.0",
50 | "has-dom": "^1.0.0",
51 | "highlight.js": "^9.3.0",
52 | "koa": "^2.0.0",
53 | "koa-66": "^0.8.4",
54 | "koa-convert": "^1.2.0",
55 | "koa-logger": "^2.0.0",
56 | "koa-react-view": "^2.0.0",
57 | "koa-static": "^2.0.0",
58 | "lodash.uniq": "^4.2.1",
59 | "md5": "^2.0.0",
60 | "memjs": "^0.10.0",
61 | "mongoose": "^4.5.1",
62 | "mongoose-auto-increment": "^5.0.1",
63 | "nib": "^1.1.0",
64 | "react": "^15.0.2",
65 | "react-dom": "^15.0.2",
66 | "redux": "^3.4.0",
67 | "shortid": "^2.2.6",
68 | "socket.io": "^1.4.6",
69 | "socket.io-client": "^1.4.6",
70 | "socket.io-request": "^0.1.0",
71 | "stylus": "^0.54.2",
72 | "uglifyify": "^3.0.1"
73 | },
74 | "devDependencies": {
75 | "babel-eslint": "^6.0.2",
76 | "babel-register": "^6.5.2",
77 | "browserify-notify": "^1.1.2",
78 | "chai": "^3.5.0",
79 | "chokidar-cli": "^1.2.0",
80 | "eslint": "^2.7.0",
81 | "eslint-plugin-if-in-test": "^0.2.0",
82 | "eslint-plugin-react": "^5.0.1",
83 | "mocha": "^2.4.5",
84 | "node-dev": "^3.1.0",
85 | "parallelshell": "^2.0.0",
86 | "supertest": "^1.2.0",
87 | "watchify": "^3.7.0"
88 | },
89 | "browserify-shim": {
90 | "socket.io-client": "global:io",
91 | "react": "global:React",
92 | "react-dom": "global:ReactDOM",
93 | "highlight.js": "global:hljs"
94 | },
95 | "repository": {
96 | "type": "git",
97 | "url": "git+https://github.com/shokai/semirara.git"
98 | },
99 | "bugs": {
100 | "url": "https://github.com/shokai/semirara/issues"
101 | },
102 | "homepage": "https://github.com/shokai/semirara#readme"
103 | }
104 |
--------------------------------------------------------------------------------
/src/client/component/syntax/decorator.js:
--------------------------------------------------------------------------------
1 | // decorate lines
2 | // put some annotation properties to page.lines
3 |
4 | import clone from "clone"
5 |
6 | export function decorateLines(lines){
7 | const _lines = clone(lines)
8 | codeblock(_lines)
9 | showUserIcon(_lines)
10 | cli(_lines)
11 | blocktitle(_lines)
12 | numberList(_lines)
13 | return _lines
14 | }
15 |
16 | function isNumberLine(line, number){
17 | return new RegExp(`^${number}\\.\\s.+`).test(line.value)
18 | }
19 |
20 | function numberList(lines){
21 | let number = 1
22 | let indent = null
23 | for(let line of lines){
24 | if(indent > line.indent ||
25 | (number === 2 && indent === line.indent && !isNumberLine(line, number))){
26 | number = 1
27 | indent = null
28 | }
29 | switch(number){
30 | case 1:
31 | if(!isNumberLine(line, number)) break
32 | indent = line.indent
33 | line.numberList = {number, prefix: ""}
34 | number += 1
35 | break
36 | case 2:
37 | if(line.indent !== indent || !isNumberLine(line, number)) break
38 | line.numberList = {number, prefix: ""}
39 | number += 1
40 | break
41 | default: // 3+
42 | if(line.indent !== indent) break
43 | line.numberList = {number, prefix: `${number}. `}
44 | number += 1
45 | break
46 | }
47 | }
48 | }
49 |
50 | function blocktitle(lines){
51 | for(let i = 0; i < lines.length; i++){
52 | let line = lines[i]
53 | if(line.indent < 1 && (i === 0 || lines[i-1].indent > 0)){
54 | line.blocktitle = true
55 | }
56 | }
57 | }
58 |
59 | function cli(lines){
60 | for(let line of lines){
61 | const m = line.value.match(/^([\%\$]) (.+)/)
62 | if(m){
63 | const [, prefix, command] = m
64 | line.cli = {prefix, command}
65 | }
66 | }
67 | }
68 |
69 | function detectCodeblockStart(str){
70 | const m = str.match(/^code:(.+)$/)
71 | if(m){
72 | const [, filename] = m
73 | const lang = m[1].split('.').pop()
74 | if(lang === filename) return {lang}
75 | return {filename, lang}
76 | }
77 | return {}
78 | }
79 |
80 | function codeblock(lines){
81 | for(let i = 0; i < lines.length; i++){
82 | let {filename, lang} = detectCodeblockStart(lines[i].value)
83 | if(lang){
84 | let indent = lines[i].indent+1
85 | let block = getBlock(lines, i, (line) => {
86 | line.codeblock = {lang, filename, indent, start: false}
87 | })
88 | lines[i].codeblock.start = true
89 | lines[block.end].codeblock.end = true
90 | i = block.end
91 | }
92 | }
93 | }
94 |
95 | function showUserIcon(lines){
96 | for(let i = 0; i < lines.length; i++){
97 | lines[i].showUserIcon =
98 | ((position) => {
99 | if(position < 1) return true
100 | const currentLine = lines[position]
101 | if(!currentLine.user) return false
102 | for(let i = position-1; i >= 0; i--){
103 | let line = lines[i]
104 | if(line.indent <= currentLine.indent){
105 | if(line.user === currentLine.user) return false
106 | if(line.user !== currentLine.user) return true
107 | }
108 | if(line.indent < 1) break
109 | }
110 | return true
111 | })(i)
112 | }
113 | }
114 |
115 | export function getBlock(lines, start, func){
116 | const indent = lines[start].indent
117 | const block = {
118 | indent, start, end: start,
119 | get length(){ return this.end - this.start + 1 }
120 | }
121 | if(typeof func === "function") func(lines[start])
122 | for(let i = start+1; i < lines.length; i++){
123 | let line = lines[i]
124 | if(indent >= line.indent) break
125 | if(typeof func === "function") func(line)
126 | block.end = i
127 | }
128 | return block
129 | }
130 |
--------------------------------------------------------------------------------
/src/client/styl/index.styl:
--------------------------------------------------------------------------------
1 | @import 'nib/normalize'
2 | normalize-css()
3 |
4 | @import 'railscasts.css'
5 |
6 | window = {
7 | width: 1000px
8 | }
9 |
10 | color = {
11 | bg: {
12 | base: #56777D
13 | select: #66878D
14 | }
15 | font: #fafafa
16 | }
17 |
18 | html
19 | height: 100%
20 |
21 | body
22 | height: 100%
23 | background-color: color.bg.base
24 | font: 12pt "Lucida Grande", Helvetica, Arial, sans-serif
25 | color: color.font
26 |
27 | h1
28 | font-size: 40pt
29 |
30 | ul, li
31 | margin: 0px
32 | padding: 0px
33 |
34 | li
35 | list-style: none
36 |
37 | input
38 | color: #111
39 | background-color: #eee
40 | border: none
41 | font: 1em "Lucida Grande", Helvetica, Arial, sans-serif
42 |
43 | div#app
44 | height: 100% auto
45 |
46 | div.app
47 | height: 100% auto
48 | padding: 5px
49 | div.main
50 | margin: 0 auto
51 | height: 100% auto
52 | max-width: window.width
53 | @media (max-width: window.width)
54 | width: 100%
55 |
56 | div.header
57 |
58 | .toolbar
59 | font-size: 12pt
60 | color: #333333
61 | li
62 | display: inline-block
63 | margin: auto 5px
64 | span
65 | padding: 10px 4px 2px 4px
66 | background-color: white
67 | .button
68 | text-decoration: underline
69 | cursor: pointer
70 |
71 | .login
72 | float: right
73 | img.usericon
74 | margin: 0px 3px 0px 0px
75 |
76 | .edit-tool
77 | float: left
78 | display: inline-block
79 |
80 | .clear
81 | clear: both
82 |
83 | h1
84 | display: inline-block
85 | .page-date
86 | font-size: 0.8em
87 | display: inline-block
88 | margin-left: 0.5em
89 |
90 | div.editor
91 | float: left
92 | width: 800px
93 | @media (max-width: window.width)
94 | width: 100%
95 | font-size: 12pt
96 | h3
97 | margin-top: 2em
98 | margin-bottom: 0.5em
99 | ul
100 | margin-left: 20px
101 | li
102 | min-height: 1.6em
103 | margin-bottom: 0.3em
104 | word-wrap: break-word
105 | input
106 | width: 98%
107 | code()
108 | font-family: Monaco, serif
109 | color: #fff
110 | padding: 0.1em
111 | .codeblock
112 | code()
113 | padding: 0.1em 0.3em 0.8em 0em
114 | background-color: #000
115 | .codeblock-start
116 | code()
117 | font-size: 0.7em
118 | padding: 0.1em 0.5em 0.1em 0.5em
119 | background-color: #7E718D
120 | .codeblock-end
121 | code()
122 | padding: 0.1em 0.3em 0.1em 0em
123 | background-color: #000
124 | .cli
125 | code()
126 | background-color: #5C5742
127 | font-size: 0.8em
128 | padding: 0.1em 0.3em 0.1em 0.3em
129 | .prefix
130 | color: #48D1C7
131 | .inline-code
132 | code()
133 | background-color: #000
134 | padding: 0.1em 0.3em 0.1em 0.3em
135 | a
136 | color: #FDD3B2
137 | a.internal
138 | background-color: #8D6B66
139 | color: #fff
140 | text-decoration: none
141 | padding: 0.1em 0.2em 0.1em 0.2em
142 | img
143 | max-width: 95%
144 | img.usericon
145 | margin-left: 0.2em
146 | width: 1.2em
147 | height: 1.2em
148 | strong
149 | text-decoration: underline
150 |
151 | div.pagelist
152 | @media (max-width: window.width)
153 | width: 100%
154 | margin: 0 auto
155 | li
156 | background-size: contain
157 | background-position: center center
158 | background-repeat: no-repeat
159 | display: inline-block
160 | width: 6em
161 | height: 6em
162 | overflow: hidden
163 | font-size: 1.2em
164 | li.image
165 | span
166 | background-color: #777
167 | width: 200px
168 | float: left
169 | li
170 | background-size: contain
171 | background-position: right center
172 | background-repeat: no-repeat
173 | min-height: 1.5em
174 | padding: 3px
175 | margin: 1px
176 | background-color: color.bg.base
177 | &:hover, &.selected
178 | background-color: color.bg.select
179 | span
180 | background-color: color.bg.select
181 | a
182 | text-decoration: none
183 | color: #FFF
184 | display: block
185 | width: 100%
186 | height: 100%
187 | span
188 | background-color: color.bg.base
189 |
190 | div.socket-status
191 | font-size: 0.9em
192 | position: fixed
193 | right: 0
194 | bottom: 0
195 | .connect
196 | background-color: color.bg.select
197 | padding: 2px
198 | display: none
199 | .disconnect
200 | background-color: #CC3333
201 | padding: 2px
202 |
203 | div.footer
204 | clear: both
205 | height: 100px
206 |
--------------------------------------------------------------------------------
/src/server/model/page.js:
--------------------------------------------------------------------------------
1 | const debug = require("../../share/debug")(__filename)
2 |
3 | import {uniq, uniqBy} from "lodash"
4 | import {validateTitle, validateWiki, validateRoute} from "../../share/route"
5 | import {Parser} from '../../share/markup/parser'
6 |
7 | import {ambiguous} from "./"
8 |
9 | import mongoose from "mongoose"
10 | import autoIncrement from "mongoose-auto-increment"
11 | autoIncrement.initialize(mongoose.connection)
12 |
13 | const pageSchema = new mongoose.Schema({
14 | title: {
15 | type: String,
16 | required: true,
17 | validate: (title) => validateTitle(title).valid
18 | },
19 | wiki: {
20 | type: String,
21 | required: true,
22 | validate: (wiki) => validateWiki(wiki).valid
23 | },
24 | image: {
25 | type: String,
26 | validate: (url) => (url === null || /^https?:\/\/.+/.test(url))
27 | },
28 | lines: {
29 | type: Array,
30 | default: [ ],
31 | required: true
32 | },
33 | innerLinks: {
34 | type: Array,
35 | default: [ ]
36 | },
37 | updatedAt: {
38 | type: Date,
39 | default: () => Date.now()
40 | },
41 | createdAt: {
42 | type: Date,
43 | default: () => Date.now()
44 | }
45 | })
46 |
47 | pageSchema.pre("save", function(next){
48 | this.updatedAt = Date.now()
49 | this.lines = this.lines
50 | .map(line => {
51 | line.value = line.value.trim()
52 | return line
53 | })
54 | .filter(line => line.value.length > 0)
55 | next()
56 | })
57 |
58 | pageSchema.pre("save", function(next){
59 | this.image = null
60 | this.innerLinks = []
61 | for(let line of this.lines){
62 | for(let node of Parser.parse(line.value)){
63 | if(!this.image && /image/.test(node.type)){
64 | debug("found image", node.image)
65 | this.image = node.image
66 | break
67 | }
68 | if(/^title\-link/.test(node.type)){
69 | this.innerLinks.push(node.title)
70 | }
71 | }
72 | }
73 | this.innerLinks = uniq(this.innerLinks)
74 | next()
75 | })
76 |
77 | pageSchema.plugin(autoIncrement.plugin, {
78 | model: "Page",
79 | field: "number",
80 | startAt: 1
81 | })
82 |
83 | pageSchema.post("save", function(page){
84 | debug(`save! ${page.wiki}::${page.title}`)
85 | if(page.lines.length < 1){
86 | Page.emit("remove", page)
87 | }
88 | else{
89 | Page.emit("update", page)
90 | }
91 | })
92 |
93 | pageSchema.statics.findNotEmpty = function(...args){
94 | args[0].lines = {$ne: []}
95 | return this.find(...args)
96 | }
97 |
98 | pageSchema.statics.findPagesByWiki = function(wiki){
99 | return Page.findNotEmpty({wiki}, 'title image', {sort: {updatedAt: -1}})
100 | }
101 |
102 | pageSchema.statics.findOneByWikiTitle = function(query){
103 | return this.findOne(ambiguous(query))
104 | }
105 |
106 | pageSchema.methods.findReverseLinkedPages = function(selector = "title image"){
107 | return Page.find({
108 | wiki: this.wiki,
109 | innerLinks: {$in: [this.title]}
110 | }, selector)
111 | }
112 |
113 | pageSchema.methods.findInnerLinkedPages = async function(selector = "title image"){
114 | const wiki = this.wiki
115 | const pages = await Promise.all(
116 | this.innerLinks.map(title => Page.findOne({wiki, title}, selector))
117 | )
118 | return pages.filter(page => !!page)
119 | }
120 |
121 | pageSchema.methods.findRelatedPages = async function(){
122 | const [relateds, innerLinks] = await Promise.all([this.findReverseLinkedPages(), this.findInnerLinkedPages()])
123 | return uniqBy(relateds.concat(innerLinks), (page) => page.title)
124 | }
125 |
126 | const saveTimeouts = {}
127 | pageSchema.methods.saveLater = function(){
128 | const validationResult = validateRoute(this)
129 | if(validationResult.invalid) throw new Error(validationResult.errors)
130 | const key = `${this.wiki}::${this.title}`
131 | clearTimeout(saveTimeouts[key])
132 | saveTimeouts[key] = setTimeout(this.save, 5000)
133 | }
134 |
135 | pageSchema.methods.rename = async function(newTitle){
136 | const {wiki} = this
137 | if((await Page.count({wiki, title: newTitle})) > 0){
138 | throw new Error("page exists")
139 | }
140 | Page.emit("remove", this)
141 | this.title = newTitle
142 | await this.save()
143 | return {wiki, title: newTitle}
144 | }
145 |
146 | pageSchema.methods.toHash = function(){
147 | return {
148 | wiki: this.wiki,
149 | title: this.title,
150 | image: this.image,
151 | number: this.number,
152 | lines: this.lines,
153 | indent: this.indent,
154 | updatedAt: this.updatedAt,
155 | createdAt: this.createdAt
156 | }
157 | }
158 |
159 | const Page = mongoose.model("Page", pageSchema)
160 |
--------------------------------------------------------------------------------
/src/share/markup/parser.js:
--------------------------------------------------------------------------------
1 | import {camelize} from "../string"
2 |
3 | export const Parser = new class Parser{
4 | constructor(){
5 | this.replacers = []
6 | }
7 |
8 | parse(str){
9 | let nodes = toNodes(str)
10 | for(let m of this.replacers){
11 | nodes = m(nodes)
12 | }
13 | return nodes
14 | }
15 |
16 | addReplacer(replacer){
17 | if(typeof replacer !== "function") throw new Error("replacer must be a function")
18 | this[camelize(replacer.type)] = replacer
19 | this.replacers.push(replacer)
20 | }
21 | }
22 |
23 | export function flatten(arr){
24 | return Array.prototype.concat.apply([], arr)
25 | }
26 |
27 | export function toNodes(obj){
28 | if(obj instanceof Array){
29 | const invalid = obj.find(o => typeof o.type !== "string")
30 | if(invalid) throw new Error(`found invalid node - ${JSON.stringify(invalid)}`)
31 | return obj // valid Nodes
32 | }
33 | if(typeof obj === "string") return [createTextNode(obj)] // wrap Text to Nodes
34 | if(typeof obj === "object" && typeof obj.type === "string") return [obj] // wrap Node to Nodes
35 | throw new Error(`invalid source, cannot convert to Nodes. - ${JSON.stringify(obj)}`)
36 | }
37 |
38 | export function disableRegExpCapture(regexp, {replace, flags} = {}){
39 | if(!(regexp instanceof RegExp)) throw new Error("Invalid argument: not RegExp")
40 | let str = regexp.source.replace(/\((?!\?)/gm, "(?:")
41 | if(typeof replace === "function") str = replace(str)
42 | return new RegExp(str, flags)
43 | }
44 |
45 | export function createTextNode(value){
46 | if(typeof value !== "string") throw new Error("Invalid argument: not String")
47 | return {type: "text", value}
48 | }
49 |
50 | function createReplacer(type, regexp, toNode){
51 | function replacer(nodes){
52 | return flatten(nodes.map(node => {
53 | if(node.type !== "text") return node
54 | let splitter = disableRegExpCapture(regexp, {
55 | flags: "gmi", replace: src => `(${src})`
56 | })
57 | return node.value.split(splitter).map(chunk => {
58 | const m = chunk.match(regexp)
59 | if(m){
60 | const source = m.shift()
61 | return Object.assign({type, source}, toNode(...m))
62 | }
63 | return createTextNode(chunk)
64 | })
65 | })).filter(node => node.type !== "text" || node.value)
66 | }
67 | replacer.type = type
68 | return replacer
69 | }
70 |
71 | Parser.addReplacer(createReplacer(
72 | "inline-code",
73 | /`((?:\\`|[^`])+)`/,
74 | (value) => ({value: value.replace(/\\`/g, '`')})
75 | ))
76 |
77 | Parser.addReplacer(createReplacer(
78 | "strong",
79 | /\[{2}(.+)\]{2}/,
80 | (value) => ({value})
81 | ))
82 |
83 | Parser.addReplacer(createReplacer(
84 | "external-link-with-image",
85 | /\[(https?:\/\/[^\s\]]+) (https?:\/\/[^\s\]]+\.(?:jpe?g|gif|png))\]/i,
86 | (link, image) => ({image, link})
87 | ))
88 |
89 | Parser.addReplacer(createReplacer(
90 | "external-link-with-image-reverse",
91 | /\[(https?:\/\/[^\s\]]+\.(?:jpe?g|gif|png)) (https?:\/\/[^\s\]]+)\]/i,
92 | (image, link) => ({image, link, type: "external-link-with-image"})
93 | ))
94 |
95 | Parser.addReplacer(createReplacer(
96 | "external-link-with-description",
97 | /\[(https?:\/\/[^\s\]]+) ([^\]]+)\]/,
98 | (link, description) => ({link, description})
99 | ))
100 |
101 | Parser.addReplacer(createReplacer(
102 | "external-link-with-description-reverse",
103 | /\[([^\]]+) (https?:\/\/[^\s\]]+)\]/,
104 | (description, link) => ({link, description, type: 'external-link-with-description'})
105 | ))
106 |
107 | Parser.addReplacer(createReplacer(
108 | "image",
109 | /\[(https?:\/\/[^\s\]]+\.(?:jpe?g|gif|png))\]/i,
110 | image => ({image})
111 | ))
112 |
113 | Parser.addReplacer(createReplacer(
114 | "image-naked",
115 | /(?:^|\s)(https?:\/\/[^\s\]]+\.(?:jpe?g|gif|png))(?:$|\s)/i,
116 | image => ({image, type: 'image'})
117 | ))
118 |
119 | Parser.addReplacer(createReplacer(
120 | "external-link",
121 | /\[(https?:\/\/[^\s\]]+)\]/,
122 | link => ({link})
123 | ))
124 |
125 | Parser.addReplacer(createReplacer(
126 | "external-link-naked",
127 | /(?:^|\s)(https?:\/\/[^\s\]]+)(?:$|\s)/,
128 | link => ({link, type: 'external-link'})
129 | ))
130 |
131 | Parser.addReplacer(createReplacer(
132 | "wiki-link",
133 | /\[([^\]]+)::\]/,
134 | wiki => ({wiki})
135 | ))
136 |
137 | Parser.addReplacer(createReplacer(
138 | "wiki-title-link",
139 | /\[([^\]]+)::([^\]]*)\]/,
140 | (wiki, title) => ({wiki, title})
141 | ))
142 |
143 | Parser.addReplacer(createReplacer(
144 | "title-link",
145 | /\[([^\]]+)\]/,
146 | title => ({title})
147 | ))
148 |
149 | Parser.addReplacer(createReplacer(
150 | "title-link-hash",
151 | /(?:^|\s)\#([^\[\]\s]+)(?:$|\s)/,
152 | title => ({title})
153 | ))
154 |
--------------------------------------------------------------------------------
/src/client/component/editor-line.jsx:
--------------------------------------------------------------------------------
1 | // const debug = require("../../share/debug")(__filename)
2 |
3 | import React, {Component} from "react"
4 | import {findDOMNode} from "react-dom"
5 |
6 | import LongPress from "./longpress"
7 | import UserIcon from "./usericon"
8 | import Code, {getFullLanguage} from "./code"
9 | import classnames from "classnames"
10 |
11 | export default class EditorLine extends Component{
12 |
13 | constructor(){
14 | super()
15 | this.focusInput = this.focusInput.bind(this)
16 | }
17 |
18 | static get propTypes(){
19 | return {
20 | line: React.PropTypes.object.isRequired,
21 | compiler: React.PropTypes.func.isRequired,
22 | edit: React.PropTypes.bool,
23 | onStartEdit: React.PropTypes.func,
24 | onChange: React.PropTypes.func,
25 | onKeyDown: React.PropTypes.func,
26 | onPaste: React.PropTypes.func,
27 | key: React.PropTypes.string.isRequired
28 | }
29 | }
30 |
31 | shouldComponentUpdate(nextProps, nextState){
32 | if(Object.keys(nextProps || {}).length !== Object.keys(this.props || {}).length ||
33 | Object.keys(nextState || {}).length !== Object.keys(this.state || {}).length) return true
34 | for(let k in nextState){
35 | if(typeof nextState[k] !== "object" && typeof nextState[k] !== "function" &&
36 | this.state[k] !== nextState[k]) return true
37 | }
38 | for(let k in nextProps){
39 | if(typeof nextProps[k] !== "object" && typeof nextProps[k] !== "function" &&
40 | this.props[k] !== nextProps[k]) return true
41 | }
42 | if (nextProps.line.value !== this.props.line.value) return true
43 | if (nextProps.line.indent !== this.props.line.indent) return true
44 | if (!!nextProps.line.codeblock !== !!this.props.line.codeblock) return true
45 | if (nextProps.line.codeblock && nextProps.line.codeblock.lang !== this.props.line.codeblock.lang) return true
46 | return false
47 | }
48 |
49 | render(){
50 | const {line, edit} = this.props
51 | const {codeblock, cli} = line
52 | if(edit) return this.renderEdit()
53 | if(codeblock) return this.renderCodeBlock()
54 | if(cli) return this.renderCli()
55 | return this.renderDefault()
56 | }
57 |
58 | renderEdit(){
59 | const {line, key} = this.props
60 | let input = (
61 | this.props.onChange(e.target.value)}
65 | onClick={e => e.stopPropagation()}
66 | onKeyDown={this.props.onKeyDown}
67 | onPasteCapture={this.props.onPaste}
68 | />
69 | )
70 | if(line.blocktitle && !line.codeblock && !line.cli){
71 | input = {input}
72 | }
73 | return {input}
74 | }
75 |
76 | renderCodeBlock(){
77 | const {line, key} = this.props
78 | const {lang, start, filename, indent} = line.codeblock
79 | if(start){
80 | return (
81 |
82 |
83 | {filename || getFullLanguage(lang) || lang}
84 |
85 |
86 | )
87 | }
88 | else{
89 | let className = classnames({
90 | codeblock: !line.codeblock.end,
91 | "codeblock-end": line.codeblock.end
92 | })
93 | return (
94 |
95 |
96 |
97 | {line.value}
98 |
99 |
100 |
101 | )
102 | }
103 | }
104 |
105 | renderCli(){
106 | const {key, line} = this.props
107 | return (
108 |
109 |
110 |
111 | {line.cli.prefix}
112 | {" "}
113 | {line.cli.command}
114 |
115 |
116 |
117 | )
118 | }
119 |
120 | renderDefault(){
121 | const {key, line, compiler} = this.props
122 | const icon = line.showUserIcon ? : null
123 | let value = line.value
124 | if(line.numberList) value = line.numberList.prefix + value
125 | let elm = (
126 |
127 |
128 | {compiler(value)}
129 |
130 | {icon}
131 |
132 | )
133 | if(line.blocktitle) elm = {elm}
134 | return (
135 |
136 | {elm}
137 |
138 | )
139 | }
140 |
141 | componentDidUpdate(){
142 | this.focusInput()
143 | }
144 |
145 | componentDidMount(){
146 | this.focusInput()
147 | }
148 |
149 | focusInput(){
150 | if(!this.props.edit) return
151 | findDOMNode(this.refs.input).focus()
152 | }
153 |
154 | }
155 |
--------------------------------------------------------------------------------
/src/client/reducer/page.js:
--------------------------------------------------------------------------------
1 | import Line from "../line"
2 | import shortid from "shortid"
3 | import {getBlock} from "../component/syntax/decorator"
4 |
5 | const MAX_INDENT = 16
6 |
7 | export default function pageReducer(state = {}, action){
8 | switch(action.type){
9 | case "route":
10 | if(action.value.wiki) state.wiki = action.value.wiki
11 | if(action.value.title) state.title = action.value.title
12 | break
13 | case "page":
14 | state = action.value
15 | break
16 | case "page:lines":
17 | if(state.wiki !== action.value.wiki || state.title !== action.value.title) break
18 | state.lines = action.value.lines
19 | break
20 | case "updateLine":{
21 | if(!window.user) break
22 | let line = state.lines[state.editline]
23 | line.value = action.value
24 | line.user = window.user.id
25 | if(!line.id) line.id = shortid.generate()
26 | break
27 | }
28 | case "insertMultiLines": {
29 | let indent = state.lines[state.editline].indent
30 | let lines = action.value.map(value => {
31 | return new Line({
32 | value: value.trim(),
33 | indent: indent + value.match(/^\s*/)[0].length
34 | })
35 | })
36 | state.lines = [
37 | ...state.lines.slice(0, state.editline+1),
38 | ...lines,
39 | ...state.lines.slice(state.editline+1)
40 | ]
41 | state.editline += action.value.length
42 | break
43 | }
44 | case "insertNewLine":
45 | if(state.editline > -1){
46 | let indent = state.lines[state.editline].indent
47 | state.lines = [
48 | ...state.lines.slice(0, state.editline+1),
49 | new Line({indent}),
50 | ...state.lines.slice(state.editline+1, state.lines.length)
51 | ]
52 | state.editline += 1
53 | }
54 | break
55 | case "removeEmptyLines": {
56 | let upCount = 0
57 | state.lines = state.lines.map(line => {
58 | line.value = line.value.trim()
59 | return line
60 | })
61 | const lines = []
62 | for(let i = 0; i < state.lines.length; i++){
63 | let line = state.lines[i]
64 | if(line.value.length < 1){ // empty line
65 | if(i <= state.editline) upCount += 1
66 | }
67 | else{
68 | lines.push(line)
69 | }
70 | }
71 | state.lines = lines
72 | if(state.editline) state.editline -= upCount
73 | break
74 | }
75 | case "editline":
76 | state.editline = action.value
77 | if(state.editline === 0 && state.lines.length === 0){ // empty page
78 | state.lines = [ new Line ]
79 | }
80 | break
81 | case "editline:up":
82 | if(state.editline > 0) state.editline -= 1
83 | break
84 | case "editline:down":
85 | if(state.editline < state.lines.length-1) state.editline += 1
86 | break
87 | case "swapLine:up":
88 | if(state.editline > 0){
89 | let currentLine = state.lines[state.editline]
90 | state.lines[state.editline] = state.lines[state.editline-1]
91 | state.lines[state.editline-1] = currentLine
92 | state.editline -= 1
93 | }
94 | break
95 | case "swapLine:down":
96 | if(state.editline < state.lines.length-1){
97 | let currentLine = state.lines[state.editline]
98 | state.lines[state.editline] = state.lines[state.editline+1]
99 | state.lines[state.editline+1] = currentLine
100 | state.editline += 1
101 | }
102 | break
103 | case "swapBlock:up": {
104 | let currentBlock = getBlock(state.lines, state.editline)
105 | let upperBlock
106 | for(let i = state.editline-1; i >= 0; i--){
107 | let line = state.lines[i]
108 | if(line.indent < currentBlock.indent) break
109 | if(line.indent === currentBlock.indent){
110 | upperBlock = getBlock(state.lines, i)
111 | break
112 | }
113 | }
114 | if(!upperBlock) break
115 | state.lines = [
116 | ...state.lines.slice(0, upperBlock.start),
117 | ...state.lines.slice(currentBlock.start, currentBlock.end+1),
118 | ...state.lines.slice(upperBlock.start, upperBlock.end+1),
119 | ...state.lines.slice(currentBlock.end+1, state.lines.length)
120 | ]
121 | state.editline = upperBlock.start
122 | break
123 | }
124 | case "swapBlock:down": {
125 | let currentBlock = getBlock(state.lines, state.editline)
126 | if(currentBlock.end+1 >= state.lines.length ||
127 | state.lines[currentBlock.end+1].indent !== currentBlock.indent) break
128 | let bottomBlock = getBlock(state.lines, currentBlock.end+1)
129 | state.lines = [
130 | ...state.lines.slice(0, currentBlock.start),
131 | ...state.lines.slice(bottomBlock.start, bottomBlock.end+1),
132 | ...state.lines.slice(currentBlock.start, currentBlock.end+1),
133 | ...state.lines.slice(bottomBlock.end+1, state.lines.length)
134 | ]
135 | state.editline += bottomBlock.length
136 | break
137 | }
138 | case "indent:decrement": {
139 | let currentLine = state.lines[state.editline]
140 | if(currentLine.indent > 0) currentLine.indent -= 1
141 | break
142 | }
143 | case "indent:increment": {
144 | let line = state.lines[state.editline]
145 | if(line.indent < MAX_INDENT) line.indent += 1
146 | break
147 | }
148 | case "indentBlock:decrement":
149 | if(state.lines[state.editline].indent < 1) break
150 | getBlock(state.lines, state.editline, line => line.indent--)
151 | break
152 | case "indentBlock:increment":
153 | if(state.lines[state.editline].indent < MAX_INDENT){
154 | getBlock(state.lines, state.editline, line => line.indent++)
155 | }
156 | break
157 | case "page:title:startEdit":
158 | state.newTitle = state.title
159 | break
160 | case "page:title:change":
161 | state.newTitle = action.value
162 | break
163 | case "page:title:cancelEdit":
164 | case "page:title:submit":
165 | state.newTitle = null
166 | break
167 | }
168 | return state
169 | }
170 |
--------------------------------------------------------------------------------
/src/share/markup/test/parser.js:
--------------------------------------------------------------------------------
1 | /* eslint-env mocha */
2 |
3 | import "./test_helper"
4 | import {assert} from "chai"
5 |
6 | import {disableRegExpCapture, flatten, toNodes, Parser} from "../parser"
7 |
8 | describe("Parser - wiki syntax parser", function(){
9 |
10 | describe("utils", function(){
11 | describe("disableRegExpCapture", function(){
12 | it('should return new RegExp with replacing captures to "non-captureing parentheses"', function(){
13 | assert.regExpEqual(disableRegExpCapture(/[(hello)] (world|work)/)
14 | , /[(?:hello)] (?:world|work)/
15 | , "disable all captures")
16 | assert.regExpEqual(disableRegExpCapture(/[[(hello)]]/, {replace: str => `(${str})`})
17 | , /([[(?:hello)]])/
18 | , '"replace" option')
19 | assert.regExpEqual(disableRegExpCapture(/[(image)\.(?:jpg|gif|png)]/)
20 | , /[(?:image)\.(?:jpg|gif|png)]/
21 | , "disable only normal captures")
22 | })
23 | })
24 |
25 | describe("flatten", function(){
26 | it("should return flat array", function(){
27 | assert.deepEqual(flatten([1, 2, [3, 4], [5, 6]])
28 | , [1, 2, 3, 4, 5, 6])
29 | })
30 | })
31 |
32 | describe("toNodes", function(){
33 | assert.deepEqual(toNodes("zanmai"), [
34 | {type: "text", value: "zanmai"}
35 | ])
36 | })
37 | })
38 |
39 | describe("replacers", function(){
40 | describe("title-link", function(){
41 | it("should parse [title]", function(){
42 | const nodes = Parser.titleLink(toNodes("hello [shokai] world"))
43 | assert.deepEqual(nodes, [
44 | {type: "text", value: "hello "},
45 | {type: "title-link", title: "shokai", source: "[shokai]"},
46 | {type: "text", value: " world"}
47 | ])
48 | })
49 | })
50 |
51 | describe("title-link-hash", function(){
52 | it("should parse #title", function(){
53 | const nodes = Parser.titleLinkHash(toNodes("hello #shokai world"))
54 | assert.deepEqual(nodes, [
55 | {type: "text", value: "hello"},
56 | {type: "title-link-hash", title: "shokai", source: " #shokai "},
57 | {type: "text", value: "world"}
58 | ])
59 | })
60 | })
61 |
62 | describe("wiki-title-link", function(){
63 | it("should parse [wiki::title]", function(){
64 | const nodes = Parser.wikiTitleLink(toNodes("hello [shokai::test] world"))
65 | assert.deepEqual(nodes, [
66 | {type: "text", value: "hello "},
67 | {type: "wiki-title-link", wiki: "shokai", title: "test", source: "[shokai::test]"},
68 | {type: "text", value: " world"}
69 | ])
70 | })
71 | })
72 |
73 | describe("wiki-link", function(){
74 | it("should parse [wiki::]", function(){
75 | const nodes = Parser.wikiLink(toNodes("hello [shokai::] world"))
76 | assert.deepEqual(nodes, [
77 | {type: "text", value: "hello "},
78 | {type: "wiki-link", wiki: "shokai", source: "[shokai::]"},
79 | {type: "text", value: " world"}
80 | ])
81 | })
82 | })
83 |
84 | describe("external-link-with-image", function(){
85 | it("should parse [http://example.com http://example.com/image.jpg]", function(){
86 | const nodes = Parser.externalLinkWithImage(toNodes("hello [http://shokai.org https://gyazo.com/0ceeecc3c7d42e94a080e21f6a23f190.gif] world"))
87 | assert.deepEqual(nodes, [
88 | {type: "text", value: "hello "},
89 | {type: "external-link-with-image", link: "http://shokai.org", image: "https://gyazo.com/0ceeecc3c7d42e94a080e21f6a23f190.gif", source: "[http://shokai.org https://gyazo.com/0ceeecc3c7d42e94a080e21f6a23f190.gif]"},
90 | {type: "text", value: " world"}
91 | ])
92 | })
93 | })
94 |
95 | describe("external-link-with-image-reverse", function(){
96 | it("should parse [http://example.com/image.jpg http://example.com]", function(){
97 | const nodes = Parser.externalLinkWithImageReverse(toNodes("hello [https://gyazo.com/0ceeecc3c7d42e94a080e21f6a23f190.gif http://shokai.org] world"))
98 | assert.deepEqual(nodes, [
99 | {type: "text", value: "hello "},
100 | {type: "external-link-with-image", link: "http://shokai.org", image: "https://gyazo.com/0ceeecc3c7d42e94a080e21f6a23f190.gif", source: "[https://gyazo.com/0ceeecc3c7d42e94a080e21f6a23f190.gif http://shokai.org]"},
101 | {type: "text", value: " world"}
102 | ])
103 | })
104 | })
105 |
106 | describe("external-link-with-description", function(){
107 | it("should parse [http://example.com example site]", function(){
108 | const nodes = Parser.externalLinkWithDescription(toNodes("hello [http://example.com example site] world"))
109 | assert.deepEqual(nodes, [
110 | {type: "text", value: "hello "},
111 | {type: "external-link-with-description", link: "http://example.com", description: "example site", source: "[http://example.com example site]"},
112 | {type: "text", value: " world"}
113 | ])
114 | })
115 | })
116 |
117 | describe("external-link-with-description-reverse", function(){
118 | it("should parse [example site http://example.com]", function(){
119 | const nodes = Parser.externalLinkWithDescriptionReverse(toNodes("hello [example site http://example.com] world"))
120 | assert.deepEqual(nodes, [
121 | {type: "text", value: "hello "},
122 | {type: "external-link-with-description", link: "http://example.com", description: "example site", source: "[example site http://example.com]"},
123 | {type: "text", value: " world"}
124 | ])
125 | })
126 | })
127 |
128 | describe("external-link", function(){
129 | it("should parse [http://example.com]", function(){
130 | const nodes = Parser.externalLink(toNodes("hello [http://shokai.org] world"))
131 | assert.deepEqual(nodes, [
132 | {type: "text", value: "hello "},
133 | {type: "external-link", link: "http://shokai.org", source: "[http://shokai.org]"},
134 | {type: "text", value: " world"}
135 | ])
136 | })
137 | })
138 |
139 | describe("external-link-naked", function(){
140 | it("should parse http://example.com", function(){
141 | const nodes = Parser.externalLinkNaked(toNodes("hello http://shokai.org world"))
142 | assert.deepEqual(nodes, [
143 | {type: "text", value: "hello"},
144 | {type: "external-link", link: "http://shokai.org", source: " http://shokai.org "},
145 | {type: "text", value: "world"}
146 | ])
147 | })
148 | })
149 |
150 | describe("image", function(){
151 | it("should parse [http://example.com/image.gif]", function(){
152 | const nodes = Parser.image(toNodes("hello [http://example.com/image.gif] world"))
153 | assert.deepEqual(nodes, [
154 | {type: "text", value: "hello "},
155 | {type: "image", image: "http://example.com/image.gif", source: "[http://example.com/image.gif]"},
156 | {type: "text", value: " world"}
157 | ])
158 | })
159 | })
160 |
161 | describe("image-naked", function () {
162 | it("should parse http://example.com/image.gif", function(){
163 | const nodes = Parser.imageNaked(toNodes("hello http://example.com/image.gif world"))
164 | assert.deepEqual(nodes, [
165 | {type: "text", value: "hello"},
166 | {type: "image", image: "http://example.com/image.gif", source: " http://example.com/image.gif "},
167 | {type: "text", value: "world"}
168 | ])
169 | })
170 | })
171 |
172 | describe("strong", function(){
173 | it("should parse [[text message]]", function(){
174 | const nodes = Parser.strong(toNodes("hello [[strong shokai]] world"))
175 | assert.deepEqual(nodes, [
176 | {type: "text", value: "hello "},
177 | {type: "strong", value: "strong shokai", source: "[[strong shokai]]"},
178 | {type: "text", value: " world"}
179 | ])
180 | })
181 | })
182 |
183 | describe("inline-code", function(){
184 | it("should parse `inline code`", function(){
185 | const nodes = Parser.inlineCode(toNodes("hello `inline code` world"))
186 | assert.deepEqual(nodes, [
187 | {type: "text", value: "hello "},
188 | {type: "inline-code", value: "inline code", source: "`inline code`"},
189 | {type: "text", value: " world"}
190 | ])
191 | })
192 |
193 | it("allow backquote in `inline code`", function(){
194 | const nodes = Parser.inlineCode(toNodes("hello `inline \\`code\\`` world"))
195 | assert.deepEqual(nodes, [
196 | {type: "text", value: "hello "},
197 | {type: "inline-code", value: "inline `code`", source: "`inline \\`code\\``"},
198 | {type: "text", value: " world"}
199 | ])
200 | })
201 |
202 | })
203 |
204 | })
205 |
206 | describe("parse", function(){
207 | it("should parse link", function(){
208 | assert.deepEqual(Parser.parse("hello [[world]] [shokai][http://shokai.org] [general::test] [ざんまい::] かずすけ [http://shokai.org https://gyazo.com/0ceeecc3c7d42e94a080e21f6a23f190.gif][http://example.com example site][http://example.com/image.PNG]"), [
209 | {type: "text", value: "hello "},
210 | {type: "strong", value: "world", source: "[[world]]"},
211 | {type: "text", value: " "},
212 | {type: "title-link", title: "shokai", source: "[shokai]"},
213 | {type: "external-link", link: "http://shokai.org", source: "[http://shokai.org]"},
214 | {type: "text", value: " "},
215 | {type: "wiki-title-link", wiki: "general", title: "test", source: "[general::test]"},
216 | {type: "text", value: " "},
217 | {type: "wiki-link", wiki: "ざんまい", source: "[ざんまい::]"},
218 | {type: "text", value: " かずすけ "},
219 | {type: "external-link-with-image", link: "http://shokai.org", image: "https://gyazo.com/0ceeecc3c7d42e94a080e21f6a23f190.gif", source: "[http://shokai.org https://gyazo.com/0ceeecc3c7d42e94a080e21f6a23f190.gif]"},
220 | {type: "external-link-with-description", link: "http://example.com", description: "example site", source: "[http://example.com example site]"},
221 | {type: "image", image: "http://example.com/image.PNG", source: "[http://example.com/image.PNG]"}
222 | ])
223 | })
224 | })
225 |
226 | })
227 |
--------------------------------------------------------------------------------