├── 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 |
20 |
21 | 22 | 23 |
24 |
25 | 26 | <PageDate store={store} /> 27 | </div> 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 ? <meta name="twitter:image" value={image} /> : null 13 | return ( 14 | <x-twitter-card> 15 | <meta name="twitter:card" value="summary" /> 16 | <meta name="twitter:title" value={buildTitle({wiki, title, lines})} /> 17 | <meta name="twitter:description" value={description} /> 18 | {imageCard} 19 | </x-twitter-card> 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(<App store={store} />, 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 | <PageList 14 | name='Related' 15 | wiki={wiki} 16 | title={title} 17 | pagelist={relatedPagelist} 18 | action={this.action} 19 | /> 20 | ) 21 | } 22 | 23 | 24 | return ( 25 | <div> 26 | {related} 27 | <PageList 28 | name={wiki} 29 | wiki={wiki} 30 | title={title} 31 | pagelist={pagelist} 32 | action={this.action} 33 | /> 34 | </div> 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 <span>{children}</span> 31 | } 32 | return <span dangerouslySetInnerHTML={{__html}} /> 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 | <div className='edit-tool'> 24 | <ul> 25 | <li> 26 | <span onClick={this.createNewPage} className='button'>new page</span> 27 | </li> 28 | <li> 29 | <span className='button'> 30 | <a href={`/api/text/${wiki}/${title}`}> 31 | text 32 | </a> 33 | </span> 34 | </li> 35 | </ul> 36 | </div> 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 <span>{node.value}</span> 7 | case "strong": 8 | return <strong>{node.value}</strong> 9 | case "image": 10 | return <img src={node.image} /> 11 | case "external-link": 12 | return <a href={node.link} target="_blank">{node.link}</a> 13 | case "external-link-with-description": 14 | return <a href={node.link} target="_blank">{node.description}</a> 15 | case "external-link-with-image": 16 | return <a href={node.link} target="_blank"><img src={node.image} /></a> 17 | case "wiki-link": 18 | return <a href={`/${node.wiki}`}>{node.wiki}::</a> 19 | case "wiki-title-link": 20 | return <a href={`/${node.wiki}/${node.title}`}>{`${node.wiki}::${node.title}`}</a> 21 | case "inline-code": 22 | return <span className="inline-code">{node.value}</span> 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 | <span 29 | onMouseDown={this.start} 30 | onMouseUp={this.stop} 31 | onMouseOut={this.stop}> 32 | {this.props.children} 33 | </span> 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 = <ul><li><span><a href="/auth/login">login</a></span></li></ul> 23 | } 24 | else{ 25 | element = ( 26 | <ul> 27 | <li> 28 | <span> 29 | <a href={"https://github.com/"+this.props.user.name}> 30 | <UserIcon id={this.props.user.id} size={20} /> 31 | {this.props.user.name} 32 | </a> 33 | </span> 34 | </li> 35 | <li><span><a href="/auth/logout">logout</a></span></li> 36 | </ul> 37 | ) 38 | } 39 | return ( 40 | <div className="login">{element}</div> 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 | <html> 14 | <head> 15 | <meta name="viewport" content="width=640px" /> 16 | <title>{buildTitle({wiki, title, lines, image})} 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 |
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 |