checkpoint: working markdown<=>html converter
This commit is contained in:
parent
c540073b66
commit
f21bdbdb0c
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 {{{ {{{ 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():
|
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=""))
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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();
|
|
||||||
103
src/ttfrog/themes/default/static/editor/grung-editor.js
Normal file
103
src/ttfrog/themes/default/static/editor/grung-editor.js
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
422
src/ttfrog/themes/default/static/editor/grung.js
Normal file
422
src/ttfrog/themes/default/static/editor/grung.js
Normal 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("{{{", "{{{");
|
||||||
|
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: '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)+';')
|
||||||
|
}
|
||||||
|
|
@ -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]);
|
||||||
}
|
}
|
||||||
60
src/ttfrog/themes/default/static/editor/marked.umd.min.js
vendored
Normal file
60
src/ttfrog/themes/default/static/editor/marked.umd.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
3
src/ttfrog/themes/default/static/editor/purify.min.js
vendored
Normal file
3
src/ttfrog/themes/default/static/editor/purify.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
112
src/ttfrog/themes/default/static/site.js
Normal file
112
src/ttfrog/themes/default/static/site.js
Normal 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
|
|
@ -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_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))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
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