diff --git a/.gitignore b/.gitignore index 6bcbbf8154..05f11d730c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ dist .agents **/_compiled-docs +**/_compiled-learn public/search-index.json public/og tsconfig.tsbuildinfo diff --git a/.marko-run/routes.d.ts b/.marko-run/routes.d.ts index 28dec98ba9..b869ee9552 100644 --- a/.marko-run/routes.d.ts +++ b/.marko-run/routes.d.ts @@ -65,7 +65,12 @@ declare module "@marko/run" { "/docs/tutorial/fundamentals": [M1, L1, L2, P53, D51]; "/docs/reference-full.md": [M1, H2]; "/docs/newsletter/feed.xml": [M1, H3]; - "/playground": [L1, P54, D52]; + "/learn": [H4]; + "/learn/basics/components": [L1, L3, P54, D52]; + "/learn/basics/dynamic-content": [L1, L3, P55, D53]; + "/learn/basics/state": [L1, L3, P56, D54]; + "/learn/basics/templates": [L1, L3, P57, D55]; + "/playground": [L1, P58, D56]; }> {} } @@ -161,6 +166,29 @@ declare module "../src/routes/docs/newsletter/feed%2exml+handler" { } } +type H4 = $.Handler<"H4", typeof import("../src/routes/learn/+handler")>; +declare module "../src/routes/learn/+handler" { + const Run: $.Namespace

; + namespace Run { + type Context = $.ContextForFile

; + } + + /** @deprecated use `Run` namespace instead */ + namespace MarkoRun { + export { NotHandled, NotMatched, GetPaths, PostPaths, GetablePath, GetableHref, PostablePath, PostableHref, Platform }; + export type Route = $.Routes["/learn"]; + export type Context = $.MultiRouteContext; + export type Handler = $.HandlerLike; + export type GET = $.HandlerLike; + export type HEAD = $.HandlerLike; + export type POST = $.HandlerLike; + export type PUT = $.HandlerLike; + export type DELETE = $.HandlerLike; + export type PATCH = $.HandlerLike; + export type OPTIONS = $.HandlerLike; + } +} + type L1 = $.Template<"L1", typeof import("../src/routes/+layout.marko")>; declare module "../src/routes/+layout.marko" { interface Input extends $.LayoutInput {} @@ -172,7 +200,7 @@ declare module "../src/routes/+layout.marko" { /** @deprecated use `Run` namespace instead */ namespace MarkoRun { export { NotHandled, NotMatched, GetPaths, PostPaths, GetablePath, GetableHref, PostablePath, PostableHref, Platform }; - export type Route = $.Routes["/" | "/brand" | "/docs/newsletter" | "/docs/newsletter/april-2026" | "/docs/newsletter/february-2026" | "/docs/newsletter/january-2026" | "/docs/newsletter/june-2026" | "/docs/newsletter/march-2026" | "/docs/newsletter/may-2026" | "/docs/explanation/class-vs-tags-api" | "/docs/explanation/controllable-components" | "/docs/explanation/fine-grained-bundling" | "/docs/explanation/immutable-state" | "/docs/explanation/let-vs-const" | "/docs/explanation/nested-reactivity" | "/docs/explanation/optimizing-performance" | "/docs/explanation/separation-of-concerns" | "/docs/explanation/serializable-state" | "/docs/explanation/streaming" | "/docs/explanation/targeted-compilation" | "/docs/explanation/why-is-marko-fast" | "/docs/guide/duplicate-form-submissions" | "/docs/guide/library-integration" | "/docs/guide/low-level-apis" | "/docs/guide/marko-5-interop" | "/docs/guide/publishing-components" | "/docs/guide/styling" | "/docs/introduction/getting-started" | "/docs/introduction/installation" | "/docs/introduction/integrations" | "/docs/introduction/welcome-to-marko" | "/docs/introduction/why-marko" | "/docs/marko-run/adapters" | "/docs/marko-run/cli" | "/docs/marko-run/data-loading" | "/docs/marko-run/file-based-routing" | "/docs/marko-run/getting-started" | "/docs/marko-run/runtime" | "/docs/marko-run/typescript" | "/docs/marko-run/validation" | "/docs/marko-run/vite-plugin" | "/docs/reference/concise-syntax" | "/docs/reference/core-tag" | "/docs/reference/custom-tag" | "/docs/reference/language" | "/docs/reference/lazy-loading" | "/docs/reference/native-tag" | "/docs/reference/reactivity" | "/docs/reference/supported-environments" | "/docs/reference/template" | "/docs/reference/typescript" | "/docs/tutorial/components-and-reactivity" | "/docs/tutorial/fundamentals" | "/playground"]; + export type Route = $.Routes["/" | "/brand" | "/docs/newsletter" | "/docs/newsletter/april-2026" | "/docs/newsletter/february-2026" | "/docs/newsletter/january-2026" | "/docs/newsletter/june-2026" | "/docs/newsletter/march-2026" | "/docs/newsletter/may-2026" | "/docs/explanation/class-vs-tags-api" | "/docs/explanation/controllable-components" | "/docs/explanation/fine-grained-bundling" | "/docs/explanation/immutable-state" | "/docs/explanation/let-vs-const" | "/docs/explanation/nested-reactivity" | "/docs/explanation/optimizing-performance" | "/docs/explanation/separation-of-concerns" | "/docs/explanation/serializable-state" | "/docs/explanation/streaming" | "/docs/explanation/targeted-compilation" | "/docs/explanation/why-is-marko-fast" | "/docs/guide/duplicate-form-submissions" | "/docs/guide/library-integration" | "/docs/guide/low-level-apis" | "/docs/guide/marko-5-interop" | "/docs/guide/publishing-components" | "/docs/guide/styling" | "/docs/introduction/getting-started" | "/docs/introduction/installation" | "/docs/introduction/integrations" | "/docs/introduction/welcome-to-marko" | "/docs/introduction/why-marko" | "/docs/marko-run/adapters" | "/docs/marko-run/cli" | "/docs/marko-run/data-loading" | "/docs/marko-run/file-based-routing" | "/docs/marko-run/getting-started" | "/docs/marko-run/runtime" | "/docs/marko-run/typescript" | "/docs/marko-run/validation" | "/docs/marko-run/vite-plugin" | "/docs/reference/concise-syntax" | "/docs/reference/core-tag" | "/docs/reference/custom-tag" | "/docs/reference/language" | "/docs/reference/lazy-loading" | "/docs/reference/native-tag" | "/docs/reference/reactivity" | "/docs/reference/supported-environments" | "/docs/reference/template" | "/docs/reference/typescript" | "/docs/tutorial/components-and-reactivity" | "/docs/tutorial/fundamentals" | "/learn/basics/components" | "/learn/basics/dynamic-content" | "/learn/basics/state" | "/learn/basics/templates" | "/playground"]; export type Context = Run.Context; export type Handler = $.HandlerLike; export type GET = $.HandlerLike; @@ -209,6 +237,30 @@ declare module "../src/routes/docs/+layout.marko" { } } +type L3 = $.Template<"L3", typeof import("../src/routes/learn/+layout.marko")>; +declare module "../src/routes/learn/+layout.marko" { + interface Input extends $.LayoutInput {} + const Run: $.Namespace; + namespace Run { + type Context = $.ContextForFile & Marko.Global; + } + + /** @deprecated use `Run` namespace instead */ + namespace MarkoRun { + export { NotHandled, NotMatched, GetPaths, PostPaths, GetablePath, GetableHref, PostablePath, PostableHref, Platform }; + export type Route = $.Routes["/learn/basics/components" | "/learn/basics/dynamic-content" | "/learn/basics/state" | "/learn/basics/templates"]; + export type Context = Run.Context; + export type Handler = $.HandlerLike; + export type GET = $.HandlerLike; + export type HEAD = $.HandlerLike; + export type POST = $.HandlerLike; + export type PUT = $.HandlerLike; + export type DELETE = $.HandlerLike; + export type PATCH = $.HandlerLike; + export type OPTIONS = $.HandlerLike; + } +} + type P1 = $.Template<"P1", typeof import("../src/routes/_home/+page.marko")>; declare module "../src/routes/_home/+page.marko" { const Run: $.Namespace; @@ -1428,13 +1480,105 @@ declare module "../src/routes/docs/_compiled-docs/tutorial/fundamentals+page.mar } } -type P54 = $.Template<"P54", typeof import("../src/routes/playground/+page.marko")>; -declare module "../src/routes/playground/+page.marko" { +type P54 = $.Template<"P54", typeof import("../src/routes/learn/_compiled-learn/basics/components+page.marko")>; +declare module "../src/routes/learn/_compiled-learn/basics/components+page.marko" { const Run: $.Namespace; namespace Run { type Context = $.ContextForFile & Marko.Global; } + /** @deprecated use `Run` namespace instead */ + namespace MarkoRun { + export { NotHandled, NotMatched, GetPaths, PostPaths, GetablePath, GetableHref, PostablePath, PostableHref, Platform }; + export type Route = $.Routes["/learn/basics/components"]; + export type Context = Run.Context; + export type Handler = $.HandlerLike; + export type GET = $.HandlerLike; + export type HEAD = $.HandlerLike; + export type POST = $.HandlerLike; + export type PUT = $.HandlerLike; + export type DELETE = $.HandlerLike; + export type PATCH = $.HandlerLike; + export type OPTIONS = $.HandlerLike; + } +} + +type P55 = $.Template<"P55", typeof import("../src/routes/learn/_compiled-learn/basics/dynamic-content+page.marko")>; +declare module "../src/routes/learn/_compiled-learn/basics/dynamic-content+page.marko" { + const Run: $.Namespace; + namespace Run { + type Context = $.ContextForFile & Marko.Global; + } + + /** @deprecated use `Run` namespace instead */ + namespace MarkoRun { + export { NotHandled, NotMatched, GetPaths, PostPaths, GetablePath, GetableHref, PostablePath, PostableHref, Platform }; + export type Route = $.Routes["/learn/basics/dynamic-content"]; + export type Context = Run.Context; + export type Handler = $.HandlerLike; + export type GET = $.HandlerLike; + export type HEAD = $.HandlerLike; + export type POST = $.HandlerLike; + export type PUT = $.HandlerLike; + export type DELETE = $.HandlerLike; + export type PATCH = $.HandlerLike; + export type OPTIONS = $.HandlerLike; + } +} + +type P56 = $.Template<"P56", typeof import("../src/routes/learn/_compiled-learn/basics/state+page.marko")>; +declare module "../src/routes/learn/_compiled-learn/basics/state+page.marko" { + const Run: $.Namespace; + namespace Run { + type Context = $.ContextForFile & Marko.Global; + } + + /** @deprecated use `Run` namespace instead */ + namespace MarkoRun { + export { NotHandled, NotMatched, GetPaths, PostPaths, GetablePath, GetableHref, PostablePath, PostableHref, Platform }; + export type Route = $.Routes["/learn/basics/state"]; + export type Context = Run.Context; + export type Handler = $.HandlerLike; + export type GET = $.HandlerLike; + export type HEAD = $.HandlerLike; + export type POST = $.HandlerLike; + export type PUT = $.HandlerLike; + export type DELETE = $.HandlerLike; + export type PATCH = $.HandlerLike; + export type OPTIONS = $.HandlerLike; + } +} + +type P57 = $.Template<"P57", typeof import("../src/routes/learn/_compiled-learn/basics/templates+page.marko")>; +declare module "../src/routes/learn/_compiled-learn/basics/templates+page.marko" { + const Run: $.Namespace; + namespace Run { + type Context = $.ContextForFile & Marko.Global; + } + + /** @deprecated use `Run` namespace instead */ + namespace MarkoRun { + export { NotHandled, NotMatched, GetPaths, PostPaths, GetablePath, GetableHref, PostablePath, PostableHref, Platform }; + export type Route = $.Routes["/learn/basics/templates"]; + export type Context = Run.Context; + export type Handler = $.HandlerLike; + export type GET = $.HandlerLike; + export type HEAD = $.HandlerLike; + export type POST = $.HandlerLike; + export type PUT = $.HandlerLike; + export type DELETE = $.HandlerLike; + export type PATCH = $.HandlerLike; + export type OPTIONS = $.HandlerLike; + } +} + +type P58 = $.Template<"P58", typeof import("../src/routes/playground/+page.marko")>; +declare module "../src/routes/playground/+page.marko" { + const Run: $.Namespace; + namespace Run { + type Context = $.ContextForFile & Marko.Global; + } + /** @deprecated use `Run` namespace instead */ namespace MarkoRun { export { NotHandled, NotMatched, GetPaths, PostPaths, GetablePath, GetableHref, PostablePath, PostableHref, Platform }; @@ -1524,4 +1668,8 @@ type D48 = $.Meta<"D48", typeof import("../src/routes/docs/_compiled-docs/refere type D49 = $.Meta<"D49", typeof import("../src/routes/docs/_compiled-docs/reference/typescript+meta.json")>; type D50 = $.Meta<"D50", typeof import("../src/routes/docs/_compiled-docs/tutorial/components-and-reactivity+meta.json")>; type D51 = $.Meta<"D51", typeof import("../src/routes/docs/_compiled-docs/tutorial/fundamentals+meta.json")>; -type D52 = $.Meta<"D52", typeof import("../src/routes/playground/+meta.json")>; +type D52 = $.Meta<"D52", typeof import("../src/routes/learn/_compiled-learn/basics/components+meta.json")>; +type D53 = $.Meta<"D53", typeof import("../src/routes/learn/_compiled-learn/basics/dynamic-content+meta.json")>; +type D54 = $.Meta<"D54", typeof import("../src/routes/learn/_compiled-learn/basics/state+meta.json")>; +type D55 = $.Meta<"D55", typeof import("../src/routes/learn/_compiled-learn/basics/templates+meta.json")>; +type D56 = $.Meta<"D56", typeof import("../src/routes/playground/+meta.json")>; diff --git a/agent-feedback/bugs.md b/agent-feedback/bugs.md index b95fa687fb..a76d2aeeef 100644 --- a/agent-feedback/bugs.md +++ b/agent-feedback/bugs.md @@ -1,3 +1,9 @@ # Suspected Bugs Out-of-scope defects noticed while working on something else. Format and rules: [README.md](README.md). + +## `` return value never flows when client-rendered without server markup + +`src/tags/let/let-debounce.marko` › `Input` | 2026-07-24 | impact:med | effort:med + +When `` is used inside a component tree that is rendered entirely in the browser (mounted via a dynamic tag after a lazy `import()`, with no server-rendered markup to hydrate), its `` value is never assigned: the compiled parent output wires `$value`/`$delay` setters but the tag's returned binding never fires, so downstream `const`/`if`/`script` sections depending on it never run. The same usage works on `/playground` where the tree is server-rendered and hydrated (`src/routes/playground/tags/playground/tags/result/result.marko`). This looks like a `@marko/compiler`/runtime issue with `` in client-only render paths and may be worth reducing and reporting upstream. Re-verify by rendering any lazily-imported component containing `let-debounce/files=input.files` followed by `script -- console.log(files)`; the log never fires (workaround in `src/tags/app-playground/tags/embed-result.marko` uses a manual `setTimeout` debounce instead). diff --git a/cspell.json b/cspell.json index 1cf84c10d4..f11798ed40 100644 --- a/cspell.json +++ b/cspell.json @@ -47,6 +47,7 @@ "mlog", "nbsp", "noindex", + "noopener", "onbeforeinput", "openjsf", "optgroup", @@ -80,6 +81,12 @@ "WHATWG" ], "ignoreRegExpList": [], - "files": ["*", "docs/**/*", "src/**/*"], - "ignorePaths": ["**/_compiled-docs", "dist", "skills", ".claude"] + "files": ["*", "docs/**/*", "learn/**/*", "src/**/*"], + "ignorePaths": [ + "**/_compiled-docs", + "**/_compiled-learn", + "dist", + "skills", + ".claude" + ] } diff --git a/docs/tutorial/components-and-reactivity.md b/docs/tutorial/components-and-reactivity.md index ec85cd7384..a9600ae324 100644 --- a/docs/tutorial/components-and-reactivity.md +++ b/docs/tutorial/components-and-reactivity.md @@ -18,7 +18,7 @@ As with many user interfaces, our first step is to gather input from the user. W Of course, right now we aren't keeping track of the value that this input contains. To do this, we need to introduce state. In Marko, the most common way to do this is with [tag variables](../reference/language.md#tag-variables). Here, we will use [Marko's `` tag](../reference/core-tag.md#let): -```marko +```marko playground @@ -29,7 +29,7 @@ Of course, right now we aren't keeping track of the value that this input contai Now the `` has an initial value, but we still aren't keeping track of it when it changes. One way you may think to do this is by listening for [the `input` event](https://developer.mozilla.org/en-US/docs/Web/API/Element/input_event) with an [event handler](../reference/native-tag.md#event-handlers): -```marko +```marko playground // Warning: There's a better way to do this! @@ -41,7 +41,7 @@ Now the `` has an initial value, but we still aren't keeping track of it This _seems_ to work at first glance, but you'll find out quickly that the value of the input isn't fully synchronized. This is because in HTML, `value=` actually refers to the [_default_ value](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input#value) of the input and not its current value. This is why instead, we should leverage the [controllable](../reference/native-tag.md#change-handlers) pattern with `Change` handlers. -```marko +```marko playground @@ -50,7 +50,7 @@ This _seems_ to work at first glance, but you'll find out quickly that the value Because this is such a common pattern, Marko provides a [shorthand](../reference/language.md#shorthand-change-handlers-two-way-binding) for it! -```marko +```marko playground @@ -61,7 +61,7 @@ Because this is such a common pattern, Marko provides a [shorthand](../reference Now we can use [the `` tag](../reference/core-tag.md#const) to convert to celsius! -```marko +```marko playground @@ -77,7 +77,7 @@ Since `degC` is a [tag variable](../reference/language.md#tag-variables), its ch Now that we have a reactive variable, let's see what else we can do! Maybe some notes about the temperature, using [conditional tags](../reference/core-tag.md#if--else)? -```marko +```marko playground @@ -101,7 +101,7 @@ Now that we have a reactive variable, let's see what else we can do! Maybe some Or what about a temperature gauge, with some fancy CSS? -```marko +```marko playground @@ -141,7 +141,7 @@ Or what about a temperature gauge, with some fancy CSS? Actually, this is getting a little bit too complex to all put in one place. Maybe we should pull that temperature gauge out into a component: -```marko +```marko playground /* index.marko */ @@ -154,7 +154,7 @@ Actually, this is getting a little bit too complex to all put in one place. Mayb ``` -```marko +```marko playground /* tags/gauge.marko */
diff --git a/learn/1-basics/1-templates.md b/learn/1-basics/1-templates.md new file mode 100644 index 0000000000..203a1d6609 --- /dev/null +++ b/learn/1-basics/1-templates.md @@ -0,0 +1,36 @@ +# Templates Are HTML + +Welcome! This is a hands-on tour of Marko. Each lesson loads files into the +editor on the right, and everything we type compiles and runs immediately in +the preview below it. + +Marko is a superset of HTML, so nearly any valid HTML is already a valid +template. The editor starts with an event flyer written in plain HTML: + +```marko playground +

Open Mic Night

+

Every Thursday at the corner cafe.

+
    +
  • Doors at 7pm
  • +
  • Sign-ups at the bar
  • +
  • Five minutes per act
  • +
+``` + +Try changing some of the markup and watch the preview update. + +## Attributes + +Attribute values are JavaScript expressions, not strings. That means template +literals, math, objects, and function calls all work directly in markup: + +```marko playground + + +

Open Mic Night

+

Every Thursday at the corner cafe.

+Email the host +``` + +Load this version and try giving the heading a different color, or computing +one from an expression. diff --git a/learn/1-basics/2-dynamic-content.md b/learn/1-basics/2-dynamic-content.md new file mode 100644 index 0000000000..f43d5de81c --- /dev/null +++ b/learn/1-basics/2-dynamic-content.md @@ -0,0 +1,34 @@ +# Dynamic Content + +Tag content works like a JavaScript template literal, so `${expressions}` +interpolate anywhere text can appear. The `` tag gives a value a name +that the rest of the template can use. + +```marko playground + + +

${album.title}

+

${album.tracks} tracks, ${album.minutes} minutes

+

About ${Math.round(album.minutes / album.tracks)} minutes per track

+``` + +Try renaming the album or changing the track count. The derived line at the +bottom recalculates automatically because it is just an expression over the +same value. + +## Lists + +The `` tag repeats its body for each item: + +```marko playground + + +

Track List

+
    + +
  1. ${track}
  2. + +
+``` + +Add a track to the array and it appears in the list. diff --git a/learn/1-basics/3-state.md b/learn/1-basics/3-state.md new file mode 100644 index 0000000000..bfe9365f0d --- /dev/null +++ b/learn/1-basics/3-state.md @@ -0,0 +1,27 @@ +# Adding State + +So far our templates have been static. The `` tag introduces state: a +value that can change over time, with every expression that uses it updating +automatically. + +```marko playground + + + +

${volume === 0 ? "Muted" : `Playing at ${volume}%`}

+ +``` + +Drag the slider in the preview. The `:=` shorthand keeps the input and the +`volume` variable in sync in both directions, and the `Mute` button assigns to +the same variable from an event handler. + +There is no render function and no subscription to manage. The compiler sees +which expressions read `volume` and wires up exactly the updates needed. diff --git a/learn/1-basics/4-components.md b/learn/1-basics/4-components.md new file mode 100644 index 0000000000..cab9b7e5d5 --- /dev/null +++ b/learn/1-basics/4-components.md @@ -0,0 +1,35 @@ +# Custom Tags + +Marko discovers components from the file system: any template in a `tags/` +directory can be used as a tag, no imports required. This lesson's editor has +two files. `index.marko` renders a `` tag, and +`tags/rating-stars.marko` defines it. + +```marko playground +/* index.marko */ + + +

Rate the Show

+ +

You rated it ${stars} of 5

+``` + +```marko playground +/* tags/rating-stars.marko */ + + + +
+ +``` + +Click the stars in the preview. The parent passes `value:=stars`, which is +shorthand for providing both `value` and a `valueChange` handler, so the child +can report clicks back without any event wiring. + +Try switching between the two files with the tabs above the editor, then add a +`half` star or change the maximum rating. diff --git a/package.json b/package.json index 4785a86ba7..a880bb86a2 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "deploy": "pnpm run build && gh-pages --nojekyll -d dist/public -u \"marko-js \"", "dev": "marko-run", "format": "prettier . -w", - "lint": "markdownlint \"docs/**/*.md\" && cspell \"**/*.{md,ts,marko}\"", + "lint": "markdownlint \"docs/**/*.md\" \"learn/**/*.md\" && cspell \"**/*.{md,ts,marko}\"", "prepare": "husky", "preview": "marko-run preview" }, diff --git a/src/routes/learn/+handler.ts b/src/routes/learn/+handler.ts new file mode 100644 index 0000000000..80574f8b3a --- /dev/null +++ b/src/routes/learn/+handler.ts @@ -0,0 +1,5 @@ +import manifest from "./_compiled-learn/manifest.json"; + +export const GET = ((ctx) => { + return ctx.redirect(`/learn/${manifest[0].slug}`); +}) satisfies MarkoRun.Handler; diff --git a/src/routes/learn/+layout.marko b/src/routes/learn/+layout.marko new file mode 100644 index 0000000000..15271e87f2 --- /dev/null +++ b/src/routes/learn/+layout.marko @@ -0,0 +1,46 @@ +import { faArrowLeft, faArrowRight } from "@fortawesome/free-solid-svg-icons"; +import Editor from "app/routes/playground/tags/playground/tags/editor/editor.marko"; +import SplitPanes from "app/routes/playground/tags/split-panes/split-panes.marko"; +import EmbedResult from "app/tags/app-playground/tags/embed-result.marko"; +import type { File } from "app/util/workspace"; +import manifest from "./_compiled-learn/manifest.json"; +client import { onLoadFiles } from "app/util/learn-files"; +export interface Input { + content: Marko.Body; +} + +let/files=[] as File[] +let/tab=0 + +script -- + onLoadFiles((newFiles) => { + files = newFiles.map((file) => ({ ...file })); + tab = 0; + }, $signal); + +const/index=manifest.findIndex((lesson) => + $global.url.pathname.endsWith(`/learn/${lesson.slug}`), +) +const/prev=(manifest[index - 1] as (typeof manifest)[number] | undefined) +const/next=(manifest[index + 1] as (typeof manifest)[number] | undefined) + +SplitPanes + @first + div.learn-lesson + article.learn-article + ${input.content} + nav.learn-nav aria-label="lessons" + if=prev + a.learn-nav-prev href=`/learn/${prev.slug}` + fa-icon=faArrowLeft + -- ${prev.title} + span.learn-nav-progress -- ${index + 1} / ${manifest.length} + if=next + a.learn-nav-next href=`/learn/${next.slug}` + -- ${next.title} + fa-icon=faArrowRight + @second + div.learn-workspace + div.learn-workspace-editor + Editor files:=files tab:=tab + EmbedResult files=files diff --git a/src/routes/learn/+layout.style.scss b/src/routes/learn/+layout.style.scss new file mode 100644 index 0000000000..4b3b5a21b8 --- /dev/null +++ b/src/routes/learn/+layout.style.scss @@ -0,0 +1,83 @@ +:root { + --stick-size: 5rem; +} + +#header { + position: fixed; + left: 0; + right: 0; + margin: 0 0; + padding: 0 1rem; + transform: none !important; + background-color: var(--color-background); +} + +.learn-lesson { + height: 100%; + overflow-y: auto; + overscroll-behavior: contain; +} + +.learn-article { + padding: 1rem 1.5rem 2rem; + max-width: 48rem; + + h1 { + font-size: 1.8rem; + } + + h2 { + font-size: 1.4rem; + } + + pre { + overflow-x: auto; + } +} + +.learn-nav { + display: flex; + align-items: center; + gap: 1rem; + padding: 1rem 1.5rem 2rem; + max-width: 48rem; + + a { + display: inline-flex; + align-items: center; + gap: 0.5rem; + + svg { + height: 0.8em; + fill: currentColor; + } + } + + .learn-nav-progress { + color: var(--color-gray); + font-size: 0.9rem; + } + + .learn-nav-next { + margin-left: auto; + } +} + +.learn-workspace { + display: flex; + flex-direction: column; + height: 100%; + + .learn-workspace-editor { + display: flex; + flex-direction: column; + flex: 1.2 1 0; + min-height: 0; + border-bottom: 1px solid var(--color-gray-dim); + } + + .playground-live-result { + flex: 1 1 0; + min-height: 0; + } +} diff --git a/src/tags/app-playground/app-playground.marko b/src/tags/app-playground/app-playground.marko new file mode 100644 index 0000000000..6a680bc40a --- /dev/null +++ b/src/tags/app-playground/app-playground.marko @@ -0,0 +1,49 @@ +import { faPlay, faRotateLeft } from "@fortawesome/free-solid-svg-icons"; +import type { File } from "app/util/workspace"; +export interface Input { + files: File[]; + content?: Marko.Body; +} + +// The workspace that powers live examples is shared, so only one example may +// be running at a time; activating one deactivates whichever ran before it. +static let deactivate: (() => void) | undefined; + +let/files=input.files +let/Live=(null as typeof import("./tags/playground-live.marko").default | null) +let/loading=false + +div.playground-embed + // The static code blocks are server-rendered only (they cannot re-render in + // the browser), so they are hidden rather than removed while running live. + div.playground-embed-static hidden=!!Live + ${input.content} + if=Live + ${Live} files:=files + div.playground-embed-bar + button.playground-embed-run + ,type="button" + ,disabled=loading + ,onClick() { + if (Live) { + Live = null; + files = input.files; + deactivate = undefined; + return; + } + loading = true; + import("./tags/playground-live.marko").then( + (live) => { + deactivate?.(); + deactivate = () => (Live = null); + Live = live.default; + loading = false; + }, + () => { + loading = false; + }, + ); + } + fa-icon=(Live ? faRotateLeft : faPlay) + -- ${Live ? "Reset" : loading ? "Loading" : "Edit and run"} + open-in-playground files=files diff --git a/src/tags/app-playground/app-playground.style.scss b/src/tags/app-playground/app-playground.style.scss new file mode 100644 index 0000000000..57442ab8af --- /dev/null +++ b/src/tags/app-playground/app-playground.style.scss @@ -0,0 +1,88 @@ +.playground-embed { + margin-block: 1rem; + border: 1px solid var(--color-gray-dim); + border-radius: 0.75rem; + overflow: hidden; + + .playground-embed-static { + padding: 0 1rem; + } + + .playground-embed-bar { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + border-top: 1px solid var(--color-gray-dim); + } + + .playground-embed-run { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.9rem; + border: none; + border-radius: 2rem; + background-color: var(--section-color, var(--color-blue)); + color: var(--color-background); + cursor: pointer; + font: inherit; + font-size: 0.95rem; + + svg { + height: 0.9em; + fill: currentColor; + } + + &:disabled { + opacity: 0.6; + cursor: progress; + } + + &:hover:not(:disabled), + &:focus-visible { + background-color: var(--section-color-alt, var(--color-blue-alt)); + } + } + + .playground-embed-open { + display: inline-flex; + align-items: center; + gap: 0.4rem; + margin-left: auto; + font-size: 0.9rem; + + svg { + height: 0.8em; + fill: currentColor; + } + } +} + +.playground-live { + display: flex; + flex-direction: column; + + .playground-live-editor { + display: flex; + flex-direction: column; + height: 20rem; + resize: vertical; + overflow: auto; + border-bottom: 1px solid var(--color-gray-dim); + } + + // The full-page playground pads the last line so it can scroll past the end; + // embedded editors are compact and don't want that. + .cm-line:last-child { + margin-bottom: 0.5rem; + } + + // Shared preview styles live in embed-result.style.scss; only the embed + // sizing belongs here. + .playground-live-result { + height: 14rem; + resize: vertical; + overflow: auto; + } +} diff --git a/src/tags/app-playground/tags/embed-result.marko b/src/tags/app-playground/tags/embed-result.marko new file mode 100644 index 0000000000..2acd47ed81 --- /dev/null +++ b/src/tags/app-playground/tags/embed-result.marko @@ -0,0 +1,35 @@ +client import { + subscribe, + update, + type File, + type Workspace, +} from "app/util/workspace"; +export interface Input { + files: File[]; +} + +let/ws={} as Partial +script -- subscribe((v) => (ws = v), $signal); + +// Debounced copy of input.files so the preview doesn't rebuild on every +// keystroke. `let-debounce` is not used here because its returned value never +// flows when rendered without server markup (see agent-feedback). +let/files=input.files +script -- + const timeout = setTimeout(() => { + files = input.files; + }, 500); + $signal.onabort = () => clearTimeout(timeout); + +const/errors=ws.buildErrors || ws.runtimeErrors + +div.playground-live-result + if=errors + div.playground-live-error role="alert" + p -- ${errors.join("\n")} + iframe/$frame title="example output" class=errors && "hide" + if=files.length + script -- update($signal, $frame(), files, true); + if=!ws.previewReady && !errors + div.playground-live-loading role="status" + span -- setting up diff --git a/src/tags/app-playground/tags/embed-result.style.scss b/src/tags/app-playground/tags/embed-result.style.scss new file mode 100644 index 0000000000..36a868f86f --- /dev/null +++ b/src/tags/app-playground/tags/embed-result.style.scss @@ -0,0 +1,39 @@ +.playground-live-result { + position: relative; + + iframe { + display: block; + width: 100%; + height: 100%; + border: none; + background-color: var(--color-white); + + &.hide { + visibility: hidden; + } + } + + .playground-live-error { + position: absolute; + inset: 0; + z-index: 2; + overflow: auto; + padding: 0.5rem 0.75rem; + background-color: var(--color-background); + color: var(--color-red); + font-family: "Ubuntu Mono", monospace; + white-space: pre-wrap; + + p { + margin: 0; + } + } + + .playground-live-loading { + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: var(--color-gray); + } +} diff --git a/src/tags/app-playground/tags/open-in-playground.marko b/src/tags/app-playground/tags/open-in-playground.marko new file mode 100644 index 0000000000..f637ebbaad --- /dev/null +++ b/src/tags/app-playground/tags/open-in-playground.marko @@ -0,0 +1,20 @@ +import { faUpRightFromSquare } from "@fortawesome/free-solid-svg-icons"; +import type { File } from "app/util/workspace"; +client import { compress } from "app/util/hasher"; +export interface Input { + files: File[]; +} + +let/hash="" +script -- + const timeout = setTimeout(() => { + compress(JSON.stringify(input.files)).then((h) => (hash = h)); + }, 500); + $signal.onabort = () => clearTimeout(timeout); + +a.playground-embed-open + ,href=`${import.meta.env.BASE_URL}playground${hash && `#${hash}`}` + ,target="_blank" + ,rel="noopener" + -- Open in Playground + fa-icon=faUpRightFromSquare diff --git a/src/tags/app-playground/tags/playground-live.marko b/src/tags/app-playground/tags/playground-live.marko new file mode 100644 index 0000000000..8e38725a6e --- /dev/null +++ b/src/tags/app-playground/tags/playground-live.marko @@ -0,0 +1,14 @@ +import Editor from "app/routes/playground/tags/playground/tags/editor/editor.marko"; +import type { File } from "app/util/workspace"; +export interface Input { + files: File[]; + filesChange?: (files: File[]) => void; +} + +let/files:=input.files +let/tab=0 + +div.playground-live + div.playground-live-editor + Editor files:=files tab:=tab + embed-result files=files diff --git a/src/tags/learn-checkpoint/learn-checkpoint.marko b/src/tags/learn-checkpoint/learn-checkpoint.marko new file mode 100644 index 0000000000..f84e1b1c62 --- /dev/null +++ b/src/tags/learn-checkpoint/learn-checkpoint.marko @@ -0,0 +1,25 @@ +import { faPlay, faRotateLeft } from "@fortawesome/free-solid-svg-icons"; +import type { File } from "app/util/workspace"; +client import { loadFiles } from "app/util/learn-files"; +export interface Input { + files: File[]; + auto?: boolean; + content?: Marko.Body; +} + +script -- if (input.auto) loadFiles(input.files); + +div.learn-checkpoint + details + summary + span.learn-checkpoint-files -- + ${input.files.map((file) => file.path).join(", ")} + div.learn-checkpoint-code + ${input.content} + button.learn-checkpoint-load + ,type="button" + ,onClick() { + loadFiles(input.files); + } + fa-icon=(input.auto ? faRotateLeft : faPlay) + -- ${input.auto ? "Reset editor" : "Load into editor"} diff --git a/src/tags/learn-checkpoint/learn-checkpoint.style.scss b/src/tags/learn-checkpoint/learn-checkpoint.style.scss new file mode 100644 index 0000000000..b263ce09bf --- /dev/null +++ b/src/tags/learn-checkpoint/learn-checkpoint.style.scss @@ -0,0 +1,55 @@ +.learn-checkpoint { + margin-block: 1rem; + border: 1px solid var(--color-gray-dim); + border-radius: 0.75rem; + overflow: hidden; + + details { + border-bottom: 1px solid var(--color-gray-dim); + + &[open] summary { + border-bottom: 1px solid var(--color-gray-dim); + } + } + + summary { + padding: 0.5rem 0.75rem; + cursor: pointer; + font-family: "Ubuntu Mono", monospace; + font-size: 0.9rem; + color: var(--section-color, var(--color-blue)); + + &:hover { + background-color: var(--color-gray-dim); + } + } + + .learn-checkpoint-code { + padding: 0 0.75rem; + } + + .learn-checkpoint-load { + display: inline-flex; + align-items: center; + gap: 0.5rem; + margin: 0.5rem 0.75rem; + padding: 0.35rem 0.9rem; + border: none; + border-radius: 2rem; + background-color: var(--section-color, var(--color-blue)); + color: var(--color-background); + cursor: pointer; + font: inherit; + font-size: 0.95rem; + + svg { + height: 0.9em; + fill: currentColor; + } + + &:hover, + &:focus-visible { + background-color: var(--section-color-alt, var(--color-blue-alt)); + } + } +} diff --git a/src/util/learn-files.ts b/src/util/learn-files.ts new file mode 100644 index 0000000000..75230e0630 --- /dev/null +++ b/src/util/learn-files.ts @@ -0,0 +1,31 @@ +import type { File } from "./workspace"; + +// Connects lesson checkpoints (rendered inside compiled markdown content) to +// the learn layout's workspace without threading props through the page tree. +type Handler = (files: File[]) => void; +let handler: Handler | undefined; +let queued: File[] | undefined; + +export function loadFiles(files: File[]) { + if (handler) { + handler(files); + } else { + // A checkpoint's auto-load can run before the layout subscribes. + queued = files; + } +} + +export function onLoadFiles(fn: Handler, signal: AbortSignal) { + handler = fn; + if (queued) { + fn(queued); + queued = undefined; + } + signal.addEventListener( + "abort", + () => { + if (handler === fn) handler = undefined; + }, + { once: true }, + ); +} diff --git a/src/util/markodown.ts b/src/util/markodown.ts index 91bc99e2a4..2b196ef2b5 100644 --- a/src/util/markodown.ts +++ b/src/util/markodown.ts @@ -43,7 +43,55 @@ export default function markodownPlugin(): PluginOption { cwd: docsPath, }); + const learnPath = path.join(process.cwd(), "learn"); + const learnPages = path.join( + process.cwd(), + "src", + "routes", + "learn", + "_compiled-learn", + ); + + await fs.rm(learnPages, { recursive: true, force: true }); + await fs.mkdir(learnPages, { recursive: true }); + + // Lesson order comes from the numeric filename prefixes, which are + // stripped from the served URLs. + const learnFiles = glob + .sync("**/*.md", { cwd: learnPath }) + .map((file) => file.split(path.sep).join("/")) + .sort((a, b) => a.localeCompare(b, "en", { numeric: true })); + const learnManifest: { slug: string; title: string }[] = []; + await Promise.all([ + ...learnFiles.map(async (file, i) => { + const content = await fs.readFile( + path.join(learnPath, file), + "utf-8", + ); + const { markoCode, headings } = await mdToMarko(content, { + checkpoints: true, + }); + const slug = file + .replace(/\.md$/, "") + .split("/") + .map((segment) => segment.replace(/^\d+-/, "")) + .join("/"); + learnManifest[i] = { slug, title: headings[0].title }; + const target = path.join(learnPages, ...slug.split("/")); + await fs.mkdir(path.dirname(target), { recursive: true }); + await Promise.all([ + fs.writeFile(`${target}+page.marko`, markoCode), + fs.writeFile( + `${target}+meta.json`, + JSON.stringify({ + pageTitle: headings[0].title, + headings: headings[0].children, + hideFooter: true, + }), + ), + ]); + }), ...mdFiles.map(async (file) => { const content = await fs.readFile(path.join(docsPath, file), "utf-8"); await fs.mkdir(path.dirname(path.join(docsPages, file)), { @@ -72,6 +120,11 @@ export default function markodownPlugin(): PluginOption { pruneDocsBanners(mdFiles), buildSearchIndex(docsPath), ]); + + await fs.writeFile( + path.join(learnPages, "manifest.json"), + JSON.stringify(learnManifest), + ); }, }; } @@ -140,10 +193,14 @@ async function pruneDocsBanners(mdFiles: string[]) { ); } -async function mdToMarko(source: string) { +async function mdToMarko(source: string, opts?: { checkpoints?: boolean }) { const headings: HeadingList = []; const markoCode = await new Marked() - .use(semanticAdmonitions(), headingSections(headings), markoDocs()) + .use( + semanticAdmonitions(), + headingSections(headings), + markoDocs(opts?.checkpoints), + ) .parse( // remove zero-width spaces (recommended from marked docs) source.replace(/^[\u200B\u200C\u200D\u200E\u200F\uFEFF]/, ""), @@ -214,13 +271,51 @@ declare module "marked" { htmlTS?: string; conciseTS?: string; filename?: string; + playgroundFiles?: Code[]; + playgroundEnd?: boolean; } } } -function markoDocs(): MarkedExtension { +function isPlaygroundFence(token: Tokens.Code) { + const [lang, ...modifiers] = (token.lang ?? "").trim().split(/\s+/); + return lang === "marko" && modifiers.includes("playground"); +} + +function markoDocs(checkpoints?: boolean): MarkedExtension { + let checkpointCount = 0; return { async: true, + hooks: { + // Consecutive ```marko playground fences (blank lines between them are + // fine) become the files of a single interactive playground; anything + // else, including prose, ends the group. + processAllTokens(tokens) { + let group: Tokens.Code[] | undefined; + const endGroup = () => { + if (group) { + group[group.length - 1].playgroundEnd = true; + group = undefined; + } + }; + + for (const token of tokens) { + if (token.type === "code" && isPlaygroundFence(token)) { + if (group) { + group.push(token); + } else { + group = [token]; + token.playgroundFiles = group; + } + } else if (token.type !== "space") { + endGroup(); + } + } + + endGroup(); + return tokens; + }, + }, async walkTokens(token) { if (token.type === "code") { // named files begin with `/* file.name */\n` @@ -298,8 +393,40 @@ function markoDocs(): MarkedExtension { table(token) { return `
${Renderer.prototype.table.call(this, token)}
`; }, - code({ lang, text, html, concise, htmlTS, conciseTS, filename }) { - let out = ` { + const path = file.filename ?? (i === 0 ? "index.marko" : ""); + if (!path) { + throw new Error( + "Each fence after the first in a playground group needs a /* filename */ comment", + ); + } + return { + path, + content: file.text.endsWith("\n") ? file.text : `${file.text}\n`, + }; + }); + if (!files.some((file) => file.path === "index.marko")) { + throw new Error( + "A playground group needs an index.marko entry file", + ); + } + if (checkpoints) { + // In lesson pages, playground fences load into the page's shared + // workspace instead of embedding their own; the first group is the + // lesson's starting state. + out += ``; + } else { + out += ``; + } + } + + out += `"; + out += "/>"; + + if (token.playgroundEnd) { + out += ""; + } + return out; }, codespan(token) { return `${token.text