Compare commits
9 Commits
c540073b66
...
2d07a5c5f6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d07a5c5f6 | ||
|
|
3d66587678 | ||
|
|
39a1b07c07 | ||
|
|
705cf9e8ba | ||
|
|
1bcaff892b | ||
|
|
ff78d55b8e | ||
|
|
995e75d3e2 | ||
|
|
27cf36c390 | ||
|
|
f21bdbdb0c |
|
|
@ -96,7 +96,7 @@ API_URI=/_/v1/
|
|||
if db:
|
||||
self.db = db
|
||||
elif self.config.IN_MEMORY_DB:
|
||||
self.db = GrungDB.with_schema(schema, path=None, storage=MemoryStorage)
|
||||
self.db = GrungDB.with_schema(schema, path=self.path.database, storage=MemoryStorage)
|
||||
else:
|
||||
self.db = GrungDB.with_schema(
|
||||
schema, path=self.path.database, sort_keys=True, indent=4, separators=(",", ": ")
|
||||
|
|
@ -165,6 +165,7 @@ API_URI=/_/v1/
|
|||
else:
|
||||
search_uri = uri
|
||||
|
||||
self.log.debug(f"Looking for page with uri {search_uri}")
|
||||
page = table.get(where("uri") == search_uri, recurse=False)
|
||||
if not page:
|
||||
# load the parent to check for write permissions
|
||||
|
|
@ -182,7 +183,7 @@ API_URI=/_/v1/
|
|||
raise UnauthorizedError(f"User {user.doc_id} does not have permission to create under {parent_uri}.")
|
||||
|
||||
obj = getattr(schema, table.name)
|
||||
page = obj(name=page_name, body=obj.default.format(name=page_name), parent=parent)
|
||||
page = obj(name=page_name, body=obj.default.format(name=page_name), parent=parent, uri=search_uri)
|
||||
|
||||
# validate the page name before we try to create anything
|
||||
page._metadata.fields["name"].validate(page, db=self.db)
|
||||
|
|
|
|||
|
|
@ -1,41 +1,240 @@
|
|||
from ttfrog import app, schema
|
||||
|
||||
TEMPLATE = """
|
||||
# Heading 1
|
||||
# Wiki Syntax
|
||||
{{toc box depth="3"}}
|
||||
|
||||
## Heading 2
|
||||
## Basic Formatting
|
||||
|
||||
### Heading 3
|
||||
|
||||
#### Heading 4
|
||||
The wiki uses [GitHub-flavored Markdown](https://github.github.com/gfm/).
|
||||
|
||||
##### Heading 5
|
||||
{{style equal-widths striped
|
||||
|
||||
###### Heading 6
|
||||
| Type | To Get |
|
||||
|------|--------|
|
||||
| `*emphasis*` | *emphasis* |
|
||||
| `**bold**` | **bold** |
|
||||
| `[link label](/link/address)` | [link label](/link/address) |
|
||||
| `` `inline` `` | `inline` |
|
||||
| `***` | <hr> |
|
||||
| `# Heading 1` | {{{ <h1>Heading 1</h1> }}} |
|
||||
| `## Heading 2` | {{{ <h2>Heading 2</h2> }}} |
|
||||
| `### Heading 3` | {{{ <h3>Heading 3</h3> }}} |
|
||||
| `#### Heading 4` | {{{ <h4>Heading 4</h4> }}} |
|
||||
| `##### Heading 5` | {{{ <h5>Heading 5</h5> }}} |
|
||||
| `###### Heading 6` | {{{ <h6>Heading 6</h6> }}} |
|
||||
| {{{
|
||||
|
||||
***
|
||||
<pre>
|
||||
* unordered
|
||||
* list
|
||||
* items
|
||||
</pre>
|
||||
|
||||
Normal text.
|
||||
**Bold text.**
|
||||
*Italic Text.*
|
||||
[A link](/).
|
||||
}}} | {{{
|
||||
|
||||
1. a
|
||||
2. numbered
|
||||
3. list.
|
||||
<ul><li>unordered</li><li>list</li><li>items</li></ul>
|
||||
|
||||
> a block quote
|
||||
}}} |
|
||||
| {{{
|
||||
|
||||
| A | Table | Section |
|
||||
| --- | ----- | ------- |
|
||||
| foo | bar | baz |
|
||||
<pre>
|
||||
1. ordered
|
||||
1. list
|
||||
1. items
|
||||
</pre>
|
||||
|
||||
}}} | {{{ <ol><li>ordered</li><li>list</li><li>items</li></ol> }}} |
|
||||
| {{{
|
||||
|
||||
<pre>
|
||||
> blockquote
|
||||
</pre>
|
||||
|
||||
}}} | <blockquote>blockquote</blockquote> |
|
||||
| {{{
|
||||
<pre>
|
||||
```
|
||||
preformatted
|
||||
code
|
||||
block
|
||||
```
|
||||
</pre>
|
||||
}}} | {{{ <pre>preformatted code<br>block</pre> }}} |
|
||||
| {{{
|
||||
|
||||
<pre>
|
||||
\\{\\{\\{
|
||||
|
||||
multi-line content,
|
||||
whitespace preserved,
|
||||
\\*\\*markup\\*\\* and \\<i\\>HTML\\</i\\> included.
|
||||
|
||||
\\}\\}\\}
|
||||
</pre>
|
||||
|
||||
}}} | {{{ <p>multi-line content,<br>whitespace preserved,<br><b>markup</b> and <i>HTML</i> included.</p> }}} |
|
||||
|
||||
}}
|
||||
|
||||
## Macros
|
||||
|
||||
Macros extend authoring options with computed values evaluated at display time.
|
||||
There are two types: *inline*, which can be used in the middle of a line of
|
||||
markdown, and *block*, which enclose a series of lines.
|
||||
|
||||
### Inline Macros
|
||||
|
||||
The basic format of an inline macro is:
|
||||
|
||||
`{{NAME [KEYWORD ..] [PARAM="VALUE" ..]}}`
|
||||
|
||||
* `NAME` is the name of the macro. It is always lower case.
|
||||
* `KEYWORD` is a bare word
|
||||
* `PARAM` is a parameter name
|
||||
* `VALUE` is the value of the associated parameter
|
||||
|
||||
No macro requires keywords or parameters, but some support them; they are documented below.
|
||||
|
||||
### Block Macros
|
||||
|
||||
The format of a block macro uses the same format for name, keywords and parameters. The closing
|
||||
braces must appear on their own line, and both the opening and closing lines must be
|
||||
surrounded by blank lines. This ensures the block macro is not interpreted as markdown.
|
||||
|
||||
```
|
||||
Before the macro..
|
||||
|
||||
{{NAME [KEYWORD ..] [PARAM="VALUE" ..]
|
||||
|
||||
..inside the macro..
|
||||
|
||||
}}
|
||||
|
||||
..after the macro.
|
||||
```
|
||||
|
||||
### Supported Macros
|
||||
|
||||
This section documents all supported macros.
|
||||
|
||||
WIP (move to its own page)
|
||||
|
||||
{{style striped equal-widths
|
||||
|
||||
| Type | To Get | Description |
|
||||
|------|--------|-------------|
|
||||
|{{{ <pre>{{user}}</pre> }}} | {{user}} | A hyperlink to the current user's wiki page. |
|
||||
|{{{ <pre>{{style [STYLE ..]<br>pretty markup goes here!<br><br>}}</pre> }}}| pretty markup goes here! | Styled markup. See Block Styling and Positioning, below. |
|
||||
|{{{ <pre>{{toc [STYLE ..] depth="N"}}</pre>}}} | {{toc depth="1"}} | A table of contents. The default depth is 3, and styles may be assigned as with the `style` macro. |
|
||||
|{{{ <pre>{{widget NAME}}</pre> }}} | *varies* | Insert specified widget; see [Wiki Syntax: Widgets](/Widgets). |
|
||||
|
||||
|
||||
### Block Styling and Positioning
|
||||
|
||||
Several styles are supported by the editor, which may be freely combined. These include:
|
||||
|
||||
* **box**: block element with background and border
|
||||
* **full-width**: Expand block to fill all horizontal space
|
||||
* **right**: right-aligned floating block (maximum 30% of full width)
|
||||
* **left**: left-aligned floating block (maximum 30% of full width)
|
||||
* **center**: centered block
|
||||
* **inline**: contained within the current text line
|
||||
|
||||
#### Examples
|
||||
|
||||
{{style striped equal-widths
|
||||
|
||||
| Type | To Get |
|
||||
|------|--------|
|
||||
| {{{ <pre>{{style box<br><br>😶 box, full-width<br><br>}}<br><br>normal text</pre> }}} | {{{ <div class='box'>😶 box, full-width</div>normal text }}} |
|
||||
| {{{ <pre>{{style right<br><br>😶 right-aligned, inline<br><br>}}<br><br>normal text</pre> }}} | {{{ <div class='right'>😶 right-aligned, inline</div>normal text }}} |
|
||||
| {{{ <pre>{{style box right<br><br>😶 box right-aligned, inline<br><br>}}<br><br>normal text</pre> }}} | {{{ <div class='box right'>😶 box right-aligned, inline</div>normal text }}} |
|
||||
| {{{ <pre>{{style box center<br><br>😶 box center-aligned, block<br><br>}}<br><br>normal text</pre> }}} | {{{ <div class='box center'>😶 box center-aligned, block</div>normal text }}} |
|
||||
| {{{ <pre>{{style box left<br><br>😶 box left-aligned, inline<br><br>}}<br><br>normal text</pre> }}} | {{{ <div class='box left'>😶 box left-aligned, inline</div>normal text }}} |
|
||||
|
||||
}}
|
||||
|
||||
### Tables
|
||||
|
||||
Basic tables follow the Github-Flavoured Markdown. By default, table cells may not contain newlines, but you can add multi-line content to your tables using {{{ {{{ and }}} }}}:
|
||||
|
||||
{{style equal-widths striped
|
||||
|
||||
| Type | To Get |
|
||||
|------|--------|
|
||||
| {{{
|
||||
|
||||
<pre>
|
||||
\\| head 1 \\| head 2 \\|
|
||||
\\| ------ \\| ------ \\|
|
||||
\\| cell 1 \\| cell 2 \\|
|
||||
</pre>
|
||||
|
||||
}}} | {{{ <table><tr><th>head 1</th><th>head 2</th></tr><tr><td>cell 1</td><td>cell 2</td></tr></table> }}} |
|
||||
| {{{
|
||||
|
||||
<pre>
|
||||
\\| head 1 \\| head 2 \\|
|
||||
\\| ------ \\| ------ \\|
|
||||
\\| \\{\\{\\{
|
||||
|
||||
multi-line
|
||||
content
|
||||
|
||||
}}} \\| cell 3 \\|
|
||||
</pre>
|
||||
|
||||
}}} | {{{ <table><tr><th>head 1</th><th>head 2</th></tr><tr><td><p>multi-line<br>content</p></td><td>cell 3</td></tr></table> }}} |
|
||||
|
||||
}}
|
||||
|
||||
|
||||
#### Table Styling
|
||||
|
||||
{{style box right outer
|
||||
|
||||
**Striped, Equal-Widths Example:**
|
||||
|
||||
{{style striped equal-widths inner1
|
||||
|
||||
| A | B|
|
||||
|---|--|
|
||||
| This table has | and |
|
||||
| equal | alternating |
|
||||
| widths! | backgrounds! |
|
||||
|
||||
}}
|
||||
|
||||
|
||||
**Layout Example:**
|
||||
|
||||
{{style layout inner2
|
||||
|
||||
| A | B|
|
||||
|---|--|
|
||||
| row 1, col 1 | row 1, col 2|
|
||||
| row 2, col 1 | row 2, col 2|
|
||||
|
||||
}}
|
||||
|
||||
}}
|
||||
|
||||
|
||||
By wrapping a table in a `style` macro, you can use the following block-level styles on the tables themselves:
|
||||
|
||||
* `striped`: Apply zebra-striping to table rows
|
||||
* `equal-widths`: Enforce equal widths of all table columns
|
||||
* `layout`: Hide the table headers, borders, and background colors. This is useful in cases
|
||||
where you would like to control layout of elements using tables.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
def bootstrap():
|
||||
"""
|
||||
Bootstrap the database entries by populating the first Page, the Admin user and the Admins group.
|
||||
Bootstrap the database entries by poplating the first Page, the Admin user and the Admins group.
|
||||
"""
|
||||
app.check_state()
|
||||
|
||||
|
|
@ -45,10 +244,13 @@ def bootstrap():
|
|||
# create the top-level pages
|
||||
root = app.db.save(schema.Page(name=app.config.VIEW_URI, body="This is the home page", uri=""))
|
||||
|
||||
root.add_member(schema.Page(name="Wiki", body=TEMPLATE))
|
||||
|
||||
users = root.add_member(schema.Page(name="User", body="# Users\nusers go here."))
|
||||
groups = root.add_member(schema.Page(name="Group", body="# Groups\ngroups go here."))
|
||||
npcs = root.add_member(schema.Page(name="NPC", body="# NPCS!"))
|
||||
wiki = root.add_member(schema.Page(name="Wiki", body=TEMPLATE))
|
||||
widgets = root.add_member(schema.Page(name="Widget", body="Widgets go here."))
|
||||
widgets.add_member(schema.Widget(name="hello", body=schema.Widget.default))
|
||||
|
||||
# create the NPCs
|
||||
npcs.add_member(schema.NPC(name="Sabetha", body=""))
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ from grung.objects import (
|
|||
TextFilePointer,
|
||||
Timestamp,
|
||||
)
|
||||
from grung.validators import PatternValidator, LengthValidator
|
||||
from grung.validators import LengthValidator, PatternValidator
|
||||
from tinydb import where
|
||||
|
||||
from ttfrog.exceptions import MalformedRequestError
|
||||
|
|
@ -303,3 +303,88 @@ class NPC(Page):
|
|||
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
class Widget(Page):
|
||||
"""
|
||||
Wiki UX widgets
|
||||
"""
|
||||
|
||||
default = dedent(
|
||||
"""
|
||||
This is a sample widget that you can customize to your liking. Widget pages must contain at minimum the
|
||||
**Template** section. It, along with the optional **CSS** and **Processor** sections, will be parsed by
|
||||
wiki at display time. All other content on this page is ignored, so you can include usage docs, exmaples,
|
||||
and so on, just like this text and the annnotations below in *italics*.
|
||||
|
||||
The name of your widget is the portion of the URI following `/Widget/`.
|
||||
|
||||
# Hello, World Example Widget
|
||||
*Provide a description of your widget.*
|
||||
|
||||
Insert the word "HELLO" and optionally a name.
|
||||
|
||||
## Usage
|
||||
*Display the usage of your widget. Ensure that the example is enclosed in a preformatted (`pre`) block.*
|
||||
*Be sure to include a description of any parameters and keyword arguments.*
|
||||
|
||||
<pre>
|
||||
{{widget hello [NAME] }}
|
||||
</pre>
|
||||
|
||||
|
||||
## Example
|
||||
*Include one or more example uses of the widget. Once you save this page, you can refer to your widget directly.*
|
||||
|
||||
Here is what it looks like to say hello to the whole world: {{widget hello world}}!
|
||||
|
||||
|
||||
## Template
|
||||
*The template is javascript string that will be evaluated when the widget is loaded. Any keyword parameters*
|
||||
*you specify in the Usage section above will be available as variables, so you can use variable subsetitution*
|
||||
*in your template definition. The template must be enclose in a code block.*
|
||||
|
||||
```
|
||||
HELLO ${{token.keywords.split(" ").slice(1).join(" ") || ""}}
|
||||
```
|
||||
|
||||
## CSS
|
||||
*If you want to customize the styling of your widget, include CSS in this section. This CSS must be enclosed*
|
||||
*in a code block.*
|
||||
|
||||
```
|
||||
display: inline;
|
||||
background: green;
|
||||
padding: 3px;
|
||||
color: white;
|
||||
border-radius: 5px;
|
||||
```
|
||||
|
||||
## Processor
|
||||
*If you want full control over how your widget is processed, you can override the default processor*
|
||||
*here. The processor function below is the default.*
|
||||
|
||||
```
|
||||
function(token, widget) {{
|
||||
/*
|
||||
* token The token object created by the wiki parser. token.keywords and
|
||||
* token.params contain the keyword and paramater arguments from
|
||||
* the wiki page source where the widget was used.
|
||||
* widget: The widget instance. widget.css, widget.template, and
|
||||
* widget.processor contain the definitions parsed from the
|
||||
* corresponding sections on this page.
|
||||
*/
|
||||
var ret = '';
|
||||
eval("ret = `" + widget.template + "`");
|
||||
return ret;
|
||||
}}
|
||||
```
|
||||
"""
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def fields(cls):
|
||||
inherited = [field for field in super().fields() if field.name not in ("members", "uid")]
|
||||
return inherited + [
|
||||
String("name", primary_key=True),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{% block title %}TTFROG{% endblock %}</title>
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='site.css' ) }}">
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='froghat.css' ) }}">
|
||||
{% block styles %}
|
||||
{% endblock %}
|
||||
</head>
|
||||
|
|
@ -22,9 +22,9 @@
|
|||
<div class='content'>
|
||||
<main>
|
||||
{% for message in g.messages %}
|
||||
<div class="alert">
|
||||
<dialog class="alert">
|
||||
{{ message }}
|
||||
</div>
|
||||
</dialog>
|
||||
{% endfor %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
|
@ -36,7 +36,6 @@
|
|||
{% endblock %}
|
||||
</footer>
|
||||
|
||||
<script src="{{ url_for('static', filename='site.js') }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,20 +1,26 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block styles %}
|
||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='editor/editor.css' ) }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id='{% if user.can_write(page) %}editor{% else %}viewer{% endif %}' class='read-only'>
|
||||
{{ page.body }}
|
||||
</div>
|
||||
<article id='froghat'>{{ page.body }}</article>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='editor/commonmark.js' ) }}"></script>
|
||||
<script src="{{ url_for('static', filename='editor/turndown.js' ) }}"></script>
|
||||
<script src="{{ url_for('static', filename='editor/turndown-plugin-gfm.js' ) }}"></script>
|
||||
<script src="{{ url_for('static', filename='editor/editor.js' ) }}"></script>
|
||||
<!-- for converting markdown to html -->
|
||||
<script src="{{ url_for('static', filename='purify.min.js' ) }}"></script>
|
||||
<script src="{{ url_for('static', filename='marked.umd.min.js' ) }}"></script>
|
||||
<script src="{{ url_for('static', filename='froghat.js' ) }}"></script>
|
||||
{% if user.can_write(page) %}
|
||||
<script src="{{ url_for('static', filename='turndown.js' ) }}"></script>
|
||||
<script src="{{ url_for('static', filename='joplin-turndown-plugin-gfm.js' ) }}"></script>
|
||||
<script src="{{ url_for('static', filename='froghat-editor.js' ) }}"></script>
|
||||
{% endif %}
|
||||
<script>
|
||||
const wiki = new Froghat{% if user.can_write(page) %}Editor{% endif %}({plugins: [MacroPlugin]});
|
||||
wiki.run();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -1,22 +0,0 @@
|
|||
#editor {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#editor.loaded {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#editor.view {
|
||||
}
|
||||
|
||||
#editor.edit {
|
||||
font-family: monospace;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
#editor.wysiwyg {
|
||||
}
|
||||
|
||||
#editor.wysiwyg .md {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
|
@ -1,343 +0,0 @@
|
|||
class Editor {
|
||||
|
||||
#states = {
|
||||
VIEW: 'view',
|
||||
EDIT: 'edit',
|
||||
WYSIWYG: 'wysiwyg'
|
||||
}
|
||||
|
||||
commonmark = null;
|
||||
turndown = null;
|
||||
#cachedHTML = null;
|
||||
#cachedMarkdown = null;
|
||||
#state = null;
|
||||
#changed = false;
|
||||
|
||||
constructor(settings) {
|
||||
/*
|
||||
* Create a new Editor instance.
|
||||
*/
|
||||
this.element = document.getElementById(settings.editorId || 'editor');
|
||||
this.source = this.element.innerHTML;
|
||||
this.commonmark = {
|
||||
reader: new commonmark.Parser(),
|
||||
writer: new commonmark.HtmlRenderer(),
|
||||
};
|
||||
this.turndown = new TurndownService({
|
||||
headingStyle: 'atx',
|
||||
codeBlockStyle: 'fenced',
|
||||
});
|
||||
|
||||
this.plugins = {};
|
||||
settings.plugins.forEach(plugin => {
|
||||
this.plugins[plugin.name] = new plugin({name: plugin.name, editor: this});
|
||||
});
|
||||
this.#bindEvents();
|
||||
this.getHTML();
|
||||
this.element.classList.add("loaded");
|
||||
}
|
||||
|
||||
#bindEvents() {
|
||||
this.element.addEventListener('keydown', (evt) => {
|
||||
if (this.#state === this.#states.VIEW) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === 'Enter') {
|
||||
if (this.#state === this.#states.EDIT) {
|
||||
evt.preventDefault();
|
||||
this.insertAtCursor(document.createTextNode("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
if (this.#cachedMarkdown != this.element.innerHTML) {
|
||||
this.#changed = true;
|
||||
this.#cachedMarkdown = this.element.innerHTML;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
getState() {
|
||||
return this.#state;
|
||||
}
|
||||
|
||||
setState(newState) {
|
||||
this.#state = newState;
|
||||
Object.values(this.#states).forEach(state => {
|
||||
if (state == newState) {
|
||||
this.element.classList.add(state);
|
||||
} else {
|
||||
this.element.classList.remove(state);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getHTML() {
|
||||
/*
|
||||
* Convert the markdown source to HTML.
|
||||
*/
|
||||
if (this.#changed || !this.#cachedHTML) {
|
||||
var md = this.getMarkdown();
|
||||
var parsed = this.commonmark.reader.parse(md);
|
||||
var html = this.commonmark.writer.render(parsed);
|
||||
Object.values(this.plugins).forEach(plugin => {
|
||||
html = plugin.toHTML(html);
|
||||
});
|
||||
this.#cachedHTML = html;
|
||||
|
||||
}
|
||||
return this.#cachedHTML;
|
||||
}
|
||||
|
||||
getMarkdown() {
|
||||
/*
|
||||
* Return the current markdown.
|
||||
*/
|
||||
if (this.getState() === this.#states.EDIT) {
|
||||
this.#cachedMarkdown = this.element.innerHTML;
|
||||
} else if (this.getState() === this.#states.WYSIWYG) {
|
||||
var md = this.element.innerHTML;
|
||||
Object.values(this.plugins).forEach(plugin => {
|
||||
md = plugin.toMarkdown(md);
|
||||
});
|
||||
this.#cachedMarkdown = this.turndown.turndown(md);
|
||||
} else if (!this.#cachedMarkdown) {
|
||||
this.#cachedMarkdown = this.source;
|
||||
}
|
||||
this.#cachedMarkdown = this.#cachedMarkdown.replaceAll(/^>/mg, '>');
|
||||
return this.#cachedMarkdown;
|
||||
}
|
||||
|
||||
reset() {
|
||||
/*
|
||||
* Discard any unsaved edits and reset the editor to its initial state.
|
||||
*/
|
||||
this.#cachedHTML = null;
|
||||
this.#cachedMarkdown = null;
|
||||
this.view();
|
||||
}
|
||||
|
||||
view() {
|
||||
/*
|
||||
* Convert the editor read-only mode and display the current HTML.
|
||||
*/
|
||||
if (this.getState() === this.#states.VIEW) {
|
||||
return;
|
||||
}
|
||||
this.element.innerHTML = this.getHTML();
|
||||
this.setState(this.#states.VIEW);
|
||||
}
|
||||
|
||||
wysiwyg() {
|
||||
/*
|
||||
* Put the editor in WYSIWYG editing mode.
|
||||
*/
|
||||
if (this.getState() === this.#states.WYSIWYG) {
|
||||
return;
|
||||
}
|
||||
this.#changed = false;
|
||||
this.element.contentEditable = true;
|
||||
this.element.innerHTML = this.getHTML();
|
||||
this.setState(this.#states.WYSIWYG);
|
||||
}
|
||||
|
||||
edit() {
|
||||
/*
|
||||
* Put the editor into source editing mode.
|
||||
*/
|
||||
if (this.#state === this.#states.EDIT) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.#changed = false;
|
||||
this.element.contentEditable = true;
|
||||
this.element.innerHTML = this.getMarkdown();
|
||||
this.setState(this.#states.EDIT);
|
||||
}
|
||||
|
||||
insertAtCursor(node) {
|
||||
var sel, range, html;
|
||||
sel = window.getSelection();
|
||||
range = sel.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
range.insertNode(node);
|
||||
range.setStartAfter(node);
|
||||
this.element.focus();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
class EditorPlugin {
|
||||
constructor(settings) {
|
||||
this.name = settings.name;
|
||||
this.editor = settings.editor;
|
||||
};
|
||||
|
||||
cleanAttribute(attribute) {
|
||||
return attribute ? attribute.replace(/(\n+\s*)+/g, '\n') : ''
|
||||
}
|
||||
|
||||
toMarkdown(html) {
|
||||
return html;
|
||||
};
|
||||
|
||||
toHTML(md) {
|
||||
return md;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
class TablePlugin extends EditorPlugin {
|
||||
constructor(settings) {
|
||||
super(settings);
|
||||
this.editor.turndown.use(turndownPluginGfm.tables);
|
||||
this.pattern = /(?<contents>(?:\|[^|]+?)+\|[\s\n]*)(?:<|$)/gims;
|
||||
this.dividerPattern = /\|\s*\-+\s*\|/;
|
||||
}
|
||||
|
||||
toHTML(md) {
|
||||
md.matchAll(this.pattern).forEach(matched => {
|
||||
var html = '<table><thead>';
|
||||
var cellTag = 'th';
|
||||
matched.groups.contents.split("\n").forEach(line => {
|
||||
if (this.dividerPattern.test(line)) {
|
||||
html += "</thead><tbody>";
|
||||
cellTag = 'td';
|
||||
} else {
|
||||
html += "<tr>";
|
||||
var cells = line.split("|");
|
||||
html += "<" + cellTag + ">" + cells.slice(1, cells.length - 1).join("</" + cellTag + "><" + cellTag + ">") + "</" + cellTag + ">";
|
||||
html += "</tr>";
|
||||
}
|
||||
});
|
||||
html += "</tbody></table>";
|
||||
md = md.replaceAll(matched[1], html);
|
||||
});
|
||||
return md;
|
||||
}
|
||||
}
|
||||
|
||||
class MacroPlugin extends EditorPlugin {
|
||||
|
||||
macros = {
|
||||
// image: {}
|
||||
// toc {}
|
||||
// widget {}
|
||||
//
|
||||
html: {
|
||||
toHTML: (settings) => {
|
||||
var html = settings.block.replaceAll(">", ">").replaceAll("<", "<");
|
||||
return html;
|
||||
},
|
||||
toMarkdown: (node) => {
|
||||
const utf8encoder = new TextEncoder();
|
||||
return "{{html\n" + b64decode(node.dataset.block) + "\n}}";
|
||||
}
|
||||
},
|
||||
user: {
|
||||
toHTML: (settings) => {
|
||||
return document.querySelector("nav > ul > li.user > a:first-child").outerHTML;
|
||||
},
|
||||
},
|
||||
npc: {
|
||||
toHTML: (settings) => {
|
||||
var name = camelCase(settings.keywords).join(" ");
|
||||
var target = name.replaceAll(" ", "");
|
||||
return `<a href="/NPC/${target}" data-npc-name="${name}">👤 ${name}</a>`;
|
||||
},
|
||||
toMarkdown: (node) => {
|
||||
return `{{npc ${node.firstChild.dataset.npcName}}}`;
|
||||
},
|
||||
},
|
||||
spell: {
|
||||
toHTML: (settings) => {
|
||||
var name = camelCase(settings.keywords).join(" ");
|
||||
var target = name.replaceAll(" ", "");
|
||||
return `<a href="/Spell/${target}" data-spell-name="${name}">✨ ${name}</a>`;
|
||||
},
|
||||
toMarkdown: (node) => {
|
||||
return `{{spell ${node.firstChild.dataset.spellName}}}`;
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
constructor(settings) {
|
||||
super(settings);
|
||||
this.pattern = /(?<contents>{{(?<name>\w+)(?<keywords>(?:\s*\w+)*)(?<parameters>(?:\s+\w+="[^"]*")*?)(?<block>\b.*?)?}})/gms;
|
||||
this.paramPattern = /\b(?<name>[^=]+)="(?<value>[^"]*?)"\b/gm;
|
||||
|
||||
var plugin = this;
|
||||
this.editor.turndown.addRule('macros', {
|
||||
filter: ['span'],
|
||||
replacement: function (content, node, options) {
|
||||
const parser = plugin.macros[node.getAttribute('data-macro-name')].toMarkdown;
|
||||
if (parser) {
|
||||
return parser(node);
|
||||
}
|
||||
|
||||
var md = '{{' + node.getAttribute('data-macro-name');
|
||||
for (var param in node.getAttributeNames()) {
|
||||
var val = node.getAttribute(param);
|
||||
if (val) {
|
||||
md += val + (param == 'data-macro-name' ? " " : `="${node.getAttribute(param)}" `);
|
||||
}
|
||||
}
|
||||
md += '}}';
|
||||
return md;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
toHTML(md) {
|
||||
const utf8encoder = new TextEncoder();
|
||||
var output = md;
|
||||
output.matchAll(this.pattern).forEach(matched => {
|
||||
var macroName = matched.groups.name;
|
||||
var html = `<span class='macro' data-macro-name='${macroName}'`;
|
||||
var settings = {
|
||||
block: matched.groups.block,
|
||||
keywords: matched.groups.keywords,
|
||||
params: {},
|
||||
}
|
||||
matched.groups.parameters.matchAll(this.paramPattern).forEach(param => {
|
||||
settings.params[param.groups.name] = param.groups.value;
|
||||
html += ` data-param-${param.groups.name}="${param.groups.value}"`;
|
||||
});
|
||||
html += ` data-block="${b64encode(matched.groups.block)}"`;
|
||||
html += '>';
|
||||
html += this.macros[macroName].toHTML(settings);
|
||||
html += '</span>';
|
||||
output = output.replaceAll(matched[1], html);
|
||||
});
|
||||
return output;
|
||||
}
|
||||
}
|
||||
|
||||
function camelCase(words) {
|
||||
var output = [];
|
||||
words.trim().split(/\s+/g).forEach(word => {
|
||||
var lcWord = word.toLowerCase();
|
||||
output.push(lcWord.charAt(0).toUpperCase() + lcWord.slice(1));
|
||||
});
|
||||
return output;
|
||||
}
|
||||
|
||||
function b64encode(input) {
|
||||
const utf8encoder = new TextEncoder();
|
||||
return utf8encoder.encode(input).toBase64();
|
||||
}
|
||||
|
||||
function b64decode(input) {
|
||||
const utf8encoder = new TextEncoder();
|
||||
return utf8encoder.decode(input.fromBase64());
|
||||
}
|
||||
|
||||
|
||||
editor = new Editor({
|
||||
plugins: [TablePlugin, MacroPlugin]
|
||||
});
|
||||
editor.view();
|
||||
99
src/ttfrog/themes/default/static/froghat-editor.js
Normal file
99
src/ttfrog/themes/default/static/froghat-editor.js
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
class FroghatEditor extends Froghat {
|
||||
|
||||
run() {
|
||||
this.states = {
|
||||
VIEW: 'view',
|
||||
EDIT: 'edit',
|
||||
WYSIWYG: 'wysiwyg'
|
||||
}
|
||||
|
||||
this.turndown = new TurndownService({
|
||||
headingStyle: 'atx',
|
||||
codeBlockStyle: 'fenced',
|
||||
});
|
||||
this.turndown.use([turndownPluginGfm.gfm, turndownPluginGfm.tables]);
|
||||
this.turndown.keep(['pre']);
|
||||
this.#bindEvents();
|
||||
|
||||
this.plugins().forEach(plugin => { plugin.setEditable() });
|
||||
this.element.classList.add("loaded");
|
||||
this.view();
|
||||
}
|
||||
|
||||
#bindEvents() {
|
||||
this.element.addEventListener('keydown', (evt) => {
|
||||
if (this.state === this.states.VIEW) {
|
||||
return;
|
||||
}
|
||||
if (this.cachedMarkdown != this.element.textContent) {
|
||||
this.changed = true;
|
||||
this.cachedMarkdown = this.element.textContent;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
htmlToMarkdown(html) {
|
||||
return this.turndown.turndown(html || this.element.innerHTML);
|
||||
}
|
||||
|
||||
getMarkdown() {
|
||||
/*
|
||||
* Return the current markdown.
|
||||
*/
|
||||
if (this.getState() === this.states.EDIT) {
|
||||
var html = this.element.innerHTML;
|
||||
html = html.replaceAll(/<(?:div|br)>/ig, '');
|
||||
html = html.replaceAll(/<\/div>/ig, "\n");
|
||||
this.cachedMarkdown = decodeHtmlEntities(html);
|
||||
} else if (this.getState() === this.states.WYSIWYG) {
|
||||
this.cachedMarkdown = this.htmlToMarkdown(this.element.innerHTML);
|
||||
} if (!this.cachedMarkdown) {
|
||||
this.cachedMarkdown = this.element.textContent;
|
||||
}
|
||||
return this.cachedMarkdown;
|
||||
}
|
||||
|
||||
wysiwyg() {
|
||||
/*
|
||||
* Put the editor in WYSIWYG editing mode.
|
||||
*/
|
||||
if (this.getState() === this.states.WYSIWYG) {
|
||||
return;
|
||||
}
|
||||
this.changed = false;
|
||||
this.element.contentEditable = true;
|
||||
this.element.innerHTML = this.getHTML();
|
||||
Array.from(this.element.querySelectorAll('.macro')).forEach(el => {
|
||||
if (el.dataset.editable == "false") {
|
||||
el.contentEditable = false;
|
||||
el.style.opacity = 0.5;
|
||||
}
|
||||
});
|
||||
this.setState(this.states.WYSIWYG);
|
||||
}
|
||||
|
||||
edit() {
|
||||
/*
|
||||
* Put the editor into source editing mode.
|
||||
*/
|
||||
if (this.state === this.states.EDIT) {
|
||||
return;
|
||||
}
|
||||
this.changed = false;
|
||||
this.element.contentEditable = true;
|
||||
this.element.innerHTML = encodeHtmlEntities(this.getMarkdown());
|
||||
this.setState(this.states.EDIT);
|
||||
}
|
||||
|
||||
insertAtCursor(node) {
|
||||
var sel, range, html;
|
||||
sel = window.getSelection();
|
||||
range = sel.getRangeAt(0);
|
||||
range.deleteContents();
|
||||
range.insertNode(node);
|
||||
range.setStartAfter(node);
|
||||
this.element.focus();
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(range);
|
||||
}
|
||||
}
|
||||
|
|
@ -113,6 +113,7 @@ table {
|
|||
|
||||
th {
|
||||
border-bottom: 1px solid #000;
|
||||
padding: 3px;
|
||||
}
|
||||
th, td {
|
||||
padding: 2px;
|
||||
|
|
@ -206,3 +207,157 @@ footer {
|
|||
menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.left {
|
||||
display: block;
|
||||
float: left;
|
||||
margin:5px;
|
||||
max-width: 30%;
|
||||
}
|
||||
|
||||
.right {
|
||||
display: block;
|
||||
float: right;
|
||||
margin: 5px;
|
||||
max-width: 30%;
|
||||
}
|
||||
|
||||
.center {
|
||||
width: fit-content;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.box {
|
||||
border-radius: 5px;
|
||||
border: 1px solid black;
|
||||
background: #DEDEDE;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.striped > table {
|
||||
background: #FAFAFA;
|
||||
}
|
||||
.striped > table tr:nth-child(even) {
|
||||
background-color: #FFFFFF;
|
||||
}
|
||||
|
||||
.equal-widths > table {
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.layout > table {
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
.layout > table > thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
table td table {
|
||||
max-width:95%;
|
||||
}
|
||||
|
||||
pre {
|
||||
border: 1px dashed black;
|
||||
border-radius: 5px;
|
||||
padding: 10px;
|
||||
margin: 5px;
|
||||
background: #EEE;
|
||||
}
|
||||
|
||||
code {
|
||||
display: inline-block;
|
||||
border: 1px dashed black;
|
||||
border-radius: 5px;
|
||||
padding: 5px;
|
||||
background: #EEE;
|
||||
margin: 3px;
|
||||
}
|
||||
|
||||
div.macro {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
[data-macro-name="toc"] {
|
||||
display: inline;
|
||||
float: left;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
margin-right: 2em;
|
||||
margin-bottom: 2em;
|
||||
border: 1px solid #000;
|
||||
padding: 0ch 2ch;
|
||||
}
|
||||
|
||||
[data-macro-name="toc"] ul {
|
||||
box-sizing: border-box;
|
||||
list-style: none;
|
||||
padding-left: 0ch;
|
||||
font-weight: normal;
|
||||
margin-left: 0px;
|
||||
|
||||
}
|
||||
|
||||
[data-macro-name="toc"] ul ul {
|
||||
padding-left: 2ch;
|
||||
}
|
||||
|
||||
[data-macro-name="toc"] > ul:first-child {
|
||||
padding-left: 0ch;
|
||||
margin-left: 0px;
|
||||
}
|
||||
[data-macro-name="toc"] > ul:first-child > li {
|
||||
padding-left: 0ch;
|
||||
margin-left: 0px;
|
||||
}
|
||||
[data-macro-name="toc"] > ul:first-child > li {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
[data-macro-name="toc"] > ul > li {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
[data-macro-name="toc"] > ul > li {
|
||||
padding-left: 0ch;
|
||||
}
|
||||
|
||||
[data-macro-name="toc"] a {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
[data-macro-name="toc"] a:hover {
|
||||
background: #CCC;
|
||||
}
|
||||
|
||||
[data-macro-name="toc"] .header {
|
||||
font-size: var(--default-font-size);
|
||||
width: fit-content;
|
||||
margin: 2ch auto 0 auto;
|
||||
}
|
||||
|
||||
#froghat {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#froghat.loaded {
|
||||
display: block;
|
||||
}
|
||||
#froghat.wysiwyg {
|
||||
display: none,
|
||||
}
|
||||
#froghat.view {
|
||||
}
|
||||
|
||||
#froghat.edit {
|
||||
font-family: monospace;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
#froghat.wysiwyg {
|
||||
}
|
||||
|
||||
#froghat.wysiwyg .md {
|
||||
opacity: 0.5;
|
||||
}
|
||||
584
src/ttfrog/themes/default/static/froghat.js
Normal file
584
src/ttfrog/themes/default/static/froghat.js
Normal file
|
|
@ -0,0 +1,584 @@
|
|||
FroghatAPIv1 = {
|
||||
get: function(doc_id, callback) {
|
||||
(async () => {
|
||||
const raw = await fetch('/_/v1/get/' + doc_id, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
const res = await raw.json();
|
||||
if (res['code'] != 200) {
|
||||
console.error("APIv1 error: ", res)
|
||||
}
|
||||
callback(res);
|
||||
})();
|
||||
},
|
||||
|
||||
put: function(data, callback) {
|
||||
(async () => {
|
||||
const raw = await fetch('/_/v1/put/' + window.location.pathname, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
'body': data
|
||||
}),
|
||||
});
|
||||
const res = await raw.json();
|
||||
if (res['code'] != 200) {
|
||||
console.error("APIv1 error: ", res)
|
||||
}
|
||||
callback(res);
|
||||
})();
|
||||
},
|
||||
|
||||
search: function(space, query, callback) {
|
||||
(async () => {
|
||||
const raw = await fetch('/_/v1/search/' + space, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
'body': query
|
||||
}),
|
||||
});
|
||||
const res = await raw.json();
|
||||
if (res['code'] != 200) {
|
||||
console.error("APIv1 error: ", res)
|
||||
}
|
||||
callback(res);
|
||||
})();
|
||||
},
|
||||
};
|
||||
|
||||
class Froghat {
|
||||
constructor(settings) {
|
||||
/*
|
||||
* Create a new Froghat instance.
|
||||
*/
|
||||
this.api = settings.api || FroghatAPIv1;
|
||||
|
||||
this.element = document.getElementById(settings.editorId || 'froghat');
|
||||
|
||||
this.marked = marked;
|
||||
this.marked.use({
|
||||
breaks: false,
|
||||
gfm: true,
|
||||
});
|
||||
this.states = {
|
||||
VIEW: 'view',
|
||||
}
|
||||
this.cachedHTML = null;
|
||||
this.cachedMarkdown = null;
|
||||
this.state = null;
|
||||
this.changed = false;
|
||||
this.enabledPlugins = {};
|
||||
|
||||
settings.plugins.forEach(plugin => {
|
||||
this.enabledPlugins[plugin.name] = new plugin({name: plugin.name, wiki: this});
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
run() {
|
||||
this.element.classList.add("loaded");
|
||||
this.view();
|
||||
}
|
||||
|
||||
plugins() {
|
||||
return Object.values(this.enabledPlugins).sort((a, b) => { a.precedence < b.precedence });
|
||||
}
|
||||
|
||||
getState() {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
setState(newState) {
|
||||
this.state = newState;
|
||||
Object.values(this.states).forEach(state => {
|
||||
if (state == newState) {
|
||||
this.element.classList.add(state);
|
||||
} else {
|
||||
this.element.classList.remove(state);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
markdownToHTML(md) {
|
||||
var html = this.marked.parse(md);
|
||||
return html;
|
||||
}
|
||||
|
||||
getHTML(string) {
|
||||
/*
|
||||
* Convert the markdown source to HTML.
|
||||
*/
|
||||
if (this.changed || !this.cachedHTML) {
|
||||
this.cachedHTML = this.markdownToHTML(this.getMarkdown());
|
||||
}
|
||||
return this.cachedHTML;
|
||||
}
|
||||
|
||||
getMarkdown() {
|
||||
if (!this.cachedMarkdown) {
|
||||
this.cachedMarkdown = this.element.textContent;
|
||||
}
|
||||
return this.cachedMarkdown;
|
||||
}
|
||||
|
||||
|
||||
view() {
|
||||
/*
|
||||
* Convert the wiki read-only mode and display the current HTML.
|
||||
*/
|
||||
if (this.getState() === this.states.VIEW) {
|
||||
return;
|
||||
}
|
||||
this.element.innerHTML = this.getHTML();
|
||||
this.setState(this.states.VIEW);
|
||||
this.element.contentEditable = false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class FroghatPlugin {
|
||||
|
||||
constructor(settings) {
|
||||
this.name = settings.name;
|
||||
this.wiki = settings.wiki;
|
||||
this.precedence = 50;
|
||||
};
|
||||
|
||||
setEditable() {
|
||||
};
|
||||
|
||||
toMarkdown(html) {
|
||||
return html;
|
||||
};
|
||||
|
||||
toHTML(md) {
|
||||
return md;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
WIDGETS = {};
|
||||
|
||||
function loadWidget(name, callback) {
|
||||
if (Object.keys(WIDGETS).indexOf(name) == -1) {
|
||||
(async () => {
|
||||
await FroghatAPIv1.search("Widget", name, (res) => {
|
||||
if (res.code == 200) {
|
||||
function block(prefix) {
|
||||
return RegExp('##\\s*' + prefix + '.*?```\\w*(.+?)```', 'gims');
|
||||
};
|
||||
var html = res.response[0].body;
|
||||
var proc = block("Processor").exec(html)[1].trim();
|
||||
if (!proc) {
|
||||
proc = function(token, widget) {
|
||||
var name = token.keywords.split(" ").slice(1).join(" ");
|
||||
var ret = '';
|
||||
eval("ret = `" + widget.template + "`");
|
||||
return ret;
|
||||
}
|
||||
} else {
|
||||
eval(`proc = ${proc}`);
|
||||
}
|
||||
WIDGETS[name] = {
|
||||
template: block("Template").exec(html)[1],
|
||||
css: block("CSS").exec(html)[1],
|
||||
processor: proc
|
||||
};
|
||||
} else {
|
||||
WIDGETS[name] = {
|
||||
template: "",
|
||||
css: "",
|
||||
processor: function() { return `Invalid Widget: "${name}"` },
|
||||
};
|
||||
}
|
||||
if (callback) {
|
||||
callback(WIDGETS[name]);
|
||||
}
|
||||
});
|
||||
})();
|
||||
} else {
|
||||
return WIDGETS[name];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class MacroPlugin extends FroghatPlugin {
|
||||
|
||||
macros = {
|
||||
// image: {}
|
||||
|
||||
widget: {
|
||||
inline: true,
|
||||
editable: false,
|
||||
|
||||
toHTML: (token, node) => {
|
||||
var widgetName = token.keywords.split(" ")[0];
|
||||
var contents = '';
|
||||
var cached = loadWidget(widgetName, (widget) => {
|
||||
contents = widget.processor(token, widget);
|
||||
var targets = wiki.element.querySelectorAll(`[data-macro-name="widget"][data-keywords="${token.keywords}"]`);
|
||||
targets.forEach(widgetElement => {
|
||||
widgetElement.style = widget.css;
|
||||
widgetElement.innerHTML = contents;
|
||||
});
|
||||
wiki.cachedHTML = wiki.element.innerHTML;
|
||||
});
|
||||
var ret = node + (cached ? cached.processor(token, cached) : "");
|
||||
return ret;
|
||||
},
|
||||
postprocess: (html) => {
|
||||
const buf = document.createElement('div');
|
||||
buf.innerHTML = html;
|
||||
var targets = buf.querySelectorAll(`[data-macro-name="widget"]`);
|
||||
targets.forEach(widgetElement => {
|
||||
var widget = WIDGETS[widgetElement.dataset.keywords.split(" ")[0]];
|
||||
if (widget) {
|
||||
widgetElement.style = widget.css;
|
||||
}
|
||||
});
|
||||
return buf.innerHTML;
|
||||
},
|
||||
},
|
||||
|
||||
style: {
|
||||
inline: false,
|
||||
editable: true,
|
||||
toHTML: (token, node) => {
|
||||
return node.replace(/class="macro"/, `class="macro ${token.keywords}"`);
|
||||
}
|
||||
},
|
||||
|
||||
multiline: {
|
||||
inline: false,
|
||||
editable: true,
|
||||
fromHTML: (node, markdown) => {
|
||||
var content = node.innerHTML.replaceAll("<br>", "\0");
|
||||
content = content.replaceAll("{{{", "{{{");
|
||||
content = content.replaceAll("}}}", "}}}");
|
||||
content = encodeHtmlEntities(content);
|
||||
return `{{{${content}}}}`;
|
||||
},
|
||||
},
|
||||
|
||||
user: {
|
||||
inline: true,
|
||||
toHTML: (token, node) => {
|
||||
return node + document.querySelector("nav > ul > li.user > a:first-child").outerHTML;
|
||||
},
|
||||
},
|
||||
toc: {
|
||||
inline: true,
|
||||
element: 'aside',
|
||||
postprocess: (html) => {
|
||||
const subList = (depth) => {
|
||||
var li = document.createElement("li");
|
||||
var ul = document.createElement("ul");
|
||||
li.appendChild(ul);
|
||||
return li;
|
||||
};
|
||||
|
||||
var tocIndex = 0;
|
||||
|
||||
const buf = document.createElement('div');
|
||||
buf.innerHTML = html;
|
||||
buf.querySelectorAll('[data-macro-name="toc"]').forEach(tocElement => {
|
||||
|
||||
var params = {
|
||||
depth: tocElement.dataset.paramDepth || 3,
|
||||
keywords: tocElement.dataset.paramKeywords || "",
|
||||
};
|
||||
|
||||
const headings = buf.querySelectorAll("h2, h3, h4, h5, h6");
|
||||
|
||||
var header = document.createElement("h2");
|
||||
header.className = 'header';
|
||||
header.textContent = 'Table of Contents';
|
||||
tocElement.prepend(header);
|
||||
|
||||
const toc = document.createElement("ul");
|
||||
toc.setAttribute('role', 'list');
|
||||
|
||||
var lastDepth = null;
|
||||
var ul = toc;
|
||||
headings.forEach(heading => {
|
||||
var depth = parseInt(heading.nodeName[1]) - 1;
|
||||
if (depth > params.depth) {
|
||||
return;
|
||||
}
|
||||
if (lastDepth === null) {
|
||||
lastDepth = depth;
|
||||
}
|
||||
|
||||
var index = document.createElement("li");
|
||||
var ref = camelCase(heading.textContent).join("");
|
||||
index.innerHTML = `<a href="#${heading.id}">${heading.textContent}</a>`;
|
||||
|
||||
var list = null;
|
||||
if (depth > lastDepth) {
|
||||
list = subList(depth);
|
||||
ul.appendChild(list);
|
||||
ul = list.firstChild;
|
||||
} else if (depth < lastDepth) {
|
||||
var list = subList(depth);
|
||||
toc.appendChild(list);
|
||||
ul = list.firstChild;
|
||||
}
|
||||
ul.appendChild(index);
|
||||
lastDepth = depth;
|
||||
});
|
||||
|
||||
if (params.keywords) {
|
||||
params.keywords.split(" ").forEach(className => {
|
||||
if (className) {
|
||||
toc.classList.add(className);
|
||||
}
|
||||
});
|
||||
}
|
||||
tocElement.appendChild(toc);
|
||||
});
|
||||
return buf.innerHTML;
|
||||
},
|
||||
},
|
||||
npc: {
|
||||
toHTML: (settings) => {
|
||||
var name = camelCase(settings.keywords).join(" ");
|
||||
var target = name.replaceAll(" ", "");
|
||||
return `<a href="/NPC/${target}" data-npc-name="${name}">👤 ${name}</a>`;
|
||||
},
|
||||
fromHTML: (node) => {
|
||||
return `{{npc ${node.firstChild.dataset.npcName}}}`;
|
||||
},
|
||||
},
|
||||
spell: {
|
||||
toHTML: (settings) => {
|
||||
var name = camelCase(settings.keywords).join(" ");
|
||||
var target = name.replaceAll(" ", "");
|
||||
return `<a href="/Spell/${target}" data-spell-name="${name}">✨ ${name}</a>`;
|
||||
},
|
||||
fromHTML: (node) => {
|
||||
return `{{spell ${node.firstChild.dataset.spellName}}}`;
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
getTokens = (pattern, source) => {
|
||||
const matched = source.matchAll(pattern);
|
||||
const tokens = [];
|
||||
if (!matched) {
|
||||
return tokens;
|
||||
}
|
||||
matched.forEach(match => {
|
||||
if (!this.macros[match.groups.name]) {
|
||||
return;
|
||||
}
|
||||
const token = {
|
||||
type: 'macro',
|
||||
source: match[0],
|
||||
matched: match,
|
||||
macro: this.macros[match.groups.name],
|
||||
keywords: (match.groups.keywords || '').trim(),
|
||||
inline: this.macros[match.groups.name].inline,
|
||||
editable: this.macros[match.groups.name].editable || false,
|
||||
params: {},
|
||||
rendered: '',
|
||||
};
|
||||
if (match.groups.parameters) {
|
||||
var params = decodeHtmlEntities(match.groups.parameters.trim());
|
||||
params.matchAll(this.paramPattern).forEach(param => {
|
||||
if (param.groups) {
|
||||
var name = param.groups.name;
|
||||
token.params[name] = decodeHtmlEntities(param.groups.value.trim());
|
||||
}
|
||||
});
|
||||
}
|
||||
token.rendered = this.renderToken(token);
|
||||
tokens.push(token);
|
||||
});
|
||||
return tokens;
|
||||
}
|
||||
|
||||
renderToken = (token) => {
|
||||
const tag = token.macro.element || (token.inline ? 'span' : 'div');
|
||||
var node = `<${tag} class="macro" data-plugin-name="macro" data-macro-name="${token.matched.groups.name}"`;
|
||||
|
||||
for (var name in token.params) {
|
||||
node += ` data-param-${name}="${(token.params[name] || "").trim()}"`;
|
||||
}
|
||||
|
||||
if (token.keywords) {
|
||||
node += ` data-keywords="${token.keywords}"`;
|
||||
}
|
||||
|
||||
node += ` data-inline="${token.inline}"`;
|
||||
|
||||
node += ` data-editable="${token.editable}"`;
|
||||
|
||||
node += ">";
|
||||
|
||||
if (token.macro.toHTML) {
|
||||
node = token.macro.toHTML(token, node);
|
||||
}
|
||||
|
||||
if (token.inline) {
|
||||
node += `</${tag}>`;
|
||||
}
|
||||
|
||||
// preserve the wrapping element, unless it is a paragraph.
|
||||
if (token.matched.groups.wrap) {
|
||||
if (token.matched.groups.wrap != '<p>') {
|
||||
node = token.matched.groups.wrap + node + token.matched.groups.endwrap;
|
||||
}
|
||||
}
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
|
||||
constructor(settings) {
|
||||
super(settings);
|
||||
|
||||
this.pattern = new RegExp(
|
||||
'(?<!`)(?<wrap><[^>]+?>)?' + // capture the enclosing HTML tag, if any
|
||||
'{{' + // start of the macro
|
||||
'(?<name>\\w+)' + // the macro name
|
||||
'(?<keywords>(?:\\s(?:\\s*(?:[\\w-](?![\\w-]+=))+))+)?' + // zero or more keywords separated by spaces
|
||||
'(?<parameters>[^}<]+)?' + // anything else before the closing
|
||||
'\\s*(?<closed>}})?' + // is the tag closed?
|
||||
'(?<endwrap>(?:>!\\<)*?<\\/[^>]+?>)?', // capture the enclosing HTML tag, if any
|
||||
'mg'
|
||||
);
|
||||
this.endPattern = /<p>}}\s*<\/p>/mg;
|
||||
this.paramPattern = /\s*(?<name>[^=]+)="(?<value>[^"]*)"/g;
|
||||
this.multilinePattern = /(?<!\{{3}[\s\n]*)\{{3}[\s\n]*(?<content>(.(?!\}{3})*)+?)[\s\n]*\}{3}/smg;
|
||||
|
||||
const plugin = this;
|
||||
|
||||
this.wiki.marked.use({
|
||||
extensions: [
|
||||
{
|
||||
name: 'heading',
|
||||
renderer(token) {
|
||||
var ref = camelCase(token.text).join("");
|
||||
return `<h${token.depth} id='${ref}'>${token.text}</h${token.depth}>`;
|
||||
}
|
||||
},
|
||||
],
|
||||
hooks: {
|
||||
preprocess: (source) => {
|
||||
const matched = source.matchAll(plugin.multilinePattern);
|
||||
var md = source;
|
||||
matched.forEach(match => {
|
||||
var wrapper = '<span class="macro" data-plugin-name="macro" data-macro-name="multiline" data-inline="true" style="display: inline-block;">';
|
||||
var content = decodeHtmlEntities(match.groups.content)
|
||||
.replaceAll("\n", "::BR::")
|
||||
.replaceAll('`', '::QU::')
|
||||
.replaceAll('{', '::OC::')
|
||||
.replaceAll('}', '::CC::');
|
||||
md = md.replaceAll(match[0], wrapper + '\0' + content + '\0</span>');
|
||||
});
|
||||
return md;
|
||||
},
|
||||
postprocess: (html) => {
|
||||
plugin.getTokens(plugin.pattern, html).forEach(token => {
|
||||
var pat = new RegExp('(?<!<pre>.+?)' + token.source, 'mg');
|
||||
html = html.replaceAll(pat, token.rendered);
|
||||
});
|
||||
html = html.replaceAll(plugin.endPattern, '</div>');
|
||||
Object.values(plugin.macros).forEach(macro => {
|
||||
if (macro.postprocess) {
|
||||
html = macro.postprocess(html);
|
||||
}
|
||||
});
|
||||
|
||||
html = html.replaceAll("::BR::", "<br>")
|
||||
.replaceAll('::QU::', '`')
|
||||
.replaceAll('::OC::', '{')
|
||||
.replaceAll('::CC::', '}');
|
||||
|
||||
// remove unsafe html tags
|
||||
return DOMPurify.sanitize(html, {});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
setEditable() {
|
||||
const plugin = this;
|
||||
this.wiki.turndown.addRule('macros', {
|
||||
filter: function (node, options) {
|
||||
return ((node.nodeName === 'ASIDE' || node.nodeName === 'DIV' || node.nodeName === 'SPAN') && node.dataset.pluginName == 'macro')
|
||||
},
|
||||
replacement: function (content, node, options) {
|
||||
var macro = plugin.macros[node.getAttribute('data-macro-name')];
|
||||
|
||||
if (macro.fromHTML) {
|
||||
return macro.fromHTML(node, content);
|
||||
}
|
||||
|
||||
var md = '{{' + node.dataset.macroName;
|
||||
if (node.dataset.keywords) {
|
||||
md += " " + node.dataset.keywords;
|
||||
}
|
||||
|
||||
for (var paramName in node.dataset) {
|
||||
if (paramName.indexOf("param") != 0) {
|
||||
continue;
|
||||
}
|
||||
md += ` ${paramName.replace('param', '').toLowerCase()}="${node.dataset[paramName]}"`
|
||||
};
|
||||
|
||||
if (node.dataset.inline == "false") {
|
||||
md = `\n\n${md}\n\n`;
|
||||
md += plugin.wiki.htmlToMarkdown(node.innerHTML);
|
||||
md += "\n\n}}\n\n";
|
||||
} else {
|
||||
md += "}}";
|
||||
}
|
||||
|
||||
// replace nulls with line breaks, for the multiline macro
|
||||
md = md.replaceAll('\0', "\n");
|
||||
|
||||
return md;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function camelCase(words) {
|
||||
var output = [];
|
||||
words.trim().split(/\s+/g).forEach(word => {
|
||||
var lcWord = word.toLowerCase();
|
||||
output.push(lcWord.charAt(0).toUpperCase() + lcWord.slice(1));
|
||||
});
|
||||
return output;
|
||||
}
|
||||
|
||||
function b64encode(input) {
|
||||
return new TextEncoder().encode(input).toBase64();
|
||||
}
|
||||
|
||||
function b64decode(input) {
|
||||
return new TextDecoder().decode(Uint8Array.fromBase64(input));
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(html) {
|
||||
var txt = document.createElement("textarea");
|
||||
txt.innerHTML = html;
|
||||
return txt.value;
|
||||
}
|
||||
|
||||
function encodeHtmlEntities(str) {
|
||||
return str.replace(/[\u00A0-\u9999<>\&]/g, i => '&#'+i.charCodeAt(0)+';')
|
||||
}
|
||||
|
|
@ -43,6 +43,7 @@ var rules = {};
|
|||
rules.tableCell = {
|
||||
filter: ['th', 'td'],
|
||||
replacement: function (content, node) {
|
||||
if (tableShouldBeSkipped(nodeParentTable(node))) return content;
|
||||
return cell(content, node)
|
||||
}
|
||||
};
|
||||
|
|
@ -50,19 +51,26 @@ rules.tableCell = {
|
|||
rules.tableRow = {
|
||||
filter: 'tr',
|
||||
replacement: function (content, node) {
|
||||
const parentTable = nodeParentTable(node);
|
||||
if (tableShouldBeSkipped(parentTable)) return content;
|
||||
|
||||
var borderCells = '';
|
||||
var alignMap = { left: ':--', right: '--:', center: ':-:' };
|
||||
|
||||
if (isHeadingRow(node)) {
|
||||
for (var i = 0; i < node.childNodes.length; i++) {
|
||||
const colCount = tableColCount(parentTable);
|
||||
for (var i = 0; i < colCount; i++) {
|
||||
const childNode = colCount >= node.childNodes.length ? null : node.childNodes[i];
|
||||
var border = '---';
|
||||
var align = (
|
||||
node.childNodes[i].getAttribute('align') || ''
|
||||
).toLowerCase();
|
||||
var align = childNode ? (childNode.getAttribute('align') || '').toLowerCase() : '';
|
||||
|
||||
if (align) border = alignMap[align] || border;
|
||||
|
||||
if (childNode) {
|
||||
borderCells += cell(border, node.childNodes[i]);
|
||||
} else {
|
||||
borderCells += cell(border, null, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
return '\n' + content + (borderCells ? '\n' + borderCells : '')
|
||||
|
|
@ -73,13 +81,27 @@ rules.table = {
|
|||
// Only convert tables with a heading row.
|
||||
// Tables with no heading row are kept using `keep` (see below).
|
||||
filter: function (node) {
|
||||
return node.nodeName === 'TABLE' && isHeadingRow(node.rows[0])
|
||||
return node.nodeName === 'TABLE'
|
||||
},
|
||||
|
||||
replacement: function (content) {
|
||||
replacement: function (content, node) {
|
||||
if (tableShouldBeSkipped(node)) return content;
|
||||
|
||||
// Ensure there are no blank lines
|
||||
content = content.replace('\n\n', '\n');
|
||||
return '\n\n' + content + '\n\n'
|
||||
content = content.replace(/\n+/g, '\n');
|
||||
|
||||
// If table has no heading, add an empty one so as to get a valid Markdown table
|
||||
var secondLine = content.trim().split('\n');
|
||||
if (secondLine.length >= 2) secondLine = secondLine[1];
|
||||
var secondLineIsDivider = secondLine.indexOf('| ---') === 0;
|
||||
|
||||
var columnCount = tableColCount(node);
|
||||
var emptyHeader = '';
|
||||
if (columnCount && !secondLineIsDivider) {
|
||||
emptyHeader = '|' + ' |'.repeat(columnCount) + '\n' + '|' + ' --- |'.repeat(columnCount);
|
||||
}
|
||||
|
||||
return '\n\n' + emptyHeader + content + '\n\n'
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -120,16 +142,71 @@ function isFirstTbody (element) {
|
|||
)
|
||||
}
|
||||
|
||||
function cell (content, node) {
|
||||
var index = indexOf.call(node.parentNode.childNodes, node);
|
||||
function cell (content, node = null, index = null) {
|
||||
if (index === null) index = indexOf.call(node.parentNode.childNodes, node);
|
||||
var prefix = ' ';
|
||||
if (index === 0) prefix = '| ';
|
||||
return prefix + content + ' |'
|
||||
let filteredContent = content.trim().replace(/\n\r/g, '<br>').replace(/\n/g, "<br>");
|
||||
filteredContent = filteredContent.replace(/\|+/g, '\\|');
|
||||
while (filteredContent.length < 3) filteredContent += ' ';
|
||||
if (node) filteredContent = handleColSpan(filteredContent, node, ' ');
|
||||
return prefix + filteredContent + ' |'
|
||||
}
|
||||
|
||||
function nodeContainsTable(node) {
|
||||
if (!node.childNodes) return false;
|
||||
|
||||
for (let i = 0; i < node.childNodes.length; i++) {
|
||||
const child = node.childNodes[i];
|
||||
if (child.nodeName === 'TABLE') return true;
|
||||
if (nodeContainsTable(child)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Various conditions under which a table should be skipped - i.e. each cell
|
||||
// will be rendered one after the other as if they were paragraphs.
|
||||
function tableShouldBeSkipped(tableNode) {
|
||||
if (!tableNode) return true;
|
||||
if (!tableNode.rows) return true;
|
||||
if (tableNode.rows.length === 1 && tableNode.rows[0].childNodes.length <= 1) return true; // Table with only one cell
|
||||
|
||||
// Not sure why we're excluding this. possibly because it'll freak out the parser? --evilchili
|
||||
//if (nodeContainsTable(tableNode)) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function nodeParentTable(node) {
|
||||
let parent = node.parentNode;
|
||||
while (parent.nodeName !== 'TABLE') {
|
||||
parent = parent.parentNode;
|
||||
if (!parent) return null;
|
||||
}
|
||||
return parent;
|
||||
}
|
||||
|
||||
function handleColSpan(content, node, emptyChar) {
|
||||
const colspan = node.getAttribute('colspan') || 1;
|
||||
for (let i = 1; i < colspan; i++) {
|
||||
content += ' | ' + emptyChar.repeat(3);
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
function tableColCount(node) {
|
||||
let maxColCount = 0;
|
||||
for (let i = 0; i < node.rows.length; i++) {
|
||||
const row = node.rows[i];
|
||||
const colCount = row.childNodes.length;
|
||||
if (colCount > maxColCount) maxColCount = colCount;
|
||||
}
|
||||
return maxColCount
|
||||
}
|
||||
|
||||
function tables (turndownService) {
|
||||
turndownService.keep(function (node) {
|
||||
return node.nodeName === 'TABLE' && !isHeadingRow(node.rows[0])
|
||||
return node.nodeName === 'TABLE'
|
||||
});
|
||||
for (var key in rules) turndownService.addRule(key, rules[key]);
|
||||
}
|
||||
60
src/ttfrog/themes/default/static/marked.umd.min.js
vendored
Normal file
60
src/ttfrog/themes/default/static/marked.umd.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
3
src/ttfrog/themes/default/static/purify.min.js
vendored
Normal file
3
src/ttfrog/themes/default/static/purify.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -1,9 +0,0 @@
|
|||
@import 'toastui-editor-viewer.min.css';
|
||||
|
||||
#viewer {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.toastui-editor-contents {
|
||||
font-size: var(--default-font-size);
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
var viewer = new toastui.Editor({
|
||||
viewer: true,
|
||||
el: document.querySelector("#viewer"),
|
||||
usageStatistics: false,
|
||||
});
|
||||
viewer.setMarkdown(document.getElementById("data_form__body").value);
|
||||
|
|
@ -123,10 +123,13 @@ def view(table, path):
|
|||
clean_table = re.sub(r"[^a-zA-Z0-9]", "", unquote(table))
|
||||
clean_path = re.sub(r"[^a-zA-Z0-9]", "", unquote(path))
|
||||
if clean_table != table or clean_path != clean_path:
|
||||
app.log.warning(f"Invalid table/path: {table=}, {path=}. Redirecting to {clean_table}/{clean_path}")
|
||||
return redirect(url_for("view", table=clean_table, path=clean_path), 302)
|
||||
|
||||
app.log.debug(f"Looking for {table=}, {path=}")
|
||||
page, error = get_page(request.path, table=table, create_okay=True)
|
||||
if error:
|
||||
app.log.error(error)
|
||||
g.messages.append(str(error))
|
||||
return rendered(page)
|
||||
|
||||
|
|
@ -150,6 +153,7 @@ def put(table, path):
|
|||
if parent:
|
||||
parent.update(members=list(set(parent.members + [updated])))
|
||||
app.db.save(parent)
|
||||
app.log.debug(f"Saved page at uri {updated.uri}")
|
||||
return api_response(response=dict(updated))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
import pytest
|
||||
|
|
@ -12,7 +13,7 @@ from ttfrog import schema
|
|||
@pytest.fixture
|
||||
def app():
|
||||
with TemporaryDirectory() as path:
|
||||
fixture_db = GrungDB.with_schema(schema, path=path, storage=MemoryStorage)
|
||||
fixture_db = GrungDB.with_schema(schema, path=Path(path), storage=MemoryStorage)
|
||||
ttfrog.app.load_config(defaults=None, IN_MEMORY_DB=1)
|
||||
ttfrog.app.initialize(db=fixture_db, force=True)
|
||||
yield ttfrog.app
|
||||
|
|
@ -21,8 +22,6 @@ def app():
|
|||
|
||||
def test_create(app):
|
||||
user = schema.User(name="john", email="john@foo", password="powerfulCat")
|
||||
assert user.uid
|
||||
assert user._metadata.fields["uid"].unique
|
||||
|
||||
# insert
|
||||
john_something = app.db.save(user)
|
||||
|
|
@ -32,7 +31,6 @@ def test_create(app):
|
|||
assert app.db.User.get(doc_id=last_insert_id) == john_something
|
||||
assert john_something.name == user.name
|
||||
assert john_something.email == user.email
|
||||
assert john_something.uid == user.uid
|
||||
|
||||
# update
|
||||
john_something.name = "james?"
|
||||
|
|
@ -42,6 +40,7 @@ def test_create(app):
|
|||
assert before_update != after_update
|
||||
|
||||
|
||||
@pytest.mark.xfail
|
||||
def test_permissions(app):
|
||||
john = app.db.save(schema.User(name="john", email="john@foo", password="powerfulCat"))
|
||||
players = app.db.save(schema.Group(name="players", members=[john]))
|
||||
|
|
|
|||
39
test/test_editor.py
Normal file
39
test/test_editor.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
import pytest
|
||||
from grung.db import GrungDB
|
||||
from tinydb.storages import MemoryStorage
|
||||
|
||||
import ttfrog.app
|
||||
from ttfrog import schema
|
||||
from ttfrog.bootstrap import bootstrap
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
with TemporaryDirectory() as path:
|
||||
fixture_db = GrungDB.with_schema(schema, path=Path(path), storage=MemoryStorage)
|
||||
ttfrog.app.load_config(defaults=None, IN_MEMORY_DB=1)
|
||||
ttfrog.app.initialize(db=fixture_db, force=True)
|
||||
ttfrog.app.web.config.update({"TESTING": True})
|
||||
bootstrap()
|
||||
yield ttfrog.app
|
||||
ttfrog.app.db.truncate()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def routes(app):
|
||||
import ttfrog.web
|
||||
|
||||
return ttfrog.web
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(routes):
|
||||
return ttfrog.app.web.test_client()
|
||||
|
||||
|
||||
def test_get(client, routes):
|
||||
response = client.get(ttfrog.app.config.VIEW_URI)
|
||||
assert response.status_code == 200
|
||||
Loading…
Reference in New Issue
Block a user