Skip to content

Extension shortcode support (Phase 3)#78

Draft
gordonwoodhull wants to merge 9 commits intomainfrom
feature/shortcode-extensions
Draft

Extension shortcode support (Phase 3)#78
gordonwoodhull wants to merge 9 commits intomainfrom
feature/shortcode-extensions

Conversation

@gordonwoodhull
Copy link
Collaborator

Summary

Implements Lua-based shortcode support for Quarto extensions, allowing extensions to define custom shortcodes via Lua scripts (e.g., {{< hello >}}). This is Phase 3 of the extensions roadmap, building on the extension discovery/metadata (Phase 1) and filter resolution (Phase 2) work already merged to main.

What's new

  • Lua shortcode engine (pampa/src/lua/shortcode.rs): Loads and dispatches Lua shortcode handlers. Supports both return-table and env-function Lua conventions, matches TS Quarto's calling convention (args, kwargs, meta, raw_args, context), and handles inline and block-level return values.

  • Block-level shortcode results: Added ShortcodeResult::Blocks variant with two-pass resolution logic — a Para/Plain containing a single shortcode gets block context; otherwise falls back to inline with graceful flattening.

  • Pipeline wiring: AstTransformsStage now builds the transform pipeline JIT in run() (moved from new()) so it has access to StageContext — extensions, runtime, and format — for the shortcode transform. Unknown shortcode names trigger on-demand loading of matching extension Lua scripts.

  • quarto.* Lua API (pampa/src/lua/quarto_api.rs): Adds quarto.json, quarto.log, and quarto.utils namespaces expected by real-world extensions (e.g., lipsum). Includes quarto.utils.resolve_path with per-handler script directory tracking.

  • Per-format shortcode paths: Extension metadata shortcodes keys under format-specific sections are now marked as ConfigValueKind::Path so they resolve correctly relative to the extension directory.

  • Smoke tests: Three new smoke-all fixtures covering inline shortcodes, block shortcodes, and per-format shortcode dispatch.

CI fix

  • Re-enable MetadataMergeStage in parse_qmd_to_ast (was inadvertently commented out alongside AstTransformsStage). This restores format injection into merged metadata, fixing 4 formatDetection.wasm.test.ts failures.
  • Remove AstTransformsStage from parse_qmd_to_ast entirely — the function intentionally returns a pre-transform AST for the React debug renderer.
  • Uncomment test:ci in ts-test-suite.yml so hub-client tests actually run in CI.
  • Delete stale PreviewRouter.test.ts (component moved to render/ subdirectory; test was never visible in CI).

Test plan

  • CI passes (test-suite.yml — Rust nextest including smoke-all)
  • CI passes (ts-test-suite.yml — hub-client test:ci, now enabled for the first time)
  • CI passes (hub-client-e2e.yml — Playwright smoke-all)
  • Two Playwright E2E failures for extension tests (shortcode-extension, filter-extension) returning 401 — under investigation

Phase 3.1: Add "shortcodes" to PATH_VALUED_KEYS in extension/read.rs
so per-format shortcode paths are marked as ConfigValueKind::Path
during extension metadata parsing.

Phase 3.2: Create LuaShortcodeEngine in pampa/src/lua/shortcode.rs
- Lua state setup with pandoc/quarto globals (WASM-aware)
- Script loading via both conventions (return-table and env-function)
- Handler dispatch with TS Quarto calling convention (args, kwargs,
  meta, raw_args, context)
- Return value classification (nil→error, string→text, userdata→
  inline/block, table→classify)
- quarto.shortcode.read_arg() and error_output() API
- pub(crate) extract helpers in filter.rs for userdata unwrapping
- Add ShortcodeResult::Blocks variant for block-level returns
- Add ResolutionContext enum (Block/Inline) to ShortcodeHandler::resolve()
- Rewrite resolve_blocks() with index-based iteration and two-pass logic:
  Para/Plain with single shortcode → block context, otherwise inline
- Graceful degradation: Blocks in inline context → flatten_blocks_to_inlines
- Remove unused AnalysisContext import (re-added as actually needed)
Connect LuaShortcodeEngine to ShortcodeResolveTransform, enabling
extension and user-defined Lua shortcode handlers alongside built-in
Rust handlers.

Key changes:
- Add with_lua_support() constructor storing extensions, runtime, format,
  and shortcode paths as Send+Sync owned data
- Create LuaShortcodeEngine on the stack in transform() (it's !Send+!Sync)
- Thread lua_engine parameter through all resolution functions
- Add name-based extension lookup: unknown shortcodes trigger on-demand
  loading of matching extension's shortcode scripts
- Move pipeline construction from AstTransformsStage::new() to run(),
  using StageContext data (extensions, runtime, format) for the shortcode
  transform
- Add extract_shortcode_paths() for reading paths from merged metadata
- Add helper functions for Lua dispatch and result conversion

Handler priority: built-in Rust > loaded Lua > extension name lookup
(matches TS Quarto behavior).

5 new integration tests covering Lua shortcode dispatch, extension
name lookup, Rust handler override priority, unknown shortcode errors,
and block-context extension shortcodes.
… 3.5-3.7)

- Add metadata merge test verifying extension shortcode paths preserve
  ConfigValueKind::Path through build_extension_metadata_layer
- Add RawBlock integration test for block-context Lua shortcodes
- Add smoke test: inline shortcode extension (hello → text output)
- Add smoke test: block shortcode extension (break → RawBlock <hr>)
- Fix unused_imports warning in pampa lua/mod.rs for shortcode exports
- All 6919 workspace tests pass, cargo xtask verify build passes
- Add smoke test for per-format extension shortcodes (contributes.formats.
  html.shortcodes path, flows through metadata merge)
- Mark Phase 3 complete in both detail plan and master plan
- Remove lipsum smoke test item (future built-in shortcode, not infra)
Real-world Quarto extensions (like lipsum) expect a quarto.* global
namespace with utility functions beyond warn/error. This adds:

- quarto.json: alias of pandoc.json (decode/encode/null)
- quarto.log: stderr logging with level-gated output/error/warning/
  info/debug/trace functions and setloglevel
- quarto.utils.resolve_path: resolves paths relative to the calling
  script's directory (tracked per handler in shortcode engine)
- quarto.utils.type: alias of pandoc.utils.type

The shortcode engine tracks script directories per handler name so
that resolve_path works correctly when multiple extensions are loaded.
The filter engine sets the script dir to the filter's parent directory.

New file: crates/pampa/src/lua/quarto_api.rs (22 unit tests)
Modified: shortcode.rs (4 new integration tests including multi-extension)
Modified: filter.rs, filter_tests.rs (2 new integration tests)
- Rewrite waitForPreviewRender to poll with fast-fail on fatal WASM errors
- Collect console errors, page errors, and HTTP 500s for diagnostic context
- Sync QMD render target last so extension files land in VFS before first render
- Re-enable MetadataMergeStage in parse_qmd_to_ast so format is injected
  into merged metadata (needed by formatDetection tests and PreviewRouter).
  Remove the commented-out AstTransformsStage entirely — parse_qmd_to_ast
  intentionally returns a pre-transform AST for Elliot's debug renderer.
- Delete stale PreviewRouter.test.ts (file moved to render/ subdirectory,
  test was never visible in CI since test:ci was disabled).
- Uncomment test:ci step in ts-test-suite.yml so hub-client tests run in CI.
@gordonwoodhull gordonwoodhull force-pushed the feature/shortcode-extensions branch from bebd2c6 to 5694ba6 Compare March 22, 2026 16:23
@gordonwoodhull
Copy link
Collaborator Author

gordonwoodhull commented Mar 22, 2026

Note for developers: some hub-client tests running in CI for the first time

Specifically, the hub-client unit/integration/WASM tests.

Not the playwright tests yet; those must be run manually.

This PR enables test:ci in ts-test-suite.yml, which was commented out since the workflow was first created (Jan 29). As a result, the following tests now run in CI automatically on every PR targeting main:

npm run test:ci runs three things in sequence:

  1. npm run test — Vitest unit tests (routing, file tree, presence service, storage, etc.)
  2. npm run test:integration — Vitest integration tests (NewFileDialog, etc.)
  3. npm run test:wasm — Vitest WASM tests, which includes:
    • Format detection, template, project context, theme CSS, and other WASM service tests
    • The WASM smoke-all runner — runs every fixture in crates/quarto/tests/smoke-all/ through the WASM module in Node.js/jsdom

None of these were running in CI before today. The most significant newly-covered test is the WASM smoke-all runner, since it exercises the full render pipeline including extension shortcode support introduced in this PR.

As a side effect of enabling this, we found and fixed two pre-existing issues:

  • PreviewRouter.test.ts was broken (its component had been moved to render/ subdirectory) and was invisible because tests never ran in CI
  • formatDetection.wasm.test.ts had 4 failing tests due to MetadataMergeStage being inadvertently commented out of parse_qmd_to_ast

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant