Merge branch 'main' of github.com:liquidev/treehouse

This commit is contained in:
liquidex 2024-03-31 18:51:14 +02:00
commit 91e5ed3eba
19 changed files with 785 additions and 17 deletions

View file

@ -13,7 +13,7 @@
- welcome! make yourself at home :ralsei_wave:
% id = "01H8VWEHX501SNYQTE61WX7YJC"
- _"owo, what's this?"_
- <a class="secret" href="/kuroneko">_"owo, what's this?"_</a>
% id = "about"
content.link = "about"

8
content/kuroneko.tree Normal file
View file

@ -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 %}

View file

@ -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 %}

View file

@ -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();

View file

@ -291,6 +291,7 @@ impl Generator {
treehouse,
config,
&mut config_derived_data,
paths,
parsed_tree.file_id,
&roots.branches,
);

View file

@ -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,
}
}

View file

@ -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,
"<li data-cast=\"{component}\" class=\"{class}\" id=\"{}\"{linked_branch}{do_not_persist}>",
"<li data-cast=\"{component}\" class=\"{class}\" id=\"{}\"{linked_branch}{do_not_persist}{data_attributes}>",
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("</ul>");
}
@ -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("<ul>");
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("</ul>");
}

View file

@ -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<String, String>,
}
/// Controls for block content presentation.

View file

@ -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<String, InvalidTemplate> {
let (function, arguments) = template.split_once(' ').unwrap_or((template, ""));
match function {
"pic" => Ok(config.pic_url(arguments)),
"c++" => Ok("<script>alert(1)</script>".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
}

View file

@ -0,0 +1,17 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_231_216)">
<path d="M58.9251 43.1308C58.9251 53.7657 51.3445 62.3869 34.3544 62.3869C17.3644 62.3869 9.78375 53.7657 9.78375 43.1308C9.78375 32.496 18.7168 23.8748 34.3544 23.8748C49.9921 23.8748 58.9251 32.496 58.9251 43.1308Z" fill="#0A1523"/>
<path d="M-1.61102 68.5364C5.60985 68.2461 16.9627 56.6158 19.1398 53.9161C29.4813 52.1381 48.2957 56.6618 45.5045 60.7887C42.694 64.9441 45.1568 70.8176 44.6125 75.3896L6.009 83.4498L-1.61102 68.5364Z" fill="#0A1523"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M35.5046 45.5406C34.2535 44.9057 33.2332 44.3707 32.5491 43.9957L39.1294 37.8372C41.3437 38.9287 43.3545 39.9507 45.1777 40.9081C60.4105 35.8277 63.2779 37.7266 62.6334 40.8358C62.4999 41.48 60.927 42.2665 59.2167 43.2832C65.8633 44.9332 66.458 46.8614 65.062 48.7284C64.561 49.3983 62.2561 49.7522 59.3611 49.8998C62.1169 52.5274 61.806 53.8254 60.2049 54.7997C58.7602 55.6788 51.4512 53.0312 44.7222 49.9714C40.7564 51.5652 37.2102 52.8731 35.5046 53.4445V48.8577L35.3493 48.8396L35.5046 48.5073V45.5406Z" fill="#0A1523"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.6009 41.5316L24.51 39.5747C24.0401 39.8064 23.5796 40.0348 23.1285 40.2601C5.84475 35.3566 4.16422 36.9547 3.82589 38.7366C3.48756 40.5185 5.48091 41.8017 7.97218 42.3895C-0.415895 44.0751 -0.0368785 47.1734 0.689339 47.9665C1.33222 48.6686 3.7533 49.2003 7.34024 49.288C2.16946 53.0764 1.84474 55.0053 3.73945 56.1582C5.46225 57.2065 16.176 52.7434 23.5975 49.175C25.8915 50.0555 27.7998 50.7471 28.9055 51.1175L28.9055 48.2076L28.9055 46.5397C29.7039 46.1284 30.3667 45.7779 30.8531 45.5113L28.9055 43.6885L28.9055 42.4061C28.1144 42.1023 27.3464 41.8109 26.6009 41.5316Z" fill="#0A1523"/>
<path d="M22 2.00019C25.7569 1.48815 30.3481 23.8844 34.2202 23.8844L17 29.0002C17.6884 19.9782 18.2431 2.51224 22 2.00019Z" fill="#0A1523"/>
<path d="M48 1.99999C44.0789 1.51492 38.8639 24.0833 36.2637 23.9509L53 30C52.3116 20.978 51.9211 2.48505 48 1.99999Z" fill="#0A1523"/>
<path d="M25 49C26 51 29 51 30 51C31 51 34 50.5 35 50" stroke="white" stroke-opacity="0.2" stroke-width="2" stroke-linecap="round"/>
<path d="M43 50C44 50.5 47 51 48 51C49 51 52 51 53 49" stroke="white" stroke-opacity="0.2" stroke-width="2" stroke-linecap="round"/>
</g>
<defs>
<clipPath id="clip0_231_216">
<rect width="64" height="64" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View file

@ -0,0 +1,19 @@
<svg width="64" height="64" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_227_163)">
<path d="M58.9251 43.1308C58.9251 53.7657 51.3445 62.3869 34.3544 62.3869C17.3644 62.3869 9.78375 53.7657 9.78375 43.1308C9.78375 32.496 18.7168 23.8748 34.3544 23.8748C49.9921 23.8748 58.9251 32.496 58.9251 43.1308Z" fill="#0A1523"/>
<path d="M-1.61102 68.5364C5.60985 68.2461 16.9627 56.6158 19.1398 53.9161C29.4813 52.1381 48.2957 56.6618 45.5045 60.7887C42.694 64.9441 45.1568 70.8176 44.6125 75.3896L6.009 83.4498L-1.61102 68.5364Z" fill="#0A1523"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M35.5046 45.5406C34.2535 44.9057 33.2332 44.3707 32.5491 43.9957L39.1294 37.8372C41.3437 38.9287 43.3545 39.9507 45.1777 40.9081C60.4105 35.8277 63.2779 37.7266 62.6334 40.8358C62.4999 41.48 60.927 42.2665 59.2167 43.2832C65.8633 44.9332 66.458 46.8614 65.062 48.7284C64.561 49.3983 62.2561 49.7522 59.3611 49.8998C62.1169 52.5274 61.806 53.8254 60.2049 54.7997C58.7602 55.6788 51.4512 53.0312 44.7222 49.9714C40.7564 51.5652 37.2102 52.8731 35.5046 53.4445V48.8577L35.3493 48.8396L35.5046 48.5073V45.5406Z" fill="#0A1523"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.6009 41.5316L24.51 39.5747C24.0401 39.8064 23.5796 40.0348 23.1285 40.2601C5.84475 35.3566 4.16422 36.9547 3.82589 38.7366C3.48756 40.5185 5.48091 41.8017 7.97218 42.3895C-0.415895 44.0751 -0.0368785 47.1734 0.689339 47.9665C1.33222 48.6686 3.7533 49.2003 7.34024 49.288C2.16946 53.0764 1.84474 55.0053 3.73945 56.1582C5.46225 57.2065 16.176 52.7434 23.5975 49.175C25.8915 50.0555 27.7998 50.7471 28.9055 51.1175L28.9055 48.2076L28.9055 46.5397C29.7039 46.1284 30.3667 45.7779 30.8531 45.5113L28.9055 43.6885L28.9055 42.4061C28.1144 42.1023 27.3464 41.8109 26.6009 41.5316Z" fill="#0A1523"/>
<path d="M36 43C36 47.9706 34.7109 51.9595 30.3224 51.9595C25.9339 51.9595 24 47.9706 24 43C24 38.0294 26.0556 33.9483 30.3224 33.9483C34.5892 33.9483 36 38.0294 36 43Z" fill="#FFED50"/>
<path d="M32.9937 43.705C32.9937 47.3398 32.3195 50.2863 30.8531 50.2863C29.3867 50.2863 28.7124 47.3398 28.7124 43.705C28.7124 40.0703 29.3867 37.1237 30.8531 37.1237C32.3195 37.1237 32.9937 40.0703 32.9937 43.705Z" fill="#171311"/>
<path d="M54 43C54 47.9706 52.7109 51.9595 48.3224 51.9595C43.9339 51.9595 42 47.9706 42 43C42 38.0294 44.0556 33.9483 48.3224 33.9483C52.5892 33.9483 54 38.0294 54 43Z" fill="#FFED50"/>
<ellipse cx="49.1134" cy="43.705" rx="2.14065" ry="6.58127" fill="#171311"/>
<path d="M22 2.00019C25.7569 1.48815 30.3481 23.8844 34.2202 23.8844L17 29.0002C17.6884 19.9782 18.2431 2.51224 22 2.00019Z" fill="#0A1523"/>
<path d="M48 1.99999C44.0789 1.51492 38.8639 24.0833 36.2637 23.9509L53 30C52.3116 20.978 51.9211 2.48505 48 1.99999Z" fill="#0A1523"/>
</g>
<defs>
<clipPath id="clip0_227_163">
<rect width="64" height="64" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

322
static/chat/kuroneko.json Normal file
View file

@ -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": "<em>*purrs*</em>",
"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"
}
}
}

View file

@ -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" }
}
}

View file

@ -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 <button> */
border: none;
background: none;
color: var(--text-color);
user-select: auto;
padding: 0.5em 0;
margin-right: 2rem;
--recursive-wght: 500;
text-decoration: underline;
text-align: right;
opacity: 80%;
cursor: pointer;
transition: opacity var(--transition-duration);
&.asked {
display: none;
opacity: 50%;
}
&[disabled] {
--recursive-wght: 600;
cursor: default;
opacity: 100%;
text-decoration: none;
}
&:hover {
opacity: 100%;
}
&::before {
content: '';
display: inline-block;
width: 16px;
height: 16px;
margin-right: 0.5em;
background-image: var(--icon-choose);
background-position: 50% 50%;
background-repeat: no-repeat;
opacity: 0%;
vertical-align: middle;
translate: -1em 0;
transition: opacity var(--transition-duration), translate var(--transition-duration);
}
&:hover::before, &[disabled]::before {
opacity: 50%;
translate: 0 0;
}
}
}

View file

@ -101,7 +101,8 @@ body::selection {
body,
pre,
code,
kbd {
kbd,
button {
font-family: 'RecVar', sans-serif;
font-size: 14px;
line-height: 1.5;
@ -343,6 +344,13 @@ a:visited {
color: var(--link-color-visited);
}
/* Allow for some secret links */
a.secret {
color: var(--text-color);
text-decoration: none;
}
/* Make blockquotes a bit prettier */
blockquote {

View file

@ -0,0 +1,188 @@
import { addSpell, spell } from "treehouse/spells.js";
import { Branch } from "treehouse/tree.js";
const characters = {
coco: {
name: "Coco",
},
}
const persistenceKey = "treehouse.chats";
let persistentState = JSON.parse(localStorage.getItem(persistenceKey)) || {};
persistentState.log ??= {};
persistentState.facts ??= {};
savePersistentState();
function savePersistentState() {
localStorage.setItem(persistenceKey, JSON.stringify(persistentState));
}
class Chat extends HTMLElement {
constructor(branch) {
super();
this.branch = branch;
}
connectedCallback() {
this.id = spell(this.branch, Branch).namedID;
this.model = JSON.parse(spell(this.branch, Branch).branchContent.textContent);
this.state = new ChatState(this, this.model);
this.state.onInteract = () => {
persistentState.log[this.id] = this.state.log;
savePersistentState();
};
this.state.exec("init");
let log = persistentState.log[this.id];
if (log != null) {
this.state.replay(log);
}
}
}
customElements.define("th-chat", Chat);
class Said extends HTMLElement {
constructor({ content, character, expression }) {
super();
this.content = content;
this.character = character;
this.expression = expression ?? "neutral";
}
connectedCallback() {
this.picture = new Image(64, 64);
this.picture.src = `${TREEHOUSE_SITE}/static/character/${this.character}/${this.expression}.svg`;
this.picture.classList.add("picture");
this.appendChild(this.picture);
this.textContainer = document.createElement("span");
this.textContainer.innerHTML = this.content;
this.textContainer.classList.add("text-container");
this.appendChild(this.textContainer);
this.dispatchEvent(new Event(".textFullyVisible"));
}
}
customElements.define("th-chat-said", Said);
class Asked extends HTMLElement {
constructor({ content, alreadyAsked }) {
super();
this.content = content;
this.alreadyAsked = alreadyAsked;
}
connectedCallback() {
this.button = document.createElement("button");
this.button.innerHTML = this.content;
this.button.addEventListener("click", _ => {
this.dispatchEvent(new Event(".click"));
});
if (this.alreadyAsked) {
this.button.classList.add("asked");
}
this.appendChild(this.button);
}
interactionFinished() {
this.button.disabled = true;
}
}
customElements.define("th-chat-asked", Asked);
class ChatState {
constructor(container, model) {
this.container = container;
this.model = model;
this.log = [];
this.results = {};
this.wereAsked = new Set();
this.onInteract = _ => {};
}
replay(log) {
for (let entry of log) {
this.interact(entry);
}
}
exec(name) {
let node = this.model.nodes[name];
let results = this.results[name];
this.results[name] = this[node.kind](name, node, results);
}
say(_, node) {
let said = new Said({ content: node.content, character: node.character, expression: node.expression });
said.addEventListener(".textFullyVisible", _ => this.exec(node.then));
this.container.appendChild(said);
}
ask(name, node) {
let questions = [];
for (let i_ = 0; i_ < node.questions.length; ++i_) {
let i = i_;
let key = `${name}[${i}]`;
let question = node.questions[i];
let asked = new Asked({ content: question.content, alreadyAsked: this.wereAsked.has(key) });
asked.addEventListener(".click", _ => {
this.interact({
kind: "ask.choose",
name,
option: i,
key,
});
});
this.container.appendChild(asked);
questions[i] = asked;
}
return questions;
}
set(_, node) {
persistentState.facts[node.fact] = true;
this.exec(node.then);
}
end() {}
interact(interaction) {
let node = this.model.nodes[interaction.name];
this.log.push(interaction);
this.onInteract();
switch (interaction.kind) {
case "ask.choose": {
if (this.wereAsked.has(interaction.key)) {
this.log.pop();
}
this.wereAsked.add(interaction.key);
let questions = this.results[interaction.name];
let question = node.questions[interaction.option];
let asked = questions[interaction.option];
asked.interactionFinished();
this.exec(question.then);
for (let q of questions) {
if (q != asked) {
q.parentNode.removeChild(q);
}
}
}
break;
}
}
}
addSpell("chat", class {
constructor(branch) {
branch.replaceWith(new Chat(branch));
}
});

View file

@ -70,10 +70,12 @@ let mutationObserver = new MutationObserver(records => {
// NOTE: Added nodes may contain children which also need to be processed.
// Collect those that have [data-cast] on them and apply spells to them.
for (let addedNode of record.addedNodes) {
if (addedNode.getAttribute("data-cast") != null) {
mutatedNodes.add(addedNode);
if (!(addedNode instanceof Text)) {
if (addedNode.getAttribute("data-cast") != null) {
mutatedNodes.add(addedNode);
}
addedNode.querySelectorAll("[data-cast]").forEach(element => mutatedNodes.add(element));
}
addedNode.querySelectorAll("[data-cast]").forEach(element => mutatedNodes.add(element));
}
applySpells(mutatedNodes);
}

View file

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 12L10 8L6 4" stroke="#d7cdbf" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 164 B

View file

@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 12L10 8L6 4" stroke="#55423e" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 164 B