Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?
Minimal, multi-user Twitter OAuth blogging platform that can create living, powerful pages
{{#if !isAuthenticated }}
<p style="text-align: center">
Please <a href="/auth/twitter">Sign In</a> to add Posts
</p>
{{return}}
{{/if}}
{{#if post == null }}
{{ `Post ${slug} does not exist` | assignTo: error }}
{{else if post.CreatedBy != userName }}
{{ `You do not have permission to edit this post` | assignTo: error }}
{{/if}}
{{ 'error' | showIfExists(error) | partial({ error }) }}
{{ it | withoutKeys(['it','error']) | assignTo: attrs }}
<div class="editor">
<div class="editor-toolbar">
<div class="btn" data-cmd="bold" title="Bold text (CTRL+B)">
<svg width="24" height="24" viewBox="0 0 24 24">
<path d="M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z"/>
<path d="M0 0h24v24H0z" fill="none"/>
</svg>
</div>
<div class="btn" data-cmd="italic" title="Italics (CTRL+I)">
<svg width="24" height="24" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z"/>
</svg>
</div>
<div class="btn" data-cmd="link" title="Insert Link (CTRL+L)">
<svg width="24" height="24" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z"/>
</svg>
</div>
<div class="btn" data-cmd="quote" title="Blockquote (CTRL+Q)">
<svg width="24" height="24" viewBox="0 0 24 24">
<path d="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z"/>
<path d="M0 0h24v24H0z" fill="none"/>
</svg>
</div>
<div class="btn" data-cmd="image" title="Insert Image (CTRL+SHIFT+L)">
<svg width="24" height="24" viewBox="0 0 24 24">
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
<path d="M0 0h24v24H0z" fill="none"/>
</svg>
</div>
<div class="btn" data-cmd="code" title="Insert Code (CTRL+<)">
<svg width="24" height="24" viewBox="0 0 24 24">
<path fill="none" d="M0 0h24v24H0V0z"/>
<path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z"/>
</svg>
</div>
<div class="btn" data-cmd="heading" title="H2 Heading (CTRL+H)">
<svg width="24" height="24" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M9 4v3h5v12h3V7h5V4H9zm-6 8h3v7h3v-7h3V9H3v3z"/>
</svg>
</div>
<div class="btn" data-cmd="ol" title="Numbered List (ALT+1)">
<svg width="24" height="24" viewBox="0 0 24 24">
<path d="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1v.9h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z"/>
<path d="M0 0h24v24H0z" fill="none"/>
</svg>
</div>
<div class="btn" data-cmd="ul" title="Bulleted List (ALT+-)">
<svg width="24" height="24" viewBox="0 0 24 24">
<path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"/>
<path fill="none" d="M0 0h24v24H0V0z"/>
</svg>
</div>
<div class="btn" data-cmd="strikethrough" title="Strike Through (ALT+S)">
<svg width="24" height="24" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M10 19h4v-3h-4v3zM5 4v3h5v3h4V7h5V4H5zM3 14h18v-2H3v2z"/>
</svg>
</div>
<div class="btn" data-cmd="undo" title="undo (CTRL+Z)">
<svg width="24" height="24" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M12.5 8c-2.65 0-5.05.99-6.9 2.6L2 7v9h9l-3.62-3.62c1.39-1.16 3.16-1.88 5.12-1.88 3.54 0 6.55 2.31 7.6 5.5l2.37-.78C21.08 11.03 17.15 8 12.5 8z"/>
</svg>
</div>
<div class="btn" data-cmd="redo" title="redo (CTRL+SHIFT+Z)">
<svg width="24" height="24" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M18.4 10.6C16.55 8.99 14.15 8 11.5 8c-4.65 0-8.58 3.03-9.96 7.22L3.9 16c1.05-3.19 4.05-5.5 7.6-5.5 1.95 0 3.73.72 5.12 1.88L13 16h9V7l-3.6 3.6z"/>
</svg>
</div>
<div class="btn" data-cmd="save" title="Save (CTRL+S)">
<svg width="24" height="24" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M17 3H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V7l-4-4zm-5 16c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3zm3-10H5V5h10v4z"/>
</svg>
</div>
</div>
<textarea {{ attrs | htmlAttrs }}>{{content}}</textarea>
<a class="input-help" href="https://sharpscript.net">template syntax</a>
{{#if error}}<div class="error">{{error}}</div>{{/if}}
</div>
<style>
.editor textarea {
padding: .5%;
width: 99%;
max-width: 99%;
min-width: 99%;
}
.editor-toolbar {
border: 1px solid #eee;
border-bottom: none;
margin: 0 -2px 0 0;
}
.editor-toolbar .btn {
margin: 5px;
padding: 2px 5px;
border-color: transparent;
background: none;
}
.editor-toolbar .btn:hover {
border-color: #ccc;
}
</style>
{{#raw}}
<script src="/editor.js"></script>
<script>
var editor = new Editor(document.querySelector(".editor"), {
save() {
let form = this.target.closest('form');
form.querySelector('[type=submit]').click(); // form.submit() does not fire submit event
}
})
</script>
{{/raw}}
<div>
<div class="alert-error">{{error}}</div>
<a href="/">&lt; home</a>
</div>
{{return}}
<!--
layout: none
-->
================================
Create blog.sqlite if not exists
================================
{{ `CREATE TABLE IF NOT EXISTS "Post"
(
"Id" INTEGER PRIMARY KEY AUTOINCREMENT,
"Slug" VARCHAR(8000) NULL,
"Title" VARCHAR(8000) NULL,
"Content" VARCHAR(8000) NULL,
"Created" VARCHAR(8000) NOT NULL,
"CreatedBy" VARCHAR(8000) NOT NULL,
"Modified" VARCHAR(8000) NOT NULL,
"ModifiedBy" VARCHAR(8000) NOT NULL
);
CREATE TABLE IF NOT EXISTS "UserInfo"
(
"UserName" VARCHAR(8000) PRIMARY KEY,
"DisplayName" VARCHAR(8000) NULL,
"AvatarUrl" VARCHAR(8000) NULL,
"AvatarUrlLarge" VARCHAR(8000) NULL,
"Created" VARCHAR(8000) NOT NULL,
"Modified" VARCHAR(8000) NOT NULL
);`
| dbExec
}}
{{ dbScalar(`SELECT COUNT(*) FROM Post`) | assignTo: postsCount }}
{{#if postsCount == 0 }}
========================================
Seed with initial UserInfo and Post data
========================================
{{ `datetime(CURRENT_TIMESTAMP,'localtime')` | assignTo: sqlNow }}
{{ `ServiceStack` | assignTo: user }}
========================
Create ServiceStack User - Contains same info as if was @ServiceStack authenticated via Twitter
========================
{{ `INSERT INTO UserInfo (UserName, DisplayName, AvatarUrl, AvatarUrlLarge, Created, Modified)
VALUES (@user, @user, @avatarUrl, @avatarUrlLarge, ${sqlNow}, ${sqlNow})`
| dbExec({
user: 'ServiceStack',
avatarUrl: 'https://pbs.twimg.com/profile_images/876249730078056448/JuTVEkWX_normal.jpg',
avatarUrlLarge: 'https://pbs.twimg.com/profile_images/876249730078056448/JuTVEkWX.jpg'
})
}}
===============================================
{{ 'Live Document Example' | assignTo: title }}
===============================================
{{#raw content}}{{#markdown}}
All Blog posts have access to [#Script](https://sharpscript.net) features which enables they to use a highly-productive,
easy to use [sandboxed](https://sharpscript.net/docs/sandbox) dynamic templating language which lets you easily create live documents
[like this one](/posts/live-document-example):
{{/markdown}}
<pre>
{{ 11200 | assignTo: balance }}
{{ 10 | assignTo: projectedMonths }}
{{'
Salary: 4000
App Royalties: 200
'| trim | parseKeyValueText(':') | assignTo: monthlyRevenues }}
{{'
Rent 1000
Internet 50
Mobile 50
Food 400
Misc 200
'| trim | parseKeyValueText | assignTo: monthlyExpenses }}
{{ monthlyRevenues | values | sum | assignTo: totalRevenues }}
{{ monthlyExpenses | values | sum | assignTo: totalExpenses }}
{{ subtract(totalRevenues, totalExpenses) | assignTo: totalSavings }}
Current Balance: <b>{{ balance | currency }}</b>
Monthly Revenues:
{{ monthlyRevenues | toList | select: { it.Key | padRight(30) }{ it.Value | currency }\n }}
Total <b>{{ totalRevenues | currency }}</b>
Monthly Expenses:
{{ monthlyExpenses | toList | select: { it.Key | padRight(30) }{ it.Value | currency }\n }}
Total <b>{{ totalExpenses | currency }}</b>
Monthly Savings: <b>{{ totalSavings | currency }}</b>
Projected Cash Position:
{{ projectedMonths | times | map => index + 1
| select: { now | addMonths(it) | dateFormat } <b>{ totalSavings | multiply(it) | add(balance) | currency }</b>\n }}
</pre>{{/raw}}
{{ { title, content } | addTo: initialPosts }}
==========================================
{{ 'Markdown Example' | assignTo: title }}
==========================================
{{#raw content}}{{#markdown}}
# Headings can start with 1-6 hashes
Markdown follows plain text conventions when rendering HTML.
So paragraphs separated by multiple lines are rendered as separate paragraphs.
### Use more hashes to create nested sub headings
> Text pre-fixed with '>' are treated as block quotes
Use a dash, asterisk or plus to create an ordered list:
- List Item
* List Item
+ List Item
Whilst you can use numbers for ordered lists:
1. Step 1
2. Step 2
3. Step 3
Indent lines by 4 spaces to create pre-formatted code blocks in monospace font:
$ cd /Users/Guest
Follow [link to markdown block docs](https://sharpscript.net/docs/blocks#markdown) for more info.
{{/markdown}}{{/raw}}
{{ { title, content } | addTo: initialPosts }}
================================================
{{ 'Web App Customizations' | assignTo: title }}
================================================
{{#raw content}}{{#markdown}}
## Init page
Just like `Global.asax.cs` can be used to run Startup logic in ASP.NET Web Applications and `Startup.cs` in .NET Core Apps,
you can add a `_init.html` to run logic once on Startup.
This is used in this Blog's [_init.html](https://github.com/NetCoreWebApps/Blog/blob/master/app/_init.html) where it will create a new
`blog.sqlite` database if it doesn't exist seeded with the UserInfo and Posts Tables and initial data, e.g:
```
{{ `CREATE TABLE IF NOT EXISTS "UserInfo"
(
"UserName" VARCHAR(8000) PRIMARY KEY,
"DisplayName" VARCHAR(8000) NULL,
"AvatarUrl" VARCHAR(8000) NULL,
"AvatarUrlLarge" VARCHAR(8000) NULL,
"Created" VARCHAR(8000) NOT NULL,
"Modified" VARCHAR(8000) NOT NULL
);`
| dbExec
}}
{{ dbScalar(`SELECT COUNT(*) FROM Post`) | assignTo: postsCount }}
{{#if postsCount == 0 }}
========================================
Seed with initial UserInfo and Post data
========================================
...
{{/if}
{{ htmlError }}
```
The output of the `_init` page is captured in the `initout` argument which can be inspected as a Script Argument as done in
[log.html](https://github.com/NetCoreWebApps/Blog/blob/master/app/log.html):
```
<div>
Output from init.html:
<pre>{{initout | raw}}</pre>
</div>
```
If there was an Exception with any of the SQL Statements it will be displayed in the `{{ htmlError }}` filter which can be later viewed
in the [/log](/log) page above.
## Customizable Auth Providers
Auth Providers can be configured in the same way
[Web App Plugins can be registered](https://sharpscript.net/docs/sharp-apps#registering-servicestack-plugins) by first specifying
you want to register the `AuthFeature` plugin with:
```
features AuthFeature
```
Then using `AuthFeature.AuthProviders` to specify which Auth Providers you want to have registered, e.g:
```
AuthFeature.AuthProviders TwitterAuthProvider, GithubAuthProvider
```
Each Auth Provider checks the Web Apps `app.settings` for its Auth Provider specific configuration it needs, e.g. to configure both
Twitter and GitHub Auth Providers you would populate it with your OAuth Apps details:
```
oauth.RedirectUrl http://127.0.0.1:5000/
oauth.CallbackUrl http://127.0.0.1:5000/auth/{0}
oauth.twitter.ConsumerKey {Twitter App Consumer Key}
oauth.twitter.ConsumerSecret {Twitter App Consumer Secret Key}
oauth.github.ClientId {GitHub Client Id}
oauth.github.ClientSecret {GitHub Client Secret}
oauth.github.Scopes {GitHub Auth Scopes}
```
## Customizable Markdown Providers
By default Web Apps utilize [Markdig](https://github.com/lunet-io/markdig) implementation to render its Markdown. You can switch it back to
the built-in Markdown provider that ServiceStack uses with:
```
markdownProvider MarkdownDeep
```
## Rich Script Config Arguments
Any `app.settings` configs that are prefixed with `args.` are made available to Sharp Pages. Any arguments which start with
`{` or `[` are automatically converted into a JS object:
```
args.blog { name:'blog.web-app.io', href:'/' }
args.tags ['technology','marketing']
```
Where they can be referenced as an `object` or an `array` directly:
```
<a href="{{blog.href}}">{{blog.name}}</a>
{{#each tags}} <em>{{it}}</em> {{/each}}
```
The alternative is to give each argument value a different name:
```
args.blogName blog.web-app.io
args.blogHref /
```
{{/markdown}}{{/raw}}
{{ { title, content } | addTo: initialPosts }}
============================================
{{ 'Dynamic API Pages' | assignTo: title }}
============================================
{{#raw content}}{{#markdown}}
In addition to providing a productive dynamic language for generating HTML pages, Sharp Pages can also be used to rapidly develop Web APIs
which can utilize [dynamic page routing](/posts/page-based-routing) to easily create data-driven JSON APIs using optimal pretty URLs, in
real-time without any C# classes or compilation in sight!
The only difference between a Sharp Page that generates HTML or a Sharp Page that returns an API Response is that API pages use the
**return** filter to return a value.
E.g. To create a **Hello World** C# ServiceStack Service you would typically create a Request DTO, Response DTO and a Service implementation:
```
[Route("/hello/{Name}")]
public class Hello : IReturn<HelloResponse>
{
public string Name { get; set; }
}
public class HelloResponse
{
public string Result { get; set; }
}
public class HelloService : Service
{
public object Any(Hello request) => $"Hello, {request.Name}!";
}
```
### /hello API Page
> Usage: /hello/\{name}
An API which returns the same wire response as above can be implemented in API Pages by creating a page at
[/hello/_name/index.html](https://github.com/NetCoreWebApps/Blog/blob/master/app/hello/_name/index.html)
with the contents:
```
{{ { result: `Hello, ${name}!` } | return }}
```
Which supports the same content negotiation as a ServiceStack Service where calling it in a browser will generate a
[human-friendly HTML Page](http://docs.servicestack.net/html5reportformat):
- [/hello/World](/hello/World)
Where as calling it with a JSON HTTP client containing `Accept: application/json` HTTP Header or with a `?format=json` query string will
return the API response in the JSON Format:
- [/hello/World?format=json](/hello/World?format=json)
Alternatively you can force a JSON Response by specifying it with:
```
{{ { result: `Hello, ${name}!` } | return({ format: 'json' }) }}
// Equivalent to:
{{ { result: `Hello, ${name}!` } | return({ contentType: 'application/json' }) }}
```
### /preview API Page
> Usage: /preview?content=\{templates}
The [/preview.html](https://github.com/NetCoreWebApps/Blog/blob/master/app/preview.html) page uses this to force a plain-text response with:
```
{{ content | evalScript({use:{plugins:'MarkdownScriptPlugin'}}) | assignTo:response }}
{{ response | return({ contentType:'text/plain' }) }}
```
The preview API above is what provides this Blog's Live Preview feature where it will render any #Script provided in the
**content** Query String or HTTP Post Form Data, e.g:
- [/preview?content={{10|times|select:{pow(index,2)},}}](/preview?content={{10|times|select:{pow(index,2)},}})
Which renders the plain text response:
0,1,4,9,16,25,36,49,64,81,
### /_user/api Page
> Usage: /\{user}/api
The [/_user/api.html](https://github.com/NetCoreWebApps/Blog/blob/master/app/_user/api.html) API page shows an example of how easy it is to
create data-driven APIs where you can literally return the response of an SQL query by calling the `dbSelect` filter and returning the results with:
```
{{ `SELECT *
FROM Post p INNER JOIN UserInfo u on p.CreatedBy = u.UserName
WHERE UserName = @user
ORDER BY p.Created DESC`
| dbSelect({ user })
| return }}
```
The **user** argument is populated as a result of dynamic route from the `_user` directory name which will let you view all
[@ServiceStack](/ServiceStack) posts with:
- [/ServiceStack/api](/ServiceStack/api)
Which also benefits from ServiceStack's multiple formats where the same API can be returned in:
- [/ServiceStack/api?format=json](/ServiceStack/api?format=json)
- [/ServiceStack/api?format=csv](/ServiceStack/api?format=csv)
- [/ServiceStack/api?format=xml](/ServiceStack/api?format=xml)
- [/ServiceStack/api?format=jsv](/ServiceStack/api?format=jsv)
Which thanks to the live development workflow provides the most productive development experience to rapidly develop Web APIs or perform common
tasks like viewing adhoc SQL queries in Excel which can be further manipulated using the
[LINQ-like expressiveness](https://sharpscript.net/linq/restriction-operators) and wrist-friendly script methods available in #Script.
### /posts/_slug/api Page
> Usage: /posts/\{slug}/api
The [/posts/_slug/api.html](https://github.com/NetCoreWebApps/Blog/blob/master/app/posts/_slug/api.html) page shows an example of using the
`httpResult` filter to return a custom HTTP Response where if the post with the specified slug does not exist it will return a
`404 Post was not found` HTTP Response:
```
{{ `SELECT *
FROM Post p INNER JOIN UserInfo u on p.CreatedBy = u.UserName
WHERE Slug = @slug
ORDER BY p.Created DESC`
| dbSingle({ slug })
| assignTo: post
}}
{{ post ?? httpResult({ status:404, statusDescription:'Post was not found' })
| return }}
```
The **httpResult** filter returns a ServiceStack `HttpResult` which allows for the following customization's:
```csharp
httpResult({
status: 404,
status: 'NotFound' // can also use .NET HttpStatusCode enum name
statusDescription: 'Post was not found',
response: post,
format: 'json',
contentType: 'application/json',
'X-Powered-By': 'ServiceStack',
})
```
Any other arguments like 'X-Powered-By' are returned as HTTP Response Headers.
This behaves similarly to customizing a response with return arguments:
```
{{ post | return({ format:'json', 'X-Powered-By':'ServiceStack' }) }}
```
Using the explicit httpResult filter is useful for returning a custom HTTP Response that doesn't have a Response Body, e.g. the **New Post** page
uses `httpFilter` to
[redirect back to the Users posts page](https://github.com/NetCoreWebApps/Blog/blob/e8bb7249192c5797348ced091ad5fd434db9a619/app/posts/new.html#L33)
after they've successfully created a new Post:
```
{{#if success}}
{{ httpResult({ status:301, Location:`/${userName}` }) | return }}
{{/if}}
```
For more examples and info on API Pages checkout to the [API Pages docs](https://sharpscript.net/docs/api-pages).
{{/markdown}}{{/raw}}
{{ { title, content } | addTo: initialPosts }}
============================================
{{ 'Page based routing' | assignTo: title }}
============================================
{{#raw content}}{{#markdown}}
Sharp Pages supports conventional page-based routes where the name of each page can be requested with or without its **.html** extension:
| path | page |
------ | ---- |
[/db](/db) | |
[/db.html](/db.html) | [/db.html](https://github.com/NetCoreWebApps/Blog/blob/master/app/db.html) |
[/posts/new](/posts/new) | |
[/posts/new.html](/posts/new.html) | [/posts/new.html](https://github.com/NetCoreWebApps/Blog/blob/master/app/posts/new.html)
and the default route **/** maps to the `index.html` in each directory if it exists, e.g:
| path | page |
------ | ---- |
[/](/) | [/index.html](https://github.com/NetCoreWebApps/Blog/blob/master/app/index.html) |
Nuxt-like [Dynamic Routes](https://nuxtjs.org/guide/routing#dynamic-routes) can also be used where any **file** or **directory** names
prefixed with an _ **underscore** allows for dynamic wildcard paths with the matching path component also assigned to the arguments name:
| path | page | arguments |
------ | ---- | --------- |
[/ServiceStack](/) | [/_user/index.html](https://github.com/NetCoreWebApps/Blog/blob/master/app/_user/index.html) | user=ServiceStack |
[/posts/markdown-example](/posts/markdown-example) | [/posts/_slug/index.html](https://github.com/NetCoreWebApps/Blog/blob/master/app/posts/_slug/index.html) | slug=markdown-example |
[/posts/markdown-example/edit](/posts/markdown-example/edit) | [/posts/_slug/edit.html](https://github.com/NetCoreWebApps/Blog/blob/master/app/posts/_slug/edit.html) | slug=markdown-example |
### Layout and partial recommended naming conventions
As the **_ underscore** prefix for declaring wildcard pages is also what is used to declare "hidden" pages, to disambiguate them from hidden
partials and layouts, the recommendation is to have them include `layout` and `partial` their name, i.e:
- _layout.html
- _alt-layout.html
- _menu-partial.html
Pages with `layout` or `partial` in their name are hidden and ignored in wildcard path resolution.
If you follow the recommended `_{name}-partial.html` naming convention you will also be able to reference partials using just their name, i.e:
```
{{ 'menu' | partial }} // Equivalent to:
{{ '_menu-partial' | partial }}
```
{{/markdown}}{{/raw}}
{{ { title, content } | addTo: initialPosts }}
===============================
{{ 'About' | assignTo: title }}
===============================
{{#raw content}}{{#markdown}}
This Blog App demonstrates some of the capabilities in [ServiceStack Sharp Apps](https://sharpscript.net/docs/sharp-apps) - an exciting real-time
development model for developing .NET Core Apps where entire Sharp Apps can be developed within a live hot-reload experience without any compilation,
build tools, dependencies, IDEs or any C# source code necessary by using the powerful and user-friendly
[#Script language](https://sharpscript.net/) and its comprehensive built-in functionality.
### Ultimate Simplicity
This eliminates much of the complexity inherent in developing .NET Web Applications which by their nature results in highly customizable Web Apps
where their entire functionality can be modified in real-time whilst the App is running, which is simple enough to be enhanced by non-developers
like Designers and Content Creators courtesy of its approachable [Handlebars-like](https://sharpscript.net/docs/blocks) and familiar
[JavaScript syntax](https://sharpscript.net/docs/expression-viewer#expression=map(range(1%2Ccount)%2C%20x%20%3D%3E%20x%20*%20x)&count=5).
Compiled Apps can have a prohibitively large barrier to entry where any modification often requires downloading source code separately, setting
up a matching development environment with appropriate extensions and correct versions and non cursory level of experience with their chosen
language, frameworks, build tools and other platform idiosyncrasies.
By contrast Sharp Apps require no development environment, no IDE's or build tools with all source code already included as part of the App which
can be modified in real-time by any text editor to instantly view changes as they're made. So Apps like http://redis.web-app.io which provide a
rich Admin UI for searching, browsing and modifying Redis's core data structures, can be easily enhanced by modifying a single
[index.html](https://github.com/sharp-apps/redis/blob/master/index.html) at the same time as using the App.
## Blog App Features
This [/Blog](https://github.com/sharp-apps/blog) App is another example of encapsulating useful functionality in a
highly customizable .NET Core Web App which to maximize approachability has no C# source code, plugins and uses no JavaScript or CSS frameworks.
The development of which ended up being one of the most enjoyable experiences we've had in recent memory where all the usual complexities of
developing a C# Server and modern JS App has been dispensed and you can just focus on the App you want to create,
using a plain-text editor on the left, a live updating browser on the right and nothing else to interrupt your flow.
Any infrastructure dependencies have also been avoided by using SQLite by default which is
[automatically created an populated on first run](/posts/web-app-customizations) if no database exists, or if preferred can be
[changed to use any other popular RDBMS](https://sharpscript.net/docs/sharp-apps#multi-platform-configurations) using just config.
### Multi User Blogging Platform
Any number of users can Sign In via Twitter and publish content under their Twitter Username where only they'll be able to modify their own Content.
Setting up Twitter is as easy as it can be which just requires modifying the
[Twitter Auth Config in app.settings](/posts/web-app-customizations#customizable-auth-providers) with the URL where the blog
is hosted and the OAuth Keys for the Twitter OAuth App created at https://apps.twitter.com
### Rich Content
Whilst most blogging platforms just edit static text, each Post content has access to the powerful and
[Sandboxed](https://sharpscript.net/docs/sandbox) features in https://sharpscript.net which can be used to create
[Live Documents](/posts/live-document-example) or [Render Markdown](/posts/markdown-example) which is itself just
[one of the available blocks](https://sharpscript.net/docs/blocks#markdown) where it will render to HTML any content between the `markdown` blocks:
{{/markdown}}
<pre><code>{​{#markdown}}
## Markdown Content
{​{/markdown}}</code></pre>
{{#markdown}}
By default the [Markdig](https://github.com/lunet-io/markdig) implementation is used to render Markdown but can also be configured to use an
[alternate Markdown provider](/posts/web-app-customizations#customizable-markdown-providers).
### Rich Markdown Editor
To make it easy to recall Markdown features, each Content is equipped with a Rich Text editor containing the most popular formatting controls
along with common short-cuts for each feature, discoverable by hovering over each button:
![](https://raw.githubusercontent.com/ServiceStack/Assets/master/img/livedemos/blog/editor.png)
The Editor also adopts popular behavior in IDEs where `Tab` and `SHIFT+Tab` can be used to indent blocks of text and lines can be commented with
`Ctrl+/` or blocks with `CTRL+SHIFT+/`.
Another nice productivity win is being able to `CTRL+CLICK` on any Content you created to navigate to its Edit page.
### Auto saved drafts
The content in each Text `input` and `textarea` is saved to `localStorage` on each key-press so you can freely reload pages and navigate
around the site where all unpublished content will be preserved upon return.
When you want to revert back to the original published content you can clear the text boxes and reload the page which will load content from
the database instead of `localStorage`.
### Server Validation
The [new.html](https://github.com/sharp-apps/blog/blob/master/posts/new.html) and [edit.html](https://github.com/sharp-apps/blog/blob/master/posts/_slug/edit.html) pages shows examples of performing server validation with #Script:
```
{{ assignErrorAndContinueExecuting: ex }}
{{ 'Title must be between 5 and 200 characters'
| onlyIf(length(postTitle) < 5 || length(postTitle) > 200) | assignTo: titleError }}
{{ 'Content must be between 25 and 64000 characters'
| onlyIf(length(content) < 25 || length(content) > 64000) | assignTo: contentError }}
{{ 'Potentially malicious characters detected'
| ifNotExists(contentError) | onlyIf(containsXss(content)) | assignTo: contentError }}
```
![](https://raw.githubusercontent.com/ServiceStack/Assets/master/img/livedemos/blog/server-validation.png)
For more info see the docs on [Error Handling](https://sharpscript.net/docs/error-handling).
### Live Previews
Creating and Posting content benefit from Live Previews where its rendered output can be visualized in real-time before it's published.
Any textarea can easily be enhanced to enable Live Preview by including the `data-livepreview` attribute with the element where the output
should be rendered in, e.g:
<textarea data-livepreview=".preview"></textarea>
<div class="preview"></div>
The implementation of which is surprisingly simple where the JavaScript snippet in
[default.js](https://github.com/NetCoreWebApps/Blog/blob/master/app/default.js) below is used to send their content on each change:
```
// Enable Live Preview of new Content
textAreas = document.querySelectorAll("textarea[data-livepreview]");
for (let i = 0; i < textAreas.length; i++) {
textAreas[i].addEventListener("input", livepreview, false);
livepreview({ target: textAreas[i] });
}
function livepreview(e) {
let el = e.target;
let sel = el.getAttribute("data-livepreview");
if (el.value.trim() == "") {
document.querySelector(sel).innerHTML = "<div>Live Preview</div>";
return;
}
let formData = new FormData();
formData.append("content", el.value);
fetch("/preview", {
method: "post",
body: formData
}).then(function(r) { return r.text(); })
.then(function(r) { document.querySelector(sel).innerHTML = r; });
}
```
To the [/preview.html](https://github.com/NetCoreWebApps/Blog/blob/master/app/preview.html) API Page which just renders and captures any
#Script Content its sent and returns the output:
```
{{ content | evalScript({use:{plugins:'MarkdownScriptPlugin'}}) | assignTo:response }}
{{ response | return({ contentType:'text/plain' }) }}
```
By default the `evalScript` method renders #Script in a new `ScriptContext` which can be customized to utilize any additional
`plugins`, script `methods` and `blocks` available in the configured [SharpPagesFeature](https://sharpscript.net/docs/sharp-pages),
or for full access you can use `{use:{context:true}}` to evaluate any #Script content under the same context that `evalScript` is run on.
{{/markdown}}{{/raw}}
{{ { title, content } | addTo: initialPosts }}
======================
Populate initial posts
======================
{{#each initialPosts}}
{{ `INSERT INTO Post (Slug, Title, Content, Created, CreatedBy, Modified, ModifiedBy)
VALUES (@slug, @title, @content, ${sqlNow}, @user, ${sqlNow}, @user)`
| dbExec({ slug: generateSlug(title), title, content, user }) }}
{{/each}}
{{/if}}
{{ htmlError }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<link href="https://fonts.googleapis.com/css?family=Noticia+Text|Open+Sans|Special+Elite" rel="stylesheet">
<title>{{title ?? 'simple.blog'}}</title>
<link rel="stylesheet" href="/default.css">
</head>
<body>
<i hidden>{{ '/js/hot-fileloader.js' | ifDebugIncludeScript }}</i>
<h3 id="title"><a href="{{ blog.href ?? '/' }}">{{ blog.name ?? 'blog' }}</a></h3>
{{#if isAuthenticated }}
{{ `SELECT * from UserInfo WHERE UserName = @userName` | dbSingle({ userName }) | to => userInfo }}
{{#if !userInfo}}
{{ userSession.providerOAuthAccess[0] | to => twitterAuth }}
{{ twitterAuth.items.profileUrl | replace(`\\/`,'/') | to => avatarUrl }}
{{ avatarUrl | replace('_normal','') | to => avatarUrlLarge }}
{{ `datetime(CURRENT_TIMESTAMP,'localtime')` | to => sqlNow }}
{{ `INSERT INTO UserInfo (UserName, DisplayName, AvatarUrl, AvatarUrlLarge, Created, Modified)
VALUES (@userName, @name, @avatarUrl, @avatarUrlLarge, ${sqlNow}, ${sqlNow})`
| dbExec({ userName, name: userSession.displayName, avatarUrl, avatarUrlLarge }) }}
{{else}}
{{ userInfo.AvatarUrl | to => avatarUrl }}
{{/if}}
<script>
var redirectedFromTwitter = location.hash === '#s=1';
if (redirectedFromTwitter) location.href = '/{{username}}';
</script>
<div class="profile auth">
<div id="avatar">
<div class="info">
<div>
<a href="/auth/logout">sign out</a>
</div>
</div>
<div class="avatar-sm">
<a href="/{{username}}"><img src="{{ avatarUrl }}" alt="avatar"></a>
</div>
</div>
<div class="links">
<div><a href="/posts/new">new post</a></div>
<div><a href="/db">db</a></div>
</div>
</div>
{{else}}
<div class="profile no-auth">
<div>
<img class="svg-icon" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAGLSURBVEhL7ZQ7SwNBFIXHnY0IdiKCVj5axcZC0EJBxM7GSvwNVtpZaGnhDwgWQsDsTDBgIVhJGhEESaWdCFYiWISdWYKoGc9ubkJ2M8maUsgHh33MPWeeDOvzTzkzQ9wLNh2p97n0t1jBDFILY7naML1ZKBjuCH3AjBmgP+1cVKe50E9catMUvtHZHrw3TFanqLKdjFRzdZM6jY2qBQTdxsKbUj9cqKwj1Q6VtpMRer5hQNCjK/wlaqpT1OPx0Lgcoe7YuT9K1RZKxsUoXhOmB4xq1xXBItpWW9uSygg1S0kdKKqxcIpYU2ULSFPop6QOeNXJejHWM2FOFQbV9XA0wJJcWQNShJlfUkQKsjKCTq5tId2EDrYpIR0s0TE29MUW1EHP4QEhezowbKCDz0SIXULVuKfXyfp33HywgI3LQx/W4IaEOiJLj+SDCYSfIODLGgxF10ovuAV/GYE5bPI9nt+20EjhXeSpFbL1iFeZwSYf4qooIeg9moFQbzglZTyz0Fp4KVJ1nz6M/QL269uAfmjPLQAAAABJRU5ErkJggg==">
<a href="/auth/twitter">sign in</a>
<div><a href="/db">db</a></div>
</div>
</div>
{{/if}}
<div id="body">
{{page}}
</div>
{{htmlError}}
<script src="/default.js"></script>
</body>
</html>
{{#each posts}}
<div class="user-post">
{{#partial contentActions }}
{{#if isAuthenticated and userName = CreatedBy }}
<div class="content-actions">
<a class="lnk" href="/posts/{{Slug}}/edit">edit post</a>
<form action="/posts/{{Slug}}/delete" method="POST">
<button class="btn btn-danger" type="submit" onclick="return confirm('Are you sure you want to delete this post?')">delete post</button>
</form>
</div>
{{/if}}
{{/partial}}
{{#if !hidePostInfo}}
<div class="post-info">
<div class="avatar-lg"><a href="/{{CreatedBy}}"><img src="{{ AvatarUrlLarge }}" alt="avatar"></a></div>
<div class="post-meta">
<a href="/{{CreatedBy}}">@{{ CreatedBy }}</a>
</div>
</div>
{{'contentActions' | partial({ Slug }) }}
{{/if}}
<h2 class="post-title">
<a href="/posts/{{Slug}}">{{Title}}</a>
</h2>
<div class="post-date">
{{ Created | toDateTime | dateFormat('dddd, dd MMMM') }}
</div>
{{ { 'data-edit-path': `/posts/${Slug}/edit`} | if(isAuthenticated and userName = CreatedBy) | to => attrs }}
<div class="post-content" {{ attrs | htmlAttrs }}>
{{ Content | evalScript({use:{context:true}}) | raw }}
</div>
{{#if hidePostInfo}}
{{ 'contentActions' | partial({ Slug }) }}
{{/if}}
</div>
{{/each}}
API /{user}/api
* user : string - Return posts created by this Username
{{ `SELECT *
FROM Post p INNER JOIN UserInfo u on p.CreatedBy = u.UserName
WHERE UserName = @user
ORDER BY p.Created DESC`
| dbSelect({ user })
| return }}
<div class="page">
{{ `SELECT * from UserInfo WHERE UserName = @user` | dbSingle({ user }) | assignTo: userInfo }}
{{#with userInfo}}
<script>document.title = '{{DisplayName}} (@{{UserName}})'</script>
<div id="user-profile">
<div class="avatar-lg"><img src="{{ AvatarUrlLarge }}" alt="avatar"></div>
<h3>@{{UserName}} posts</h3>
{{ UserName == userName | assignTo: userPageIsAuthenticatedUser }}
{{#if userPageIsAuthenticatedUser }}
<div class="links">
<a href="/posts/new">
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none"/>
<path d="M13 7h-2v4H7v2h4v4h2v-4h4v-2h-4V7zm-1-5C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/>
</svg>
<div>new post</div>
</a>
</div>
{{/if}}
</div>
<div>
{{ `SELECT *
FROM Post p INNER JOIN UserInfo u on p.CreatedBy = u.UserName
WHERE UserName = @user
ORDER BY p.Created DESC, Id DESC`
| dbSelect({ user })
| assignTo: posts }}
{{ 'posts' | partial({ posts, hidePostInfo: true } )}}
</div>
<style>
#body {
flex-direction: column;
}
#user-profile {
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
}
#user-profile h3 {
font-family:'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-weight: normal;
margin: .5em 0;
}
#user-profile .links {
margin: .5em 0 0 0;
display: flex;
justify-content: center;
}
</style>
{{else}}
{{ 'error' | partial({ error: `User ${user} does not exist` }) }}
{{/with}}
</div>
debug false
name Blog Web App
db sqlite
db.connection blog.sqlite
features AuthFeature
AuthFeature.AuthProviders TwitterAuthProvider
SharpPagesFeature { ScriptAdminRole: 'AllowAnon' }
oauth.RedirectUrl http://localhost:5000/
oauth.CallbackUrl http://localhost:5000/auth/{0}
oauth.twitter.ConsumerKey JvWZokH73rdghDdCFCFkJtCEU
oauth.twitter.ConsumerSecret WNeOT6YalxXDR4iWZjc4jVjFaydoDcY8jgRrGc5FVLjsVlY2Y8
args.blog { name:'blog.web-app.io', href:'/' }
CefConfig { width:1150, height:1050 }
<!--
title: blog.sqlite database
-->
<div class="page-blank">
{{ `SELECT * FROM Post ORDER BY Modified DESC` | dbSelect | htmlDump({ caption: 'Post table', className: 'table-data db-post' }) }}
{{#noop remove noop block to also display UserInfo table}}
{{ `SELECT * FROM UserInfo ORDER BY Modified DESC` | dbSelect | htmlDump({ caption: 'UserInfo table', className: 'table-data' }) }}
{{/noop}}
<style>
#body { justify-content: left; }
.profile { display: none; }
.db-post td:nth-child(4) {
white-space: pre;
}
</style>
</div>
body {
color: #212121;
padding: 0;
margin: 0;
font-family: 'Open Sans', sans-serif;
font-size: 1.4rem;
}
h1,h2,h3,h4,h5,h6 {
font-family: 'Special Elite', cursive;
font-family: 'Noticia Text', serif;
}
#title { margin:0; padding:.5em .5em 0 0; font-size: 1.5rem; text-align: right; font-weight: normal }
#body { display: flex; align-items: center; justify-content: center; }
a { text-decoration: none; color: #428bca; }
a:hover { text-decoration: underline; opacity: .8; }
.page { min-width: 880px; max-width: 880px; }
.page-edit {
width: 85%;
max-width: 1200px;
padding: 1em;
}
.profile {
position: absolute;
top: 50px;
right: 10px;
text-align: right;
margin: .3em .8em 0 0;
font-size: .8em;
}
#avatar {
display: flex;
}
#avatar img {
margin-left: 10px;
}
.profile a {
font-size: .8em;
}
.profile .info {
margin: .5em 0 0 0;
}
.profile strong {
padding: .3em;
}
.profile .links {
margin: 0 0 0 0;
}
.svg-icon {
vertical-align: bottom;
margin: 5px 0 0 0;
}
.avatar-sm img {
width: 42px;
height: 42px;
border-radius: 50%;
}
.avatar-lg img {
height: 128px;
width: 128px;
margin: 4px;
border-radius: 50%;
border: 1px solid #eee;
padding: 4px;
}
.error {
font-size: 16px;
color: red;
margin: .2em;
}
.error-summary {
background: #f2dede;
color: #a94442;
border-color: #ebccd1;
padding: .5em;
margin: .5em 0px;
}
.alert-danger, .alert-error {
color: #a94442;
margin: .5em 0px;
}
form .row {
margin: 0 0 1em 0;
}
input, textarea {
width: 99%;
padding: .5%;
font-size:16px;
border: 1px solid #eee;
}
button {
font-size: 1.5rem;
padding: .5em 1em;
}
.input-help {
float: right;
font-size: 14px;
}
.lnk {
padding: 8px 16px;
font-size: 16px;
}
.btn {
color: #24292e;
background-color: #eff3f6;
background-image: linear-gradient(-180deg, #fafbfc 0%, #eff3f6 90%);
}
.btn {
font-family: -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol";
position: relative;
display: inline-block;
padding: 8px 16px;
font-size: 16px;
font-weight: 600;
line-height: 20px;
white-space: nowrap;
vertical-align: middle;
cursor: pointer;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
background-repeat: repeat-x;
background-position: -1px -1px;
background-size: 110% 110%;
border: 1px solid rgba(27,31,35,0.2);
border-radius: 0.25em;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
}
.btn:hover, .btn.hover {
background-color: #e6ebf1;
background-image: linear-gradient(-180deg, #f0f3f6 0%, #e6ebf1 90%);
background-position: -.5em;
border-color: rgba(27,31,35,0.35);
}
.btn:active {
background-image: none;
border-color: rgba(27,31,35,0.5);
box-shadow: inset 0 0.15em 0.3em rgba(27,31,35,0.15);
}
.btn-danger:active {
color: #fff;
background-color: #b5202c;
}
.btn-danger {
color: #cb2431;
background-color: #fafbfc;
background-image: linear-gradient(-180deg, #fafbfc 0%, #eff3f6 90%);
}
.btn-danger:hover {
color: #fff;
background-color: #cb2431;
background-image: linear-gradient(-180deg, #de4450 0%, #cb2431 90%);
border-color: rgba(27,31,35,0.5);
}
table.table-data {
width: 100%;
margin: 1em;
border-collapse: collapse;
font-size: 14px;
}
table.table-data caption {
font-size: 20px;
padding: .5em;
border: 1px solid #f1f1f1;
}
.table-data th {
white-space: nowrap;
padding: .2em;
}
.table-data td {
max-width: 500px;
text-overflow: ellipsis;
overflow: hidden;
vertical-align: top;
border: 1px solid #f1f1f1;
padding: .2em;
}
.table-data thead th {
background: #f1f1f1;
}
.post-info {
display: flex;
flex-direction: column;
justify-content: center;
text-align: center;
}
.post-info .avatar-lg img {
width: 96px;
height: 96px;
}
.post-info .post-meta {
font-size: .7em;
}
.post-content {
margin-bottom: 2em;
}
.post-title {
text-align: center;
}
.post-title a {
color: #212121;
border-bottom: 2px dotted #eee;
}
.post-title a:hover {
color: #428bca;
text-decoration: none;
}
.post-date {
text-align: center;
font-size: 16px;
margin: -1em 0 0 0;
color: #999;
}
.content-actions {
text-align: center;
padding: 1em;
}
.content-actions form {
display: inline;
}
.preview {
border: 1px solid #eee;
border-radius: 2px;
padding: 1em;
user-select: none;
overflow-x: auto;
}
.preview:first-child, .preview:last-child {
margin-top:0;
padding-top:0;
}
pre {
word-spacing: normal;
}
code, kbd, pre, samp {
font-family: monospace,monospace;
}
p>code {
font-style: normal;
line-height: 1.5em;
padding: 2px 5px;
margin: 0;
background-color: rgba(0,0,0,0.04);
color: #c7254e;
border-radius: 3px;
}
pre>code {
display: block;
padding: 1em;
margin-bottom: 1em;
color: #333;
font-weight: 400;
}
code {
background-color: #f5f5f5;
white-space: pre-wrap;
font-size: .75em;
}
blockquote {
margin: 0 0 10px 0;
padding: 10px;
background-color: #FFF8DC;
border-left: 2px solid #ffeb8e;
}
blockquote>p {
margin: .5em;
}
.post-content img {
max-width: 880px;
}
.post-content table, .preview table {
border-collapse: collapse;
min-width: 50%;
font-size: .9em;
}
.post-content table th, .preview table th {
text-align: left;
padding: .2em .5em;
background: #f1f1f1;
}
.post-content table td, .preview table td {
text-align: left;
padding: .2em .5em;
}
.post-content h2[id], .post-content h3[id], .post-content h4[id], .post-content h5[id] {
cursor: pointer;
}
.post-content h2[id]:hover:after, .post-content h3[id]:hover:after, .post-content h4[id]:hover:after, .post-content h5[id]:hover:after {
content: " \00B6";
}
// Save content to local storage to preserve state across page reloads
let forms = document.querySelectorAll('form[data-save-drafts]');
for (let i = 0; i < forms.length; i++) {
let form = forms[i];
let monitorInputs = form.querySelectorAll('input[type=text],textarea');
let keys = [];
for (let j=0; j < monitorInputs.length; j++) {
let input = monitorInputs[j];
let key = 'drafts.' + location.pathname + '.' + (input.getAttribute('name') || input.id);
keys.push(key);
let draftValue = localStorage.getItem(key);
if (draftValue) {
input.value = draftValue;
}
input.addEventListener('input', function(e){
localStorage.setItem(key, this.value);
}, false);
}
form.addEventListener('submit', function(e) {
keys.forEach(key => localStorage.removeItem(key));
});
}
// Enable autogrowing textareas
let textAreas = document.querySelectorAll("textarea[data-autogrow]");
for (let i = 0; i < textAreas.length; i++) {
textAreas[i].addEventListener("input", autogrow);
autogrow({ target: textAreas[i] });
}
function autogrow(e) {
let el = e.target;
let minHeignt = 150;
el.style.height = "5px";
el.style.height = Math.max(el.scrollHeight, minHeignt) + "px";
}
// Enable Live Preview of new Content
textAreas = document.querySelectorAll("textarea[data-livepreview]");
for (let i = 0; i < textAreas.length; i++) {
textAreas[i].addEventListener("input", livepreview, false);
livepreview({ target: textAreas[i] });
}
function livepreview(e) {
let el = e.target;
let sel = el.getAttribute("data-livepreview");
if (el.value.trim() == "") {
document.querySelector(sel).innerHTML = "<div style='text-align:center;padding-top:20px;color:#999'>Live Preview</div>";
return;
}
let formData = new FormData();
formData.append("content", el.value);
fetch("/preview", {
method: "post",
body: formData
}).then(function(r) { return r.text(); })
.then(function(r) { document.querySelector(sel).innerHTML = r; });
}
// Ctrl + Click on page to edit
let posts = document.querySelectorAll("[data-edit-path]");
for (let i = 0; i < posts.length; i++) {
let el = posts[i];
let url = el.getAttribute("data-edit-path");
el.addEventListener("click", function(e) {
if (e.ctrlKey) {
location.href = url;
}
});
}
// Auto Link Headings with id attributes
var headings = document.querySelectorAll(".post-content h2[id],.post-content h3[id],.post-content h4[id],.post-content h5[id]");
for (let i = 0; i < headings.length; i++) {
let el = headings[i];
el.addEventListener("click", function(e) {
location.href = "#" + this.id;
});
}
function Editor($editor, opt) {
let history = [];
let redo = [];
let ops = {
lang: opt.lang || "",
target: $editor.querySelector("textarea"),
$emit(evt, arg) {
// input or save
if (evt === "input") {
this.target.value = arg;
var event = new Event("input", {
bubbles: true,
cancelable: true
});
this.target.dispatchEvent(event);
}
if (opt[evt]) {
opt[evt].call(this,arg);
}
},
$nextTick(fn) {
setTimeout(fn, 0);
},
input() {
return this.target;
},
hasSelection() {
return this.input().selectionStart !== this.input().selectionEnd;
},
selection() {
let $txt = this.input();
return $txt.value.substring($txt.selectionStart, $txt.selectionEnd) || "";
},
selectionInfo() {
let $txt = this.input();
let value = $txt.value,
selPos = $txt.selectionStart,
sel = value.substring(selPos, $txt.selectionEnd) || "",
beforeSel = value.substring(0, selPos),
prevCRPos = beforeSel.lastIndexOf("\n");
return {
value,
sel,
selPos,
beforeSel,
afterSel: value.substring(selPos),
prevCRPos,
beforeCR: prevCRPos >= 0 ? beforeSel.substring(0, prevCRPos + 1) : "",
afterCR: prevCRPos >= 0 ? beforeSel.substring(prevCRPos + 1) : ""
};
},
replace({ value, selectionStart, selectionEnd }) {
if (selectionEnd == null) {
selectionEnd = selectionStart;
}
let $txt = this.input();
this.$emit("input", value);
this.$nextTick(() => {
$txt.focus();
$txt.setSelectionRange(selectionStart, selectionEnd);
});
},
insert(
prefix,
suffix,
placeholder,
{
selectionAtEnd,
offsetStart,
offsetEnd,
filterValue,
filterSelection
} = {}
) {
let $txt = this.input();
let value = $txt.value;
let pos = $txt.selectionEnd;
history.push({
value,
selectionStart: $txt.selectionStart,
selectionEnd: $txt.selectionEnd
});
redo = [];
let from = $txt.selectionStart,
to = $txt.selectionEnd,
len = to - from;
let beforeRange = value.substring(0, from);
let afterRange = value.substring(to);
let toggleOff =
prefix && beforeRange.endsWith(prefix) && afterRange.startsWith(suffix);
let originalPos = pos;
let noSelection = from == to;
if (noSelection) {
if (!toggleOff) {
value = beforeRange + prefix + placeholder + suffix + afterRange;
pos += prefix.length;
offsetStart = 0;
offsetEnd = placeholder.length;
if (selectionAtEnd) {
pos += offsetEnd;
offsetEnd = 0;
}
} else {
value =
beforeRange.substring(0, beforeRange.length - prefix.length) +
afterRange.substring(suffix.length);
pos += -suffix.length;
}
if (filterValue) {
var opt = { pos };
value = filterValue(value, opt);
pos = opt.pos;
}
} else {
var selectedText = value.substring(from, to);
if (filterSelection) {
selectedText = filterSelection(selectedText);
}
if (!toggleOff) {
value = beforeRange + prefix + selectedText + suffix + afterRange;
if (offsetStart) {
pos += (prefix + suffix).length;
} else {
pos = from;
offsetStart = prefix.length;
offsetEnd = selectedText.length;
}
} else {
value =
beforeRange.substring(0, beforeRange.length - prefix.length) +
selectedText +
afterRange.substring(suffix.length);
offsetStart = -selectedText.length - prefix.length;
offsetEnd = selectedText.length;
}
}
this.$emit("input", value);
this.$nextTick(() => {
$txt.focus();
offsetStart = pos + (offsetStart || 0);
offsetEnd = offsetStart + (offsetEnd || 0);
$txt.setSelectionRange(offsetStart, offsetEnd);
});
},
bold() {
this.insert("**", "**", "bold");
},
italic() {
this.insert("_", "_", "italics");
},
strikethrough() {
this.insert("~~", "~~", "strikethrough");
},
link() {
this.insert("[", "](http://)", "", { offsetStart: -8, offsetEnd: 7 });
},
quote() {
this.insert("\n> ", "\n", "Blockquote", {});
},
image() {
this.insert("![", "](http://)", "alt text", {
offsetStart: -8,
offsetEnd: 7
});
},
code(e) {
let sel = this.selection();
if (sel && !e.shiftKey) {
this.insert("`", "`", "code");
} else {
let lang = this.lang || "";
let partialSel = sel.indexOf("\n") === -1;
if (partialSel) {
this.insert("\n```" + lang + "\n", "\n```\n", "// code");
} else {
this.insert("```" + lang + "\n", "```\n", "");
}
}
},
ol() {
if (this.hasSelection()) {
let {
sel,
selPos,
beforeSel,
afterSel,
prevCRPos,
beforeCR,
afterCR
} = this.selectionInfo();
let partialSel = sel.indexOf("\n") === -1;
if (!partialSel) {
let indent = !sel.startsWith(" 1. ");
if (indent) {
let index = 1;
this.insert("", "", " - ", {
selectionAtEnd: true,
filterSelection: v =>
" 1. " +
v.replace(/\n$/, "").replace(/\n/g, x => `\n ${++index}. `) +
"\n"
});
} else {
this.insert("", "", "", {
filterValue: (v, opt) => {
if (prevCRPos >= 0) {
let afterCRTrim = afterCR.replace(/^ - /, "");
beforeSel = beforeCR + afterCRTrim;
opt.pos -= afterCR.length - afterCRTrim.length;
}
return beforeSel + afterSel;
},
filterSelection: v =>
v.replace(/^ 1. /g, "").replace(/\n \d+. /g, "\n")
});
}
} else {
this.insert("\n 1. ", "\n");
}
} else {
this.insert("\n 1. ", "\n", "List Item", {
offsetStart: -10,
offsetEnd: 9
});
}
},
ul() {
if (this.hasSelection()) {
let {
sel,
selPos,
beforeSel,
afterSel,
prevCRPos,
beforeCR,
afterCR
} = this.selectionInfo();
let partialSel = sel.indexOf("\n") === -1;
if (!partialSel) {
let indent = !sel.startsWith(" - ");
if (indent) {
this.insert("", "", " - ", {
selectionAtEnd: true,
filterSelection: v =>
" - " + v.replace(/\n$/, "").replace(/\n/g, "\n - ") + "\n"
});
} else {
this.insert("", "", "", {
filterValue: (v, opt) => {
if (prevCRPos >= 0) {
let afterCRTrim = afterCR.replace(/^ - /, "");
beforeSel = beforeCR + afterCRTrim;
opt.pos -= afterCR.length - afterCRTrim.length;
}
return beforeSel + afterSel;
},
filterSelection: v =>
v.replace(/^ - /g, "").replace(/\n - /g, "\n")
});
}
} else {
this.insert("\n - ", "\n");
}
} else {
this.insert("\n - ", "\n", "List Item", {
offsetStart: -10,
offsetEnd: 9
});
}
},
heading() {
let sel = this.selection(),
partialSel = sel.indexOf("\n") === -1;
if (sel) {
if (partialSel) {
this.insert("\n## ", "\n", "");
} else {
this.insert("## ", "", "");
}
} else {
this.insert("\n## ", "\n", "Heading", {
offsetStart: -8,
offsetEnd: 7
});
}
},
comment() {
let {
sel,
selPos,
beforeSel,
afterSel,
prevCRPos,
beforeCR,
afterCR
} = this.selectionInfo();
let comment = !sel.startsWith("//") && !afterCR.startsWith("//");
if (comment) {
if (!sel) {
this.replace({
value: beforeCR + "//" + afterCR + afterSel,
selectionStart: selPos + "//".length
});
} else {
this.insert("", "", "//", {
selectionAtEnd: true,
filterSelection: v =>
"//" + v.replace(/\n$/, "").replace(/\n/g, "\n//") + "\n"
});
}
} else {
this.insert("", "", "", {
filterValue: (v, opt) => {
if (prevCRPos >= 0) {
let afterCRTrim = afterCR.replace(/^\/\//, "");
beforeSel = beforeCR + afterCRTrim;
opt.pos -= afterCR.length - afterCRTrim.length;
}
return beforeSel + afterSel;
},
filterSelection: v => v.replace(/^\/\//g, "").replace(/\n\/\//g, "\n")
});
}
},
blockComment() {
this.insert("/*\n", "*/\n", "");
},
undo() {
if (history.length === 0) return false;
let $txt = this.input();
let lastState = history.pop();
redo.push({
value: $txt.value,
selectionStart: $txt.selectionStart,
selectionEnd: $txt.selectionEnd
});
this.replace(lastState);
return true;
},
redo() {
if (redo.length === 0) return false;
let $txt = this.input();
let lastState = redo.pop();
history.push({
value: $txt.value,
selectionStart: $txt.selectionStart,
selectionEnd: $txt.selectionEnd
});
this.replace(lastState);
return true;
},
save() {
this.$emit("save");
},
onkeydown(e) {
if (e.key === "Escape" || e.keyCode === 27) {
this.$emit('close');
return;
}
let c = String.fromCharCode(e.keyCode).toLowerCase();
if (c === '\t') { //tab: indent/unindent
let indent = !e.shiftKey;
if (indent) {
this.insert('','',' ', {
selectionAtEnd: true,
filterSelection: v => " " + v.replace(/\n$/,'').replace(/\n/g,"\n ") + "\n"
});
} else {
this.insert('','','', {
filterValue:(v,opt) => {
let { selPos, beforeSel, afterSel, prevCRPos, beforeCR, afterCR } = this.selectionInfo();
if (prevCRPos >= 0) {
let afterCRTrim = afterCR.replace(/\t/g,' ').replace(/^ ? ? ? ?/,'');
beforeSel = beforeCR + afterCRTrim;
opt.pos -= afterCR.length - afterCRTrim.length;
}
return beforeSel + afterSel;
},
filterSelection: v => v.replace(/\t/g,' ').replace(/^ ? ? ? ?/g,'').replace(/\n /g,"\n")
});
}
e.preventDefault();
}
else if (e.ctrlKey)
{
if (c === 'z') { //z: undo/redo
if (!e.shiftKey) {
if (this.undo()) {
e.preventDefault();
}
} else {
if (this.redo()) {
e.preventDefault();
}
}
} else if (c === 'b' && !e.shiftKey) { //b: bold
this.bold();
e.preventDefault();
} else if (c === 'h' && !e.shiftKey) { //h: heading
this.heading();
e.preventDefault();
} else if (c === 'i' && !e.shiftKey) { //i: italic
this.italic();
e.preventDefault();
} else if (c === 'q' && !e.shiftKey) { //q: blockquote
this.quote();
e.preventDefault();
} else if (c === 'l') { //l: link/image
if (!e.shiftKey) {
this.link();
e.preventDefault();
} else {
this.image();
e.preventDefault();
}
} else if ((c === 'k' || c === ',' || e.key === '<' || e.key === '>' || e.keyCode === 188)) { //<>: code
this.code(e);
e.preventDefault();
} else if (c === 's' && !e.shiftKey) { //s: save
this.save();
e.preventDefault();
} else if (c === '/' || e.key === '/') {
this.comment();
e.preventDefault();
} else if ((c === '?' || e.key === '?') && e.shiftKey) {
this.blockComment();
e.preventDefault();
}
}
else if (e.altKey) {
if (e.key === '1' || e.key === '0') {
this.ol();
e.preventDefault();
} else if (e.key === '-') {
this.ul();
e.preventDefault();
} else if (e.key === 's') {
this.strikethrough();
e.preventDefault();
}
}
},
ensureMarkdownBlock() {
if (this.target.value.indexOf("{{#markdown") == -1) {
let prefix = "{{#markdown}}\n";
var selection = {
start: this.target.selectionStart,
end: this.target.selectionEnd
};
this.target.value = prefix + this.target.value + "\n{{/markdown}}";
this.target.setSelectionRange(
selection.start + prefix.length,
selection.end + prefix.length
);
}
}
};
let ACTIVE_KEYS = '\t,b,n,h,i,q,l,k,<,>,/,?,1,0'.split(',');
ops.target.addEventListener('keydown', function(e){
var isActiveKey = ACTIVE_KEYS.indexOf(e.key) >= 0;
if (isActiveKey && (e.ctrlKey || e.altKey)) {
ops.ensureMarkdownBlock();
}
ops.onkeydown(e);
});
let btns = $editor.querySelectorAll(".editor-toolbar [data-cmd]");
for (let i = 0; i < btns.length; i++) {
let el = btns[i];
let cmd = el.getAttribute("data-cmd");
el.addEventListener("click", function(e) {
if (ops[cmd]) {
if (['save','undo','redo'].indexOf(cmd) === -1) {
ops.ensureMarkdownBlock();
}
ops[cmd](e);
}
});
}
}
API /hello/{name}
* name : string - Name of Person to greet
{{ { result: `Hello, ${name}!` } | return }}
<div class="page">
{{ `SELECT *
FROM Post p INNER JOIN UserInfo u on p.CreatedBy = u.UserName
ORDER BY p.Created DESC, Id DESC`
| dbSelect | assignTo: posts }}
{{ 'posts' | partial({ posts } )}}
</div>
<div>
Output from init.html:
<pre>{{initout | raw}}</pre>
</div>
API /posts/{slug}/api
* slug : string - Return post with this slug name
{{ `SELECT *
FROM Post p INNER JOIN UserInfo u on p.CreatedBy = u.UserName
WHERE Slug = @slug
ORDER BY p.Created DESC`
| dbSingle({ slug })
| assignTo: post }}
{{ post ?? httpResult({ status:404, statusDescription:'Post was not found' })
| return }}
{{#if isHttpPost}}
{{ 'assert-auth' | partial }}
{{ `SELECT * from Post p INNER JOIN UserInfo u on p.CreatedBy = u.UserName WHERE Slug = @slug ORDER BY p.Created DESC`
| dbSingle({ slug })
| assignTo: post }}
{{ 'assert-post' | partial({ post }) }}
{{ `DELETE FROM Post WHERE Slug=@slug AND CreatedBy=@userName` | dbExec({ slug, userName }) | end }}
{{/if}}
{{ httpResult({ status:301, Location: `/${userName}` }) | return }}
<!--
title: Edit post
-->
<div class="page-edit">
{{ 'assert-auth' | partial }}
{{ 'postTitle,content' | importRequestParams }}
{{ `SELECT * from Post p INNER JOIN UserInfo u on p.CreatedBy = u.UserName WHERE Slug = @slug ORDER BY p.Created DESC`
| dbSingle({ slug })
| assignTo: post }}
{{ 'assert-post' | partial({ post }) }}
{{#if isHttpGet }}
{{ post.Title | assignTo: postTitle}}
{{ post.Content | assignTo: content}}
{{else if isHttpPost }}
{{ assignErrorAndContinueExecuting: ex }}
{{ 'Title must be between 5 and 200 characters' | onlyIf(length(postTitle) < 5 || length(postTitle) > 200) | assignTo: titleError }}
{{ 'Content must be between 25 and 64000 characters' | onlyIf(length(content) < 25 || length(content) > 64000) | assignTo: contentError }}
{{ 'Potentially malicious characters detected' | ifNotExists(contentError) | onlyIf(containsXss(content)) | assignTo: contentError }}
{{#if !(ex || titleError || contentError) }}
{{ `datetime(CURRENT_TIMESTAMP,'localtime')` | assignTo: sqlNow }}
{{ `UPDATE Post SET Title=@title, Content=@content, Modified=${sqlNow}, ModifiedBy=@userName WHERE Slug=@slug`
| dbExec({ slug:post.Slug, title: postTitle, content, userName }) | end }}
{{ ex == null | assignTo: success }}
{{/if}}
{{/if}}
{{#if success}}
{{ httpResult({ status:301, Location: `/${userName}` }) | return }}
{{else}}
<form method="POST" data-save-drafts>
{{#if ex}}<div class="error-summary">{{ex.message}}</div>{{/if}}
<div class="row">
<input name="postTitle" type="text" placeholder="title" autocomplete="off" value="{{postTitle}}">
{{#if titleError}}<div class="error">{{titleError}}</div>{{/if}}
</div>
<div class="row">
{{ 'editor' | partial({ name:"content", placeholder:"content (templates)", 'data-autogrow':true, 'data-livepreview':'.preview', error:contentError }) }}
</div>
<div class="row">
<button class="btn" type="submit">Update post</button>
</div>
</form>
<div>
<div class="preview"></div>
</div>
{{/if}}
<style>
#body {
justify-content: left;
}
textarea {
height: 20em;
}
</style>
</div>
<div class="page">
{{ `SELECT *
FROM Post p INNER JOIN UserInfo u on p.CreatedBy = u.UserName
WHERE Slug = @slug
ORDER BY p.Created DESC`
| dbSingle({ slug })
| assignTo: post }}
{{ 'error' | showIf(!post) | partial({ error:'Post does not exist' }) }}
{{ 'posts' | partial({ posts: [post] } )}}
<div class="content-src">
<h3>Content Source</h3>
<textarea data-autogrow onfocus="this.select()" onmouseup="return false">{{ post.Content }}</textarea>
</div>
<style>
.content-src {
border-top: 1px solid #eee;
position: absolute;
left: 10%;
width: 80%;
padding: 0 0 2em 0;
}
.content-src h3 {
text-align: center;
}
.content-src textarea {
min-height: 20em;
font-size: 16px;
}
</style>
</div>
<!--
title: New post
-->
<div class="page-edit">
{{ 'assert-auth' | partial }}
{{ 'postTitle,content' | importRequestParams }}
{{#if isHttpPost }}
{{ assignErrorAndContinueExecuting: ex }}
{{ 'Title must be between 5 and 200 characters' | onlyIf(length(postTitle) < 5 || length(postTitle) > 200) | assignTo: titleError }}
{{ 'Content must be between 25 and 64000 characters' | onlyIf(length(content) < 25 || length(content) > 64000) | assignTo: contentError }}
{{ 'Potentially malicious characters detected' | ifNotExists(contentError) | onlyIf(containsXss(content)) | assignTo: contentError }}
{{#if !(titleError || contentError) }}
{{#if dbSingle(`SELECT Slug FROM Post WHERE slug = @slug`, { slug: generateSlug(postTitle) }) }}
{{ 'Title already exists' | assignTo: titleError }}
{{/if}}
{{/if}}
{{#if !(ex || titleError || contentError) }}
{{ `datetime(CURRENT_TIMESTAMP,'localtime')` | assignTo: sqlNow }}
{{ `INSERT INTO Post (Slug, Title, Content, Created, CreatedBy, Modified, ModifiedBy) VALUES (@slug, @title, @content, ${sqlNow}, @user, ${sqlNow}, @user)`
| dbExec({ slug: generateSlug(postTitle), title: postTitle, content, user: userName }) }}
{{ ex == null | assignTo: success }}
{{/if}}
{{/if}}
{{#if success}}
{{ httpResult({ status:301, Location: `/${userName}` }) | return }}
{{else}}
<form method="POST" data-save-drafts>
{{#if ex}}<div class="error-summary">{{ex.message}}</div>{{/if}}
<div class="row">
<input name="postTitle" type="text" placeholder="title" autocomplete="off" value="{{postTitle}}">
{{#if titleError}}<div class="error">{{titleError}}</div>{{/if}}
</div>
<div class="row">
{{ 'editor' | partial({ name:"content", placeholder:"content (templates)", 'data-autogrow':true, 'data-livepreview':'.preview', error:contentError }) }}
</div>
<div class="row">
<button class="btn" type="submit">create post</button>
</div>
</form>
<div>
<div class="preview"></div>
</div>
{{/if}}
<style>
#body {
justify-content: left;
}
</style>
</div>
API /preview
* content : string - #Script to evaluate
```code
'content' | importRequestParams
content | evalScript({use:{context:true}}) | to => response
response | return({ contentType:'text/plain' })
```
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
You can’t perform that action at this time.