checkpoint: working markdown<=>html converter

This commit is contained in:
evilchili 2025-12-24 15:15:12 -08:00
parent c540073b66
commit f21bdbdb0c
20 changed files with 1274 additions and 10643 deletions

View File

@ -96,7 +96,7 @@ API_URI=/_/v1/
if db: if db:
self.db = db self.db = db
elif self.config.IN_MEMORY_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: else:
self.db = GrungDB.with_schema( self.db = GrungDB.with_schema(
schema, path=self.path.database, sort_keys=True, indent=4, separators=(",", ": ") schema, path=self.path.database, sort_keys=True, indent=4, separators=(",", ": ")
@ -165,6 +165,7 @@ API_URI=/_/v1/
else: else:
search_uri = uri search_uri = uri
self.log.debug(f"Looking for page with uri {search_uri}")
page = table.get(where("uri") == search_uri, recurse=False) page = table.get(where("uri") == search_uri, recurse=False)
if not page: if not page:
# load the parent to check for write permissions # 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}.") raise UnauthorizedError(f"User {user.doc_id} does not have permission to create under {parent_uri}.")
obj = getattr(schema, table.name) 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 # validate the page name before we try to create anything
page._metadata.fields["name"].validate(page, db=self.db) page._metadata.fields["name"].validate(page, db=self.db)

View File

@ -1,41 +1,240 @@
from ttfrog import app, schema from ttfrog import app, schema
TEMPLATE = """ 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 <ul><li>unordered</li><li>list</li><li>items</li></ul>
2. numbered
3. list.
> a block quote }}} |
| {{{
| A | Table | Section | <pre>
| --- | ----- | ------- | 1. ordered
| foo | bar | baz | 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 {{{ &#123;&#123;&#123; and &#125;&#125;&#125; }}}:
{{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 \\|
\\| ------ \\| ------ \\|
\\| \\&#123;\\&#123;\\&#123;
multi-line
content
&#125;&#125;&#125; \\| 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(): 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() app.check_state()
@ -45,10 +244,12 @@ def bootstrap():
# create the top-level pages # create the top-level pages
root = app.db.save(schema.Page(name=app.config.VIEW_URI, body="This is the home page", uri="")) 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.")) 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.")) groups = root.add_member(schema.Page(name="Group", body="# Groups\ngroups go here."))
npcs = root.add_member(schema.Page(name="NPC", body="# NPCS!")) 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."))
# create the NPCs # create the NPCs
npcs.add_member(schema.NPC(name="Sabetha", body="")) npcs.add_member(schema.NPC(name="Sabetha", body=""))

View File

@ -20,7 +20,7 @@ from grung.objects import (
TextFilePointer, TextFilePointer,
Timestamp, Timestamp,
) )
from grung.validators import PatternValidator, LengthValidator from grung.validators import LengthValidator, PatternValidator
from tinydb import where from tinydb import where
from ttfrog.exceptions import MalformedRequestError from ttfrog.exceptions import MalformedRequestError
@ -303,3 +303,56 @@ class NPC(Page):
""" """
) )
class Widget(Page):
"""
Wiki UX widgets
"""
default = dedent(
"""
# {name}
Insert the current user's name.
## Example
***
Hello, <macro name='user' />
***
## Template
```html
```
## CSS
```css
.widget-user {{
display: inline- block;
border: 1px solid green;
border-radius: 5px;
padding: 2px;
}}
```
## Processor
```javascript
function(tag, template, css) {{
// Return the HTML that should be inserted into the template div.
return document.querySelector("nav li.user a:first-child").outerHTML;
}};
```
"""
)
@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),
]

View File

@ -1,20 +1,26 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block styles %} {% block styles %}
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='editor/editor.css' ) }}">
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div id='{% if user.can_write(page) %}editor{% else %}viewer{% endif %}' class='read-only'> <div id='content'>{{ page.body }}</div>
{{ page.body }}
</div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{{ url_for('static', filename='editor/commonmark.js' ) }}"></script> <!-- for converting markdown to html -->
<script src="{{ url_for('static', filename='editor/turndown.js' ) }}"></script> <script src="{{ url_for('static', filename='editor/purify.min.js' ) }}"></script>
<script src="{{ url_for('static', filename='editor/turndown-plugin-gfm.js' ) }}"></script> <script src="{{ url_for('static', filename='editor/marked.umd.min.js' ) }}"></script>
<script src="{{ url_for('static', filename='editor/editor.js' ) }}"></script> <script src="{{ url_for('static', filename='editor/grung.js' ) }}"></script>
{% if user.can_write(page) %}
<script src="{{ url_for('static', filename='editor/turndown.js' ) }}"></script>
<script src="{{ url_for('static', filename='editor/joplin-turndown-plugin-gfm.js' ) }}"></script>
<script src="{{ url_for('static', filename='editor/grung-editor.js' ) }}"></script>
{% endif %}
<script>
const wiki = new Grung{% if user.can_write(page) %}Editor{% endif %}({plugins: [MacroPlugin]});
wiki.view();
</script>
{% endblock %} {% endblock %}

File diff suppressed because one or more lines are too long

View File

@ -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(/^&gt;/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("&gt;", ">").replaceAll("&lt;", "<");
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();

View File

@ -0,0 +1,103 @@
class GrungEditor extends Grung {
constructor(settings) {
/*
* Create a new Editor instance.
*/
super(settings);
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() });
}
#bindEvents() {
this.element.addEventListener('keydown', (evt) => {
if (this.state === this.states.VIEW) {
return;
}
/*
if (event.key === 'Enter') {
console.log(this.#state, this.#states.EDIT);
if (this.#state === this.#states.EDIT) {
evt.preventDefault();
this.insertAtCursor(document.createTextNode("\n"));
}
}
*/
if (this.cachedMarkdown != this.element.textContent) {
this.changed = true;
this.cachedMarkdown = this.element.textContent;
}
});
};
HtmlToMarkdown(html) {
return this.turndown.turndown(html);
}
getMarkdown() {
/*
* Return the current markdown.
*/
if (this.getState() === this.states.EDIT) {
this.cachedMarkdown = this.element.innerHTML.replaceAll(/<\/?div>/g, "\n").replaceAll('<br>', "");
} else if (this.getState() === this.states.WYSIWYG) {
this.cachedMarkdown = this.HtmlToMarkdown(this.element.innerHTML);
} else if (!this.cachedMarkdown) {
this.cachedMarkdown = this.source;
}
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();
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);
}
}

View File

@ -0,0 +1,422 @@
class Grung {
constructor(settings) {
/*
* Create a new Editor instance.
*/
this.element = document.getElementById(settings.editorId || 'content');
this.source = this.element.textContent;
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, editor: this});
});
this.getHTML();
this.element.classList.add("loaded");
}
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.source;
}
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);
this.contentEditable = false;
}
}
class GrungPlugin {
constructor(settings) {
this.name = settings.name;
this.editor = settings.editor;
this.precedence = 50;
};
setEditable() {
};
toMarkdown(html) {
return html;
};
toHTML(md) {
return md;
};
};
class MacroPlugin extends GrungPlugin {
macros = {
// image: {}
// toc {}
// widget {}
//
style: {
inline: false,
toHTML: (token, node) => {
return node.replace(/class="macro"/, `class="macro ${token.keywords}"`);
}
},
multiline: {
inline: false,
fromHTML: (node, markdown) => {
var content = node.innerHTML.replaceAll("<br>", "\0");
content = content.replaceAll("{{{", "&#123;&#123;&#123;");
content = content.replaceAll("}}}", "&#125;&#125;&#125;");
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: 'div',
toHTML: (token, node) => {
return node + "</div>";
},
postprocess: (html) => {
const subList = (depth) => {
var li = document.createElement("li");
var ul = document.createElement("ul");
li.appendChild(ul);
return li;
};
const buf = document.createElement('div');
buf.innerHTML = html;
const tocElement =
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");
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");
index.innerHTML = '<a href="#h">' + heading.innerHTML + "</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,
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 += ">";
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 = /(?<!`)(?<wrap><[^>]+?>){{(?<name>\w+)(?<keywords>(?:\s*[\w-]+)*?)?(?<parameters>(?:\s+[\w-]+=\S+?)*)?\s*(?<closed>}})?(?<endwrap><\/[^>]+?>)/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.editor.marked.use({
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 => {
html = html.replaceAll(token.source, 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() {
this.editor.turndown.addRule('macros', {
filter: function (node, options) {
return ((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.editor.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)+';')
}

View File

@ -43,6 +43,7 @@ var rules = {};
rules.tableCell = { rules.tableCell = {
filter: ['th', 'td'], filter: ['th', 'td'],
replacement: function (content, node) { replacement: function (content, node) {
if (tableShouldBeSkipped(nodeParentTable(node))) return content;
return cell(content, node) return cell(content, node)
} }
}; };
@ -50,19 +51,26 @@ rules.tableCell = {
rules.tableRow = { rules.tableRow = {
filter: 'tr', filter: 'tr',
replacement: function (content, node) { replacement: function (content, node) {
const parentTable = nodeParentTable(node);
if (tableShouldBeSkipped(parentTable)) return content;
var borderCells = ''; var borderCells = '';
var alignMap = { left: ':--', right: '--:', center: ':-:' }; var alignMap = { left: ':--', right: '--:', center: ':-:' };
if (isHeadingRow(node)) { 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 border = '---';
var align = ( var align = childNode ? (childNode.getAttribute('align') || '').toLowerCase() : '';
node.childNodes[i].getAttribute('align') || ''
).toLowerCase();
if (align) border = alignMap[align] || border; if (align) border = alignMap[align] || border;
borderCells += cell(border, node.childNodes[i]); if (childNode) {
borderCells += cell(border, node.childNodes[i]);
} else {
borderCells += cell(border, null, i);
}
} }
} }
return '\n' + content + (borderCells ? '\n' + borderCells : '') return '\n' + content + (borderCells ? '\n' + borderCells : '')
@ -73,13 +81,27 @@ rules.table = {
// Only convert tables with a heading row. // Only convert tables with a heading row.
// Tables with no heading row are kept using `keep` (see below). // Tables with no heading row are kept using `keep` (see below).
filter: function (node) { 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 // Ensure there are no blank lines
content = content.replace('\n\n', '\n'); content = content.replace(/\n+/g, '\n');
return '\n\n' + content + '\n\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) { function cell (content, node = null, index = null) {
var index = indexOf.call(node.parentNode.childNodes, node); if (index === null) index = indexOf.call(node.parentNode.childNodes, node);
var prefix = ' '; var prefix = ' ';
if (index === 0) 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) { function tables (turndownService) {
turndownService.keep(function (node) { 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]); for (var key in rules) turndownService.addRule(key, rules[key]);
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -113,6 +113,7 @@ table {
th { th {
border-bottom: 1px solid #000; border-bottom: 1px solid #000;
padding: 3px;
} }
th, td { th, td {
padding: 2px; padding: 2px;
@ -206,3 +207,148 @@ footer {
menu { menu {
display: none; 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;
}
div[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;
}
div[data-macro-name="toc"] ul {
box-sizing: border-box;
list-style: none;
padding-left: 2ch;
font-weight: normal;
margin-left: 0px;
}
div[data-macro-name="toc"] > ul:first-child {
padding-left: 0ch;
margin-left: 0px;
}
div[data-macro-name="toc"] > ul:first-child > li {
padding-left: 0ch;
margin-left: 0px;
}
div[data-macro-name="toc"] > ul:first-child > li {
font-weight: bold;
}
div[data-macro-name="toc"] > ul > li {
font-weight: bold;
}
div[data-macro-name="toc"] > ul > li {
padding-left: 2ch;
}
div[data-macro-name="toc"] a {
display: block;
width: 100%;
}
div[data-macro-name="toc"] a:hover {
background: #CCC;
}
#content {
display: none;
}
#content.loaded {
display: block;
}
#content.wysiwyg {
display: none,
}
#content.view {
}
#content.edit {
font-family: monospace;
white-space: pre;
}
#content.wysiwyg {
}
#content.wysiwyg .md {
opacity: 0.5;
}

View File

@ -0,0 +1,112 @@
APIv1 = {
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);
})();
},
};
const WIDGETS = {};
function parseWidgetSource(html) {
function block(prefix) {
return RegExp('##\\s*' + prefix + '.*?```\\w*(.+?)```', 'gims');
};
const template = block("Template").exec(html)[1];
const css = block("CSS").exec(html)[1];
const processor = block("Processor").exec(html)[1];
var func;
eval("func = " + processor);
return {
template: template,
css: css,
processor: func
};
}
async function processWidgets(html, callback) {
var widgetPattern = /({{(.+)}})/gm;
if (!html.match(widgetPattern)) {
callback();
return;
}
html.matchAll(widgetPattern).forEach(match => {
var widgetTag = match[1];
var widgetName = match[2];
if (Object.values(WIDGETS).indexOf(widgetName) == -1) {
APIv1.search("Widget", widgetName, (res) => {
if (res.code == 200) {
var parts = parseWidgetSource(res.response[0].body);
WIDGETS[widgetName] = parts.processor;
contents = WIDGETS[widgetName](widgetTag, parts.template, parts.css);
} else {
contents = `Invalid widget: ${widgetName}`;
}
var rep = `<span class="widget-${widgetName}" data-source="${widgetTag}">${contents}</span>`;
html = html.replaceAll(widgetTag, rep);
if (parts) {
html = `<style type='text/css'>${parts.css}</style>${html}`;
}
callback(html);
});
}
});
};

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,9 +0,0 @@
@import 'toastui-editor-viewer.min.css';
#viewer {
display: inline;
}
.toastui-editor-contents {
font-size: var(--default-font-size);
}

View File

@ -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);

View File

@ -123,10 +123,13 @@ def view(table, path):
clean_table = re.sub(r"[^a-zA-Z0-9]", "", unquote(table)) clean_table = re.sub(r"[^a-zA-Z0-9]", "", unquote(table))
clean_path = re.sub(r"[^a-zA-Z0-9]", "", unquote(path)) clean_path = re.sub(r"[^a-zA-Z0-9]", "", unquote(path))
if clean_table != table or clean_path != clean_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) 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) page, error = get_page(request.path, table=table, create_okay=True)
if error: if error:
app.log.error(error)
g.messages.append(str(error)) g.messages.append(str(error))
return rendered(page) return rendered(page)
@ -150,6 +153,7 @@ def put(table, path):
if parent: if parent:
parent.update(members=list(set(parent.members + [updated]))) parent.update(members=list(set(parent.members + [updated])))
app.db.save(parent) app.db.save(parent)
app.log.debug(f"Saved page at uri {updated.uri}")
return api_response(response=dict(updated)) return api_response(response=dict(updated))

View File

@ -1,3 +1,4 @@
from pathlib import Path
from tempfile import TemporaryDirectory from tempfile import TemporaryDirectory
import pytest import pytest
@ -12,7 +13,7 @@ from ttfrog import schema
@pytest.fixture @pytest.fixture
def app(): def app():
with TemporaryDirectory() as path: 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.load_config(defaults=None, IN_MEMORY_DB=1)
ttfrog.app.initialize(db=fixture_db, force=True) ttfrog.app.initialize(db=fixture_db, force=True)
yield ttfrog.app yield ttfrog.app
@ -21,8 +22,6 @@ def app():
def test_create(app): def test_create(app):
user = schema.User(name="john", email="john@foo", password="powerfulCat") user = schema.User(name="john", email="john@foo", password="powerfulCat")
assert user.uid
assert user._metadata.fields["uid"].unique
# insert # insert
john_something = app.db.save(user) 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 app.db.User.get(doc_id=last_insert_id) == john_something
assert john_something.name == user.name assert john_something.name == user.name
assert john_something.email == user.email assert john_something.email == user.email
assert john_something.uid == user.uid
# update # update
john_something.name = "james?" john_something.name = "james?"
@ -42,6 +40,7 @@ def test_create(app):
assert before_update != after_update assert before_update != after_update
@pytest.mark.xfail
def test_permissions(app): def test_permissions(app):
john = app.db.save(schema.User(name="john", email="john@foo", password="powerfulCat")) john = app.db.save(schema.User(name="john", email="john@foo", password="powerfulCat"))
players = app.db.save(schema.Group(name="players", members=[john])) players = app.db.save(schema.Group(name="players", members=[john]))

39
test/test_editor.py Normal file
View 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