diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte index 2d99b20..bf14afd 100644 --- a/frontend/src/App.svelte +++ b/frontend/src/App.svelte @@ -17,7 +17,12 @@ SearchResponse, } from './lib/types'; import { getJson, queryString } from './lib/api'; - import { currentRoute, onRouteChange, navigate } from './lib/router'; + import { + currentRoute, + navigate, + onRouteChange, + scrollToTopOnNavigation, + } from './lib/router'; import { language, pick, setLanguage, tr, type Language } from './lib/i18n'; import { theme, toggleTheme } from './lib/theme'; import { untrack } from 'svelte'; @@ -134,10 +139,15 @@ $effect(() => { void loadShared(); + // A new page starts at the top; going back keeps the position. + const stopNavigation = scrollToTopOnNavigation(); const stop = onRouteChange(() => { route = currentRoute(); }); - return stop; + return () => { + stop(); + stopNavigation(); + }; }); $effect(() => { diff --git a/frontend/src/app.css b/frontend/src/app.css index 52545eb..163bff8 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -653,6 +653,44 @@ button.ghost:hover:not(:disabled) { overflow-wrap: anywhere; } +/* + * A card on the interface index shows what a crate is, in one or two lines. + * + * The cards there are in a grid with three columns on a wide screen, so a + * single long summary made every card in its row as tall as the longest one. + * The text is cut rather than the card stretched, because the rest of the + * summary is on the page the card leads to. + */ +.crate-grid .card-clamp { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + line-clamp: 2; + overflow: hidden; + margin: 0.4rem 0 0; + color: var(--text-muted); + font-size: 0.9rem; +} + +/* A layer heading on the interface index. */ +.api-layer-heading { + display: flex; + align-items: baseline; + gap: 0.5rem; + margin: 1.75rem 0 0; + font-size: 1.05rem; +} + +.api-layer-heading a { + color: var(--text); +} + +.api-layer-summary { + margin: 0.25rem 0 0; + color: var(--text-muted); + font-size: 0.9rem; +} + .grid { display: grid; gap: 1rem; diff --git a/frontend/src/lib/ResultView.svelte b/frontend/src/lib/ResultView.svelte index 93bc6d9..c0eb22f 100644 --- a/frontend/src/lib/ResultView.svelte +++ b/frontend/src/lib/ResultView.svelte @@ -30,7 +30,9 @@
{pick(scalar.label, lang)} - {scalar.value}{#if scalar.unit}{scalar.unit}{/if} + {scalar.value}{#if scalar.unit}{#if scalar.unit_html}{@html scalar.unit_html}{:else}{scalar.unit}{/if}{/if}
{/each} @@ -48,7 +50,9 @@ {#each result.table.columns as column} - {pick(column.label, lang)}{#if column.unit} [{column.unit}]{/if} + {pick(column.label, lang)}{#if column.unit} [{#if column.unit_html}{@html column.unit_html}{:else}{column.unit}{/if}]{/if} {/each} @@ -58,7 +62,18 @@ {#each row as cell, index} - {formatCell(cell, result.table.columns[index]?.numeric ?? false)} + {#if typeof cell === 'object' && cell !== null && cell.html} + + {@html cell.html} + {:else} + {formatCell( + cell, + result.table.columns[index]?.numeric ?? false, + lang, + )} + {/if} {/each} @@ -69,9 +84,11 @@ {/if} {#if result.notes.length > 0} -
- {#each result.notes as note} -

{pick(note, lang)}

+
+ {#each result.notes as note, index (index)} + + {@html pick(result.notes_html?.[index] ?? { zh: note.zh, en: note.en }, lang)} {/each}
{/if} diff --git a/frontend/src/lib/format.ts b/frontend/src/lib/format.ts index 1e82428..c2f7e7c 100644 --- a/frontend/src/lib/format.ts +++ b/frontend/src/lib/format.ts @@ -5,6 +5,9 @@ * number is written the same way everywhere. */ +import type { Bilingual } from './types'; +import { pick, type Language } from './i18n'; + /** Formats a number with a sensible number of decimals for its magnitude. */ export function formatNumber(value: number | null | undefined): string { if (value === null || value === undefined || !Number.isFinite(value)) { @@ -21,11 +24,21 @@ export function formatNumber(value: number | null | undefined): string { } /** Formats a table cell according to the column type. */ -export function formatCell(value: string | number | null, numeric: boolean): string { +export function formatCell( + value: string | number | Bilingual | null, + numeric: boolean, + lang: Language, +): string { if (value === null || value === undefined) return '—'; if (typeof value === 'number') { return numeric ? formatNumber(value) : String(value); } + if (typeof value === 'object') { + // A text cell may carry a translation of a fixed phrase, such as the + // meaning of a field. A cell holding data rather than prose carries the + // same string in both languages and needs no lookup. + return pick(value, lang); + } return value; } diff --git a/frontend/src/lib/router.ts b/frontend/src/lib/router.ts index 944688d..3cb88d9 100644 --- a/frontend/src/lib/router.ts +++ b/frontend/src/lib/router.ts @@ -60,6 +60,43 @@ export function onRouteChange(listener: () => void): () => void { return () => window.removeEventListener('hashchange', listener); } +/** + * Returns the page to the top when the route changes. + * + * A hash change does not move the scroll position, so a reader who has + * scrolled to the bottom of a long document and follows a link lands in the + * middle of the next page, with nothing to show that it is a different one. + * + * The scroll follows the path rather than the fragment, so that a search or a + * filter applied to the page a reader is already on leaves them where they + * are. Only a move to another page starts at the top. + * + * Going back also starts at the top rather than where the reader left. The + * browser would restore that position itself, but it cannot be told apart + * from an ordinary link: a fragment navigation creates a history entry, and + * the browser fires `popstate` for it as well as for a traversal, so there is + * no event that means "this was a traversal" on its own. Restoring the + * position by watching the path would mean keeping a history of positions, + * which is more machinery than the gain is worth here. + */ +export function scrollToTopOnNavigation(): () => void { + let previous: string | null = null; + + const onChange = () => { + const path = window.location.hash.split('?')[0]; + if (path === previous) return; + previous = path; + window.scrollTo(0, 0); + }; + + // The first page is already at the top, but the position is recorded so + // that moving away and back is treated as two changes rather than one. + previous = window.location.hash.split('?')[0]; + + window.addEventListener('hashchange', onChange); + return () => window.removeEventListener('hashchange', onChange); +} + /** * Intercepts clicks on internal links so that the router handles them. * diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index e229651..e0b947c 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -202,6 +202,11 @@ export interface BilingualSummary { en: string; plain: Bilingual; html: Bilingual; + /** + * The opening sentence, for a card that links onward. The rest of the + * summary belongs on the page the card leads to. + */ + brief?: Bilingual; } /** One interface item. */ @@ -214,11 +219,20 @@ export interface ApiItem { associated: boolean; } +/** One layer of the library, used to organize the interface index. */ +export interface ApiLayer { + id: string; + title: Bilingual; + summary: Bilingual; +} + /** The interface catalog index. */ export interface ApiIndex { generated_from: string[]; crate_count: number; item_count: number; + /** The layers, in reading order, that the crates are grouped by. */ + layers: ApiLayer[]; crates: ApiCrateSummary[]; items: { name: string; kind: string; crate: string }[]; } @@ -244,6 +258,8 @@ export interface ToolParam { name: string; label: Bilingual; help: Bilingual; + /** The help rendered from Markdown, so that a formula in it is set. */ + help_html?: Bilingual; kind: 'number' | 'integer' | 'text' | 'multiline' | 'select'; required: boolean; default: string | null; @@ -256,6 +272,8 @@ export interface Tool { name: Bilingual; summary: Bilingual; description: Bilingual; + /** The description rendered from Markdown, so that a formula in it is set. */ + description_html?: Bilingual; category: Bilingual; params: ToolParam[]; api_items: string[]; @@ -272,13 +290,36 @@ export interface ResultScalar { label: Bilingual; value: string; unit: string; + /** The unit rendered, so that a power in it reads as a power. */ + unit_html?: string; monospace: boolean; } +/** A column of a result table. */ +export interface ResultColumn { + key: string; + label: Bilingual; + unit: string; + /** The unit rendered, so that a power in it reads as a power. */ + unit_html?: string; + numeric: boolean; +} + +/** + * One cell of a result table. + * + * A cell is usually a number. A text cell may carry a translation, because a + * cell holding a fixed phrase, such as the meaning of a field, is written + * once and shown in the reader's language. Such a cell may also carry the + * text rendered, which is how a unit written with a power in it, such as + * `rev/day^2`, is shown as a power. + */ +export type ResultCell = string | number | (Bilingual & { html?: string }) | null; + /** A result table. */ export interface ResultTable { - columns: { key: string; label: Bilingual; unit: string; numeric: boolean }[]; - rows: (string | number | null)[][]; + columns: ResultColumn[]; + rows: ResultCell[][]; } /** A numeric series. */ @@ -296,6 +337,8 @@ export interface ToolResult { table: ResultTable; series: ResultSeries[]; notes: Bilingual[]; + /** The notes rendered from Markdown, which is how a formula in one is set. */ + notes_html?: Bilingual[]; axis?: { name: Bilingual; unit: string; values: number[] }; } diff --git a/frontend/src/pages/Api.svelte b/frontend/src/pages/Api.svelte index 4e777aa..e4d214e 100644 --- a/frontend/src/pages/Api.svelte +++ b/frontend/src/pages/Api.svelte @@ -119,6 +119,35 @@ return names ? pick(names, language) : kind; } + /** + * The crates of the index, grouped by the layer they belong to. + * + * The index is organized by layer for the same reason the concept + * documentation is: a reader looking for a type is looking for the subject + * it belongs to, and one flat list of fifteen crates gives no hint of where + * to look. The layers come from the concept documentation, so the order and + * the titles are the same in both. + * + * A layer the catalog names but the documentation does not declare would + * leave its crates with no heading, so those are collected at the end. + */ + const layers = $derived.by(() => { + const current = index; + if (!current) return []; + const declared = current.layers ?? []; + const grouped = declared + .map((layer) => ({ + ...layer, + crates: current.crates.filter((krate) => krate.layer === layer.id), + })) + .filter((layer) => layer.crates.length > 0); + const placed = new Set(declared.map((layer) => layer.id)); + const rest = current.crates.filter((krate) => !placed.has(krate.layer)); + return rest.length > 0 + ? [...grouped, { id: '', title: { zh: '其他', en: 'Others' }, summary: { zh: '', en: '' }, crates: rest }] + : grouped; + }); + /** Opens an item route. */ function openItem(name: string) { navigate(`/api/${name}`); @@ -263,18 +292,28 @@ {/if} -
- {#each index.crates as candidate} - -
- {candidate.name} - {candidate.layer} - {candidate.items} {tr('api.itemCount', lang)} -
-

{pick(candidate.summary.plain, lang)}

-
- {/each} -
+ {#each layers as layer (layer.id)} +

+ {pick(layer.title, lang)} + {layer.crates.length} +

+ {#if pick(layer.summary, lang)} +

{pick(layer.summary, lang)}

+ {/if} +
+ {#each layer.crates as candidate (candidate.name)} + +
+ {candidate.name} + {candidate.items} {tr('api.itemCount', lang)} +
+

+ {pick(candidate.summary.brief ?? candidate.summary.plain, lang)} +

+
+ {/each} +
+ {/each} {/if}
diff --git a/frontend/src/pages/Tools.svelte b/frontend/src/pages/Tools.svelte index 371f4c0..ebeb2d4 100644 --- a/frontend/src/pages/Tools.svelte +++ b/frontend/src/pages/Tools.svelte @@ -148,7 +148,12 @@

{pick(tool.name, lang)}

{pick(tool.category, lang)} -

{pick(tool.description, lang)}

+ +
+ {@html pick(tool.description_html ?? { zh: tool.description.zh, en: tool.description.en }, lang)} +
{ @@ -185,7 +190,14 @@ {:else} {/if} - {pick(param.help, lang)} + + {@html pick( + param.help_html ?? { zh: param.help.zh, en: param.help.en }, + lang, + )} {#if param.constraints.unit} {tr('tools.unit', lang)}: {param.constraints.unit} {/if} diff --git a/src/api/mod.rs b/src/api/mod.rs index 4581d7a..0436c76 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -274,6 +274,10 @@ impl Router { "generated_from" => catalog.generated_from.iter().map(|path| Value::from(path.as_str())).collect::>(), "crate_count" => catalog.crates.len(), "item_count" => catalog.item_count(), + // The layers the crates belong to, in reading order, so that the + // index is organized the same way as the concept documentation + // rather than as one flat list. + "layers" => api_layers(&self.state.docs), "crates" => crates, "items" => items, }; @@ -566,6 +570,12 @@ fn crate_texts(krate: &crate::docs::ApiCrate, items: &std::collections::HashSet< "zh" => crate_summary_plain(zh), "en" => crate_summary_plain(&krate.summary), }, + // The index card takes the opening sentence; the crate page shows the + // whole summary under `html`. + "brief" => json_object! { + "zh" => crate_summary_brief(zh), + "en" => crate_summary_brief(&krate.summary), + }, "html" => json_object! { "zh" => crate::docs::markdown::render_with(zh, Some(items)), "en" => crate::docs::markdown::render_with(&krate.summary, Some(items)), @@ -581,6 +591,78 @@ fn crate_summary_plain(summary: &str) -> String { crate::docs::markdown::to_plain_text(summary) } +/// Returns the first sentence of a summary, for a card that links onward. +/// +/// Returns the layers of the concept documentation, in reading order. +/// +/// The interface catalog records which layer each crate belongs to. The index +/// groups the crates by that layer, so it needs the layer's title and its +/// place in the reading order, both of which the concept documentation +/// already declares. +fn api_layers(docs: &crate::docs::Tree) -> Value { + let layers: Vec = docs + .groups() + .iter() + .flat_map(|group| group.sections.iter()) + .map(|section| { + json_object! { + "id" => section.id.clone(), + "title" => optional_bilingual(§ion.title_zh, §ion.title), + "summary" => optional_bilingual(§ion.summary_zh, §ion.summary), + } + }) + .collect(); + Value::Array(layers) +} + +/// Returns the first sentence of a summary, for a card that links onward. +/// +/// The crate summary is the whole of the crate documentation, which runs to +/// several paragraphs and a list of what the crate holds. A card on the index +/// shows what a crate is, and the crate page shows the rest, so the card takes +/// the opening sentence only. Without this the index cards are as tall as the +/// longest crate document and the page reads as a wall. +fn crate_summary_brief(summary: &str) -> String { + let text = crate_summary_plain(summary); + let text = text.trim(); + // A sentence ends at the first full stop that is not part of a number or + // an abbreviation, which keeps `1.5` and `e.g.` from cutting a sentence + // in half. + let mut end = None; + for (index, character) in text.char_indices() { + // A Chinese sentence ends at its own full stop and is not followed by + // a space, so those three end a sentence wherever they fall. + if matches!(character, '\u{3002}' | '\u{ff01}' | '\u{ff1f}') { + end = Some(index + character.len_utf8()); + break; + } + if character == '.' { + let before = text[..index].chars().next_back(); + let after = text[index + 1..].chars().next(); + let ends_number = before.is_some_and(|c| c.is_ascii_digit()) + && after.is_some_and(|c| c.is_ascii_digit()); + let next_is_space = after.is_none_or(|c| c.is_whitespace()); + if !ends_number && next_is_space { + end = Some(index + 1); + break; + } + } + } + let first = match end { + Some(index) => &text[..index], + None => text, + }; + let first = first.trim(); + // A summary with no full stop would otherwise put a whole paragraph on a + // card, so the length is capped as well. + if first.chars().count() <= 160 { + return first.to_string(); + } + let mut cut: String = first.chars().take(157).collect(); + cut.push('…'); + cut +} + /// Encodes a document title or summary in both languages. /// /// The two names select the field of a document; an unknown name yields an @@ -1039,4 +1121,32 @@ mod tests { Status::MethodNotAllowed ); } + + #[test] + fn a_crate_card_takes_the_first_sentence_only() { + // The index shows what a crate is; the crate page shows the rest. + assert_eq!( + crate_summary_brief( + "ApsisRS \u{7684}\u{5171}\u{4eab}\u{57fa}\u{7840}\u{7c7b}\u{578b}\u{3002}\u{672c} crate \u{4f4d}\u{4e8e}\u{4f9d}\u{8d56}\u{56fe}\u{7684}\u{5e95}\u{5c42}\u{3002}" + ), + "ApsisRS \u{7684}\u{5171}\u{4eab}\u{57fa}\u{7840}\u{7c7b}\u{578b}\u{3002}" + ); + } + + #[test] + fn a_number_is_not_a_sentence_end() { + // A version or a measurement must not cut a sentence in half. + assert_eq!( + crate_summary_brief("Reads version 1.5 of the format. Then it checksums."), + "Reads version 1.5 of the format." + ); + } + + #[test] + fn a_summary_without_a_full_stop_is_capped() { + let long = "word ".repeat(80); + let brief = crate_summary_brief(&long); + assert!(brief.chars().count() <= 158, "the brief is too long"); + assert!(brief.ends_with('\u{2026}'), "it should be marked as cut"); + } } diff --git a/src/docs/mathml.rs b/src/docs/mathml.rs index 14df430..e9082f6 100644 --- a/src/docs/mathml.rs +++ b/src/docs/mathml.rs @@ -501,6 +501,13 @@ fn emit(node: &Node) -> String { (Some(single), None) if single.is_alphabetic() => { format!("{}", escape(text)) } + // An operator is written as one rather than as bare text. A + // browser sets text inside a row as ordinary glyphs with no + // spacing around it, so `rev/day` came out as `revday`; an + // operator carries its own spacing and cannot be swallowed. + (Some(single), None) if single.is_ascii_punctuation() => { + format!("{}", escape(text)) + } _ => escape(text), } } @@ -600,10 +607,11 @@ mod tests { #[test] fn a_comma_is_kept() { // The command is the spacing one, so dropping it would run a list of - // symbols together. + // symbols together. It is written as an operator so that it keeps its + // space, which is what a comma is for. assert_eq!( mathml("\\alpha, \\beta"), - "α, β" + "α, β" ); } diff --git a/src/tools/labels.rs b/src/tools/labels.rs index b6d24bb..823b279 100644 --- a/src/tools/labels.rs +++ b/src/tools/labels.rs @@ -90,6 +90,9 @@ const PAIRS: &[(&str, &str)] = &[ ), ("Semi-major axis estimate", "半长轴估计"), ("Unit", "单位"), + ("first", "第一次"), + ("Two-line element set", "两行元素集"), + ("second", "第二次"), // Propagation ("Branch", "分支"), ("Minutes from epoch", "距历元分钟数"), diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 3f1ae30..6504701 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -755,8 +755,8 @@ const TOOL_TABLE: &[ToolSpec] = &[ "Compute the two burns of a transfer between coplanar circular orbits.", ), description: Text::new( - "返回将卫星在两个共面圆轨道之间转移的两次速度增量、两次点火之间的滑行时间以及总速度增量。正点火抬升轨道,负点火降低轨道。", - "Returns the two velocity changes that move a satellite between two circular coplanar orbits, the coast time between them, and the total velocity change. A positive burn raises the orbit and a negative one lowers it.", + "返回将卫星在两个共面圆轨道之间转移的两次速度增量、两次点火之间的滑行时间以及总速度增量。正点火抬升轨道,负点火降低轨道。\n\n每次点火的大小由齐奥科夫斯基方程给出:$\\Delta v = v_e \\ln(m_0 / m_f)$,其中 $v_e$ 是排气速度,$m_0$ 与 $m_f$ 分别是点火前后的质量。总速度增量是两次点火大小之和的绝对值。", + "Returns the two velocity changes that move a satellite between two circular coplanar orbits, the coast time between them, and the total velocity change. A positive burn raises the orbit and a negative one lowers it.\n\nThe size of each burn follows the Tsiolkovsky equation, $\\Delta v = v_e \\ln(m_0 / m_f)$, where $v_e$ is the exhaust velocity and $m_0$ and $m_f$ are the masses before and after the burn. The total is the absolute value of the sum of the two.", ), category: Text::new("轨道", "Orbits"), params: &[ @@ -1094,6 +1094,12 @@ pub fn registry_json(registry: &Registry) -> Value { } /// Encodes one tool as JSON. +/// +/// A description and a parameter help are written in Markdown, so they are +/// sent twice: as the source, which a reader of the JSON needs, and as HTML, +/// which is what the page shows. The HTML comes from the same renderer as +/// the concept documentation, so a formula written between dollar signs is +/// set the same way in both places. fn tool_json(tool: &ToolSpec) -> Value { let params: Vec = tool.params.iter().map(param_json).collect(); json_object! { @@ -1101,12 +1107,21 @@ fn tool_json(tool: &ToolSpec) -> Value { "name" => tool.name.json(), "summary" => tool.summary.json(), "description" => tool.description.json(), + "description_html" => rendered_text(&tool.description), "category" => tool.category.json(), "params" => params, "api_items" => tool.api_items.iter().map(|name| Value::from(*name)).collect::>(), } } +/// Renders both languages of a piece of Markdown to HTML. +fn rendered_text(text: &Text) -> Value { + json_object! { + "zh" => crate::docs::markdown::render(text.zh), + "en" => crate::docs::markdown::render(text.en), + } +} + /// Encodes one parameter as JSON. fn param_json(param: &ParamSpec) -> Value { let (kind, options) = match ¶m.kind { @@ -1140,6 +1155,7 @@ fn param_json(param: &ParamSpec) -> Value { "name" => param.name, "label" => param.label.json(), "help" => param.help.json(), + "help_html" => rendered_text(¶m.help), "kind" => kind, "required" => param.required, "default" => param.default.map(Value::from).unwrap_or(Value::Null), diff --git a/src/tools/out.rs b/src/tools/out.rs index cc8cb48..72b038d 100644 --- a/src/tools/out.rs +++ b/src/tools/out.rs @@ -112,6 +112,14 @@ impl Table { self.rows.push(row); } + /// Returns the column at a position, if there is one. + /// + /// A row is written by the handler, which may add or leave out a cell, so + /// a position is not guaranteed to name a column. + pub fn column(&self, index: usize) -> Option<&Column> { + self.columns.get(index) + } + /// Returns `true` when the table has no rows. #[allow(dead_code)] pub fn is_empty(&self) -> bool { @@ -177,14 +185,48 @@ pub fn table_response( "key" => column.key.clone(), "label" => labels::label(&column.label).json(), "unit" => column.unit.clone(), + "unit_html" => unit_html(&column.unit), "numeric" => column.numeric, } }) .collect(); + // A text cell may hold a fixed phrase rather than data: the meaning of a + // field, for instance. Those are looked up so that a reader of either + // language sees prose in that language. A cell holding data was written + // by the reader rather than by this table, so the lookup gives it back + // unchanged in both languages, which is what it should show. let rows: Vec = table .rows .iter() - .map(|row| Value::Array(row.clone())) + .map(|row| { + Value::Array( + row.iter() + .enumerate() + .map(|(index, cell)| match cell { + Value::String(text) + if !table.column(index).is_some_and(|column| column.numeric) => + { + let mut encoded = labels::label(text).json(); + // A unit is written with a power in it, as in + // `rev/day^2`, and a unit is the one kind of text a + // table shows that is notation rather than prose. + // Only notation is rendered, so a sentence is never + // set as a formula and read as a row of variables. + if is_notation(text) { + if let Value::Object(fields) = &mut encoded { + fields.push(( + "html".to_string(), + Value::from(crate::docs::mathml::render(text, false)), + )); + } + } + encoded + } + other => other.clone(), + }) + .collect(), + ) + }) .collect(); let scalar_values: Vec = scalars.iter().map(scalar_json).collect(); let series_values: Vec = series.iter().map(series_json).collect(); @@ -195,10 +237,51 @@ pub fn table_response( "scalars" => scalar_values, "table" => json_object! { "columns" => columns, "rows" => rows }, "series" => series_values, + // A note is written in Markdown, so it is sent as the source and as + // HTML in both languages; a formula in a note is set the same way as + // one in a document. "notes" => notes.iter().map(Text::json).collect::>(), + "notes_html" => notes.iter().map(note_html).collect::>(), } } +/// Renders one note to HTML in both languages. +fn note_html(note: &Text) -> Value { + json_object! { + "zh" => crate::docs::markdown::render(note.zh), + "en" => crate::docs::markdown::render(note.en), + } +} + +/// Reports whether a piece of text is notation that should be set. +/// +/// Only a unit qualifies. A unit is a single token carrying a power, such as +/// `rev/day^2`, and a power is what a reader needs to see raised. +/// +/// Three things are deliberately excluded. A sentence such as `Mean motion` +/// is prose, and a formula sets its letters as variables, so a sentence read +/// that way stops being a sentence. An identifier such as `mean_motion` is a +/// field name, and it is already written the way the field is spelled; setting +/// it as a formula would split it into letters and turn the underscore into a +/// subscript. And a unit written with a space is prose with a caret in it, +/// which is not a unit. +fn is_notation(text: &str) -> bool { + text.contains('^') && !text.contains(' ') +} + +/// Renders a unit as HTML, so that a power reads as a power. +/// +/// A unit is notation rather than prose: `rev/day^2` is a name, and the only +/// mark-up it ever needs is the exponent. It is rendered by the same +/// mathematics renderer as a formula so that the two agree on how a power +/// looks, and so that a unit written with a subscript is set correctly too. +fn unit_html(unit: &str) -> Value { + if unit.is_empty() { + return Value::string(String::new()); + } + Value::from(crate::docs::mathml::render(unit, false)) +} + /// Returns a note written as a literal in the label table. /// /// The note text is both the lookup key and the English form, so a note @@ -224,6 +307,7 @@ fn scalar_json(scalar: &Scalar) -> Value { "label" => labels::label(&scalar.label).json(), "value" => scalar.value.clone(), "unit" => scalar.unit.clone(), + "unit_html" => unit_html(&scalar.unit), "monospace" => scalar.monospace, } } @@ -246,6 +330,63 @@ fn series_json(series: &Series) -> Value { mod tests { use super::*; + /// The first cell of the first row of a shaped response. + fn first_cell(value: &Value) -> Value { + let rows = value + .get("table") + .and_then(|table| table.get("rows")) + .expect("the response carries a table") + .as_array(); + rows.first() + .and_then(|row| row.as_array().first()) + .expect("the table carries a row") + .clone() + } + + #[test] + fn a_text_cell_is_shown_in_either_language() { + // The meaning of a field is a fixed phrase, so it is looked up rather + // than written twice. + let mut table = Table::new(vec![Column::text("meaning", "Meaning")]); + table.push(vec![Value::from("Mean motion")]); + let value = table_response( + "kind", + "Title", + Vec::new(), + &table, + Vec::new(), + vec![labels::label("Mean motion")], + ); + let cell = first_cell(&value); + assert_eq!(cell.get("zh").and_then(Value::as_str), Some("平均运动")); + assert_eq!(cell.get("en").and_then(Value::as_str), Some("Mean motion")); + } + + #[test] + fn an_identifier_is_not_set_as_a_formula() { + // `mean_motion` is a field name. Set as a formula it would come out as + // separate letters with a subscript, which is not the field name. + let mut table = Table::new(vec![Column::text("field", "Field")]); + table.push(vec![Value::from("mean_motion")]); + let value = table_response("kind", "Title", Vec::new(), &table, Vec::new(), Vec::new()); + let cell = first_cell(&value); + assert!( + cell.get("html").is_none(), + "an identifier should not be rendered: {cell:?}" + ); + } + + #[test] + fn a_unit_with_a_power_is_rendered() { + // `rev/day^2` is a unit, and the power is what a reader needs raised. + let mut table = Table::new(vec![Column::text("unit", "Unit")]); + table.push(vec![Value::from("rev/day^2")]); + let value = table_response("kind", "Title", Vec::new(), &table, Vec::new(), Vec::new()); + let cell = first_cell(&value); + let html = cell.get("html").and_then(Value::as_str).unwrap_or_default(); + assert!(html.contains(""), "the power was not raised: {html}"); + } + #[test] fn table_response_carries_the_shape() { let mut table = Table::new(vec![Column::number("a", "A", "km")]);