[add] pages::atomic_editor

[add] pages::atomic_editor::pages::dashboard_request
[add] pages::atomic_editor::new_request
[add] pages::atomic_editor::edit_request
[add] api::pastes::edit_atomic_request
[fix] markdown: hashtag in special elements
[chore] bump version (v0.6.0 -> v0.7.0)
This commit is contained in:
hkau 2024-01-24 14:51:06 -05:00
parent 9c9beb4055
commit 2f06212cfd
15 changed files with 1071 additions and 8 deletions

1
.gitignore vendored
View file

@ -6,6 +6,7 @@ Cargo.lock
# js
node_modules/
/static/js
bun.lockb
# env
.env

View file

@ -3,7 +3,7 @@ name = "bundlrs"
authors = ["hkau"]
license = "MIT"
version = "0.6.0"
version = "0.7.0"
edition = "2021"
rust-version = "1.75"

BIN
bun.lockb

Binary file not shown.

View file

@ -3,7 +3,8 @@
"module": "index.ts",
"type": "module",
"devDependencies": {
"@types/bun": "latest"
"@types/bun": "latest",
"@types/htmlhint": "^1.1.5"
},
"peerDependencies": {
"typescript": "^5.0.0"
@ -21,6 +22,8 @@
"@codemirror/language": "^6.10.0",
"@codemirror/state": "^6.4.0",
"@codemirror/view": "^6.23.0",
"highlight.js": "^11.9.0"
"highlight.js": "^11.9.0",
"htmlhint": "^1.1.4",
"prettier": "^3.2.4"
}
}

View file

@ -1,6 +1,6 @@
use actix_web::{get, post, web, HttpRequest, HttpResponse, Responder};
use crate::db::bundlesdb;
use crate::db::bundlesdb::{self, AtomicPasteFSFile};
use crate::{markdown, ssm, utility};
#[derive(serde::Deserialize)]
@ -25,6 +25,13 @@ struct EditInfo {
new_custom_url: Option<String>,
}
#[derive(serde::Deserialize)]
struct EditAtomicInfo {
custom_url: String,
path: String,
content: String,
}
#[derive(serde::Deserialize)]
struct DeleteInfo {
custom_url: String,
@ -206,6 +213,101 @@ pub async fn edit_request(
.body(serde_json::to_string(&res).unwrap());
}
#[post("/api/edit-atomic")]
pub async fn edit_atomic_request(
req: HttpRequest,
body: web::Json<EditAtomicInfo>,
data: web::Data<bundlesdb::AppData>,
) -> impl Responder {
// this is essentially the same as edit_request but it handles the atomic JSON file system
// ...it does NOT accept an edit password! users must be authenticated
let custom_url: String = body.custom_url.trim().to_string();
let path: String = body.path.trim().to_string();
let content: String = body.content.trim().to_string();
// get owner
let token_cookie = req.cookie("__Secure-Token");
let token_user = if token_cookie.is_some() {
Option::Some(
data.db
.get_user_by_hashed(token_cookie.as_ref().unwrap().value().to_string()) // if the user is returned, that means the ID is valid
.await,
)
} else {
Option::None
};
if token_user.is_some() {
// make sure user exists
if token_user.as_ref().unwrap().success == false {
return HttpResponse::NotFound().body("Invalid token");
}
}
// get paste
let paste: bundlesdb::DefaultReturn<Option<bundlesdb::Paste<String>>> =
data.db.get_paste_by_url(custom_url.clone()).await;
if paste.success == false {
return HttpResponse::Ok()
.append_header(("Content-Type", "application/json"))
.body(serde_json::to_string(&paste).unwrap());
}
// make sure paste is an atomic paste
let unwrap = paste.payload.unwrap();
let is_atomic = unwrap.content.contains("\"_is_atomic\":true");
if is_atomic == false {
return HttpResponse::NotFound().body("Paste is not atomic");
}
// get file from path
let real_content = serde_json::from_str::<bundlesdb::AtomicPaste>(&unwrap.content);
if real_content.is_err() {
return HttpResponse::NotAcceptable().body("Paste failed to deserialize");
}
let mut decoded = real_content.unwrap();
// check for existing file in atomic paste fs
let existing = decoded.files.iter().position(|f| f.path == path);
if existing.is_some() {
// remove existing file
decoded.files.remove(existing.unwrap());
}
// insert file
decoded.files.push(AtomicPasteFSFile {
path,
content: content.clone(),
});
// ...
let res = data
.db
.edit_paste_by_url(
custom_url,
serde_json::to_string::<bundlesdb::AtomicPaste>(&decoded).unwrap(), // encode content
String::new(),
Option::None,
Option::None,
if token_user.is_some() {
Option::Some(token_user.unwrap().payload.unwrap().username)
} else {
Option::None
},
)
.await;
// return
return HttpResponse::Ok()
.append_header(("Content-Type", "application/json"))
.body(serde_json::to_string(&res).unwrap());
}
#[post("/api/delete")]
pub async fn delete_request(
body: web::Json<DeleteInfo>,

View file

@ -45,7 +45,7 @@ pub struct Paste<M> {
pub views: usize,
}
#[derive(Debug, Default, sqlx::FromRow, Clone, Serialize, Deserialize)]
#[derive(Debug, Default, sqlx::FromRow, Clone, Serialize, Deserialize, PartialEq)]
pub struct PasteIdentifier {
pub custom_url: String,
pub id: String,
@ -62,6 +62,21 @@ pub struct PasteMetadata {
pub embed_color: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AtomicPaste {
// atomic pastes are a plain JSON file system storing HTML, CSS, and JS files only
// they have the least amount of boilerplate for rendering!
pub _is_atomic: bool, // this must exist so we know a paste's content is for an atomic paste
pub files: Vec<AtomicPasteFSFile>,
}
#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct AtomicPasteFSFile {
// store only the bare minimum for the required file types
pub path: String,
pub content: String,
}
#[derive(Default, PartialEq, sqlx::FromRow, Clone, Serialize, Deserialize)]
pub struct Group<M> {
// selectors
@ -712,6 +727,50 @@ impl BundlesDB {
};
}
pub async fn get_atomic_pastes_by_owner(
&self,
owner: String,
) -> DefaultReturn<Option<Vec<PasteIdentifier>>> {
let query: &str = if (self.db._type == "sqlite") | (self.db._type == "mysql") {
"SELECT * FROM \"Pastes\" WHERE \"metadata\" LIKE ? AND \"content\" LIKE ?"
} else {
"SELECT * FROM \"Pastes\" WHERE \"metadata\" LIKE $1 AND \"content\" LIKE $2"
};
let c = &self.db.client;
let res = sqlx::query(query)
.bind::<&String>(&format!("%\"owner\":\"{}\"%", &owner))
.bind("%\"_is_atomic\":true%")
.fetch_all(c)
.await;
if res.is_err() {
return DefaultReturn {
success: false,
message: String::from(res.err().unwrap().to_string()),
payload: Option::None,
};
}
// build res
let mut full_res: Vec<PasteIdentifier> = Vec::new();
for row in res.unwrap() {
let row = self.textify_row(row).data;
full_res.push(PasteIdentifier {
custom_url: row.get("custom_url").unwrap().to_string(),
id: row.get("id").unwrap().to_string(),
});
}
// return
return DefaultReturn {
success: true,
message: owner,
payload: Option::Some(full_res),
};
}
// SET
pub async fn create_paste(
&self,

View file

@ -105,6 +105,7 @@ async fn main() -> std::io::Result<()> {
.service(crate::api::pastes::render_request)
.service(crate::api::pastes::create_request)
.service(crate::api::pastes::edit_request)
.service(crate::api::pastes::edit_atomic_request)
.service(crate::api::pastes::delete_request)
.service(crate::api::pastes::metadata_request)
// POST api::pastes SSM
@ -121,6 +122,10 @@ async fn main() -> std::io::Result<()> {
.service(crate::pages::auth::login_request)
.service(crate::pages::settings::user_settings_request)
.service(crate::pages::settings::paste_settings_request)
// GET dashboard (atomic pastes)
.service(crate::pages::atomic_editor::dashboard_request)
.service(crate::pages::atomic_editor::new_request)
.service(crate::pages::atomic_editor::edit_request)
// GET root
.service(crate::pages::home::home_request)
.service(crate::pages::home::robotstxt)

View file

@ -201,7 +201,7 @@ pub fn parse_markdown(input: &String) -> String {
for capture in custom_element_regex.captures_iter(&out.clone()) {
let name = capture.name("NAME").unwrap().as_str();
let atrs = capture.name("ATRS").unwrap().as_str();
let atrs = capture.name("ATRS").unwrap().as_str().replace("$", "#");
let mut atrs_split: Vec<String> = atrs.split("+").map(|s| s.to_string()).collect();
// make sure everything exists (before we try to call .unwrap on them!)
@ -301,7 +301,7 @@ pub fn parse_markdown(input: &String) -> String {
.unwrap();
for capture in ssm_regex.captures_iter(&out.clone()) {
let content = capture.name("CONTENT").unwrap().as_str();
let content = capture.name("CONTENT").unwrap().as_str().replace("$", "#");
// compile
let css = ssm::parse_ssm_program(content.to_string());

407
src/pages/atomic_editor.rs Normal file
View file

@ -0,0 +1,407 @@
use actix_web::HttpRequest;
use actix_web::{get, web, HttpResponse, Responder};
use yew::prelude::*;
use yew::ServerRenderer;
use crate::components::navigation::Footer;
use crate::db::bundlesdb::Paste;
use crate::db::{self, bundlesdb};
use crate::utility::{self, format_html};
#[derive(Default, Properties, PartialEq, serde::Deserialize)]
struct EditQueryProps {
pub path: Option<String>,
}
#[derive(Default, Properties, PartialEq, serde::Deserialize)]
struct FSProps {
pub files: Vec<bundlesdb::AtomicPasteFSFile>,
pub auth_state: Option<bool>,
}
#[derive(Default, Properties, PartialEq, serde::Deserialize)]
struct EditProps {
pub custom_url: String,
pub file: bundlesdb::AtomicPasteFSFile,
pub auth_state: Option<bool>,
}
#[derive(Default, Properties, PartialEq, serde::Deserialize)]
struct NewProps {
pub auth_state: Option<bool>,
}
#[derive(Default, Properties, PartialEq, serde::Deserialize)]
struct Props {
pub pastes: Vec<bundlesdb::PasteIdentifier>,
pub auth_state: Option<bool>,
}
#[function_component]
fn Dashboard(props: &Props) -> Html {
return html! {
<div class="flex flex-column g-4" style="height: 100dvh;">
<main class="small">
<div class="card round secondary flex g-4 flex-wrap mobile:flex-column justify-center align-center" id="pastes_list">
{for props.pastes.iter().map(|p| html! {
<a class="button secondary round mobile:max" href={format!("/d/atomic/{}?path=/index.html", &p.id)}>{&p.custom_url}</a>
})}
<a class="button border round mobile:max" href="/d/atomic/new">
<svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-plus-square"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M8 12h8"/><path d="M12 8v8"/></svg>
{"New"}
</a>
</div>
<style>
{"#pastes_list .button {
display: flex;
width: 10rem !important;
height: 10rem !important;
flex-direction: column;
gap: var(--u-08);
align-items: center;
justify-content: center;
}"}
</style>
<Footer auth_state={props.auth_state} />
</main>
</div>
};
}
fn build_dashboard_renderer_with_props(props: Props) -> ServerRenderer<Dashboard> {
return ServerRenderer::<Dashboard>::with_props(|| props);
}
#[get("/d/atomic")]
pub async fn dashboard_request(
req: HttpRequest,
data: web::Data<db::bundlesdb::AppData>,
) -> impl Responder {
// verify auth status
let token_cookie = req.cookie("__Secure-Token");
let mut set_cookie: &str = "";
let token_user = if token_cookie.is_some() {
Option::Some(
data.db
.get_user_by_hashed(token_cookie.as_ref().unwrap().value().to_string()) // if the user is returned, that means the ID is valid
.await,
)
} else {
Option::None
};
if token_user.is_some() {
// make sure user exists, refresh token if not
if token_user.as_ref().unwrap().success == false {
set_cookie = "__Secure-Token=refresh; SameSite=Strict; Secure; Path=/; HostOnly=true; HttpOnly=true; Max-Age=0";
}
} else {
// you must have an account to use atomic pastes
// we'll likely track bandwidth used by atomic pastes and limit it in the future
return HttpResponse::NotFound().body(
"You must have an account to use atomic pastes.
You can login at: /d/auth/login
You can create an account at: /d/auth/register",
);
}
// fetch pastes
let pastes = data
.db
.get_atomic_pastes_by_owner(token_user.clone().unwrap().payload.unwrap().username)
.await;
// ...
let renderer = build_dashboard_renderer_with_props(Props {
pastes: pastes.payload.unwrap(),
auth_state: if req.cookie("__Secure-Token").is_some() {
Option::Some(true)
} else {
Option::Some(false)
},
});
return HttpResponse::Ok()
.append_header(("Set-Cookie", set_cookie))
.append_header(("Content-Type", "text/html"))
.body(format_html(
renderer.render().await,
"<title>Atomic Dashboard - ::SITE_NAME::</title>",
));
}
#[function_component]
fn CreateNew(props: &NewProps) -> Html {
return html! {
<div class="flex flex-column g-4" style="height: 100dvh;">
<main class="small flex flex-column g-4 align-center">
<div class="card secondary round border" style="width: 25rem;" id="forms">
<div id="error" class="mdnote note-error full" style="display: none;" />
<form class="full flex flex-column g-4" action="/api/auth/register" id="create-site">
<label for="custom_url"><b>{"Custom URL"}</b></label>
<input
type="text"
name="custom_url"
id="custom_url"
placeholder="Custom URL"
class="full round"
minlength={4}
maxlength={32}
/>
<hr />
<button class="bundles-primary full round">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-plus"><path d="M5 12h14"/><path d="M12 5v14"/></svg>
{"Create Site"}
</button>
</form>
</div>
<script type="module">
{"import AuthPages from \"/static/js/NewAtomic.js\";"}
</script>
<Footer auth_state={props.auth_state} />
</main>
</div>
};
}
fn build_new_renderer_with_props(props: NewProps) -> ServerRenderer<CreateNew> {
return ServerRenderer::<CreateNew>::with_props(|| props);
}
#[get("/d/atomic/new")]
pub async fn new_request(
req: HttpRequest,
data: web::Data<db::bundlesdb::AppData>,
) -> impl Responder {
// verify auth status
let token_cookie = req.cookie("__Secure-Token");
let mut set_cookie: &str = "";
let token_user = if token_cookie.is_some() {
Option::Some(
data.db
.get_user_by_hashed(token_cookie.as_ref().unwrap().value().to_string()) // if the user is returned, that means the ID is valid
.await,
)
} else {
Option::None
};
if token_user.is_some() {
// make sure user exists, refresh token if not
if token_user.as_ref().unwrap().success == false {
set_cookie = "__Secure-Token=refresh; SameSite=Strict; Secure; Path=/; HostOnly=true; HttpOnly=true; Max-Age=0";
}
} else {
// you must have an account to use atomic pastes
// we'll likely track bandwidth used by atomic pastes and limit it in the future
return HttpResponse::NotFound().body(
"You must have an account to use atomic pastes.
You can login at: /d/auth/login
You can create an account at: /d/auth/register",
);
}
// ...
let renderer = build_new_renderer_with_props(NewProps {
auth_state: if req.cookie("__Secure-Token").is_some() {
Option::Some(true)
} else {
Option::Some(false)
},
});
return HttpResponse::Ok()
.append_header(("Set-Cookie", set_cookie))
.append_header(("Content-Type", "text/html"))
.body(format_html(
renderer.render().await,
"<title>New Atomic Paste - ::SITE_NAME::</title>",
));
}
#[function_component]
fn EditPaste(props: &EditProps) -> Html {
return html! {
<div class="flex flex-column" style="height: 100dvh;">
<div id="_doc" style="height: 100%; overflow: auto;" />
<div class="card secondary flex mobile:justify-center justify-space-between align-center" style="
overflow: auto hidden;
border-top: 1px solid var(--background-surface2a);
">
// editor actions
<b style="min-width: max-content;" class="device:desktop">{&props.file.path}</b>
<div class="flex g-4">
<button class="round secondary" id="save">{"Save"}</button>
<a href="?" class="button round secondary" id="save" target="_blank">{"Files"}</a>
<div class="hr-left" />
<button class="round border" id="preview">{"Preview"}</button>
</div>
</div>
<script type="module">
{format!("import {{ create_editor }} from \"/static/js/AtomicEditor.js\";
create_editor(document.getElementById('_doc'), '{}', '{}');
globalThis.AtomicEditor.Update(`{}`)", &props.custom_url, &props.file.path, &props.file.content.replace("`", "\\`"))}
</script>
<style>
{".cm-editor, .cm-line, .cm-line span { font-family: monospace !important; }"}
</style>
</div>
};
}
fn build_edit_renderer_with_props(props: EditProps) -> ServerRenderer<EditPaste> {
return ServerRenderer::<EditPaste>::with_props(|| props);
}
#[function_component]
fn PasteFiles(props: &FSProps) -> Html {
return html! {
<div class="flex flex-column" style="height: 100dvh;">
<main class="small">
<div class="card secondary round flex flex-column g-4">
{for props.files.iter().map(|p| html! {
<a href={format!("?path={}", &p.path)}>{&p.path}</a>
})}
</div>
<Footer auth_state={props.auth_state} />
</main>
</div>
};
}
fn build_fs_renderer_with_props(props: FSProps) -> ServerRenderer<PasteFiles> {
return ServerRenderer::<PasteFiles>::with_props(|| props);
}
#[get("/d/atomic/{id:.*}")]
pub async fn edit_request(
req: HttpRequest,
data: web::Data<db::bundlesdb::AppData>,
info: web::Query<EditQueryProps>,
) -> impl Responder {
// verify auth status
let token_cookie = req.cookie("__Secure-Token");
let mut set_cookie: &str = "";
let token_user = if token_cookie.is_some() {
Option::Some(
data.db
.get_user_by_hashed(token_cookie.as_ref().unwrap().value().to_string()) // if the user is returned, that means the ID is valid
.await,
)
} else {
Option::None
};
if token_user.is_some() {
// make sure user exists, refresh token if not
if token_user.as_ref().unwrap().success == false {
set_cookie = "__Secure-Token=refresh; SameSite=Strict; Secure; Path=/; HostOnly=true; HttpOnly=true; Max-Age=0";
}
} else {
// you must have an account to use atomic pastes
// we'll likely track bandwidth used by atomic pastes and limit it in the future
return HttpResponse::NotFound().body(
"You must have an account to use atomic pastes.
You can login at: /d/auth/login
You can create an account at: /d/auth/register",
);
}
// get paste
let id: String = req.match_info().get("id").unwrap().to_string();
let paste: bundlesdb::DefaultReturn<Option<Paste<String>>> = data.db.get_paste_by_id(id).await;
if paste.success == false {
let renderer = ServerRenderer::<crate::pages::errors::_404Page>::new();
return HttpResponse::NotFound()
.append_header(("Content-Type", "text/html"))
.body(utility::format_html(
renderer.render().await,
"<title>404: Not Found</title>",
));
}
// make sure paste is an atomic paste
let unwrap = paste.payload.unwrap();
let is_atomic = unwrap.content.contains("\"_is_atomic\":true");
if is_atomic == false {
return HttpResponse::NotFound().body("Paste is not atomic");
}
// get file from path
let real_content = serde_json::from_str::<bundlesdb::AtomicPaste>(&unwrap.content);
if real_content.is_err() {
return HttpResponse::NotAcceptable().body("Paste failed to deserialize");
}
let decoded = real_content.unwrap();
// show file list if path is none
if info.path.is_none() {
let renderer = build_fs_renderer_with_props(FSProps {
files: decoded.files,
auth_state: if req.cookie("__Secure-Token").is_some() {
Option::Some(true)
} else {
Option::Some(false)
},
});
return HttpResponse::Ok()
.append_header(("Set-Cookie", set_cookie))
.append_header(("Content-Type", "text/html"))
.body(format_html(
renderer.render().await,
&format!(
"<title>Files in {} - ::SITE_NAME::</title>",
&unwrap.custom_url
),
));
}
let path_unwrap = info.path.clone().unwrap();
// ...
let file = decoded.files.iter().find(|f| f.path == path_unwrap);
if file.is_none() {
return HttpResponse::NotAcceptable().body("Path does not exist");
}
// ...
let renderer = build_edit_renderer_with_props(EditProps {
custom_url: unwrap.custom_url,
file: file.unwrap().to_owned(),
auth_state: if req.cookie("__Secure-Token").is_some() {
Option::Some(true)
} else {
Option::Some(false)
},
});
return HttpResponse::Ok()
.append_header(("Set-Cookie", set_cookie))
.append_header(("Content-Type", "text/html"))
.body(format_html(
renderer.render().await,
&format!("<title>{} - ::SITE_NAME::</title>", path_unwrap),
));
}

View file

@ -1,3 +1,4 @@
pub mod atomic_editor;
pub mod auth;
pub mod errors;
pub mod home;

View file

@ -98,6 +98,27 @@ pub async fn paste_view_request(req: HttpRequest, data: web::Data<AppData>) -> i
}
let unwrap = paste.payload.as_ref().unwrap();
// handle atomic pastes (just return index.html)
if unwrap.content.contains("\"_is_atomic\":true") {
let real_content = serde_json::from_str::<bundlesdb::AtomicPaste>(&unwrap.content);
if real_content.is_err() {
return HttpResponse::NotAcceptable().body("Paste failed to deserialize");
}
let decoded = real_content.unwrap();
let index_html = decoded.files.iter().find(|f| f.path == "/index.html");
if index_html.is_none() {
return HttpResponse::NotAcceptable()
.body("Paste is missing a file at the path '/index.html'");
}
return HttpResponse::Ok().body(index_html.unwrap().content.clone());
}
// ...
let metadata = serde_json::from_str::<bundlesdb::PasteMetadata>(&unwrap.metadata).unwrap();
// verify auth status

View file

@ -0,0 +1,419 @@
/**
* @file Handle Atomic Paste file editor
* @name AtomicEditor.ts
* @license MIT
*/
// codemirror
import { EditorState } from "@codemirror/state";
import {
EditorView,
keymap,
highlightSpecialChars,
drawSelection,
highlightActiveLine,
dropCursor,
rectangularSelection,
crosshairCursor,
lineNumbers,
highlightActiveLineGutter,
placeholder,
} from "@codemirror/view";
import {
syntaxHighlighting,
indentOnInput,
bracketMatching,
foldGutter,
foldKeymap,
HighlightStyle,
indentUnit,
} from "@codemirror/language";
import {
autocompletion,
completionKeymap,
closeBrackets,
closeBracketsKeymap,
CompletionContext,
} from "@codemirror/autocomplete";
import {
defaultKeymap,
history,
historyKeymap,
indentWithTab,
} from "@codemirror/commands";
import { html, htmlCompletionSource } from "@codemirror/lang-html";
import { tags } from "@lezer/highlight";
import { linter, Diagnostic, lintGutter } from "@codemirror/lint";
// prettier
// @ts-ignore
import * as prettier from "prettier/standalone.mjs";
import type { Options } from "prettier";
import EstreePlugin from "prettier/plugins/estree";
import BabelParser from "prettier/plugins/babel";
import CSSParser from "prettier/plugins/postcss";
import HTMLParser from "prettier/plugins/html";
// create editor theme
export const DefaultHighlight = HighlightStyle.define([
{
tag: tags.keyword,
color: "var(--red3)",
},
{
tag: tags.tagName,
color: "var(--red3)",
textShadow: "0 0 1px var(--red3)",
},
{
tag: tags.variableName,
color: "var(--blue2)",
},
{
tag: tags.propertyName,
color: "var(--red)",
},
{
tag: tags.comment,
color: "var(--text-color-faded)",
},
{
tag: tags.number,
color: "var(--yellow)",
},
{
tag: tags.string,
color: "var(--green)",
},
{
tag: tags.operator,
color: "var(--red3)",
},
{
tag: tags.bool,
color: "var(--blue2)",
},
{
tag: tags.attributeName,
color: "var(--blue2)",
},
{
tag: tags.attributeValue,
color: "var(--green)",
},
]);
// create lint
import { HTMLHint } from "htmlhint";
let LastLint = performance.now();
export const HTMLLint = linter((view) => {
let diagnostics: Diagnostic[] = [];
// get hints
const hints = HTMLHint.verify(
view.state.sliceDoc(0, view.state.doc.length),
{
"doctype-first": true,
// attributes (https://htmlhint.com/docs/user-guide/list-rules#attributes)
"attr-lowercase": true,
"attr-value-not-empty": true,
"attr-value-double-quotes": true,
// tags (https://htmlhint.com/docs/user-guide/list-rules#tags)
"tag-self-close": true,
"tag-pair": true,
// id (https://htmlhint.com/docs/user-guide/list-rules#id)
"id-unique": true,
}
);
// turn hints into diagnostics
if (hints.length > 0 && performance.now() - LastLint > 100) {
LastLint = performance.now(); // can only run lint every 100ms
// ...
for (const hint of hints) {
if (hint.line === view.state.doc.lines) hint.line = 1; // do not add an error to the last line (breaks editor)
const line = view.state.doc.line(hint.line);
diagnostics.push({
from: line.from + hint.col - 1,
to: line.from + hint.col + hint.raw.length - 1,
severity: hint.type,
message: `${hint.message} (${hint.line}:${hint.col})\n${hint.rule.id}: ${hint.rule.description}`,
});
}
}
// return
return diagnostics;
});
// create completion context
/**
* @function BasicCompletion
*
* @param {CompletionContext} context
* @return {*}
*/
function BasicCompletion(context: CompletionContext): any {
let word = context.matchBefore(/\w*/);
if (!word || (word.from == word.to && !context.explicit)) return null;
return {
from: word.from,
options: [
{
label: "boilerplate",
type: "variable",
apply: `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<span>Hello, world!</span>
</body>
</html>`,
info: "Basic HTML Page Boilerplate",
},
],
};
}
// create editor function
export function create_editor(
element: HTMLElement,
custom_url: string,
path: string
) {
if (globalThis.Bun) return; // must be run from client
const view = new EditorView({
// @ts-ignore
state: EditorState.create({
doc: "",
extensions: [
placeholder(path),
lineNumbers(),
highlightActiveLineGutter(),
highlightSpecialChars(),
history(),
foldGutter(),
drawSelection(),
dropCursor(),
EditorState.allowMultipleSelections.of(true),
syntaxHighlighting(DefaultHighlight, { fallback: true }),
bracketMatching(),
closeBrackets(),
autocompletion({
override: [BasicCompletion, htmlCompletionSource],
activateOnTyping: true,
}),
rectangularSelection(),
crosshairCursor(),
highlightActiveLine(),
lintGutter(),
EditorView.lineWrapping,
EditorView.updateListener.of(async (update) => {
if (update.docChanged) {
const content = update.state.doc.toString();
if (content === "") return;
(globalThis as any).AtomicEditor.Content = content;
}
}),
// keymaps
indentOnInput(),
indentUnit.of(" "),
keymap.of({
...closeBracketsKeymap,
...defaultKeymap,
...historyKeymap,
...foldKeymap,
...completionKeymap,
...indentWithTab,
}),
keymap.of([
// ...new line fix
{
key: "Enter",
run: (): boolean => {
// get current line
const CurrentLine = view.state.doc.lineAt(
view.state.selection.main.head
);
// get indentation string (for automatic indent)
let IndentationString =
// gets everything before the first non-whitespace character
CurrentLine.text.split(/[^\s]/)[0];
let ExtraCharacters = "";
// if last character of the line is }, add an indentation
// } because it's automatically added after opened braces!
if (
CurrentLine.text[
CurrentLine.text.length - 1
] === "{" ||
CurrentLine.text[
CurrentLine.text.length - 1
] === "}"
) {
IndentationString += " ";
ExtraCharacters = "\n"; // auto insert line break after
}
// start transaction
const cursor = view.state.selection.main.head;
const transaction = view.state.update({
changes: {
from: cursor,
insert: `\n${IndentationString}${ExtraCharacters}`,
},
selection: {
anchor:
cursor + 1 + IndentationString.length,
},
scrollIntoView: true,
});
if (transaction) {
view.dispatch(transaction);
}
// return
return true;
},
},
]),
// language
html({ autoCloseTags: true }),
HTMLLint,
],
}),
parent: element,
});
// global functions
(globalThis as any).AtomicEditor = {
Content: "",
Update: (content: string, clear: boolean = false) => {
const transaction = view.state.update({
changes: {
from: 0,
to: view.state.doc.length,
insert: content,
},
scrollIntoView: true,
});
if (transaction) {
view.dispatch(transaction);
}
},
Format: async () => {
try {
const formatted = await prettier.format(
(globalThis as any).AtomicEditor.Content,
{
parser: "html",
plugins: [
EstreePlugin,
BabelParser,
HTMLParser,
CSSParser,
],
htmlWhitespaceSensitivity: "ignore",
// all from the project's .prettierrc
useTabs: false,
singleQuote: false,
tabWidth: 4,
trailingComma: "es5",
printWidth: 85,
semi: true,
} as Options
);
(globalThis as any).AtomicEditor.Update(formatted);
} catch (err) {
alert(err);
}
},
};
// handle interactions
const preview_button = document.getElementById(
"preview"
) as HTMLButtonElement | null;
if (preview_button) {
let url: string = "";
preview_button.addEventListener("click", () => {
if (url.length > 0) {
URL.revokeObjectURL(url);
}
// create blob
const blob = new Blob([(globalThis as any).AtomicEditor.Content], {
type: "text/html",
});
// get url
url = URL.createObjectURL(blob);
// open
window.open(url);
});
}
const save_button = document.getElementById(
"save"
) as HTMLButtonElement | null;
if (save_button) {
save_button.addEventListener("click", async () => {
const res = await fetch("/api/edit-atomic", {
method: "POST",
body: JSON.stringify({
custom_url,
path,
content: (globalThis as any).AtomicEditor.Content,
}),
headers: {
"Content-Type": "application/json",
},
});
const json = await res.json();
if (json.success === false) {
return alert(json.message);
} else {
return alert("File saved");
}
});
}
// return
return view;
}
// default export
export default {
DefaultHighlight,
create_editor,
};

View file

@ -0,0 +1,44 @@
const error: HTMLElement = document.getElementById("error")!;
const create_form: HTMLFormElement | null = document.getElementById(
"create-site"
) as HTMLFormElement | null;
if (create_form) {
// create site
create_form.addEventListener("submit", async (e) => {
e.preventDefault();
const res = await fetch("/api/new", {
method: "POST",
body: JSON.stringify({
custom_url: create_form.custom_url.value,
edit_password: crypto.randomUUID(),
group_name: "",
content: JSON.stringify({
// db::bundlesdb::AtomicPaste
_is_atomic: true,
files: [
{
path: "/index.html",
content: "<!-- New HTML Page -->",
},
],
}),
}),
headers: {
"Content-Type": "application/json",
},
});
const json = await res.json();
if (json.success === false) {
error.style.display = "block";
error.innerHTML = `<div class="mdnote-title">${json.message}</div>`;
} else {
window.location.href = `/${json.payload.custom_url}`;
}
});
}
// default export
export default {};

View file

@ -9,11 +9,13 @@ if (process.env.DO_NOT_CLEAR_DIST === undefined)
const output = await build({
entrypoints: [
"./static/ts/editors/AtomicEditor.ts",
"./static/ts/editors/MarkdownEditor.ts",
"./static/ts/editors/ClientFixMarkdown.ts",
"./static/ts/editors/SettingsEditor.ts",
"./static/ts/pages/AuthPages.ts",
"./static/ts/pages/Footer.ts",
"./static/ts/pages/NewAtomic.ts",
],
minify: {
identifiers: true,

View file

@ -10,7 +10,6 @@
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
/* Linting */