Compare commits
No commits in common. "2d07a5c5f66670368040e6581c3e2d325d9f7c97" and "c540073b6600a832bf4481a78973786a25146a69" have entirely different histories.
2d07a5c5f6
...
c540073b66
|
|
@ -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=self.path.database, storage=MemoryStorage)
|
self.db = GrungDB.with_schema(schema, path=None, 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,7 +165,6 @@ 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
|
||||||
|
|
@ -183,7 +182,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, uri=search_uri)
|
page = obj(name=page_name, body=obj.default.format(name=page_name), parent=parent)
|
||||||
|
|
||||||
# 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,240 +1,41 @@
|
||||||
from ttfrog import app, schema
|
from ttfrog import app, schema
|
||||||
|
|
||||||
TEMPLATE = """
|
TEMPLATE = """
|
||||||
# Wiki Syntax
|
# Heading 1
|
||||||
{{toc box depth="3"}}
|
|
||||||
|
|
||||||
## Basic Formatting
|
## Heading 2
|
||||||
|
|
||||||
|
### Heading 3
|
||||||
|
|
||||||
The wiki uses [GitHub-flavored Markdown](https://github.github.com/gfm/).
|
#### Heading 4
|
||||||
|
|
||||||
{{style equal-widths striped
|
##### Heading 5
|
||||||
|
|
||||||
| Type | To Get |
|
###### Heading 6
|
||||||
|------|--------|
|
|
||||||
| `*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](/).
|
||||||
|
|
||||||
<ul><li>unordered</li><li>list</li><li>items</li></ul>
|
1. a
|
||||||
|
2. numbered
|
||||||
|
3. list.
|
||||||
|
|
||||||
}}} |
|
> a block quote
|
||||||
| {{{
|
|
||||||
|
|
||||||
<pre>
|
| A | Table | Section |
|
||||||
1. ordered
|
| --- | ----- | ------- |
|
||||||
1. list
|
| foo | bar | baz |
|
||||||
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 poplating the first Page, the Admin user and the Admins group.
|
Bootstrap the database entries by populating the first Page, the Admin user and the Admins group.
|
||||||
"""
|
"""
|
||||||
app.check_state()
|
app.check_state()
|
||||||
|
|
||||||
|
|
@ -244,13 +45,10 @@ 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!"))
|
||||||
widgets = root.add_member(schema.Page(name="Widget", body="Widgets go here."))
|
wiki = root.add_member(schema.Page(name="Wiki", body=TEMPLATE))
|
||||||
widgets.add_member(schema.Widget(name="hello", body=schema.Widget.default))
|
|
||||||
|
|
||||||
# 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 LengthValidator, PatternValidator
|
from grung.validators import PatternValidator, LengthValidator
|
||||||
from tinydb import where
|
from tinydb import where
|
||||||
|
|
||||||
from ttfrog.exceptions import MalformedRequestError
|
from ttfrog.exceptions import MalformedRequestError
|
||||||
|
|
@ -303,88 +303,3 @@ class NPC(Page):
|
||||||
|
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class Widget(Page):
|
|
||||||
"""
|
|
||||||
Wiki UX widgets
|
|
||||||
"""
|
|
||||||
|
|
||||||
default = dedent(
|
|
||||||
"""
|
|
||||||
This is a sample widget that you can customize to your liking. Widget pages must contain at minimum the
|
|
||||||
**Template** section. It, along with the optional **CSS** and **Processor** sections, will be parsed by
|
|
||||||
wiki at display time. All other content on this page is ignored, so you can include usage docs, exmaples,
|
|
||||||
and so on, just like this text and the annnotations below in *italics*.
|
|
||||||
|
|
||||||
The name of your widget is the portion of the URI following `/Widget/`.
|
|
||||||
|
|
||||||
# Hello, World Example Widget
|
|
||||||
*Provide a description of your widget.*
|
|
||||||
|
|
||||||
Insert the word "HELLO" and optionally a name.
|
|
||||||
|
|
||||||
## Usage
|
|
||||||
*Display the usage of your widget. Ensure that the example is enclosed in a preformatted (`pre`) block.*
|
|
||||||
*Be sure to include a description of any parameters and keyword arguments.*
|
|
||||||
|
|
||||||
<pre>
|
|
||||||
{{widget hello [NAME] }}
|
|
||||||
</pre>
|
|
||||||
|
|
||||||
|
|
||||||
## Example
|
|
||||||
*Include one or more example uses of the widget. Once you save this page, you can refer to your widget directly.*
|
|
||||||
|
|
||||||
Here is what it looks like to say hello to the whole world: {{widget hello world}}!
|
|
||||||
|
|
||||||
|
|
||||||
## Template
|
|
||||||
*The template is javascript string that will be evaluated when the widget is loaded. Any keyword parameters*
|
|
||||||
*you specify in the Usage section above will be available as variables, so you can use variable subsetitution*
|
|
||||||
*in your template definition. The template must be enclose in a code block.*
|
|
||||||
|
|
||||||
```
|
|
||||||
HELLO ${{token.keywords.split(" ").slice(1).join(" ") || ""}}
|
|
||||||
```
|
|
||||||
|
|
||||||
## CSS
|
|
||||||
*If you want to customize the styling of your widget, include CSS in this section. This CSS must be enclosed*
|
|
||||||
*in a code block.*
|
|
||||||
|
|
||||||
```
|
|
||||||
display: inline;
|
|
||||||
background: green;
|
|
||||||
padding: 3px;
|
|
||||||
color: white;
|
|
||||||
border-radius: 5px;
|
|
||||||
```
|
|
||||||
|
|
||||||
## Processor
|
|
||||||
*If you want full control over how your widget is processed, you can override the default processor*
|
|
||||||
*here. The processor function below is the default.*
|
|
||||||
|
|
||||||
```
|
|
||||||
function(token, widget) {{
|
|
||||||
/*
|
|
||||||
* token The token object created by the wiki parser. token.keywords and
|
|
||||||
* token.params contain the keyword and paramater arguments from
|
|
||||||
* the wiki page source where the widget was used.
|
|
||||||
* widget: The widget instance. widget.css, widget.template, and
|
|
||||||
* widget.processor contain the definitions parsed from the
|
|
||||||
* corresponding sections on this page.
|
|
||||||
*/
|
|
||||||
var ret = '';
|
|
||||||
eval("ret = `" + widget.template + "`");
|
|
||||||
return ret;
|
|
||||||
}}
|
|
||||||
```
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def fields(cls):
|
|
||||||
inherited = [field for field in super().fields() if field.name not in ("members", "uid")]
|
|
||||||
return inherited + [
|
|
||||||
String("name", primary_key=True),
|
|
||||||
]
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>{% block title %}TTFROG{% endblock %}</title>
|
<title>{% block title %}TTFROG{% endblock %}</title>
|
||||||
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='froghat.css' ) }}">
|
<link rel="stylesheet" type="text/css" href="{{ url_for('static', filename='site.css' ) }}">
|
||||||
{% block styles %}
|
{% block styles %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
|
|
@ -22,9 +22,9 @@
|
||||||
<div class='content'>
|
<div class='content'>
|
||||||
<main>
|
<main>
|
||||||
{% for message in g.messages %}
|
{% for message in g.messages %}
|
||||||
<dialog class="alert">
|
<div class="alert">
|
||||||
{{ message }}
|
{{ message }}
|
||||||
</dialog>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% block content %}{% endblock %}
|
{% block content %}{% endblock %}
|
||||||
</main>
|
</main>
|
||||||
|
|
@ -36,6 +36,7 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='site.js') }}"></script>
|
||||||
{% block scripts %}{% endblock %}
|
{% block scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,20 @@
|
||||||
{% 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 %}
|
||||||
<article id='froghat'>{{ page.body }}</article>
|
<div id='{% if user.can_write(page) %}editor{% else %}viewer{% endif %}' class='read-only'>
|
||||||
|
{{ page.body }}
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<!-- for converting markdown to html -->
|
<script src="{{ url_for('static', filename='editor/commonmark.js' ) }}"></script>
|
||||||
<script src="{{ url_for('static', filename='purify.min.js' ) }}"></script>
|
<script src="{{ url_for('static', filename='editor/turndown.js' ) }}"></script>
|
||||||
<script src="{{ url_for('static', filename='marked.umd.min.js' ) }}"></script>
|
<script src="{{ url_for('static', filename='editor/turndown-plugin-gfm.js' ) }}"></script>
|
||||||
<script src="{{ url_for('static', filename='froghat.js' ) }}"></script>
|
<script src="{{ url_for('static', filename='editor/editor.js' ) }}"></script>
|
||||||
{% if user.can_write(page) %}
|
|
||||||
<script src="{{ url_for('static', filename='turndown.js' ) }}"></script>
|
|
||||||
<script src="{{ url_for('static', filename='joplin-turndown-plugin-gfm.js' ) }}"></script>
|
|
||||||
<script src="{{ url_for('static', filename='froghat-editor.js' ) }}"></script>
|
|
||||||
{% endif %}
|
|
||||||
<script>
|
|
||||||
const wiki = new Froghat{% if user.can_write(page) %}Editor{% endif %}({plugins: [MacroPlugin]});
|
|
||||||
wiki.run();
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
10207
src/ttfrog/themes/default/static/editor/commonmark.js
Normal file
10207
src/ttfrog/themes/default/static/editor/commonmark.js
Normal file
File diff suppressed because one or more lines are too long
22
src/ttfrog/themes/default/static/editor/editor.css
Normal file
22
src/ttfrog/themes/default/static/editor/editor.css
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
#editor {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#editor.loaded {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
#editor.view {
|
||||||
|
}
|
||||||
|
|
||||||
|
#editor.edit {
|
||||||
|
font-family: monospace;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
||||||
|
#editor.wysiwyg {
|
||||||
|
}
|
||||||
|
|
||||||
|
#editor.wysiwyg .md {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
343
src/ttfrog/themes/default/static/editor/editor.js
Normal file
343
src/ttfrog/themes/default/static/editor/editor.js
Normal file
|
|
@ -0,0 +1,343 @@
|
||||||
|
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();
|
||||||
|
|
@ -43,7 +43,6 @@ 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)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -51,26 +50,19 @@ 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)) {
|
||||||
const colCount = tableColCount(parentTable);
|
for (var i = 0; i < node.childNodes.length; i++) {
|
||||||
for (var i = 0; i < colCount; i++) {
|
|
||||||
const childNode = colCount >= node.childNodes.length ? null : node.childNodes[i];
|
|
||||||
var border = '---';
|
var border = '---';
|
||||||
var align = childNode ? (childNode.getAttribute('align') || '').toLowerCase() : '';
|
var align = (
|
||||||
|
node.childNodes[i].getAttribute('align') || ''
|
||||||
|
).toLowerCase();
|
||||||
|
|
||||||
if (align) border = alignMap[align] || border;
|
if (align) border = alignMap[align] || border;
|
||||||
|
|
||||||
if (childNode) {
|
borderCells += cell(border, node.childNodes[i]);
|
||||||
borderCells += cell(border, node.childNodes[i]);
|
|
||||||
} else {
|
|
||||||
borderCells += cell(border, null, i);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return '\n' + content + (borderCells ? '\n' + borderCells : '')
|
return '\n' + content + (borderCells ? '\n' + borderCells : '')
|
||||||
|
|
@ -81,27 +73,13 @@ 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'
|
return node.nodeName === 'TABLE' && isHeadingRow(node.rows[0])
|
||||||
},
|
},
|
||||||
|
|
||||||
replacement: function (content, node) {
|
replacement: function (content) {
|
||||||
if (tableShouldBeSkipped(node)) return content;
|
|
||||||
|
|
||||||
// Ensure there are no blank lines
|
// Ensure there are no blank lines
|
||||||
content = content.replace(/\n+/g, '\n');
|
content = content.replace('\n\n', '\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'
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -142,71 +120,16 @@ function isFirstTbody (element) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function cell (content, node = null, index = null) {
|
function cell (content, node) {
|
||||||
if (index === null) index = indexOf.call(node.parentNode.childNodes, node);
|
var index = indexOf.call(node.parentNode.childNodes, node);
|
||||||
var prefix = ' ';
|
var prefix = ' ';
|
||||||
if (index === 0) prefix = '| ';
|
if (index === 0) prefix = '| ';
|
||||||
let filteredContent = content.trim().replace(/\n\r/g, '<br>').replace(/\n/g, "<br>");
|
return prefix + content + ' |'
|
||||||
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'
|
return node.nodeName === 'TABLE' && !isHeadingRow(node.rows[0])
|
||||||
});
|
});
|
||||||
for (var key in rules) turndownService.addRule(key, rules[key]);
|
for (var key in rules) turndownService.addRule(key, rules[key]);
|
||||||
}
|
}
|
||||||
|
|
@ -1,99 +0,0 @@
|
||||||
class FroghatEditor extends Froghat {
|
|
||||||
|
|
||||||
run() {
|
|
||||||
this.states = {
|
|
||||||
VIEW: 'view',
|
|
||||||
EDIT: 'edit',
|
|
||||||
WYSIWYG: 'wysiwyg'
|
|
||||||
}
|
|
||||||
|
|
||||||
this.turndown = new TurndownService({
|
|
||||||
headingStyle: 'atx',
|
|
||||||
codeBlockStyle: 'fenced',
|
|
||||||
});
|
|
||||||
this.turndown.use([turndownPluginGfm.gfm, turndownPluginGfm.tables]);
|
|
||||||
this.turndown.keep(['pre']);
|
|
||||||
this.#bindEvents();
|
|
||||||
|
|
||||||
this.plugins().forEach(plugin => { plugin.setEditable() });
|
|
||||||
this.element.classList.add("loaded");
|
|
||||||
this.view();
|
|
||||||
}
|
|
||||||
|
|
||||||
#bindEvents() {
|
|
||||||
this.element.addEventListener('keydown', (evt) => {
|
|
||||||
if (this.state === this.states.VIEW) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.cachedMarkdown != this.element.textContent) {
|
|
||||||
this.changed = true;
|
|
||||||
this.cachedMarkdown = this.element.textContent;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
htmlToMarkdown(html) {
|
|
||||||
return this.turndown.turndown(html || this.element.innerHTML);
|
|
||||||
}
|
|
||||||
|
|
||||||
getMarkdown() {
|
|
||||||
/*
|
|
||||||
* Return the current markdown.
|
|
||||||
*/
|
|
||||||
if (this.getState() === this.states.EDIT) {
|
|
||||||
var html = this.element.innerHTML;
|
|
||||||
html = html.replaceAll(/<(?:div|br)>/ig, '');
|
|
||||||
html = html.replaceAll(/<\/div>/ig, "\n");
|
|
||||||
this.cachedMarkdown = decodeHtmlEntities(html);
|
|
||||||
} else if (this.getState() === this.states.WYSIWYG) {
|
|
||||||
this.cachedMarkdown = this.htmlToMarkdown(this.element.innerHTML);
|
|
||||||
} if (!this.cachedMarkdown) {
|
|
||||||
this.cachedMarkdown = this.element.textContent;
|
|
||||||
}
|
|
||||||
return this.cachedMarkdown;
|
|
||||||
}
|
|
||||||
|
|
||||||
wysiwyg() {
|
|
||||||
/*
|
|
||||||
* Put the editor in WYSIWYG editing mode.
|
|
||||||
*/
|
|
||||||
if (this.getState() === this.states.WYSIWYG) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.changed = false;
|
|
||||||
this.element.contentEditable = true;
|
|
||||||
this.element.innerHTML = this.getHTML();
|
|
||||||
Array.from(this.element.querySelectorAll('.macro')).forEach(el => {
|
|
||||||
if (el.dataset.editable == "false") {
|
|
||||||
el.contentEditable = false;
|
|
||||||
el.style.opacity = 0.5;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.setState(this.states.WYSIWYG);
|
|
||||||
}
|
|
||||||
|
|
||||||
edit() {
|
|
||||||
/*
|
|
||||||
* Put the editor into source editing mode.
|
|
||||||
*/
|
|
||||||
if (this.state === this.states.EDIT) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.changed = false;
|
|
||||||
this.element.contentEditable = true;
|
|
||||||
this.element.innerHTML = encodeHtmlEntities(this.getMarkdown());
|
|
||||||
this.setState(this.states.EDIT);
|
|
||||||
}
|
|
||||||
|
|
||||||
insertAtCursor(node) {
|
|
||||||
var sel, range, html;
|
|
||||||
sel = window.getSelection();
|
|
||||||
range = sel.getRangeAt(0);
|
|
||||||
range.deleteContents();
|
|
||||||
range.insertNode(node);
|
|
||||||
range.setStartAfter(node);
|
|
||||||
this.element.focus();
|
|
||||||
sel.removeAllRanges();
|
|
||||||
sel.addRange(range);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,584 +0,0 @@
|
||||||
FroghatAPIv1 = {
|
|
||||||
get: function(doc_id, callback) {
|
|
||||||
(async () => {
|
|
||||||
const raw = await fetch('/_/v1/get/' + doc_id, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const res = await raw.json();
|
|
||||||
if (res['code'] != 200) {
|
|
||||||
console.error("APIv1 error: ", res)
|
|
||||||
}
|
|
||||||
callback(res);
|
|
||||||
})();
|
|
||||||
},
|
|
||||||
|
|
||||||
put: function(data, callback) {
|
|
||||||
(async () => {
|
|
||||||
const raw = await fetch('/_/v1/put/' + window.location.pathname, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
'body': data
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const res = await raw.json();
|
|
||||||
if (res['code'] != 200) {
|
|
||||||
console.error("APIv1 error: ", res)
|
|
||||||
}
|
|
||||||
callback(res);
|
|
||||||
})();
|
|
||||||
},
|
|
||||||
|
|
||||||
search: function(space, query, callback) {
|
|
||||||
(async () => {
|
|
||||||
const raw = await fetch('/_/v1/search/' + space, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
'body': query
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const res = await raw.json();
|
|
||||||
if (res['code'] != 200) {
|
|
||||||
console.error("APIv1 error: ", res)
|
|
||||||
}
|
|
||||||
callback(res);
|
|
||||||
})();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
class Froghat {
|
|
||||||
constructor(settings) {
|
|
||||||
/*
|
|
||||||
* Create a new Froghat instance.
|
|
||||||
*/
|
|
||||||
this.api = settings.api || FroghatAPIv1;
|
|
||||||
|
|
||||||
this.element = document.getElementById(settings.editorId || 'froghat');
|
|
||||||
|
|
||||||
this.marked = marked;
|
|
||||||
this.marked.use({
|
|
||||||
breaks: false,
|
|
||||||
gfm: true,
|
|
||||||
});
|
|
||||||
this.states = {
|
|
||||||
VIEW: 'view',
|
|
||||||
}
|
|
||||||
this.cachedHTML = null;
|
|
||||||
this.cachedMarkdown = null;
|
|
||||||
this.state = null;
|
|
||||||
this.changed = false;
|
|
||||||
this.enabledPlugins = {};
|
|
||||||
|
|
||||||
settings.plugins.forEach(plugin => {
|
|
||||||
this.enabledPlugins[plugin.name] = new plugin({name: plugin.name, wiki: this});
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
run() {
|
|
||||||
this.element.classList.add("loaded");
|
|
||||||
this.view();
|
|
||||||
}
|
|
||||||
|
|
||||||
plugins() {
|
|
||||||
return Object.values(this.enabledPlugins).sort((a, b) => { a.precedence < b.precedence });
|
|
||||||
}
|
|
||||||
|
|
||||||
getState() {
|
|
||||||
return this.state;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(newState) {
|
|
||||||
this.state = newState;
|
|
||||||
Object.values(this.states).forEach(state => {
|
|
||||||
if (state == newState) {
|
|
||||||
this.element.classList.add(state);
|
|
||||||
} else {
|
|
||||||
this.element.classList.remove(state);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
markdownToHTML(md) {
|
|
||||||
var html = this.marked.parse(md);
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
|
|
||||||
getHTML(string) {
|
|
||||||
/*
|
|
||||||
* Convert the markdown source to HTML.
|
|
||||||
*/
|
|
||||||
if (this.changed || !this.cachedHTML) {
|
|
||||||
this.cachedHTML = this.markdownToHTML(this.getMarkdown());
|
|
||||||
}
|
|
||||||
return this.cachedHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
getMarkdown() {
|
|
||||||
if (!this.cachedMarkdown) {
|
|
||||||
this.cachedMarkdown = this.element.textContent;
|
|
||||||
}
|
|
||||||
return this.cachedMarkdown;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
view() {
|
|
||||||
/*
|
|
||||||
* Convert the wiki read-only mode and display the current HTML.
|
|
||||||
*/
|
|
||||||
if (this.getState() === this.states.VIEW) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.element.innerHTML = this.getHTML();
|
|
||||||
this.setState(this.states.VIEW);
|
|
||||||
this.element.contentEditable = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
class FroghatPlugin {
|
|
||||||
|
|
||||||
constructor(settings) {
|
|
||||||
this.name = settings.name;
|
|
||||||
this.wiki = settings.wiki;
|
|
||||||
this.precedence = 50;
|
|
||||||
};
|
|
||||||
|
|
||||||
setEditable() {
|
|
||||||
};
|
|
||||||
|
|
||||||
toMarkdown(html) {
|
|
||||||
return html;
|
|
||||||
};
|
|
||||||
|
|
||||||
toHTML(md) {
|
|
||||||
return md;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
WIDGETS = {};
|
|
||||||
|
|
||||||
function loadWidget(name, callback) {
|
|
||||||
if (Object.keys(WIDGETS).indexOf(name) == -1) {
|
|
||||||
(async () => {
|
|
||||||
await FroghatAPIv1.search("Widget", name, (res) => {
|
|
||||||
if (res.code == 200) {
|
|
||||||
function block(prefix) {
|
|
||||||
return RegExp('##\\s*' + prefix + '.*?```\\w*(.+?)```', 'gims');
|
|
||||||
};
|
|
||||||
var html = res.response[0].body;
|
|
||||||
var proc = block("Processor").exec(html)[1].trim();
|
|
||||||
if (!proc) {
|
|
||||||
proc = function(token, widget) {
|
|
||||||
var name = token.keywords.split(" ").slice(1).join(" ");
|
|
||||||
var ret = '';
|
|
||||||
eval("ret = `" + widget.template + "`");
|
|
||||||
return ret;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
eval(`proc = ${proc}`);
|
|
||||||
}
|
|
||||||
WIDGETS[name] = {
|
|
||||||
template: block("Template").exec(html)[1],
|
|
||||||
css: block("CSS").exec(html)[1],
|
|
||||||
processor: proc
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
WIDGETS[name] = {
|
|
||||||
template: "",
|
|
||||||
css: "",
|
|
||||||
processor: function() { return `Invalid Widget: "${name}"` },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (callback) {
|
|
||||||
callback(WIDGETS[name]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
} else {
|
|
||||||
return WIDGETS[name];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class MacroPlugin extends FroghatPlugin {
|
|
||||||
|
|
||||||
macros = {
|
|
||||||
// image: {}
|
|
||||||
|
|
||||||
widget: {
|
|
||||||
inline: true,
|
|
||||||
editable: false,
|
|
||||||
|
|
||||||
toHTML: (token, node) => {
|
|
||||||
var widgetName = token.keywords.split(" ")[0];
|
|
||||||
var contents = '';
|
|
||||||
var cached = loadWidget(widgetName, (widget) => {
|
|
||||||
contents = widget.processor(token, widget);
|
|
||||||
var targets = wiki.element.querySelectorAll(`[data-macro-name="widget"][data-keywords="${token.keywords}"]`);
|
|
||||||
targets.forEach(widgetElement => {
|
|
||||||
widgetElement.style = widget.css;
|
|
||||||
widgetElement.innerHTML = contents;
|
|
||||||
});
|
|
||||||
wiki.cachedHTML = wiki.element.innerHTML;
|
|
||||||
});
|
|
||||||
var ret = node + (cached ? cached.processor(token, cached) : "");
|
|
||||||
return ret;
|
|
||||||
},
|
|
||||||
postprocess: (html) => {
|
|
||||||
const buf = document.createElement('div');
|
|
||||||
buf.innerHTML = html;
|
|
||||||
var targets = buf.querySelectorAll(`[data-macro-name="widget"]`);
|
|
||||||
targets.forEach(widgetElement => {
|
|
||||||
var widget = WIDGETS[widgetElement.dataset.keywords.split(" ")[0]];
|
|
||||||
if (widget) {
|
|
||||||
widgetElement.style = widget.css;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return buf.innerHTML;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
style: {
|
|
||||||
inline: false,
|
|
||||||
editable: true,
|
|
||||||
toHTML: (token, node) => {
|
|
||||||
return node.replace(/class="macro"/, `class="macro ${token.keywords}"`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
multiline: {
|
|
||||||
inline: false,
|
|
||||||
editable: true,
|
|
||||||
fromHTML: (node, markdown) => {
|
|
||||||
var content = node.innerHTML.replaceAll("<br>", "\0");
|
|
||||||
content = content.replaceAll("{{{", "{{{");
|
|
||||||
content = content.replaceAll("}}}", "}}}");
|
|
||||||
content = encodeHtmlEntities(content);
|
|
||||||
return `{{{${content}}}}`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
user: {
|
|
||||||
inline: true,
|
|
||||||
toHTML: (token, node) => {
|
|
||||||
return node + document.querySelector("nav > ul > li.user > a:first-child").outerHTML;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
toc: {
|
|
||||||
inline: true,
|
|
||||||
element: 'aside',
|
|
||||||
postprocess: (html) => {
|
|
||||||
const subList = (depth) => {
|
|
||||||
var li = document.createElement("li");
|
|
||||||
var ul = document.createElement("ul");
|
|
||||||
li.appendChild(ul);
|
|
||||||
return li;
|
|
||||||
};
|
|
||||||
|
|
||||||
var tocIndex = 0;
|
|
||||||
|
|
||||||
const buf = document.createElement('div');
|
|
||||||
buf.innerHTML = html;
|
|
||||||
buf.querySelectorAll('[data-macro-name="toc"]').forEach(tocElement => {
|
|
||||||
|
|
||||||
var params = {
|
|
||||||
depth: tocElement.dataset.paramDepth || 3,
|
|
||||||
keywords: tocElement.dataset.paramKeywords || "",
|
|
||||||
};
|
|
||||||
|
|
||||||
const headings = buf.querySelectorAll("h2, h3, h4, h5, h6");
|
|
||||||
|
|
||||||
var header = document.createElement("h2");
|
|
||||||
header.className = 'header';
|
|
||||||
header.textContent = 'Table of Contents';
|
|
||||||
tocElement.prepend(header);
|
|
||||||
|
|
||||||
const toc = document.createElement("ul");
|
|
||||||
toc.setAttribute('role', 'list');
|
|
||||||
|
|
||||||
var lastDepth = null;
|
|
||||||
var ul = toc;
|
|
||||||
headings.forEach(heading => {
|
|
||||||
var depth = parseInt(heading.nodeName[1]) - 1;
|
|
||||||
if (depth > params.depth) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (lastDepth === null) {
|
|
||||||
lastDepth = depth;
|
|
||||||
}
|
|
||||||
|
|
||||||
var index = document.createElement("li");
|
|
||||||
var ref = camelCase(heading.textContent).join("");
|
|
||||||
index.innerHTML = `<a href="#${heading.id}">${heading.textContent}</a>`;
|
|
||||||
|
|
||||||
var list = null;
|
|
||||||
if (depth > lastDepth) {
|
|
||||||
list = subList(depth);
|
|
||||||
ul.appendChild(list);
|
|
||||||
ul = list.firstChild;
|
|
||||||
} else if (depth < lastDepth) {
|
|
||||||
var list = subList(depth);
|
|
||||||
toc.appendChild(list);
|
|
||||||
ul = list.firstChild;
|
|
||||||
}
|
|
||||||
ul.appendChild(index);
|
|
||||||
lastDepth = depth;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (params.keywords) {
|
|
||||||
params.keywords.split(" ").forEach(className => {
|
|
||||||
if (className) {
|
|
||||||
toc.classList.add(className);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
tocElement.appendChild(toc);
|
|
||||||
});
|
|
||||||
return buf.innerHTML;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
npc: {
|
|
||||||
toHTML: (settings) => {
|
|
||||||
var name = camelCase(settings.keywords).join(" ");
|
|
||||||
var target = name.replaceAll(" ", "");
|
|
||||||
return `<a href="/NPC/${target}" data-npc-name="${name}">👤 ${name}</a>`;
|
|
||||||
},
|
|
||||||
fromHTML: (node) => {
|
|
||||||
return `{{npc ${node.firstChild.dataset.npcName}}}`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
spell: {
|
|
||||||
toHTML: (settings) => {
|
|
||||||
var name = camelCase(settings.keywords).join(" ");
|
|
||||||
var target = name.replaceAll(" ", "");
|
|
||||||
return `<a href="/Spell/${target}" data-spell-name="${name}">✨ ${name}</a>`;
|
|
||||||
},
|
|
||||||
fromHTML: (node) => {
|
|
||||||
return `{{spell ${node.firstChild.dataset.spellName}}}`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
getTokens = (pattern, source) => {
|
|
||||||
const matched = source.matchAll(pattern);
|
|
||||||
const tokens = [];
|
|
||||||
if (!matched) {
|
|
||||||
return tokens;
|
|
||||||
}
|
|
||||||
matched.forEach(match => {
|
|
||||||
if (!this.macros[match.groups.name]) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const token = {
|
|
||||||
type: 'macro',
|
|
||||||
source: match[0],
|
|
||||||
matched: match,
|
|
||||||
macro: this.macros[match.groups.name],
|
|
||||||
keywords: (match.groups.keywords || '').trim(),
|
|
||||||
inline: this.macros[match.groups.name].inline,
|
|
||||||
editable: this.macros[match.groups.name].editable || false,
|
|
||||||
params: {},
|
|
||||||
rendered: '',
|
|
||||||
};
|
|
||||||
if (match.groups.parameters) {
|
|
||||||
var params = decodeHtmlEntities(match.groups.parameters.trim());
|
|
||||||
params.matchAll(this.paramPattern).forEach(param => {
|
|
||||||
if (param.groups) {
|
|
||||||
var name = param.groups.name;
|
|
||||||
token.params[name] = decodeHtmlEntities(param.groups.value.trim());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
token.rendered = this.renderToken(token);
|
|
||||||
tokens.push(token);
|
|
||||||
});
|
|
||||||
return tokens;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderToken = (token) => {
|
|
||||||
const tag = token.macro.element || (token.inline ? 'span' : 'div');
|
|
||||||
var node = `<${tag} class="macro" data-plugin-name="macro" data-macro-name="${token.matched.groups.name}"`;
|
|
||||||
|
|
||||||
for (var name in token.params) {
|
|
||||||
node += ` data-param-${name}="${(token.params[name] || "").trim()}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (token.keywords) {
|
|
||||||
node += ` data-keywords="${token.keywords}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
node += ` data-inline="${token.inline}"`;
|
|
||||||
|
|
||||||
node += ` data-editable="${token.editable}"`;
|
|
||||||
|
|
||||||
node += ">";
|
|
||||||
|
|
||||||
if (token.macro.toHTML) {
|
|
||||||
node = token.macro.toHTML(token, node);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (token.inline) {
|
|
||||||
node += `</${tag}>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// preserve the wrapping element, unless it is a paragraph.
|
|
||||||
if (token.matched.groups.wrap) {
|
|
||||||
if (token.matched.groups.wrap != '<p>') {
|
|
||||||
node = token.matched.groups.wrap + node + token.matched.groups.endwrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
constructor(settings) {
|
|
||||||
super(settings);
|
|
||||||
|
|
||||||
this.pattern = new RegExp(
|
|
||||||
'(?<!`)(?<wrap><[^>]+?>)?' + // capture the enclosing HTML tag, if any
|
|
||||||
'{{' + // start of the macro
|
|
||||||
'(?<name>\\w+)' + // the macro name
|
|
||||||
'(?<keywords>(?:\\s(?:\\s*(?:[\\w-](?![\\w-]+=))+))+)?' + // zero or more keywords separated by spaces
|
|
||||||
'(?<parameters>[^}<]+)?' + // anything else before the closing
|
|
||||||
'\\s*(?<closed>}})?' + // is the tag closed?
|
|
||||||
'(?<endwrap>(?:>!\\<)*?<\\/[^>]+?>)?', // capture the enclosing HTML tag, if any
|
|
||||||
'mg'
|
|
||||||
);
|
|
||||||
this.endPattern = /<p>}}\s*<\/p>/mg;
|
|
||||||
this.paramPattern = /\s*(?<name>[^=]+)="(?<value>[^"]*)"/g;
|
|
||||||
this.multilinePattern = /(?<!\{{3}[\s\n]*)\{{3}[\s\n]*(?<content>(.(?!\}{3})*)+?)[\s\n]*\}{3}/smg;
|
|
||||||
|
|
||||||
const plugin = this;
|
|
||||||
|
|
||||||
this.wiki.marked.use({
|
|
||||||
extensions: [
|
|
||||||
{
|
|
||||||
name: 'heading',
|
|
||||||
renderer(token) {
|
|
||||||
var ref = camelCase(token.text).join("");
|
|
||||||
return `<h${token.depth} id='${ref}'>${token.text}</h${token.depth}>`;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
],
|
|
||||||
hooks: {
|
|
||||||
preprocess: (source) => {
|
|
||||||
const matched = source.matchAll(plugin.multilinePattern);
|
|
||||||
var md = source;
|
|
||||||
matched.forEach(match => {
|
|
||||||
var wrapper = '<span class="macro" data-plugin-name="macro" data-macro-name="multiline" data-inline="true" style="display: inline-block;">';
|
|
||||||
var content = decodeHtmlEntities(match.groups.content)
|
|
||||||
.replaceAll("\n", "::BR::")
|
|
||||||
.replaceAll('`', '::QU::')
|
|
||||||
.replaceAll('{', '::OC::')
|
|
||||||
.replaceAll('}', '::CC::');
|
|
||||||
md = md.replaceAll(match[0], wrapper + '\0' + content + '\0</span>');
|
|
||||||
});
|
|
||||||
return md;
|
|
||||||
},
|
|
||||||
postprocess: (html) => {
|
|
||||||
plugin.getTokens(plugin.pattern, html).forEach(token => {
|
|
||||||
var pat = new RegExp('(?<!<pre>.+?)' + token.source, 'mg');
|
|
||||||
html = html.replaceAll(pat, token.rendered);
|
|
||||||
});
|
|
||||||
html = html.replaceAll(plugin.endPattern, '</div>');
|
|
||||||
Object.values(plugin.macros).forEach(macro => {
|
|
||||||
if (macro.postprocess) {
|
|
||||||
html = macro.postprocess(html);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
html = html.replaceAll("::BR::", "<br>")
|
|
||||||
.replaceAll('::QU::', '`')
|
|
||||||
.replaceAll('::OC::', '{')
|
|
||||||
.replaceAll('::CC::', '}');
|
|
||||||
|
|
||||||
// remove unsafe html tags
|
|
||||||
return DOMPurify.sanitize(html, {});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setEditable() {
|
|
||||||
const plugin = this;
|
|
||||||
this.wiki.turndown.addRule('macros', {
|
|
||||||
filter: function (node, options) {
|
|
||||||
return ((node.nodeName === 'ASIDE' || node.nodeName === 'DIV' || node.nodeName === 'SPAN') && node.dataset.pluginName == 'macro')
|
|
||||||
},
|
|
||||||
replacement: function (content, node, options) {
|
|
||||||
var macro = plugin.macros[node.getAttribute('data-macro-name')];
|
|
||||||
|
|
||||||
if (macro.fromHTML) {
|
|
||||||
return macro.fromHTML(node, content);
|
|
||||||
}
|
|
||||||
|
|
||||||
var md = '{{' + node.dataset.macroName;
|
|
||||||
if (node.dataset.keywords) {
|
|
||||||
md += " " + node.dataset.keywords;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (var paramName in node.dataset) {
|
|
||||||
if (paramName.indexOf("param") != 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
md += ` ${paramName.replace('param', '').toLowerCase()}="${node.dataset[paramName]}"`
|
|
||||||
};
|
|
||||||
|
|
||||||
if (node.dataset.inline == "false") {
|
|
||||||
md = `\n\n${md}\n\n`;
|
|
||||||
md += plugin.wiki.htmlToMarkdown(node.innerHTML);
|
|
||||||
md += "\n\n}}\n\n";
|
|
||||||
} else {
|
|
||||||
md += "}}";
|
|
||||||
}
|
|
||||||
|
|
||||||
// replace nulls with line breaks, for the multiline macro
|
|
||||||
md = md.replaceAll('\0', "\n");
|
|
||||||
|
|
||||||
return md;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
function camelCase(words) {
|
|
||||||
var output = [];
|
|
||||||
words.trim().split(/\s+/g).forEach(word => {
|
|
||||||
var lcWord = word.toLowerCase();
|
|
||||||
output.push(lcWord.charAt(0).toUpperCase() + lcWord.slice(1));
|
|
||||||
});
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
function b64encode(input) {
|
|
||||||
return new TextEncoder().encode(input).toBase64();
|
|
||||||
}
|
|
||||||
|
|
||||||
function b64decode(input) {
|
|
||||||
return new TextDecoder().decode(Uint8Array.fromBase64(input));
|
|
||||||
}
|
|
||||||
|
|
||||||
function decodeHtmlEntities(html) {
|
|
||||||
var txt = document.createElement("textarea");
|
|
||||||
txt.innerHTML = html;
|
|
||||||
return txt.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function encodeHtmlEntities(str) {
|
|
||||||
return str.replace(/[\u00A0-\u9999<>\&]/g, i => '&#'+i.charCodeAt(0)+';')
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -113,7 +113,6 @@ table {
|
||||||
|
|
||||||
th {
|
th {
|
||||||
border-bottom: 1px solid #000;
|
border-bottom: 1px solid #000;
|
||||||
padding: 3px;
|
|
||||||
}
|
}
|
||||||
th, td {
|
th, td {
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
|
|
@ -207,157 +206,3 @@ 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-macro-name="toc"] {
|
|
||||||
display: inline;
|
|
||||||
float: left;
|
|
||||||
border-radius: 5px;
|
|
||||||
font-size: 14px;
|
|
||||||
margin-right: 2em;
|
|
||||||
margin-bottom: 2em;
|
|
||||||
border: 1px solid #000;
|
|
||||||
padding: 0ch 2ch;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-macro-name="toc"] ul {
|
|
||||||
box-sizing: border-box;
|
|
||||||
list-style: none;
|
|
||||||
padding-left: 0ch;
|
|
||||||
font-weight: normal;
|
|
||||||
margin-left: 0px;
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-macro-name="toc"] ul ul {
|
|
||||||
padding-left: 2ch;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-macro-name="toc"] > ul:first-child {
|
|
||||||
padding-left: 0ch;
|
|
||||||
margin-left: 0px;
|
|
||||||
}
|
|
||||||
[data-macro-name="toc"] > ul:first-child > li {
|
|
||||||
padding-left: 0ch;
|
|
||||||
margin-left: 0px;
|
|
||||||
}
|
|
||||||
[data-macro-name="toc"] > ul:first-child > li {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-macro-name="toc"] > ul > li {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-macro-name="toc"] > ul > li {
|
|
||||||
padding-left: 0ch;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-macro-name="toc"] a {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
[data-macro-name="toc"] a:hover {
|
|
||||||
background: #CCC;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-macro-name="toc"] .header {
|
|
||||||
font-size: var(--default-font-size);
|
|
||||||
width: fit-content;
|
|
||||||
margin: 2ch auto 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
#froghat {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#froghat.loaded {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
#froghat.wysiwyg {
|
|
||||||
display: none,
|
|
||||||
}
|
|
||||||
#froghat.view {
|
|
||||||
}
|
|
||||||
|
|
||||||
#froghat.edit {
|
|
||||||
font-family: monospace;
|
|
||||||
white-space: pre;
|
|
||||||
}
|
|
||||||
|
|
||||||
#froghat.wysiwyg {
|
|
||||||
}
|
|
||||||
|
|
||||||
#froghat.wysiwyg .md {
|
|
||||||
opacity: 0.5;
|
|
||||||
}
|
|
||||||
6
src/ttfrog/themes/default/static/viewer/toastui-editor-viewer.min.css
vendored
Normal file
6
src/ttfrog/themes/default/static/viewer/toastui-editor-viewer.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
24
src/ttfrog/themes/default/static/viewer/toastui-editor-viewer.min.js
vendored
Normal file
24
src/ttfrog/themes/default/static/viewer/toastui-editor-viewer.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
9
src/ttfrog/themes/default/static/viewer/viewer.css
Normal file
9
src/ttfrog/themes/default/static/viewer/viewer.css
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
@import 'toastui-editor-viewer.min.css';
|
||||||
|
|
||||||
|
#viewer {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toastui-editor-contents {
|
||||||
|
font-size: var(--default-font-size);
|
||||||
|
}
|
||||||
6
src/ttfrog/themes/default/static/viewer/viewer.js
Normal file
6
src/ttfrog/themes/default/static/viewer/viewer.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
var viewer = new toastui.Editor({
|
||||||
|
viewer: true,
|
||||||
|
el: document.querySelector("#viewer"),
|
||||||
|
usageStatistics: false,
|
||||||
|
});
|
||||||
|
viewer.setMarkdown(document.getElementById("data_form__body").value);
|
||||||
|
|
@ -123,13 +123,10 @@ 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)
|
||||||
|
|
||||||
|
|
@ -153,7 +150,6 @@ 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,4 +1,3 @@
|
||||||
from pathlib import Path
|
|
||||||
from tempfile import TemporaryDirectory
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
@ -13,7 +12,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(path), storage=MemoryStorage)
|
fixture_db = GrungDB.with_schema(schema, 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
|
||||||
|
|
@ -22,6 +21,8 @@ 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)
|
||||||
|
|
@ -31,6 +32,7 @@ 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?"
|
||||||
|
|
@ -40,7 +42,6 @@ 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]))
|
||||||
|
|
|
||||||
|
|
@ -1,39 +0,0 @@
|
||||||
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