diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..fde1b09 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,80 @@ +# This is a basic workflow to help you get started with Actions + +name: CI + +# Controls when the action will run. +on: + # Triggers the workflow on push or pull request events but only for the master branch + push: + branches: [ master ] + pull_request: + branches: [ master ] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + test_stable: + runs-on: ubuntu-latest + strategy: + matrix: + firefox: [ '73.0' ] + include: + - nim-version: 'stable' + cache-key: 'stable' + steps: + - uses: actions/checkout@v2 + - name: Checkout submodules + run: git submodule update --init --recursive + + - name: Setup firefox + uses: browser-actions/setup-firefox@latest + with: + firefox-version: ${{ matrix.firefox }} + + - name: Get Date + id: get-date + run: echo "::set-output name=date::$(date "+%Y-%m-%d")" + shell: bash + + - name: Cache choosenim + uses: actions/cache@v2 + with: + path: ~/.choosenim + key: ${{ runner.os }}-choosenim-${{ matrix.cache-key }} + + - name: Cache nimble + uses: actions/cache@v2 + with: + path: ~/.nimble + key: ${{ runner.os }}-nimble-${{ hashFiles('*.nimble') }} + + - uses: jiro4989/setup-nim-action@v1 + with: + nim-version: "${{ matrix.nim-version }}" + + - name: Install geckodriver + run: | + sudo apt-get -qq update + sudo apt-get install autoconf libtool libsass-dev + + wget https://github.com/mozilla/geckodriver/releases/download/v0.29.1/geckodriver-v0.29.1-linux64.tar.gz + mkdir geckodriver + tar -xzf geckodriver-v0.29.1-linux64.tar.gz -C geckodriver + export PATH=$PATH:$PWD/geckodriver + + - name: Install choosenim + run: | + export CHOOSENIM_CHOOSE_VERSION="stable" + curl https://nim-lang.org/choosenim/init.sh -sSf > init.sh + sh init.sh -y + export PATH=$HOME/.nimble/bin:$PATH + nimble refresh -y + + - name: Run tests + run: | + export MOZ_HEADLESS=1 + nimble -y install + nimble -y test + diff --git a/.gitignore b/.gitignore index 83421ca..fe26a8a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ # Wildcard patterns. *.swp nimcache/ -*.db +*.db* # Specific paths /createdb @@ -12,3 +12,12 @@ nimcache/ forum createdb editdb + +.vscode +forum.json* +browsertester +setup_nimforum +buildcss +nimforum.css + +/src/frontend/forum.js diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..6ea9ea9 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "frontend/spectre"] + path = frontend/spectre + url = https://github.com/picturepan2/spectre +[submodule "public/css/spectre"] + path = public/css/spectre + url = https://github.com/picturepan2/spectre diff --git a/README.md b/README.md index 1df440e..d7dedb4 100644 --- a/README.md +++ b/README.md @@ -1,83 +1,131 @@ # nimforum -This is Nim's forum. Available at http://forum.nim-lang.org. +NimForum is a light-weight forum implementation +with many similarities to Discourse. It is implemented in +the [Nim](https://nim-lang.org) programming +language and uses SQLite for its database. -## Building +## Examples in the wild -You can use ``nimble`` (available [here](https://github.com/nim-lang/nimble) -to get all the necessary -[dependencies](https://github.com/nim-lang/nimforum/blob/master/nimforum.nimble#L11). +[![forum.nim-lang.org](https://i.imgur.com/hdIF5Az.png)](https://forum.nim-lang.org) -Clone this repo and execute ``nimble build`` in this repositories directory. +

forum.nim-lang.org

-_See also: Running the forum for how to create the database_ +## Features + +* Efficient, type safe and clean **single-page application** developed using the + [Karax](https://github.com/pragmagic/karax) and + [Jester](https://github.com/dom96/jester) frameworks. +* **Utilizes SQLite** making set up much easier. +* Endlessly **customizable** using SASS. +* Spam blocking via new user sandboxing with great tools for moderators. +* reStructuredText enriched by Markdown to make formatting your posts a breeze. +* Search powered by SQLite's full-text search. +* Context-aware replies. +* Last visit tracking. +* Gravatar support. +* And much more! + +## Setup + +[See this document.](https://github.com/nim-lang/nimforum/blob/master/setup.md) ## Dependencies -The code depends on the RST parser of the Nim -compiler and on Jester. The code generating captchas for registration uses the -[cairo module](https://github.com/nim-lang/cairo), which requires you to have -the [cairo library](http://cairographics.org) installed when you run the forum, -or you will be greeted by a cryptic error message similar to: +The following lists the dependencies which you may need to install manually +in order to get NimForum running, compiled*, or tested†. - $ ./forum could not load: libcairo.so(1.2) +* libsass +* SQLite +* pcre +* Nim (and the Nimble package manager)* +* [geckodriver](https://github.com/mozilla/geckodriver)† + * Firefox† -### Mac OS X +[*] Build time dependencies -#### cairo -If you are using macosx and have installed the ``cairo`` library through -[MacPorts](https://www.macports.org) you still need to add the library path to -your ``LD_LIBRARY_PATH`` environment variable. Example: +[†] Test time dependencies - $ LD_LIBRARY_PATH=/opt/local/lib/ ./forum +## Development -Replace ``/opt/local/lib`` with the correct path on your system. - -#### bcrypt - -On macosx you also need to make sure to use the bcrypt >= 0.2.1 module if that -is not yet updated you can install it with: +Check out the tasks defined by this project's ``nimforum.nimble`` file by +running ``nimble tasks``, as of writing they are: ``` -nimble install https://github.com/oderwat/bcryptnim.git@#fix-osx +backend Compiles and runs the forum backend +runbackend Runs the forum backend +frontend Builds the necessary JS frontend (with CSS) +minify Minifies the JS using Google's closure compiler +testdb Creates a test DB (with admin account!) +devdb Creates a test DB (with admin account!) +blankdb Creates a blank DB +test Runs tester +fasttest Runs tester without recompiling backend ``` -You may also need to change `nimforum.nimble` such that it uses 0.2.1 by -changing the dependencies slightly. +To get up and running: -``` -[Deps] -Requires: "nimrod >= 0.10.3, cairo#head, jester#head, bcrypt >= 0.2.1" +```bash +git clone https://github.com/nim-lang/nimforum +cd nimforum +git submodule update --init --recursive + +# Setup the db with user: admin, pass: admin and some other users +nimble devdb + +# Run this again if frontend code changes +nimble frontend + +# Will start a server at localhost:5000 +nimble backend ``` -# Running the forum +Development typically involves running `nimble devdb` which sets up the +database for development and testing, then `nimble backend` +which compiles and runs the forum's backend, and `nimble frontend` +separately to build the frontend. When making changes to the frontend it +should be enough to simply run `nimble frontend` again to rebuild. This command +will also build the SASS ``nimforum.scss`` file in the `public/css` directory. -**Important: You need to compile and run `createdb` to generate the initial database -before you can run `forum` the first time**! +### With docker -**Note: If you do not have a mail server set up locally, you can specify -``-d:dev`` during compilation to prevent nimforum from attempting to send -emails and to automatically activate user accounts** +You can easily launch site on localhost if you have `docker` and `docker-compose`. +You don't have to setup dependencies (libsass, sglite, pcre, etc...) on you host PC. -This is as simple as: +To get up and running: -``` -nim c -r createdb +```bash +cd docker +docker-compose build +docker-compose up ``` -After that you can just run `forum` and if everything is ok you will get the info which URL you need to open in your browser (http://localhost:5000) to access it. +And you can access local NimForum site. +Open http://localhost:5000 . -_There is an update helper `editdb` which you can safely ignore for now._ +# Troubleshooting -_The files `captchas.nim`, `cache.nim` are included by `forum.nim` and do -not need to be compiled by you._ +You might have to run `nimble install karax@#5f21dcd`, if setup fails +with: + +``` +andinus@circinus ~/projects/forks/nimforum> nimble --verbose devdb +[...] + Installing karax@#5f21dcd + Tip: 24 messages have been suppressed, use --verbose to show them. + Error: No binaries built, did you specify a valid binary name? +[...] + Error: Exception raised during nimble script execution +``` + +The hash needs to be replaced with the one specified in output. # Copyright -Copyright (c) 2012-2015 Andreas Rumpf, Dominik Picheta. +Copyright (c) 2012-2018 Andreas Rumpf, Dominik Picheta. All rights reserved. # License -Nimforum is licensed under the MIT license. +NimForum is licensed under the MIT license. diff --git a/cache.nim b/cache.nim deleted file mode 100644 index 6023393..0000000 --- a/cache.nim +++ /dev/null @@ -1,32 +0,0 @@ -import tables, uri -type - CacheInfo = object - valid: bool - value: string - - CacheHolder = ref object - caches: Table[string, CacheInfo] - -proc normalizePath(x: string): string = - let u = parseUri(x) - result = u.path & (if u.query != "": '?' & u.query else: "") - -proc newCacheHolder*(): CacheHolder = - new result - result.caches = initTable[string, CacheInfo]() - -proc invalidate*(cache: CacheHolder, name: string) = - cache.caches[name.normalizePath()].valid = false - -proc invalidateAll*(cache: CacheHolder) = - for key, val in mpairs(cache.caches): - val.valid = false - -template get*(cache: CacheHolder, name: string, grabValue: untyped): untyped = - ## Check to see if the cache contains value for ``name``. If it does and the - ## cache is valid then doesn't recalculate it but returns the cached version. - mixin normalizePath - let nName = name.normalizePath() - if not (cache.caches.hasKey(nName) and cache.caches[nName].valid): - cache.caches[nName] = CacheInfo(valid: true, value: grabValue) - cache.caches[nName].value diff --git a/captchas.nim b/captchas.nim deleted file mode 100644 index f569f04..0000000 --- a/captchas.nim +++ /dev/null @@ -1,37 +0,0 @@ -# -# -# The Nim Forum -# (c) Copyright 2012 Andreas Rumpf, Dominik Picheta -# Look at license.txt for more info. -# All rights reserved. -# - -import cairo, os, strutils, jester - -proc getCaptchaFilename*(i: int): string {.inline.} = - result = "public/captchas/capture_" & $i & ".png" - -proc getCaptchaUrl*(req: Request, i: int): string = - result = req.makeUri("/captchas/capture_" & $i & ".png", absolute = false) - -proc createCaptcha*(file, text: string) = - var surface = imageSurfaceCreate(FORMAT_ARGB32, int32(10*text.len), int32(10)) - var cr = create(surface) - - selectFontFace(cr, "serif", FONT_SLANT_NORMAL, FONT_WEIGHT_BOLD) - setFontSize(cr, 12.0) - - setSourceRgb(cr, 1.0, 0.5, 0.0) - moveTo(cr, 0.0, 10.0) - showText(cr, repeat('O', text.len)) - - setSourceRgb(cr, 0.0, 0.0, 1.0) - moveTo(cr, 0.0, 10.0) - showText(cr, text) - - destroy(cr) - discard writeToPng(surface, file) - destroy(surface) - -when isMainModule: - createCaptcha("test.png", "1+33") diff --git a/createdb.nim b/createdb.nim deleted file mode 100644 index 4162c36..0000000 --- a/createdb.nim +++ /dev/null @@ -1,124 +0,0 @@ -# -# -# The Nim Forum -# (c) Copyright 2012 Andreas Rumpf, Dominik Picheta -# Look at license.txt for more info. -# All rights reserved. -# - -import strutils, db_sqlite - -var db = open(connection="nimforum.db", user="postgres", password="", - database="nimforum") - -const - TUserName = "varchar(20)" - TPassword = "varchar(32)" - TEmail = "varchar(30)" - -db.exec(sql""" -create table if not exists thread( - id integer primary key, - name varchar(100) not null, - views integer not null, - modified timestamp not null default (DATETIME('now')) -);""", []) - -db.exec(sql""" -create unique index if not exists ThreadNameIx on thread (name); -""", []) - -db.exec(sql(""" -create table if not exists person( - id integer primary key, - name $# not null, - password $# not null, - email $# not null, - creation timestamp not null default (DATETIME('now')), - salt varbin(128) not null, - status varchar(30) not null, - lastOnline timestamp not null default (DATETIME('now')) -);""" % [TUserName, TPassword, TEmail]), []) -# echo "person table already exists" - -db.exec(sql(""" -alter table person -add ban varchar(128) not null default '' -""")) - -db.exec(sql""" -create unique index if not exists UserNameIx on person (name); -""", []) - -# ----------------------- Forum ------------------------------------------------ - - -if not db.tryExec(sql""" -create table if not exists post( - id integer primary key, - author integer not null, - ip inet not null, - header varchar(100) not null, - content varchar(1000) not null, - thread integer not null, - creation timestamp not null default (DATETIME('now')), - - foreign key (thread) references thread(id), - foreign key (author) references person(id) -);""", []): - echo "post table already exists" - -# -------------------- Session ------------------------------------------------- - -if not db.tryExec(sql(""" -create table if not exists session( - id integer primary key, - ip inet not null, - password $# not null, - userid integer not null, - lastModified timestamp not null default (DATETIME('now')), - foreign key (userid) references person(id) -);""" % [TPassword]), []): - echo "session table already exists" - -if not db.tryExec(sql""" -create table if not exists antibot( - id integer primary key, - ip inet not null, - answer varchar(30) not null, - created timestamp not null default (DATETIME('now')) -);""", []): - echo "antibot table already exists" - -# -------------------- Search -------------------------------------------------- - -if not db.tryExec(sql""" - CREATE VIRTUAL TABLE thread_fts USING fts4 ( - id INTEGER PRIMARY KEY, - name VARCHAR(100) NOT NULL - );""", []): - echo "thread_fts table already exists or fts4 not supported" -else: - db.exec(sql""" - INSERT INTO thread_fts - SELECT id, name FROM thread; - """, []) -if not db.tryExec(sql""" - CREATE VIRTUAL TABLE post_fts USING fts4 ( - id INTEGER PRIMARY KEY, - header VARCHAR(100) NOT NULL, - content VARCHAR(1000) NOT NULL - );""", []): - echo "post_fts table already exists or fts4 not supported" -else: - db.exec(sql""" - INSERT INTO post_fts - SELECT id, header, content FROM post; - """, []) - - -# ------------------------------------------------------------------------------ - -#discard stdin.readline() - -close(db) diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 0000000..cb3191a --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,14 @@ +FROM nimlang/nim:1.2.6-ubuntu + +RUN apt-get update -yqq \ + && apt-get install -y --no-install-recommends \ + libsass-dev \ + sqlite3 \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY . /app + +# install dependencies +RUN nimble install -Y diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..8657235 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,12 @@ +version: "3.7" + +services: + forum: + build: + context: ../ + dockerfile: ./docker/Dockerfile + volumes: + - "../:/app" + ports: + - "5000:5000" + entrypoint: "/app/docker/entrypoint.sh" diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 0000000..d8f5923 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,19 @@ +#!/bin/sh + +set -eu + +git submodule update --init --recursive + +# setup +nimble c -d:release src/setup_nimforum.nim +./src/setup_nimforum --dev + +# build frontend +nimble c -r src/buildcss +nimble js -d:release src/frontend/forum.nim +mkdir -p public/js +cp src/frontend/forum.js public/js/forum.js + +# build backend +nimble c src/forum.nim +./src/forum diff --git a/editdb.nim b/editdb.nim deleted file mode 100644 index e12f679..0000000 --- a/editdb.nim +++ /dev/null @@ -1,13 +0,0 @@ - -import strutils, db_sqlite, ranks - -var db = open(connection="nimforum.db", user="postgres", password="", - database="nimforum") - -db.exec(sql("update person set status = ?"), $User) -db.exec(sql("update person set status = ? where ban <> ''"), $Troll) -db.exec(sql("update person set status = ? where ban like '%spam%'"), $Spammer) -db.exec(sql("update person set status = ? where ban = 'DEACTIVATED' or ban = 'EMAILCONFIRMATION'"), $EmailUnconfirmed) -db.exec(sql("update person set status = ? where admin = 'true'"), $Admin) - -close(db) diff --git a/forms.tmpl b/forms.tmpl deleted file mode 100644 index 9d62d95..0000000 --- a/forms.tmpl +++ /dev/null @@ -1,514 +0,0 @@ -#? stdtmpl | standard -# -#template `%`(idx: untyped): untyped = -# row[idx] -#end template -# -# -#proc genThreadsList(c: var TForumData, count: var int): string = -# const queryModAdmin = sql"""select id, name, views, modified from thread -# where id in (select thread from post where author in -# (select id from person where status not in ('Spammer') or id = ?)) -# order by modified desc limit ?, ?""" -# const query = sql"""select id, name, views, modified from thread -# where id in (select thread from post where author in -# (select id from person where status not in ('Moderated', 'Spammer') or id = ?)) -# order by modified desc limit ?, ?""" -# const threadId = 0 -# const name = 1 -# const views = 2 -# -# result = "" -# count = 0 -
-
-
- Topic - - - -
-
-
Users
-
Details
-
-
- Activity - - - -
-
-
-
-# for row in rows(db, if c.rank >= Moderator: queryModAdmin else: query, -# c.userId, $((c.pageNum-1) * ThreadsPerPage), $ThreadsPerPage): -# inc(count) -
-
-
- ${xmlEncode(%name)} - ${genPagenumLocalNav(c, (%threadid).parseInt)} -
-
- - #let users = getAllRows(db, - # sql("select distinct name, email from person where id in " & - # "(select author from post where thread = ?)"), %threadId) -
-
- #for i in 0 .. min(6, users.len-1): - - #end for -
-
- - #let latestReplyAuthor = getValue(db, sql("select name from person where id = " & - # "(select author from post where id = " & - # "(select max(id) from post where thread = ?))"), %threadId) - - #let replyProfileUrl = c.req.makeUri("profile/", false) & - # xmlEncode(latestReplyAuthor) - -# let posts = getValue(db, sql"select count(*) from post where thread = ?", %threadId) -
-
${xmlEncode(%views)}
-
$posts
-
- - #let latestReplyDate = getValue(db, sql("SELECT strftime('%s', " & - # "(select creation from post where id = (select max(id) from post where thread = ?)))"), %threadId) - #let timeStr = formatTimestamp(latestReplyDate.parseInt()) -
-
- $latestReplyAuthor replied $timeStr -
-
-
-# end for -
- -#end proc -# -# -#proc genPostPreview(c: var TForumData, -# title, content, author, date: string): string = -# result = "" - -
-
-
-
- #let profileUrl = c.req.makeUri("profile/", false) & xmlEncode(author) - ${xmlEncode(author)} -
-
-
-
- #try: - ${content.rstToHtml} - #except EParseError: - # c.errorMsg = getCurrentExceptionMsg() - #end - ${xmlEncode(date)} -
-
-
-
-#end proc -# -# -#proc genPostsList(c: var TForumData, threadId: string, count: var int): string = -# let query = sql("""select p.id, u.name, p.header, p.content, p.creation, p.author, u.email from post p, -# person u -# where u.id = p.author and p.thread = ? and $# -# and (u.status <> 'Spammer' or p.author = ?) -# order by p.id limit ?, ?""" % -# (if c.rank >= Moderator: "(1 or u.id = ?)" else: "(u.status <> 'Moderated' or p.author = ?)")) -# const postId = 0 -# const userName = 1 -# const postHeader = 2 -# const postContent = 3 -# const postCreation = 4 -# const postAuthor = 5 -# const userEmail = 6 -# result = "" -# count = 0 -# let posts = getAllRows(db, query, threadId, c.userId, c.userId, $((c.pageNum-1) * PostsPerPage), $PostsPerPage) -# if posts.len < 1: return "" -# end if -
-
-
- forum index > - ${posts[0][postHeader]} -
-
-
-
-# for row in posts: -# inc(count) - -
-
-
- #let profileUrl = c.req.makeUri("profile/", false) & xmlEncode(%userName) -
${genGravatar(%userEmail)}
- ${xmlEncode(%userName)} - #if c.userId == %postAuthor and c.currentPost.subject.len == 0: -
Edit post - #elif c.rank >= Moderator and c.currentPost.subject.len == 0: -
Edit post - #end if -
-
-
-
- #try: - ${(%postContent).rstToHtml} - #except EParseError: - # c.errorMsg = getCurrentExceptionMsg() - #end - ${xmlEncode(%postCreation)} -
-
-
-# end for -
-#end proc -# -#proc genMarkHelp(): string -#end proc -#proc genFormPost(c: var TForumData, action: string, -# topText, title, content: string, isEdit: bool): string = -# result = "" -
- -
-
-
-
- forum index > - $topText -
-
-
-
- #if action == "doreply": - ${hiddenField(c, "subject", title)} - #else: - ${fieldValid(c, "subject", "Subject:")} - ${textWidget(c, "subject", title, maxlength=100)} -
- #end if - ${fieldValid(c, "content", "Content:")}
- ${textAreaWidget(c, "content", content)}
- ${formSession(c, action)} - - # if isEdit: - Delete Post
- # end if - #if c.errorMsg != "": -
- $c.errorMsg -
- #end if -
- - - - - ${genMarkHelp()} -
-
-#end proc -# -# -#proc genFormRegister(c: var TForumData): string = -# result = "" -
-
-
- forum index > - Register -
-
-
-
- - - - - - - - - - - - - - - - - -
${fieldValid(c, "name", "Username:")}${textWidget(c, "name", reuseText, maxlength=20)}
${fieldValid(c, "new_password", "Password:")}
${fieldValid(c, "email", "E-Mail:")}${textWidget(c, "email", reuseText, maxlength=300)}
${fieldValid(c, "antibot", "What is " & antibot(c) & "?")}${textWidget(c, "antibot", "", maxlength=4)}
- #if c.errorMsg != "": -
- $c.errorMsg -
- #end if - -
-#end proc -# -#proc genFormSetRank(c: var TForumData; ui: TUserInfo): string = -# result = "" -
- - - - - - - - - -
Reason${textWidget(c, "reason", ui.ban, maxlength=100)}
Rank
- -
-#end proc -# -#proc genFormLogin(c: var TForumData): string = -# result = "" -# if not c.loggedIn: -
- - - -
Username: -
Password: -
- -
- $c.loginErrorMsg -# else: - You're already logged in! -# end if -#end proc -# -# -#proc genListOnline(c: var TForumData, stats: TForumStats): string = -# result = "" -# var active: seq[string] = @[] -# for i in stats.activeUsers: -# active.add(i.nick) -# end for -# let profileUrl = c.req.makeUri("profile/", false) & -# xmlEncode(stats.newestMember.nick) - - ${stats.activeUsers.len} of ${stats.totalUsers} users online  |  - ${stats.totalThreads} threads  |  ${stats.totalPosts} posts  |  - newest member: ${stats.newestMember.nick} -#end proc -# -# -# -# -#proc genSearchResults(c: var TForumData, -# results: iterator: db_sqlite.Row {.closure, tags: [ReadDbEffect].}, -# count: var int): string = -# const threadId = 0 -# const threadName = 1 -# const postId = 2 -# const postHeader = 3 -# const postContent = 4 -# const userName = 5 -# const postCreation = 6 -# const postAuthor = 7 -# const userEmail = 8 -# const what = 9 -# result = "" -# count = 0 -# var whCount: array[bool, int] -
-
-
- Search results for: ${xmlEncode(c.search.replace(""","\""))}. -
-
-
-
-# for row in results(): -# inc(count) -# let isThread = %what == "0" -# inc(whCount[isThread]) -# let postUrl = c.genThreadUrl(%postId,"",%threadId,"") -# let threadUrl = c.genThreadUrl("","",%threadId) -# var headersDiffer = false -
-
-
- #let profileUrl = c.req.makeUri("profile/", false) & xmlEncode(%userName) -
${genGravatar(%userEmail, 40)}
-
${xmlEncode(%userName)}
- #if c.userId == %postAuthor and c.currentPost.subject.len == 0: -
Edit post - #elif c.rank >= Moderator and c.currentPost.subject.len == 0: -
Edit post - #end if -
-
-
-
- #if %postHeader != "": - - #end if - #if not isThread: - #try: - ${(%postContent).rstToHtml} - #except EParseError: - # c.errorMsg = getCurrentExceptionMsg() - ${xmlEncode(%postContent)} - #end - #end if - ${xmlEncode(%postCreation)} -
-
-
-# end for -
-# if c.pageNum > 1: -
- - -
-# end if -# if whCount[true] == ThreadsPerPage or whCount[false] == ThreadsPerPage: -
- - -
-# end if -#end proc -# -# -#proc genFormResetPassword(c: var TForumData): string = -# result = "" -
-
-
- forum index > - Reset Password -
-
-
-
- - - - - - - - - -
${fieldValid(c, "nick", "Your nickname:")}
${fieldValid(c, "antibot", "What is " & antibot(c) & "?")}${textWidget(c, "antibot", "", maxlength=4)}
- #if c.errorMsg != "": -
- $c.errorMsg -
- #end if - -
-#end proc -#proc genMarkHelp(): string = -#result = "" -
-

nimforum uses a slightly-customized version of - reStructuredText for formatting. See below for some basics, or check - this link for a more detailed help reference.

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
you type: - you see: -
*italics*italics -
**bold**bold -
`nim! <http://nim-lang.org>`_nim! -
* item 1 -
* item 2 -
* item 3
-
    -
  • item 1
  • -
  • item 2
  • -
  • item 3
  • -
-
> quoted text -
quoted text
-
The forum supports the Github Markdown syntax -
for code listings: -
-
```nim -
if 1 * 2 < 3: -
  echo "hello, world!" -
``` -
-
The forum supports the Github Markdown syntax -
for code listings: -
-
-if 1*2 < 3:
-  echo "hello, world!"
-                    
-
A horizontal rule can be created
----
but it needs text after it
A horizontal rule can be created
but it needs text after it
-
-#end proc diff --git a/forum.nim b/forum.nim deleted file mode 100644 index 1b29cdf..0000000 --- a/forum.nim +++ /dev/null @@ -1,1353 +0,0 @@ -# -# -# The Nim Forum -# (c) Copyright 2012 Andreas Rumpf, Dominik Picheta -# Look at license.txt for more info. -# All rights reserved. -# - -import - os, strutils, times, md5, strtabs, cgi, math, db_sqlite, - captchas, scgi, jester, asyncdispatch, asyncnet, cache, sequtils, - parseutils, utils, random, rst, ranks - -when not defined(windows): - import bcrypt # TODO - -from htmlgen import tr, th, td, span, input - -const - unselectedThread = -1 - transientThread = 0 - - ThreadsPerPage = 15 - PostsPerPage = 10 - MaxPagesFromCurrent = 8 - noPageNums = ["/login", "/register", "/dologin", "/doregister", "/profile"] - noHomeBtn = ["/", "/login", "/register", "/dologin", "/doregister", "/profile"] - -type - TCrud = enum crCreate, crRead, crUpdate, crDelete - - TSession = object of RootObj - threadid: int - postid: int - userName, userPass, email: string - rank: Rank - - TPost = tuple[subject, content: string] - - TForumData = object of TSession - req: Request - userid: string - actionContent: string - errorMsg, loginErrorMsg: string - invalidField: string - currentPost: TPost ## Only used for reply previews - startTime: float - isThreadsList: bool - pageNum: int - totalPosts: int - search: string - noPagenumumNav: bool - config: Config - - TStyledButton = tuple[text: string, link: string] - - TForumStats = object - totalUsers: int - totalPosts: int - totalThreads: int - newestMember: tuple[nick: string, id: int] - activeUsers: seq[tuple[nick: string, id: int]] - - TUserInfo = object - nick: string - posts: int - threads: int - lastOnline: int - email: string - ban: string - rank: Rank - - ForumError = object of Exception - -var - db: DbConn - isFTSAvailable: bool - config: Config - -proc init(c: var TForumData) = - c.userPass = "" - c.userName = "" - c.threadId = unselectedThread - c.postId = -1 - - c.userid = "" - c.actionContent = "" - c.errorMsg = "" - c.loginErrorMsg = "" - c.invalidField = "" - c.currentPost = (subject: "", content: "") - - c.search = "" - -proc loggedIn(c: TForumData): bool = - result = c.userName.len > 0 - -# --------------- HTML widgets ------------------------------------------------ - -# for widgets "" means the empty string as usual; should the old value be -# used again, pass `reuseText` instead: -const - reuseText = "\1" - -proc textWidget(c: TForumData, name, defaultText: string, - maxlength = 30, size = -1): string = - let x = if defaultText != reuseText: defaultText - else: xmlEncode(c.req.params.getOrDefault(name)) - return """""" % [ - name, $maxlength, x, if size != -1: "size=\"" & $size & "\"" else: ""] - -proc hiddenField(c: TForumData, name, defaultText: string): string = - let x = xmlencode( - if defaultText != reuseText: defaultText - else: c.req.params.getOrDefault(name) - ) - return """""" % [name, x] - -proc textAreaWidget(c: TForumData, name, defaultText: string): string = - let x = if defaultText != reuseText: defaultText - else: xmlEncode(c.req.params.getOrDefault(name)) - return """""" % [ - name, x] - -proc fieldValid(c: TForumData, name, text: string): string = - if name == c.invalidField: - result = """$1""" % text - else: - result = text - -proc genThreadUrl(c: TForumData, postId = "", action = "", threadid = "", pageNum = ""): string = - result = "/t/" & (if threadid == "": $c.threadId else: threadid) - if pageNum != "": - result.add("/" & pageNum) - if action != "": - result.add("?action=" & action) - if postId != "": - result.add("&postid=" & postid) - elif postId != "": - result.add("#" & postId) - result = c.req.makeUri(result, absolute = false) - -proc formSession(c: var TForumData, nextAction: string): string = - return """ - """ % [ - $c.threadId, $c.postid] - -proc urlButton(c: var TForumData, text, url: string): string = - return ("""$2""") % [ - url, text] - -proc genButtons(c: var TForumData, btns: seq[TStyledButton]): string = - if btns.len == 1: - var anchor = "" - - result = ("""$2""") % [ - btns[0].link, btns[0].text, anchor] - else: - result = "" - for i, btn in pairs(btns): - var anchor = "" - - var class = "" - if i == 0: class = "left " - elif i == btns.len()-1: class = "right " - else: class = "middle " - result.add(("""$2""") % [ - btns[i].link, btns[i].text, class, anchor]) - -proc toInterval(diff: int64): TimeInterval = - var remaining = diff - let years = remaining div 31536000 - remaining -= years * 31536000 - let months = remaining div 2592000 - remaining -= months * 2592000 - let days = remaining div 86400 - remaining -= days * 86400 - let hours = remaining div 3600 - remaining -= hours * 3600 - let minutes = remaining div 60 - remaining -= minutes * 60 - result = initInterval(0, remaining.int, minutes.int, hours.int, days.int, - months.int, years.int) - -proc formatTimestamp(t: int): string = - let t2 = Time(t) - let now = getTime() - let diff = (now - t2).toInterval() - if diff.years > 0: - return getGMTime(t2).format("MMMM d',' yyyy") - elif diff.months > 0: - return $diff.months & (if diff.months > 1: " months ago" else: " month ago") - elif diff.days > 0: - return $diff.days & (if diff.days > 1: " days ago" else: " day ago") - elif diff.hours > 0: - return $diff.hours & (if diff.hours > 1: " hours ago" else: " hour ago") - elif diff.minutes > 0: - return $diff.minutes & - (if diff.minutes > 1: " minutes ago" else: " minute ago") - else: - return "just now" - -proc getGravatarUrl(email: string, size = 80): string = - let emailMD5 = email.toLowerAscii.toMD5 - return ("http://www.gravatar.com/avatar/" & $emailMD5 & "?s=" & $size & - "&d=identicon") - -proc genGravatar(email: string, size: int = 80): string = - result = "" % - [$size, $size, getGravatarUrl(email, size)] - -proc randomSalt(): string = - result = "" - for i in 0..127: - var r = random(225) - if r >= 32 and r <= 126: - result.add(chr(random(225))) - -proc devRandomSalt(): string = - when defined(posix): - result = "" - var f = open("/dev/urandom") - var randomBytes: array[0..127, char] - discard f.readBuffer(addr(randomBytes), 128) - for i in 0..127: - if ord(randomBytes[i]) >= 32 and ord(randomBytes[i]) <= 126: - result.add(randomBytes[i]) - f.close() - else: - result = randomSalt() - -proc makeSalt(): string = - ## Creates a salt using a cryptographically secure random number generator. - ## - ## Ensures that the resulting salt contains no ``\0``. - try: - result = devRandomSalt() - except IOError: - result = randomSalt() - - var newResult = "" - for i in 0 .. 0 - -proc antibot(c: var TForumData): string = - let a = random(10)+1 - let b = random(1000)+1 - let answer = $(a+b) - - exec(db, sql"delete from antibot where ip = ?", c.req.ip) - let captchaId = tryInsertID(db, - sql"insert into antibot(ip, answer) values (?, ?)", c.req.ip, - answer).int mod 10_000 - let captchaFile = getCaptchaFilename(captchaId) - createCaptcha(captchaFile, $a & "+" & $b) - result = """""" % c.req.getCaptchaUrl(captchaId) - -const - SecureChars = {'A'..'Z', 'a'..'z', '0'..'9', '_', '\128'..'\255'} - -proc setError(c: var TForumData, field, msg: string): bool {.inline.} = - c.invalidField = field - c.errorMsg = "Error: " & msg - return false - -proc isCaptchaCorrect(c: var TForumData, antibot: string): bool = - ## Determines whether the user typed in the captcha correctly. - let correctRes = getValue(db, - sql"select answer from antibot where ip = ?", c.req.ip) - return antibot == correctRes - -proc register(c: var TForumData, name, pass, antibot, - email: string): bool = - # Username validation: - if name.len == 0 or not allCharsInSet(name, SecureChars): - return setError(c, "name", "Invalid username!") - if getValue(db, sql"select name from person where name = ?", name).len > 0: - return setError(c, "name", "Username already exists!") - - # Password validation: - if pass.len < 4: - return setError(c, "new_password", "Invalid password!") - - # captcha validation: - if not isCaptchaCorrect(c, antibot): - return setError(c, "antibot", "Answer to captcha incorrect!") - - # email validation - if not ('@' in email and '.' in email): - return setError(c, "email", "Invalid email address") - - # perform registration: - var salt = makeSalt() - let password = makePassword(pass, salt) - - # Send activation email. - let epoch = $int(epochTime()) - let activateUrl = c.req.makeUri("/activateEmail?nick=$1&epoch=$2&ident=$3" % - [encodeUrl(name), encodeUrl(epoch), - encodeUrl(makeIdentHash(name, password, epoch, salt))]) - - let emailSentFut = sendEmailActivation(c.config, email, name, activateUrl) - # Block until we send the email. - # TODO: This is a workaround for 'var T' not being usable in async procs. - while not emailSentFut.finished: - poll() - when not defined(dev): - if emailSentFut.failed: - echo("[WARNING] Couldn't send activation email: ", emailSentFut.error.msg) - return setError(c, "email", "Couldn't send activation email") - - # add account to person table - exec(db, - sql("INSERT INTO person(name, password, email, salt, status, lastOnline) " & - "VALUES (?, ?, ?, ?, ?, DATETIME('now'))"), name, - password, email, salt, - when defined(dev): $Moderated else: $EmailUnconfirmed) - - return true - -proc resetPassword(c: var TForumData, nick, antibot: string): bool = - # Validate captcha - if not isCaptchaCorrect(c, antibot): - return setError(c, "antibot", "Answer to captcha incorrect!") - # Gather some extra information to determine ident hash. - let epoch = $int(epochTime()) - let row = db.getRow( - sql"select password, salt, email from person where name = ?", nick) - if row[0] == "": - return setError(c, "nick", "Nickname not found") - # Generate URL for the email. - # TODO: Get rid of the stupid `%` in main.tmpl as it screws up strutils.% - let resetUrl = c.req.makeUri( - strutils.`%`("/emailResetPassword?nick=$1&epoch=$2&ident=$3", - [encodeUrl(nick), encodeUrl(epoch), - encodeUrl(makeIdentHash(nick, row[0], epoch, row[1]))])) - echo "User's reset URL is: ", resetUrl - # Send the email. - let emailSentFut = sendPassReset(c.config, row[2], nick, resetUrl) - # TODO: This is a workaround for 'var T' not being usable in async procs. - while not emailSentFut.finished: - poll() - if emailSentFut.failed: - echo("[WARNING] Couldn't send activation email: ", emailSentFut.error.msg) - return setError(c, "email", "Couldn't send activation email") - - return true - -proc logout(c: var TForumData) = - const query = sql"delete from session where ip = ? and password = ?" - c.username = "" - c.userpass = "" - exec(db, query, c.req.ip, c.req.cookies["sid"]) - -proc getBanErrorMsg(banValue: string; rank: Rank): string = - if banValue.len > 0: - return "You have been banned: " & banValue - case rank - of Spammer: return "You are a spammer." - of Troll: return "You have been banned." - of EmailUnconfirmed: - return "You need to confirm your email first." - of Moderated, User, Moderator, Admin: - return "" - -proc checkLoggedIn(c: var TForumData) = - if not c.req.cookies.hasKey("sid"): return - let pass = c.req.cookies["sid"] - if execAffectedRows(db, - sql("update session set lastModified = DATETIME('now') " & - "where ip = ? and password = ?"), - c.req.ip, pass) > 0: - c.userpass = pass - c.userid = getValue(db, - sql"select userid from session where ip = ? and password = ?", - c.req.ip, pass) - - let row = getRow(db, - sql"select name, email, status, ban from person where id = ?", c.userid) - c.username = ||row[0] - c.email = ||row[1] - c.rank = parseEnum[Rank](||row[2]) - let ban = getBanErrorMsg(||row[3], c.rank) - if ban.len > 0: - discard c.setError("name", ban) - logout(c) - return - - # Update lastOnline - db.exec(sql"update person set lastOnline = DATETIME('now') where id = ?", - c.userid) - - else: - echo("SID not found in sessions. Assuming logged out.") - -proc incrementViews(c: var TForumData) = - const query = sql"update thread set views = views + 1 where id = ?" - exec(db, query, $c.threadId) - -proc isPreview(c: TForumData): bool = - result = c.req.params.hasKey("previewBtn") - -proc isDelete(c: TForumData): bool = - result = c.req.params.hasKey("delete") - -proc validateRst(c: var TForumData, content: string): bool = - result = true - try: - discard rstToHtml(content) - except EParseError: - result = setError(c, "", getCurrentExceptionMsg()) - -proc crud(c: TCrud, table: string, data: varargs[string]): SqlQuery = - case c - of crCreate: - var fields = "insert into " & table & "(" - var vals = "" - for i, d in data: - if i > 0: - fields.add(", ") - vals.add(", ") - fields.add(d) - vals.add('?') - result = sql(fields & ") values (" & vals & ")") - of crRead: - var res = "select " - for i, d in data: - if i > 0: res.add(", ") - res.add(d) - result = sql(res & " from " & table) - of crUpdate: - var res = "update " & table & " set " - for i, d in data: - if i > 0: res.add(", ") - res.add(d) - res.add(" = ?") - result = sql(res & " where id = ?") - of crDelete: - result = sql("delete from " & table & " where id = ?") - -template retrSubject(c: untyped) = - if not c.req.params.hasKey("subject"): - raise newException(ForumError, "Subject empty") - let subject {.inject.} = c.req.params["subject"] - if subject.strip.len < 3: - return setError(c, "subject", "Subject not long enough") - -template retrContent(c: untyped) = - if not c.req.params.hasKey("content"): - raise newException(ForumError, "Content empty") - let content {.inject.} = c.req.params["content"] - if content.strip.len < 2: - return setError(c, "content", "Content not long enough") - - if not validateRst(c, content): return false - -template retrPost(c: untyped) = - retrSubject(c) - retrContent(c) - -template checkLogin(c: untyped) = - if not loggedIn(c): return setError(c, "", "User is not logged in") - -template checkOwnership(c, postId: untyped) = - if c.rank < Moderator: - let x = getValue(db, sql"select author from post where id = ?", - postId) - if x != c.userId: - return setError(c, "", "You are not the owner of this post") - -template setPreviewData(c: untyped) {.dirty.} = - c.currentPost.subject = subject - c.currentPost.content = content - -template writeToDb(c, cr, setPostId: untyped) = - # insert a comment in the DB - let retID = insertID(db, crud(cr, "post", "author", "ip", "header", "content", "thread"), - c.userId, c.req.ip, subject, content, $c.threadId, "") - discard tryExec(db, crud(cr, "post_fts", "id", "header", "content"), - retID.int, subject, content) - if setPostId: - c.postId = retID.int - -proc updateThreads(c: var TForumData): int = - ## Removes threads if they have no posts, or changes their modified field - ## if they still contain posts. - const query = - sql"delete from thread where id not in (select thread from post)" - result = execAffectedRows(db, query).int - if result > 0: - discard tryExec(db, sql"delete from thread_fts where id not in (select thread from post)") - else: - # Update corresponding thread's modified field. - let getModifiedSql = "(select creation from post where post.thread = ?" & - " order by creation desc limit 1)" - let updateSql = sql("update thread set modified=" & getModifiedSql & - " where id = ?") - if not tryExec(db, updateSql, $c.threadId, $c.threadId): - result = -1 - discard setError(c, "", "database error") - -proc edit(c: var TForumData, postId: int): bool = - checkLogin(c) - if c.isPreview: - retrPost(c) - setPreviewData(c) - elif c.isDelete: - checkOwnership(c, $postId) - if not tryExec(db, crud(crDelete, "post"), $postId): - return setError(c, "", "database error") - discard tryExec(db, crud(crDelete, "post_fts"), $postId) - result = true - # delete corresponding thread: - let updateResult = updateThreads(c) - if updateResult > 0: - # whole thread has been deleted, so: - c.threadId = unselectedThread - elif updateResult < 0: - # error occurred - return false - else: - checkOwnership(c, $postId) - retrPost(c) - exec(db, crud(crUpdate, "post", "header", "content"), - subject, content, $postId) - exec(db, crud(crUpdate, "post_fts", "header", "content"), - subject, content, $postId) - # Check if post is the first post of the thread. - let rows = db.getAllRows(sql("select id, thread, creation from post " & - "where thread = ? order by creation asc"), $c.threadId) - if rows[0][0] == $postId: - exec(db, crud(crUpdate, "thread", "name"), subject, $c.threadId) - result = true - -proc gatherUserInfo(c: var TForumData, nick: string, ui: var TUserInfo): bool -proc spamCheck(c: var TForumData, subject, content: string): bool = - # Check current user's info - var ui: TUserInfo - if gatherUserInfo(c, c.userName, ui): - if ui.posts > 1: return - - # Strip all punctuation - var subjAlphabet = "" - for i in subject: - if i in Letters: - subjAlphabet.add(i) - case i - of '!': - subjAlphabet.add("i") - else: discard - var contentAlphabet = "" - for i in content: - if i in Letters: - contentAlphabet.add(i) - case i - of '!': - subjAlphabet.add("i") - else: discard - - for word in ["appliance", "kitchen", "cheap", "sale", "relocating", - "packers", "lenders", "fifa", "coins"]: - if word in subjAlphabet.toLowerAscii() or - word in contentAlphabet.toLowerAscii(): - return true - -proc rateLimitCheck(c: var TForumData): bool = - const query40 = - sql("SELECT count(*) FROM post where author = ? and " & - "(strftime('%s', 'now') - strftime('%s', creation)) < 40") - const query90 = - sql("SELECT count(*) FROM post where author = ? and " & - "(strftime('%s', 'now') - strftime('%s', creation)) < 90") - const query300 = - sql("SELECT count(*) FROM post where author = ? and " & - "(strftime('%s', 'now') - strftime('%s', creation)) < 300") - # TODO Why can't I pass the secs as a param? - let last40s = getValue(db, query40, c.userId).parseInt - let last90s = getValue(db, query90, c.userId).parseInt - let last300s = getValue(db, query300, c.userId).parseInt - if last40s > 1: return true - if last90s > 2: return true - if last300s > 6: return true - return false - -proc makeThreadURL(c: var TForumData): string = - c.req.makeUri("/t/" & $c.threadId) - -template postChecks() {.dirty.} = - if spamCheck(c, subject, content): - echo("[WARNING] Found spam: ", subject) - return true - if rateLimitCheck(c): - return setError(c, "subject", "You're posting too fast.") - -proc reply(c: var TForumData): bool = - # reply to an existing thread - checkLogin(c) - retrPost(c) - if c.isPreview: - setPreviewData(c) - else: - postChecks() - writeToDb(c, crCreate, true) - - exec(db, sql"update thread set modified = DATETIME('now') where id = ?", - $c.threadId) - if c.rank >= User: - asyncCheck sendMailToMailingList(c.config, c.username, c.email, - subject, content, threadId=c.threadId, postId=c.postID, is_reply=true, - threadUrl=c.makeThreadURL()) - result = true - -proc newThread(c: var TForumData): bool = - # create new conversation thread (permanent or transient) - const query = sql"insert into thread(name, views, modified) values (?, 0, DATETIME('now'))" - checkLogin(c) - retrPost(c) - if c.isPreview: - setPreviewData(c) - c.threadID = transientThread - else: - postChecks() - c.threadID = tryInsertID(db, query, c.req.params["subject"]).int - if c.threadID < 0: return setError(c, "subject", "Subject already exists") - discard tryExec(db, crud(crCreate, "thread_fts", "id", "name"), - c.threadID, c.req.params["subject"]) - writeToDb(c, crCreate, false) - discard tryExec(db, sql"insert into post_fts(post_fts) values('optimize')") - discard tryExec(db, sql"insert into post_fts(thread_fts) values('optimize')") - if c.rank >= User: - asyncCheck sendMailToMailingList(c.config, c.username, c.email, - subject, content, threadId=c.threadID, postId=c.postID, is_reply=false, - threadUrl=c.makeThreadURL()) - result = true - -proc login(c: var TForumData, name, pass: string): bool = - # get form data: - const query = - sql"select id, name, password, email, salt, status, ban from person where name = ?" - if name.len == 0: - return c.setError("name", "Username cannot be nil.") - var success = false - for row in fastRows(db, query, name): - if row[2] == makePassword(pass, row[4], row[2]): - c.rank = parseEnum[Rank](row[5]) - let ban = getBanErrorMsg(row[6], c.rank) - if ban.len > 0: - return c.setError("name", ban) - c.userid = row[0] - c.username = row[1] - c.userpass = row[2] - c.email = row[3] - success = true - break - if success: - # create session: - exec(db, - sql"insert into session (ip, password, userid) values (?, ?, ?)", - c.req.ip, c.userpass, c.userid) - return true - else: - return c.setError("password", "Login failed!") - -proc verifyIdentHash(c: var TForumData, name, epoch, ident: string): bool = - const query = - sql"select password, salt, strftime('%s', lastOnline) from person where name = ?" - var row = getRow(db, query, name) - if row[0] == "": return false - let newIdent = makeIdentHash(name, row[0], epoch, row[1], ident) - # Check that the user has not been logged in since this ident hash has been - # created. Give the timestamp a certain range to prevent false negatives. - if row[2].parseInt > (epoch.parseInt + 60): return false - result = newIdent == ident - -proc deleteAll(c: var TForumData, nick: string): bool = - const query = - sql("delete from post where author = (select id from person where name = ?)") - result = tryExec(db, query, nick) - result = result and updateThreads(c) >= 0 - -proc setStatus(c: var TForumData, nick: string, status: Rank; - reason: string): bool = - const query = - sql("update person set status = ?, ban = ? where name = ?") - result = tryExec(db, query, $status, reason, nick) - when false: - # for now we filter Spammers in forms.tmpl, so that a moderator - # cannot accidentically delete all of a user's posts. We go even - # further than that and show spammers their own spam postings. - if status == Spammer and result: - result = deleteAll(c, nick) - -proc setPassword(c: var TForumData, nick, pass: string): bool = - const query = - sql("update person set password = ?, salt = ? where name = ?") - var salt = makeSalt() - result = tryExec(db, query, makePassword(pass, salt), salt, nick) - -proc hasReplyBtn(c: var TForumData): bool = - result = c.req.pathInfo != "/donewthread" and c.req.pathInfo != "/doreply" - result = result and c.req.params.getOrDefault("action") notin ["reply", "edit"] - # If the user is not logged in and there are no page numbers then we shouldn't - # generate the div. - let pages = ceil(c.totalPosts / PostsPerPage).int - result = result and (pages > 1 or c.loggedIn) - return c.threadId >= 0 and result - -proc getStats(c: var TForumData, simple: bool): TForumStats = - const totalUsersQuery = - sql"select count(*) from person" - result.totalUsers = getValue(db, totalUsersQuery).parseInt - const totalPostsQuery = - sql"select count(*) from post" - result.totalPosts = getValue(db, totalPostsQuery).parseInt - const totalThreadsQuery = - sql"select count(*) from thread" - result.totalThreads = getValue(db, totalThreadsQuery).parseInt - if not simple: - var newestMemberCreation = 0 - result.activeUsers = @[] - result.newestMember = ("", -1) - const getUsersQuery = - sql"select id, name, strftime('%s', lastOnline), strftime('%s', creation) from person" - for row in fastRows(db, getUsersQuery): - let secs = if row[3] == "": 0 else: row[3].parseint - let lastOnlineSeconds = getTime() - Time(secs) - if lastOnlineSeconds < (60 * 5): # 5 minutes - result.activeUsers.add((row[1], row[0].parseInt)) - if row[3].parseInt > newestMemberCreation: - result.newestMember = (row[1], row[0].parseInt) - newestMemberCreation = row[3].parseInt - -proc genPagenumNav(c: var TForumData, stats: TForumStats): string = - result = "" - var - firstUrl = "" - prevUrl = "" - totalPages = 0 - lastUrl = "" - nextUrl = "" - - if c.isThreadsList: - firstUrl = c.req.makeUri("/") - prevUrl = c.req.makeUri(if c.pageNum == 1: "/" else: "/page/" & $(c.pageNum-1)) - totalPages = ceil(stats.totalThreads / ThreadsPerPage).int - lastUrl = c.req.makeUri("/page/" & $(totalPages)) - nextUrl = c.req.makeUri("/page/" & $(c.pageNum+1)) - else: - firstUrl = c.makeThreadURL() - if c.pageNum == 1: - prevUrl = firstUrl - else: - prevUrl = c.req.makeUri(firstUrl & "/" & $(c.pageNum-1)) - totalPages = ceil(c.totalPosts / PostsPerPage).int - lastUrl = c.req.makeUri(firstUrl & "/" & $(totalPages)) - nextUrl = c.req.makeUri(firstUrl & "/" & $(c.pageNum+1)) - - if totalPages <= 1: - return "" - - var firstTag = "" - var prevTag = "" - if c.pageNum == 1: - firstTag = span("<<") - prevTag = span("<••") - else: - firstTag = htmlgen.a(href=firstUrl, "<<") - prevTag = htmlgen.a(href=prevUrl, "<••") - prevTag.add(htmlgen.link(rel="previous", href=prevUrl)) - result.add(firstTag) - result.add(prevTag) - - # Numbers - var pages = "" # Tags - # cutting numbers to the left and to the right tp MaxPagesFromCurrent - let firstToShow = max(1, c.pageNum - MaxPagesFromCurrent) - let lastToShow = min(totalPages, c.pageNum + MaxPagesFromCurrent) - if firstToShow > 1: pages.add(span("...")) - for i in firstToShow .. lastToShow: - if i == c.pageNum: - pages.add(span($(i))) - else: - var pageUrl = "" - if c.isThreadsList: - pageUrl = c.req.makeUri("/page/" & $(i)) - else: - pageUrl = c.req.makeUri(firstUrl & "/" & $(i)) - - pages.add(htmlgen.a(href = pageUrl, $(i))) - if lastToShow < totalPages: pages.add(span("...")) - result.add(pages) - - # Right - var lastTag = "" - var nextTag = "" - if c.pageNum == totalPages: - lastTag = span(">>") - nextTag = span("••>") - else: - lastTag = htmlgen.a(href=lastUrl, ">>") - nextTag = htmlgen.a(href=nextUrl, "••>") - nextTag.add(htmlgen.link(rel="next",href=nextUrl)) - result.add(nextTag) - result.add(lastTag) - -proc gatherTotalPostsByID(c: var TForumData, thrid: int): int = - ## Gets the total post count of a thread. - result = getValue(db, sql"select count(*) from post where thread = ?", $thrid).parseInt - -proc gatherTotalPosts(c: var TForumData) = - if c.totalPosts > 0: return - # Gather some data. - const totalPostsQuery = - sql"select count(*) from post p, person u where u.id = p.author and p.thread = ?" - c.totalPosts = getValue(db, totalPostsQuery, $c.threadId).parseInt - -proc getPagesInThread(c: var TForumData): int = - c.gatherTotalPosts() # Get total post count - result = ceil(c.totalPosts / PostsPerPage).int-1 - -proc getPagesInThreadByID(c: var TForumData, thrid: int): int = - result = ceil(c.gatherTotalPostsByID(thrid) / PostsPerPage).int - -proc getThreadTitle(thrid: int, pageNum: int): string = - result = getValue(db, sql"select name from thread where id = ?", $thrid) - if pageNum notin {0,1}: - result.add(" - Page " & $pageNum) - -proc genPagenumLocalNav(c: var TForumData, thrid: int): string = - result = "" - const maxPostPages = 6 # Maximum links to pages shown. - const hmpp = maxPostPages div 2 - # 1 2 3 ... 10 11 12 - var currentThrURL = "/t/" & $thrid & "/" - let totalPagesInThread = c.getPagesInThreadByID(thrid) - if totalPagesInThread <= 1: return - var i = 1 - while i <= totalPagesInThread: - result.add(htmlgen.a(href=c.req.makeUri(currentThrURL & $i), $i)) - if i == hmpp and totalPagesInThread-i > hmpp: - result.add(span("...")) - # skip to the last 3 - i = totalPagesInThread-(hmpp-1) - else: - inc(i) - - result = htmlgen.span(class = "pages", result) - -proc gatherUserInfo(c: var TForumData, nick: string, ui: var TUserInfo): bool = - ui.nick = nick - const getUIDQuery = sql"select id from person where name = ?" - var uid = getValue(db, getUIDQuery, nick) - if uid == "": return false - result = true - const totalPostsQuery = - sql"select count(*) from post where author = ?" - ui.posts = getValue(db, totalPostsQuery, uid).parseInt - const totalThreadsQuery = - sql("select count(*) from thread where id in (select thread from post where" & - " author = ? and post.id in (select min(id) from post group by thread))") - - ui.threads = getValue(db, totalThreadsQuery, uid).parseInt - const lastOnlineQuery = - sql"""select strftime('%s', lastOnline), email, ban, status - from person where id = ?""" - let row = db.getRow(lastOnlineQuery, $uid) - ui.lastOnline = if row[0].len > 0: row[0].parseInt else: -1 - ui.email = row[1] - ui.ban = row[2] - ui.rank = parseEnum[Rank](row[3]) - -include "forms.tmpl" -include "main.tmpl" - -proc genProfile(c: var TForumData, ui: TUserInfo): string = - result = "" - - result.add(htmlgen.`div`(id = "talk-head", - htmlgen.`div`(class="info-post", - htmlgen.`div`( - htmlgen.a(href = c.req.makeUri("/"), - span(style = "font-weight: bold;", "forum index") - ), - " > " & ui.nick & "'s profile" - ) - ) - ) - ) - result.add(htmlgen.`div`(id = "avatar", genGravatar(ui.email, 250))) - let t2 = if ui.lastOnline != -1: getGMTime(Time(ui.lastOnline)) - else: getGMTime(getTime()) - - result.add(htmlgen.`div`(id = "info", - htmlgen.table( - tr( - th("Nickname"), - td(ui.nick) - ), - tr( - th("Threads"), - td($ui.threads) - ), - tr( - th("Posts"), - td($ui.posts) - ), - tr( - th("Last Online"), - td(if ui.lastOnline != -1: t2.format("dd/MM/yy HH':'mm 'UTC'") - else: "Never") - ), - tr( - th("Status"), - td($ui.rank) - ), - tr( - th(""), - td(if c.rank >= Moderator and c.rank > ui.rank: - c.genFormSetRank(ui) - else: "") - ), - tr( - th(""), - td(if c.rank >= Moderator: - htmlgen.a(href=c.req.makeUri("/deleteAll?nick=$1" % ui.nick), - "Delete all user's posts and threads") - else: "") - ), - ) - )) - - result = htmlgen.`div`(id = "profile", - htmlgen.`div`(id = "left", result)) - -proc prependRe(s: string): string = - result = if s.len == 0: - "" - elif s.startswith("Re:"): s - else: "Re: " & s - -template createTFD() = - var c {.inject.}: TForumData - init(c) - c.req = request - c.startTime = epochTime() - c.isThreadsList = false - c.pageNum = 1 - c.config = config - if request.cookies.len > 0: - checkLoggedIn(c) - -routes: - get "/": - createTFD() - c.isThreadsList = true - var count = 0 - let threadList = genThreadsList(c, count) - let data = genMain(c, threadList, - additionalHeaders = genRSSHeaders(c), showRssLinks = true) - resp data - - get "/threadActivity.xml": - createTFD() - c.isThreadsList = true - resp genThreadsRSS(c), "application/atom+xml" - - get "/postActivity.xml": - createTFD() - resp genPostsRSS(c), "application/atom+xml" - - get "/t/@threadid/?@page?/?@postid?/?": - createTFD() - parseInt(@"threadid", c.threadId, -1..1000_000) - - if c.threadId == unselectedThread: - # Thread has just been deleted. - redirect(uri("/")) - - if @"page".len > 0: - parseInt(@"page", c.pageNum, 0..1000_000) - if @"postid".len > 0: - parseInt(@"postid", c.postId, 0..1000_000) - cond(c.pageNum > 0) - var count = 0 - var pSubject = getThreadTitle(c.threadid, c.pageNum) - cond validThreadId(c) - gatherTotalPosts(c) - if (@"action").len > 0: - var title = "" - case @"action" - of "reply": - let subject = getValue(db, - sql"select header from post where id = (select max(id) from post where thread = ?)", - $c.threadId).prependRe - body = genPostsList(c, $c.threadId, count) - cond count != 0 - body.add genFormPost(c, "doreply", "Reply", subject, "", false) - title = "Replying to thread: " & pSubject - of "edit": - cond c.postId != -1 - const query = sql"select header, content from post where id = ?" - let row = getRow(db, query, $c.postId) - let header = ||row[0] - let content = ||row[1] - body = genFormPost(c, "doedit", "Edit", header, content, true) - title = "Editing post" - else: discard - resp c.genMain(body, title & " - Nim Forum") - else: - incrementViews(c) - let posts = genPostsList(c, $c.threadId, count) - cond count != 0 - resp genMain(c, posts, pSubject & " - Nim Forum") - - get "/page/?@page?/?": - createTFD() - c.isThreadsList = true - cond(@"page" != "") - parseInt(@"page", c.pageNum, 0..1000_000) - cond(c.pageNum > 0) - var count = 0 - let list = genThreadsList(c, count) - if count == 0: - pass() - resp genMain(c, list, "Page " & $c.pageNum & " - Nim Forum", - genRSSHeaders(c), showRssLinks = true) - - get "/profile/@nick/?": - createTFD() - cond(@"nick" != "") - var userinfo: TUserInfo - if gatherUserInfo(c, @"nick", userinfo): - resp genMain(c, c.genProfile(userinfo), - @"nick" & "'s profile - Nim Forum") - else: - halt() - - get "/login/?": - createTFD() - resp genMain(c, genFormLogin(c), "Log in - Nim Forum") - - get "/logout/?": - createTFD() - logout(c) - redirect(uri("/")) - - get "/register/?": - createTFD() - resp genMain(c, genFormRegister(c), "Register - Nim Forum") - - template readIDs() = - # Retrieve the threadid, postid and pagenum - if (@"threadid").len > 0: - parseInt(@"threadid", c.threadId, -1..1000_000) - if (@"postid").len > 0: - parseInt(@"postid", c.postId, -1..1000_000) - - template finishLogin() = - setCookie("sid", c.userpass, daysForward(7)) - redirect(uri("/")) - - template handleError(action: string, topText: string, isEdit: bool) = - if c.isPreview: - body.add genPostPreview(c, @"subject", @"content", - c.userName, $getGMTime(getTime())) - body.add genFormPost(c, action, topText, reuseText, reuseText, isEdit) - resp genMain(c, body(), "Nim Forum - " & - (if c.isPreview: "Preview" else: "Error")) - - post "/dologin": - createTFD() - if login(c, @"name", @"password"): - finishLogin() - else: - c.isThreadsList = true - var count = 0 - let threadList = genThreadsList(c, count) - let data = genMain(c, threadList, - additionalHeaders = genRSSHeaders(c), showRssLinks = true) - resp data - - post "/doregister": - createTFD() - if c.register(@"name", @"new_password", @"antibot", @"email"): - resp genMain(c, "You are now registered. You must now confirm your" & - " email address by clicking the link sent to " & @"email", - "Registration successful - Nim Forum") - else: - resp c.genMain(genFormRegister(c)) - - post "/donewthread": - createTFD() - if newThread(c): - redirect(uri("/")) - else: - body = "" - handleError("donewthread", "New thread", false) - - post "/doreply": - createTFD() - readIDs() - if reply(c): - redirect(c.genThreadUrl(pageNum = $(c.getPagesInThread+1)) & "#" & $c.postId) - else: - var count = 0 - if c.isPreview: - c.pageNum = c.getPagesInThread+1 - body = genPostsList(c, $c.threadId, count) - handleError("doreply", "Reply", false) - - post "/doedit": - createTFD() - readIDs() - if edit(c, c.postId): - redirect(c.genThreadUrl(postId = $c.postId, - pageNum = $(c.getPagesInThread+1))) - else: - body = "" - handleError("doedit", "Edit", true) - - get "/newthread/?": - createTFD() - resp genMain(c, genFormPost(c, "donewthread", "New thread", "", "", false), - "New Thread - Nim Forum") - - get "/deleteAll/?": - createTFD() - cond(@"nick" != "") - var formBody = "" - var del = false - var content = "" - formBody.add "" - content = htmlgen.p("Are you sure you wish to delete all " & - "the posts and threads created by ", htmlgen.b(@"nick"), "?") - content = content & htmlgen.form(action = c.req.makeUri("/dodeleteall"), - `method` = "POST", formBody) - resp genMain(c, content, "Delete all user's posts & threads - Nim Forum") - - post "/dodeleteall/?": - createTFD() - cond(@"nick" != "") - if c.rank < Moderator: - resp genMain(c, "You cannot delete this user's data.", "Error - Nim Forum") - let result = deleteAll(c, @"nick") - if result: - redirect(c.req.makeUri("/profile/" & @"nick")) - else: - resp genMain(c, "Failed to delete all user's posts and threads.", - "Error - NimForum") - - post "/dosetrank/?@nick?/?": - createTFD() - cond(@"nick" != "") - - if c.rank < Moderator: - resp genMain(c, "You cannot change this user's rank.", "Error - Nim Forum") - - var ui: TUserInfo - if not gatherUserInfo(c, @"nick", ui): - resp genMain(c, "User " & @"nick" & " does not exist.", "Error - Nim Forum") - let newRank = parseEnum[Rank](@"rank") - if newRank > c.rank: - resp genMain(c, "You cannot change this user's rank to this value.", "Error - Nim Forum") - - if setStatus(c, @"nick", newRank, @"reason"): - redirect(c.req.makeUri("/profile/" & @"nick")) - else: - resp genMain(c, "Failed to change the ban status of user.", - "Error - Nim Forum") - - get "/setpassword/?": - createTFD() - cond(@"nick" != "") - cond(@"pass" != "") - if c.rank < Moderator: - resp genMain(c, "You cannot change this user's pass.", "Error - Nim Forum") - let res = setPassword(c, @"nick", @"pass") - if res: - resp genMain(c, "Success", "Nim Forum") - else: - resp genMain(c, "Failure", "Nim Forum") - - get "/activateEmail/?": - createTFD() - cond(@"nick" != "") - cond(@"epoch" != "") - cond(@"ident" != "") - var epoch: BiggestInt = 0 - cond(parseBiggestInt(@"epoch", epoch) > 0) - var success = false - if verifyIdentHash(c, @"nick", $epoch, @"ident"): - let ban = parseEnum[Rank](db.getValue(sql"select status from person where name = ?", @"nick")) - if ban == EmailUnconfirmed: - success = setStatus(c, @"nick", Moderated, "") - - if success: - resp genMain(c, "Account activated", "Nim Forum") - else: - resp genMain(c, "Account activation failed", "Nim Forum") - - get "/emailResetPassword/?": - createTFD() - cond(@"nick" != "") - cond(@"epoch" != "") - cond(@"ident" != "") - var epoch: BiggestInt = 0 - cond(parseBiggestInt(@"epoch", epoch) > 0) - if verifyIdentHash(c, @"nick", $epoch, @"ident"): - let formBody = input(`type`="hidden", name="nick", value = @"nick") & - input(`type`="hidden", name="epoch", value = @"epoch") & - input(`type`="hidden", name="ident", value = @"ident") & - input(`type`="password", name="password") & - "
" & - input(`type`="submit", name="submitBtn", - value="Change my password") - let message = htmlgen.p("Please enter a new password for ", - htmlgen.b(@"nick"), ':') - let content = htmlgen.form(action=c.req.makeUri("/doemailresetpassword"), - `method`="POST", message & formBody) - - resp genMain(c, content, "Reset password - Nim Forum") - else: - resp genMain(c, "Invalid ident hash", "Error - Nim Forum") - - post "/doemailresetpassword": - createTFD() - cond(@"nick" != "") - cond(@"epoch" != "") - cond(@"ident" != "") - cond(@"password" != "") - var epoch: BiggestInt = 0 - cond(parseBiggestInt(@"epoch", epoch) > 0) - if verifyIdentHash(c, @"nick", $epoch, @"ident"): - let res = setPassword(c, @"nick", @"password") - if res: - resp genMain(c, "Password reset successfully!", "Nim Forum") - else: - resp genMain(c, "Password reset failure", "Nim Forum") - else: - resp genMain(c, "Invalid ident hash", "Nim Forum") - - get "/resetPassword/?": - createTFD() - - resp genMain(c, genFormResetPassword(c), "Reset Password - Nim Forum") - - post "/doresetpassword": - createTFD() - echo(request.params) - cond(@"nick" != "") - - if resetPassword(c, @"nick", @"antibot"): - resp genMain(c, "Email sent!", "Reset Password - Nim Forum") - else: - resp genMain(c, genFormResetPassword(c), "Reset Password - Nim Forum") - - const licenseRst = slurp("static/license.rst") - get "/license": - createTFD() - resp genMain(c, rstToHtml(licenseRst), "Content license - Nim Forum") - - post "/search/?@page?": - cond isFTSAvailable - createTFD() - c.isThreadsList = true - c.noPagenumumNav = true - var count = 0 - var q = @"q" - for i in 0 .. q.len-1: - if q[i].int < 32: q[i] = ' ' - elif q[i] == '\'': q[i] = '"' - c.search = q.replace("\"",""") - if @"page".len > 0: - parseInt(@"page", c.pageNum, 0..1000_000) - cond(c.pageNum > 0) - iterator searchResults(): db_sqlite.Row {.closure, tags: [ReadDbEffect].} = - const queryFT = "fts.sql".slurp.sql - for rowFT in fastRows(db, queryFT, - [q,q,$ThreadsPerPage,$c.pageNum,$ThreadsPerPage,q, - q,q,$ThreadsPerPage,$c.pageNum,$ThreadsPerPage,q]): - yield rowFT - resp genMain(c, genSearchResults(c, searchResults, count), - additionalHeaders = genRSSHeaders(c), showRssLinks = true) - - # tries first to read html, then to read rst, convert ot html, cache and return - template textPage(path: string) = - createTFD() - #c.isThreadsList = true - var page = "" - if existsFile(path): - page = readFile(path) - else: - let basePath = - if path[path.high] == '/': path & "index" - elif path.endsWith(".html"): path[^5 .. ^1] - else: path - if existsFile(basePath & ".html"): - page = readFile(basePath & ".html") - elif existsFile(basePath & ".rst"): - page = readFile(basePath & ".rst").rstToHtml - writeFile(basePath & ".html", page) - resp genMain(c, page) - get "/search-help": - textPage "static/search-help" - -when isMainModule: - randomize() - db = open(connection="nimforum.db", user="postgres", password="", - database="nimforum") - isFTSAvailable = db.getAllRows(sql("SELECT name FROM sqlite_master WHERE " & - "type='table' AND name='post_fts'")).len == 1 - config = loadConfig() - var http = true - if paramCount() > 0: - if paramStr(1) == "scgi": - http = false - - #run("", port = TPort(9000), http = http) - - runForever() - db.close() diff --git a/license.txt b/license.txt index cf4ac4a..3eb168b 100644 --- a/license.txt +++ b/license.txt @@ -1,4 +1,4 @@ -Copyright (C) 2015 Andreas Rumpf, Dominik Picheta +Copyright (C) 2018 Andreas Rumpf, Dominik Picheta Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in @@ -7,7 +7,7 @@ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in all +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR diff --git a/localhost.local/public/css/custom-style.scss b/localhost.local/public/css/custom-style.scss new file mode 100644 index 0000000..6c9e50c --- /dev/null +++ b/localhost.local/public/css/custom-style.scss @@ -0,0 +1,16 @@ +// Use this to customise the styles of your forum. +$primary-color: #6577ac; +$body-font-color: #292929; +$dark-color: #505050; +$label-color: #7cd2ff; +$secondary-btn-color: #f1f1f1; + +// Define nav bar colours. +$body-bg: #ffffff; +$navbar-color: $body-bg; +$navbar-border-color-dark: $body-bg; +$navbar-primary-color: #e80080; + +#main-navbar input#search-box { + border: 1px solid #e6e6e6; +} \ No newline at end of file diff --git a/localhost.local/public/images/logo.png b/localhost.local/public/images/logo.png new file mode 100644 index 0000000..4ac52b7 Binary files /dev/null and b/localhost.local/public/images/logo.png differ diff --git a/main.tmpl b/main.tmpl deleted file mode 100644 index 3590b8d..0000000 --- a/main.tmpl +++ /dev/null @@ -1,276 +0,0 @@ -#? stdtmpl | standard -#proc genMain(c: var TForumData, content: string, title = "Nim Forum", -# additional_headers = "", showRssLinks = false): string = -# result = "" -# var stats: TForumStats -# if c.isThreadsList: stats = c.getStats(false) -# else: -# stats = c.getStats(true) -# end if - - - - ${xmlEncode(title)} - - ${additional_headers} - - - - - #let frontQuery = c.req.makeUri("/") - - - -
-
-
-
-
-
-
-
-
- ${content} - #if c.isThreadsList: -
-
-
- ${c.genListOnline(stats)} -
-
-
- #if not c.noPagenumumNav: -
- ${genPagenumNav(c, stats)} -
- #end if - #elif hasReplyBtn(c): -
-
-
- ${genPagenumNav(c, stats)} -
-
-
- #if c.loggedIn(): - #let replyUri = c.req.makeUri(c.req.path & "?action=reply#reply") - -
- Reply -
-
- #end if -
-
- #end if - -
- -
-
- - - - - - - -#end proc -# -#proc genRSSHeaders(c: var TForumData): string = -# result = "" - - -#end proc -# -#proc genThreadsRSS(c: var TForumData): string = -# result = "" -# const query = sql"""SELECT A.id, A.name, -# strftime('%Y-%m-%dT%H:%M:%SZ', (A.modified)), -# COUNT(B.id), C.name, B.content, B.id -# FROM thread AS A, post AS B, person AS C -# WHERE A.id = b.thread AND B.author = C.id -# GROUP BY B.thread -# ORDER BY modified DESC LIMIT ?""" -# const threadId = 0 -# const name = 1 -# const threadDate = 2 -# const postCount = 3 -# const postAuthor = 4 -# const postContent = 5 -# const postId = 6 -# let frontQuery = c.req.makeUri("/") -# let recent = getValue(db, sql"""SELECT -# strftime('%Y-%m-%dT%H:%M:%SZ', (modified)) FROM thread -# ORDER BY modified DESC LIMIT 1""") - - - Nim forum thread activity - - - ${frontQuery} - ${recent} -# for row in rows(db, query, 10): - - ${xmlEncode(%name)} - urn:entry:${%threadid} - # let url = c.genThreadUrl(threadid = %threadid, - # pageNum = $(ceil(parseInt(%postCount) / PostsPerPage).int)) & - # "#" & %postId - - ${%threadDate} - ${%threadDate} - ${xmlEncode(%postAuthor)} - Posts ${%postCount}, ${xmlEncode(%postAuthor)} said: -<p> -${xmlEncode(rstToHtml(%postContent))} - -# end for - -#end proc -# -#proc genPostsRSS(c: var TForumData): string = -# result = "" -# const query = sql"""SELECT A.id, B.name, A.content, A.thread, -# A.header, strftime('%Y-%m-%dT%H:%M:%SZ', A.creation), -# A.creation, COUNT(C.id) -# FROM post AS A, person AS B, post AS C -# WHERE A.author = B.id AND A.thread = C.thread AND C.id <= A.id -# GROUP BY A.id -# ORDER BY A.creation DESC LIMIT ?""" -# const postId = 0 -# const postAuthor = 1 -# const postContent = 2 -# const postThread = 3 -# const postHeader = 4 -# const postRssDate = 5 -# const postHumanDate = 6 -# const postPosition = 7 -# let frontQuery = c.req.makeUri("/") -# let recent = getValue(db, sql"""SELECT -# strftime('%Y-%m-%dT%H:%M:%SZ', creation) FROM post -# ORDER BY creation DESC LIMIT 1""") - - - Nim forum post activity - - - ${frontQuery} - ${recent} -# for row in rows(db, query, 10): - - ${xmlEncode(%postHeader)} - urn:entry:${%postId} - # let url = c.genThreadUrl(threadid = %postThread, - # pageNum = $(ceil(parseInt(%postPosition) / PostsPerPage).int)) & - # "#" & %postId - - ${%postRssDate} - ${%postRssDate} - ${xmlEncode(%postAuthor)} - On ${xmlEncode(%postHumanDate)}, ${xmlEncode(%postAuthor)} said: -<p> -${xmlEncode(rstToHtml(%postContent))} - -# end for - -#end proc diff --git a/mockup/index.html b/mockup/index.html new file mode 100644 index 0000000..b836674 --- /dev/null +++ b/mockup/index.html @@ -0,0 +1,151 @@ + + + + + + + + + The Nim programming language forum + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
TopicCategoryUsersRepliesViewsActivity
Few mixed up questions
help
+
+ +
+
+ +
+
+
+
+
+
+
+
554745m
Lexers and parsers in Nim
community
+
+
+
01444m
I need help 2
help
+
+ +
+
+ +
+
+
+
41d
+ + last visit + +
Nim v1.0 is here!
announcement
+
+ +
+
+ +
+
44d
+ + load more threads + +
+
+ + + + \ No newline at end of file diff --git a/mockup/thread.html b/mockup/thread.html new file mode 100644 index 0000000..298bb80 --- /dev/null +++ b/mockup/thread.html @@ -0,0 +1,232 @@ + + + + + + + + + The Nim programming language forum + + + + + + + + + +
+
+

Lexers and parsers in nim

+
community +
+
+
+
+
+ Avatar +
+
+
+
+
+ ErikCampobadal +
+
Jan 2015
+
+
+

Hey! I'm willing to create a programming language using nim.

+ +

It's an educational project. Been reading about compilers for weeks now and I started using tools like flex and bison for lexer and parser. I know nim have a parsing library but nowhere near that level.

+ +

There is an old post (2014) with a similar question so I'm bringing that back a few years later. Is there anything anyone know that could speed up the process of developing a programing language using nim? (I can have c code if needed ofc)

+
+
+ +
+ +
+
+ +
+
+
+
+ +
+
+
+ Avatar +
+
+
+
+
+ twetzel59 +
+
Jan 2015
+
+
+

Wow, I was just reading about the compilation pipeline today!

+ +

I suppose you could use at least the lexing part from a generator like flex, not so sure about using AST generators easily (it's possible).

+ +

Is your language complicated enough to warrant a parser generator or could you just use a custom parser?

+
+
+ +
+ +
+
+ +
+
+
+
+ +
+
+ +
+
+
+ 3 years later +
+
+
+ +
+
+
+ Avatar +
+
+
+
+
+ dom96 +
+
32m
+
+
+

Let us test this new design a bit, shall we?

+
proc hello(x: int) =
+  echo("Hello ", x)
+
+42.hello()
Output
Hello 42
+ +

The greatest function ever written is hello.

+
+

Designing websites is often a pain.

+
Multi-level baby!
+

True that.

+

I also want to be able to support more detailed quoting:

+
+
+
+ Avatar +
+ Araq: + +
+ Unix is a cancer. +
+

We also want to be able to highlight user mentions:

+

Please let + + @Araq + + know that this forum is awesome.

+
+
+ +
+ +
+
+ +
+
+
+
+ + + +
+
+ +
+
+
+ Replying to "Lexers and parsers in nim" +
+
+ +
+
+
+ +
+
+ + + + \ No newline at end of file diff --git a/nimforum.nimble b/nimforum.nimble index 3591767..58a22f7 100644 --- a/nimforum.nimble +++ b/nimforum.nimble @@ -1,11 +1,64 @@ -[Package] -name = "nimforum" -version = "0.1.0" +# Package +version = "2.1.0" author = "Dominik Picheta" -description = "Nim forum" +description = "The Nim forum" license = "MIT" -bin = "forum" +srcDir = "src" -[Deps] -Requires: "nim >= 0.14.0, cairo#head, jester#head, bcrypt#head" +bin = @["forum"] + +skipExt = @["nim"] + +# Dependencies + +requires "nim >= 1.0.6" +requires "jester#405be2e" +requires "bcrypt#440c5676ff6" +requires "hmac#9c61ebe2fd134cf97" +requires "recaptcha#d06488e" +requires "sass#649e0701fa5c" + +requires "karax#5f21dcd" + +requires "webdriver#429933a" + +# Tasks + +task backend, "Compiles and runs the forum backend": + exec "nimble c src/forum.nim" + exec "./src/forum" + +task runbackend, "Runs the forum backend": + exec "./src/forum" + +task testbackend, "Runs the forum backend in test mode": + exec "nimble c -r -d:skipRateLimitCheck src/forum.nim" + +task frontend, "Builds the necessary JS frontend (with CSS)": + exec "nimble c -r src/buildcss" + exec "nimble js -d:release src/frontend/forum.nim" + mkDir "public/js" + cpFile "src/frontend/forum.js", "public/js/forum.js" + +task minify, "Minifies the JS using Google's closure compiler": + exec "closure-compiler public/js/forum.js --js_output_file public/js/forum.js.opt" + +task testdb, "Creates a test DB (with admin account!)": + exec "nimble c src/setup_nimforum" + exec "./src/setup_nimforum --test" + +task devdb, "Creates a test DB (with admin account!)": + exec "nimble c src/setup_nimforum" + exec "./src/setup_nimforum --dev" + +task blankdb, "Creates a blank DB": + exec "nimble c src/setup_nimforum" + exec "./src/setup_nimforum --blank" + +task test, "Runs tester": + exec "nimble c -y src/forum.nim" + exec "nimble c -y -r -d:actionDelayMs=0 tests/browsertester" + +task fasttest, "Runs tester without recompiling backend": + exec "nimble c -r -d:actionDelayMs=0 tests/browsertester" diff --git a/public/captchas/.gitignore b/public/captchas/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/public/captchas/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/public/css/nimforum.scss b/public/css/nimforum.scss new file mode 100644 index 0000000..2daecdb --- /dev/null +++ b/public/css/nimforum.scss @@ -0,0 +1,781 @@ +@import "custom-style"; + +// Import full Spectre source code +@import "spectre/src/spectre"; + +// Global styles. +// - TODO: Make these non-global. +.btn, .form-input { + margin-right: $control-padding-x; +} + +table th { + font-size: 0.65rem; +} + +// Spectre fixes. +// - Weird avatar outline. +.avatar { + background: transparent; +} + +// Custom styles. +// - Navigation bar. +$navbar-height: 60px; +$default-category-color: #a3a3a3; +$logo-height: $navbar-height - 20px; + +.navbar-button { + border-color: $navbar-border-color-dark; + background-color: $navbar-primary-color; + color: $navbar-color; + + &:focus { + box-shadow: none; + } + + &:hover { + background-color: darken($navbar-primary-color, 20%); + color: $navbar-color; + border-color: $navbar-border-color-dark; + } +} + +#main-navbar { + background-color: $navbar-color; + + .navbar { + height: $navbar-height; + } + + // Unfortunately we must colour the controls in the navbar manually. + .search-input { + @extend .form-input; + min-width: 120px; + border-color: $navbar-border-color-dark; + } + + .search-input:focus { + box-shadow: none; + border-color: $navbar-border-color-dark; + } + + .btn-primary { + @extend .navbar-button; + } + +} + +#img-logo { + vertical-align: middle; + height: $logo-height; +} + +.menu-right { + // To make sure the user menu doesn't move off the screen. + @media (max-width: 1600px) { + left: auto; + right: 0; + } + position: absolute; +} + +// - Main buttons +.btn-secondary { + background: $secondary-btn-color; + border-color: darken($secondary-btn-color, 5%); + color: invert($secondary-btn-color); + + margin-right: $control-padding-x*2; + + &:hover, &:focus { + background: darken($secondary-btn-color, 5%); + border-color: darken($secondary-btn-color, 10%); + + color: invert($secondary-btn-color); + } + + &:focus { + @include control-shadow(darken($secondary-btn-color, 40%)); + } +} + +#main-buttons { + margin-top: $control-padding-y*2; + margin-bottom: $control-padding-y*2; + + .dropdown > .btn { + @extend .btn-secondary; + } +} + +#category-selection { + .dropdown { + .btn { + margin-right: 0px; + } + } + .plus-btn { + margin-right: 0px; + i { + margin-right: 0px; + } + } +} + +.category-description { + opacity: 0.6; + font-size: small; +} + +.category-status { + font-size: small; + font-weight: bold; + + .topic-count { + margin-left: 5px; + opacity: 0.7; + font-size: small; + } +} + +.category { + white-space: nowrap; +} + +#new-thread { + .modal-container .modal-body { + max-height: none; + } + + .panel-body { + padding-top: $control-padding-y*2; + padding-bottom: $control-padding-y*2; + } + + .form-input[name='subject'] { + margin-bottom: $control-padding-y*2; + } + + textarea.form-input, .panel-body > div { + min-height: 40vh; + } + + .footer { + float: right; + margin-top: $control-padding-y*2; + } +} + +// - Thread table +.thread-title { + a, a:hover { + color: $body-font-color; + text-decoration: none; + } + + a.visited, a:visited { + color: lighten($body-font-color, 40%); + } + + i { + // Icon + margin-right: $control-padding-x-sm; + } +} + +.thread-list { + @extend .container; + @extend .grid-xl; +} + +.category-list { + @extend .thread-list; + + + .category-title { + @extend .thread-title; + a, a:hover { + color: lighten($body-font-color, 10%); + text-decoration: none; + } + } + + .category-description { + opacity: 0.6; + } +} + +#categories-list .category { + border-left: 6px solid; + border-left-color: $default-category-color; +} + +$super-popular-color: #f86713; +$popular-color: darken($super-popular-color, 25%); +$threads-meta-color: #545d70; + +.super-popular-text { + color: $super-popular-color; +} + +.popular-text { + color: $popular-color; +} + +.views-text { + color: $threads-meta-color; +} + +.label-custom { + color: white; + background-color: $label-color; + + font-size: 0.6rem; + padding-left: 0.3rem; + padding-right: 0.3rem; + border-radius: 5rem; +} + +.last-visit-separator { + td { + border-bottom: 1px solid $super-popular-color; + line-height: 0.1rem; + padding: 0; + text-align: center; + } + + span { + color: $super-popular-color; + padding: 0 8px; + font-size: 0.7rem; + background-color: $body-bg; + } +} + +.no-border { + td { + border: none; + } +} + +.category-color { + width: 0; + height: 0; + border: 0.25rem solid $default-category-color; + display: inline-block; + margin-right: 5px; +} + +.load-more-separator { + text-align: center; + color: darken($label-color, 35%); + background-color: lighten($label-color, 15%); + text-transform: uppercase; + font-weight: bold; + font-size: 80%; + cursor: pointer; + + td { + border: none; + padding: $control-padding-x $control-padding-y/2; + } +} + +// - Thread view +.title { + margin-top: $control-padding-y*2; + margin-bottom: $control-padding-y*2; + + p { + font-size: 1.4rem; + font-weight: bold; + + color: darken($dark-color, 20%); + + margin: 0; + } + + i.fas { + margin-right: $control-padding-x-sm; + } +} + +.thread-replies, .thread-time, .views-text, .popular-text, .centered-header { + text-align: center; +} + +.thread-users { + text-align: left; +} + +.thread-time { + color: $threads-meta-color; + + &.is-new { + @extend .text-success; + } + + &.is-old { + @extend .text-gray; + } + +} + +// Hide all the avatars but the first on small screens. +@media screen and (max-width: 600px) { + #threads-list a:not(:first-child) > .avatar { + display: none; + } +} + +.posts, .about { + @extend .grid-md; + @extend .container; + margin: 0; + padding: 0; + + margin-bottom: 10rem; // Just some empty space at the bottom. +} + +.post { + @extend .tile; + border-top: 1px solid $border-color; + padding-top: $control-padding-y-lg; + + &:target .post-main, &.highlight .post-main { + animation: highlight 2000ms ease-out; + } +} + +@keyframes highlight { + 0% { + background-color: lighten($primary-color, 20%); + } + 100% { + background-color: inherit; + } +} + +.post-icon { + @extend .tile-icon; +} + +.post-avatar { + @extend .avatar; + font-size: 1.6rem; + height: 2.5rem; + width: 2.5rem; +} + +.post-main { + @extend .tile-content; + + margin-bottom: $control-padding-y-lg*2; + // https://stackoverflow.com/a/41675912/492186 + flex: 1; + min-width: 0; +} + +.post-title { + margin-bottom: $control-padding-y*2; + + &, a, a:visited, a:hover { + color: lighten($body-font-color, 20%); + text-decoration: none; + } + + + .thread-title { + width: 100%; + + a > div { + display: inline-block; + } + } + + .post-username { + font-weight: bold; + display: inline-block; + + i { + margin-left: $control-padding-x; + } + } + + .post-metadata { + float: right; + + .post-replyingTo { + display: inline-block; + margin-right: $control-padding-x; + + i.fa-reply { + transform: rotate(180deg); + } + } + + .post-history { + display: inline-block; + margin-right: $control-padding-x; + + i { + font-size: 90%; + } + + .edit-count { + margin-right: $control-padding-x-sm/2; + } + } + } +} + +.post-content, .about { + img { + max-width: 100%; + } +} + +.post-buttons { + float: right; + + > div { + display: inline-block; + } + + .btn { + background: transparent; + border-color: transparent; + color: darken($secondary-btn-color, 40%); + + margin: 0; + margin-left: $control-padding-y-sm; + } + + .btn:hover { + background: $secondary-btn-color; + border-color: darken($secondary-btn-color, 5%); + color: invert($secondary-btn-color); + } + + .btn:focus { + @include control-shadow(darken($secondary-btn-color, 50%)); + } + + .btn:active { + box-shadow: inset 0 0 .4rem .01rem darken($secondary-btn-color, 80%); + } + + .like-button i:hover, .like-button i.fas { + color: #f783ac; + } + + .like-count { + margin-right: $control-padding-x-sm; + } +} + +#thread-buttons { + border-top: 1px solid $border-color; + width: 100%; + padding-top: $control-padding-y; + padding-bottom: $control-padding-y; + @extend .clearfix; + + .btn { + float: right; + margin-right: 0; + margin-left: $control-padding-x; + } +} + +blockquote { + border-left: 0.2rem solid darken($bg-color, 10%); + background-color: $bg-color; + + .detail { + margin-bottom: $control-padding-y; + color: lighten($body-font-color, 20%); + } +} + +.quote-avatar { + @extend .avatar; + @extend .avatar-sm; +} + +.quote-link { + float: right; +} + +.user-mention { + @extend .chip; + vertical-align: initial; + font-weight: bold; + display: inline-block; + font-size: 85%; + height: inherit; + padding: 0.08rem 0.4rem; + background-color: darken($bg-color-dark, 5%); + + img { + @extend .avatar; + @extend .avatar-sm; + } +} + +.code-buttons { + position: absolute; + bottom: 0; + right: 0; + + .btn-primary { + margin-bottom: $control-padding-y; + } +} + +.execution-result { + @extend .toast; + + h6 { + font-family: $base-font-family; + } +} + +.execution-success { + @extend .toast-success; +} + +.code { + // Don't show the "none". + &[data-lang="none"]::before { + content: ""; + } + + // &:not([data-lang="Nim"]) > .code-buttons { + // display: none; + // } +} +.code-buttons { + display: none; +} + +.post-content { + pre:not(.code) { + overflow: scroll; + } +} + +.information { + @extend .tile; + border-top: 1px solid $border-color; + padding-top: $control-padding-y-lg*2; + padding-bottom: $control-padding-y-lg*2; + color: lighten($body-font-color, 20%); + .information-title { + font-weight: bold; + } + + &.no-border { + border: none; + } +} + +.information-icon { + @extend .tile-icon; + + i { + width: $unit-16; + text-align: center; + font-size: 1rem; + + } +} + +.time-passed { + text-transform: uppercase; +} + +.load-more-posts { + text-align: center; + color: darken($label-color, 35%); + background-color: lighten($label-color, 15%); + border: none; + text-transform: uppercase; + font-weight: bold; + cursor: pointer; + + .information-main { + width: 100%; + text-align: left; + } + + .more-post-count { + color: rgba(darken($label-color, 35%), 0.5); + margin-right: $control-padding-x*2; + float: right; + } +} + +.form-input.post-text-area { + margin-top: $control-padding-y*2; + resize: vertical; +} + +#reply-box { + .panel { + margin-top: $control-padding-y*2; + } +} + +code { + color: $body-font-color; + background-color: $bg-color; +} + +tt { + @extend code; +} + +hr { + background: $border-color; + height: $border-width; + margin: $unit-2 0; + border: 0; +} + +.edit-box { + .edit-buttons { + margin-top: $control-padding-y*2; + + float: right; + + > div { + display: inline-block; + } + } + + .text-error { + margin-top: $control-padding-y*3; + display: inline-block; + } + + .form-input.post-text-area { + margin-bottom: $control-padding-y*2; + } +} + +@import "syntax.scss"; + +// - Profile view + +.profile { + @extend .tile; + margin-top: $control-padding-y*5; +} + +.profile-icon { + @extend .tile-icon; + margin-right: $control-padding-x; +} + +.profile-avatar { + @extend .avatar; + @extend .avatar-xl; + + height: 6.2rem; + width: 6.2rem; +} + +.profile-content { + @extend .tile-content; + padding: $control-padding-x $control-padding-y; +} + +.profile-title { + @extend .tile-title; +} + +.profile-stats { + dl { + border-top: 1px solid $border-color; + border-bottom: 1px solid $border-color; + padding: $control-padding-x $control-padding-y; + } + + dt { + font-weight: normal; + color: lighten($dark-color, 15%); + } + + dt, dd { + display: inline-block; + margin: 0; + margin-right: $control-padding-x; + } + + dd { + margin-right: $control-padding-x-lg; + } +} + +.profile-tabs { + margin-bottom: $control-padding-y-lg*2; +} + +.profile-post { + @extend .post; + + .profile-post-main { + flex: 1; + } + + .profile-post-time { + float: right; + } +} + +.spoiler { + text-shadow: gray 0px 0px 15px; + color: transparent; + -moz-user-select: none; + user-select: none; + cursor: normal; + + &:hover, &:focus { + text-shadow: $body-font-color 0px 0px 0px; + } +} + +.profile-post-title { + @extend .thread-title; +} + +// - Sign up modal + +#signup-modal { + .modal-container .modal-body { + max-height: 60vh; + } +} + +.license-text { + text-align: left; + font-size: 80%; +} + +// - Reset password +#resetpassword { + @extend .grid-sm; + @extend .container; + + .form-input { + display: inline-block; + width: 15rem; + margin-bottom: $control-padding-y*2; + } + + .footer { + margin-top: $control-padding-y*2; + } +} diff --git a/public/css/spectre b/public/css/spectre new file mode 160000 index 0000000..7a6af53 --- /dev/null +++ b/public/css/spectre @@ -0,0 +1 @@ +Subproject commit 7a6af53bca72b549b2cbf1948763348bd5b30dcd diff --git a/public/css/style.css b/public/css/style.css deleted file mode 100644 index fa38840..0000000 --- a/public/css/style.css +++ /dev/null @@ -1,713 +0,0 @@ - -a, a * { cursor:pointer; } - -html { margin:0; overflow-x:auto; } -body { - overflow-x:hidden; - min-width:1030px; - margin:0; - font: 13pt Helvetica,Arial,sans-serif; - background:#152534 url("/images/bg.png") no-repeat fixed center top; } - -pre { color: #F5F5F5;} -pre, pre * { cursor:text; } -pre .Comment { color:#6D6D6D; font-style:italic; } -pre .Keyword { color:#43A8CF; font-weight:bold; } -pre .Type { color:#128B7D; font-weight:bold; } -pre .Operator { font-weight: bold; } -pre .atr { color:#128B7D; font-weight:bold; font-style:italic; } -pre .def { color:#CAD6E4; font-weight:bold; font-style:italic; } -pre .StringLit { color:#854D6A; font-weight:bold; } -pre .DecNumber, pre .FloatNumber { color:#8AB647; } -pre .tab { border-left:1px dotted rgba(67,168,207,0.4); } -pre .end { background:url("/images/tabEnd.png") no-repeat left bottom; } -pre .EscapeSequence -{ - color: #C08D12; -} - -.tall { height:100%; } -.pre { padding:0 5px; font: 11pt "DejaVu Sans Mono",monospace; background:rgba(255,255,255,.30); border-radius:3px; } - -.page-layout { margin:0 auto; width:1000px; } -.docs-layout { margin:0 40px; } -.talk-layout { margin:0 40px; } -.wide-layout { margin:0 auto; } - -#head { - height:100px; - margin-bottom: 40px; - background:url("/images/head.png") repeat-x bottom; } -#head.docs { margin-left:280px; background:rgba(0,0,0,.25) url("/images/head-fade.png") no-repeat right top; } -#head > div { position:relative } - - #head-logo { - position:absolute; - left:-390px; - top:0; - width:917px; - height:268px; - pointer-events:none; - background:url("/images/logo.png") no-repeat; } - #head.docs #head-logo { left:-381px; position:fixed; } - #head.forum #head-logo { left:-370px; } - - #head-logo-link { - position:absolute; - display:block; - top:10px; - left:10px; - width:236px; - height:85px; } - #head.docs #head-logo-link { left:-260px; } - #head.forum #head-logo-link { left:30px; } - - #head-links { position:absolute; right:0; bottom:13px; } - #head.docs #head-links, - #head.forum #head-links { right:20px; } - #head-links > a { - display:block; - float:left; - padding:10px 25px 25px 25px; - color:rgba(255,255,255,.5); - font-size:14pt; - text-decoration:none; - letter-spacing:1px; - background:url("/images/head-link.png") no-repeat center bottom; - transition: - color 0.3s ease-in-out, - text-shadow 0.4s ease-in-out; } - #head-links > a:hover, - #head-links > a.active { - position: relative; - color:#1cb3ec; - text-shadow:0 0 4px rgba(28,179,236,.8); - background-image:url("/images/head-link_hover.png"); } - - #head-links > a.active:after { - display: block; - content: ""; - width: 771px; - background: url("/images/glow-arrow.png") no-repeat left; - height: 41px; - position: absolute; - left: 50%; - bottom: -49px; - transform: translateX(-618px); } - - #head-banner { width:200px; height:100px; background:#000; } - - #glow-line-vert { - position:fixed; - top:100px; - left:280px; - width:3px; - height:844px; - background:url("/images/glow-line-vert.png") no-repeat; } - - -#body { z-index:1; position:relative; background:rgba(220,231,248,.6); } -#body.docs { margin:0 40px 20px 320px; } -#body.forum { margin:0 40px 20px 40px; min-height: 700px; } - - #body-border { - position:absolute; - top:-25px; - left:0; - right:0; - height:35px; - background:rgba(0,0,0,.25); } - - #body-border-left { - position:absolute; - left:-25px; - top:-25px; - bottom:-25px; - width:35px; - background:rgba(0,0,0,.25); } - - #body-border-right { - position:absolute; - right:-25px; - top:-25px; - bottom:-25px; - width:35px; - background:rgba(0,0,0,.25); } - - #body-border-bottom { - position:absolute; - left:10px; - right:10px; - bottom:-25px; - height:35px; - background:rgba(0,0,0,.25); } - - #body.docs #body-border, - #body.forum #body-border { left:10px; right:10px; } - - #glow-line { - position:absolute; - top:-27px; - left:100px; - right:-25px; - height:3px; - background:url("/images/glow-line.png") no-repeat left; } - #glow-line-bottom { - position:absolute; - bottom:-27px; - left:-25px; - right:100px; - height:3px; - background:url("/images/glow-line2.png") no-repeat right; } - - #content { padding:40px 0; } - #content.page { width:680px; min-height:800px; padding-left:20px; } - #content h1 { font-size:20pt; letter-spacing:1px; color:rgba(0,0,0,.75); } - #content h2 { font-size:16pt; letter-spacing:1px; color:rgba(0,0,0,.7); margin-top:40px; } - #content p { color: #1D1D1D; margin: 5pt 0pt; } - #content a { color:#CEDAE9; text-decoration:none; } - #content a:hover { color:#fff; } - #content ul { padding-left:20px; } - #content li { margin-bottom:10px; text-align:justify; } - - #talk-heads { overflow:auto; margin:0 8px 0 8px; } - #talk-heads > div { float:left; font-size:120%; font-weight:bold; } - #talk-heads > .topic { width:45%; } - #talk-heads > .detail { width:15%; } - #talk-heads > .activity { width:25%; } - #talk-heads > .users { width:15%; } - #talk-heads > div > div { margin:0 10px 10px 10px; padding:0 10px 10px 10px; border-bottom:1px dashed rgba(0,0,0,0.4); } - #talk-heads > .topic > div { margin-left:0; } - #talk-heads > .activity > div { margin-right:0; } - - #talk-thread > div { - background-color: rgba(255, 255, 255, 0.5); - } - #talk-thread > div, - #talk-threads > div { - position:relative; - margin:5px 0; - overflow:auto; - border-radius:3px; - border:8px solid rgba(0,0,0,.8); - border-top:none; - border-bottom:none; - } - #talk-threads > div - { - line-height: 150%; - background:rgba(0,0,0,0.1); - } - #talk-threads > div:nth-child(odd) { background:rgba(0,0,0,0.2); } - #talk-thread > div > div, - #talk-threads > div > div - { - float:left; - text-overflow: ellipsis; - overflow: hidden; - font-size: 13pt; - } - #talk-threads > div > div > div { margin: 5px 10px; } - #talk-thread > div > div > div { margin: 15px 10px; } - #talk-thread > div > .topic - { - margin-top: 15pt; - white-space: normal; - } - #talk-thread > div > .topic > div - { - margin-left: 15px; - } - #talk-thread > div > .topic > div > span.date - { - position: absolute; - top: 5px; - right: 10pt; - border-bottom: 1px dashed; - color: #3D3D3D; - } - #talk-threads > div > .topic { width:45%; } - #talk-threads > div > .users { width:15%; overflow:hidden; height: 30px; } - #talk-threads > div > .users > div > img - { - margin-bottom: -4pt; - cursor: help; - width: 20px; - } - #talk-threads > div > .detail { width:16%; overflow:hidden; } - #talk-thread > div > .author, - #talk-threads > div > .activity { - overflow:hidden; - background:rgba(0,0,0,0.8); - color: white; - - } - #talk-thread > div > .author { - width: 15%; - } - #talk-threads > div > .activity { - width:24%; - font-size: 9pt; - } - #talk-threads > div > .activity a - { - color: #1CB3EC; - } - #talk-threads > div > .activity a:hover - { - color: #ffffff; - } - #talk-thread > div > .author { - height: 100%; - position: absolute; - } - #talk-thread > div > .author a, - #talk-threads > div > .author a { color:#1cb3ec !important; } - #talk-thread > div > .author a:hover, - #talk-threads > div > .author a:hover { color:#fff !important; } - #talk-threads > div > .topic .pages { float:right; } - #talk-threads > div > .topic .pages > a - { - margin-right: 5pt; - } - #talk-threads > div > .topic > div > a - { - font-weight:bold; - white-space: nowrap; - } - #talk-threads > div > .topic > div > a:visited { color: #1a1a1a; } - #talk-threads > div > .detail > div { float:left; margin:0; } - #talk-threads > div > .detail > div > div { margin-left:15px; padding: 5px 5px 5px 22px; } - #talk-threads > div > .detail > div { width:50%; } - #talk-threads > div > .detail > div:first-child > div { background:url("/images/forum-views.png") no-repeat left; cursor: help; } - #talk-threads > div > .detail > div:last-child > div { background:url("/images/forum-posts.png") no-repeat left; cursor: help; } - - #talk-thread > div { margin:20px 0; min-height:160px; padding-bottom: 10pt; } - #talk-thread > div > .author > div > .avatar { margin-top:20px; } - #talk-thread > div > .author > div > .name { } - #talk-thread > div > .author > div > .date { font-size: 8pt; color: white; } - #talk-thread > div > .topic { width:85%; padding-bottom:10px; margin-left: 15%; } - #talk-thread > div > .topic pre, #markhelp pre.listing { - overflow:auto; - margin:0; - padding:15px 10px; - font-size:10pt; - font-style:normal; - line-height:14pt; - background:rgba(0,0,0,.75); - border-left:8px solid rgba(0,0,0,.3); - margin-bottom: 10pt; - font-family: "DejaVu Sans Mono", monospace; - } - #talk-thread > div > .topic a, #talk-thread > div > .topic a:visited, - #markhelp a, #markhelp a:visited - { - color: #3680C9; - text-decoration: none; - } - #talk-thread > div > .topic a:hover - { - text-decoration: underline; - } - #talk-head, - #talk-info { - overflow:auto; - border-radius:3px; - border:8px solid rgba(0,0,0,.2); - border-top:none; - border-bottom:none; - background:rgba(0,0,0,0.1); } - #talk-head { margin-bottom:20px; } - #talk-info { margin-top:20px; } - #talk-head > div, - #talk-info > div { float:left; } - #talk-head > .info, - #talk-info > .info { width:80%; } - #talk-head > .info-post, - #talk-info > .info-post { width: 85%; } - #talk-head > .user, - #talk-info > .user { width:20%; background:rgba(0,0,0,.2); } - #talk-head > .user-post, - #talk-info > .user-post { width: 15%; background:rgba(0,0,0,.2); } - #talk-info > .user-post .reply { font-weight:bold; padding-left:22px; background:url("/images/forum-reply.png") no-repeat left; } - #talk-info > .user-post a span - { - color: #CEDAE9 !important; - } - #talk-info > .user-post > a > div:hover > span - { - color: #fff !important; - } - #talk-head > div > div, - #talk-info > div > div, - #talk-info > div > a > div { padding:5px 20px; color: #1a1a1a; } - #talk-head > div > div { color: #353535; } - #talk-head > .detail > div { float:left; margin:0; } - #talk-head > .detail > div > div { padding-left:22px; } - #talk-head > .detail > div:first-child > div { background:url("/images/forum-views.png") no-repeat left; } - #talk-head > .detail > div:last-child > div { background:url("/images/forum-posts.png") no-repeat left; } - - #talk-nav { margin:20px 8px 0 8px; padding-top:10px; border-top:1px dashed rgba(0,0,0,0.4); text-align:center; } - #talk-nav > a.active { text-decoration:underline !important; } - #talk-nav > a, #talk-nav > span, #talk-info > .info-post > div > a, - #talk-info > .info-post > div > span { margin-left: 5pt; } - - .standout { - padding:5px 30px; - margin-bottom:20px; - border:8px solid rgba(0,0,0,.8); - border-right-width:16px; - border-top-width:0; - border-bottom-width:0; - border-radius:3px; - background:rgba(0,0,0,0.1); - box-shadow:1px 3px 12px rgba(0,0,0,.4); } - .standout h3 { margin-bottom:10px; padding-bottom:10px; border-bottom:1px dashed rgba(0,0,0,.8); } - .standout li { margin:0 !important; padding-top:10px; border-top:1px dashed rgba(0,0,0,.2); } - .standout ul { padding-bottom:5px; } - .standout ul.tools { list-style:url("/images/docs-tools.png"); } - .standout ul.library { list-style:url("/images/docs-library.png"); } - .standout ul.internal { list-style:url("/images/docs-internal.png"); } - .standout ul.tutorial { list-style:url("/images/docs-tutorial.png"); } - .standout ul.example { list-style:url("/images/docs-example.png"); } - .standout li:first-child { padding-top:0; border-top:none; } - .standout li p { margin:0 0 10px 0 !important; line-height:130%; } - .standout li > a { font-weight:bold; } - - .forum-user-info, - .forum-user-info * { cursor:help } - -#foot { height:150px; position:relative; top:-10px; letter-spacing:1px; } -#foot.home { background:url("/images/foot.png") repeat-x top; height:200px; } -#foot.docs { margin-left:320px; margin-right:40px; } -#foot.forum { margin-left:40px; margin-right:40px; } -#foot > div { position:relative; } -#foot.home > div { width:960px; } -#foot h4 { font-size:11pt; color:rgba(255,255,255,.4); margin:40px 0 6px 0; } -#foot a:hover { color:#fff; } - - #foot-links { float:left; } - #foot-links > div { float:left; padding:0 40px 0 0; line-height:120%; } - #foot-links a { display:block; font-size:10pt; color:rgba(255,255,255,.3); text-decoration:none; } - #foot-legal { float:right; font-size:10pt; color:rgba(255,255,255,.3); line-height:150%; text-align:right; } - #foot-legal a { color:inherit; text-decoration:none; } - #foot-legal > h4 > a { color:inherit; } - - #mascot { - z-index:2; - position:absolute; - top:-340px; - right:25px; - width:202px; - height:319px; - background:url("/images/mascot.png") no-repeat; } - -article#content -{ - width: 80%; - display: inline-block; -} - -div#sidebar -{ - background-color: rgba(255, 255, 255, 0.1); - - border-left: 8px solid rgba(0, 0, 0, 0.8); - border-right: 8px solid rgba(0, 0, 0, 0.8); - border-bottom: 8px solid rgba(0, 0, 0, 0.8); - border-radius: 3px; - - width: 15%; - margin-top: 40px; - - display: inline-block; - float: right; - - color: #FFF; -} - -div#sidebar .title -{ - background-color: rgba(0, 0, 0, 0.8); - color: #FFF; - text-align: center; - padding: 10pt; -} - -div#sidebar .content -{ - padding: 12pt; - overflow: auto; - -} - -div#sidebar .content .button -{ - background-color: rgba(0,0,0,0.2); - text-decoration: none; - color: #FFF; - padding: 4pt; - float: right; - border-bottom: 2px solid rgba(0,0,0,0.24); - font-size: 11pt; - margin-top: 5pt; -} - -div#sidebar .content .button:hover -{ - border-bottom: 2px solid rgba(0,0,0,0.5); -} - -div#sidebar .content input -{ - width: 99%; - margin-bottom: 10pt; - margin-top: 2pt; - - border: 1px solid #6D6D6D; - font-size: 12pt; -} - -div#sidebar .content a.avatar img -{ - float: left; - margin-top: 5pt; -} - -div#sidebar .content a.user -{ - background-color: rgba(0, 0, 0, 0.8); - color: #1cb3ec; - padding: 5pt; - width: 93%; - display: block; - text-align: center; - text-decoration: none; -} - -div#sidebar .content a.user:hover -{ - color: #FFF; -} - -div#sidebar .user .button -{ - float: left; - margin-top: 5pt; - width: 52.5%; -} - -div#sidebar .user .logout -{ - clear: left; - width: 52pt; - text-align: center; - margin-left: 0pt; -} - -div#sidebar .user .avatar > img -{ - margin-right: 5pt; -} - -div#sidebar .content .search -{ - text-align: center; - margin: auto; - display: block; - width: 95%; -} - -div#sidebar .content a#passreset { - color: #CEDAE9; - font-size: 9pt; - display: block; - text-decoration: none; - margin-top: -4pt; -} - -div#sidebar .content a#passreset:hover { - color: #fff; -} - - - -span.error -{ - float: left; - width: 100%; - color: #FF4848; - text-align: center; - font-size: 10pt; - background-color: rgba(0,0,0,0.8); - padding: 5pt 0pt; - font-weight: bold; -} - -section#body #content span.error -{ - width: 25%; - margin-top: 5px; - margin-bottom: 5px; -} - -article#content form -{ - border-right: 8px solid rgba(0, 0, 0, 0.2); - background-color: rgba(255, 255, 255, 0.1); - padding: 10pt 20pt; -} - -article#content form > input, article#content form > textarea -{ - border: 1px solid #6D6D6D; -} - -article#content form > input[type=text] -{ - width: 70%; - min-width: 500px; -} - -article#content form > textarea -{ - width: 100%; - height: 200px; -} - -article#content form > input:focus, article#content form > textarea:focus -{ - border: 1px solid #1cb3ec; -} - -hr -{ - border: 1px solid #3D3D3D; -} - -.activity .isoDate -{ - display: none; -} - - -/* highlighting current post */ - -div:target { - background: rgba(139, 218, 255, 0.25) !important; -} - -/* full-text search */ - -.searchResults h4 b, -.searchResults h5 b { - border-bottom: 1px dotted #ffffff; -} -.titleHeader { - margin-right: 1em; - color: #121212; - font-weight: bold; -} - -.postTitle b { - border-bottom: 1px solid #D7300C; -} - -.postTitle a:hover { - text-decoration: none !important; - border-bottom: 1px solid #D7300C; -} - -.searchForm { - margin-top: 0px; - margin-right: 1em; - margin-bottom: 0px; - margin-left: 1em; -} - -.searchHelp { - color: #000000 !important; - float: right; - font-size: 11px; - left: -17px; - top: 3px; - position: relative; - text-decoration: none; - text-shadow: #FFFF00 1px 1px 2px; - cursor: help; -} - -#talk-thread.searchResults > div > div > div { - margin: 15px 8px; -} - -form.searchNav { - display: inline; - border: none !important; - background: transparent !important; -} - -.searchNav input { - background: #858C97; - color: #000000; - border: 1px solid #333; -} - -.clear { - clear: both; - height: 1px; -} - -img.smiley { - width: 20px; - height: 20px; - vertical-align: middle; - margin: 0; -} - -img.rssfeed { - width: 16px; - float: right; - margin-top: 10px; -} - - -#markhelp { - width: 80%; - background-color: #cbcfd6; - padding: 2pt 10pt; - margin-top: 10pt; -} - -#markhelp .markheading { - background-color: #6fa1ff; - text-align: center; -} - -#markhelp table.rst { - width: 100%; - margin: 10px 0px; - font-size: 12pt; - border-collapse: collapse; -} - -#markhelp table tr, #markhelp table td { - width: 50%; - border: 1px solid #7d7d7d; -} - -#markhelp table td { - padding:4px 9px; -} - -blockquote { - padding: 0px 8px; - margin: 10px 0px; - border-left: 2px solid rgb(61, 61, 61); - color: rgb(109, 109, 109); -} - -blockquote p { - color: rgb(109, 109, 109) !important; - -} \ No newline at end of file diff --git a/public/css/syntax.scss b/public/css/syntax.scss new file mode 100644 index 0000000..14dfa49 --- /dev/null +++ b/public/css/syntax.scss @@ -0,0 +1,13 @@ +pre .Comment { color:#618f0b; font-style:italic; } +pre .Keyword { color:rgb(39, 141, 182); font-weight:bold; } +pre .Type { color:#128B7D; font-weight:bold; } +pre .Operator { font-weight: bold; } +pre .atr { color:#128B7D; font-weight:bold; font-style:italic; } +pre .def { color:#CAD6E4; font-weight:bold; font-style:italic; } +pre .StringLit { color:rgb(190, 15, 15); font-weight:bold; } +pre .DecNumber, pre .FloatNumber { color:#8AB647; } +pre .tab { border-left:1px dotted rgba(67,168,207,0.4); } +pre .EscapeSequence +{ + color: #C08D12; +} \ No newline at end of file diff --git a/public/images/Feed-icon.svg b/public/images/Feed-icon.svg deleted file mode 100644 index b325149..0000000 --- a/public/images/Feed-icon.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/public/images/bg.png b/public/images/bg.png deleted file mode 100644 index 91f3359..0000000 Binary files a/public/images/bg.png and /dev/null differ diff --git a/public/images/forum-posts.png b/public/images/forum-posts.png deleted file mode 100644 index 4c4c63b..0000000 Binary files a/public/images/forum-posts.png and /dev/null differ diff --git a/public/images/forum-reply.png b/public/images/forum-reply.png deleted file mode 100644 index 12e9c60..0000000 Binary files a/public/images/forum-reply.png and /dev/null differ diff --git a/public/images/forum-views.png b/public/images/forum-views.png deleted file mode 100644 index febbdf6..0000000 Binary files a/public/images/forum-views.png and /dev/null differ diff --git a/public/images/glow-arrow.png b/public/images/glow-arrow.png deleted file mode 100644 index 6a91c23..0000000 Binary files a/public/images/glow-arrow.png and /dev/null differ diff --git a/public/images/glow-line.png b/public/images/glow-line.png deleted file mode 100644 index 6607bde..0000000 Binary files a/public/images/glow-line.png and /dev/null differ diff --git a/public/images/glow-line2.png b/public/images/glow-line2.png deleted file mode 100644 index 7c4c5f5..0000000 Binary files a/public/images/glow-line2.png and /dev/null differ diff --git a/public/images/head-link.png b/public/images/head-link.png deleted file mode 100644 index 8e55b94..0000000 Binary files a/public/images/head-link.png and /dev/null differ diff --git a/public/images/head-link_hover.png b/public/images/head-link_hover.png deleted file mode 100644 index 4180fbc..0000000 Binary files a/public/images/head-link_hover.png and /dev/null differ diff --git a/public/images/head.png b/public/images/head.png deleted file mode 100644 index 9d7bcd2..0000000 Binary files a/public/images/head.png and /dev/null differ diff --git a/public/images/logo.png b/public/images/logo.png deleted file mode 100644 index 2594249..0000000 Binary files a/public/images/logo.png and /dev/null differ diff --git a/public/images/smilieys/icon_cool.png b/public/images/smilieys/icon_cool.png deleted file mode 100644 index 37e55e3..0000000 Binary files a/public/images/smilieys/icon_cool.png and /dev/null differ diff --git a/public/images/smilieys/icon_e_biggrin.png b/public/images/smilieys/icon_e_biggrin.png deleted file mode 100644 index d54cc5d..0000000 Binary files a/public/images/smilieys/icon_e_biggrin.png and /dev/null differ diff --git a/public/images/smilieys/icon_e_confused.png b/public/images/smilieys/icon_e_confused.png deleted file mode 100644 index 17dd8ea..0000000 Binary files a/public/images/smilieys/icon_e_confused.png and /dev/null differ diff --git a/public/images/smilieys/icon_e_sad.png b/public/images/smilieys/icon_e_sad.png deleted file mode 100644 index 1ef0291..0000000 Binary files a/public/images/smilieys/icon_e_sad.png and /dev/null differ diff --git a/public/images/smilieys/icon_e_smile.png b/public/images/smilieys/icon_e_smile.png deleted file mode 100644 index f7ee8aa..0000000 Binary files a/public/images/smilieys/icon_e_smile.png and /dev/null differ diff --git a/public/images/smilieys/icon_e_surprised.png b/public/images/smilieys/icon_e_surprised.png deleted file mode 100644 index 60a82d4..0000000 Binary files a/public/images/smilieys/icon_e_surprised.png and /dev/null differ diff --git a/public/images/smilieys/icon_e_wink.png b/public/images/smilieys/icon_e_wink.png deleted file mode 100644 index e32171d..0000000 Binary files a/public/images/smilieys/icon_e_wink.png and /dev/null differ diff --git a/public/images/smilieys/icon_exclaim.png b/public/images/smilieys/icon_exclaim.png deleted file mode 100644 index 9391f2b..0000000 Binary files a/public/images/smilieys/icon_exclaim.png and /dev/null differ diff --git a/public/images/smilieys/icon_mad.png b/public/images/smilieys/icon_mad.png deleted file mode 100644 index 3029c96..0000000 Binary files a/public/images/smilieys/icon_mad.png and /dev/null differ diff --git a/public/images/smilieys/icon_neutral.png b/public/images/smilieys/icon_neutral.png deleted file mode 100644 index 099cf9d..0000000 Binary files a/public/images/smilieys/icon_neutral.png and /dev/null differ diff --git a/public/images/smilieys/icon_razz.png b/public/images/smilieys/icon_razz.png deleted file mode 100644 index 73401ea..0000000 Binary files a/public/images/smilieys/icon_razz.png and /dev/null differ diff --git a/public/karax.html b/public/karax.html new file mode 100644 index 0000000..3f7f41b --- /dev/null +++ b/public/karax.html @@ -0,0 +1,31 @@ + + + + + + + + + $title + + + + + + + + + + + +
+ + + + diff --git a/public/license.rst b/public/license.rst new file mode 100644 index 0000000..beebae3 --- /dev/null +++ b/public/license.rst @@ -0,0 +1,38 @@ +Content license +=============== + +All the content contributed to $hostname is `cc-wiki (aka cc-by-sa) +`_ licensed, intended to be +**shared and remixed**. + +The cc-wiki licensing, while intentionally permissive, does require +attribution: + +**Attribution** — You must attribute the work in the manner specified by +the author or licensor (but not in any way that suggests that they endorse +you or your use of the work). + +This means that if you republish this content, you are +required to: + +* **Visually indicate that the content is from the $name**. It doesn’t + have to be obnoxious; a discreet text blurb is fine. +* **Hyperlink directly to the original post** (e.g., + https://$hostname/t/186/1#908) +* **Show the author names** for every post. +* **Hyperlink each author name** directly back to their user profile page + (e.g., http://$hostname/profile/Araq) + +To be more specific, each hyperlink must +point directly to the $hostname domain in +standard HTML visible even with JavaScript disabled, and not use a tinyurl or +any other form of obfuscation or redirection. Furthermore, the links must not +be `nofollowed +`_. + +This is about the spirit of fair **attribution**. Attribution to the website, +and more importantly, to the individuals who so generously contributed their +time to create that content in the first place! + +Feel free to remix and reuse to your heart’s content, as long as a good faith +effort is made to attribute the content! diff --git a/rst.txt b/public/rst.rst similarity index 62% rename from rst.txt rename to public/rst.rst index 70bbd63..b5db812 100644 --- a/rst.txt +++ b/public/rst.rst @@ -1,9 +1,8 @@ -=========================================================================== - reStructuredText cheat sheet -=========================================================================== +Markdown and RST supported by this forum +======================================== This is a cheat sheet for the *reStructuredText* dialect as implemented by -Nimrod's documentation generator which has been reused for this forum. :-) +Nim's documentation generator which has been reused for this forum. See also the `official RST cheat sheet `_ @@ -12,9 +11,8 @@ for further information. Elements of **markdown** are also supported. - Inline elements -=============== +--------------- Ordinary text may contain *inline elements*: @@ -29,68 +27,71 @@ Plain text Result ``\\escape`` \\escape =============================== ============================================ -Links -===== +Quoting other users can be done by prefixing their message with ``>``:: -Links are either direct URLs like ``http://nimrod-lang.org`` or written like + > Hello World + + Hi! + +Which will result in: + +> Hello World + +Hi! + +Links +----- + +Links are either direct URLs like ``https://nim-lang.org`` or written like this:: - `Nimrod `_ + `Nim `_ Or like this:: - ``_ + ``_ Code blocks -=========== +----------- -are done this way:: +The code blocks can be written in the same style as most common Markdown +flavours:: - .. code-block:: nimrod + ```nim + if x == "abc": + echo "xyz" + ``` + +or using RST syntax:: + + .. code-block:: nim if x == "abc": echo "xyz" +Both are rendered as: -Is rendered as: - -.. code-block:: nimrod - - if x == "abc": - echo "xyz" - - -Except Nimrod, the programming languages C, C++, Java and C# have highlighting -support. - -An alternative github-like syntax is also supported. This has the advantage -that no excessive indentation is needed:: - - ```nimrod - if x == "abc": - echo "xyz"``` - -Is rendered as: - -.. code-block:: nimrod +.. code-block:: nim if x == "abc": echo "xyz" +Apart from Nim, the programming languages C, C++, Java and C# also +have highlighting support. Literal blocks -============== +-------------- -Are introduced by '::' and a newline. The block is indicated by indentation: +These are introduced by '::' and a newline. The block is indicated by indentation: :: :: if x == "abc": echo "xyz" -Is rendered as:: +The above is rendered as:: if x == "abc": echo "xyz" @@ -98,9 +99,9 @@ Is rendered as:: Bullet lists -============ +------------ -look like this:: +Bullet lists look like this:: * Item 1 * Item 2 that @@ -111,7 +112,7 @@ look like this:: - item 3b - valid bullet characters are ``+``, ``*`` and ``-`` -Is rendered as: +The above rendered as: * Item 1 * Item 2 that spans over multiple lines @@ -123,9 +124,9 @@ Is rendered as: Enumerated lists -================ +---------------- -are written like this:: +Enumerated lists are written like this:: 1. This is the first item 2. This is the second item @@ -133,64 +134,17 @@ are written like this:: single letters, or roman numerals #. This item is auto-enumerated -Is rendered as: +They are rendered as: 1. This is the first item 2. This is the second item 3. Enumerators are arabic numbers, single letters, or roman numerals -#. This item is auto-enumerated - - -Quoting someone -=============== - -quotes are just:: - - **Someone said**: Indented paragraphs, - - and they may nest. - -Is rendered as: - - **Someone said**: Indented paragraphs, - - and they may nest. - - - -Definition lists -================ - -are written like this:: - - what - Definition lists associate a term with - a definition. - - how - The term is a one-line phrase, and the - definition is one or more paragraphs or - body elements, indented relative to the - term. Blank lines are not allowed - between term and definition. - -and look like: - -what - Definition lists associate a term with - a definition. - -how - The term is a one-line phrase, and the - definition is one or more paragraphs or - body elements, indented relative to the - term. Blank lines are not allowed - between term and definition. +#. This item is auto-enumerated Tables -====== +------ Only *simple tables* are supported. They are of the form:: @@ -218,3 +172,39 @@ Cell 4 Cell 5; any Cell 6 multiple lines Cell 7 Cell 8 Cell 9 ================== =============== =================== + +Images +------ + +Image embedding is supported. This includes GIFs as well as mp4 (for which a +