diff --git a/.phpcs.xml b/.phpcs.xml index 4491da7e..46fa70d6 100644 --- a/.phpcs.xml +++ b/.phpcs.xml @@ -5,7 +5,6 @@ . - \.stubs/ hack/ tests/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..070e28d4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,10 @@ +# AGENTS.md + +Run `make checks` before committing. `test` runs plain PHP scripts +(`php tests/*.php`), not PHPUnit. `phpcs` needs `composer install`, which +`make` does automatically. + +`hack/demo/demo.sh` / `make screenshots [demo]` render a demo through a +local Docker MediaWiki and regenerate `docs/screenshots/*.png`. Only run +this when asked to, or when a change affects rendering (`resources/`, +`hack/demo/demos.yaml`) — not for routine changes. diff --git a/Makefile b/Makefile index 4357317f..20b14d3d 100644 --- a/Makefile +++ b/Makefile @@ -1,18 +1,31 @@ -.PHONY: checks test phpcs mathjax +.PHONY: checks test phpcs local-mathjax screenshots -MATHJAX_VERSION_LOCAL ?= 4.1.3 -MATHJAX_VERSION_CDN ?= 4 +LOCAL_MATHJAX_VERSION ?= 4.1.3 checks: test phpcs ## Everything CI runs before merging (needs `composer install`, PHP >= 8.2) test: ## Run the pure-PHP test suites php tests/QuotesTest.php + php tests/RevisionOverridesTest.php phpcs: vendor/autoload.php ## parallel-lint + minus-x + phpcs against the MediaWiki coding standard composer test +local-mathjax: ## Pin the bundled local MathJax submodule, e.g. `make local-mathjax LOCAL_MATHJAX_VERSION=4.1.3` + hack/local-mathjax.sh $(LOCAL_MATHJAX_VERSION) + vendor/autoload.php: composer.json composer install --no-progress -mathjax: ## Pin the local MathJax submodule + CDN major version, e.g. `make mathjax MATHJAX_VERSION_LOCAL=4.1.3 MATHJAX_VERSION_CDN=4` - hack/mathjax.sh $(MATHJAX_VERSION_LOCAL) $(MATHJAX_VERSION_CDN) +screenshots: ## Screenshot a demo page, e.g. `make screenshots custom01` (no demo = every demo in hack/demo/demos.yaml) + hack/demo/demo.sh screenshot $(filter-out $@,$(MAKECMDGOALS)) + +# Swallows the extra word in `make screenshots custom01` so make doesn't +# treat "custom01" as a target of its own and fail with "No rule to make +# target". Scoped to only fire when `screenshots` is actually one of the +# invoked goals, so an unrelated typo like `make cheks` still fails loudly +# instead of silently no-op'ing. +ifneq ($(filter screenshots,$(MAKECMDGOALS)),) +%: + @: +endif diff --git a/README.md b/README.md index a5381188..a8ee0d83 100644 --- a/README.md +++ b/README.md @@ -6,98 +6,42 @@ https://www.mediawiki.org/wiki/Extension:SimpleMathJax # Installation * git clone in extensions directory * Using CDN is recommended. Because it's much faster than using local resources in most cases. ("the benefits of using a CDN") -```Bash +```bash $ git clone https://github.com/jmnote/SimpleMathJax.git ``` -* (Optional) If you want to use not CDN but local mathjax scripts, you can use git clone recursive. -```Bash +* (Optional) If you want to use not CDN but local mathjax scripts, you can use git clone recursive, then set `$wgSmjCdnEnabled = false` (see [`$wgSmjCdnEnabled`](docs/configuration.md#wgsmjcdnenabled)). +```bash $ git clone --recursive https://github.com/jmnote/SimpleMathJax.git ``` * LocalSettings.php -```PHP +```php wfLoadExtension( 'SimpleMathJax' ); ``` # Optional Settings -| Setting name | Description | default value | custom value example | -| ------------------------ | -------------------------------- | ------------------------- | --------------------------- | -| `$wgSmjUseCdn` | use CDN or local scripts | true | false | -| `$wgSmjUseChem` | enable chem tag | true | false | -| `$wgSmjDirectMathJax` | which ones can be written directly | "full" | "none" | -| `$wgSmjEnableMenu` | MathJax.options.enableMenu | true | false | -| `$wgSmjDisplayMath` | MathJax.tex.displayMath | [] | [['$$','$$'],['\\[','\\]']] | -| `$wgSmjExtraInlineMath` | MathJax.tex.inlineMath | [] | [['\\(', '\\)']] | -| `$wgSmjIgnoreHtmlClass` | MathJax.options.ignoreHtmlClass | "mathjax_ignore\|comment\|
diff-(context\|
addedline\|deletedline)" | "mathjax_ignore" | -| `$wgSmjScale` | MathJax.chtml.scale | 1 | 1.5 | -| `$wgSmjDisplayAlign` | MathJax.chtml.displayAlign | "left" | "center" | -| `$wgSmjWrapDisplaystyle` | wrap with displaystyle on `` | true | false | -| `$wgSmjEnableHtmlAttributes` | process attributes of math tag | false | true | -| `$wgSmjConfigByRevision` | switch the configuration according to the article's revision | [] | [['upto'=>1048576,
'wgSmjDisplayAlign'
=>'left']] | - -If you want to change font size, set `$wgSmjScale`. -```PHP -wfLoadExtension( 'SimpleMathJax' ); -$wgSmjScale = 1.5; -``` - -If you want to use local module, set `$wgSmjUseCdn`. -```PHP -wfLoadExtension( 'SimpleMathJax' ); -$wgSmjUseCdn = false; -``` - -If you want to enable some extra inlineMath symbol pairs, set `$wgSmjExtraInlineMath`. Pairs of `[math][/math]` are always in-line math delimiters. (And independently of this setting, you can use `$ ... $` to switch to math mode within text (`\text{}` etc.) or chemical formulas (`\ce{}`).) -```PHP -wfLoadExtension( 'SimpleMathJax' ); -$wgSmjExtraInlineMath = [["$","$"],["\\(","\\)"]]; -``` - -Since version 0.8.7, inlineMath and blockMath and environments are ignored in edit summaries and diffs. To restore the previous behavior (especially if you are using maths in edit summaries), set `$wgSmjIgnoreHtmlClass`. -```PHP -wfLoadExtension( 'SimpleMathJax' ); -$wgSmjIgnoreHtmlClass = "mathjax_ignore"; -``` - -If you want to disable MathJax context menu, set `$wgSmjEnableMenu`. -```PHP -wfLoadExtension( 'SimpleMathJax' ); -$wgSmjEnableMenu = false; -``` - -By enabling `$wgSmjEnableHtmlAttributes`, the `display` attribute of the `` tag will work, and the `class`, `id`, `title` and `data-*` attributes of the `` tag will be carried over to the `` tag. -```PHP -wfLoadExtension( 'SimpleMathJax' ); -$wgSmjEnableHtmlAttributes = true; -``` - -In version 0.8.9, an option was added to make it completely dedicated to `` and ``. Setting `$wgSmjDirectMathJax` to `env` disables `\ref{}` and escaping of `$`, while setting it to `none` disables all delimiters, including `[math]`, making it mandatory to enclose all TeX expressions in `` or `` (and `$wgSmjDisplayMath`, `$wgSmjExtraInlineMath`, and `$wgSmjIgnoreHtmlClass` will become meaningless). -```PHP -wfLoadExtension( 'SimpleMathJax' ); -$wgSmjDirectMathJax = "none"; -``` - -By using $wgSmjConfigByRevision, you can apply different settings to an article's revisions up to a certain point and to revisions after that. This allows past revisions to be displayed with the settings that were in place at the time. Only the keys written inside the [] are overwritten. Preview, History and SpecialPages are treated as base cases that do not apply this setting. -```PHP -wfLoadExtension( 'SimpleMathJax' ); -$wgSmjDirectMathJax = "none"; #To match the preview with the actual rendering, write the latest settings in the base case -$wgSmjConfigByRevision = [ - ["upto"=>50000, "wgSmjDirectMathJax"=>"full"], - ["since"=>50001, "upto"=>60000, "wgSmjDirectMathJax"=>"env"] -]; -``` - -# Hooks -The hook `SimpleMathJaxAttributes` is available to add attributes to the span around the math. (Note that this process is performed only for `` and `` elements, and other delimiters are handled directly by MathJax.) This hook provides you with the opportunity to ensure that your own code does not interfere with MathJax's rendering of math. - -For instance, if Lingo's JS functions are called before MathJax is invoked, then it is possible that Lingo will change the text so that MathJax could no longer render the math. - -Lingo understands that [it should not touch anything inside an element with the class `noglossary`](https://www.mediawiki.org/wiki/Extension:Lingo#Excluding_text_from_markup) so the following code can be used to keep Lingo from ruining math: -```PHP -$wgHooks['SimpleMathJaxAttributes'][] - = function ( array &$attributes, string $tex, array $args = [] ) { - $attributes['class'] .= ' noglossary'; - }; -``` - +| Setting name | Default value | Description | Custom value example | +| ------------------------ | --------------------------- | -------------------------------- | --------------------------- | +| `$wgSmjCdnEnabled` | `true` | Whether to load MathJax from a CDN | `false` | +| `$wgSmjCdnVersion` | `'4'` | MathJax version to load from the CDN | `'4.1.3'` | +| `$wgSmjScale` | `1` | `MathJax.chtml.scale` | `1.5` | +| `$wgSmjEnableMenu` | `true` | `MathJax.options.enableMenu` | `false` | +| `$wgSmjDelimitersEnabled` | `false` | Whether to also scan for bare delimiters (e.g. `$...$`) outside ``/`` | `true` | +| `$wgSmjDelimitersInlineMath` | `[]` | Inline math delimiter pairs | `[['$','$']]` | +| `$wgSmjDelimitersDisplayMath` | `[]` | Display math delimiter pairs | `[['$$','$$']]` | +| `$wgSmjAllowedAttributes` | `[]` | List of generic HTML attributes to carry over to the output `` | `['class', 'title']` | +| `$wgSmjIgnoreHtmlClass` | `'mathjax_ignore\|comment\|`
`diff-(context\|`
`addedline\|deletedline)'` | `MathJax.options.ignoreHtmlClass` | `'mathjax_ignore\|comment\|`
`diff-(context\|`
`addedline\|deletedline)\|my_custom_class'` | +| `$wgSmjRevisionOverrides` | `[]` | Switch the configuration according to the article's revision | `[['max'=>1048576,`
`'wgSmjScale'=>1.5]]` | + +See [docs/configuration.md](docs/configuration.md) for a detailed walkthrough of each +setting, with usage examples. Upgrading from before 1.0.0? See the +[migration guide](docs/mig-1.0.md) for what to change in your +`LocalSettings.php`. + +See [docs/displaystyle.md](docs/displaystyle.md) for the `` +rendering modes and examples. + +Need to keep another extension (e.g. Lingo) from interfering with MathJax's +rendering? See [Hooks](docs/configuration.md#hooks) in the configuration +guide for the `SimpleMathJaxAttributes` hook. diff --git a/composer.json b/composer.json index a02e8c67..b1287791 100644 --- a/composer.json +++ b/composer.json @@ -1,4 +1,5 @@ { + "license": "MIT", "require-dev": { "mediawiki/mediawiki-codesniffer": "52.0.0", "mediawiki/mediawiki-phan-config": "0.20.0", diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 00000000..3cfa48d6 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,185 @@ +# Configuration + +Full reference for every `$wgSmj*` setting, plus the `SimpleMathJaxAttributes` +hook for extending it in code. The quick-reference table in the +[README](../README.md#optional-settings) links here for full explanations +and examples. Upgrading from before 1.0.0? See the +[migration guide](mig-1.0.md) instead. + +## Settings reference + +| Setting name | Default value | Description | +| ------------------------ | ------------- | ----------- | +| `$wgSmjCdnEnabled` | `true` | Whether to load MathJax from a CDN instead of the bundled local copy | +| `$wgSmjCdnVersion` | `'4'` | MathJax version to load from the CDN | +| `$wgSmjScale` | `1` | `MathJax.chtml.scale` | +| `$wgSmjEnableMenu` | `true` | `MathJax.options.enableMenu` | +| `$wgSmjDelimitersEnabled` | `false` | Whether to also scan for bare delimiters (e.g. `$...$`) outside ``/`` | +| `$wgSmjDelimitersInlineMath` | `[]` | Inline math delimiter pairs, e.g. `[['$','$']]` | +| `$wgSmjDelimitersDisplayMath` | `[]` | Display math delimiter pairs, e.g. `[['$$','$$']]` | +| `$wgSmjAllowedAttributes` | `[]` | List of generic HTML attributes to carry over to the output `` | +| `$wgSmjIgnoreHtmlClass` | `'mathjax_ignore\|comment\|`
`diff-(context\|`
`addedline\|deletedline)'` | `MathJax.options.ignoreHtmlClass` | +| `$wgSmjRevisionOverrides` | `[]` | Switch the configuration according to the article's revision | + +### `$wgSmjCdnEnabled` + +The default follows the latest 4.x release. To pin the CDN to an exact +version, set it explicitly: + +```php +wfLoadExtension( 'SimpleMathJax' ); +$wgSmjCdnVersion = '4.1.3'; +``` + +To use the local MathJax module, disable the CDN. + +```php +wfLoadExtension( 'SimpleMathJax' ); +$wgSmjCdnEnabled = false; +``` + +When working from the Git repository, initialize or update the bundled +MathJax submodule with `make local-mathjax` — see +[Updating MathJax](development.md#updating-mathjax) for version +selection. Normal extension packages already include the bundled MathJax +resources and do not require Git commands. + +### `$wgSmjScale` + +`$wgSmjScale` is passed straight through as MathJax's own `chtml.scale` +option — a multiplier on the default font size, where `1` is 100% (the +default) and, say, `1.5` is 150%. Any positive number works; there's no +built-in minimum or maximum. + +```php +wfLoadExtension( 'SimpleMathJax' ); +$wgSmjScale = 1.5; +``` + +### `$wgSmjEnableMenu` + +If you want to disable MathJax context menu, set `$wgSmjEnableMenu`. + +```php +wfLoadExtension( 'SimpleMathJax' ); +$wgSmjEnableMenu = false; +``` + +### `$wgSmjDelimitersEnabled` + +By default, `$wgSmjDelimitersEnabled` is `false`, so only TeX wrapped in +`` or `` is recognized — bare `$...$`/`$$...$$` delimiters are +left as plain text, and `$wgSmjDelimitersInlineMath`/`DisplayMath` go +unused (as does [`$wgSmjIgnoreHtmlClass`](#wgsmjignorehtmlclass)'s +diff/comment protection, since there's nothing for it to protect against). +Set `Enabled` to `true` and list the delimiter pairs yourself to also +recognize bare delimiters: + +```php +wfLoadExtension( 'SimpleMathJax' ); +$wgSmjDelimitersEnabled = true; +$wgSmjDelimitersInlineMath = [ [ '$', '$' ] ]; +$wgSmjDelimitersDisplayMath = [ [ '$$', '$$' ] ]; +``` + +### `$wgSmjAllowedAttributes` + +`$wgSmjAllowedAttributes` is the administrator-defined allow-list of generic +HTML attributes to copy from ``/`` to the output ``. No +attributes are copied by default; add the attribute names you want. Values +are still passed through MediaWiki's attribute sanitizer. A common case is +wanting a CSS styling hook plus a hover tooltip, without carrying over `id` +(which must be unique on the page, so an editor-supplied one can collide with +an anchor link (`#section`) or another element/JS gadget): + +```php +wfLoadExtension( 'SimpleMathJax' ); +$wgSmjAllowedAttributes = [ 'class', 'title' ]; +``` + +`display` and `chem` are unrelated to this list and always work regardless — +see [Display styles](displaystyle.md). + +### `$wgSmjIgnoreHtmlClass` + +You probably don't need to change this — the default already covers what it +needs to: + +``` +mathjax_ignore|comment|diff-(context|addedline|deletedline) +``` + +`mathjax_ignore` lets an editor skip one ``/`` by adding that +class, if `class` is allowed via +[`$wgSmjAllowedAttributes`](#wgsmjallowedattributes) (empty by default). +`comment` and `diff-(context|addedline|deletedline)` match what MediaWiki +puts on edit-summary and diff-table elements, protecting them — though only +if [`$wgSmjDelimitersEnabled`](#wgsmjdelimitersenabled) is on (see +[Rendering internals](development.md#rendering-internals) for why). + +**Don't** replace the whole pattern with just your own class when extra +delimiters are on — the default's diff/comment protection goes with it: + +```php +wfLoadExtension( 'SimpleMathJax' ); +$wgSmjDelimitersEnabled = true; +// Don't: replaces the whole pattern, so it loses diff/comment protection. +$wgSmjIgnoreHtmlClass = 'my_custom_class'; +``` + +**Do**, only if you really need your own class, extend the default with `|` +instead — and add `class` to `$wgSmjAllowedAttributes` so it survives on +individual `` elements: + +```php +wfLoadExtension( 'SimpleMathJax' ); +$wgSmjDelimitersEnabled = true; +// Do, if you really need it: extends the default pattern instead of replacing it. +$wgSmjIgnoreHtmlClass = 'mathjax_ignore|comment|diff-(context|addedline|deletedline)|my_custom_class'; +$wgSmjAllowedAttributes = [ 'class' ]; +``` + +### `$wgSmjRevisionOverrides` + +`$wgSmjRevisionOverrides` applies different settings to different ranges of +an article's revisions. This doesn't happen on its own — a past revision only +keeps rendering with the settings that were in effect when it was current if +you map that revision range to those settings yourself, as below. Each entry +is an array whose other keys are the overrides to apply, plus: + +- A `min` and/or a `max` (the revision id bounds it applies to; either end + is open if omitted). Both bounds are inclusive. Revision ids increase + across the whole wiki, not per page — check the page's history for the + actual numbers rather than guessing from its edit count. +- Only the keys written inside `[]` are overwritten — the rest of your + `$wgSmj*` settings stay as they are. +- When more than one entry matches the same revision, later entries in the + list win — each one overwrites whatever the ones before it set for the + same key. +- Preview, History and Special pages are treated as revision `0`, a base + case where no entry ever applies, so they always render with the plain + `$wgSmj*` settings instead. + +```php +wfLoadExtension( 'SimpleMathJax' ); +$wgSmjDelimitersEnabled = false; // To match the preview with the actual rendering, write the latest settings in the base case +$wgSmjRevisionOverrides = [ + [ 'max' => 50000, 'wgSmjDelimitersEnabled' => true ], + [ 'min' => 50001, 'max' => 60000, 'wgSmjDelimitersEnabled' => false ], +]; +``` + +## Hooks + +The hook `SimpleMathJaxAttributes` is available to add attributes to the span around the math. (Note that this process is performed only for `` and `` elements, and other delimiters are handled directly by MathJax.) This hook provides you with the opportunity to ensure that your own code does not interfere with MathJax's rendering of math. + +For instance, if Lingo's JS functions are called before MathJax is invoked, then it is possible that Lingo will change the text so that MathJax could no longer render the math. + +Lingo understands that [it should not touch anything inside an element with the class `noglossary`](https://www.mediawiki.org/wiki/Extension:Lingo#Excluding_text_from_markup) so the following code can be used to keep Lingo from ruining math: + +```php +$wgHooks['SimpleMathJaxAttributes'][] + = function ( array &$attributes, string $tex, array $args = [] ) { + $attributes['class'] .= ' noglossary'; + }; +``` diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 00000000..d88d2b45 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,100 @@ +# Development + +## Updating MathJax + +Local and CDN MathJax versions are independent and managed separately — +there's no need to keep them in sync. + +**Local**: the `local-mathjax` make target pins the bundled MathJax +submodule to a tag. Run it from the repository root: + +```bash +make local-mathjax +``` + +The version is configured in the `Makefile`: `LOCAL_MATHJAX_VERSION` +defaults to `4.1.3`. Change this value in the `Makefile` when changing the +project's default version, or override it on the command line for a +one-off update (`make local-mathjax LOCAL_MATHJAX_VERSION=4.1.4`). + +**CDN**: `$wgSmjCdnVersion`'s default lives in `extension.json` like any +other setting's default — edit `SmjCdnVersion.value` there directly to +change what version new installs load from the CDN. + +## Rendering internals + +A few things in `includes/Hooks.php`/`resources/ext.SimpleMathJax.js` aren't +obvious from the code alone: + +**`\displaystyle{}`/`\textstyle{}` wrapping happens in `renderTex()`, not in +`renderMath()`.** It's only a rendering-mode default guess (no `display` +attribute means block-like `\displaystyle`, `display="inline"` means +`\textstyle`) — not part of what the editor wrote — so `renderTex()` applies +it itself, gated by the `$mathTag` flag (`` never gets it) and skipped +entirely when the element is ignored (see below), since an ignored element +is never typeset and would otherwise show that wrapping as literal, +meaningless text. + +**An ignored element (`class` matching `$wgSmjIgnoreHtmlClass`) skips both +`smj-container` and the `[math]...[/math]`/`\begin{displaymjx}...` wrapping, +and drops the `opacity:.5` style.** `smj-container` is also listed in the JS +module's `processHtmlClass`, which makes MathJax typeset an element even if +it (or an ancestor) matches `ignoreHtmlClass` — needed so ordinary `` +tags still render inside a diff/comment wrapper. That same override would +defeat an editor deliberately opting one `` out via a class matching +`$wgSmjIgnoreHtmlClass` (e.g. `class="mathjax_ignore"`), so `renderTex()` +skips adding it in that case. And since an ignored element is never +typeset, `opacity:.5` and the delimiter wrapping — both only meaningful as a +placeholder MathJax is expected to replace — would otherwise permanently +show a half-transparent, undelimited blob of TeX source instead of the +plain text an editor expects. + +**`renderMath()` sets `smjPreloadChem` unconditionally for ``, +not gated by `display`.** The `chem` attribute only preloads the mhchem +package (a JS optimization); it's unrelated to display handling, and +`...` itself always behaves the same way regardless of +display (see `renderChem()`), so there's no reason to gate `` +differently. + +**`applyRevisionOverrides()` is a free function, not inlined into +`onParserFirstCallInit()`.** That's so it can be unit-tested as a pure +function without a MediaWiki bootstrap — see `tests/RevisionOverridesTest.php`. + +**`ext.SimpleMathJax.js` always adds `['[math]','[/math]']` to MathJax's +`inlineMath` list, regardless of `$wgSmjDelimiters*`.** That's +SimpleMathJax's own internal marker for the ``/`` output +`renderTex()` wraps TeX in (see above), not wikitext syntax an editor would +type themselves — MathJax needs it in its delimiter list to typeset that +output at all, independently of whatever bare delimiters the admin +configured for extra-delimiter scanning. + +**`$wgSmjIgnoreHtmlClass`'s diff/comment protection only matters with +`$wgSmjDelimitersEnabled` on.** `$wgSmjDelimitersEnabled` also +decides how much of the page MathJax scans: off (the default), the JS +module's `elements` option restricts it to its own `` output, so a diff or comment is never a target +regardless of `ignoreHtmlClass`; on, `elements` is `null` and MathJax scans +the whole page instead, including a diff table's literal text — that's what +the default `ignoreHtmlClass` pattern protects against. ``/`` +tags are never at risk in a diff either way, since MediaWiki shows diffs as +raw, unparsed wikitext — only bare delimiters can leak through there. + +**`matchesIgnoreHtmlClass()` picks its own `preg_match()` delimiter instead of +hardcoding `~`.** `$wgSmjIgnoreHtmlClass` is an admin-supplied regex fragment +with no delimiter of its own, so a hardcoded `~` would break for any pattern +that contains one; the function uses `~` unless the pattern contains it, in +which case it falls back to `\x01`, a control character unlikely to appear in +a class-matching regex. A failed/invalid pattern is treated as "does not +match" rather than suppressed with `@`, so a broken regex surfaces instead of +silently misbehaving. + +## Quote protection + +A few things in `includes/Quotes.php` aren't obvious from the code alone: + +**`protectQuotesInMath()` re-indexes each delimiter pair with `array_values()` +before reading `$open`/`$close`.** List-assignment (`[ $open, $close ] = $pair`) +reads keys 0 and 1 by position, not by order of insertion, so a pair with +non-sequential or associative keys (e.g. `[ 1 => '$', 2 => '$' ]`) would +otherwise leave `$open` undefined and emit a warning before the pair is +filtered out below. diff --git a/docs/displaystyle.md b/docs/displaystyle.md new file mode 100644 index 00000000..9892e441 --- /dev/null +++ b/docs/displaystyle.md @@ -0,0 +1,61 @@ +# Display styles + +SimpleMathJax follows MediaWiki Math's display conventions for ``. + +| Syntax | Layout | TeX style | +| --- | --- | --- | +| `...` | inline | `\displaystyle` | +| `...` | inline | `\textstyle` | +| `...` | block, centered | `\displaystyle` | + +The `display` attribute is always processed. + +Examples: + +```wikitext +Inline display-style: \frac{1}{2} + +Inline text-style: \frac{1}{2} + +Block display-style: +a^2 + b^2 = c^2 + +``` + +The default `` behavior intentionally matches MediaWiki Math: the +formula remains in the text flow, while fractions, sums, and integrals use +display-style sizing. + +Block formulas are centered, as in MediaWiki Math. Inline formulas use the +surrounding text flow. + +## `\displaystyle` and `\textstyle` + +These are TeX math styles used by MathJax. They control the size and layout +of parts of a formula; they do not control whether the surrounding HTML is +inline or block. + +`\displaystyle` is the larger, equation-oriented style. Fractions are +larger, and sum and integral operators have room for larger limits. +It is the default style for `` and `display="block"`. + +`\textstyle` is the more compact, prose-oriented style. It is useful when +the formula should fit comfortably inside a sentence, especially when it +contains fractions or large operators. It is used by `display="inline"`. + +For example, these two formulas have the same inline placement but different +math styles: + +```wikitext +\sum_{i=0}^\infty \frac{1}{2^i} +\sum_{i=0}^\infty \frac{1}{2^i} +``` + +## `chem` attribute + +`` only preloads the mhchem package — unlike `...`, +it doesn't wrap the content in `\ce{...}` for you, so write that yourself: + +```wikitext +\ce{CO2 + C -> 2 CO} +``` diff --git a/docs/mig-1.0.md b/docs/mig-1.0.md new file mode 100644 index 00000000..6eecb0c8 --- /dev/null +++ b/docs/mig-1.0.md @@ -0,0 +1,182 @@ +# Migrating from pre-1.0.0 + +1.0.0 redesigns the `$wgSmj*` settings with no backward compatibility, to +fix names and defaults that no longer matched what they actually did (see +the per-setting notes below for specifics). This section covers what +admins with an existing `LocalSettings.php` need to change. Every setting +is one flat `$wgSmj*` global — there's no nested-array structure to learn, +including for the settings that used to be (or briefly were) grouped, like +CDN and extra-delimiter scanning. + +Before upgrading, find every `$wgSmj*` line in your `LocalSettings.php` and +follow the table below. **Anything left unchanged is silently ignored** — +the old names are no longer read in 1.0.0. + +> ⚠️ **This is not a pure rename.** Two defaults actually flip: +> - Bare `$...$`/`$$...$$` math scanning now defaults to *off* (see +> `$wgSmjDelimitersEnabled` below). If you never explicitly set +> `$wgSmjDirectMathJax`/`$wgSmjExtraInlineMath`/`$wgSmjDisplayMath` and +> relied on their old default (`'full'`), those formulas **stop +> rendering** unless you add `$wgSmjDelimitersEnabled = true`. + +### Renamed only + +| Before 1.0.0 | 1.0.0 | +| --- | --- | +| `$wgSmjUseCdn` | `$wgSmjCdnEnabled` | +| `$wgSmjConfigByRevision` | `$wgSmjRevisionOverrides` | + +`$wgSmjRevisionOverrides` keeps the same overall structure, but each entry's +own `upto`/`since` bounds are renamed to `max`/`min`: + +```diff +- $wgSmjConfigByRevision = [ [ 'upto' => 1048576, 'wgSmjScale' => 1 ] ]; ++ $wgSmjRevisionOverrides = [ [ 'max' => 1048576, 'wgSmjScale' => 1 ] ]; +``` + +There's also a new, unrelated setting: `$wgSmjCdnVersion` (default `'4'`) +pins the CDN to a specific MathJax version — there was no equivalent before +1.0.0, since the CDN URL was hardcoded to `mathjax@4` with no way to pin it. + +### `$wgSmjUseChem` removed — `` is always registered now + +There was never a real reason to turn `` off: it's the same tag name +MediaWiki's own [Math extension](https://www.mediawiki.org/wiki/Extension:Math/Syntax) +uses for chemical formulas, so a wiki that wants chemistry markup at all +wants this specific tag, and a wiki running both Math and SimpleMathJax +was never workable anyway — they'd already collide on `` itself, +which has no such toggle. If you had `$wgSmjUseChem = false;`, just remove +the line; `` now behaves like `` and is not configurable. + +### Unchanged + +These are unchanged in name, type, and meaning — nothing to do: + +`$wgSmjIgnoreHtmlClass`, `$wgSmjScale`, `$wgSmjEnableMenu` + +### `$wgSmjEnableHtmlAttributes` becomes an allow-list + +Before 1.0.0, this single boolean controlled **all** attribute processing +on ``/`` tags — not just generic HTML attributes like +`id`/`class`/`title`/`lang`/`dir`, but also SimpleMathJax's own +rendering-control attributes like `display="block"` and +`chem`. (`style` was never one of the passthrough attributes — see +[includes/Hooks.php:140](../includes/Hooks.php#L140).) + +As of 1.0.0, generic HTML attributes are controlled by an allow-list +instead of an on/off switch. Rendering attributes such as `display` are +always supported. + +### `inline-block` removed + +The former SimpleMathJax-only attribute: + +```diff +- ... ++ ... +``` + +The default `` rendering is now inline with `\displaystyle`, so the +attribute is no longer needed. + +**If this affects you**: if your site set `$wgSmjEnableHtmlAttributes = +false;` before 1.0.0, that also silently disabled `` +and friends. In 1.0.0, rendering attributes are always supported. + +```diff +- $wgSmjEnableHtmlAttributes = false; ++ $wgSmjAllowedAttributes = []; // no HTML attribute passthrough: unchanged (this is the default, so it can be omitted) +``` + +If your site set `$wgSmjEnableHtmlAttributes = true;`, the equivalent is +listing all five names explicitly: + +```diff +- $wgSmjEnableHtmlAttributes = true; ++ $wgSmjAllowedAttributes = [ 'class', 'id', 'title', 'lang', 'dir' ]; +``` + +While you're here, consider narrowing the list instead of listing all +five — e.g. `[ 'class', 'title' ]` covers the common case of a CSS styling +hook plus a hover tooltip, without carrying over `id` (which can collide +with other elements on the page). + +### `$wgSmjDirectMathJax` / `$wgSmjDisplayMath` / `$wgSmjExtraInlineMath` → `$wgSmjDelimiters*` + +These three settings become three differently-named flat settings — +`$wgSmjDelimitersEnabled`, `$wgSmjDelimitersInlineMath`, +`$wgSmjDelimitersDisplayMath` — and the `"full"`/`"env"`/`"none"` +three-way mode collapses into a single boolean (`Enabled`). +**The `"env"` mode is gone** — it used to let you turn `\ref`/escape +handling on or off separately, but turning escapes off only made stray +`\$`/`\\` more likely to be misread as delimiters, and turning `\ref` off +did nothing for sites that didn't use it — so there was never a real +reason to pick it over `"full"`. It's folded into `Enabled`. + +**The default also flips, from on to off.** `$wgSmjDirectMathJax` used to +default to `'full'` (bare `$...$` math worked out of the box). +`$wgSmjDelimitersEnabled`'s default is now `false` — only +``/`` tags work out of the box, matching this extension's +original contract ("TeX between `` and ``"). This is a real +behavior change, not just a rename: **if you never touched these settings, +you are affected.** + +```diff +- // (nothing set — relied on the old default, 'full') ++ $wgSmjDelimitersEnabled = true; // keep bare $...$/$$...$$ working after the upgrade +``` + +```diff +- $wgSmjDirectMathJax = 'full'; ++ $wgSmjDelimitersEnabled = true; +``` + +```diff +- $wgSmjExtraInlineMath = [ [ '$', '$' ] ]; +- $wgSmjDisplayMath = [ [ '$$', '$$' ] ]; ++ $wgSmjDelimitersEnabled = true; ++ $wgSmjDelimitersInlineMath = [ [ '$', '$' ] ]; ++ $wgSmjDelimitersDisplayMath = [ [ '$$', '$$' ] ]; +``` + +```diff +- $wgSmjDirectMathJax = 'none'; ++ // (nothing needed — the new default already behaves like 'none') +``` + +```diff +- $wgSmjDirectMathJax = 'env'; ++ $wgSmjDelimitersEnabled = true; // \ref and escapes now come along too — fine for most sites +``` + +### If `$wgSmjRevisionOverrides` overrode `$wgSmjDirectMathJax` + +Just use the new flat key directly — there's no array to reach into, so no +dot path is needed: + +```diff + $wgSmjRevisionOverrides = [ +- [ 'upto' => 2000000, 'wgSmjDirectMathJax' => 'none' ], ++ [ 'max' => 2000000, 'wgSmjDelimitersEnabled' => false ], + ]; +``` + +### Checklist + +1. Rename `$wgSmjConfigByRevision` → `$wgSmjRevisionOverrides`, and rename + each entry's `upto`/`since` bounds to `max`/`min`. Rename `$wgSmjUseCdn` + → `$wgSmjCdnEnabled` (same boolean value, just a new name — no wrapping + needed). Remove `$wgSmjUseChem` if you had it — `` is always + registered now. +2. If you had `$wgSmjEnableHtmlAttributes` set to anything, replace it with + `$wgSmjAllowedAttributes` + (list) — `[]` for the old `false`, or list the attributes you want to + preserve for the old `true` behavior. +3. **If you want bare `$...$`/`$$...$$` math to keep working**, add + `$wgSmjDelimitersEnabled = true` explicitly — this is now required + even if you never set `$wgSmjDirectMathJax` before, since the default + flipped from on to off. If you used `$wgSmjDisplayMath`/`$wgSmjExtraInlineMath`, + rename them to `$wgSmjDelimitersDisplayMath`/`$wgSmjDelimitersInlineMath`. + If you used `'env'`, be aware it now behaves like `'full'`. +4. If `$wgSmjConfigByRevision` overrode `$wgSmjDirectMathJax` or another + renamed setting, just use its new flat key name — no dot path needed. diff --git a/docs/screenshots/screenshot-custom01-diff.png b/docs/screenshots/screenshot-custom01-diff.png new file mode 100644 index 00000000..53fce2fe Binary files /dev/null and b/docs/screenshots/screenshot-custom01-diff.png differ diff --git a/docs/screenshots/screenshot-custom01-rightclick.png b/docs/screenshots/screenshot-custom01-rightclick.png new file mode 100644 index 00000000..0e710811 Binary files /dev/null and b/docs/screenshots/screenshot-custom01-rightclick.png differ diff --git a/docs/screenshots/screenshot-custom01.png b/docs/screenshots/screenshot-custom01.png new file mode 100644 index 00000000..3acc800b Binary files /dev/null and b/docs/screenshots/screenshot-custom01.png differ diff --git a/docs/screenshots/screenshot-custom02-diff.png b/docs/screenshots/screenshot-custom02-diff.png new file mode 100644 index 00000000..7397f3c5 Binary files /dev/null and b/docs/screenshots/screenshot-custom02-diff.png differ diff --git a/docs/screenshots/screenshot-custom02.png b/docs/screenshots/screenshot-custom02.png new file mode 100644 index 00000000..b5d621bb Binary files /dev/null and b/docs/screenshots/screenshot-custom02.png differ diff --git a/docs/screenshots/screenshot-default01.png b/docs/screenshots/screenshot-default01.png new file mode 100644 index 00000000..4da6ef84 Binary files /dev/null and b/docs/screenshots/screenshot-default01.png differ diff --git a/docs/screenshots/screenshot-default02-diff.png b/docs/screenshots/screenshot-default02-diff.png new file mode 100644 index 00000000..f9c2ec0b Binary files /dev/null and b/docs/screenshots/screenshot-default02-diff.png differ diff --git a/docs/screenshots/screenshot-default02-rightclick.png b/docs/screenshots/screenshot-default02-rightclick.png new file mode 100644 index 00000000..368d410f Binary files /dev/null and b/docs/screenshots/screenshot-default02-rightclick.png differ diff --git a/docs/screenshots/screenshot-default02.png b/docs/screenshots/screenshot-default02.png new file mode 100644 index 00000000..9931c563 Binary files /dev/null and b/docs/screenshots/screenshot-default02.png differ diff --git a/extension.json b/extension.json index 1b026bb0..5f6a5378 100644 --- a/extension.json +++ b/extension.json @@ -1,6 +1,6 @@ { "name": "SimpleMathJax", - "version": "0.12.0", + "version": "1.0.0", "author": "jmnote", "url": "https://www.mediawiki.org/wiki/Extension:SimpleMathJax", "description": "render TeX between and ", @@ -13,18 +13,46 @@ "MediaWiki\\Extension\\SimpleMathJax\\": "includes/" }, "config": { - "SmjUseCdn": {"value":true, "description":"Whether to load MathJax from CDN"}, - "SmjUseChem": {"value":true, "description":"Whether to enable tag"}, - "SmjDirectMathJax": {"value":"full", "description":"Direct MathJax mode: 'full', 'env', or 'none' (lazy load)"}, - "SmjDisplayMath": {"value":[], "description":"Delimiters for MathJax.tex.displayMath"}, - "SmjExtraInlineMath": {"value":[], "description":"Delimiters for MathJax.tex.inlineMath"}, - "SmjIgnoreHtmlClass": {"value":"mathjax_ignore|comment|diff-(context|addedline|deletedline)", "description":"Pattern for MathJax.options.ignoreHtmlClass"}, - "SmjScale": {"value":1, "description":"Output scale for MathJax.chtml.scale"}, - "SmjEnableMenu": {"value":true, "description":"Whether to enable MathJax context menu"}, - "SmjDisplayAlign": {"value":"left", "description":"Alignment for MathJax.chtml.displayAlign"}, - "SmjWrapDisplaystyle": {"value":true, "description":"Whether to wrap in displaystyle"}, - "SmjEnableHtmlAttributes": {"value":false, "description":"Whether to process HTML attributes on "}, - "SmjConfigByRevision": {"value":[], "description":"Revision-based configuration overrides"} + "SmjCdnEnabled": { + "value": true, + "description": "Whether to load MathJax from a CDN instead of the bundled local copy" + }, + "SmjCdnVersion": { + "value": "4", + "description": "MathJax version to load from the CDN" + }, + "SmjDelimitersEnabled": { + "value": false, + "description": "Whether to also scan for bare delimiters (e.g. $...$), outside of /" + }, + "SmjDelimitersInlineMath": { + "value": [], + "description": "Inline math delimiter pairs for extra delimiter scanning, e.g. [['$','$']]" + }, + "SmjDelimitersDisplayMath": { + "value": [], + "description": "Display math delimiter pairs for extra delimiter scanning, e.g. [['$$','$$']]" + }, + "SmjIgnoreHtmlClass": { + "value": "mathjax_ignore|comment|diff-(context|addedline|deletedline)", + "description": "Pattern for MathJax.options.ignoreHtmlClass" + }, + "SmjScale": { + "value": 1, + "description": "Output scale for MathJax.chtml.scale" + }, + "SmjEnableMenu": { + "value": true, + "description": "Whether to enable MathJax context menu" + }, + "SmjAllowedAttributes": { + "value": [], + "description": "Generic HTML attributes to carry over from / to the output " + }, + "SmjRevisionOverrides": { + "value": [], + "description": "Revision-based configuration overrides" + } }, "Hooks": { "ParserFirstCallInit": "MediaWiki\\Extension\\SimpleMathJax\\Hooks::onParserFirstCallInit", @@ -32,7 +60,9 @@ }, "ResourceModules": { "ext.SimpleMathJax": { - "scripts": ["resources/ext.SimpleMathJax.js"] + "scripts": [ + "resources/ext.SimpleMathJax.js" + ] } }, "ResourceFileModulePaths": { diff --git a/hack/demo/demo.sh b/hack/demo/demo.sh new file mode 100755 index 00000000..cf97dd2e --- /dev/null +++ b/hack/demo/demo.sh @@ -0,0 +1,222 @@ +#!/usr/bin/env bash +# Local test wiki: the official `mediawiki` Docker image + SQLite (no +# separate DB container), with this repo bind-mounted in as the extension — +# no MediaWiki core checkout needed. See AGENTS.md. +# +# Usage: hack/demo/demo.sh [up|down] [demo] +# hack/demo/demo.sh screenshot [demo] +# `demo` is a top-level key in hack/demo/demos.yaml (e.g. default01, custom01); +# its capture is saved to docs/screenshots/screenshot-.png. Defaults +# to `default01`; `screenshot` with no `demo` given screenshots every demo in +# demos.yaml. +set -euo pipefail +DEMO_DIR="$(cd "$(dirname "$0")" && pwd)" +cd "$DEMO_DIR/../.." + +IMAGE=mediawiki:1.43 +NAME=demo +PORT=8080 +DATA="$PWD/hack/demo/temp" +DOCKER_USER=33:33 +PASS=demo12345678 + +# Extracts a query.tokens.* field from an API JSON response (properly +# unescaped — plain grep/sed would mangle tokens containing backslashes). +# Uses php since this project already requires it, rather than reaching for +# another language just for a one-line JSON parse. +json_field() { + php -r 'echo json_decode(stream_get_contents(STDIN), true)["query"]["tokens"][$argv[1]];' "$1" +} + +# Extracts the new revision id from an action=edit API JSON response, so a +# demo with `addDiffShot: true` can link straight to "diff against the +# previous revision" without a second API round-trip to look it up. +edit_new_revid() { + php -r 'echo json_decode(stream_get_contents(STDIN), true)["edit"]["newrevid"];' +} + +# Prints a demo's `settings:` block from docs/demos.yaml — the raw PHP +# lines appended into LocalSettings.php (see up()) and shown on the demo +# page in a block. See render.php. +local_settings_body() { + php "$DEMO_DIR/render.php" "$DEMO_DIR/demos.yaml" "$1" settings +} + +# Prints a demo's `examples:` list from docs/demos.yaml, each rendered as a +# syntaxhighlight block next to its live render, in a responsive flex row. +# See render.php. +render_examples() { + php "$DEMO_DIR/render.php" "$DEMO_DIR/demos.yaml" "$1" examples +} + +# Logs in as Admin and edits a page called "Demo" (not "Main Page", which +# the installer already fills with its own default content) with a small +# demo showing primes inside $...$/$$...$$ surviving wikitext emphasis +# parsing, prefixed with the demo's own `settings:` block (wrapped in +# ) for context. Blanks the page first so the demo edit +# always has an empty previous revision to diff against — a real +# two-column diff, not just a "page creation" summary — regardless of how +# many times this runs against the same wiki (see screenshot_one's +# addDiffShot handling). Re-run on every up so editing a demo's entry in +# docs/demos.yaml and re-running `up` (or `screenshot`) always shows the +# latest content. +seed_demo_page() { + local demo="$1" + local jar url="http://localhost:$PORT/api.php" + jar=$(mktemp) + local login_token + login_token=$(curl -s -c "$jar" "$url?action=query&meta=tokens&type=login&format=json" | json_field logintoken) + curl -s -b "$jar" -c "$jar" \ + --data-urlencode "action=login" --data-urlencode "lgname=Admin" \ + --data-urlencode "lgpassword=$PASS" --data-urlencode "lgtoken=$login_token" \ + --data-urlencode "format=json" "$url" >/dev/null + local csrf_token + csrf_token=$(curl -s -b "$jar" -c "$jar" "$url?action=query&meta=tokens&format=json" | json_field csrftoken) + + curl -s -b "$jar" -c "$jar" \ + --data-urlencode "action=edit" --data-urlencode "title=Demo" \ + --data-urlencode "text=" \ + --data-urlencode "token=$csrf_token" --data-urlencode "format=json" "$url" >/dev/null + + local page + page=$(mktemp) + { + echo '' + local_settings_body "$demo" + echo '' + echo + render_examples "$demo" + } > "$page" + + local edit_response + edit_response=$(curl -s -b "$jar" -c "$jar" \ + --data-urlencode "action=edit" --data-urlencode "title=Demo" \ + --data-urlencode "text@$page" \ + --data-urlencode "token=$csrf_token" --data-urlencode "format=json" "$url") + rm -f "$jar" "$page" + echo "$edit_response" | edit_new_revid > "$DATA/last_revid" + echo "==> Seeded the Demo page with the $demo demo" +} + +wait_for_wiki() { + # Main Page, not Demo: it's the installer's own default page, so it + # already exists the moment the wiki responds — unlike Demo, which + # seed_demo_page hasn't created yet at this point. + for _ in $(seq 1 30); do + curl -sf -o /dev/null "http://localhost:$PORT/index.php/Main_Page" && return 0 + sleep 1 + done + echo "Wiki did not come up in time" >&2 + return 1 +} + +up() { + local demo="${1:-default01}" + mkdir -p "$DATA" + chmod 777 "$DATA" + if [ ! -f "$DATA/LocalSettings.php" ]; then + echo "==> First run: installing MediaWiki into $DATA" + docker run --rm --user "$DOCKER_USER" \ + -v "$DATA:/var/www/html/data" \ + -v "$PWD:/var/www/html/extensions/SimpleMathJax:ro" \ + "$IMAGE" \ + php maintenance/run.php install \ + --confpath /var/www/html/data \ + --dbtype sqlite --dbpath /var/www/html/data \ + --scriptpath "" --server "http://localhost:$PORT" \ + --pass "$PASS" \ + --extensions SyntaxHighlight_GeSHi \ + SimpleMathJax Admin + local_settings_body "$demo" >> "$DATA/LocalSettings.php" + fi + docker rm -f "$NAME" >/dev/null 2>&1 || true + docker run --rm -d --name "$NAME" --user "$DOCKER_USER" \ + -p "$PORT:80" \ + -v "$DATA:/var/www/html/data" \ + -v "$DATA/LocalSettings.php:/var/www/html/LocalSettings.php:ro" \ + -v "$PWD:/var/www/html/extensions/SimpleMathJax:ro" \ + "$IMAGE" + echo "==> Wiki running at http://localhost:$PORT (Admin / $PASS)" + wait_for_wiki && seed_demo_page "$demo" +} + +# Stops the container and wipes its data, so the next `up` reinstalls fresh +# (also how you pick up changes to a demo's `settings:` block, which is +# only applied during install). +down() { + docker rm -f "$NAME" >/dev/null 2>&1 || true + rm -rf "$DATA" +} + +# Screenshots one demo's page with a real browser (Puppeteer); see +# screenshot.mjs. Forces a fresh install (down, then up) so the demo's own +# `settings:` block is guaranteed to be the one in effect, then saves to +# docs/screenshots/screenshot-.png. A demo with `addRightClickShot: true` +# (see render.php) gets one extra screenshot, +# docs/screenshots/screenshot--rightclick.png, after right-clicking its +# first mjx-container — needed to show MathJax's context menu, e.g. for +# $wgSmjEnableMenu, since the normal capture never triggers one. A demo with +# `addDiffShot: true` gets a separate extra screenshot, +# docs/screenshots/screenshot--diff.png, of the Demo page's diff +# against the blank revision seed_demo_page saves right before its real +# edit — needed to show $wgSmjIgnoreHtmlClass keeping bare-delimiter +# scanning out of diff views, since a diff is a different page/URL entirely, +# not something a click on the normal capture can reveal. +screenshot_one() { + local demo="$1" + down + up "$demo" + if [ ! -d "$DEMO_DIR/node_modules" ]; then + echo "==> Installing screenshot dependencies (npm install)" + ( cd "$DEMO_DIR" && npm install ) + fi + local right_click="" + if [ "$(php "$DEMO_DIR/render.php" "$DEMO_DIR/demos.yaml" "$demo" addrightclickshot)" = "true" ]; then + right_click=mjx-container + fi + mkdir -p "$PWD/docs/screenshots" + ( + cd "$DEMO_DIR" && + URL="http://localhost:$PORT/index.php/Demo" \ + OUT="$PWD/../../docs/screenshots/screenshot-$demo.png" \ + RIGHT_CLICK="$right_click" \ + RIGHT_CLICK_OUT="$PWD/../../docs/screenshots/screenshot-$demo-rightclick.png" \ + node screenshot.mjs + ) + if [ "$(php "$DEMO_DIR/render.php" "$DEMO_DIR/demos.yaml" "$demo" adddiffshot)" = "true" ]; then + local revid + revid=$(<"$DATA/last_revid") + ( + cd "$DEMO_DIR" && + URL="http://localhost:$PORT/index.php?title=Demo&diff=prev&oldid=$revid" \ + OUT="$PWD/../../docs/screenshots/screenshot-$demo-diff.png" \ + node screenshot.mjs + ) + fi +} + +# Screenshots every demo in docs/demos.yaml when none is named. +screenshot() { + if [ -n "${1:-}" ]; then + screenshot_one "$1" + return + fi + # Regenerating every demo: clear old captures first so a demo that got + # renamed or removed from demos.yaml doesn't leave a stale screenshot + # behind under its old name. + rm -f "$PWD/docs/screenshots"/screenshot-*.png + local demo + for demo in $(sed -n 's/^- name: //p' "$DEMO_DIR/demos.yaml"); do + screenshot_one "$demo" + done +} + +case "${1:-up}" in + up) up "${2:-}" ;; + down) down ;; + screenshot) screenshot "${2:-}" ;; + *) + echo "Usage: $0 [up|down] [demo] | $0 screenshot [demo]" >&2 + exit 1 + ;; +esac diff --git a/hack/demo/demos.yaml b/hack/demo/demos.yaml new file mode 100644 index 00000000..93575529 --- /dev/null +++ b/hack/demo/demos.yaml @@ -0,0 +1,54 @@ +- name: default01 + examples: + - | + + \frac{1}{\Bigl(\sqrt{\phi \sqrt{5}}-\phi\Bigr) e^{\frac25 \pi}} = + 1+\frac{e^{-2\pi}} {1+\frac{e^{-4\pi}} {1+\frac{e^{-6\pi}} + {1+\frac{e^{-8\pi}} {1+\ldots} } } } + + - "CO2 + C -> 2 CO" + - '전압 = 전류 \times 저항' + - '速さ = \frac{道のり}{時間}' + - 'E=mc^2' +- name: default02 + addDiffShot: true + addRightClickShot: true + examples: + - "E=mc^2" + # $wgSmjAllowedAttributes is [] by default, so 'class' is stripped here + # and this renders anyway — see custom01 for the working version, where + # $wgSmjAllowedAttributes includes 'class'. + - 'E=mc^2' +- name: custom01 + addRightClickShot: true + addDiffShot: true + settings: | + $wgSmjCdnEnabled = false; + $wgSmjScale = 1.5; + $wgSmjAllowedAttributes = [ 'style', 'class' ]; + $wgSmjDelimitersEnabled = true; + $wgSmjDelimitersInlineMath = [ [ '$', '$' ] ]; + $wgSmjDelimitersDisplayMath = [ [ '$$', '$$' ] ]; + examples: + - "CO2 + C -> 2 CO" + - "''Italic'' works." + - "$y'' = 2a_2 + 6a_3 x$." + - "'''Bold''' works." + - "$f'''(x) = 6a_3$" + - '$$e^{i\pi} + 1 = 0$$' + - 'e^{i\pi} + 1 = 0' + - 'E=mc^2' + - "E=mc^2" + - 'e^{i\pi} + 1 = 0' +- name: custom02 + addDiffShot: true + settings: | + $wgSmjDelimitersEnabled = true; + $wgSmjDelimitersInlineMath = [ [ '$', '$' ] ]; + $wgSmjDelimitersDisplayMath = [ [ '$$', '$$' ] ]; + // Don't: the default is already good. + // Blanked to show diffs rendering unnecessarily. + $wgSmjIgnoreHtmlClass = 'mathjax_ignore'; + examples: + - "$y' = 2a_2 + 6a_3 x$." + - '$$e^{i\pi} + 1 = 0$$' diff --git a/hack/demo/package-lock.json b/hack/demo/package-lock.json new file mode 100644 index 00000000..c5e71d50 --- /dev/null +++ b/hack/demo/package-lock.json @@ -0,0 +1,410 @@ +{ + "name": "simplemathjax-demo", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "simplemathjax-demo", + "devDependencies": { + "@fontsource/noto-sans-kr": "^5.3.0", + "@fontsource/noto-serif-kr": "^5.3.0", + "puppeteer": "^25.10.0" + } + }, + "node_modules/@fontsource/noto-sans-kr": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/noto-sans-kr/-/noto-sans-kr-5.3.0.tgz", + "integrity": "sha512-/JnpTjaCOXW7xUoqOyCVYSr05VOkDy5Yla9O+WAEiS7u+yYLsmHoqj6v1W0bf91G8G3XA2+NnRS3CX2Tf/FVZg==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/noto-serif-kr": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@fontsource/noto-serif-kr/-/noto-serif-kr-5.3.0.tgz", + "integrity": "sha512-PMVJPol7ZsWntq4p1blZnhzl1hJruzzQZbWOZJPnJriw+nEUPYyG+pkWCCHfbziZ6OolSxl1o05Z0gy4iMGmPA==", + "dev": true, + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@puppeteer/browsers": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.2.tgz", + "integrity": "sha512-q2BU4YfO9h/Wt7IcWPcggpOOqLk2Tbs1hDwolvKZrweRjy751OJBKMN9zO5bfD0pzU7X/tvKw/exQds4pM/LOg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "modern-tar": "^0.8.4", + "yargs": "^18.0.0" + }, + "bin": { + "browsers": "lib/main-cli.js" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "proxy-agent": ">=8.0.1", + "yauzl": "^2.10.0 || ^3.4.0" + }, + "peerDependenciesMeta": { + "proxy-agent": { + "optional": true + }, + "yauzl": { + "optional": true + } + } + }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chromium-bidi": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-17.0.2.tgz", + "integrity": "sha512-5v9GQFhTktFvotn/OFNJBmKLKRAb6n9r0bVCwf7sHgWc3/JryK0bj1nn93L3pHFrfgcsu6Be6EWsDi+1XHTGDg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "mitt": "^3.0.1", + "zod": "^3.24.1" + }, + "engines": { + "node": ">=20.19.0 <22.0.0 || >=22.12.0" + }, + "peerDependencies": { + "devtools-protocol": "*" + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/devtools-protocol": { + "version": "0.0.1666840", + "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1666840.tgz", + "integrity": "sha512-gCcO42XCHKEs7Ag0S7aGYsnJ7hlgrO3qderYqeiY0Eqk+0GFfuvT13IA0hHreJTa2KCdDVyGMeOhdMNmrrTjVg==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "dev": true, + "license": "MIT" + }, + "node_modules/modern-tar": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.4.tgz", + "integrity": "sha512-gN54ddmyzEg10orwZ2u4OOv+bjpMWdIl5jIkodK97bMq8QBSL5c0D7YX0lT1Ooz+99S7+PvFbnxzdjgHo1r41g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/puppeteer": { + "version": "25.10.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-25.10.0.tgz", + "integrity": "sha512-9ZfkiaZDQWpGPJp9XTS+Bkn/D78hPvYmtjPfIBeybn05oeY6Jj7aiSbYdfcSQD2UMvC0vE7Yi9PSDo179euRzw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.2.2", + "chromium-bidi": "17.0.2", + "devtools-protocol": "0.0.1666840", + "lilconfig": "^3.1.3", + "puppeteer-core": "25.10.0", + "typed-query-selector": "^2.12.2" + }, + "bin": { + "puppeteer": "lib/puppeteer/node/cli.js" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/puppeteer-core": { + "version": "25.10.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.10.0.tgz", + "integrity": "sha512-Hy5eMQshOEMil4JUUx03h5pw1HYkYCso1RG/gcpPlFSd4cYPOcopxcXEAxpLPOkOPJb9LIJtwxuj66bSdvknFg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@puppeteer/browsers": "3.2.2", + "chromium-bidi": "17.0.2", + "devtools-protocol": "0.0.1666840", + "typed-query-selector": "^2.12.2", + "webdriver-bidi-protocol": "0.4.3", + "ws": "^8.21.3" + }, + "engines": { + "node": ">=22.12.0" + } + }, + "node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/typed-query-selector": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz", + "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.3.tgz", + "integrity": "sha512-uuN0goWfxP22B7J/uAgBpOYNPttC+XVseYE+rSY5+rQ+YBeVz/VORw8WbmLVcqW78zNg5A4qnjNXYUWR3il2ig==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/hack/demo/package.json b/hack/demo/package.json new file mode 100644 index 00000000..740398b3 --- /dev/null +++ b/hack/demo/package.json @@ -0,0 +1,13 @@ +{ + "name": "simplemathjax-demo", + "private": true, + "description": "Dev-only tooling for the local test wiki (see demo.sh). Not part of the SimpleMathJax extension itself.", + "scripts": { + "screenshot": "node screenshot.mjs" + }, + "devDependencies": { + "@fontsource/noto-sans-kr": "^5.3.0", + "@fontsource/noto-serif-kr": "^5.3.0", + "puppeteer": "^25.10.0" + } +} diff --git a/hack/demo/render.php b/hack/demo/render.php new file mode 100644 index 00000000..e08cca9f --- /dev/null +++ b/hack/demo/render.php @@ -0,0 +1,185 @@ + settings prints `wfLoadExtension( +// 'SimpleMathJax' );` followed by the +// demo's `settings:` literal block +// (its $wgSmj* overrides only — every +// demo needs the wfLoadExtension line, +// so it isn't repeated in demos.yaml) +// — raw PHP appended into +// LocalSettings.php (see up()) and +// shown on the demo page in a +// block. +// render.php examples prints the demo's `examples:` list +// as wikitext, each example as a +// syntaxhighlight block next to its +// live render, laid out in a +// responsive flex row. +// render.php addrightclickshot prints "true" if the demo has +// `addRightClickShot: true`. +// render.php adddiffshot prints "true" if the demo has +// `addDiffShot: true`. +// +// Only this narrow shape is supported, not general YAML: a top-level +// sequence of demo items (`- name: `), each an optional `settings:` +// literal block scalar (`|`), an `examples:` block sequence whose items are +// either quoted scalars or their own `- |` literal block scalar, and an +// optional `addRightClickShot: true` / `addDiffShot: true` scalar. +// YAML's C-style escaping for \\, \" and \n inside a double-quoted scalar +// is a strict subset of JSON's, so each quoted example is unescaped by +// wrapping it in JSON quotes and handing it to json_decode — this project +// already requires php (see demo.sh's json_field()), so reusing it here +// avoids a YAML library dependency just for this. + +[ , $path, $demo, $mode ] = $argv; + +// Slice out $demo's sub-document: lines after its "- name: " item +// header up to the next column-0 "-" (the following item) or EOF, then +// dedent them by their common indent so the rest of this script can treat +// them exactly like a standalone file. +$lines = []; +$capturing = false; +foreach ( file( $path ) as $line ) { + if ( !$capturing ) { + if ( preg_match( '/^-\s*name:\s*' . preg_quote( $demo, '/' ) . '\s*$/', $line ) ) { + $capturing = true; + } + continue; + } + if ( $line !== '' && $line[0] === '-' ) { + break; + } + $lines[] = $line; +} +if ( !$capturing ) { + fwrite( STDERR, "render.php: no demo named '$demo' found in $path\n" ); + exit( 1 ); +} +$indent = null; +foreach ( $lines as $line ) { + if ( trim( $line ) === '' ) { + continue; + } + $lineIndent = strlen( $line ) - strlen( ltrim( $line ) ); + $indent = $indent === null ? $lineIndent : min( $indent, $lineIndent ); +} +if ( $indent !== null ) { + foreach ( $lines as &$line ) { + if ( trim( $line ) !== '' ) { + $line = substr( $line, $indent ); + } + } + unset( $line ); +} + +if ( $mode === 'settings' ) { + $out = []; + $inBlock = false; + $indent = null; + foreach ( $lines as $line ) { + if ( !$inBlock ) { + if ( preg_match( '/^settings:\s*\|\s*$/', $line ) ) { + $inBlock = true; + } + continue; + } + if ( trim( $line ) === '' ) { + $out[] = "\n"; + continue; + } + $lineIndent = strlen( $line ) - strlen( ltrim( $line ) ); + $indent ??= $lineIndent; + if ( $lineIndent < $indent ) { + break; + } + $out[] = substr( $line, $indent ); + } + echo "wfLoadExtension( 'SimpleMathJax' );\n"; + if ( $out !== [] ) { + echo rtrim( implode( '', $out ) ) . "\n"; + } + exit; +} + +if ( $mode === 'examples' ) { + $examples = []; + $count = count( $lines ); + for ( $i = 0; $i < $count; $i++ ) { + $line = $lines[$i]; + if ( preg_match( '/^\s*-\s*"(.*)"\s*$/', $line, $m ) ) { + $examples[] = json_decode( "\"{$m[1]}\"" ); + continue; + } + if ( preg_match( "/^\s*-\s*'(.*)'\s*$/", $line, $m ) ) { + // YAML single-quoted scalars escape a literal apostrophe as ''. + $examples[] = str_replace( "''", "'", $m[1] ); + continue; + } + // A `- |` literal block scalar: every following line indented more + // than the "-" is taken verbatim (no quote-escaping) until indentation + // drops back to the item's own level or lower, then dedented by its + // own common indent and trailing blank lines clipped — long examples + // (e.g. a multi-line continued fraction) read better this way than + // escaped into one quoted line. + if ( preg_match( '/^(\s*)-\s*\|\s*$/', $line, $m ) ) { + $itemIndent = strlen( $m[1] ); + $blockLines = []; + $blockIndent = null; + for ( $i++; $i < $count; $i++ ) { + $next = $lines[$i]; + if ( trim( $next ) === '' ) { + // Each non-blank line below still carries its own + // trailing "\n" from file(), so joining with '' (not a + // "\n" glue) reproduces the source exactly — matching + // how the settings-mode block above is joined. + $blockLines[] = "\n"; + continue; + } + $nextIndent = strlen( $next ) - strlen( ltrim( $next ) ); + if ( $nextIndent <= $itemIndent ) { + break; + } + $blockIndent ??= $nextIndent; + $blockLines[] = substr( $next, $blockIndent ); + } + $i--; // the for loop's own $i++ will land back on the line that broke us out + $examples[] = rtrim( implode( '', $blockLines ) ); + } + } + // Column fragmentation ("column-count" below) makes the container its + // own block formatting context, so the first item's own top margin (a + // browser default on
, which  renders as) doesn't
+	// collapse into the page above it the way it normally would — visible
+	// as a gap above column 1 only, since a later column's break point
+	// isn't a "start" and so never re-applies that margin. Pull the whole
+	// block up by that amount to cancel it out.
+	echo '
'; + foreach ( $examples as $example ) { + echo "$example $example\n"; + } + echo '
'; + exit; +} + +if ( $mode === 'addrightclickshot' ) { + foreach ( $lines as $line ) { + if ( preg_match( '/^addRightClickShot:\s*true\s*$/', $line ) ) { + echo "true\n"; + break; + } + } + exit; +} + +if ( $mode === 'adddiffshot' ) { + foreach ( $lines as $line ) { + if ( preg_match( '/^addDiffShot:\s*true\s*$/', $line ) ) { + echo "true\n"; + break; + } + } + exit; +} + +fwrite( STDERR, "render.php: unknown mode '$mode' (want 'settings', 'examples', 'addrightclickshot' or 'adddiffshot')\n" ); +exit( 1 ); diff --git a/hack/demo/screenshot.mjs b/hack/demo/screenshot.mjs new file mode 100644 index 00000000..11698ed2 --- /dev/null +++ b/hack/demo/screenshot.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node +// Screenshots a page of the local test wiki (see demo.sh) with a real +// headless browser, so the captured image reflects MathJax's actual +// client-side rendering — not just the raw parsed HTML. +// +// Usage: URL=... OUT=... RIGHT_CLICK= RIGHT_CLICK_OUT=... node screenshot.mjs +// (env vars, not positional args, so setting only one of them can't shift +// the other — `make screenshot OUT=...` just works) +// +// RIGHT_CLICK, when set, takes one extra screenshot (to RIGHT_CLICK_OUT) +// after right-clicking the LAST element matching that CSS selector — used +// by demo.sh's `addRightClickShot: true` field to also show MathJax's context menu +// (only visible on right-click, so the plain OUT capture can't demonstrate +// $wgSmjEnableMenu on its own). The normal OUT capture always happens +// first, unaffected by this. +import puppeteer from 'puppeteer'; +import fs from 'node:fs'; +import path from 'node:path'; + +// The container/CI environment this runs in has no CJK fonts installed, so +// Korean/Japanese/Chinese text in a demo page (including inside MathJax's +// own output) would render blank. Rather than depend on system fonts +// (apt-get, different per host), load pinned Fontsource packages and apply +// them as fallbacks. One sans (regular text) and one serif (MathJax defaults +// to a serif TeX look), so each keeps the weight it would normally have. +const DEMO_DIR = path.dirname(new URL(import.meta.url).pathname); + +function localFontCss(packageName) { + const packageDir = path.join(DEMO_DIR, 'node_modules', '@fontsource', packageName); + let css = fs.readFileSync(path.join(packageDir, 'index.css'), 'utf8'); + return css.replace(/url\(\.\/files\/([^\)]+)\)/g, (match, filename) => { + const font = fs.readFileSync(path.join(packageDir, 'files', filename)); + return `url(data:font/woff2;base64,${font.toString('base64')})`; + }); +} + +const url = process.env.URL ?? 'http://localhost:8080/index.php/Demo'; +const outfile = process.env.OUT ?? '../../docs/demo1-screenshot.png'; + +// --no-sandbox is needed when running as root (e.g. in CI or a dev +// container) — Chromium's sandbox refuses to start otherwise. +const browser = await puppeteer.launch( + process.getuid?.() === 0 ? { args: ['--no-sandbox'] } : {} +); +try { + const page = await browser.newPage(); + await page.setViewport({ width: 1024, height: 768 }); + await page.goto(url, { waitUntil: 'networkidle0' }); + // Real browsers already show CJK fine here — the OS substitutes any + // installed CJK font for glyphs missing from whatever font is named + // (even MathJax's own inline `font-family: MJXZERO, serif` on its + // `` text nodes, used only for characters outside its own + // font, and the browser default `pre, code { font-family: monospace }` + // used by blocks). This sandbox just has no CJK font + // at all, so nothing to substitute. `!important` is needed because + // these are all higher-priority than inheriting from `body`. + // + // The per-script substitution CDP offers for exactly this + // (`Page.setFontFamilies`) would avoid hand-listing every selector that + // names its own font-family below, but it's a no-op in this headless + // Chromium (call succeeds, page still shows tofu) — tried and reverted, + // see git history if revisiting. + // + // mjx-utext is scoped narrowly, not every mjx-container descendant: + // stretchy delimiters like \left(...\right) are sized glyphs from + // MathJax's own font (e.g. class "TEX-S2"), not text — forcing a web + // font onto those too breaks their metrics, so `\left(` stops growing + // to match its contents. pre/code keep "monospace" first so Latin text + // stays aligned, with the web font only as a per-glyph fallback for CJK. + await page.addStyleTag({ content: localFontCss('noto-sans-kr') }); + await page.addStyleTag({ content: localFontCss('noto-serif-kr') }); + await page.addStyleTag({ + content: ` + body { font-family: 'Noto Sans KR', sans-serif !important; } + mjx-utext { font-family: 'Noto Serif KR', serif !important; } + pre, code { font-family: monospace, 'Noto Sans KR' !important; } + .diff-addedline, .diff-deletedline, .diff-context, + .mw-diff-inline-added, .mw-diff-inline-deleted, + .mw-diff-inline-moved, .mw-diff-inline-changed, + .mw-diff-inline-context { font-family: monospace, 'Noto Sans KR' !important; } + `, + }); + await page.evaluate(() => document.fonts.ready); + // MathJax typesets asynchronously after the page loads; give it a moment. + await page.waitForNetworkIdle({ idleTime: 500 }).catch(() => { }); + await page.screenshot({ path: outfile, fullPage: true }); + console.log(`==> Saved ${outfile}`); + + const rightClick = process.env.RIGHT_CLICK; + if (rightClick) { + const rightClickOutfile = process.env.RIGHT_CLICK_OUT; + // page.click() only ever hits the first match; a demo page can have + // several mjx-container elements (one per example), so grab all of + // them and right-click the last one instead. + const matches = await page.$$(rightClick); + await matches[matches.length - 1].click({ button: 'right' }); + // The context menu renders synchronously off the click, but give + // MathJax's own transition/animation a moment to settle. + await new Promise((resolve) => setTimeout(resolve, 500)); + await page.screenshot({ path: rightClickOutfile, fullPage: true }); + console.log(`==> Saved ${rightClickOutfile}`); + } +} finally { + await browser.close(); +} diff --git a/hack/local-mathjax.sh b/hack/local-mathjax.sh new file mode 100755 index 00000000..a0276730 --- /dev/null +++ b/hack/local-mathjax.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Pins the bundled local MathJax submodule to one tag. +# +# The CDN version ($wgSmjCdnVersion's default in extension.json) is managed +# independently — edit that value directly, the same as any other config +# default — since it doesn't have to track the local submodule's version. +# +# Usage: hack/local-mathjax.sh +# e.g. hack/local-mathjax.sh 4.1.3 +set -euo pipefail +cd "$(dirname "$0")/.." + +MATHJAX_DIR="resources/MathJax" +LOCAL_VERSION="${1:-}" + +if [ -z "$LOCAL_VERSION" ]; then + echo "Usage: hack/local-mathjax.sh (e.g. hack/local-mathjax.sh 4.1.3)" >&2 + exit 1 +fi + +git submodule update --init "$MATHJAX_DIR" +(cd "$MATHJAX_DIR" && git fetch --tags origin && git checkout "tags/$LOCAL_VERSION") +git add "$MATHJAX_DIR" + +echo "==> Pinned local MathJax to $LOCAL_VERSION." +echo "==> Review with 'git status', then commit the bump." diff --git a/hack/mathjax.sh b/hack/mathjax.sh deleted file mode 100755 index fd98ac6b..00000000 --- a/hack/mathjax.sh +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env bash -# Pins the resources/MathJax submodule to a tag and updates the CDN URL's -# major version in resources/ext.SimpleMathJax.js. The two are versioned -# separately on purpose: the local copy is pinned to an exact tag, while -# the CDN URL only pins a major version (jsdelivr resolves it to the -# latest matching release). -# -# Usage: hack/mathjax.sh -# e.g. hack/mathjax.sh 4.1.3 4 -set -euo pipefail -cd "$(dirname "$0")/.." - -MATHJAX_DIR="resources/MathJax" -JS_FILE="resources/ext.SimpleMathJax.js" -LOCAL_VERSION="${1:-}" -CDN_VERSION="${2:-}" - -if [ -z "$LOCAL_VERSION" ] || [ -z "$CDN_VERSION" ]; then - echo "Usage: hack/mathjax.sh (e.g. hack/mathjax.sh 4.1.3 4)" >&2 - exit 1 -fi - -git submodule update --init "$MATHJAX_DIR" -(cd "$MATHJAX_DIR" && git fetch --tags origin && git checkout "tags/$LOCAL_VERSION") -git add "$MATHJAX_DIR" - -sed -i -E "s#(cdn\.jsdelivr\.net/npm/mathjax@)[^/]+#\1${CDN_VERSION}#" "$JS_FILE" -git add "$JS_FILE" - -echo "==> Pinned $MATHJAX_DIR to $LOCAL_VERSION and the CDN URL to mathjax@$CDN_VERSION." -echo "==> Review with 'git status', then commit the bump." diff --git a/includes/Hooks.php b/includes/Hooks.php index 2c40d9df..6b810f74 100644 --- a/includes/Hooks.php +++ b/includes/Hooks.php @@ -9,200 +9,185 @@ use PPFrame; class Hooks { - /** @var bool */ - private static $useChem; - /** @var bool */ - private static $wrapDisplaystyle; - /** @var bool */ - private static $enableHtmlAttributes; - /** @var string */ - private static $directMathJax; - /** @var array[] */ - private static $displayMath; - /** @var array[] */ - private static $extraInlineMath; + private static array $allowedAttributes = []; + private static bool $extraDelimitersEnabled = false; + private static array $extraDelimitersInlineMath = []; + private static array $extraDelimitersDisplayMath = []; + private static string $ignoreHtmlClass = ''; public static function onParserFirstCallInit( Parser $parser ) { - global $wgOut, $wgSmjUseCdn, $wgSmjUseChem, $wgSmjDirectMathJax, $wgSmjEnableMenu, - $wgSmjDisplayMath, $wgSmjExtraInlineMath, $wgSmjIgnoreHtmlClass, - $wgSmjScale, $wgSmjDisplayAlign, $wgSmjWrapDisplaystyle, - $wgSmjEnableHtmlAttributes, $wgSmjConfigByRevision; + global $wgOut, $wgSmjCdnEnabled, $wgSmjCdnVersion, $wgSmjEnableMenu, + $wgSmjDelimitersEnabled, $wgSmjDelimitersInlineMath, $wgSmjDelimitersDisplayMath, + $wgSmjIgnoreHtmlClass, $wgSmjScale, + $wgSmjAllowedAttributes, $wgSmjRevisionOverrides; $config = [ - "wgSmjUseCdn" => $wgSmjUseCdn, - "wgSmjUseChem" => $wgSmjUseChem, - "wgSmjDirectMathJax" => $wgSmjDirectMathJax, - "wgSmjDisplayMath" => $wgSmjDisplayMath, - "wgSmjExtraInlineMath" => $wgSmjExtraInlineMath, - "wgSmjIgnoreHtmlClass" => $wgSmjIgnoreHtmlClass, - "wgSmjScale" => $wgSmjScale, - "wgSmjEnableMenu" => $wgSmjEnableMenu, - "wgSmjDisplayAlign" => $wgSmjDisplayAlign, - "wgSmjWrapDisplaystyle" => $wgSmjWrapDisplaystyle, - "wgSmjEnableHtmlAttributes" => $wgSmjEnableHtmlAttributes, + "wgSmjCdnEnabled" => $wgSmjCdnEnabled, + "wgSmjCdnVersion" => $wgSmjCdnVersion, + "wgSmjDelimitersEnabled" => $wgSmjDelimitersEnabled, + "wgSmjDelimitersInlineMath" => $wgSmjDelimitersInlineMath, + "wgSmjDelimitersDisplayMath" => $wgSmjDelimitersDisplayMath, + "wgSmjIgnoreHtmlClass" => $wgSmjIgnoreHtmlClass, + "wgSmjScale" => $wgSmjScale, + "wgSmjEnableMenu" => $wgSmjEnableMenu, + "wgSmjAllowedAttributes" => $wgSmjAllowedAttributes, ]; $articlerev = (int)$wgOut->getRevisionId(); - foreach ( $wgSmjConfigByRevision as $confset ) { + $config = self::applyRevisionOverrides( $config, $wgSmjRevisionOverrides, $articlerev ); + + $clientConfigVars = [ "wgSmjCdnEnabled", "wgSmjCdnVersion", + "wgSmjDelimitersEnabled", "wgSmjDelimitersInlineMath", "wgSmjDelimitersDisplayMath", + "wgSmjIgnoreHtmlClass", "wgSmjScale", "wgSmjEnableMenu" ]; + foreach ( $clientConfigVars as $varname ) { + $wgOut->addJsConfigVars( $varname, $config[$varname] ); + } + + self::$allowedAttributes = + is_array( $config["wgSmjAllowedAttributes"] ) ? $config["wgSmjAllowedAttributes"] : []; + self::$extraDelimitersEnabled = (bool)$config["wgSmjDelimitersEnabled"]; + self::$extraDelimitersInlineMath = + is_array( $config["wgSmjDelimitersInlineMath"] ) ? $config["wgSmjDelimitersInlineMath"] : []; + self::$extraDelimitersDisplayMath = + is_array( $config["wgSmjDelimitersDisplayMath"] ) ? $config["wgSmjDelimitersDisplayMath"] : []; + self::$ignoreHtmlClass = + is_string( $config["wgSmjIgnoreHtmlClass"] ) ? $config["wgSmjIgnoreHtmlClass"] : ''; + + if ( self::$extraDelimitersEnabled ) { + $wgOut->addModules( [ 'ext.SimpleMathJax' ] ); + } + + $parser->setHook( 'math', __CLASS__ . '::renderMath' ); + $parser->setHook( 'chem', __CLASS__ . '::renderChem' ); + } + + // $pattern is an admin-supplied regex fragment with no delimiter of its + // own, so avoid one that could occur inside it. + private static function matchesIgnoreHtmlClass( string $pattern, string $class ): bool { + $delimiter = strpos( $pattern, '~' ) === false ? '~' : "\x01"; + $result = preg_match( $delimiter . $pattern . $delimiter, $class ); + return $result === 1; + } + + // Apply $wgSmjRevisionOverrides on top of $config for the given revision id. + // A free function so it's unit-testable without a MediaWiki bootstrap. + public static function applyRevisionOverrides( array $config, array $overrides, int $articlerev ): array { + foreach ( $overrides as $confset ) { if ( $articlerev == 0 ) { break; } - if ( !isset( $confset["upto"] ) && !isset( $confset["since"] ) ) { + + if ( !isset( $confset["min"] ) && !isset( $confset["max"] ) ) { continue; } - if ( isset( $confset["upto"] ) && $confset["upto"] < $articlerev ) { + + if ( isset( $confset["max"] ) && $confset["max"] < $articlerev ) { continue; } - if ( isset( $confset["since"] ) && $confset["since"] > $articlerev ) { + + if ( isset( $confset["min"] ) && $confset["min"] > $articlerev ) { continue; } - foreach ( array_keys( $config ) as $varname ) { - if ( array_key_exists( $varname, $confset ) ) { - $config[$varname] = $confset[$varname]; + + foreach ( $confset as $key => $value ) { + if ( array_key_exists( $key, $config ) ) { + $config[$key] = $value; } } } - - $clientConfigVars = [ "wgSmjUseCdn", "wgSmjDirectMathJax", - "wgSmjDisplayMath", "wgSmjExtraInlineMath", "wgSmjIgnoreHtmlClass", - "wgSmjScale", "wgSmjEnableMenu", "wgSmjDisplayAlign" ]; - foreach ( $clientConfigVars as $varname ) { - $wgOut->addJsConfigVars( $varname, $config[$varname] ); - } - - self::$useChem = $config["wgSmjUseChem"]; - self::$wrapDisplaystyle = $config["wgSmjWrapDisplaystyle"]; - self::$enableHtmlAttributes = $config["wgSmjEnableHtmlAttributes"]; - - // Cached for onInternalParseBeforeLinks(), which otherwise has no way - // to see $wgSmjConfigByRevision overrides applied above — reading the - // raw globals there could protect quotes for a different mode/delimiter - // set than what the client (built from this same effective $config) - // actually parses on a wiki using per-revision overrides. - self::$directMathJax = $config["wgSmjDirectMathJax"]; - self::$displayMath = is_array( $config["wgSmjDisplayMath"] ) ? $config["wgSmjDisplayMath"] : []; - self::$extraInlineMath = is_array( $config["wgSmjExtraInlineMath"] ) ? $config["wgSmjExtraInlineMath"] : []; - - if ( $config["wgSmjDirectMathJax"] !== 'none' ) { - $wgOut->addModules( [ 'ext.SimpleMathJax' ] ); - } - - $parser->setHook( 'math', __CLASS__ . '::renderMath' ); - if ( self::$useChem ) { - $parser->setHook( 'chem', __CLASS__ . '::renderChem' ); - } + return $config; } - public static function renderMath( $tex, array $args, Parser $parser, PPFrame $frame ) { + public static function renderMath( ?string $tex, array $args, Parser $parser, PPFrame $frame ) { $parserOutput = $parser->getOutput(); $parserOutput->addModules( [ 'ext.SimpleMathJax' ] ); - if ( !self::$enableHtmlAttributes ) { - $args = []; - } + + // Unconditional: this only preloads the mhchem JS package, unrelated + // to display handling. if ( isset( $args["chem"] ) ) { - $parserOutput->setJsConfigVar( "wgSmjPreloadChem", true ); + $parserOutput->setJsConfigVar( "smjPreloadChem", true ); } - if ( isset( $args["inline-block"] ) ) { - if ( isset( $args["display"] ) ) { - return self::renderError( - 'SimpleMathJax: Do not use the inline-block attribute ' . - 'and the display attribute together on the same element.' - ); - } - $tex = "\\displaystyle{ $tex }"; - } elseif ( !isset( $args["display"] ) ) { - if ( self::$wrapDisplaystyle ) { - $tex = "\\displaystyle{ $tex }"; - } - } else { - switch ( $args["display"] ) { - case "": - break; - case "inline": - $tex = "\\textstyle{ $tex }"; - break; - case "block": - break; - default: - return self::renderError( - 'SimpleMathJax: Invalid attribute value: display="' . $args["display"] . '"' - ); - } + + if ( isset( $args["display"] ) && !in_array( $args["display"], [ "", "inline", "block" ], true ) ) { + return self::renderError( 'SimpleMathJax: invalid display="' . $args["display"] . '"' ); } - return self::renderTex( $tex, $parser, $args ); + + // renderTex() applies \displaystyle{}/\textstyle{} itself, since it's + // only a default guess, not part of what the editor wrote. + return self::renderTex( $tex, $parser, $args, true ); } - public static function renderChem( $tex, array $args, Parser $parser, PPFrame $frame ) { + public static function renderChem( ?string $tex, array $args, Parser $parser, PPFrame $frame ) { $parserOutput = $parser->getOutput(); $parserOutput->addModules( [ 'ext.SimpleMathJax' ] ); - $parserOutput->setJsConfigVar( "wgSmjPreloadChem", true ); - if ( !self::$enableHtmlAttributes ) { - $args = []; - } - return self::renderTex( "\\ce{ $tex }", $parser, $args ); + $parserOutput->setJsConfigVar( "smjPreloadChem", true ); + + // Wrapping happens inside renderTex(), not here, so an ignored + // element (see below) shows the editor's original TeX rather than + // the \ce{} wrapper meant for MathJax. + return self::renderTex( $tex, $parser, $args, false, true ); } - private static function renderTex( $tex, $parser, $args ) { - $hookContainer = MediaWikiServices::getInstance()->getHookContainer(); - $attributes = [ "style" => "opacity:.5", "class" => "" ]; - $inherit_tags = [ "class", "id", "title", "lang", "dir" ]; - $validatedAttribs = Sanitizer::validateAttributes( $args, array_fill_keys( $inherit_tags, true ) ); - $attributes = array_merge( $attributes, $validatedAttribs ); + private static function renderTex( + ?string $tex, Parser $parser, array $args, bool $mathTag, bool $wrapChem = false + ) { + $hookContainer = MediaWikiServices::getInstance()->getHookContainer(); + $attributes = [ "style" => "opacity:.5", "class" => "" ]; + $allowedAttributes = array_filter( self::$allowedAttributes, 'is_string' ); + $validatedAttribs = Sanitizer::validateAttributes( + $args, + array_fill_keys( $allowedAttributes, true ) + ); + $attributes = array_merge( $attributes, $validatedAttribs ); $hookContainer->run( "SimpleMathJaxAttributes", [ &$attributes, $tex, $args ] ); - if ( !isset( $attributes["smj-debug"] ) && !isset( $args["smj-debug"] ) ) { - $attributes["class"] .= " smj-container"; - } - - if ( isset( $args["display"] ) && $args["display"] == "block" ) { - $element = Html::Element( "span", $attributes, "\\begin{displaymjx}{$tex}\\end{displaymjx}" ); + // An ignored element is never typeset, so it skips smj-container and + // the delimiter wrapping instead of showing them as literal text. + $isIgnored = self::$ignoreHtmlClass !== '' + && self::matchesIgnoreHtmlClass( self::$ignoreHtmlClass, $attributes["class"] ); + if ( $isIgnored ) { + unset( $attributes["style"] ); + $element = Html::Element( "span", $attributes, $tex ); } else { - $element = Html::Element( "span", $attributes, "[math]{$tex}[/math]" ); + if ( !isset( $attributes["smj-debug"] ) && !isset( $args["smj-debug"] ) ) { + $attributes["class"] .= " smj-container"; + } + if ( $wrapChem ) { + $tex = "\\ce{ $tex }"; + } + if ( $mathTag ) { + if ( !isset( $args["display"] ) ) { + $tex = "\\displaystyle{ $tex }"; + } elseif ( $args["display"] === "inline" ) { + $tex = "\\textstyle{ $tex }"; + } + } + $element = isset( $args["display"] ) && $args["display"] === "block" + ? Html::Element( "span", $attributes, "\\begin{displaymjx}{$tex}\\end{displaymjx}" ) + : Html::Element( "span", $attributes, "[math]{$tex}[/math]" ); } return [ $element, 'markerType' => 'nowiki' ]; } - private static function renderError( $str ) { + private static function renderError( string $str ) { $attributes = [ "class" => "error texerror" ]; - $element = Html::Element( "strong", $attributes, $str ); + $element = Html::Element( "strong", $attributes, $str ); return [ $element, 'markerType' => 'nowiki' ]; } - /** - * Protect '' / ''' runs inside MathJax-delimited math from MediaWiki's - * wikitext emphasis parsing (InternalParseBeforeLinks runs after - * nowiki/tag stripping but before handleAllQuotes, so code blocks are - * already markers and prose italics is still to come). In TeX, '' and - * ''' are primes (y'', f'''(x)); without this guard the parser inserts - * / inside the delimited text, splitting it so MathJax cannot find - * the closing delimiter (dangling $ then swallows prose as math). - * - * Runs only when direct $…$/$$…$$ parsing is enabled (mode 'full'/'env'); - * in 'none' mode MathJax handles only / tags, whose content - * is already protected from wikitext parsing. Reads the effective, - * revision-overridden config cached by onParserFirstCallInit() rather - * than the raw globals, so this always protects for the same mode and - * delimiters the client will actually parse with. - * - * @param Parser $parser - * @param string &$text - * @param mixed $stripState - */ public static function onInternalParseBeforeLinks( $parser, &$text, $stripState ) { - if ( self::$directMathJax === 'none' ) { + if ( !self::$extraDelimitersEnabled ) { return; } - // processEscapes: ext.SimpleMathJax.js only sets this in 'full' mode. $text = Quotes::protectQuotesInMath( $text, static function ( $run ) use ( $parser ) { return $parser->insertStripItem( $run ); }, - self::$extraInlineMath, - self::$displayMath, - self::$directMathJax === 'full', - // protectEnvironments: also protect \begin…\end + self::$extraDelimitersInlineMath, + self::$extraDelimitersDisplayMath, + true, true ); } diff --git a/includes/Quotes.php b/includes/Quotes.php index 7f8bad8b..a9a7e1e0 100644 --- a/includes/Quotes.php +++ b/includes/Quotes.php @@ -20,10 +20,8 @@ public static function protectQuotesInMath( if ( !is_array( $pair ) || count( $pair ) < 2 ) { continue; } - // array_values() re-indexes from 0: a pair with non-sequential or - // associative keys (e.g. [1 => '$', 2 => '$']) would otherwise - // leave $open undefined (list-assignment reads keys 0/1 by - // position) and emit a warning before being filtered below. + // Non-sequential/associative keys would leave $open undefined + // via list-assignment; array_values() re-indexes from 0 first. [ $open, $close ] = array_values( $pair ); if ( !is_string( $open ) || !is_string( $close ) || $open === '' || $close === '' ) { continue; diff --git a/resources/ext.SimpleMathJax.js b/resources/ext.SimpleMathJax.js index fab0c493..a872fe9f 100644 --- a/resources/ext.SimpleMathJax.js +++ b/resources/ext.SimpleMathJax.js @@ -1,12 +1,12 @@ mw.hook( 'wikipage.content' ).add( function ( $content ) { window.MathJax = { tex: { - inlineMath: mw.config.get('wgSmjExtraInlineMath').concat([['[math]','[/math]']]), - displayMath: mw.config.get('wgSmjDisplayMath'), + inlineMath: mw.config.get('wgSmjDelimitersInlineMath').concat([['[math]','[/math]']]), + displayMath: mw.config.get('wgSmjDelimitersDisplayMath'), processEnvironments: true, - processRefs: mw.config.get('wgSmjDirectMathJax') == 'full', - processEscapes: mw.config.get('wgSmjDirectMathJax') == 'full', - packages: mw.config.exists('wgSmjPreloadChem') ? {'[+]': ['autoload','mhchem']} : {'[+]': ['autoload']}, + processRefs: mw.config.get('wgSmjDelimitersEnabled'), + processEscapes: mw.config.get('wgSmjDelimitersEnabled'), + packages: mw.config.exists('smjPreloadChem') ? {'[+]': ['autoload','mhchem']} : {'[+]': ['autoload']}, macros: { AA: "{\u00c5}", alef: "{\\aleph}", @@ -120,24 +120,25 @@ window.MathJax = { }, chtml: { scale: mw.config.get('wgSmjScale'), - displayAlign: mw.config.get('wgSmjDisplayAlign') }, loader: { - load: ['ui/safe','[tex]/autoload'].concat(mw.config.exists('wgSmjPreloadChem') ? ['[tex]/mhchem'] : []) + load: ['ui/safe','[tex]/autoload'].concat(mw.config.exists('smjPreloadChem') ? ['[tex]/mhchem'] : []) }, startup: { - elements: mw.config.get('wgSmjDirectMathJax') == 'none' ? ["span.smj-container"] : null, + elements: mw.config.get('wgSmjDelimitersEnabled') ? null : ["span.smj-container"], pageReady: () => { return MathJax.startup.defaultPageReady().then(() => { - $("span.smj-container > .MathJax").parent().css('opacity',1); + document.querySelectorAll("span.smj-container > .MathJax").forEach((mjx) => { + mjx.parentElement.style.opacity = 1; + }); }); } } }; (function () { - var script = document.createElement('script'); - script.src = mw.config.get('wgSmjUseCdn') - ? 'https://cdn.jsdelivr.net/npm/mathjax@4/tex-chtml.js' + const script = document.createElement('script'); + script.src = mw.config.get('wgSmjCdnEnabled') + ? 'https://cdn.jsdelivr.net/npm/mathjax@' + mw.config.get('wgSmjCdnVersion') + '/tex-chtml.js' : mw.config.get('wgExtensionAssetsPath') + '/SimpleMathJax/resources/MathJax/tex-chtml.js'; script.async = true; document.head.appendChild(script); diff --git a/tests/RevisionOverridesTest.php b/tests/RevisionOverridesTest.php new file mode 100644 index 00000000..b1994ccb --- /dev/null +++ b/tests/RevisionOverridesTest.php @@ -0,0 +1,86 @@ + 1, 'wgSmjCdnEnabled' => true, 'wgSmjDelimitersEnabled' => false ]; +assert_same( + 'applyRevisionOverrides is a no-op with no overrides configured', + $baseConfig, + Hooks::applyRevisionOverrides( $baseConfig, [], 12345 ) +); +assert_same( + 'applyRevisionOverrides is a no-op for revision 0 (base case)', + $baseConfig, + Hooks::applyRevisionOverrides( + $baseConfig, + [ [ 'max' => 50000, 'wgSmjScale' => 2 ] ], + 0 + ) +); + +assert_same( + 'applyRevisionOverrides replaces a top-level key inside its range', + [ 'wgSmjScale' => 2, 'wgSmjCdnEnabled' => true, 'wgSmjDelimitersEnabled' => false ], + Hooks::applyRevisionOverrides( + $baseConfig, + [ [ 'max' => 50000, 'wgSmjScale' => 2 ] ], + 40000 + ) +); +assert_same( + 'applyRevisionOverrides skips an override outside its range', + $baseConfig, + Hooks::applyRevisionOverrides( + $baseConfig, + [ [ 'max' => 50000, 'wgSmjScale' => 2 ] ], + 60000 + ) +); + +assert_same( + 'applyRevisionOverrides sets an unrelated flat key without touching the rest', + [ 'wgSmjScale' => 1, 'wgSmjCdnEnabled' => true, 'wgSmjDelimitersEnabled' => true ], + Hooks::applyRevisionOverrides( + $baseConfig, + [ [ 'min' => 1, 'max' => 50000, 'wgSmjDelimitersEnabled' => true ] ], + 25000 + ) +); + +assert_same( + 'applyRevisionOverrides ignores an entry with no min or max', + $baseConfig, + Hooks::applyRevisionOverrides( + $baseConfig, + [ [ 'wgSmjScale' => 2 ] ], + 25000 + ) +); + +assert_same( + 'applyRevisionOverrides never matches a reversed min/max range', + $baseConfig, + Hooks::applyRevisionOverrides( + $baseConfig, + [ [ 'min' => 100, 'max' => 50, 'wgSmjScale' => 2 ] ], + 75 + ) +); + +echo $failures === 0 ? "\nAll tests passed.\n" : "\n$failures test(s) FAILED.\n"; +exit( $failures === 0 ? 0 : 1 );