diff --git a/content/index.tree b/content/index.tree
index 6573d60..4b6850c 100644
--- a/content/index.tree
+++ b/content/index.tree
@@ -13,7 +13,7 @@
- welcome! make yourself at home :ralsei_wave:
% id = "01H8VWEHX501SNYQTE61WX7YJC"
- - _"owo, what's this?"_
+ - _"owo, what's this?"_
% id = "about"
content.link = "about"
diff --git a/content/kuroneko.tree b/content/kuroneko.tree
new file mode 100644
index 0000000..90c0f17
--- /dev/null
+++ b/content/kuroneko.tree
@@ -0,0 +1,8 @@
+%% title = "back porch"
+ scripts = ["components/chat.js"]
+ styles = ["components/chat.css"]
+
+% template = true
+ cast = "chat js"
+ id = "01HSS5TMDD4WSZSXW17ANQN7VK"
+- {% include_static chat/kuroneko.json %}
diff --git a/content/treehouse/dev/chats.tree b/content/treehouse/dev/chats.tree
new file mode 100644
index 0000000..30507ab
--- /dev/null
+++ b/content/treehouse/dev/chats.tree
@@ -0,0 +1,11 @@
+%% title = "testing ground for (chit)chats"
+ scripts = ["components/chat.js"]
+ styles = ["components/chat.css"]
+
+% id = "01HSR695VNDXRGPC3XCHS3A61V"
+- this is a test page for the treehouse's chat system.
+
+% cast = "chat js"
+ id = "01HSR695VNDYS0GDB527B9NPJS"
+ template = true
+- {% include_static chat/treehouse/dev/chats/test.json %}
diff --git a/crates/treehouse/src/cli/fix.rs b/crates/treehouse/src/cli/fix.rs
index 8992c0b..1e48753 100644
--- a/crates/treehouse/src/cli/fix.rs
+++ b/crates/treehouse/src/cli/fix.rs
@@ -1,4 +1,4 @@
-use std::ops::Range;
+use std::{ffi::OsStr, ops::Range};
use anyhow::Context;
use treehouse_format::ast::Branch;
@@ -154,7 +154,7 @@ pub fn fix_file_cli(fix_args: FixArgs) -> anyhow::Result<()> {
pub fn fix_all_cli(fix_all_args: FixAllArgs, paths: &Paths<'_>) -> anyhow::Result<()> {
for entry in WalkDir::new(paths.content_dir) {
let entry = entry?;
- if entry.file_type().is_file() {
+ if entry.file_type().is_file() && entry.path().extension() == Some(OsStr::new("tree")) {
let file = std::fs::read_to_string(entry.path())
.with_context(|| format!("cannot read file to fix: {:?}", entry.path()))?;
let utf8_filename = entry.path().to_string_lossy();
diff --git a/crates/treehouse/src/cli/generate.rs b/crates/treehouse/src/cli/generate.rs
index de3001e..17c3887 100644
--- a/crates/treehouse/src/cli/generate.rs
+++ b/crates/treehouse/src/cli/generate.rs
@@ -291,6 +291,7 @@ impl Generator {
treehouse,
config,
&mut config_derived_data,
+ paths,
parsed_tree.file_id,
&roots.branches,
);
diff --git a/crates/treehouse/src/cli/serve.rs b/crates/treehouse/src/cli/serve.rs
index 94647a0..6ab4ef2 100644
--- a/crates/treehouse/src/cli/serve.rs
+++ b/crates/treehouse/src/cli/serve.rs
@@ -86,6 +86,7 @@ fn get_content_type(path: &str) -> Option<&'static str> {
_ if path.ends_with(".html") => Some("text/html"),
_ if path.ends_with(".js") => Some("text/javascript"),
_ if path.ends_with(".woff2") => Some("font/woff2"),
+ _ if path.ends_with(".svg") => Some("image/svg+xml"),
_ => None,
}
}
diff --git a/crates/treehouse/src/html/tree.rs b/crates/treehouse/src/html/tree.rs
index 988b642..7961037 100644
--- a/crates/treehouse/src/html/tree.rs
+++ b/crates/treehouse/src/html/tree.rs
@@ -1,9 +1,10 @@
-use std::fmt::Write;
+use std::{borrow::Cow, fmt::Write};
use pulldown_cmark::{BrokenLink, LinkType};
use treehouse_format::pull::BranchKind;
use crate::{
+ cli::Paths,
config::{Config, ConfigDerivedData},
html::EscapeAttribute,
state::{FileId, Treehouse},
@@ -20,6 +21,7 @@ pub fn branch_to_html(
treehouse: &mut Treehouse,
config: &Config,
config_derived_data: &mut ConfigDerivedData,
+ paths: &Paths<'_>,
file_id: FileId,
branch_id: SemaBranchId,
) {
@@ -49,6 +51,11 @@ pub fn branch_to_html(
} else {
"b"
};
+ let component = if !branch.attributes.cast.is_empty() {
+ Cow::Owned(format!("{component} {}", branch.attributes.cast))
+ } else {
+ Cow::Borrowed(component)
+ };
let linked_branch = if let Content::Link(link) = &branch.attributes.content {
format!(" data-th-link=\"{}\"", EscapeHtml(link))
@@ -62,9 +69,19 @@ pub fn branch_to_html(
""
};
+ let mut data_attributes = String::new();
+ for (key, value) in &branch.attributes.data {
+ write!(
+ data_attributes,
+ " data-{key}=\"{}\"",
+ EscapeAttribute(value)
+ )
+ .unwrap();
+ }
+
write!(
s,
- "
",
+ "",
EscapeAttribute(&branch.html_id)
)
.unwrap();
@@ -136,7 +153,7 @@ pub fn branch_to_html(
}
};
if branch.attributes.template {
- final_markdown = mini_template::render(config, treehouse, &final_markdown);
+ final_markdown = mini_template::render(config, treehouse, paths, &final_markdown);
}
let markdown_parser = pulldown_cmark::Parser::new_with_broken_link_callback(
&final_markdown,
@@ -204,7 +221,15 @@ pub fn branch_to_html(
let num_children = branch.children.len();
for i in 0..num_children {
let child_id = treehouse.tree.branch(branch_id).children[i];
- branch_to_html(s, treehouse, config, config_derived_data, file_id, child_id);
+ branch_to_html(
+ s,
+ treehouse,
+ config,
+ config_derived_data,
+ paths,
+ file_id,
+ child_id,
+ );
}
s.push_str("");
}
@@ -221,12 +246,21 @@ pub fn branches_to_html(
treehouse: &mut Treehouse,
config: &Config,
config_derived_data: &mut ConfigDerivedData,
+ paths: &Paths<'_>,
file_id: FileId,
branches: &[SemaBranchId],
) {
s.push_str("");
for &child in branches {
- branch_to_html(s, treehouse, config, config_derived_data, file_id, child);
+ branch_to_html(
+ s,
+ treehouse,
+ config,
+ config_derived_data,
+ paths,
+ file_id,
+ child,
+ );
}
s.push_str("
");
}
diff --git a/crates/treehouse/src/tree/attributes.rs b/crates/treehouse/src/tree/attributes.rs
index 6a317ca..025195c 100644
--- a/crates/treehouse/src/tree/attributes.rs
+++ b/crates/treehouse/src/tree/attributes.rs
@@ -1,3 +1,5 @@
+use std::collections::HashMap;
+
use serde::{Deserialize, Serialize};
/// Top-level `%%` root attributes.
@@ -79,6 +81,14 @@ pub struct Attributes {
/// debug mode.
#[serde(default)]
pub stage: Stage,
+
+ /// List of extra spells to cast on the branch.
+ #[serde(default)]
+ pub cast: String,
+
+ /// List of extra `data` attributes to add to the block.
+ #[serde(default)]
+ pub data: HashMap,
}
/// Controls for block content presentation.
diff --git a/crates/treehouse/src/tree/mini_template.rs b/crates/treehouse/src/tree/mini_template.rs
index 947613e..3a1ae86 100644
--- a/crates/treehouse/src/tree/mini_template.rs
+++ b/crates/treehouse/src/tree/mini_template.rs
@@ -8,7 +8,7 @@ use std::ops::Range;
use pulldown_cmark::escape::escape_html;
-use crate::{config::Config, state::Treehouse};
+use crate::{cli::Paths, config::Config, state::Treehouse};
struct Lexer<'a> {
input: &'a str,
@@ -149,7 +149,7 @@ impl<'a> Renderer<'a> {
self.output.push_str(&self.lexer.input[token.range.clone()]);
}
- fn render(&mut self, config: &Config, treehouse: &Treehouse) {
+ fn render(&mut self, config: &Config, treehouse: &Treehouse, paths: &Paths<'_>) {
let kind_of = |token: &Token| token.kind;
while let Some(token) = self.lexer.next() {
@@ -166,6 +166,7 @@ impl<'a> Renderer<'a> {
match Self::render_template(
config,
treehouse,
+ paths,
self.lexer.input[inside.as_ref().unwrap().range.clone()].trim(),
) {
Ok(s) => match escaping {
@@ -192,22 +193,24 @@ impl<'a> Renderer<'a> {
fn render_template(
config: &Config,
_treehouse: &Treehouse,
+ paths: &Paths<'_>,
template: &str,
) -> Result {
let (function, arguments) = template.split_once(' ').unwrap_or((template, ""));
match function {
"pic" => Ok(config.pic_url(arguments)),
- "c++" => Ok("".into()),
+ "include_static" => std::fs::read_to_string(paths.static_dir.join(arguments))
+ .map_err(|_| InvalidTemplate),
_ => Err(InvalidTemplate),
}
}
}
-pub fn render(config: &Config, treehouse: &Treehouse, input: &str) -> String {
+pub fn render(config: &Config, treehouse: &Treehouse, paths: &Paths<'_>, input: &str) -> String {
let mut renderer = Renderer {
lexer: Lexer::new(input),
output: String::new(),
};
- renderer.render(config, treehouse);
+ renderer.render(config, treehouse, paths);
renderer.output
}
diff --git a/static/character/coco/eyes_closed.svg b/static/character/coco/eyes_closed.svg
new file mode 100644
index 0000000..67e67ab
--- /dev/null
+++ b/static/character/coco/eyes_closed.svg
@@ -0,0 +1,17 @@
+
diff --git a/static/character/coco/neutral.svg b/static/character/coco/neutral.svg
new file mode 100644
index 0000000..e8f1ee3
--- /dev/null
+++ b/static/character/coco/neutral.svg
@@ -0,0 +1,19 @@
+
diff --git a/static/chat/kuroneko.json b/static/chat/kuroneko.json
new file mode 100644
index 0000000..b804f78
--- /dev/null
+++ b/static/chat/kuroneko.json
@@ -0,0 +1,322 @@
+{
+ "nodes": {
+ "init": {
+ "kind": "say",
+ "character": "coco",
+ "expression": "eyes_closed",
+ "content": "…",
+ "then": "initQuestion"
+ },
+ "initQuestion": {
+ "kind": "ask",
+ "questions": [{ "content": "Kitty?", "then": "kitty" }]
+ },
+ "kitty": {
+ "kind": "say",
+ "character": "coco",
+ "expression": "eyes_closed",
+ "content": "I'm no ordinary cat.",
+ "then": "introductions"
+ },
+ "introductions": {
+ "kind": "ask",
+ "questions": [
+ {
+ "content": "…woah! You speak!",
+ "then": "introductions.youSpeak"
+ },
+ { "content": "Certainly.", "then": "introductions.certainly" }
+ ]
+ },
+ "introductions.youSpeak": {
+ "kind": "say",
+ "character": "coco",
+ "content": "Yeah. No clue what's so surprising about that. I mean, I've spoken for as long as I remember! But you're not the first person I've met that was surprised that a tiny thing like me could speak.",
+ "then": "introductions.youSpeak2"
+ },
+ "introductions.youSpeak2": {
+ "kind": "ask",
+ "questions": [
+ {
+ "content": "Who was the other?",
+ "then": "introductions.youSpeak.theOther"
+ },
+ {
+ "content": "I mean, obviously.",
+ "then": "introductions.youSpeak.anyways"
+ }
+ ]
+ },
+ "introductions.youSpeak.theOther": {
+ "kind": "say",
+ "character": "coco",
+ "content": "My owner. I've never seen someone this freaked out in my life!",
+ "then": "introductions.youSpeak.theOther2"
+ },
+ "introductions.youSpeak.theOther2": {
+ "kind": "ask",
+ "questions": [
+ {
+ "content": "I'm not surprised at all.",
+ "then": "introductions.youSpeak.anyways"
+ },
+ {
+ "content": "What about me?",
+ "then": "introductions.youSpeak.theOther.whatAboutMe"
+ },
+ {
+ "content": "Who's your owner?",
+ "then": "introductions.youSpeak.theOther.owner"
+ }
+ ]
+ },
+ "introductions.youSpeak.theOther.whatAboutMe": {
+ "kind": "say",
+ "character": "coco",
+ "expression": "eyes_closed",
+ "content": "You're a brave soul. Us cats can feel that. Nothing about your reaction came off as being scared.",
+ "then": "introductions.youSpeak.theOther.whatAboutMe2"
+ },
+ "introductions.youSpeak.theOther.whatAboutMe2": {
+ "kind": "ask",
+ "questions": [
+ {
+ "content": "Glad to hear that.",
+ "then": "introductions.youSpeak.anyways"
+ }
+ ]
+ },
+ "introductions.youSpeak.theOther.owner": {
+ "kind": "say",
+ "character": "coco",
+ "content": "I'd ask her to introduce herself, but she hasn't been around lately. I believe she's out on a trip or something. No clue what the trip's about or when she'll be back.",
+ "then": "introductions.youSpeak.theOther.owner2"
+ },
+ "introductions.youSpeak.theOther.owner2": {
+ "kind": "ask",
+ "questions": [
+ {
+ "content": "\"She?\" You mean, your owner isn't liquidex?",
+ "then": "introductions.youSpeak.theOther.owner3"
+ },
+ {
+ "content": "I see.",
+ "then": "introductions.youSpeak.anyways"
+ }
+ ]
+ },
+ "introductions.youSpeak.theOther.owner3": {
+ "kind": "say",
+ "character": "coco",
+ "expression": "eyes_closed",
+ "content": "No.",
+ "then": "introductions.youSpeak.theOther.owner4"
+ },
+ "introductions.youSpeak.theOther.owner4": {
+ "kind": "ask",
+ "questions": [
+ {
+ "content": "I see.",
+ "then": "introductions.youSpeak.anyways"
+ }
+ ]
+ },
+ "introductions.youSpeak.anyways": {
+ "kind": "say",
+ "character": "coco",
+ "content": "Anyways. Is there anything in particular you're looking for?",
+ "then": "introductions.whatAreYouLookingFor"
+ },
+ "introductions.certainly": {
+ "kind": "say",
+ "character": "coco",
+ "content": "Interesting to see you keeping your composure after meeting a speaking cat.",
+ "then": "introductions.certainly2"
+ },
+ "introductions.certainly2": {
+ "kind": "ask",
+ "questions": [
+ {
+ "content": "I see no reason to be upset. I've seen weirder things.",
+ "then": "introductions.certainly3"
+ }
+ ]
+ },
+ "introductions.certainly3": {
+ "kind": "say",
+ "character": "coco",
+ "content": "Well anyways, while my owner is out, why don't we have a little chat?",
+ "then": "introductions.whatAreYouLookingFor"
+ },
+ "introductions.whatAreYouLookingFor": {
+ "kind": "ask",
+ "questions": [
+ {
+ "content": "Tell me more about yourself.",
+ "then": "aboutYourself"
+ },
+ {
+ "content": "Tell me more about this place.",
+ "then": "aboutPlace"
+ }
+ ]
+ },
+ "aboutYourself": {
+ "kind": "say",
+ "character": "coco",
+ "content": "I'm Coco, a black cat that lives in the treehouse. I like scritches and treats. One day I climbed into the treehouse and got lost. Fortunately my fine owner lady found me and took good care of me! Nowadays I live a happy life with the treefolk.",
+ "then": "aboutYourself2"
+ },
+ "aboutYourself2": {
+ "kind": "ask",
+ "questions": [
+ {
+ "content": "(scritch Coco)",
+ "then": "aboutYourself2.scritch"
+ },
+ {
+ "content": "Are you friends with the treefolk?",
+ "then": "aboutYourself2.friends"
+ },
+ {
+ "content": "You said earlier you're no ordinary cat. What did you mean?",
+ "then": "aboutYourself2.extraordinary"
+ },
+ {
+ "content": "That's all I wanted to know about you, thanks.",
+ "then": "introductions.whatAreYouLookingFor"
+ }
+ ]
+ },
+ "aboutYourself2.scritch": {
+ "kind": "say",
+ "character": "coco",
+ "expression": "eyes_closed",
+ "content": "*purrs*",
+ "then": "aboutYourself2"
+ },
+ "aboutYourself2.friends": {
+ "kind": "say",
+ "character": "coco",
+ "content": "I'm just here for the warmth, food, and scritches. The people here could just as easily be someone else. This is just a nice place to live, so I'm not complaining.",
+ "then": "aboutYourself2"
+ },
+ "aboutYourself2.extraordinary": {
+ "kind": "say",
+ "character": "coco",
+ "content": "I speak. Is there anything more to say?",
+ "then": "aboutYourself2.extraordinary2"
+ },
+ "aboutYourself2.extraordinary2": {
+ "kind": "ask",
+ "questions": [
+ {
+ "content": "Well, that's the obvious part. Is there anything else?",
+ "then": "aboutYourself2.extraordinary3"
+ }
+ ]
+ },
+ "aboutYourself2.extraordinary3": {
+ "kind": "say",
+ "character": "coco",
+ "expression": "eyes_closed",
+ "content": "…Maybe.",
+ "then": "aboutYourself2"
+ },
+ "aboutPlace": {
+ "kind": "say",
+ "character": "coco",
+ "content": "This is the back porch of the house. What do you think?",
+ "then": "aboutPlace.definition"
+ },
+ "aboutPlace.definition": {
+ "kind": "ask",
+ "questions": [
+ {
+ "content": "It's empty.",
+ "then": "aboutPlace.definition.empty"
+ },
+ {
+ "content": "It's wild.",
+ "then": "aboutPlace.definition.wild"
+ },
+ {
+ "content": "It's odd.",
+ "then": "aboutPlace.definition.odd"
+ },
+ {
+ "content": "It's even.",
+ "then": "aboutPlace.definition.even"
+ },
+ {
+ "content": "What even are these answers?",
+ "then": "aboutPlace.definition.what"
+ }
+ ]
+ },
+ "aboutPlace.definition.empty": {
+ "kind": "set",
+ "fact": "kuroneko/empty",
+ "then": "aboutPlace.definition.thankYou"
+ },
+ "aboutPlace.definition.wild": {
+ "kind": "set",
+ "fact": "kuroneko/wild",
+ "then": "aboutPlace.definition.thankYou"
+ },
+ "aboutPlace.definition.odd": {
+ "kind": "set",
+ "fact": "kuroneko/odd",
+ "then": "aboutPlace.definition.thankYou"
+ },
+ "aboutPlace.definition.even": {
+ "kind": "set",
+ "fact": "kuroneko/even",
+ "then": "aboutPlace.definition.thankYou"
+ },
+ "aboutPlace.definition.what": {
+ "kind": "set",
+ "fact": "kuroneko/what",
+ "then": "aboutPlace.definition.thankYou2"
+ },
+ "aboutPlace.definition.thankYou": {
+ "kind": "say",
+ "character": "coco",
+ "expression": "eyes_closed",
+ "content": "Yeah, I think I agree.",
+ "then": "aboutPlace.thatsIt"
+ },
+ "aboutPlace.definition.thankYou2": {
+ "kind": "say",
+ "character": "coco",
+ "expression": "eyes_closed",
+ "content": "I just asked you what you think about how the back porch looks. That's all.",
+ "then": "aboutPlace.theWhat"
+ },
+ "aboutPlace.thatsIt": {
+ "kind": "ask",
+ "questions": [
+ {
+ "content": "That's it? You're not gonna tell me anything else?",
+ "then": "aboutPlace.end"
+ }
+ ]
+ },
+ "aboutPlace.theWhat": {
+ "kind": "ask",
+ "questions": [
+ {
+ "content": "Seriously though, where in the world did that come from.",
+ "then": "aboutPlace.end"
+ }
+ ]
+ },
+ "aboutPlace.end": {
+ "kind": "say",
+ "character": "coco",
+ "expression": "eyes_closed",
+ "content": "…",
+ "then": "introductions.whatAreYouLookingFor"
+ }
+ }
+}
diff --git a/static/chat/treehouse/dev/chats/test.json b/static/chat/treehouse/dev/chats/test.json
new file mode 100644
index 0000000..94bff5a
--- /dev/null
+++ b/static/chat/treehouse/dev/chats/test.json
@@ -0,0 +1,42 @@
+{
+ "nodes": {
+ "init": {
+ "kind": "say",
+ "content": "hello! nice to meet you.",
+ "then": "question1"
+ },
+ "question1": {
+ "kind": "ask",
+ "questions": [
+ { "content": "Who are you?", "then": "whoAreYou" },
+ { "content": "What is this?", "then": "whatIsThis" }
+ ]
+ },
+ "whoAreYou": {
+ "kind": "say",
+ "content": "I'm liquidex. y'know, the guy running this place.",
+ "then": "question1"
+ },
+ "whatIsThis": {
+ "kind": "say",
+ "content": "this is a test of the treehouse dialogue system. basically, I made myself a little framework for writing conversations between characters, and this is a test page for it.",
+ "then": "whatIsThisQuestion"
+ },
+ "whatIsThisQuestion": {
+ "kind": "ask",
+ "questions": [
+ { "content": "Right.", "then": "question1" },
+ {
+ "content": "\"Dialogues\", seriously? What, are you British?",
+ "then": "british"
+ }
+ ]
+ },
+ "british": {
+ "kind": "say",
+ "content": "no, but I think British is pretty damn funny.",
+ "then": "question1"
+ },
+ "end": { "kind": "end" }
+ }
+}
diff --git a/static/css/components/chat.css b/static/css/components/chat.css
new file mode 100644
index 0000000..bd2f13e
--- /dev/null
+++ b/static/css/components/chat.css
@@ -0,0 +1,96 @@
+:root {
+ --icon-choose: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTYgMTJMMTAgOEw2IDQiIHN0cm9rZT0iIzU1NDIzZSIgc3Ryb2tlLXdpZHRoPSIyIi8+Cjwvc3ZnPgo=');
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --icon-choose: url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTYiIGhlaWdodD0iMTYiIHZpZXdCb3g9IjAgMCAxNiAxNiIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTYgMTJMMTAgOEw2IDQiIHN0cm9rZT0iI2Q3Y2RiZiIgc3Ryb2tlLXdpZHRoPSIyIi8+Cjwvc3ZnPgo=');
+ }
+}
+
+th-chat {
+ display: flex;
+ flex-direction: column;
+}
+
+th-chat-said {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+
+ border: 1px solid var(--border-1);
+ padding: 12px 16px;
+ margin: 8px 0;
+ border-radius: 8px;
+
+ max-width: 60%;
+
+ &>.picture {
+ padding-right: 16px;
+ align-self: baseline;
+ }
+
+ &>.text-container {
+ display: inline-block;
+ }
+}
+
+th-chat-asked {
+ display: flex;
+ flex-direction: row-reverse;
+
+ &>button {
+ /* Reset