Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

- **Hybrid Markdown editing** ([#460](https://github.com/raiseCatError/notMyShell/issues/460)). **F3** in a Markdown document (or `H` from the read-only views) shows it *rendered* — headings, emphasis, links, lists, tables, quotes, code — except for the block your caret (or selection) is in, which shows its exact Markdown source and is fully editable. Move on and it renders again. It is another presentation of the same buffer: every key is the editor's (letters always type; snippets, list/table helpers, undo/redo, hunk review, recovery drafts and Save As all work as in the source editor), the caret lives in source positions, and switching views never changes the text. The smallest safe block is revealed — the whole list for a nested item, the whole table, the whole fence — and a selection shows every block it touches as source. The view is anchored to a source line, so revealing a block does not move what is above it. F3 again returns to the source editor; in the split view (F2) F3 switches the text pane between source and hybrid. Limits: Markdown files only (not HTML); images appear as `[image: alt]`; Up/Down move by source line; pointer input is not handled.
- **Expandable `<details>`** ([#462](https://github.com/raiseCatError/notMyShell/issues/462)). The native Markdown/HTML viewer now shows `<details><summary>…</summary>…</details>` as a disclosure: `▸ Summary` when closed (hiding its content), `▾ Summary` with the content indented when open — in the rendered view, the preview, the split's preview pane and the hybrid view. GitHub-style details, with a blank line and Markdown inside and `</details>` in a later block, are understood (nested ones too); the `open` attribute sets the starting state. **`z`** toggles the summary at or below the top of the screen, **`Z`** all summaries in view; choices are kept by summary text, survive scrolling, resizing and edits above, and never change the file. Scripts, handlers and unknown tags in a details block are inert text. Not yet: pointer clicks (the viewer has no pointer support), and toggling inside the hybrid view (it shows each details as written: closed unless `open`).
- **Math in Markdown** ([#462](https://github.com/raiseCatError/notMyShell/issues/462)). `$…$` (GitHub's rules: no space inside the dollars, not before a digit) and display `$$…$$` / ```` ```math ```` render as typeset math in the viewer, preview, split and hybrid view, offline and without a browser: real stacked fractions with a bar, roots, super- and subscripts (Unicode where every character has one, an unambiguous `x^(…)` form otherwise, placed in two dimensions in display), `∑ ∏ ∫` with limits, Greek letters, operators and relations, `\left…\right` stretched delimiters, `matrix`/`pmatrix`/`bmatrix`/`vmatrix`/`cases` and `\text`. It is a defined subset, not full LaTeX: anything outside it (unknown commands, too large, too wide, a fraction that does not fit one row inline) stays as its source text with a dim `(not rendered: reason)`, never a guess. Source is preserved exactly (the editor and `/copy` are unchanged), Safe Glyphs draw with plain ASCII, and formulas are limited in size, depth and rows. Rendering through KaTeX in the optional browser is not part of this slice.
- **Problems: offline static diagnostics** ([#466](https://github.com/raiseCatError/notMyShell/issues/466)). In a Markdown, HTML or CSS document, **F1 then `e`** (the snippet palette's *Problems…* in HTML files; **F1** in a CSS file; **`P`** in the read-only views and the split's preview pane) lists what is wrong: ✖ errors, ▲ warnings, ℹ notes and `?` things that cannot be checked. Markdown: links to files that do not exist, `#fragments` with no heading or id, missing images, images without alt text, undefined `[text][ref]` references and footnotes, unused footnotes, repeated headings. HTML: missing images, stylesheets and scripts, `<img>` without `alt`, form controls without a label, duplicate ids, unresolved `#fragments`, unclosed or unexpected tags. CSS: missing `url()` assets and unbalanced braces. Type to search, Tab filters (all, problems, errors), Enter jumps to the exact text and selects it (Back returns), Del dismisses one, Ctrl+R checks again. Checking starts when you first open Problems, follows the text (typing, undo, snippets, restored drafts, a merge) after a short pause, drops results for text that has since changed, and counts show in the editor's status row. Everything is offline: external URLs are counted, never fetched; no script, style or command is run; files are only looked at (never read except another Markdown file's headings, 256 KiB at most) and only inside the document's project (a path that leaves it, by `..` or a symlink, is reported as not checked); nothing is written. Browser-console diagnostics are a later, separate step.
- **Footnote renumbering** ([#463](https://github.com/raiseCatError/notMyShell/issues/463)). *Renumber footnotes* in the snippet palette (F1, then `/` in a Markdown file) numbers the footnotes 1, 2, 3… in the order they are first referenced and updates each definition to match, as one undo step. Named footnotes (`[^note]`), escaped references, code spans and fenced code are left alone; a definition nobody references keeps its number unless a renumbered footnote needs it.
- **Split view for Markdown and HTML documents** ([#461](https://github.com/raiseCatError/notMyShell/issues/461)). **F2** (or `s` in the read-only views) in the document workspace shows the editor beside NMSh's own preview of the *same* buffer: the text on the left, the rendered document on the right, updated as you type, without saving. The preview follows the caret to its block (`l` in the preview pane turns that off), **Ctrl+O** moves between the panes, `x` swaps the sides and `<` `>` change their widths (30–70%). Only the text pane is editable; keys typed while the preview has the focus never reach the text. The preview pane has its own find (`/`) and heading prompt (`#`). Below 100 columns only the focused pane is shown at full width, and widening the terminal brings the split back. Esc leaves for the read-only source view with your unsaved text kept; F2 again returns to editing. The browser-quality preview is not used in the split; the pane is NMSh's renderer. Mouse input in the split is not handled yet.
Expand Down
4 changes: 4 additions & 0 deletions docs/design/document-workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ Limits: code fences are recognised by a line scan (indented code blocks and fenc
`Block` gains `{kind: 'details'; open; summary; blocks}` (#462). Two sources: `htmlToBlocks` (an HTML document or a self-contained element) and `parseLines`, which recognises GitHub's layout — an HTML block that opens a `<details>` it does not close, Markdown after a blank line, `</details>` alone on a later line (nesting counted, within 5,000 lines) — and parses the lines between as Markdown at the right source offset, so inner blocks keep exact spans. Content written in the opening chunk is kept; anything that does not match the layout is left to the old HTML-block path, so no text is dropped. A summary is HTML, as on GitHub: Markdown punctuation in it stays literal; no summary reads "Details".

`renderMarkdown` takes `disclosure.isOpen(key, defaultOpen)`; the summary row carries `MarkdownRow.details`, the key (summary text and which one of that text). The viewer keeps `disclosure: Map<key, open>` (never persisted, never in the file); `z`/`Z` read each row's drawn state (▾/v), so a block's own `open` is respected, and set the opposite. The state is part of the rendered-rows cache key. The hybrid view renders inactive details as written (no toggle there yet), and the plain-text fallback is the source.
## Math

`src/markdown/math.ts` is a small offline typesetter, not a command-to-Unicode table. A tokenizer and a recursive-descent parser build a tree (limits: 4,000 source characters, depth 24, 800 nodes); a layout pass turns it into boxes of rows with a measured width and a baseline, so fractions, roots, matrices and scripts compose and align. Inline mode must yield one row (a fraction becomes `a/b`, parenthesised when needed; a script that cannot use Unicode becomes `x^(…)`); display mode may use up to 40 rows and 500 columns. Anything outside the subset throws a reason and the caller keeps the source: `$…$` stays literal text, a display block shows `$$ source $$` dim with `(not rendered: reason)`. Block parsing (`$$` one-line or multi-line, ```` ```math ````) records the usual source span, so the source map, hybrid editing and navigation need no special case. KaTeX through the optional browser engine was evaluated and is deferred: it would need a bundled, reviewed copy and the lazy Chromium path, and the subset covers prose-level formulas without either.

## Problems (static diagnostics)

`src/markdown/diagnostics.ts` (engine), `src/viewer/problems.ts` (runner) and the *problems* dialog of `EditorPanel.ts` ([#466](https://github.com/raiseCatError/notMyShell/issues/466)). Reuses `parseBlockLines` (code blocks are skipped through the block spans), `headingsFromLines` and `resolveFragment`; no second parser, no language server.
Expand Down
1 change: 1 addition & 0 deletions docs/development/v024-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ The paused issues #460–#469 are the milestone (the maintainer superseded the e
8. **#460: hybrid Markdown editing** — branch `feature/v024-hybrid` (from `dev` after #489–#496 merged). Done: hybrid mode, items from the source map, active-block raw editing, anchored viewport, split text-pane toggle, tests, independent review (see its section). **#460 remaining:** images in place, visual-line movement, pointer input, tables as a grid, HTML. **#461** still lacks pointer input, preview→caret sync, vertical orientation and the Chromium preview; **#463** footnote renumbering; **#466**, **#462** (details/Mermaid/math), **#464/#465/#467** not started.
9. **#463: footnote renumbering** — branch `feature/v024-footnotes`, from `dev`. Done (tests in `footnotes.test.ts`). **#463 acceptance:** every implemented item is covered by automated tests; the only open requirement is the physical Ghostty/Zed pass (NOT RUN), so the issue stays open.

12. **#462: math** — branch `feature/v024-math`, from `dev`. Done: offline typesetter (`src/markdown/math.ts`), `$`/`$$`/math-fence parsing with source spans, viewer/preview/split/hybrid rendering, source fallback with a reason, tests. KaTeX via the browser is deferred. **#462 remaining:** Mermaid, pointer and hybrid `<details>` toggling, fragment links, optional outline rail.
11. **#462: `<details>`** — branch `feature/v024-details`, from `dev`. Done: block, parser (GitHub layout), renderer, `z`/`Z`, tests. **#462 remaining:** Mermaid and math (separate slices; not started), pointer toggling, hybrid toggling. The diagnostics engine descends into `details` blocks (so a link or code span inside one is checked like any other).
10. **#466: offline static diagnostics** — branch `feature/v024-diagnostics`, from `dev`. Done: engine, runner, Problems surface in all modes, tests, independent review (see its section in the design doc). **#466 remaining:** the optional browser console for trusted previews (separate gated slice, nothing started), a gutter marker, further languages. **#462 next:** `<details>` disclosure, then Mermaid and math (separate slices).

Expand Down
1 change: 1 addition & 0 deletions docs/testing/v024-editor-physical-qa.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Automated tests cover the logic headlessly. Everything below is **NOT RUN** unti

## Details (#462)
- [ ] A Markdown file with GitHub-style `<details>`: closed shows `▸ Summary`; `z` opens it; `Z`; in the preview, the split's preview pane and the hybrid view; narrow widths; Safe Glyphs (`>`/`v`); a hostile file.
- [ ] NOT RUN: A Markdown file with `$E=mc^2$`, `$\frac{a}{b}$`, a multi-line `$$ … $$` with `\sum`, `\int` and a matrix, and an unsupported command: inline and display rendering in the viewer, preview, split and hybrid; the unsupported one shows its source and the reason; Safe Glyphs; narrow widths; no glyph drift in Ghostty and Zed.
## Problems (#466)
- [ ] F1 then `e` in a Markdown file with a broken link, a missing image and a bad `#fragment`: the list shows them; Enter selects the exact text; Back returns. `P` in the read-only views and in the split's preview pane.
- [ ] An HTML file (palette → Problems) and a CSS file (F1); the status row counts; fixing a finding removes it after a moment; undo brings it back.
Expand Down
33 changes: 31 additions & 2 deletions src/markdown/blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ export type Block = (
| {kind: 'rule'}
/** `<details>`: a disclosure, collapsed unless `open`; its content is blocks (Markdown between GitHub-style tags is Markdown). */
| {kind: 'details'; open: boolean; summary: string; blocks: Block[]}
/** `$$ … $$` display mathematics (only when parsing with the `math` option): the LaTeX between the dollars. */
| {kind: 'math'; source: string; raw: string[]}
) & {
/** The source lines the block came from (0-based; `to` is exclusive), when `parseBlocks` knows them: the shared source-position map. */
from?: number;
Expand Down Expand Up @@ -139,20 +141,22 @@ function delimiterRow(line: string): Alignment[] | undefined {

/** Whether a line starts a block that interrupts a paragraph (a lazy continuation line must not). */
function interrupts(line: string, next: string | undefined): boolean {
if (mathEnabled && /^ {0,3}(?:\$\$\s*|\$\$(?:(?!\$\$)[^\n])+\$\$\s*)$/u.test(line)) return true;
if (fenceOf(line) || headingOf(line) || isRule(line) || QUOTE.test(line)) return true;
const item = ITEM.exec(line);
// An ordered item interrupts a paragraph only when it starts at 1, and an empty item never does (CommonMark).
if (item && line.slice(item[0].length).trim() && (!/\d/u.test(item[2]!) || /^1[.)]$/u.test(item[2]!))) return true;
return Boolean(line.includes('|') && next !== undefined && delimiterRow(next) && splitRow(line).length === delimiterRow(next)!.length);
}

export function parseBlocks(source: string, options: {html?: boolean} = {}): Block[] {
export function parseBlocks(source: string, options: {html?: boolean; math?: boolean} = {}): Block[] {
return parseBlockLines(source.replace(/\r\n?/gu, '\n').split('\n'), options);
}

/** The same, for lines already cut (a buffer's): a stray CR inside a line stays in it and cannot shift the line numbers after it. */
export function parseBlockLines(lines: readonly string[], options: {html?: boolean} = {}): Block[] {
export function parseBlockLines(lines: readonly string[], options: {html?: boolean; math?: boolean} = {}): Block[] {
detailsBudget = DETAILS_SCAN_LINES;
mathEnabled = Boolean(options.math);
return parseLines(lines.length > MAX_LINES ? lines.slice(0, MAX_LINES) : lines, 0, Boolean(options.html), 0);
}

Expand All @@ -170,10 +174,32 @@ function htmlBlockKind(line: string): 1 | 2 | 6 | 7 | undefined {
return KNOWN.has(name) && text.length <= 4096 && /^<[^<>]*>\s*$/u.test(text) ? 7 : undefined;
}

/** `$$ x $$` on one line, or `$$` … `$$` over several (at most 200 lines, closed): the LaTeX inside and the line after the block. */
function mathBlockAt(lines: readonly string[], start: number): {source: string; end: number} | undefined {
const first = lines[start]!;
if (indentOf(first) > 3 || !first.trimStart().startsWith('$$')) return undefined;
const text = first.trim();
// `$$x$$ and $$y$$` is two inline formulas, not one block; so is a first line that closes and goes on.
const inner = text.slice(2);
if (inner.includes('$$')) {
const single = /^\$\$((?:(?!\$\$)[\s\S])+)\$\$$/u.exec(text);
return single?.[1]!.trim() ? {source: single[1]!.trim(), end: start + 1} : undefined;
}
const parts = [inner];
for (let at = start + 1; at < Math.min(lines.length, start + 200); at += 1) {
const line = lines[at]!.trim();
if (line.endsWith('$$') && !line.slice(0, -2).includes('$$')) { parts.push(line.slice(0, -2)); return {source: parts.join('\n').trim(), end: at + 1}; }
if (line.includes('$$')) return undefined;
parts.push(lines[at]!);
}
return undefined;
}

const OPENS = /<details\b/giu, CLOSES = /<\/details\s*>/giu;
/** Lines the whole parse may look through to find closing `</details>` tags: one budget for all openers, so many unclosed ones cost a bounded amount in total. */
const DETAILS_SCAN_LINES = 100_000;
let detailsBudget = DETAILS_SCAN_LINES;
let mathEnabled = false;
const count = (pattern: RegExp, text: string) => (text.match(pattern) ?? []).length;

/**
Expand Down Expand Up @@ -239,6 +265,9 @@ function parseLines(lines: readonly string[], depth: number, html = false, offse
const line = lines[index]!;
if (blank(line)) { index++; continue; }

const math = mathEnabled ? mathBlockAt(lines, index) : undefined;
if (math) { blocks.push({kind: 'math', source: math.source, raw: lines.slice(index, math.end)}); index = math.end; continue; }

const fence = fenceOf(line);
if (fence) {
const code: string[] = [];
Expand Down
34 changes: 34 additions & 0 deletions src/markdown/inline.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {safeHyperlinkTarget} from '../output/Hyperlinks.js';
import {renderMath} from './math.js';
import {decodeEntities, decodeEntity, DROPPED, imageLabel, KNOWN, RAW_TEXT, readTag} from './markup.js';

/**
Expand All @@ -20,6 +21,8 @@ export interface InlineStyle {
strike?: boolean;
/** A safe http(s) target; present only on link text. */
link?: string;
/** Rendered inline mathematics (`$…$`): the text is the formula as one row of Unicode. */
math?: boolean;
/** Text of an image (its alt text): shown, never fetched. */
image?: boolean;
/** The image's source reference as written (bounded). Never fetched here: the graphics service decides what may be read. */
Expand Down Expand Up @@ -48,6 +51,8 @@ function safeLink(url: string): string | undefined {
export interface InlineOptions {
/** Read the HTML tags `/view` renders (see the module comment); otherwise tags are text. */
html?: boolean;
/** Read `$…$` as mathematics (documents only): `ascii` draws it without box and bracket pieces. */
math?: {ascii: boolean};
}

/** Raw tokens before emphasis resolution: literal text, finished spans (code, links) and delimiter runs. */
Expand Down Expand Up @@ -203,6 +208,35 @@ function tokenize(source: string, options: InlineOptions): Token[] {
}
text += fence; index += run; continue;
}
if (char === '$' && options.math) {
// GitHub's rules: `$x$` needs a non-space after the opener and before the closer, and the closer must not be followed by a digit
// (so prices are not formulas); `$$x$$` may sit inside a paragraph too. A formula that cannot be drawn stays as written.
const run = source[index + 1] === '$' ? 2 : 1;
const open = index + run;
let close = -1;
if (open < source.length && !/\s/u.test(source[open]!)) {
for (let at = open; at < Math.min(source.length, open + 1000); at += 1) {
if (source[at] === '\\') { at += 1; continue; }
if (source[at] === '`') {
// A code span binds tighter than a formula: `$a `b$` c` is code, not a formula that swallows the backtick.
let ticks = 1;
while (source[at + ticks] === '`') ticks++;
if (closingRun(ticks, at + ticks) >= 0) break;
at += ticks - 1;
continue;
}
if (source[at] === '$' && (run === 1 || source[at + 1] === '$')) { close = at; break; }
}
}
if (close > open && !/\s/u.test(source[close - 1]!) && !(run === 1 && /[0-9]/u.test(source[close + 1] ?? ''))) {
const result = renderMath(source.slice(open, close), {display: false, ascii: options.math.ascii});
const end = close + run;
if (result.ok) { flush(); tokens.push({kind: 'span', span: [{text: result.rows[0]!, style: {math: true}}]}); }
else text += source.slice(index, end);
index = end;
continue;
}
}
if (char === '<') {
const auto = /^<(https?:\/\/[^\s<>]{1,2000})>/iu.exec(source.slice(index, index + 2100));
if (auto) {
Expand Down
Loading
Loading