From d98a51301e29eb0f1ac3081f35f61996d88f8902 Mon Sep 17 00:00:00 2001 From: Thiemann96 Date: Fri, 16 Oct 2026 10:06:56 +0200 Subject: [PATCH 1/2] first draft --- src/common/imageUrlBuilder.ts | 34 -------- src/common/sanity.ts | 103 ------------------------- src/documentation/IdeasArea.tsx | 30 ------- src/documentation/ReferenceArea.tsx | 30 ------- src/documentation/ideas/content.ts | 33 -------- src/documentation/mapping/model.ts | 20 ----- src/documentation/reference/content.ts | 94 ---------------------- 7 files changed, 344 deletions(-) delete mode 100644 src/common/imageUrlBuilder.ts delete mode 100644 src/common/sanity.ts delete mode 100644 src/documentation/IdeasArea.tsx delete mode 100644 src/documentation/ReferenceArea.tsx delete mode 100644 src/documentation/ideas/content.ts delete mode 100644 src/documentation/mapping/model.ts delete mode 100644 src/documentation/reference/content.ts diff --git a/src/common/imageUrlBuilder.ts b/src/common/imageUrlBuilder.ts deleted file mode 100644 index e02992b84..000000000 --- a/src/common/imageUrlBuilder.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * (c) 2022, Micro:bit Educational Foundation and contributors - * - * SPDX-License-Identifier: MIT - */ - -import unconfiguredImageUrlBuilder from "@sanity/image-url"; -import { dataset, project } from "./sanity"; - -export const defaultQuality = 80; - -export const imageUrlBuilder = unconfiguredImageUrlBuilder() - // Hardcoded for now as there's no practical alternative. - .projectId(project) - .dataset(dataset) - .auto("format") - .dpr(window.devicePixelRatio ?? 1) - .quality(defaultQuality); - -/** - * Percentage padding-bottom matching the image's aspect ratio, read from the - * dimensions embedded in a Sanity image reference. Used to reserve an image's - * height before it loads via a padding spacer rather than the aspect-ratio - * property, which Safari 14 does not support. - */ -export const getAspectRatioPadding = (imageRef: string): string | undefined => { - const dimensionsArr = imageRef.match(/\d+x\d+/g); - if (!dimensionsArr) { - return undefined; - } - const dimensions = dimensionsArr.join().split("x"); - const [width, height] = dimensions.map((n: string) => Number(n)); - return `${(height / width) * 100}%`; -}; diff --git a/src/common/sanity.ts b/src/common/sanity.ts deleted file mode 100644 index bfc15f952..000000000 --- a/src/common/sanity.ts +++ /dev/null @@ -1,103 +0,0 @@ -/** - * Common sanity types. - */ - -import { flags } from "../flags"; - -export interface PortableTextBlock { - _type: "block"; - _key: string; - // Partial/lax modelling. We pass this straight to Sanity's rendering API. - children: any; - markDefs: any; - style: string; -} - -export type PortableText = Array< - PortableTextBlock | { _type: string; children?: any; [other: string]: any } ->; - -/** - * Common image type. - */ -export interface SimpleImage { - _type: "simpleImage"; - alt?: string; - // The Sanity image asset. - asset: any; -} - -/** - * Sanity's slug type. - */ -export interface Slug { - _type: "slug"; - current: string; -} - -/** - * Query CMS content preferring the given language but - * falling back to "en" if it is not present. - * - * @param languageId The preferred language. - * @param adaptContent Validates and converts the result. - * @returns The content. - */ -export const fetchContent = async ( - languageId: string, - query: (languageId: string) => string, - adaptContent: (result: any) => T | undefined -): Promise => { - try { - const preferred = adaptContent( - await fetchContentInternal(query(sanityLanguageId(languageId))) - ); - if (preferred) { - return preferred; - } - } catch { - // Fall through to fallback without crashing if user is offline. - } - const fallback = adaptContent(await fetchContentInternal(query("en"))); - if (!fallback) { - throw new Error("English content must exist"); - } - return fallback; -}; - -const fetchContentInternal = async (query: string): Promise => { - const response = await fetch(queryUrl(query)); - if (response.ok) { - const { result } = await response.json(); - if (!result) { - throw new Error("Unexpected response format"); - } - return result; - } - throw new Error("Error fetching content: " + response.status); -}; - -export const sanityLanguageId = (locale: string): string => { - if (!locale) { - return ""; - } - if (locale && !locale.match(/^[A-Za-z-]+$/g)) { - throw new Error(`Invalid language id: ${locale}`); - } - const parts = locale.split("-"); - if (parts.length !== 2) { - return locale; - } - return `${parts[0]}-${parts[1].toUpperCase()}`; -}; - -export const project = import.meta.env.VITE_SANITY_PROJECT; -export const dataset = flags.cmsPreview - ? import.meta.env.VITE_SANITY_PREVIEW_DATASET - : import.meta.env.VITE_SANITY_DATASET; - -const queryUrl = (query: string): string => { - return `https://${project}.apicdn.sanity.io/v1/data/query/${dataset}?query=${encodeURIComponent( - query - )}`; -}; diff --git a/src/documentation/IdeasArea.tsx b/src/documentation/IdeasArea.tsx deleted file mode 100644 index cd00fcf49..000000000 --- a/src/documentation/IdeasArea.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/** - * (c) 2021-2022, Micro:bit Educational Foundation and contributors - * - * SPDX-License-Identifier: MIT - */ -import { Text } from "@microbit/ui"; -import { FormattedMessage } from "react-intl"; -import IdeasDocumentation from "./ideas/IdeasDocumentation"; -import Spinner from "../common/Spinner"; -import { useDocumentation } from "./documentation-hooks"; - -const IdeasArea = () => { - const { ideas } = useDocumentation(); - switch (ideas.status) { - case "loading": - return ; - case "error": - return ( - - - - ); - case "ok": - return ; - default: - throw new Error(); - } -}; - -export default IdeasArea; diff --git a/src/documentation/ReferenceArea.tsx b/src/documentation/ReferenceArea.tsx deleted file mode 100644 index 55a21ba8c..000000000 --- a/src/documentation/ReferenceArea.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/** - * (c) 2021, Micro:bit Educational Foundation and contributors - * - * SPDX-License-Identifier: MIT - */ -import { Text } from "@microbit/ui"; -import { FormattedMessage } from "react-intl"; -import ReferenceToolkit from "./reference/ReferenceDocumentation"; -import Spinner from "../common/Spinner"; -import { useDocumentation } from "./documentation-hooks"; - -const ReferenceArea = () => { - const { reference } = useDocumentation(); - switch (reference.status) { - case "loading": - return ; - case "error": - return ( - - - - ); - case "ok": - return ; - default: - throw new Error(); - } -}; - -export default ReferenceArea; diff --git a/src/documentation/ideas/content.ts b/src/documentation/ideas/content.ts deleted file mode 100644 index 0eab70e1a..000000000 --- a/src/documentation/ideas/content.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * (c) 2021-2022, Micro:bit Educational Foundation and contributors - * - * SPDX-License-Identifier: MIT - */ -import { fetchContent } from "../../common/sanity"; -import { Idea } from "./model"; - -export const fetchIdeas = async (languageId: string): Promise => - fetchContent(languageId, ideasQuery, adaptContent); - -const ideasQuery = (languageId: string): string => { - return ` - *[_type == "pythonIdeasConfig" && language == "${languageId}" && !(_id in path("drafts.**"))][0]{ - pythonIdeasOrder[]->{ - _id, name, language, compatibility, image, slug, - content[] { - ..., - markDefs[]{ - ..., - _type == "toolkitInternalLink" => { - "slug": @.reference->slug, - "targetType": @.reference->_type - } - } - }, - } - }`; -}; - -const adaptContent = (result: any): Idea[] | undefined => { - return result?.pythonIdeasOrder ?? []; -}; diff --git a/src/documentation/mapping/model.ts b/src/documentation/mapping/model.ts deleted file mode 100644 index f80d3cdde..000000000 --- a/src/documentation/mapping/model.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * (c) 2021-2022, Micro:bit Educational Foundation and contributors - * - * SPDX-License-Identifier: MIT - */ - -import { Slug } from "../../common/sanity"; - -interface PythonModuleItem { - pythonApiEntry: string; - referenceLink?: { - slug: Slug; - }; - pythonAlternativeContentLink?: string; -} - -export interface PythonModule { - pythonModuleName: string; - pythonModuleItem: PythonModuleItem[]; -} diff --git a/src/documentation/reference/content.ts b/src/documentation/reference/content.ts deleted file mode 100644 index 015b77e92..000000000 --- a/src/documentation/reference/content.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * (c) 2021, Micro:bit Educational Foundation and contributors - * - * SPDX-License-Identifier: MIT - */ -import { fetchContent } from "../../common/sanity"; -import { Toolkit, ToolkitTopic, ToolkitTopicEntry } from "./model"; - -export const fetchReferenceToolkit = async ( - languageId: string -): Promise => fetchContent(languageId, toolkitQuery, adaptContent); - -export const getTopicAndEntry = ( - toolkit: Toolkit, - topicOrEntryId: string | undefined -): [ToolkitTopic | undefined, ToolkitTopicEntry | undefined] => { - const topic = toolkit.contents?.find( - (t) => t.slug.current === topicOrEntryId - ); - if (topic) { - return [topic, undefined]; - } - const entry = toolkit.contents - ?.flatMap((topic) => topic.contents ?? []) - .find((entry) => entry.slug.current === topicOrEntryId); - if (!entry) { - return [undefined, undefined]; - } - return [entry.parent, entry]; -}; - -// We just slurp the whole toolkit at once. -// This is necessary for the client-side search index. -const toolkitQuery = (languageId: string): string => { - return ` - *[_type == "toolkit" && language == "${languageId}" && (slug.current == "explore" || slug.current == "reference") && !(_id in path("drafts.**"))]{ - id, name, description, language, - contents[]->{ - name, slug, compatibility, subtitle, image, - introduction[] { - ..., - markDefs[]{ - ..., - _type == "toolkitInternalLink" => { - "slug": @.reference->slug, - "targetType": @.reference->_type - } - } - }, - contents[]->{ - name, slug, compatibility, - content[] { - ..., - markDefs[]{ - ..., - _type == "toolkitInternalLink" => { - "slug": @.reference->slug, - "targetType": @.reference->_type - } - } - }, - alternativesLabel, alternatives, - detailContent[] { - ..., - markDefs[]{ - ..., - _type == "toolkitInternalLink" => { - "slug": @.reference->slug, - "targetType": @.reference->_type - } - } - }, - } - } - }`; -}; - -const adaptContent = (result: any): Toolkit | undefined => { - const toolkits = result as Toolkit[]; - if (toolkits.length === 0) { - return undefined; - } - if (toolkits.length > 1) { - throw new Error("Unexpected results"); - } - // Add topic entry parent for toolkit navigation. - const toolkit = toolkits[0]; - toolkit.contents?.forEach((topic) => { - topic.contents?.forEach((entry) => { - entry.parent = topic; - }); - }); - return toolkit; -}; From b84b90b8d4db6210b7d27c11c83762b5c2b13b7d Mon Sep 17 00:00:00 2001 From: Thiemann96 Date: Fri, 16 Oct 2026 10:07:02 +0200 Subject: [PATCH 2/2] first draft --- .env | 3 - content/de/grundlagen/ausgabe.md | 32 ++ content/de/grundlagen/schleifen.md | 43 +++ content/de/projekte/wetterstation.md | 22 ++ content/de/sensoren/temperatur.md | 30 ++ content/images/ausgabe.svg | 1 + content/images/schleifen.svg | 1 + content/images/temperatur.svg | 1 + content/images/wetterstation.svg | 1 + content/mapping.json | 1 + content/sidebar.json | 33 ++ docs/analytics-events.md | 7 +- docs/content.md | 185 ++++++++++ docs/tech-overview.md | 6 +- package-lock.json | 10 - package.json | 1 - src/common/documentation-styles.ts | 20 -- src/common/portable-text.ts | 43 +++ src/documentation/DocsSectionArea.tsx | 47 +++ src/documentation/api/ApiDocumentation.tsx | 7 +- .../common/DocumentationBreadcrumbHeading.tsx | 2 +- .../common/DocumentationContent.test.tsx | 9 +- .../common/DocumentationContent.tsx | 41 ++- .../common/DocumentationIcon.tsx | 5 +- .../common/DocumentationTopLevelItem.tsx | 2 +- .../common/collapse-util.test.ts | 2 +- src/documentation/common/collapse-util.ts | 2 +- src/documentation/documentation-hooks.tsx | 18 +- src/documentation/ideas/IdeaCard.tsx | 23 +- .../ideas/IdeasDocumentation.tsx | 68 +--- src/documentation/ideas/model.ts | 4 +- src/documentation/mapping/content.ts | 69 ++-- .../reference/ReferenceDocumentation.tsx | 29 +- .../reference/ReferenceTopicEntry.tsx | 5 +- src/documentation/reference/content.ts | 25 ++ src/documentation/reference/model.ts | 8 +- src/documentation/search/SearchDialog.tsx | 38 +- .../search/blocks-to-text.test.ts | 2 +- src/documentation/search/blocks-to-text.ts | 2 +- src/documentation/search/common.ts | 21 +- src/documentation/search/search-client.ts | 6 +- src/documentation/search/search-hooks.tsx | 49 ++- .../search/search.worker.test.ts | 109 ++++-- src/documentation/search/search.worker.ts | 38 +- src/documentation/sections/config.ts | 127 +++++++ src/documentation/sections/content.test.ts | 55 +++ src/documentation/sections/content.ts | 175 ++++++++++ src/documentation/sections/icons.ts | 49 +++ src/documentation/sections/markdown.test.ts | 238 +++++++++++++ src/documentation/sections/markdown.ts | 326 ++++++++++++++++++ src/documentation/sections/parse.ts | 247 +++++++++++++ .../language-server/documentation.ts | 35 +- src/external-links.ts | 5 - src/flags.ts | 5 - src/router-hooks.test.tsx | 5 + src/router-hooks.tsx | 13 +- src/setupTests.ts | 3 - src/simulator/SimulatorModules.tsx | 35 +- src/vite.d.ts | 3 - src/workbench/SideBar.tsx | 61 ++-- vite.config.ts | 30 -- 61 files changed, 2106 insertions(+), 377 deletions(-) create mode 100644 content/de/grundlagen/ausgabe.md create mode 100644 content/de/grundlagen/schleifen.md create mode 100644 content/de/projekte/wetterstation.md create mode 100644 content/de/sensoren/temperatur.md create mode 100644 content/images/ausgabe.svg create mode 100644 content/images/schleifen.svg create mode 100644 content/images/temperatur.svg create mode 100644 content/images/wetterstation.svg create mode 100644 content/mapping.json create mode 100644 content/sidebar.json create mode 100644 docs/content.md create mode 100644 src/common/portable-text.ts create mode 100644 src/documentation/DocsSectionArea.tsx create mode 100644 src/documentation/reference/content.ts create mode 100644 src/documentation/sections/config.ts create mode 100644 src/documentation/sections/content.test.ts create mode 100644 src/documentation/sections/content.ts create mode 100644 src/documentation/sections/icons.ts create mode 100644 src/documentation/sections/markdown.test.ts create mode 100644 src/documentation/sections/markdown.ts create mode 100644 src/documentation/sections/parse.ts diff --git a/.env b/.env index 905d17ed1..e10a70f1c 100644 --- a/.env +++ b/.env @@ -1,6 +1,3 @@ VITE_VERSION=$npm_package_version VITE_NAME=$npm_package_name VITE_FULL_URL=/ -VITE_SANITY_PROJECT=9y5xmu9m -VITE_SANITY_DATASET=production -VITE_SANITY_PREVIEW_DATASET=production \ No newline at end of file diff --git a/content/de/grundlagen/ausgabe.md b/content/de/grundlagen/ausgabe.md new file mode 100644 index 000000000..3488f74f5 --- /dev/null +++ b/content/de/grundlagen/ausgabe.md @@ -0,0 +1,32 @@ +--- +name: Ausgabe +subtitle: Text und Werte in der Konsole anzeigen +image: ../../images/ausgabe.svg +--- + +Mit `print` gibst du Text und Messwerte in der seriellen Konsole aus. + +## Text ausgeben + +Schreibe den Text in Anführungszeichen: + +```python +print("Hallo senseBox!") +``` + + + +Die Ausgabe siehst du in der **Konsole** unterhalb des Editors, sobald die senseBox verbunden ist. + +## Werte ausgeben + + + +Du kannst auch Zahlen und Variablen ausgeben. Mehrere Werte trennst du mit Kommas: + +```python +temperatur = 21.5 +print("Temperatur:", temperatur) +``` + +Mehr über Messwerte erfährst du unter [Temperatur](doc:sensoren/temperatur). diff --git a/content/de/grundlagen/schleifen.md b/content/de/grundlagen/schleifen.md new file mode 100644 index 000000000..8793cf194 --- /dev/null +++ b/content/de/grundlagen/schleifen.md @@ -0,0 +1,43 @@ +--- +name: Schleifen +subtitle: Code wiederholen +image: ../../images/schleifen.svg +--- + +Schleifen führen Code mehrfach aus. + +## Endlosschleife + +Eine `while True`-Schleife läuft für immer. So misst die senseBox z. B. dauerhaft: + +```python +import time + +while True: + print("Messung ...") + time.sleep(1) +``` + +## Zählschleife + + + +### 3 Mal + +```python +for i in range(3): + print(i) +``` + +### 10 Mal + + + +```python +for i in range(10): + print(i) +``` + + + +`range(n)` zählt von 0 bis n - 1. diff --git a/content/de/projekte/wetterstation.md b/content/de/projekte/wetterstation.md new file mode 100644 index 000000000..692261f02 --- /dev/null +++ b/content/de/projekte/wetterstation.md @@ -0,0 +1,22 @@ +--- +name: Wetterstation +image: ../../images/wetterstation.svg +--- + +Baue eine einfache Wetterstation, die regelmäßig die Temperatur ausgibt. + +![Skizze einer Wetterstation](../../images/wetterstation.svg) + +### So funktioniert's + +1. Schließe den [Temperatursensor](doc:sensoren/temperatur) an. +2. Öffne den Code mit dem Button unten. +3. Übertrage ihn auf die senseBox. + +```python +import time + +while True: + print("Temperatur: 21.5") + time.sleep(10) +``` diff --git a/content/de/sensoren/temperatur.md b/content/de/sensoren/temperatur.md new file mode 100644 index 000000000..e1d0e2b85 --- /dev/null +++ b/content/de/sensoren/temperatur.md @@ -0,0 +1,30 @@ +--- +name: Temperatur +subtitle: Temperatur und Luftfeuchtigkeit messen +image: ../../images/temperatur.svg +--- + +Der Temperatur- und Luftfeuchtigkeitssensor wird über I²C angeschlossen. + +## Temperatur messen + + + +Dieses Beispiel liest den Sensor jede Sekunde aus: + +```python +import time +import board + +i2c = board.I2C() + +while True: + # Sensor auslesen und Wert ausgeben + print("Temperatur messen ...") + time.sleep(1) +``` + + + +Die Werte kannst du mit [print](doc:grundlagen/ausgabe) ausgeben. +Weitere Informationen findest du in der [senseBox-Dokumentation](https://docs.sensebox.de). diff --git a/content/images/ausgabe.svg b/content/images/ausgabe.svg new file mode 100644 index 000000000..4916c0b50 --- /dev/null +++ b/content/images/ausgabe.svg @@ -0,0 +1 @@ +print diff --git a/content/images/schleifen.svg b/content/images/schleifen.svg new file mode 100644 index 000000000..ff42cadcc --- /dev/null +++ b/content/images/schleifen.svg @@ -0,0 +1 @@ +loop diff --git a/content/images/temperatur.svg b/content/images/temperatur.svg new file mode 100644 index 000000000..5099468c8 --- /dev/null +++ b/content/images/temperatur.svg @@ -0,0 +1 @@ +°C diff --git a/content/images/wetterstation.svg b/content/images/wetterstation.svg new file mode 100644 index 000000000..d04c11f1d --- /dev/null +++ b/content/images/wetterstation.svg @@ -0,0 +1 @@ +☀ diff --git a/content/mapping.json b/content/mapping.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/content/mapping.json @@ -0,0 +1 @@ +{} diff --git a/content/sidebar.json b/content/sidebar.json new file mode 100644 index 000000000..8929d2067 --- /dev/null +++ b/content/sidebar.json @@ -0,0 +1,33 @@ +{ + "fallbackLanguage": "de", + "sections": [ + { + "id": "grundlagen", + "layout": "reference", + "icon": "VscLibrary", + "title": { "de": "Grundlagen", "en": "Basics" }, + "description": { + "de": "Die wichtigsten Python-Bausteine für die senseBox.", + "en": "The most important Python building blocks for the senseBox." + }, + "order": ["ausgabe", "schleifen"] + }, + { + "id": "sensoren", + "layout": "reference", + "icon": "RiSensorLine", + "title": { "de": "Sensoren", "en": "Sensors" } + }, + { + "id": "projekte", + "layout": "cards", + "icon": "RiLightbulbFlashLine", + "title": { "de": "Projektideen", "en": "Project ideas" }, + "description": { + "de": "Fertige Projekte zum Ausprobieren und Weiterentwickeln.", + "en": "Complete projects to try out and build on." + } + }, + { "builtin": "api" } + ] +} diff --git a/docs/analytics-events.md b/docs/analytics-events.md index 0e0eae1f1..a9c19800f 100644 --- a/docs/analytics-events.md +++ b/docs/analytics-events.md @@ -157,7 +157,7 @@ back/forward and typed URLs don't fire it (they are `page_view` only). | Param | Values | | --------- | ------------------------------------------------------------------------------------------------------------------------------------------ | | `via` | `user` (clicked in the sidebar), `search` (picked a search result), `code` (from a code hover/help link), `simulator` (from the simulator) | -| `surface` | `reference` / `ideas` / `api` | +| `surface` | The sidebar section id from `content/sidebar.json` (e.g. `grundlagen`) or `api` | | `id` | The page slug, e.g. `buttons`; absent when navigating to a tab root | ### `docs_search` @@ -179,8 +179,9 @@ success rate. | `code_copy` | `surface`, `id` | User used the snippet's copy button | | `code_paste` | `surface`, `id` | A copied snippet was pasted into the editor (via our own clipboard context) | -- `surface` — `reference` / `ideas` / `api`. -- `id` — the documentation slug for Reference / Ideas, or the fully qualified +- `surface` — `reference` (snippets in a section with the reference layout), + `ideas` (snippets in a section with the cards layout) or `api`. +- `id` — the documentation slug for sections, or the fully qualified name for API (e.g. `microbit.display.scroll`). Bounded cardinality, but large; expect `(other)` in standard reports and use Explorations. diff --git a/docs/content.md b/docs/content.md new file mode 100644 index 000000000..ac532de28 --- /dev/null +++ b/docs/content.md @@ -0,0 +1,185 @@ +# Documentation content + +The documentation tabs in the sidebar and everything in them are read from the +`content/` folder. Content is written in Markdown and bundled with the app at +build time. To change it, edit the files and redeploy. + +``` +content/ + sidebar.json # which tabs exist, in which order + mapping.json # optional: API entries → documentation (editor "Help" links) + images/ # images, referenced relatively from Markdown + de/ # one folder per language + grundlagen/ # one folder per section (the section id) + ausgabe.md # reference layout: one file per topic + schleifen.md + projekte/ + wetterstation.md # cards layout: one file per card + en/ + grundlagen/ + ausgabe.md # translations use the same file names +``` + +## Sidebar (`content/sidebar.json`) + +```json +{ + "fallbackLanguage": "de", + "sections": [ + { + "id": "grundlagen", + "layout": "reference", + "icon": "VscLibrary", + "title": { "de": "Grundlagen", "en": "Basics" }, + "description": { "de": "…", "en": "…" }, + "order": ["ausgabe", "schleifen"] + }, + { + "id": "projekte", + "layout": "cards", + "icon": "RiLightbulbFlashLine", + "title": "Projekte" + }, + { "builtin": "api" } + ] +} +``` + +- Each entry in `sections` is a tab, in this order. The **Project** (files) tab + always comes last. +- **`id`** is the folder name and appears in URLs (`/grundlagen/ausgabe`). Use + lowercase letters, digits and `-`. `api` and `project` are reserved. +- **`layout`**: + - `reference`: a list of topics. Each topic has entries with code you can + drag into the editor. + - `cards`: a grid of cards. Each card opens a page whose code can be opened + as a project. +- **`icon`**: one of the names in `src/documentation/sections/icons.ts`. To + use another [react-icons](https://react-icons.github.io/react-icons/) icon, + add it there. +- **`title`** / **`description`**: a string, or an object with a value per + language. +- **`order`** (optional): file names without `.md`. Files that aren't listed + come after the listed ones, in alphabetical order. +- **`{ "builtin": "api" }`** shows the API tab, which is generated from the + type stubs. Leave it out to hide the tab and the API search results. + +### Languages + +For each file, the editor uses the version in the user's language +(`content//…`). If there isn't one, it tries the base language (e.g. +`pt` for `pt-BR`), then `fallbackLanguage`. Partial translations are fine. + +## Reference topics + +One Markdown file per topic: + +````markdown +--- +name: Schleifen +subtitle: Code wiederholen +image: ../../images/schleifen.svg +compatibility: [microbitV2] +--- + +Introduction shown at the top of the topic. + +## Endlosschleife + + + +Text shown before the code. When an entry has code, this text collapses to +one line until the user clicks "More". + +```python +while True: + pass +``` + + + +Only shown after clicking "More". + +## Zählschleife + + + +### 3 Mal + +```python +for i in range(3): print(i) +``` + +### 10 Mal + +```python +for i in range(10): print(i) +``` +```` + +- **Front matter**: + - `name`: defaults to the file name. + - `slug`: defaults to the file name. + - `subtitle` + - `image`: the topic icon. + - `compatibility`: optional. +- Everything before the first `##` is the **introduction**. +- Each **`##` heading starts an entry**. The entry's slug is the heading, + lower-cased and with umlauts replaced, e.g. `zaehlschleife`. +- **Directives** are HTML comments placed after an entry heading: + - `` sets the entry's slug. Placed after a `###` + alternative heading, it sets that alternative's slug instead. + - `` + - ``: the `###` headings that follow become + choices in a dropdown labelled "Label". + - ``: the content that follows is shown only when expanded. + - Several directives can share one comment: + ``. +- Slugs must be unique within a section. A duplicate entry slug is + automatically prefixed with the topic slug. + +## Cards + +One Markdown file per card: + +```markdown +--- +name: Wetterstation +image: ../../images/wetterstation.svg +--- + +Page content, including `###` headings, images and code. +``` + +## Markdown support + +| Markdown | Result | +| ------------------------------------ | ----------------------------------------------------------------------------------------------------- | +| `**bold**`, `*italic*`, `` `code` `` | Inline formatting | +| Lists (`-`, `1.`), `> quote`, `###` | Lists, quotes, headings | +| ` ```python ` (or no language) | Code snippet: drag/copy (reference), open (cards) | +| ` ```text ` (any other language) | Plain code text | +| `![alt](../../images/x.png)` | Image (path relative to the `.md` file, or `/images/x.png` for `content/images/x.png`, or a full URL) | +| `[text](https://…)` | External link (opens a new tab) | +| `[text](doc:section/slug)` | Link to a topic, entry or card in a section | +| `[text](doc:slug)` | Same, searching all sections for the slug | +| `[text](api:time.sleep)` | Link to an entry in the API tab | + +Inline HTML and tables aren't supported. + +## Help links from the editor (`content/mapping.json`) + +The editor's autocomplete and signature help show a "Help" link when an API +entry is mapped to documentation: + +```json +{ + "time": { + "sleep": "grundlagen/warten", + "monotonic": { "reference": "grundlagen/zeit", "alternative": "ms" } + } +} +``` + +The target is `section/slug`. The `alternative` field preselects a choice in an +entry that has `@alternatives`. diff --git a/docs/tech-overview.md b/docs/tech-overview.md index 6f42d37ff..41bd23c32 100644 --- a/docs/tech-overview.md +++ b/docs/tech-overview.md @@ -32,11 +32,7 @@ To produce the correct data format to flash a micro:bit, we use the Foundation's ## The sidebar and educational content -The Reference and Ideas sidebar tabs show educational content that is managed in the Micro:bit Educational Foundation's content management system (Sanity CMS). This content is not Open Source. The content is sourced live from the CMS APIs. It will work for local development but non-Foundation deployments will see CORS errors. - -You can substitute your own content by setting `VITE_SANITY_PROJECT` and `VITE_SANITY_DATASET` environment variables, overriding the defaults in `.env`. The schemas we use for Sanity CMS are [packaged as a plugin in this GitHub project](https://github.com/microbit-foundation/sanity-plugin-python-editor-v3/). - -We also plan to explore adding Markdown documentation support as an alternative to Sanity CMS. You can follow the discussion on [#1160](https://github.com/microbit-foundation/python-editor-v3/issues/1160). +The documentation tabs in the sidebar are configured in `content/sidebar.json` and their content is written as Markdown files in the `content/` folder. The content is bundled with the app at build time; there is no CMS. See [content.md](./content.md) for the format. The API tab shows detailed documentation of the MicroPython API for users who need more detail than the curated content in the Reference tab provides. The API tab content is generated at runtime from the bundled type stubs for MicroPython. We do this using an enhancement to the Foundation's fork of Pyright. For more details see [Python code intelligence](#python-code-intelligence). diff --git a/package-lock.json b/package-lock.json index 69a652d28..fba301887 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,6 @@ "@microbit/ui": "^0.5.0", "@microbit/ui-patterns": "^0.7.0", "@sanity/block-content-to-react": "^3.0.0", - "@sanity/image-url": "^1.0.1", "@sentry/browser": "^10.71.0", "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^14.0.0", @@ -4561,15 +4560,6 @@ "integrity": "sha512-H/G/WA9S22TXcXST52CIiTsHx3S2hH0gvK7LnI5w76vfKS0obnDPh8jrPg4xeNRYGPuV9MHYRlyERGpRGoo4Qw==", "license": "MIT" }, - "node_modules/@sanity/image-url": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@sanity/image-url/-/image-url-1.2.0.tgz", - "integrity": "sha512-pYRhti+lDi22it+npWXkEGuYyzbXJLF+d0TYLiyWbKu46JHhYhTDKkp6zmGu4YKF5cXUjT6pnUjFsaf2vlB9nQ==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - } - }, "node_modules/@sentry/browser": { "version": "10.76.0", "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.76.0.tgz", diff --git a/package.json b/package.json index a78da2b9f..81f4a1e87 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,6 @@ "@microbit/ui": "^0.5.0", "@microbit/ui-patterns": "^0.7.0", "@sanity/block-content-to-react": "^3.0.0", - "@sanity/image-url": "^1.0.1", "@sentry/browser": "^10.71.0", "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^14.0.0", diff --git a/src/common/documentation-styles.ts b/src/common/documentation-styles.ts index b11e869b9..04c161c25 100644 --- a/src/common/documentation-styles.ts +++ b/src/common/documentation-styles.ts @@ -5,26 +5,6 @@ */ import { css } from "styled-system/css"; -// Follows the pattern used by the AspectRatio component which uses a -// percentage-padding spacer rather than the aspect-ratio property, which is -// not supported in Safari 14. Only apply when --aspect-ratio-padding is set or -// the wrapper collapses to zero height. -export const runtimeAspectRatioClass = css({ - position: "relative", - _before: { - content: '""', - display: "block", - height: 0, - paddingBottom: "var(--aspect-ratio-padding)", - }, - "& > *": { - position: "absolute", - inset: 0, - width: "100%", - height: "100%", - }, -}); - // A class rather than a style object: Panda can only statically extract // styles written literally at the definition site, not objects spread into // a css prop elsewhere. diff --git a/src/common/portable-text.ts b/src/common/portable-text.ts new file mode 100644 index 000000000..25e060750 --- /dev/null +++ b/src/common/portable-text.ts @@ -0,0 +1,43 @@ +/** + * Portable Text types used for documentation content. + * + * Content is authored as Markdown in the content/ folder and converted to + * this structure at load time so the existing renderer, collapse handling + * and search indexing can be shared. + * + * (c) 2021-2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ + +export interface PortableTextBlock { + _type: "block"; + _key: string; + // Partial/lax modelling. We pass this straight to the rendering API. + children: any; + markDefs: any; + style: string; + listItem?: "bullet" | "number"; + level?: number; +} + +export type PortableText = Array< + PortableTextBlock | { _type: string; children?: any; [other: string]: any } +>; + +/** + * Common image type. + */ +export interface SimpleImage { + _type: "simpleImage"; + alt?: string; + /** + * The resolved image URL. + */ + src: string; +} + +export interface Slug { + _type: "slug"; + current: string; +} diff --git a/src/documentation/DocsSectionArea.tsx b/src/documentation/DocsSectionArea.tsx new file mode 100644 index 000000000..ed85b47bb --- /dev/null +++ b/src/documentation/DocsSectionArea.tsx @@ -0,0 +1,47 @@ +/** + * (c) 2021-2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { Text } from "@microbit/ui"; +import { FormattedMessage } from "react-intl"; +import Spinner from "../common/Spinner"; +import { useDocumentation } from "./documentation-hooks"; +import IdeasDocumentation from "./ideas/IdeasDocumentation"; +import ReferenceToolkit from "./reference/ReferenceDocumentation"; +import { DocsSectionConfig } from "./sections/config"; + +interface DocsSectionAreaProps { + section: DocsSectionConfig; +} + +/** + * A sidebar tab for a documentation section from content/sidebar.json. + */ +const DocsSectionArea = ({ section }: DocsSectionAreaProps) => { + const { sections } = useDocumentation(); + switch (sections.status) { + case "loading": + return ; + case "error": + return ( + + + + ); + case "ok": { + const content = sections.content[section.id]; + if (content?.layout === "cards") { + return ; + } + if (content?.layout === "reference") { + return ; + } + return null; + } + default: + throw new Error(); + } +}; + +export default DocsSectionArea; diff --git a/src/documentation/api/ApiDocumentation.tsx b/src/documentation/api/ApiDocumentation.tsx index 4bb5ba7e3..2820366eb 100644 --- a/src/documentation/api/ApiDocumentation.tsx +++ b/src/documentation/api/ApiDocumentation.tsx @@ -14,6 +14,7 @@ import HeadedScrollablePanel from "../../common/HeadedScrollablePanel"; import { splitDocString } from "../../editor/codemirror/language-server/docstrings"; import { ApiDocsEntry, ApiDocsResponse } from "../../language-server/apidocs"; import { Anchor, useRouterTabSlug, useRouterState } from "../../router-hooks"; +import { docsSections } from "../sections/config"; import DocString from "../common/DocString"; import { useAnimationDirection } from "../common/documentation-animation-hooks"; import DocumentationBreadcrumbHeading from "../common/DocumentationBreadcrumbHeading"; @@ -66,7 +67,7 @@ const ActiveLevel = ({ const handleReferenceLink = useCallback( (e: React.MouseEvent) => { e.preventDefault(); - setParams({ tab: "reference" }); + setParams({ tab: referenceSectionId }); }, [setParams] ); @@ -130,6 +131,10 @@ const ActiveLevel = ({ ); }; +const referenceSectionId = docsSections.find( + (s) => s.layout === "reference" +)?.id; + interface ShortModuleDescriptionProps { value: ApiDocsEntry; as?: "div" | "span"; diff --git a/src/documentation/common/DocumentationBreadcrumbHeading.tsx b/src/documentation/common/DocumentationBreadcrumbHeading.tsx index 0dd8dbdc0..63b2f3494 100644 --- a/src/documentation/common/DocumentationBreadcrumbHeading.tsx +++ b/src/documentation/common/DocumentationBreadcrumbHeading.tsx @@ -7,7 +7,7 @@ import { Button, Collapse, Text } from "@microbit/ui"; import { ReactNode, useCallback, useEffect, useRef, useState } from "react"; import { RiArrowLeftSFill } from "react-icons/ri"; import { Box, HStack, Stack, VStack } from "styled-system/jsx"; -import { SimpleImage } from "../../common/sanity"; +import { SimpleImage } from "../../common/portable-text"; import { useScrollablePanelAncestor } from "../../common/ScrollablePanel"; import { useResizeObserverContentRect } from "../../common/use-resize-observer"; import DocumentationIcon from "./DocumentationIcon"; diff --git a/src/documentation/common/DocumentationContent.test.tsx b/src/documentation/common/DocumentationContent.test.tsx index 906054d77..6e437b6df 100644 --- a/src/documentation/common/DocumentationContent.test.tsx +++ b/src/documentation/common/DocumentationContent.test.tsx @@ -6,7 +6,7 @@ * @vitest-environment jsdom */ import { render } from "@testing-library/react"; -import { PortableText } from "../../common/sanity"; +import { PortableText } from "../../common/portable-text"; import DocumentationContent from "./DocumentationContent"; describe("DocumentationContent", () => { @@ -50,15 +50,12 @@ describe("DocumentationContent", () => { { _type: "simpleImage", alt: "micro:bit showing X axis going across the front, Y axis going down and up, Z axis going back to front", - asset: { - _ref: "image-9fccaf51a164fedc98662188593de19bfb9be8ad-435x512-png", - _type: "reference", - }, + src: "/content/images/axes.png", }, ]; const view = render(); expect(view.container.innerHTML).toMatchInlineSnapshot( - `"
micro:bit showing X axis going across the front, Y axis going down and up, Z axis going back to front
"` + `"
micro:bit showing X axis going across the front, Y axis going down and up, Z axis going back to front
"` ); }); }); diff --git a/src/documentation/common/DocumentationContent.tsx b/src/documentation/common/DocumentationContent.tsx index 1846ac01c..250172372 100644 --- a/src/documentation/common/DocumentationContent.tsx +++ b/src/documentation/common/DocumentationContent.tsx @@ -8,13 +8,10 @@ import BlockContent from "@sanity/block-content-to-react"; import React, { ReactNode, useContext, useMemo } from "react"; import { RiExternalLinkLine } from "react-icons/ri"; import { Box, Stack } from "styled-system/jsx"; -import { runtimeAspectRatioClass } from "../../common/documentation-styles"; -import { - getAspectRatioPadding, - imageUrlBuilder, -} from "../../common/imageUrlBuilder"; -import { PortableText, SimpleImage } from "../../common/sanity"; +import { PortableText, SimpleImage } from "../../common/portable-text"; import { useRouterState } from "../../router-hooks"; +import { useDocumentation } from "../documentation-hooks"; +import { findSectionForSlug } from "../sections/content"; import { ToolkitApiLink, ToolkitCode, @@ -43,6 +40,10 @@ interface DocumentationContextValue { parentSlug?: string; toolkitType?: string; title?: string; + /** + * The sidebar section the content belongs to. + */ + sectionId?: string; } const DocumentationContext = React.createContext({}); @@ -91,17 +92,26 @@ const DocumentationInternalLinkMark = ( props: SerializerMarkProps ) => { const [state, setState] = useRouterState(); + const { sections } = useDocumentation(); + const { sectionId } = useContext(DocumentationContext); return ( { e.preventDefault(); + const slug = props.mark.slug.current; + const tab = + props.mark.section ?? + (sections.status === "ok" + ? findSectionForSlug(sections.content, slug) + : undefined) ?? + sectionId; setState( { ...state, - tab: "reference", + tab, slug: { - id: props.mark.slug.current, + id: slug, }, }, "user" @@ -204,21 +214,10 @@ const serializers = { ), simpleImage: (props: SerializerNodeProps) => { - const padding = getAspectRatioPadding(props.node.asset._ref); return ( - + } alt={props.node.alt} diff --git a/src/documentation/common/DocumentationIcon.tsx b/src/documentation/common/DocumentationIcon.tsx index 6558175ae..18d3ed791 100644 --- a/src/documentation/common/DocumentationIcon.tsx +++ b/src/documentation/common/DocumentationIcon.tsx @@ -4,8 +4,7 @@ * SPDX-License-Identifier: MIT */ import { SystemStyleObject } from "styled-system/types"; -import { imageUrlBuilder } from "../../common/imageUrlBuilder"; -import { SimpleImage } from "../../common/sanity"; +import { SimpleImage } from "../../common/portable-text"; import OfflineImageFallback from "../OfflineImageFallback"; import ImageWithFallback from "./ImageWithFallback"; @@ -22,7 +21,7 @@ const DocumentationIcon = ({ icon, reduced, css }: DocumentationIconProps) => { transition="all .2s" width={reduced ? "50px" : "80px"} height={reduced ? "50px" : "80px"} - src={imageUrlBuilder.image(icon.asset).url()} + src={icon.src} ignoreFallback={navigator.onLine} fallback={ = | { status: "ok"; content: T; languageId: string } @@ -87,8 +84,10 @@ const useApiDocumentation = (): ApiDocsContent | undefined => { export interface DocumentationContextValue { api: ApiDocsContent | undefined; - ideas: ContentState; - reference: ContentState; + /** + * Content for the sections configured in content/sidebar.json. + */ + sections: ContentState; apiReferenceMap: ContentState; } @@ -109,12 +108,11 @@ export const useDocumentation = (): DocumentationContextValue => { const DocumentationProvider = ({ children }: { children: ReactNode }) => { const api = useApiDocumentation(); - const ideas = useContent(fetchIdeas); - const reference = useContent(fetchReferenceToolkit); + const sections = useContent(fetchSections); const apiReferenceMap = useContent(fetchMappingData); const value: DocumentationContextValue = useMemo(() => { - return { reference, api, ideas, apiReferenceMap }; - }, [reference, api, ideas, apiReferenceMap]); + return { sections, api, apiReferenceMap }; + }, [sections, api, apiReferenceMap]); return ( {children} diff --git a/src/documentation/ideas/IdeaCard.tsx b/src/documentation/ideas/IdeaCard.tsx index 11f6d1b38..af979ba23 100644 --- a/src/documentation/ideas/IdeaCard.tsx +++ b/src/documentation/ideas/IdeaCard.tsx @@ -4,15 +4,14 @@ * SPDX-License-Identifier: MIT */ import { styled } from "styled-system/jsx"; -import { imageUrlBuilder } from "../../common/imageUrlBuilder"; -import { SimpleImage } from "../../common/sanity"; +import { SimpleImage } from "../../common/portable-text"; import DocumentationHeading from "../common/DocumentationHeading"; import ImageWithFallback from "../common/ImageWithFallback"; import OfflineImageFallback from "../OfflineImageFallback"; interface IdeaCardProps { name: string; - image: SimpleImage; + image?: SimpleImage; isV2Only?: boolean; onClick: () => void; } @@ -32,14 +31,16 @@ const IdeaCard = ({ name, image, isV2Only, onClick }: IdeaCardProps) => { boxShadow="md" _focusVisible={{ focusRing: "outline" }} > - } - alt="" - borderTopRadius="lg" - width="500px" - /> + {image && ( + } + alt="" + borderTopRadius="lg" + width="500px" + /> + )} { - const [anchor, setAnchor] = useRouterTabSlug("ideas"); +const IdeasDocumentation = ({ section, ideas }: IdeasDocumentationProps) => { + const [anchor, setAnchor] = useRouterTabSlug(section.id); const direction = useAnimationDirection(anchor); const ideaId = anchor?.id; const handleNavigate = useCallback( @@ -52,6 +44,7 @@ const IdeasDocumentation = ({ ideas }: IdeasDocumentationProps) => { anchor={anchor} ideaId={ideaId} onNavigate={handleNavigate} + section={section} ideas={ideas} direction={direction} /> @@ -68,23 +61,19 @@ interface ActiveLevelProps extends IdeasDocumentationProps { const ActiveLevel = ({ ideaId, onNavigate, + section, ideas, direction, }: ActiveLevelProps) => { const activeIdea = ideas.find((idea) => idea.slug.current === ideaId); - const intl = useIntl(); const [{ languageId }] = useSettings(); - const headingString = intl.formatMessage({ id: "ideas-tab" }); + const headingString = localize(section.title, languageId); const ref = useRef(null); const contentRect = useResizeObserverContentRect(ref); const contentWidth = contentRect?.width ?? 0; const numCols = !contentWidth || contentWidth > 1100 ? 3 : contentWidth > 550 ? 2 : 1; if (activeIdea) { - // Runtime value derived from the image reference. - const imagePadding = activeIdea.image - ? getAspectRatioPadding(activeIdea.image.asset._ref) - : undefined; return ( {activeIdea.image && ( - + } - alt="" + alt={activeIdea.image.alt ?? ""} borderTopRadius="lg" /> @@ -135,6 +112,7 @@ const ActiveLevel = ({ @@ -150,7 +128,7 @@ const ActiveLevel = ({ heading={ } > @@ -170,24 +148,6 @@ const ActiveLevel = ({ /> ))} - - ( - - {chunks} - - ), - }} - /> - ); }; diff --git a/src/documentation/ideas/model.ts b/src/documentation/ideas/model.ts index 97d8104a0..4eb251457 100644 --- a/src/documentation/ideas/model.ts +++ b/src/documentation/ideas/model.ts @@ -4,13 +4,13 @@ * SPDX-License-Identifier: MIT */ -import { PortableText, SimpleImage, Slug } from "../../common/sanity"; +import { PortableText, SimpleImage, Slug } from "../../common/portable-text"; import { HasCompatibility } from "../common/model"; export interface Idea extends HasCompatibility { _id: string; name: string; - image: SimpleImage; + image?: SimpleImage; content?: PortableText; language: string; slug: Slug; diff --git a/src/documentation/mapping/content.ts b/src/documentation/mapping/content.ts index 914250bbe..3f69862d6 100644 --- a/src/documentation/mapping/content.ts +++ b/src/documentation/mapping/content.ts @@ -1,29 +1,23 @@ /** - * (c) 2021-2022, Micro:bit Educational Foundation and contributors + * Mapping from API entries to documentation, read from content/mapping.json. + * + * (c) 2021-2026, Micro:bit Educational Foundation and contributors * * SPDX-License-Identifier: MIT */ -import { fetchContent } from "../../common/sanity"; -import { PythonModule } from "./model"; - -export const fetchMappingData = async (): Promise => - fetchContent("", mappingQuery, adaptContent); +const mappingFiles = import.meta.glob("/content/mapping.json", { + import: "default", + eager: true, +}); -const mappingQuery = (): string => { - return ` - *[_type == "pythonModule" && !(_id in path("drafts.**"))]{ - pythonModuleName, - pythonModuleItem[] { - pythonAlternativeContentLink, - pythonApiEntry, - referenceLink { - _type == "reference" =>^-> { - slug - } - } - } - }`; -}; +/** + * Module name to API entry to link target. The target is either + * "section/slug" or an object with an alternative to preselect. + */ +type RawMapping = Record< + string, + Record +>; export type ApiReferenceMap = Record< string, @@ -31,30 +25,33 @@ export type ApiReferenceMap = Record< >; export interface ReferenceLinkDetail { + /** + * The sidebar section id. If absent the section containing the slug is used. + */ + section?: string; referenceLink: string; alternative?: string; } -const adaptContent = (result: any): ApiReferenceMap | undefined => { - const mappingData = result as PythonModule[]; - if (mappingData.length === 0) { - return undefined; - } +export const adaptMapping = (raw: RawMapping): ApiReferenceMap => { const map: ApiReferenceMap = {}; - for (const module of mappingData) { - for (const moduleItem of module.pythonModuleItem) { - const referenceLink = moduleItem.referenceLink?.slug.current; - const alternative = moduleItem.pythonAlternativeContentLink; + for (const [moduleName, items] of Object.entries(raw)) { + for (const [apiEntry, target] of Object.entries(items)) { + const { reference, alternative } = + typeof target === "string" ? { reference: target } : target; + const parts = reference.split("/").filter(Boolean); + const [section, referenceLink] = + parts.length > 1 ? parts : [undefined, parts[0]]; if (referenceLink) { - map[module.pythonModuleName] = { - ...map[module.pythonModuleName], - [moduleItem.pythonApiEntry]: { - referenceLink, - alternative, - }, + map[moduleName] = { + ...map[moduleName], + [apiEntry]: { section, referenceLink, alternative }, }; } } } return map; }; + +export const fetchMappingData = async (): Promise => + adaptMapping(Object.values(mappingFiles)[0] ?? {}); diff --git a/src/documentation/reference/ReferenceDocumentation.tsx b/src/documentation/reference/ReferenceDocumentation.tsx index 62d515de9..de3ff78b9 100644 --- a/src/documentation/reference/ReferenceDocumentation.tsx +++ b/src/documentation/reference/ReferenceDocumentation.tsx @@ -5,21 +5,24 @@ */ import { Divider, List, ListItem } from "@microbit/ui"; import { useCallback } from "react"; -import { useIntl } from "react-intl"; import { Box } from "styled-system/jsx"; import { docStylesClass } from "../../common/documentation-styles"; import HeadedScrollablePanel from "../../common/HeadedScrollablePanel"; import { Anchor, useRouterTabSlug } from "../../router-hooks"; +import { useSettings } from "../../settings/settings"; +import AreaHeading from "../../common/AreaHeading"; import { useAnimationDirection } from "../common/documentation-animation-hooks"; import DocumentationBreadcrumbHeading from "../common/DocumentationBreadcrumbHeading"; import DocumentationContent from "../common/DocumentationContent"; import DocumentationTopLevelItem from "../common/DocumentationTopLevelItem"; import { isV2Only } from "../common/model"; +import { DocsSectionConfig, localize } from "../sections/config"; import { getTopicAndEntry } from "./content"; import { Toolkit } from "./model"; import ReferenceTopicEntry from "./ReferenceTopicEntry"; interface ReferenceDocumentationProps { + section: DocsSectionConfig; toolkit: Toolkit; } @@ -29,8 +32,11 @@ interface ReferenceDocumentationProps { * The components used here are also used with the API data to * generate the API documentation. */ -const ReferenceToolkit = ({ toolkit }: ReferenceDocumentationProps) => { - const [anchor, setAnchor] = useRouterTabSlug("reference"); +const ReferenceToolkit = ({ + section, + toolkit, +}: ReferenceDocumentationProps) => { + const [anchor, setAnchor] = useRouterTabSlug(section.id); const direction = useAnimationDirection(anchor); const topicOrEntryId = anchor?.id.split("/")[0]; const handleNavigate = useCallback( @@ -45,6 +51,7 @@ const ReferenceToolkit = ({ toolkit }: ReferenceDocumentationProps) => { anchor={anchor} topicOrEntryId={topicOrEntryId} onNavigate={handleNavigate} + section={section} toolkit={toolkit} direction={direction} /> @@ -62,12 +69,14 @@ const ActiveLevel = ({ anchor, topicOrEntryId, onNavigate, + section, toolkit, direction, }: ActiveLevelProps) => { const [topic, activeItem] = getTopicAndEntry(toolkit, topicOrEntryId); - const intl = useIntl(); - const referenceString = intl.formatMessage({ id: "reference-tab" }); + const [{ languageId }] = useSettings(); + const referenceString = localize(section.title, languageId); + const description = localize(section.description, languageId); if (topic) { return ( @@ -107,7 +117,14 @@ const ActiveLevel = ({ ); } return ( - + + ) : undefined + } + > {toolkit.contents?.map((topic) => ( { const { content, detailContent, alternatives, alternativesLabel } = entry; const activeAlterative = anchor?.id.split("/")[1]; @@ -91,6 +93,7 @@ const ReferenceTopicEntry = ({ diff --git a/src/documentation/reference/content.ts b/src/documentation/reference/content.ts new file mode 100644 index 000000000..cac9f7e46 --- /dev/null +++ b/src/documentation/reference/content.ts @@ -0,0 +1,25 @@ +/** + * (c) 2021, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { Toolkit, ToolkitTopic, ToolkitTopicEntry } from "./model"; + +export const getTopicAndEntry = ( + toolkit: Toolkit, + topicOrEntryId: string | undefined +): [ToolkitTopic | undefined, ToolkitTopicEntry | undefined] => { + const topic = toolkit.contents?.find( + (t) => t.slug.current === topicOrEntryId + ); + if (topic) { + return [topic, undefined]; + } + const entry = toolkit.contents + ?.flatMap((topic) => topic.contents ?? []) + .find((entry) => entry.slug.current === topicOrEntryId); + if (!entry) { + return [undefined, undefined]; + } + return [entry.parent, entry]; +}; diff --git a/src/documentation/reference/model.ts b/src/documentation/reference/model.ts index c31f2a1fe..3fb13fed6 100644 --- a/src/documentation/reference/model.ts +++ b/src/documentation/reference/model.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: MIT */ -import { PortableText, SimpleImage, Slug } from "../../common/sanity"; +import { PortableText, SimpleImage, Slug } from "../../common/portable-text"; import { HasCompatibility } from "../common/model"; export interface Toolkit { @@ -55,8 +55,10 @@ export interface ToolkitTopicEntry extends HasCompatibility { } export interface ToolkitInternalLink { - reference: ToolkitTopicEntry; - targetType: string; + /** + * The sidebar section id, if given in the link. + */ + section?: string; slug: Slug; } diff --git a/src/documentation/search/SearchDialog.tsx b/src/documentation/search/SearchDialog.tsx index c9c39b12a..910a6639a 100644 --- a/src/documentation/search/SearchDialog.tsx +++ b/src/documentation/search/SearchDialog.tsx @@ -17,6 +17,7 @@ import { RiCloseLine, RiSearch2Line } from "react-icons/ri"; import { FormattedMessage, useIntl } from "react-intl"; import { Box, Stack } from "styled-system/jsx"; import { RouterState } from "../../router-hooks"; +import { sidebarConfig } from "../sections/config"; import { SearchResults } from "./common"; import SearchResultList from "./SearchResultList"; @@ -107,22 +108,31 @@ const SearchDialog = ({ sum + s.results.length, + 0 + ) + (showApi ? results.api.length : 0), }} /> - - + {results.sections.map((section) => ( + + ))} + {showApi && ( + + )} )} @@ -130,4 +140,6 @@ const SearchDialog = ({ ); }; +const showApi = sidebarConfig.entries.some((e) => e.kind === "builtin"); + export default SearchDialog; diff --git a/src/documentation/search/blocks-to-text.test.ts b/src/documentation/search/blocks-to-text.test.ts index 675c7596c..da69f85a0 100644 --- a/src/documentation/search/blocks-to-text.test.ts +++ b/src/documentation/search/blocks-to-text.test.ts @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: MIT */ -import { PortableText } from "../../common/sanity"; +import { PortableText } from "../../common/portable-text"; import { blocksToText } from "./blocks-to-text"; const content: PortableText[] = [ diff --git a/src/documentation/search/blocks-to-text.ts b/src/documentation/search/blocks-to-text.ts index 6f939b551..b530109bb 100644 --- a/src/documentation/search/blocks-to-text.ts +++ b/src/documentation/search/blocks-to-text.ts @@ -3,7 +3,7 @@ * * SPDX-License-Identifier: MIT */ -import { PortableText } from "../../common/sanity"; +import { PortableText } from "../../common/portable-text"; const defaults = { nonTextBehavior: "remove" }; diff --git a/src/documentation/search/common.ts b/src/documentation/search/common.ts index fa41e3a9c..86570654c 100644 --- a/src/documentation/search/common.ts +++ b/src/documentation/search/common.ts @@ -10,13 +10,28 @@ import type { ApiDocsResponse } from "../../language-server/apidocs"; import type { RouterState } from "../../router-hooks"; import type { Toolkit } from "../reference/model"; +/** + * A sidebar section's content for indexing. + */ +export interface SearchableSection { + id: string; + title: string; + toolkit: Toolkit; +} + export interface Search { search(text: string): Promise; - index(reference: Toolkit, api: ApiDocsResponse): void; + index(sections: SearchableSection[], api: ApiDocsResponse): void; +} + +export interface SectionResults { + id: string; + title: string; + results: Result[]; } export interface SearchResults { - reference: Result[]; + sections: SectionResults[]; api: Result[]; } @@ -40,7 +55,7 @@ export interface Extracts { export interface IndexMessage { kind: "index"; - reference: Toolkit; + sections: SearchableSection[]; api: ApiDocsResponse; } diff --git a/src/documentation/search/search-client.ts b/src/documentation/search/search-client.ts index 729490278..f2a8027a2 100644 --- a/src/documentation/search/search-client.ts +++ b/src/documentation/search/search-client.ts @@ -8,11 +8,11 @@ import { QueryMessage, QueryResponseMessage, Search, + SearchableSection, SearchResults, } from "./common"; import { ApiDocsResponse } from "../../language-server/apidocs"; -import { Toolkit } from "../reference/model"; export class WorkerSearch implements Search { private worker: Worker; @@ -20,10 +20,10 @@ export class WorkerSearch implements Search { constructor(public language: string) { this.worker = workerForLanguage(language); } - index(reference: Toolkit, api: ApiDocsResponse) { + index(sections: SearchableSection[], api: ApiDocsResponse) { const message: IndexMessage = { kind: "index", - reference, + sections, api, }; this.worker.postMessage(message); diff --git a/src/documentation/search/search-hooks.tsx b/src/documentation/search/search-hooks.tsx index fed25d84d..eb7376cdd 100644 --- a/src/documentation/search/search-hooks.tsx +++ b/src/documentation/search/search-hooks.tsx @@ -17,7 +17,9 @@ import useIsUnmounted from "../../common/use-is-unmounted"; import { useLogging } from "../../logging/logging-hooks"; import { useSettings } from "../../settings/settings"; import { useDocumentation } from "../documentation-hooks"; -import { SearchResults } from "./common"; +import { docsSections, localize } from "../sections/config"; +import { DocsSections } from "../sections/content"; +import { SearchableSection, SearchResults } from "./common"; import { WorkerSearch } from "./search-client"; type UseSearch = { @@ -37,7 +39,7 @@ export const useSearch = (): UseSearch => { }; const SearchProvider = ({ children }: { children: ReactNode }) => { - const { reference, api } = useDocumentation(); + const { sections, api } = useDocumentation(); const [query, setQuery] = useState(""); const [results, setResults] = useState(); const isUnmounted = useIsUnmounted(); @@ -53,13 +55,16 @@ const SearchProvider = ({ children }: { children: ReactNode }) => { } // Wait for everything to be loaded and in the right language if ( - reference.status === "ok" && - reference.languageId === languageId && + sections.status === "ok" && + sections.languageId === languageId && api?.languageId === languageId ) { - search.current.index(reference.content, api.content); + search.current.index( + searchableSections(sections.content, languageId), + api.content + ); } - }, [languageId, reference, api]); + }, [languageId, sections, api]); const debouncedSearch = useMemo( () => @@ -98,4 +103,36 @@ const SearchProvider = ({ children }: { children: ReactNode }) => { ); }; +/** + * Cards are indexed as topics without entries. + */ +const searchableSections = ( + sections: DocsSections, + languageId: string +): SearchableSection[] => + docsSections.flatMap((config) => { + const content = sections[config.id]; + if (!content) { + return []; + } + const title = localize(config.title, languageId); + const toolkit = + content.layout === "reference" + ? content.toolkit + : { + id: config.id, + name: title, + description: "", + language: languageId, + contents: content.cards.map((card) => ({ + name: card.name, + subtitle: "", + slug: card.slug, + compatibility: card.compatibility, + introduction: card.content, + })), + }; + return [{ id: config.id, title, toolkit }]; + }); + export default SearchProvider; diff --git a/src/documentation/search/search.worker.test.ts b/src/documentation/search/search.worker.test.ts index 2651d59b1..98f26d41b 100644 --- a/src/documentation/search/search.worker.test.ts +++ b/src/documentation/search/search.worker.test.ts @@ -123,15 +123,28 @@ describe("buildReferenceIndex", () => { ...referenceEn, language: "fr", }; - const enIndex = await buildIndex(referenceEn, api, undefined, undefined); - expect(enIndex.search("topic").reference.length).toEqual(1); + const section = (toolkit: Toolkit) => [ + { id: "reference", title: "Reference", toolkit }, + ]; + const enIndex = await buildIndex( + section(referenceEn), + api, + undefined, + undefined + ); + expect(enIndex.search("topic").sections[0].results.length).toEqual(1); // "that" is an English stopword - expect(enIndex.search("that").reference.length).toEqual(0); + expect(enIndex.search("that").sections[0].results.length).toEqual(0); - const frIndex = await buildIndex(referenceFr, api, "fr", frLanguageSupport); - expect(frIndex.search("topic").reference.length).toEqual(1); + const frIndex = await buildIndex( + section(referenceFr), + api, + "fr", + frLanguageSupport + ); + expect(frIndex.search("topic").sections[0].results.length).toEqual(1); // "that" is not a French stopword - expect(frIndex.search("that").reference.length).toEqual(1); + expect(frIndex.search("that").sections[0].results.length).toEqual(1); }); }); @@ -158,13 +171,19 @@ describe("SearchWorker", () => { const indexMessage: IndexMessage = { kind: "index", - reference: { - id: "reference", - description: "Reference stuff", - name: "Reference", - contents: [], - language: "en", - }, + sections: [ + { + id: "reference", + title: "Reference", + toolkit: { + id: "reference", + description: "Reference stuff", + name: "Reference", + contents: [], + language: "en", + }, + }, + ], api: {}, }; ctx.onmessage!( @@ -175,7 +194,13 @@ describe("SearchWorker", () => { await new Promise((resolve) => setTimeout(resolve, 0)); expect(postMessage.mock.calls).toEqual([ - [{ reference: [], api: [], kind: "queryResponse" }], + [ + { + sections: [{ id: "reference", title: "Reference", results: [] }], + api: [], + kind: "queryResponse", + }, + ], ]); }); @@ -189,31 +214,43 @@ describe("SearchWorker", () => { const emptyIndex: IndexMessage = { kind: "index", - reference: { - id: "reference", - description: "Reference stuff", - name: "Reference", - contents: [], - language: "en", - }, + sections: [ + { + id: "reference", + title: "Reference", + toolkit: { + id: "reference", + description: "Reference stuff", + name: "Reference", + contents: [], + language: "en", + }, + }, + ], api: {}, }; const fullIndex: IndexMessage = { kind: "index", - reference: { - id: "reference", - description: "Reference stuff", - name: "Reference", - contents: [ - { - name: "Hello", - subtitle: "Hello", - slug: { _type: "slug", current: "hello" }, - compatibility: [], + sections: [ + { + id: "reference", + title: "Reference", + toolkit: { + id: "reference", + description: "Reference stuff", + name: "Reference", + contents: [ + { + name: "Hello", + subtitle: "Hello", + slug: { _type: "slug", current: "hello" }, + compatibility: [], + }, + ], + language: "en", }, - ], - language: "en", - }, + }, + ], api: {}, }; @@ -241,8 +278,8 @@ describe("SearchWorker", () => { await queryHello(); expect(postMessage.mock.calls.length).toEqual(2); - expect(postMessage.mock.calls[0][0].reference.length).toEqual(0); - expect(postMessage.mock.calls[1][0].reference.length).toEqual(1); + expect(postMessage.mock.calls[0][0].sections[0].results.length).toEqual(0); + expect(postMessage.mock.calls[1][0].sections[0].results.length).toEqual(1); }); }); diff --git a/src/documentation/search/search.worker.ts b/src/documentation/search/search.worker.ts index 7551aaff1..cc9943bbb 100644 --- a/src/documentation/search/search.worker.ts +++ b/src/documentation/search/search.worker.ts @@ -19,6 +19,7 @@ import { IndexMessage, QueryMessage, Result, + SearchableSection, SearchResults, } from "./common"; import { Position, contextExtracts, fullStringExtracts } from "./extracts"; @@ -88,7 +89,7 @@ export class SearchIndex { private contentByRef: Map, public index: lunr.Index, private tokenizer: TokenizerFunction, - private tab: "reference" | "api" + private tab: string ) {} search(text: string): Result[] { @@ -145,11 +146,18 @@ const getExtracts = ( }; export class LunrSearch { - constructor(private reference: SearchIndex, private api: SearchIndex) {} + constructor( + private sections: { id: string; title: string; index: SearchIndex }[], + private api: SearchIndex + ) {} search(text: string): SearchResults { return { - reference: this.reference.search(text), + sections: this.sections.map(({ id, title, index }) => ({ + id, + title, + results: index.search(text), + })), api: this.api.search(text), }; } @@ -236,7 +244,7 @@ type TokenizerFunction = { export const buildSearchIndex = ( searchableContent: SearchableContent[], - tab: "reference" | "api", + tab: string, language: LunrLanguage | undefined, languagePlugin: lunr.Builder.Plugin, ...plugins: lunr.Builder.Plugin[] @@ -288,7 +296,7 @@ export const buildSearchIndex = ( // Exposed for testing. export const buildIndex = async ( - reference: Toolkit, + sections: SearchableSection[], api: ApiDocsResponse, lunrLanguage: LunrLanguage | undefined, languageSupport: ((l: typeof lunr) => void) | undefined @@ -307,13 +315,17 @@ export const buildIndex = async ( const languagePlugin = lunrWithLanguages.multiLanguage(...multiLanguages); return new LunrSearch( - buildSearchIndex( - referenceSearchableContent(reference), - "reference", - lunrLanguage, - languagePlugin, - ...plugins - ), + sections.map((section) => ({ + id: section.id, + title: section.title, + index: buildSearchIndex( + referenceSearchableContent(section.toolkit), + section.id, + lunrLanguage, + languagePlugin, + ...plugins + ), + })), buildSearchIndex( apiSearchableContent(api), "api", @@ -358,7 +370,7 @@ export class SearchWorker { private async index(message: IndexMessage) { this.search = await buildIndex( - message.reference, + message.sections, message.api, this.languageId, this.languageSupport diff --git a/src/documentation/sections/config.ts b/src/documentation/sections/config.ts new file mode 100644 index 000000000..9b8aa6622 --- /dev/null +++ b/src/documentation/sections/config.ts @@ -0,0 +1,127 @@ +/** + * Sidebar configuration read from content/sidebar.json. + * + * The documentation tabs in the sidebar are driven entirely by this file. + * See docs/content.md for the format. + * + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import rawConfig from "../../../content/sidebar.json"; + +/** + * "reference": topics with expandable entries and draggable code. + * "cards": a grid of cards that each open a page (like the original Ideas). + */ +export type SectionLayout = "reference" | "cards"; + +/** + * Either a plain string or a map from language id to string. + */ +export type LocalizedString = string | Record; + +export interface DocsSectionConfig { + kind: "section"; + id: string; + layout: SectionLayout; + icon?: string; + title: LocalizedString; + description?: LocalizedString; + /** + * File names (without .md) in display order. Files not listed are appended + * in alphabetical order. + */ + order?: string[]; +} + +export interface BuiltinTabConfig { + kind: "builtin"; + id: "api"; +} + +export type SidebarEntry = DocsSectionConfig | BuiltinTabConfig; + +export interface SidebarConfig { + fallbackLanguage: string; + entries: SidebarEntry[]; +} + +const reservedIds = new Set(["api", "project"]); +const validId = /^[a-z0-9][a-z0-9-]*$/; + +export const parseSidebarConfig = (raw: any): SidebarConfig => { + if (!raw || !Array.isArray(raw.sections)) { + throw new Error("content/sidebar.json must have a sections array"); + } + const seen = new Set(); + const entries = raw.sections.map((s: any): SidebarEntry => { + if (s.builtin) { + if (s.builtin !== "api") { + throw new Error(`Unknown builtin sidebar tab: ${s.builtin}`); + } + return { kind: "builtin", id: "api" }; + } + if (typeof s.id !== "string" || !validId.test(s.id)) { + throw new Error(`Invalid sidebar section id: ${s.id}`); + } + if (reservedIds.has(s.id) || seen.has(s.id)) { + throw new Error(`Duplicate or reserved sidebar section id: ${s.id}`); + } + seen.add(s.id); + if (s.layout !== "reference" && s.layout !== "cards") { + throw new Error(`Invalid layout for sidebar section ${s.id}`); + } + if (!s.title) { + throw new Error(`Missing title for sidebar section ${s.id}`); + } + return { + kind: "section", + id: s.id, + layout: s.layout, + icon: s.icon, + title: s.title, + description: s.description, + order: s.order, + }; + }); + return { + fallbackLanguage: raw.fallbackLanguage ?? "en", + entries, + }; +}; + +export const sidebarConfig = parseSidebarConfig(rawConfig); + +export const docsSections: DocsSectionConfig[] = sidebarConfig.entries.filter( + (e): e is DocsSectionConfig => e.kind === "section" +); + +export const getSectionConfig = ( + id: string | undefined +): DocsSectionConfig | undefined => docsSections.find((s) => s.id === id); + +/** + * Picks the best string for the language, falling back to the configured + * fallback language and then any available value. + */ +export const localize = ( + value: LocalizedString | undefined, + languageId: string +): string => { + if (value === undefined) { + return ""; + } + if (typeof value === "string") { + return value; + } + const lower = languageId.toLowerCase(); + return ( + value[languageId] ?? + value[lower] ?? + value[lower.split("-")[0]] ?? + value[sidebarConfig.fallbackLanguage] ?? + Object.values(value)[0] ?? + "" + ); +}; diff --git a/src/documentation/sections/content.test.ts b/src/documentation/sections/content.test.ts new file mode 100644 index 000000000..f24351e2e --- /dev/null +++ b/src/documentation/sections/content.test.ts @@ -0,0 +1,55 @@ +/** + * Checks the bundled content in content/ loads. + * + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { docsSections } from "./config"; +import { + candidateLanguages, + fetchSections, + findSectionForSlug, + resolveContentImage, +} from "./content"; + +describe("bundled content", () => { + it("loads every configured section", async () => { + const sections = await fetchSections("en"); + for (const config of docsSections) { + const content = sections[config.id]; + expect(content.layout).toEqual(config.layout); + const items = + content.layout === "reference" + ? content.toolkit.contents + : content.cards; + expect(items?.length).toBeGreaterThan(0); + } + }); + + it("finds sections by slug", async () => { + const sections = await fetchSections("de"); + const [first] = docsSections; + const content = sections[first.id]; + const slug = + content.layout === "reference" + ? content.toolkit.contents![0].slug.current + : content.cards[0].slug.current; + expect(findSectionForSlug(sections, slug)).toEqual(first.id); + expect(findSectionForSlug(sections, "does-not-exist")).toBeUndefined(); + }); +}); + +describe("candidateLanguages", () => { + it("prefers the user's language then the fallback", () => { + expect(candidateLanguages("pt-BR")).toEqual(["pt-br", "pt", "de"]); + }); +}); + +describe("resolveContentImage", () => { + it("passes through absolute URLs", () => { + expect( + resolveContentImage("/content/de/a/b.md", "https://example.com/x.png") + ).toEqual("https://example.com/x.png"); + }); +}); diff --git a/src/documentation/sections/content.ts b/src/documentation/sections/content.ts new file mode 100644 index 000000000..4f0176ee7 --- /dev/null +++ b/src/documentation/sections/content.ts @@ -0,0 +1,175 @@ +/** + * Loads the documentation sections from the Markdown files in content/. + * + * Content is bundled at build time. Each language is a separate chunk. + * + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { Idea } from "../ideas/model"; +import { Toolkit } from "../reference/model"; +import { + docsSections, + DocsSectionConfig, + localize, + sidebarConfig, +} from "./config"; +import { MarkdownContext } from "./markdown"; +import { parseCard, parseTopic } from "./parse"; + +export type SectionContent = + | { layout: "reference"; toolkit: Toolkit } + | { layout: "cards"; cards: Idea[] }; + +/** + * Content by section id. + */ +export type DocsSections = Record; + +const markdownFiles = import.meta.glob("/content/**/*.md", { + query: "?raw", + import: "default", +}); + +const imageFiles = import.meta.glob( + "/content/**/*.{png,jpg,jpeg,gif,svg,webp}", + { query: "?url", import: "default", eager: true } +); + +interface ContentFile { + // e.g. /content/de/grundlagen/leds.md + path: string; + language: string; + section: string; + name: string; +} + +const contentFiles: ContentFile[] = Object.keys(markdownFiles).flatMap( + (path) => { + const match = /^\/content\/([^/]+)\/([^/]+)\/([^/]+)\.md$/.exec(path); + if (!match) { + return []; + } + const [, language, section, name] = match; + return [{ path, language: language.toLowerCase(), section, name }]; + } +); + +/** + * Languages to try in order of preference. + */ +export const candidateLanguages = (languageId: string): string[] => { + const lower = languageId.toLowerCase(); + return Array.from( + new Set([ + lower, + lower.split("-")[0], + sidebarConfig.fallbackLanguage.toLowerCase(), + ]) + ).filter(Boolean); +}; + +/** + * Picks one file per name, preferring the user's language. + */ +const filesForSection = ( + section: DocsSectionConfig, + languageId: string +): ContentFile[] => { + const languages = candidateLanguages(languageId); + const byName = new Map(); + for (const language of [...languages].reverse()) { + contentFiles + .filter((f) => f.section === section.id && f.language === language) + .forEach((f) => byName.set(f.name, f)); + } + const order = section.order ?? []; + const position = (name: string) => { + const i = order.indexOf(name); + return i === -1 ? Number.MAX_SAFE_INTEGER : i; + }; + return Array.from(byName.values()).sort( + (a, b) => + position(a.name) - position(b.name) || a.name.localeCompare(b.name) + ); +}; + +export const resolveContentImage = ( + markdownPath: string, + imagePath: string +): string => { + if (/^(https?:|data:)/.test(imagePath)) { + return imagePath; + } + const resolved = imagePath.startsWith("/") + ? "/content" + imagePath + : new URL(imagePath, "file://" + markdownPath).pathname; + const url = imageFiles[decodeURI(resolved)]; + if (!url) { + console.warn(`Missing documentation image ${resolved} (${markdownPath})`); + return imagePath; + } + return url; +}; + +const contextFor = (file: ContentFile): MarkdownContext => ({ + resolveImage: (imagePath) => resolveContentImage(file.path, imagePath), +}); + +const loadSection = async ( + section: DocsSectionConfig, + languageId: string +): Promise => { + const files = filesForSection(section, languageId); + const sources = await Promise.all(files.map((f) => markdownFiles[f.path]())); + if (section.layout === "cards") { + return { + layout: "cards", + cards: files.map((f, i) => + parseCard(sources[i], f.name, f.language, contextFor(f)) + ), + }; + } + const usedSlugs = new Set(); + return { + layout: "reference", + toolkit: { + id: section.id, + name: localize(section.title, languageId), + description: localize(section.description, languageId), + language: languageId, + contents: files.map((f, i) => + parseTopic(sources[i], f.name, contextFor(f), usedSlugs) + ), + }, + }; +}; + +export const fetchSections = async ( + languageId: string +): Promise => { + const loaded = await Promise.all( + docsSections.map((s) => loadSection(s, languageId)) + ); + return Object.fromEntries(docsSections.map((s, i) => [s.id, loaded[i]])); +}; + +/** + * Finds the section containing a topic, entry or card slug. + */ +export const findSectionForSlug = ( + sections: DocsSections, + slug: string +): string | undefined => { + const id = slug.split("/")[0]; + return Object.entries(sections).find(([, content]) => + content.layout === "reference" + ? content.toolkit.contents?.some( + (t) => + t.slug.current === id || + t.contents?.some((e) => e.slug.current === id) + ) + : content.cards.some((c) => c.slug.current === id) + )?.[0]; +}; diff --git a/src/documentation/sections/icons.ts b/src/documentation/sections/icons.ts new file mode 100644 index 000000000..351046405 --- /dev/null +++ b/src/documentation/sections/icons.ts @@ -0,0 +1,49 @@ +/** + * Icons that can be used for sidebar sections in content/sidebar.json. + * + * Listed explicitly so only these icons are bundled. Add more as needed. + * + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { IconType } from "react-icons"; +import { + RiBookOpenLine, + RiCompass3Line, + RiCpuLine, + RiDashboard3Line, + RiFlaskLine, + RiLightbulbFlashLine, + RiPlantLine, + RiRocketLine, + RiSensorLine, + RiTempHotLine, + RiWifiLine, +} from "react-icons/ri"; +import { VscLibrary } from "react-icons/vsc"; + +const sectionIcons: Record = { + RiBookOpenLine, + RiCompass3Line, + RiCpuLine, + RiDashboard3Line, + RiFlaskLine, + RiLightbulbFlashLine, + RiPlantLine, + RiRocketLine, + RiSensorLine, + RiTempHotLine, + RiWifiLine, + VscLibrary, +}; + +export const sectionIcon = (name: string | undefined): IconType => { + const icon = name ? sectionIcons[name] : undefined; + if (name && !icon) { + console.warn( + `Unknown sidebar icon ${name}, see src/documentation/sections/icons.ts` + ); + } + return icon ?? VscLibrary; +}; diff --git a/src/documentation/sections/markdown.test.ts b/src/documentation/sections/markdown.test.ts new file mode 100644 index 000000000..22235d0a6 --- /dev/null +++ b/src/documentation/sections/markdown.test.ts @@ -0,0 +1,238 @@ +/** + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { adaptMapping } from "../mapping/content"; +import { + markdownToPortableText, + parseDirectives, + parseFrontMatter, + slugify, +} from "./markdown"; +import { parseCard, parseTopic } from "./parse"; + +const context = { + resolveImage: (path: string) => `resolved:${path}`, +}; + +describe("parseFrontMatter", () => { + it("parses values and lists", () => { + const { data, body } = parseFrontMatter( + '---\nname: LEDs\nsubtitle: "Light: up"\ncompatibility: [a, b]\n---\nBody' + ); + expect(data).toEqual({ + name: "LEDs", + subtitle: "Light: up", + compatibility: ["a", "b"], + }); + expect(body).toEqual("Body"); + }); + + it("handles no front matter", () => { + expect(parseFrontMatter("Body")).toEqual({ data: {}, body: "Body" }); + }); +}); + +describe("slugify", () => { + it("handles umlauts and punctuation", () => { + expect(slugify("Größe & Länge!")).toEqual("groesse-laenge"); + }); +}); + +describe("parseDirectives", () => { + it("parses one or more directives", () => { + expect(parseDirectives("")).toEqual([ + { name: "slug", value: "foo" }, + { name: "compatibility", value: "a, b" }, + ]); + expect(parseDirectives("")).toEqual([ + { name: "details", value: "" }, + ]); + }); + + it("ignores plain comments", () => { + expect(parseDirectives("")).toBeUndefined(); + }); +}); + +describe("markdownToPortableText", () => { + it("converts paragraphs with marks and links", () => { + const blocks = markdownToPortableText( + "Some **bold** and `code` with [a link](https://example.com), [doc](doc:sensors/temp), [short](doc:temp) and [api](api:time.sleep).", + context + ); + expect(blocks).toHaveLength(1); + const block = blocks[0]; + expect(block._type).toEqual("block"); + expect(block.style).toEqual("normal"); + expect(block.markDefs).toEqual([ + { _type: "link", _key: expect.any(String), href: "https://example.com" }, + { + _type: "toolkitInternalLink", + _key: expect.any(String), + section: "sensors", + slug: { _type: "slug", current: "temp" }, + }, + { + _type: "toolkitInternalLink", + _key: expect.any(String), + section: undefined, + slug: { _type: "slug", current: "temp" }, + }, + { _type: "toolkitApiLink", _key: expect.any(String), name: "time.sleep" }, + ]); + const spans = block.children as { text: string; marks: string[] }[]; + expect(spans.map((s) => s.text).join("")).toEqual( + "Some bold and code with a link, doc, short and api." + ); + expect(spans.find((s) => s.text === "bold")!.marks).toEqual(["strong"]); + expect(spans.find((s) => s.text === "code")!.marks).toEqual(["code"]); + }); + + it("converts python code to code embeds", () => { + expect(markdownToPortableText("```python\nprint(1)\n```", context)).toEqual( + [{ _type: "python", _key: expect.any(String), main: "print(1)" }] + ); + }); + + it("converts images", () => { + expect(markdownToPortableText("![Alt text](img.png)", context)).toEqual([ + { + _type: "simpleImage", + _key: expect.any(String), + alt: "Alt text", + src: "resolved:img.png", + }, + ]); + }); + + it("converts lists and headings", () => { + const blocks = markdownToPortableText( + "### Title\n\n1. One\n2. Two", + context + ); + expect(blocks.map((b) => [b.style, b.listItem])).toEqual([ + ["h3", undefined], + ["normal", "number"], + ["normal", "number"], + ]); + }); + + it("decodes entities", () => { + const blocks = markdownToPortableText("a < b & c", context); + expect(blocks[0].children[0].text).toEqual("a < b & c"); + }); +}); + +describe("parseTopic", () => { + const source = `--- +name: Loops +subtitle: Repeat things +image: loops.png +--- +Intro text. + +## While loop + +Runs forever. + +\`\`\`python +while True: + pass +\`\`\` + + +More detail. + +## Counting + + +### Three +\`\`\`python +for i in range(3): pass +\`\`\` + +### Ten + +\`\`\`python +for i in range(10): pass +\`\`\` +`; + + it("parses topic, entries, alternatives and details", () => { + const topic = parseTopic(source, "loops", context); + expect(topic.name).toEqual("Loops"); + expect(topic.slug.current).toEqual("loops"); + expect(topic.subtitle).toEqual("Repeat things"); + expect(topic.image).toEqual({ + _type: "simpleImage", + src: "resolved:loops.png", + alt: undefined, + }); + expect(topic.introduction).toHaveLength(1); + + const [whileLoop, counting] = topic.contents!; + expect(whileLoop.name).toEqual("While loop"); + expect(whileLoop.slug.current).toEqual("while"); + expect(whileLoop.compatibility).toEqual(["microbitV2"]); + expect(whileLoop.parent).toBe(topic); + expect(whileLoop.content!.map((b) => b._type)).toEqual(["block", "python"]); + expect(whileLoop.detailContent).toHaveLength(1); + + expect(counting.slug.current).toEqual("counting"); + expect(counting.content).toBeUndefined(); + expect(counting.alternativesLabel).toEqual("Count"); + expect(counting.alternatives!.map((a) => [a.name, a.slug.current])).toEqual( + [ + ["Three", "three"], + ["Ten", "ten"], + ] + ); + expect(counting.alternatives![1].content[0]._type).toEqual("python"); + }); + + it("makes entry slugs unique within a section", () => { + const used = new Set(); + parseTopic("## Example", "a", context, used); + const b = parseTopic("## Example", "b", context, used); + expect(b.contents![0].slug.current).toEqual("b-example"); + }); +}); + +describe("parseCard", () => { + it("parses front matter and content", () => { + const card = parseCard( + "---\nname: Weather\nimage: w.png\n---\nBuild it.", + "weather", + "de", + context + ); + expect(card.name).toEqual("Weather"); + expect(card.slug.current).toEqual("weather"); + expect(card.image?.src).toEqual("resolved:w.png"); + expect(card.content).toHaveLength(1); + }); +}); + +describe("adaptMapping", () => { + it("supports section/slug strings and objects", () => { + expect( + adaptMapping({ + time: { + sleep: "basics/waiting", + monotonic: { reference: "timing", alternative: "ms" }, + }, + }) + ).toEqual({ + time: { + sleep: { section: "basics", referenceLink: "waiting" }, + monotonic: { + section: undefined, + referenceLink: "timing", + alternative: "ms", + }, + }, + }); + }); +}); diff --git a/src/documentation/sections/markdown.ts b/src/documentation/sections/markdown.ts new file mode 100644 index 000000000..217f30cb0 --- /dev/null +++ b/src/documentation/sections/markdown.ts @@ -0,0 +1,326 @@ +/** + * Converts Markdown documentation content to Portable Text. + * + * See docs/content.md for the supported syntax. + * + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { marked, Token, Tokens } from "marked"; +import { + PortableText, + PortableTextBlock, + SimpleImage, +} from "../../common/portable-text"; + +export interface MarkdownContext { + /** + * Resolves an image path as written in Markdown to a URL. + */ + resolveImage: (path: string) => string; +} + +export interface FrontMatter { + [key: string]: string | string[] | undefined; +} + +/** + * Splits YAML-style front matter from the document. + * + * Only the subset we need is supported: `key: value` and + * `key: [a, b]` lines. + */ +export const parseFrontMatter = ( + source: string +): { data: FrontMatter; body: string } => { + const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(source); + if (!match) { + return { data: {}, body: source }; + } + const data: FrontMatter = {}; + for (const line of match[1].split(/\r?\n/)) { + const kv = /^\s*([A-Za-z0-9_-]+)\s*:\s*(.*?)\s*$/.exec(line); + if (!kv) { + continue; + } + const [, key, value] = kv; + const list = /^\[(.*)\]$/.exec(value); + data[key] = list + ? list[1] + .split(",") + .map((v) => unquote(v.trim())) + .filter(Boolean) + : unquote(value); + } + return { data, body: source.slice(match[0].length) }; +}; + +const unquote = (value: string): string => + /^(["']).*\1$/.test(value) ? value.slice(1, -1) : value; + +const umlauts: Record = { + ä: "ae", + ö: "oe", + ü: "ue", + ß: "ss", +}; + +export const slugify = (text: string): string => + text + .toLowerCase() + .replace(/[äöüß]/g, (c) => umlauts[c]) + .normalize("NFKD") + .replace(/[̀-ͯ]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + +/** + * A directive in an HTML comment, e.g. ``. + */ +export interface Directive { + name: string; + value: string; +} + +export const parseDirectives = (html: string): Directive[] | undefined => { + const comment = /^\s*\s*$/.exec(html); + if (!comment || !comment[1].includes("@")) { + return undefined; + } + const directives: Directive[] = []; + const re = /@([A-Za-z]+)(?:\s*:?\s*([^@]*))?/g; + let m: RegExpExecArray | null; + while ((m = re.exec(comment[1])) !== null) { + directives.push({ name: m[1], value: (m[2] ?? "").trim() }); + } + return directives; +}; + +export const lex = (markdown: string): Token[] => marked.lexer(markdown); + +class KeyGenerator { + private next = 0; + key(): string { + return `k${(this.next++).toString(36)}`; + } +} + +interface Span { + _type: "span"; + _key: string; + text: string; + marks: string[]; +} + +interface InlineResult { + spans: Span[]; + markDefs: any[]; + images: SimpleImage[]; +} + +/** + * Converts block-level Markdown tokens to Portable Text. + */ +export const tokensToPortableText = ( + tokens: Token[], + context: MarkdownContext +): PortableText => { + const keys = new KeyGenerator(); + const result: PortableText = []; + + const inline = (tokens: Token[] | undefined, marks: string[]) => { + const out: InlineResult = { spans: [], markDefs: [], images: [] }; + const walk = (tokens: Token[] | undefined, marks: string[]) => { + for (const token of tokens ?? []) { + switch (token.type) { + case "strong": + walk((token as Tokens.Strong).tokens, [...marks, "strong"]); + break; + case "em": + walk((token as Tokens.Em).tokens, [...marks, "em"]); + break; + case "del": + walk((token as Tokens.Del).tokens, [...marks, "strike-through"]); + break; + case "codespan": + out.spans.push( + span((token as Tokens.Codespan).text, [...marks, "code"]) + ); + break; + case "br": + out.spans.push(span("\n", marks)); + break; + case "link": { + const link = token as Tokens.Link; + const markDef = linkMarkDef(link.href, keys.key()); + out.markDefs.push(markDef); + walk(link.tokens, [...marks, markDef._key]); + break; + } + case "image": { + const image = token as Tokens.Image; + out.images.push({ + _type: "simpleImage", + alt: image.text, + src: context.resolveImage(image.href), + }); + break; + } + case "text": { + const text = token as Tokens.Text; + if (text.tokens && text.tokens.length > 0) { + walk(text.tokens, marks); + } else { + out.spans.push(span(decodeEntities(text.text), marks)); + } + break; + } + case "escape": + out.spans.push(span((token as Tokens.Escape).text, marks)); + break; + case "html": + // Inline HTML is not supported. + break; + default: + if ("text" in token && typeof token.text === "string") { + out.spans.push(span(token.text, marks)); + } + } + } + }; + walk(tokens, marks); + return out; + }; + + const span = (text: string, marks: string[]): Span => ({ + _type: "span", + _key: keys.key(), + text, + marks, + }); + + const pushTextBlock = ( + tokens: Token[] | undefined, + style: string, + list?: { listItem: "bullet" | "number"; level: number } + ) => { + const { spans, markDefs, images } = inline(tokens, []); + if (spans.some((s) => s.text.trim().length > 0)) { + const block: PortableTextBlock = { + _type: "block", + _key: keys.key(), + style, + markDefs, + children: spans, + ...list, + }; + result.push(block); + } + for (const image of images) { + result.push({ ...image, _key: keys.key() }); + } + }; + + const pushList = (list: Tokens.List, level: number) => { + const listItem = list.ordered ? "number" : "bullet"; + for (const item of list.items) { + const inlineTokens: Token[] = []; + const nested: Tokens.List[] = []; + for (const child of item.tokens) { + if (child.type === "list") { + nested.push(child as Tokens.List); + } else if (child.type === "paragraph" || child.type === "text") { + const t = child as Tokens.Paragraph | Tokens.Text; + inlineTokens.push( + ...(t.tokens && t.tokens.length > 0 ? t.tokens : [child]) + ); + } + } + pushTextBlock(inlineTokens, "normal", { listItem, level }); + nested.forEach((n) => pushList(n, level + 1)); + } + }; + + for (const token of tokens) { + switch (token.type) { + case "heading": { + const heading = token as Tokens.Heading; + pushTextBlock(heading.tokens, `h${Math.max(3, heading.depth)}`); + break; + } + case "paragraph": + pushTextBlock((token as Tokens.Paragraph).tokens, "normal"); + break; + case "text": + pushTextBlock((token as Tokens.Text).tokens ?? [token], "normal"); + break; + case "blockquote": + for (const child of (token as Tokens.Blockquote).tokens) { + if (child.type === "paragraph") { + pushTextBlock((child as Tokens.Paragraph).tokens, "blockquote"); + } + } + break; + case "list": + pushList(token as Tokens.List, 1); + break; + case "code": { + const code = token as Tokens.Code; + const lang = (code.lang ?? "").trim().toLowerCase(); + if (lang === "" || lang === "python" || lang === "py") { + result.push({ _type: "python", _key: keys.key(), main: code.text }); + } else { + result.push({ + _type: "block", + _key: keys.key(), + style: "normal", + markDefs: [], + children: [span(code.text, ["code"])], + }); + } + break; + } + // Directives, comments, spacing and unsupported blocks. + default: + break; + } + } + return result; +}; + +export const markdownToPortableText = ( + markdown: string, + context: MarkdownContext +): PortableText => tokensToPortableText(lex(markdown), context); + +const linkMarkDef = (href: string, key: string) => { + // Internal documentation links: doc:section/slug or doc:slug + const doc = /^doc:(.+)$/.exec(href); + if (doc) { + const parts = doc[1].split("/").filter(Boolean); + const [section, slug] = parts.length > 1 ? parts : [undefined, parts[0]]; + return { + _type: "toolkitInternalLink", + _key: key, + section, + slug: { _type: "slug", current: slug }, + }; + } + const api = /^api:(.+)$/.exec(href); + if (api) { + return { _type: "toolkitApiLink", _key: key, name: api[1] }; + } + return { _type: "link", _key: key, href }; +}; + +const entities: Record = { + "&": "&", + "<": "<", + ">": ">", + """: '"', + "'": "'", +}; + +const decodeEntities = (text: string): string => + text.replace(/&(amp|lt|gt|quot|#39);/g, (e) => entities[e]); diff --git a/src/documentation/sections/parse.ts b/src/documentation/sections/parse.ts new file mode 100644 index 000000000..32218442c --- /dev/null +++ b/src/documentation/sections/parse.ts @@ -0,0 +1,247 @@ +/** + * Parses Markdown topic and card files into the documentation models. + * + * (c) 2026, Micro:bit Educational Foundation and contributors + * + * SPDX-License-Identifier: MIT + */ +import { Token, Tokens } from "marked"; +import { SimpleImage } from "../../common/portable-text"; +import { HasCompatibility } from "../common/model"; +import { Idea } from "../ideas/model"; +import { ToolkitTopic, ToolkitTopicEntry } from "../reference/model"; +import { + FrontMatter, + lex, + MarkdownContext, + parseDirectives, + parseFrontMatter, + slugify, + tokensToPortableText, +} from "./markdown"; + +const slug = (current: string) => ({ _type: "slug" as const, current }); + +const asString = (value: string | string[] | undefined): string | undefined => + Array.isArray(value) ? value.join(", ") : value; + +const compatibility = ( + value: string | string[] | undefined +): HasCompatibility["compatibility"] => { + const list = Array.isArray(value) + ? value + : (value ?? "").split(/[\s,]+/).filter(Boolean); + return list as HasCompatibility["compatibility"]; +}; + +const image = ( + data: FrontMatter, + context: MarkdownContext +): SimpleImage | undefined => { + const src = asString(data.image); + return src + ? { + _type: "simpleImage", + src: context.resolveImage(src), + alt: asString(data.imageAlt), + } + : undefined; +}; + +interface EntryDraft { + name: string; + slug?: string; + compatibility?: string; + content: Token[]; + alternativesLabel?: string; + alternatives: { name: string; slug?: string; tokens: Token[] }[]; + details: Token[]; +} + +/** + * Parses a topic file for a "reference" layout section. + * + * - Front matter: name, slug, subtitle, image, compatibility. + * - Text before the first `##` heading is the introduction. + * - Each `##` heading starts an entry. + * - `` and `` configure the + * current entry (or alternative). + * - `` makes following `###` headings + * alternatives selectable from a dropdown. + * - `` starts content shown only when expanded. + * + * @param source The Markdown source. + * @param fileName The file name without extension, the default slug. + * @param context For resolving images. + * @param usedSlugs Slugs already in use in the section, updated. + */ +export const parseTopic = ( + source: string, + fileName: string, + context: MarkdownContext, + usedSlugs: Set = new Set() +): ToolkitTopic => { + const { data, body } = parseFrontMatter(source); + const topicSlug = asString(data.slug) || fileName; + usedSlugs.add(topicSlug); + + const intro: Token[] = []; + const drafts: EntryDraft[] = []; + let mode: "content" | "alternatives" | "details" = "content"; + + const current = () => drafts[drafts.length - 1]; + const bucket = (): Token[] => { + const entry = current(); + if (!entry) { + return intro; + } + if (mode === "details") { + return entry.details; + } + if (mode === "alternatives" && entry.alternatives.length > 0) { + return entry.alternatives[entry.alternatives.length - 1].tokens; + } + return entry.content; + }; + + for (const token of lex(body)) { + if (token.type === "space") { + continue; + } + if (token.type === "heading") { + const heading = token as Tokens.Heading; + if (heading.depth <= 2) { + drafts.push({ + name: heading.text, + content: [], + alternatives: [], + details: [], + }); + mode = "content"; + continue; + } + if (heading.depth === 3 && mode === "alternatives" && current()) { + current().alternatives.push({ name: heading.text, tokens: [] }); + continue; + } + } + if (token.type === "html") { + const directives = parseDirectives((token as Tokens.HTML).text); + if (directives) { + for (const { name, value } of directives) { + const entry = current(); + if (!entry) { + continue; + } + switch (name) { + case "slug": { + const alternative = + mode === "alternatives" + ? entry.alternatives[entry.alternatives.length - 1] + : undefined; + if (alternative) { + alternative.slug = value; + } else { + entry.slug = value; + } + break; + } + case "compatibility": + entry.compatibility = value; + break; + case "alternatives": + mode = "alternatives"; + entry.alternativesLabel = value; + break; + case "details": + mode = "details"; + break; + default: + console.warn(`Unknown documentation directive @${name}`); + } + } + continue; + } + } + bucket().push(token); + } + + const topic: ToolkitTopic = { + name: asString(data.name) || fileName, + subtitle: asString(data.subtitle) ?? "", + slug: slug(topicSlug), + image: image(data, context), + compatibility: compatibility(data.compatibility), + introduction: intro.length + ? tokensToPortableText(intro, context) + : undefined, + contents: [], + }; + topic.contents = drafts.map( + (draft): ToolkitTopicEntry => ({ + name: draft.name, + slug: slug( + uniqueSlug(draft.slug || slugify(draft.name), topicSlug, usedSlugs) + ), + compatibility: compatibility(draft.compatibility), + content: draft.content.length + ? tokensToPortableText(draft.content, context) + : undefined, + alternativesLabel: draft.alternativesLabel, + alternatives: + draft.alternatives.length > 0 + ? draft.alternatives.map((a) => ({ + name: a.name, + slug: slug(a.slug || slugify(a.name)), + content: tokensToPortableText(a.tokens, context), + })) + : undefined, + detailContent: draft.details.length + ? tokensToPortableText(draft.details, context) + : undefined, + parent: topic, + }) + ); + return topic; +}; + +const uniqueSlug = ( + candidate: string, + topicSlug: string, + usedSlugs: Set +): string => { + let result = candidate; + if (usedSlugs.has(result)) { + result = `${topicSlug}-${candidate}`; + let n = 2; + while (usedSlugs.has(result)) { + result = `${topicSlug}-${candidate}-${n++}`; + } + } + usedSlugs.add(result); + return result; +}; + +/** + * Parses a card file for a "cards" layout section. + * + * Front matter: name, slug, image, compatibility. The body is the content. + */ +export const parseCard = ( + source: string, + fileName: string, + languageId: string, + context: MarkdownContext +): Idea => { + const { data, body } = parseFrontMatter(source); + const cardSlug = asString(data.slug) || fileName; + return { + _id: cardSlug, + name: asString(data.name) || fileName, + slug: slug(cardSlug), + language: languageId, + image: image(data, context), + compatibility: compatibility(data.compatibility), + content: tokensToPortableText(lex(body), context), + }; +}; diff --git a/src/editor/codemirror/language-server/documentation.ts b/src/editor/codemirror/language-server/documentation.ts index 34c8275b1..e8976db3a 100644 --- a/src/editor/codemirror/language-server/documentation.ts +++ b/src/editor/codemirror/language-server/documentation.ts @@ -9,6 +9,7 @@ import { IntlShape } from "react-intl"; import { MarkupContent } from "vscode-languageserver-types"; import { moduleAndApiFromId } from "../../../documentation/api/apidocs-util"; import { ApiReferenceMap } from "../../../documentation/mapping/content"; +import { docsSections } from "../../../documentation/sections/config"; import { splitDocString } from "./docstrings"; import "./documentation.css"; @@ -121,7 +122,7 @@ export const wrapWithDocumentationButton = ( intl: IntlShape, child: Element, id: string, - referenceLink: string | undefined + referenceLink: ReferenceLink | undefined ): Element => { const docsAndActions = document.createElement("div"); docsAndActions.style.display = "flex"; @@ -146,8 +147,8 @@ export const wrapWithDocumentationButton = ( document.dispatchEvent( new CustomEvent("cm/openDocs", { detail: { - tab: "reference", - id: referenceLink, + tab: referenceLink.tab, + id: referenceLink.id, }, }) ); @@ -184,20 +185,32 @@ export const wrapWithDocumentationButton = ( return docsAndActions; }; +export interface ReferenceLink { + tab: string; + id: string; +} + export const getLinkToReference = ( id: string, apiReferenceMap: ApiReferenceMap -): string | undefined => { +): ReferenceLink | undefined => { const { pythonModuleName, apiId } = moduleAndApiFromId(id); if (!pythonModuleName && !apiId) { return; } - let referenceLink = apiReferenceMap[pythonModuleName]?.[apiId]?.referenceLink; - const alternative = apiReferenceMap[pythonModuleName]?.[apiId]?.alternative; - if (referenceLink && alternative) { - referenceLink = `${encodeURIComponent(referenceLink)}/${encodeURIComponent( - alternative - )}`; + const detail = apiReferenceMap[pythonModuleName]?.[apiId]; + const tab = + detail?.section ?? docsSections.find((s) => s.layout === "reference")?.id; + if (!detail || !tab) { + return; } - return referenceLink; + const { referenceLink, alternative } = detail; + return { + tab, + id: alternative + ? `${encodeURIComponent(referenceLink)}/${encodeURIComponent( + alternative + )}` + : referenceLink, + }; }; diff --git a/src/external-links.ts b/src/external-links.ts index f5e7dcfe2..fa76830ce 100644 --- a/src/external-links.ts +++ b/src/external-links.ts @@ -23,8 +23,3 @@ const langPath = (languageId: string) => { export const microbitOrgUrl = (languageId: string) => `https://microbit.org/${langPath(languageId)}`; - -export const microbitOrgMiciProjectsUrl = (languageId: string) => - `https://microbit.org/${langPath( - languageId - )}projects/make-it-code-it/?filters=python`; diff --git a/src/flags.ts b/src/flags.ts index 55b1e344c..b4e6508b5 100644 --- a/src/flags.ts +++ b/src/flags.ts @@ -20,10 +20,6 @@ export type Flag = * Flag to add a beta notice. Enabled for staging site but not production stages. */ | "betaNotice" - /** - * Uses CMS content from an alternative preview dataset. - */ - | "cmsPreview" /** * Enables verbose debug logging to the console of drag events. */ @@ -62,7 +58,6 @@ interface FlagMetadata { const allFlags: FlagMetadata[] = [ // Alphabetical order. { name: "betaNotice", defaultOnStages: ["local", "REVIEW", "STAGING"] }, - { name: "cmsPreview", defaultOnStages: [] }, { name: "dndDebug", defaultOnStages: [] }, { name: "noLang", defaultOnStages: [] }, { name: "translate", defaultOnStages: [] }, diff --git a/src/router-hooks.test.tsx b/src/router-hooks.test.tsx index cff888e3a..e961a09ec 100644 --- a/src/router-hooks.test.tsx +++ b/src/router-hooks.test.tsx @@ -4,6 +4,7 @@ * SPDX-License-Identifier: MIT */ import { act, render } from "@testing-library/react"; +import { vi } from "vitest"; import { useEffect } from "react"; import { createMemoryRouter, RouterProvider } from "react-router"; import { LoggingProvider } from "./logging/logging-hooks"; @@ -11,6 +12,10 @@ import { MockLogging } from "./logging/mock"; import { NavigationSource, RouterState, useRouterState } from "./router-hooks"; import { editorRoutePath } from "./urls"; +vi.mock("./documentation/sections/config", () => ({ + docsSections: [{ id: "reference" }, { id: "ideas" }], +})); + const result: { current?: ReturnType } = {}; const state = (): RouterState => result.current![0]; const setState = (state: RouterState, source?: NavigationSource) => diff --git a/src/router-hooks.tsx b/src/router-hooks.tsx index aa728b435..cd22ad5d2 100644 --- a/src/router-hooks.tsx +++ b/src/router-hooks.tsx @@ -11,12 +11,21 @@ */ import { useCallback, useMemo } from "react"; import { useLocation, useNavigate, useParams } from "react-router"; +import { docsSections } from "./documentation/sections/config"; import { useLogging } from "./logging/logging-hooks"; import { createEditorUrl } from "./urls"; -export type TabName = "api" | "ideas" | "reference" | "project"; +/** + * "api", "project" or the id of a documentation section from + * content/sidebar.json. + */ +export type TabName = string; -const tabNames: readonly string[] = ["api", "ideas", "reference", "project"]; +const tabNames: readonly string[] = [ + "api", + "project", + ...docsSections.map((s) => s.id), +]; const isTabName = (value: string | undefined): value is TabName => value !== undefined && tabNames.includes(value); diff --git a/src/setupTests.ts b/src/setupTests.ts index 4512d8e28..6ffda59f9 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -13,9 +13,6 @@ afterEach(() => { cleanup(); }); -vi.stubEnv("VITE_SANITY_PROJECT", "project"); -vi.stubEnv("VITE_SANITY_DATASET", "dataset"); - globalThis.matchMedia = globalThis.matchMedia || function () { diff --git a/src/simulator/SimulatorModules.tsx b/src/simulator/SimulatorModules.tsx index c775b7dad..0d8e702a9 100644 --- a/src/simulator/SimulatorModules.tsx +++ b/src/simulator/SimulatorModules.tsx @@ -16,6 +16,8 @@ import { SimulatorState, StateChange, } from "../device/simulator"; +import { useDocumentation } from "../documentation/documentation-hooks"; +import { findSectionForSlug } from "../documentation/sections/content"; import { useRouterState } from "../router-hooks"; import ButtonsModule from "./ButtonsModule"; import CompassModule from "./CompassModule"; @@ -168,16 +170,21 @@ const CollapsibleModule = ({ const [isOpen, setOpen] = useState(false); const intl = useIntl(); const [, setRouterState] = useRouterState(); + const { sections } = useDocumentation(); + const referenceSection = + sections.status === "ok" + ? findSectionForSlug(sections.content, references[id]) + : undefined; const handleLinkToReference = useCallback(() => { setRouterState( { - tab: "reference", + tab: referenceSection, slug: { id: references[id] }, focus: true, }, "simulator" ); - }, [id, setRouterState]); + }, [id, referenceSection, setRouterState]); const module = ( {title} - - - + {referenceSection && ( + + + + )} )} {!isOpen && {module}} diff --git a/src/vite.d.ts b/src/vite.d.ts index 6295d64a2..91d3b1708 100644 --- a/src/vite.d.ts +++ b/src/vite.d.ts @@ -4,9 +4,6 @@ interface ImportMetaEnv { readonly VITE_VERSION: string; readonly VITE_STAGE: string; - readonly VITE_SANITY_PROJECT: string; - readonly VITE_SANITY_DATASET: string; - readonly VITE_SANITY_PREVIEW_DATASET: string; } interface ImportMeta { diff --git a/src/workbench/SideBar.tsx b/src/workbench/SideBar.tsx index eeb15d675..25bd3ef61 100644 --- a/src/workbench/SideBar.tsx +++ b/src/workbench/SideBar.tsx @@ -7,8 +7,7 @@ import { usePrevious } from "@microbit/ui"; import { ReactNode, useCallback, useEffect, useMemo, useRef } from "react"; import { TabList, TabPanel, TabPanels, Tabs } from "react-aria-components"; import { IconType } from "react-icons"; -import { RiLightbulbFlashLine } from "react-icons/ri"; -import { VscFiles, VscLibrary } from "react-icons/vsc"; +import { VscFiles } from "react-icons/vsc"; import { useIntl } from "react-intl"; import { css } from "styled-system/css"; import { Box, Flex, styled, VStack } from "styled-system/jsx"; @@ -16,11 +15,13 @@ import { SystemStyleObject } from "styled-system/types"; import ErrorBoundary from "../common/ErrorBoundary"; import PythonLogo from "../common/PythonLogo"; import ApiArea from "../documentation/ApiArea"; -import IdeasArea from "../documentation/IdeasArea"; -import ReferenceArea from "../documentation/ReferenceArea"; +import DocsSectionArea from "../documentation/DocsSectionArea"; +import { localize, sidebarConfig } from "../documentation/sections/config"; +import { sectionIcon } from "../documentation/sections/icons"; import { flags } from "../flags"; import ProjectArea from "../project/ProjectArea"; import { TabName, useRouterState } from "../router-hooks"; +import { useSettings } from "../settings/settings"; import SettingsMenu from "../settings/SettingsMenu"; import HelpMenu from "./HelpMenu"; import PreReleaseNotice, { useReleaseDialogState } from "./PreReleaseNotice"; @@ -69,32 +70,34 @@ const SideBar = ({ }: SideBarProps) => { const intl = useIntl(); const [releaseDialog, setReleaseDialog] = useReleaseDialogState(); + const [{ languageId }] = useSettings(); const panes: Pane[] = useMemo(() => { - const result = [ - { - id: "reference" as const, - title: intl.formatMessage({ id: "reference-tab" }), - icon: VscLibrary, - contents: , - color: "gray.75", - }, - { - id: "ideas" as const, - title: intl.formatMessage({ id: "ideas-tab" }), - icon: RiLightbulbFlashLine, - contents: , - color: "gray.75", - }, - { - id: "api" as const, - title: intl.formatMessage({ id: "api-tab" }), - icon: PythonLogo as IconType, - contents: , - color: "gray.75", - mb: "auto", - }, + // Documentation tabs come from content/sidebar.json. + const docsPanes: Pane[] = sidebarConfig.entries.map((entry) => + entry.kind === "builtin" + ? { + id: "api", + title: intl.formatMessage({ id: "api-tab" }), + icon: PythonLogo as IconType, + contents: , + color: "gray.75", + } + : { + id: entry.id, + title: localize(entry.title, languageId), + icon: sectionIcon(entry.icon), + contents: , + color: "gray.75", + } + ); + if (docsPanes.length > 0) { + // Pushes the project tab to the bottom. + docsPanes[docsPanes.length - 1].mb = "auto"; + } + const result: Pane[] = [ + ...docsPanes, { - id: "project" as const, + id: "project", title: intl.formatMessage({ id: "project-tab" }), icon: VscFiles, contents: ( @@ -107,7 +110,7 @@ const SideBar = ({ }, ]; return result; - }, [onSelectedFileChanged, selectedFile, intl]); + }, [onSelectedFileChanged, selectedFile, intl, languageId]); const [{ tab, slug, focus }, setParams] = useRouterState(); const tabPanelsRef = useRef(null); const setPanelFocus = () => { diff --git a/vite.config.ts b/vite.config.ts index 4e1166ea7..030a9d661 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -142,36 +142,6 @@ export default defineConfig(({ mode }) => { maximumFileSizeToCacheInBytes: 3097152, globPatterns: ["**/*.{js,css,html,ico,png,svg,gif,hex}"], runtimeCaching: [ - { - urlPattern: new RegExp( - `^https://${process.env.VITE_SANITY_PROJECT}.apicdn.sanity.io/.*` - ), - handler: "NetworkFirst", - options: { - cacheName: `${pwaCacheId}-sanity-content-cache`, - expiration: { - maxEntries: 40, - }, - cacheableResponse: { - statuses: [0, 200], - }, - }, - }, - { - urlPattern: new RegExp( - `^https://cdn.sanity.io/images/${process.env.VITE_SANITY_PROJECT}/${process.env.VITE_SANITY_DATASET}/.*` - ), - handler: "CacheFirst", - options: { - cacheName: `${pwaCacheId}-sanity-images-cache`, - expiration: { - maxEntries: 100, - }, - cacheableResponse: { - statuses: [0, 200], - }, - }, - }, { urlPattern: /^https:\/\/fonts.microbit.org\/.*/, handler: "CacheFirst",