diff --git a/docs/adr/0014-demote-markdown-headings-in-lesson-rendering.md b/docs/adr/0014-demote-markdown-headings-in-lesson-rendering.md index 709aab4..b510b4b 100644 --- a/docs/adr/0014-demote-markdown-headings-in-lesson-rendering.md +++ b/docs/adr/0014-demote-markdown-headings-in-lesson-rendering.md @@ -55,6 +55,12 @@ audits exist to catch. - Trade-off: the rendered HTML no longer matches what a generic CommonMark renderer would produce for the same source (a surprise when comparing with an external preview) +- Amendment (2026-07-28, issue #117): demotion turns the common habit of + starting a document with its own title into an `h2` that duplicates the + page `h1`, so the lesson route now strips a leading level-1 heading whose + text equals the lesson title before rendering + (`MarkdownRenderer.stripLeadingTitleHeading`); the stored Markdown is + untouched ## Pros and Cons of the Options diff --git a/docs/adr/0015-render-lesson-alerts-with-commonmark-alerts.md b/docs/adr/0015-render-lesson-alerts-with-commonmark-alerts.md new file mode 100644 index 0000000..c22e673 --- /dev/null +++ b/docs/adr/0015-render-lesson-alerts-with-commonmark-alerts.md @@ -0,0 +1,107 @@ +# Render lesson alerts with the commonmark-java alerts extension + +- Status: accepted +- Date: 2026-07-28 +- Deciders: Eric Bouchut + +## Context and Problem Statement + +Instructors write lesson content in Markdown (see +[ADR-0013](0013-render-lesson-markdown-with-commonmark-java.md)) and expect +the callout syntax they know from GitHub and Obsidian to work: +`> [!note]`, `> [!tip] Custom title`, nested callouts. Plain CommonMark +renders these as ordinary blockquotes with the marker as literal text. How +should lessons render alert/callout blocks while keeping the sanitization +pipeline allowlist-based and the rendered HTML free of author-controlled +markup tricks? + +## Decision Drivers + +- Author familiarity: the GitHub/Obsidian syntax should just work, including + content pasted from either tool +- ADR-0013's pipeline stays: parse, render, sanitize against an allowlist; + no raw SVG or style attributes may survive into the page +- Maintenance cost: prefer an official, tested module over in-repo parser code +- GitHub fidelity: content authored for GitHub must render with the same + type semantics as on GitHub + +## Considered Options + +- The official `commonmark-ext-gfm-alerts` extension (requires upgrading + commonmark 0.24.0 => 0.29.0) +- A custom commonmark-java block parser and renderer kept in this repository +- A jsoup post-processing pass that rewrites `[!note]` blockquotes after + rendering +- Do nothing: callout markers render as literal blockquote text + +## Decision Outcome + +Chosen: "the official `commonmark-ext-gfm-alerts` extension", because it is +maintained by the commonmark-java project itself, implements the exact GFM +syntax plus the options needed to cover Obsidian (custom types, custom +titles, nested alerts), and emits plain `div`/`p` markup with predictable +class names that the sanitizer can allowlist precisely. The extension is +version-locked to its same-version core and its configuration API only +exists in 0.29.0, so the whole commonmark stack moves 0.24.0 => 0.29.0 +(changelog reviewed: no API removals; the notable parser change is that +tables no longer require a preceding blank line). + +Configuration decisions that follow: + +- The five GFM types (`NOTE`, `TIP`, `IMPORTANT`, `WARNING`, `CAUTION`) keep + their GitHub identity: `IMPORTANT` and `CAUTION` are registered as + standalone types, not as Obsidian aliases, so GitHub-authored content + renders with GitHub's semantics. The remaining Obsidian callout set joins + them with all aliases (27 registered types in total); markers are + case-insensitive; custom titles and nesting are enabled. +- Icons are Octicons (the set GitHub uses for alerts), matched per type + against Obsidian's icon semantics, and shipped as CSS `mask-image` data + URIs colored by `currentColor`. The sanitizer therefore never has to let + SVG through. +- The jsoup allowlist must now admit `class` on `div` and `p` plus + `data-alert-type`. To keep raw HTML in lessons from borrowing site classes + (`alert`, `site-header`, ...), a second sanitization pass strips every + class value the alert renderer does not emit: only + `markdown-alert markdown-alert-` on `div` and `markdown-alert-title` + on `p` survive. + +### Consequences + +- Good: GitHub- and Obsidian-authored content renders as its authors expect, + with no house syntax to teach +- Good: the sanitization posture of ADR-0013 is preserved; the allowlist + widens by two attributes and is immediately narrowed by an exact-value pass +- Good: the commonmark stack is current again (0.24.0 dated from the initial + integration) +- Trade-off: a major-feature upgrade of the Markdown stack rides along; the + full test suite is the regression net for tables, heading demotion, and + caching +- Trade-off: 27 registered types means 27 CSS selectors and a set of icon + data URIs in `base.css`, a one-time styling cost paid in this change + +## Pros and Cons of the Options + +### Official commonmark-ext-gfm-alerts extension + +- 👍 Maintained upstream by the commonmark-java project, GFM-exact syntax +- 👍 Options cover the full Obsidian feature set (types, titles, nesting) +- 👍 Emits sanitizer-friendly `div`/`p` markup, no inline styles or SVG +- 👎 Forces the 0.24.0 => 0.29.0 core upgrade in the same change + +### Custom block parser in this repository + +- 👍 No version constraint on the core +- 👎 Reimplements and then maintains non-trivial parsing (nesting, titles, + lazy continuation) that upstream already tests + +### jsoup post-processing of rendered blockquotes + +- 👍 No parser changes at all +- 👎 Fragile text matching inside rendered HTML; nesting and custom titles + get hard quickly; the marker has already been mangled by inline rendering + +### Do nothing + +- 👍 No code +- 👎 Pasted GitHub/Obsidian content degrades into blockquotes with visible + `[!note]` markers, exactly what instructors would report as a bug diff --git a/docs/adr/README.md b/docs/adr/README.md index e479efb..30f53a1 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -44,4 +44,5 @@ NNNN-short-title-in-kebab-case.md | [0011](0011-start-ci-quality-checks-as-advisory-reports.md) | Start CI quality checks as advisory reports, gates come later | superseded by ADR-0012 | | [0012](0012-publish-test-coverage-to-codecov.md) | Publish test coverage to Codecov | accepted | | [0013](0013-render-lesson-markdown-with-commonmark-java.md) | Render lesson Markdown with commonmark-java, sanitized by jsoup | accepted | -| [0014](0014-demote-markdown-headings-in-lesson-rendering.md) | Demote Markdown headings one level in lesson rendering | accepted | \ No newline at end of file +| [0014](0014-demote-markdown-headings-in-lesson-rendering.md) | Demote Markdown headings one level in lesson rendering | accepted | +| [0015](0015-render-lesson-alerts-with-commonmark-alerts.md) | Render lesson alerts with the commonmark-java alerts extension | accepted | \ No newline at end of file diff --git a/docs/plans/2026-07-28-lesson-alerts-and-title-dedup.md b/docs/plans/2026-07-28-lesson-alerts-and-title-dedup.md new file mode 100644 index 0000000..e3a8f85 --- /dev/null +++ b/docs/plans/2026-07-28-lesson-alerts-and-title-dedup.md @@ -0,0 +1,32 @@ +# Lesson Alerts and Title Dedup Implementation Plan + +**Goal:** Two improvements to lesson Markdown rendering, on the `fix/lesson-content-styles` branch: (1) alert/callout blocks (`> [!note]`) in the syntax GitHub and Obsidian share, styled with octicons and theme accents (issue #116); (2) stop showing a duplicated heading when a lesson's Markdown starts with a level-1 heading equal to the lesson title (issue #117). + +**Architecture:** Both changes stay inside the rendering pipeline of [ADR-0013](../adr/0013-render-lesson-markdown-with-commonmark-java.md) and [ADR-0014](../adr/0014-demote-markdown-headings-in-lesson-rendering.md). Alerts are parsed by the official `commonmark-ext-gfm-alerts` extension (decision recorded in [ADR-0015](../adr/0015-render-lesson-alerts-with-commonmark-alerts.md)), which forces a commonmark 0.24.0 => 0.29.0 upgrade; sanitization stays allowlist-based with a second pass that rejects any class value the alert renderer does not emit. The title dedup is a pure static helper in `MarkdownRenderer`, applied by the lesson route before `render()` so the render cache stays content-addressed. + +**Out of scope:** alert support outside lessons, a Markdown preview in the lesson form, and any change to stored lesson content. + +--- + +## Version Control (GitButler) + +- Commit with `but commit fix/lesson-content-styles -m ""` from the main repository; never `git add`/`git commit`. +- **NEVER push.** The user reviews in GitButler and pushes manually. +- One atomic commit per task below; tests land with the code they prove. + +--- + +## Tasks + +- [ ] `docs(plan): Add the lesson alerts and title dedup plan` (this document) +- [ ] `docs(adr): Record the lesson alert rendering decision` ([ADR-0015](../adr/0015-render-lesson-alerts-with-commonmark-alerts.md) plus the ADR index row) +- [ ] `build(deps): Upgrade commonmark to 0.29.0` (version property only; required by the alerts extension, which is version-locked to its core; changelog reviewed, no API removals; full suite green proves tables, heading demotion, and caching survive) +- [ ] `feat(markdown): Render GFM and Obsidian-style alerts in lessons` (alerts dependency; `MarkdownRenderer` wiring: 27 registered types where the five GFM types keep their GitHub identity and the Obsidian set joins them with aliases, custom titles, nesting; sanitizer allowlist for `div[class, data-alert-type]` and `p[class]` plus the `stripUnknownAlertClasses` second pass; renderer tests incl. a class-spoofing case) +- [ ] `feat(frontend): Style lesson alerts with octicon icons` (alert styles in the `.lesson-content` section of `base.css`: accent border and title color per type family via theme tokens, octicons as `mask` data URIs colored by `currentColor`, nested alerts inset; closes #116) +- [ ] `fix(markdown): Skip a leading heading duplicating the lesson title` (static `stripLeadingTitleHeading`; lesson route applies it before `render()`; tests for ATX/setext, case and whitespace tolerance, non-matching heading kept; lesson form hint; consequence note in [ADR-0014](../adr/0014-demote-markdown-headings-in-lesson-rendering.md); closes #117) + +## Verification + +- `make test` and `./mvnw checkstyle:check` green after the upgrade commit and again at the end. +- Browser pass on a lesson exercising several alert types, nesting, and a custom title: correct icon, accent, and title in both themes; axe reports zero violations; no horizontal page scroll (RGAA 10.11). +- A lesson whose Markdown starts with `# ` renders exactly one heading with that text. diff --git a/pom.xml b/pom.xml index 25b6013..e28634d 100644 --- a/pom.xml +++ b/pom.xml @@ -58,7 +58,7 @@ 3.6.0 3.11.2 0.8.15 - 0.24.0 + 0.29.0 1.21.1 @@ -135,6 +135,20 @@ commonmark ${commonmark.version} + + + org.commonmark + commonmark-ext-gfm-tables + ${commonmark.version} + + + + org.commonmark + commonmark-ext-gfm-alerts + ${commonmark.version} + org.jsoup jsoup diff --git a/src/main/java/com/ericbouchut/learndev/course/CourseController.java b/src/main/java/com/ericbouchut/learndev/course/CourseController.java index 80d88cb..3c31456 100644 --- a/src/main/java/com/ericbouchut/learndev/course/CourseController.java +++ b/src/main/java/com/ericbouchut/learndev/course/CourseController.java @@ -122,9 +122,11 @@ public String drop(@PathVariable Long courseId, Principal principal) { /** * Display one published lesson to an enrolled student, with the lesson * Markdown rendered to sanitized HTML and previous/next links in - * reading order. A student who is not actively enrolled is sent back to - * the course page with an {@code enroll-required} hint instead of a 403: - * the Register button is right there. + * reading order. A leading heading that repeats the lesson title is + * skipped: the page already renders the title as its {@code h1}. A + * student who is not actively enrolled is sent back to the course page + * with an {@code enroll-required} hint instead of a 403: the Register + * button is right there. * * @param courseId the course the lesson belongs to * @param lessonId the lesson to read @@ -153,7 +155,9 @@ public String lesson( int index = indexOf(lessons, lesson); model.addAttribute("course", course); model.addAttribute("lesson", lesson); - model.addAttribute("contentHtml", markdownRenderer.render(lesson.getContentMarkdown())); + model.addAttribute("contentHtml", markdownRenderer.render( + MarkdownRenderer.stripLeadingTitleHeading( + lesson.getContentMarkdown(), lesson.getTitle()))); model.addAttribute("previousLesson", index > 0 ? lessons.get(index - 1) : null); model.addAttribute("nextLesson", index < lessons.size() - 1 ? lessons.get(index + 1) : null); diff --git a/src/main/java/com/ericbouchut/learndev/course/MarkdownRenderer.java b/src/main/java/com/ericbouchut/learndev/course/MarkdownRenderer.java index 47e38c4..08de0d3 100644 --- a/src/main/java/com/ericbouchut/learndev/course/MarkdownRenderer.java +++ b/src/main/java/com/ericbouchut/learndev/course/MarkdownRenderer.java @@ -1,6 +1,9 @@ package com.ericbouchut.learndev.course; import com.ericbouchut.learndev.common.config.CacheConfig; +import org.commonmark.Extension; +import org.commonmark.ext.gfm.alerts.AlertsExtension; +import org.commonmark.ext.gfm.tables.TablesExtension; import org.commonmark.node.Heading; import org.commonmark.node.Node; import org.commonmark.parser.Parser; @@ -9,6 +12,8 @@ import org.commonmark.renderer.html.HtmlRenderer; import org.commonmark.renderer.html.HtmlWriter; import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; import org.jsoup.safety.Safelist; import org.springframework.cache.annotation.Cacheable; import org.springframework.stereotype.Service; @@ -16,9 +21,13 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.util.Arrays; import java.util.HexFormat; +import java.util.List; import java.util.Map; import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * Converts lesson Markdown to HTML that is safe to serve. @@ -36,12 +45,78 @@ public class MarkdownRenderer { // The class attribute on code keeps CommonMark's language-* hints - // (```java fences) available to a future syntax highlighter. + // (```java fences) available to a future syntax highlighter. The div/p + // class and data-alert-type attributes exist only for alerts, and + // stripUnknownAlertClasses() rejects every value the alert renderer + // does not emit, so raw HTML in a lesson cannot borrow site classes. private static final Safelist SAFELIST = Safelist.relaxed() - .addAttributes("code", "class"); + .addAttributes("code", "class") + .addAttributes("div", "class", "data-alert-type") + .addAttributes("p", "class"); - private final Parser parser = Parser.builder().build(); + private static final Pattern ALERT_DIV_CLASS = + Pattern.compile("^markdown-alert markdown-alert-[a-z]+$"); + private static final Pattern ALERT_TYPE_VALUE = Pattern.compile("^[a-z]+$"); + + // A level-1 ATX heading per CommonMark: up to 3 leading spaces, one #, + // whitespace, the text, then an optional closing run of #s that only + // counts when preceded by whitespace. + private static final Pattern LEADING_ATX_TITLE = + Pattern.compile("^ {0,3}#\\s+(.*?)(?:\\s+#+)?\\s*$"); + private static final Pattern SETEXT_H1_UNDERLINE = + Pattern.compile("^ {0,3}=+\\s*$"); + + /** + * Alert types accepted in lessons: the five GFM types keep their GitHub + * identity (IMPORTANT and CAUTION stay standalone), and the rest of the + * Obsidian callout set joins them, aliases included, each with its + * default title. + */ + private static final Map ALERT_TYPES = Map.ofEntries( + Map.entry("NOTE", "Note"), + Map.entry("TIP", "Tip"), + Map.entry("IMPORTANT", "Important"), + Map.entry("WARNING", "Warning"), + Map.entry("CAUTION", "Caution"), + Map.entry("ABSTRACT", "Abstract"), + Map.entry("SUMMARY", "Summary"), + Map.entry("TLDR", "TL;DR"), + Map.entry("INFO", "Info"), + Map.entry("TODO", "Todo"), + Map.entry("HINT", "Hint"), + Map.entry("SUCCESS", "Success"), + Map.entry("CHECK", "Check"), + Map.entry("DONE", "Done"), + Map.entry("QUESTION", "Question"), + Map.entry("HELP", "Help"), + Map.entry("FAQ", "FAQ"), + Map.entry("ATTENTION", "Attention"), + Map.entry("FAILURE", "Failure"), + Map.entry("FAIL", "Fail"), + Map.entry("MISSING", "Missing"), + Map.entry("DANGER", "Danger"), + Map.entry("ERROR", "Error"), + Map.entry("BUG", "Bug"), + Map.entry("EXAMPLE", "Example"), + Map.entry("QUOTE", "Quote"), + Map.entry("CITE", "Cite")); + + // GFM pipe tables and alerts, added on demand as ADR-0013 planned; the + // sanitizer allowlist lets table markup through (Safelist.relaxed) and + // is extended above for the alert markup. + private static final List EXTENSIONS = List.of( + TablesExtension.create(), + AlertsExtension.builder() + .setAllowedTypes(ALERT_TYPES) + .allowCustomTitles(true) + .allowNestedAlerts(true) + .build()); + + private final Parser parser = Parser.builder() + .extensions(EXTENSIONS) + .build(); private final HtmlRenderer renderer = HtmlRenderer.builder() + .extensions(EXTENSIONS) .nodeRendererFactory(DemotedHeadingRenderer::new) .build(); @@ -52,7 +127,72 @@ public String render(String markdown) { return ""; } String html = renderer.render(parser.parse(markdown)); - return Jsoup.clean(html, SAFELIST); + return stripUnknownAlertClasses(Jsoup.clean(html, SAFELIST)); + } + + /** + * Second sanitization pass: the allowlist admits {@code class} on + * {@code div}/{@code p} so alerts stay styleable, but only the exact + * values the alert renderer emits may survive. Anything else (for + * example raw HTML trying to wear a site class like {@code alert} or + * {@code site-header}) is stripped. + */ + private static String stripUnknownAlertClasses(String html) { + Document doc = Jsoup.parseBodyFragment(html); + doc.outputSettings().prettyPrint(false); + for (Element div : doc.select("div[class], div[data-alert-type]")) { + if (!ALERT_DIV_CLASS.matcher(div.className()).matches()) { + div.removeAttr("class"); + } + if (!ALERT_TYPE_VALUE.matcher(div.attr("data-alert-type")).matches()) { + div.removeAttr("data-alert-type"); + } + } + for (Element p : doc.select("p[class]")) { + if (!"markdown-alert-title".equals(p.className())) { + p.removeAttr("class"); + } + } + return doc.body().html(); + } + + /** + * Drops a leading level-1 heading whose text repeats the lesson title, + * so the common habit of starting a document with its title does not + * render as an {@code h2} duplicating the page {@code h1} (see + * ADR-0014). Handles the ATX form ({@code # Title}, closing {@code #}s + * tolerated) and the setext form ({@code Title} underlined with + * {@code =}); the match is trimmed and case-insensitive. Callers apply + * this BEFORE {@link #render(String)} so the render cache stays keyed + * by the exact rendered input; the stored Markdown is never modified. + */ + public static String stripLeadingTitleHeading(String markdown, String title) { + if (markdown == null || title == null || title.isBlank()) { + return markdown; + } + String[] lines = markdown.split("\n", -1); + int first = 0; + while (first < lines.length && lines[first].isBlank()) { + first++; + } + if (first == lines.length) { + return markdown; + } + String wanted = title.strip(); + Matcher atx = LEADING_ATX_TITLE.matcher(lines[first]); + if (atx.matches() && atx.group(1).strip().equalsIgnoreCase(wanted)) { + return joinFrom(lines, first + 1); + } + if (first + 1 < lines.length + && lines[first].strip().equalsIgnoreCase(wanted) + && SETEXT_H1_UNDERLINE.matcher(lines[first + 1]).matches()) { + return joinFrom(lines, first + 2); + } + return markdown; + } + + private static String joinFrom(String[] lines, int from) { + return String.join("\n", Arrays.asList(lines).subList(from, lines.length)); } /** diff --git a/src/main/resources/static/css/base.css b/src/main/resources/static/css/base.css index 7f617e1..4e016a5 100644 --- a/src/main/resources/static/css/base.css +++ b/src/main/resources/static/css/base.css @@ -590,3 +590,215 @@ textarea.form__input { .reveal--2 { animation-delay: 100ms; } .reveal--3 { animation-delay: 200ms; } .reveal--4 { animation-delay: 300ms; } + +/* ---------- Lesson content (rendered Markdown) ---------- */ + +/* The lesson body is instructor Markdown rendered to sanitized HTML + (ADR-0013): the sanitizer strips class attributes, so BEM classes can + never reach this markup. Element selectors scoped under the wrapper + are the only way to style it (deliberate, documented BEM exception). */ + +.lesson-content h2, +.lesson-content h3, +.lesson-content h4 { + margin-top: var(--space-5); +} + +/* The global heading rule stops at h3; demoted headings go down to h6. */ +.lesson-content h4, +.lesson-content h5, +.lesson-content h6 { + font-family: var(--font-display); + line-height: 1.2; + margin: var(--space-4) 0 var(--space-3); + letter-spacing: -0.015em; +} + +.lesson-content ul, +.lesson-content ol { + margin: 0 0 var(--space-3); + padding-left: var(--space-5); +} + +.lesson-content li { + margin-bottom: var(--space-1); +} + +.lesson-content pre { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + padding: var(--space-3); + margin: 0 0 var(--space-3); + overflow-x: auto; /* long lines scroll inside the block, no page scroll (RGAA 10.11) */ +} + +/* Inline code gets a chip; code inside pre relies on the block styling. */ +.lesson-content :not(pre) > code { + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: 0.1em 0.35em; +} + +.lesson-content blockquote { + margin: 0 0 var(--space-3); + padding: var(--space-1) var(--space-4); + border-left: 4px solid var(--color-primary); + color: var(--color-text-muted); +} + +.lesson-content table { + width: 100%; + border-collapse: collapse; + margin: 0 0 var(--space-3); +} + +.lesson-content th, +.lesson-content td { + text-align: left; + padding: var(--space-2) var(--space-3); + border: 1px solid var(--color-border); +} + +.lesson-content th { + font-family: var(--font-display); + font-size: var(--font-size-sm); + background: var(--color-surface); +} + +.lesson-content hr { + border: 0; + border-top: 1px solid var(--color-border); + margin: var(--space-5) 0; +} + +/* Alerts (GFM/Obsidian callouts) inside lesson content. Accent colors and + title come from CSS; icons are Octicons inlined as mask data URIs so the + sanitized HTML never needs to carry SVG markup. */ + +.lesson-content .markdown-alert { + margin: 0 0 var(--space-3); + padding: var(--space-2) var(--space-4); + border-left: 4px solid var(--alert-accent, var(--color-border)); + border-radius: var(--radius-sm); + background: var(--color-surface); +} + +.lesson-content .markdown-alert-title { + display: flex; + align-items: center; + gap: var(--space-2); + font-family: var(--font-display); + font-weight: 600; + /* Pulled 30% toward the text color: several raw accents sit just under + the 4.5:1 contrast floor (WCAG 1.4.3, RGAA 3.2) on the surface + background in the light theme; mixing toward the theme's text color + darkens them there and lightens them in the dark theme. */ + color: color-mix(in srgb, + var(--alert-accent, var(--color-text)) 70%, var(--color-text)); + margin-bottom: var(--space-2); +} + +.lesson-content .markdown-alert-title::before { + content: ""; + width: 1em; + height: 1em; + flex: none; + background-color: currentColor; + -webkit-mask: var(--alert-icon) center / contain no-repeat; + mask: var(--alert-icon) center / contain no-repeat; +} + +/* Nested alerts sit flush inside their parent. */ +.lesson-content .markdown-alert .markdown-alert { + margin-top: var(--space-2); + background: var(--color-bg); +} + +.lesson-content .markdown-alert-note { + --alert-accent: var(--color-link); + --alert-icon: url("data:image/svg+xml,%3Csvg xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22 width%3D%2216%22 height%3D%2216%22 viewBox%3D%220 0 16 16%22%3E%3Cpath d%3D%22M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z%22%2F%3E%3C%2Fsvg%3E"); +} + +.lesson-content .markdown-alert-info { + --alert-accent: var(--color-link); + --alert-icon: url("data:image/svg+xml,%3Csvg xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22 width%3D%2216%22 height%3D%2216%22 viewBox%3D%220 0 16 16%22%3E%3Cpath d%3D%22M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z%22%2F%3E%3C%2Fsvg%3E"); +} + +.lesson-content .markdown-alert-todo { + --alert-accent: var(--color-link); + --alert-icon: url("data:image/svg+xml,%3Csvg xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22 width%3D%2216%22 height%3D%2216%22 viewBox%3D%220 0 16 16%22%3E%3Cpath d%3D%22M2.5 1.75v11.5c0 .138.112.25.25.25h3.17a.75.75 0 0 1 0 1.5H2.75A1.75 1.75 0 0 1 1 13.25V1.75C1 .784 1.784 0 2.75 0h8.5C12.216 0 13 .784 13 1.75v7.736a.75.75 0 0 1-1.5 0V1.75a.25.25 0 0 0-.25-.25h-8.5a.25.25 0 0 0-.25.25Zm13.274 9.537v-.001l-4.557 4.45a.75.75 0 0 1-1.055-.008l-1.943-1.95a.75.75 0 0 1 1.062-1.058l1.419 1.425 4.026-3.932a.75.75 0 1 1 1.048 1.074ZM4.75 4h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM4 7.75A.75.75 0 0 1 4.75 7h2a.75.75 0 0 1 0 1.5h-2A.75.75 0 0 1 4 7.75Z%22%2F%3E%3C%2Fsvg%3E"); +} + +.lesson-content .markdown-alert-abstract, +.lesson-content .markdown-alert-summary, +.lesson-content .markdown-alert-tldr { + --alert-accent: var(--color-focus); + --alert-icon: url("data:image/svg+xml,%3Csvg xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22 width%3D%2216%22 height%3D%2216%22 viewBox%3D%220 0 16 16%22%3E%3Cpath d%3D%22M5.75 2.5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5ZM2 14a1 1 0 1 1 0-2 1 1 0 0 1 0 2Zm1-6a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM2 4a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z%22%2F%3E%3C%2Fsvg%3E"); +} + +.lesson-content .markdown-alert-tip, +.lesson-content .markdown-alert-hint { + --alert-accent: var(--color-success); + --alert-icon: url("data:image/svg+xml,%3Csvg xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22 width%3D%2216%22 height%3D%2216%22 viewBox%3D%220 0 16 16%22%3E%3Cpath d%3D%22M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z%22%2F%3E%3C%2Fsvg%3E"); +} + +.lesson-content .markdown-alert-important { + --alert-accent: var(--color-primary); + --alert-icon: url("data:image/svg+xml,%3Csvg xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22 width%3D%2216%22 height%3D%2216%22 viewBox%3D%220 0 16 16%22%3E%3Cpath d%3D%22M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Zm7 2.25v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 9a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z%22%2F%3E%3C%2Fsvg%3E"); +} + +.lesson-content .markdown-alert-success, +.lesson-content .markdown-alert-check, +.lesson-content .markdown-alert-done { + --alert-accent: var(--color-success); + --alert-icon: url("data:image/svg+xml,%3Csvg xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22 width%3D%2216%22 height%3D%2216%22 viewBox%3D%220 0 16 16%22%3E%3Cpath d%3D%22M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm1.5 0a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm10.28-1.72-4.5 4.5a.75.75 0 0 1-1.06 0l-2-2a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018l1.47 1.47 3.97-3.97a.751.751 0 0 1 1.042.018.751.751 0 0 1 .018 1.042Z%22%2F%3E%3C%2Fsvg%3E"); +} + +.lesson-content .markdown-alert-question, +.lesson-content .markdown-alert-help, +.lesson-content .markdown-alert-faq { + --alert-accent: var(--color-warning); + --alert-icon: url("data:image/svg+xml,%3Csvg xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22 width%3D%2216%22 height%3D%2216%22 viewBox%3D%220 0 16 16%22%3E%3Cpath d%3D%22M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.92 6.085h.001a.749.749 0 1 1-1.342-.67c.169-.339.436-.701.849-.977C6.845 4.16 7.369 4 8 4a2.756 2.756 0 0 1 1.637.525c.503.377.863.965.863 1.725 0 .448-.115.83-.329 1.15-.205.307-.47.513-.692.662-.109.072-.22.138-.313.195l-.006.004a6.24 6.24 0 0 0-.26.16.952.952 0 0 0-.276.245.75.75 0 0 1-1.248-.832c.184-.264.42-.489.692-.661.103-.067.207-.132.313-.195l.007-.004c.1-.061.182-.11.258-.161a.969.969 0 0 0 .277-.245C8.96 6.514 9 6.427 9 6.25a.612.612 0 0 0-.262-.525A1.27 1.27 0 0 0 8 5.5c-.369 0-.595.09-.74.187a1.01 1.01 0 0 0-.34.398ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z%22%2F%3E%3C%2Fsvg%3E"); +} + +.lesson-content .markdown-alert-warning, +.lesson-content .markdown-alert-attention { + --alert-accent: var(--color-warning); + --alert-icon: url("data:image/svg+xml,%3Csvg xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22 width%3D%2216%22 height%3D%2216%22 viewBox%3D%220 0 16 16%22%3E%3Cpath d%3D%22M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z%22%2F%3E%3C%2Fsvg%3E"); +} + +.lesson-content .markdown-alert-caution { + --alert-accent: var(--color-error); + --alert-icon: url("data:image/svg+xml,%3Csvg xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22 width%3D%2216%22 height%3D%2216%22 viewBox%3D%220 0 16 16%22%3E%3Cpath d%3D%22M4.47.22A.749.749 0 0 1 5 0h6c.199 0 .389.079.53.22l4.25 4.25c.141.14.22.331.22.53v6a.749.749 0 0 1-.22.53l-4.25 4.25A.749.749 0 0 1 11 16H5a.749.749 0 0 1-.53-.22L.22 11.53A.749.749 0 0 1 0 11V5c0-.199.079-.389.22-.53Zm.84 1.28L1.5 5.31v5.38l3.81 3.81h5.38l3.81-3.81V5.31L10.69 1.5ZM8 4a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 8 4Zm0 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z%22%2F%3E%3C%2Fsvg%3E"); +} + +.lesson-content .markdown-alert-failure, +.lesson-content .markdown-alert-fail, +.lesson-content .markdown-alert-missing { + --alert-accent: var(--color-error); + --alert-icon: url("data:image/svg+xml,%3Csvg xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22 width%3D%2216%22 height%3D%2216%22 viewBox%3D%220 0 16 16%22%3E%3Cpath d%3D%22M2.344 2.343h-.001a8 8 0 0 1 11.314 11.314A8.002 8.002 0 0 1 .234 10.089a8 8 0 0 1 2.11-7.746Zm1.06 10.253a6.5 6.5 0 1 0 9.108-9.275 6.5 6.5 0 0 0-9.108 9.275ZM6.03 4.97 8 6.94l1.97-1.97a.749.749 0 0 1 1.275.326.749.749 0 0 1-.215.734L9.06 8l1.97 1.97a.749.749 0 0 1-.326 1.275.749.749 0 0 1-.734-.215L8 9.06l-1.97 1.97a.749.749 0 0 1-1.275-.326.749.749 0 0 1 .215-.734L6.94 8 4.97 6.03a.751.751 0 0 1 .018-1.042.751.751 0 0 1 1.042-.018Z%22%2F%3E%3C%2Fsvg%3E"); +} + +.lesson-content .markdown-alert-danger, +.lesson-content .markdown-alert-error { + --alert-accent: var(--color-error); + --alert-icon: url("data:image/svg+xml,%3Csvg xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22 width%3D%2216%22 height%3D%2216%22 viewBox%3D%220 0 16 16%22%3E%3Cpath d%3D%22M9.504.43a1.516 1.516 0 0 1 2.437 1.713L10.415 5.5h2.123c1.57 0 2.346 1.909 1.22 3.004l-7.34 7.142a1.249 1.249 0 0 1-.871.354h-.302a1.25 1.25 0 0 1-1.157-1.723L5.633 10.5H3.462c-1.57 0-2.346-1.909-1.22-3.004L9.503.429Zm1.047 1.074L3.286 8.571A.25.25 0 0 0 3.462 9H6.75a.75.75 0 0 1 .694 1.034l-1.713 4.188 6.982-6.793A.25.25 0 0 0 12.538 7H9.25a.75.75 0 0 1-.683-1.06l2.008-4.418.003-.006a.036.036 0 0 0-.004-.009l-.006-.006-.008-.001c-.003 0-.006.002-.009.004Z%22%2F%3E%3C%2Fsvg%3E"); +} + +.lesson-content .markdown-alert-bug { + --alert-accent: var(--color-error); + --alert-icon: url("data:image/svg+xml,%3Csvg xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22 width%3D%2216%22 height%3D%2216%22 viewBox%3D%220 0 16 16%22%3E%3Cpath d%3D%22M4.72.22a.75.75 0 0 1 1.06 0l1 .999a3.488 3.488 0 0 1 2.441 0l.999-1a.748.748 0 0 1 1.265.332.75.75 0 0 1-.205.729l-.775.776c.616.63.995 1.493.995 2.444v.327c0 .1-.009.197-.025.292.408.14.764.392 1.029.722l1.968-.787a.75.75 0 0 1 .556 1.392L13 7.258V9h2.25a.75.75 0 0 1 0 1.5H13v.5c0 .409-.049.806-.141 1.186l2.17.868a.75.75 0 0 1-.557 1.392l-2.184-.873A4.997 4.997 0 0 1 8 16a4.997 4.997 0 0 1-4.288-2.427l-2.183.873a.75.75 0 0 1-.558-1.392l2.17-.868A5.036 5.036 0 0 1 3 11v-.5H.75a.75.75 0 0 1 0-1.5H3V7.258L.971 6.446a.75.75 0 0 1 .558-1.392l1.967.787c.265-.33.62-.583 1.03-.722a1.677 1.677 0 0 1-.026-.292V4.5c0-.951.38-1.814.995-2.444L4.72 1.28a.75.75 0 0 1 0-1.06Zm.53 6.28a.75.75 0 0 0-.75.75V11a3.5 3.5 0 1 0 7 0V7.25a.75.75 0 0 0-.75-.75ZM6.173 5h3.654A.172.172 0 0 0 10 4.827V4.5a2 2 0 1 0-4 0v.327c0 .096.077.173.173.173Z%22%2F%3E%3C%2Fsvg%3E"); +} + +.lesson-content .markdown-alert-example { + --alert-accent: var(--color-primary); + --alert-icon: url("data:image/svg+xml,%3Csvg xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22 width%3D%2216%22 height%3D%2216%22 viewBox%3D%220 0 16 16%22%3E%3Cpath d%3D%22M5 5.782V2.5h-.25a.75.75 0 0 1 0-1.5h6.5a.75.75 0 0 1 0 1.5H11v3.282l3.666 5.76C15.619 13.04 14.543 15 12.767 15H3.233c-1.776 0-2.852-1.96-1.899-3.458Zm-2.4 6.565a.75.75 0 0 0 .633 1.153h9.534a.75.75 0 0 0 .633-1.153L12.225 10.5h-8.45ZM9.5 2.5h-3V6c0 .143-.04.283-.117.403L4.73 9h6.54L9.617 6.403A.746.746 0 0 1 9.5 6Z%22%2F%3E%3C%2Fsvg%3E"); +} + +.lesson-content .markdown-alert-quote, +.lesson-content .markdown-alert-cite { + --alert-accent: var(--color-text-muted); + --alert-icon: url("data:image/svg+xml,%3Csvg xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22 width%3D%2216%22 height%3D%2216%22 viewBox%3D%220 0 16 16%22%3E%3Cpath d%3D%22M1.75 2.5h10.5a.75.75 0 0 1 0 1.5H1.75a.75.75 0 0 1 0-1.5Zm4 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5Zm0 5h8.5a.75.75 0 0 1 0 1.5h-8.5a.75.75 0 0 1 0-1.5ZM2.5 7.75v6a.75.75 0 0 1-1.5 0v-6a.75.75 0 0 1 1.5 0Z%22%2F%3E%3C%2Fsvg%3E"); +} diff --git a/src/main/resources/templates/courses/lesson.html b/src/main/resources/templates/courses/lesson.html index 1e1a220..6ed6ee9 100644 --- a/src/main/resources/templates/courses/lesson.html +++ b/src/main/resources/templates/courses/lesson.html @@ -13,7 +13,7 @@

Lesson title

-
Lesson content
+
Lesson content