| 40 |
40 |
|
//! keeps an edit buffer; a description carrying it would have to carry a way to
|
| 41 |
41 |
|
//! write it back, at which point it is a form model.
|
| 42 |
42 |
|
|
| 43 |
|
- |
use crate::{Emit, push_class};
|
| 44 |
|
- |
use makeover_layout::{Choice, Field, FieldKind};
|
|
43 |
+ |
use crate::{Emit, class, push_class};
|
|
44 |
+ |
use makeover_layout::{Choice, Depth, Field, FieldKind, Selector};
|
| 45 |
45 |
|
use std::fmt::Write as _;
|
| 46 |
46 |
|
|
| 47 |
47 |
|
/// A string that is already markup, and is emitted without escaping.
|
| 511 |
511 |
|
// actual one. Two attributes a letter apart meaning opposite things is
|
| 512 |
512 |
|
// how a renderer's own vocabulary starts drifting.
|
| 513 |
513 |
|
kind if kind.multiline() => {
|
|
514 |
+ |
let rich = matches!(kind, FieldKind::Rich);
|
|
515 |
+ |
if rich {
|
|
516 |
+ |
push_editor_open(out, opts);
|
|
517 |
+ |
}
|
| 514 |
518 |
|
out.push_str("<textarea class=\"");
|
| 515 |
519 |
|
push_class(out, "field", opts);
|
| 516 |
520 |
|
out.push('"');
|
| 517 |
|
- |
if matches!(kind, FieldKind::Rich) {
|
|
521 |
+ |
if rich {
|
| 518 |
522 |
|
out.push_str(" data-format=\"markdown\"");
|
| 519 |
523 |
|
}
|
| 520 |
524 |
|
push_control_attributes(out, field, &id, field.name);
|
| 522 |
526 |
|
out.push('>');
|
| 523 |
527 |
|
escape_into(filling.value.as_text(), out);
|
| 524 |
528 |
|
out.push_str("</textarea>");
|
|
529 |
+ |
if rich {
|
|
530 |
+ |
push_editor_close(out, opts);
|
|
531 |
+ |
}
|
| 525 |
532 |
|
}
|
| 526 |
533 |
|
FieldKind::Select => {
|
| 527 |
534 |
|
out.push_str("<select class=\"");
|
| 589 |
596 |
|
}
|
| 590 |
597 |
|
}
|
| 591 |
598 |
|
|
|
599 |
+ |
/// The chrome a markdown field gets and a plain textarea does not: the two
|
|
600 |
+ |
/// modes, and the pane a preview lands in.
|
|
601 |
+ |
///
|
|
602 |
+ |
/// # Why this is the one field with markup around it
|
|
603 |
+ |
///
|
|
604 |
+ |
/// [`FieldKind::Rich`]'s own doc says the mark buys a renderer permission to
|
|
605 |
+ |
/// offer a preview or a syntax pass, and that a renderer with neither draws a
|
|
606 |
+ |
/// textarea. A renderer taking the permission and emitting the same box as
|
|
607 |
+ |
/// [`FieldKind::Textarea`] leaves an app converting onto the member with less
|
|
608 |
+ |
/// than it had written by hand: MNW's `partial-item-text-editor.js` has a
|
|
609 |
+ |
/// Write/Preview pair and a pane behind it, and describing the field without
|
|
610 |
+ |
/// this would delete both. So the pair is here, on `facet`'s argument one
|
|
611 |
+ |
/// field down -- the markup it replaces is not markup an app is keeping.
|
|
612 |
+ |
///
|
|
613 |
+ |
/// # Nothing here renders markdown, and that is where the sanitising stays
|
|
614 |
+ |
///
|
|
615 |
+ |
/// The pane arrives empty and this crate never turns a value into markup.
|
|
616 |
+ |
/// Converting markdown is the host's, which is where the sanitiser already is:
|
|
617 |
+ |
/// MNW renders through `docengine` over ammonia and holds an allowlist beside
|
|
618 |
+ |
/// it. A converter here would move that guarantee into a crate with no view of
|
|
619 |
+ |
/// the host's content-security posture, and `Rich`'s doc is explicit that a
|
|
620 |
+ |
/// host with its own sanitiser still owns it. What this emits is a hook, and
|
|
621 |
+ |
/// whatever fills it fills it with markup it has already made safe.
|
|
622 |
+ |
///
|
|
623 |
+ |
/// # The direction the enhancement runs
|
|
624 |
+ |
///
|
|
625 |
+ |
/// [`crate::stylesheet`]'s rule for a showing region, and for its reason: a
|
|
626 |
+ |
/// control rendered into a document with no script is a control that looks live
|
|
627 |
+ |
/// and answers nothing. Nothing is hidden here and no control is shown until
|
|
628 |
+ |
/// whatever binds the editor sets `data-ready` on the wrapper, so a reader with
|
|
629 |
+ |
/// no script gets the textarea alone -- what 0.50.0 emitted -- and a reader with
|
|
630 |
+ |
/// script gets the modes. A bound editor says which mode it is in with
|
|
631 |
+ |
/// `data-mode`, and [`editor_rules`] reads that.
|
|
632 |
+ |
fn push_editor_open(out: &mut String, opts: &Emit) {
|
|
633 |
+ |
// The mark sits on the wrapper as well as on the control, saying one thing
|
|
634 |
+ |
// about two: this control's value is markdown, and this editor edits
|
|
635 |
+ |
// markdown. The rules gate on the wrapper and they are attribute rules
|
|
636 |
+ |
// rather than class rules for `data-format`'s own reason -- the gate has to
|
|
637 |
+ |
// survive `Emit`'s class prefixing, because the enhancement selects on it
|
|
638 |
+ |
// too.
|
|
639 |
+ |
out.push_str("<div data-format=\"markdown\"><div class=\"");
|
|
640 |
+ |
push_class(out, "form-editor-modes", opts);
|
|
641 |
+ |
out.push_str("\">");
|
|
642 |
+ |
push_mode(out, "write", "Write", true, opts);
|
|
643 |
+ |
push_mode(out, "preview", "Preview", false, opts);
|
|
644 |
+ |
out.push_str("</div>");
|
|
645 |
+ |
}
|
|
646 |
+ |
|
|
647 |
+ |
/// One of the two modes, as a segment of the pair.
|
|
648 |
+ |
///
|
|
649 |
+ |
/// [`crate::option_class`] for [`Selector::Segmented`] rather than a name of
|
|
650 |
+ |
/// its own: a Write/Preview pair is a segmented control, and spelling it as one
|
|
651 |
+ |
/// gets it the depth, the focus ring and the chosen state every described
|
|
652 |
+ |
/// selector gets, from rules that already exist. The words are written here for
|
|
653 |
+ |
/// the reason `facet`'s exclude button writes its own: a description carrying
|
|
654 |
+ |
/// them would be choosing them for the terminal as well.
|
|
655 |
+ |
fn push_mode(out: &mut String, mode: &str, label: &str, chosen: bool, opts: &Emit) {
|
|
656 |
+ |
out.push_str("<button type=\"button\" class=\"");
|
|
657 |
+ |
push_class(out, crate::option_class(Selector::Segmented), opts);
|
|
658 |
+ |
if chosen {
|
|
659 |
+ |
// The sheet keys the held-in segment on the class and a screen reader
|
|
660 |
+ |
// reads the attribute. Both, because they are two readings of one fact,
|
|
661 |
+ |
// which is the arrangement a facet value already has.
|
|
662 |
+ |
out.push_str(" chosen");
|
|
663 |
+ |
}
|
|
664 |
+ |
let _ = write!(
|
|
665 |
+ |
out,
|
|
666 |
+ |
"\" data-editor-mode=\"{mode}\" aria-pressed=\"{chosen}\">{label}</button>"
|
|
667 |
+ |
);
|
|
668 |
+ |
}
|
|
669 |
+ |
|
|
670 |
+ |
/// The preview pane, and the wrapper closing over both halves.
|
|
671 |
+ |
fn push_editor_close(out: &mut String, opts: &Emit) {
|
|
672 |
+ |
out.push_str("<div class=\"");
|
|
673 |
+ |
push_class(out, "form-editor-preview", opts);
|
|
674 |
+ |
// `data-editor-preview` and not an id: a form appears twice in a document
|
|
675 |
+ |
// often enough that `Filling::id_prefix` exists for it, and a binder holding
|
|
676 |
+ |
// the control can reach this without either of them being unique.
|
|
677 |
+ |
out.push_str("\" data-editor-preview></div></div>");
|
|
678 |
+ |
}
|
|
679 |
+ |
|
|
680 |
+ |
/// The rules the markdown editor's chrome needs.
|
|
681 |
+ |
///
|
|
682 |
+ |
/// The one place this module writes CSS. The class names [`field_html`] emits
|
|
683 |
+ |
/// are goingson's and are deliberately unruled -- `.form-group`, `.form-label`,
|
|
684 |
+ |
/// `.form-hint` and `.form-error` are the app's own, and phase A emits only what
|
|
685 |
+ |
/// it can generate from the description -- but the two names here have no app
|
|
686 |
+ |
/// counterpart to keep, because the chrome did not exist before the member did.
|
|
687 |
+ |
///
|
|
688 |
+ |
/// Every rule is gated on `[data-format="markdown"]`, which is what keeps them
|
|
689 |
+ |
/// off a plain textarea, and every rule that hides content is gated on
|
|
690 |
+ |
/// `data-ready` as well, which is what keeps them out of a document with no
|
|
691 |
+ |
/// script.
|
|
692 |
+ |
pub(crate) fn editor_rules(opts: &Emit) -> String {
|
|
693 |
+ |
let mut css = String::new();
|
|
694 |
+ |
let modes = class("form-editor-modes", opts);
|
|
695 |
+ |
let preview = class("form-editor-preview", opts);
|
|
696 |
+ |
let field = class("field", opts);
|
|
697 |
+ |
|
|
698 |
+ |
// Hidden until something binds the editor, which is the whole argument in
|
|
699 |
+ |
// `push_editor_open`.
|
|
700 |
+ |
let _ = writeln!(
|
|
701 |
+ |
css,
|
|
702 |
+ |
"[data-format=\"markdown\"] > .{modes} {{\n display: none;\n}}"
|
|
703 |
+ |
);
|
|
704 |
+ |
// Block, and nothing about how the two segments sit in it. A button is
|
|
705 |
+ |
// inline already, so they make a row without this crate saying so, and
|
|
706 |
+ |
// saying so is where a gap would follow -- a magnitude, and
|
|
707 |
+ |
// `makeover-geometry`'s.
|
|
708 |
+ |
let _ = writeln!(
|
|
709 |
+ |
css,
|
|
710 |
+ |
"[data-format=\"markdown\"][data-ready] > .{modes} {{\n display: block;\n}}"
|
|
711 |
+ |
);
|
|
712 |
+ |
|
|
713 |
+ |
// The pane is empty until the host fills it, so it is out of flow in every
|
|
714 |
+ |
// state but the one where a bound editor is showing it. An empty box under
|
|
715 |
+ |
// the control is chrome claiming a preview nobody rendered.
|
|
716 |
+ |
let _ = writeln!(
|
|
717 |
+ |
css,
|
|
718 |
+ |
"[data-format=\"markdown\"] > .{preview} {{\n display: none;\n}}"
|
|
719 |
+ |
);
|
|
720 |
+ |
let _ = writeln!(
|
|
721 |
+ |
css,
|
|
722 |
+ |
"[data-format=\"markdown\"][data-ready][data-mode=\"preview\"] > .{preview} \
|
|
723 |
+ |
{{\n display: block;\n}}"
|
|
724 |
+ |
);
|
|
725 |
+ |
// One at a time. The source and the preview are the same content read two
|
|
726 |
+ |
// ways, and a field showing both answers its own question twice.
|
|
727 |
+ |
let _ = writeln!(
|
|
728 |
+ |
css,
|
|
729 |
+ |
"[data-format=\"markdown\"][data-ready][data-mode=\"preview\"] > .{field} \
|
|
730 |
+ |
{{\n display: none;\n}}"
|
|
731 |
+ |
);
|
|
732 |
+ |
|
|
733 |
+ |
// The pane stands where the control stood, so it reads as the surface the
|
|
734 |
+ |
// control was: `.field` is a well, and this is the well it stands in for.
|
|
735 |
+ |
// Nothing about size -- how tall a preview is is the app's, the way the
|
|
736 |
+ |
// height of a track is.
|
|
737 |
+ |
let _ = write!(
|
|
738 |
+ |
css,
|
|
739 |
+ |
"[data-format=\"markdown\"] > .{preview} {{\n{}}}\n",
|
|
740 |
+ |
crate::depth_declarations(Depth::Well)
|
|
741 |
+ |
);
|
|
742 |
+ |
|
|
743 |
+ |
css
|
|
744 |
+ |
}
|
|
745 |
+ |
|
| 592 |
746 |
|
/// One field, as the group the app drops into its form.
|
| 593 |
747 |
|
///
|
| 594 |
748 |
|
/// The shape is goingson's, down to the class names, so adoption there deletes
|
| 1187 |
1341 |
|
assert!(!html.contains("<input"), "{html}");
|
| 1188 |
1342 |
|
}
|
| 1189 |
1343 |
|
|
|
1344 |
+ |
#[test]
|
|
1345 |
+ |
fn a_markdown_field_gets_the_preview_the_member_permits() {
|
|
1346 |
+ |
// The mark on its own is what 0.50.0 shipped, and nothing read it. What
|
|
1347 |
+ |
// a conversion needs is the pair MNW's `partial-item-text-editor.js`
|
|
1348 |
+ |
// already draws, so describing the field is not a way to lose it.
|
|
1349 |
+ |
let filling = Filling::of(Value::Text("# Heading"));
|
|
1350 |
+ |
let html = field_html(&field(FieldKind::Rich), &filling, &Emit::default());
|
|
1351 |
+ |
assert!(html.contains("data-editor-mode=\"write\""), "{html}");
|
|
1352 |
+ |
assert!(html.contains("data-editor-mode=\"preview\""), "{html}");
|
|
1353 |
+ |
assert!(html.contains("data-editor-preview"), "{html}");
|
|
1354 |
+ |
// Write is the mode a fresh editor is in, and the segment says so twice
|
|
1355 |
+ |
// because the sheet reads one and a screen reader reads the other.
|
|
1356 |
+ |
assert!(
|
|
1357 |
+ |
html.contains(
|
|
1358 |
+ |
"class=\"segment chosen\" data-editor-mode=\"write\" aria-pressed=\"true\""
|
|
1359 |
+ |
),
|
|
1360 |
+ |
"{html}"
|
|
1361 |
+ |
);
|
|
1362 |
+ |
assert!(
|
|
1363 |
+ |
html.contains("data-editor-mode=\"preview\" aria-pressed=\"false\""),
|
|
1364 |
+ |
"{html}"
|
|
1365 |
+ |
);
|
|
1366 |
+ |
// The value is still the textarea's, and still text rather than an
|
|
1367 |
+ |
// attribute. The chrome sits around the control, not in place of it.
|
|
1368 |
+ |
assert!(html.contains("># Heading</textarea>"), "{html}");
|
|
1369 |
+ |
}
|
|
1370 |
+ |
|
|
1371 |
+ |
#[test]
|
|
1372 |
+ |
fn a_plain_textarea_gets_no_editor_chrome() {
|
|
1373 |
+ |
let filling = Filling::of(Value::Text("plain"));
|
|
1374 |
+ |
let html = field_html(&field(FieldKind::Textarea), &filling, &Emit::default());
|
|
1375 |
+ |
assert!(!html.contains("data-editor-mode"), "{html}");
|
|
1376 |
+ |
assert!(!html.contains("data-editor-preview"), "{html}");
|
|
1377 |
+ |
assert!(!html.contains("segment"), "{html}");
|
|
1378 |
+ |
}
|
|
1379 |
+ |
|
|
1380 |
+ |
#[test]
|
|
1381 |
+ |
fn nothing_the_editor_emits_renders_the_value_as_markup() {
|
|
1382 |
+ |
// The whole of this crate's half of the sanitising question: the pane is
|
|
1383 |
+ |
// empty, so no value reaches markup through it, and the host's own
|
|
1384 |
+ |
// renderer keeps the guarantee it already has.
|
|
1385 |
+ |
let filling = Filling::of(Value::Text("<img src=x onerror=alert(1)>"));
|
|
1386 |
+ |
let html = field_html(&field(FieldKind::Rich), &filling, &Emit::default());
|
|
1387 |
+ |
assert!(html.contains("data-editor-preview></div>"), "{html}");
|
|
1388 |
+ |
assert!(!html.contains("<img"), "{html}");
|
|
1389 |
+ |
assert!(
|
|
1390 |
+ |
html.contains("<img src=x onerror=alert(1)>"),
|
|
1391 |
+ |
"{html}"
|
|
1392 |
+ |
);
|
|
1393 |
+ |
}
|
|
1394 |
+ |
|
|
1395 |
+ |
#[test]
|
|
1396 |
+ |
fn the_editor_rules_gate_on_the_attribute_and_on_a_binding() {
|
|
1397 |
+ |
let css = editor_rules(&Emit::default());
|
|
1398 |
+ |
// Behind the attribute, which is the reason the mark is an attribute:
|
|
1399 |
+ |
// a class-keyed gate would be prefixed away from the enhancement that
|
|
1400 |
+ |
// selects on it.
|
|
1401 |
+ |
for line in css.lines().filter(|line| line.contains('{')) {
|
|
1402 |
+ |
assert!(line.contains("[data-format=\"markdown\"]"), "{line}");
|
|
1403 |
+ |
}
|
|
1404 |
+ |
// Nothing is hidden and no control appears until something binds the
|
|
1405 |
+ |
// editor. A reader with no script gets the textarea alone.
|
|
1406 |
+ |
assert!(
|
|
1407 |
+ |
css.contains(
|
|
1408 |
+ |
"[data-format=\"markdown\"] > .form-editor-modes {\n display: none;\n}"
|
|
1409 |
+ |
)
|
|
1410 |
+ |
);
|
|
1411 |
+ |
assert!(css.contains(
|
|
1412 |
+ |
"[data-format=\"markdown\"][data-ready] > .form-editor-modes {\n display: block;\n}"
|
|
1413 |
+ |
));
|
|
1414 |
+ |
assert!(css.contains(
|
|
1415 |
+ |
"[data-ready][data-mode=\"preview\"] > .form-editor-preview {\n display: block;\n}"
|
|
1416 |
+ |
));
|
|
1417 |
+ |
assert!(
|
|
1418 |
+ |
css.contains("[data-ready][data-mode=\"preview\"] > .field {\n display: none;\n}")
|
|
1419 |
+ |
);
|
|
1420 |
+ |
// No magnitude, the line this crate holds everywhere else.
|
|
1421 |
+ |
assert!(!css.contains("px"), "{css}");
|
|
1422 |
+ |
assert!(!css.contains("rem"), "{css}");
|
|
1423 |
+ |
}
|
|
1424 |
+ |
|
|
1425 |
+ |
/// The prefix reaches the chrome as well, and the gate deliberately does
|
|
1426 |
+ |
/// not: an app assembling the sheet with its own prefix still has the
|
|
1427 |
+ |
/// selector an enhancement finds the editors by.
|
|
1428 |
+ |
#[test]
|
|
1429 |
+ |
fn the_editor_chrome_is_prefixed_and_its_gate_is_not() {
|
|
1430 |
+ |
let opts = Emit {
|
|
1431 |
+ |
class_prefix: "mk-",
|
|
1432 |
+ |
..Emit::default()
|
|
1433 |
+ |
};
|
|
1434 |
+ |
let html = field_html(&field(FieldKind::Rich), &Filling::default(), &opts);
|
|
1435 |
+ |
assert!(html.contains("class=\"mk-form-editor-modes\""), "{html}");
|
|
1436 |
+ |
assert!(html.contains("class=\"mk-form-editor-preview\""), "{html}");
|
|
1437 |
+ |
assert!(html.contains("class=\"mk-segment chosen\""), "{html}");
|
|
1438 |
+ |
assert!(html.contains("data-format=\"markdown\""), "{html}");
|
|
1439 |
+ |
|
|
1440 |
+ |
let css = editor_rules(&opts);
|
|
1441 |
+ |
assert!(css.contains(".mk-form-editor-modes"), "{css}");
|
|
1442 |
+ |
assert!(css.contains("[data-format=\"markdown\"]"), "{css}");
|
|
1443 |
+ |
}
|
|
1444 |
+ |
|
|
1445 |
+ |
/// Every class the editor puts in markup is one the generated sheet rules,
|
|
1446 |
+ |
/// which is `FACET_CLASSES`' obligation without a list to keep: these two
|
|
1447 |
+ |
/// have rules, so the vocabulary seal picks them up from the sheet itself.
|
|
1448 |
+ |
#[test]
|
|
1449 |
+ |
fn the_editor_classes_are_in_the_vocabulary() {
|
|
1450 |
+ |
let opts = Emit::default();
|
|
1451 |
+ |
let names = crate::vocabulary::names(&opts);
|
|
1452 |
+ |
for name in ["form-editor-modes", "form-editor-preview", "segment"] {
|
|
1453 |
+ |
assert!(names.contains(name), "{name} is not in the vocabulary");
|
|
1454 |
+ |
}
|
|
1455 |
+ |
}
|
|
1456 |
+ |
|
| 1190 |
1457 |
|
#[test]
|
| 1191 |
1458 |
|
fn the_class_prefix_reaches_the_markup_as_well_as_the_stylesheet() {
|
| 1192 |
1459 |
|
let opts = Emit {
|