Full Code of yihui/litedown for AI

main 42d6af45041e cached
142 files
557.1 KB
174.8k tokens
23 symbols
1 requests
Download .txt
Showing preview only (594K chars total). Download the full file or copy to clipboard to get everything.
Repository: yihui/litedown
Branch: main
Commit: 42d6af45041e
Files: 142
Total size: 557.1 KB

Directory structure:
gitextract_h5miewmq/

├── .Rbuildignore
├── .github/
│   ├── copilot-instructions.md
│   └── workflows/
│       ├── R-CMD-check.yaml
│       ├── copilot-setup-steps.yml
│       └── github-pages.yml
├── .gitignore
├── DESCRIPTION
├── LICENSE
├── LICENSE.md
├── Makefile
├── NAMESPACE
├── NEWS.md
├── R/
│   ├── format.R
│   ├── fuse.R
│   ├── mark.R
│   ├── package.R
│   ├── preview.R
│   ├── site.R
│   ├── utils.R
│   └── zzz.R
├── README.md
├── docs/
│   ├── 01-start.Rmd
│   ├── 02-fuse.Rmd
│   ├── 03-syntax.Rmd
│   ├── 04-mark.Rmd
│   ├── 05-assets.Rmd
│   ├── 06-widgets.Rmd
│   ├── 07-editor.Rmd
│   ├── 08-site.Rmd
│   ├── A-misc.Rmd
│   ├── _litedown.yml
│   └── index.Rmd
├── examples/
│   ├── 001-minimal.Rmd
│   ├── 001-minimal.md
│   ├── 002-attr-options.Rmd
│   ├── 002-attr-options.md
│   ├── 003-attr-callout.Rmd
│   ├── 003-attr-callout.md
│   ├── 004-caption-position.Rmd
│   ├── 004-caption-position.md
│   ├── 005-option-code.Rmd
│   ├── 005-option-code.md
│   ├── 006-option-collapse.Rmd
│   ├── 006-option-collapse.md
│   ├── 007-option-comment.Rmd
│   ├── 007-option-comment.md
│   ├── 008-option-device.Rmd
│   ├── 008-option-device.md
│   ├── 009-option-figure-decoration.Rmd
│   ├── 009-option-figure-decoration.md
│   ├── 010-option-plot-files.Rmd
│   ├── 010-option-plot-files.md
│   ├── 011-option-label.Rmd
│   ├── 011-option-label.md
│   ├── 012-option-order.Rmd
│   ├── 012-option-order.md
│   ├── 013-option-print.Rmd
│   ├── 013-option-print.md
│   ├── 014-option-purl.R
│   ├── 014-option-purl.Rmd
│   ├── 014-option-purl.md
│   ├── 015-fill-chunk.Rmd
│   ├── 015-fill-chunk.md
│   ├── 016-option-results.Rmd
│   ├── 016-option-results.md
│   ├── 017-option-strip-white.Rmd
│   ├── 017-option-strip-white.md
│   ├── 018-option-table.Rmd
│   ├── 018-option-table.md
│   ├── 019-option-verbose.Rmd
│   ├── 019-option-verbose.md
│   ├── 020-inline.Rmd
│   ├── 020-inline.md
│   ├── 021-simple-datatables.Rmd
│   ├── 021-simple-datatables.md
│   ├── 022-dygraphs.Rmd
│   ├── 022-dygraphs.md
│   ├── 023-leaflet.Rmd
│   ├── 023-leaflet.md
│   ├── 024-chart-js.Rmd
│   ├── 024-chart-js.md
│   ├── 025-option-filter.Rmd
│   ├── 025-option-filter.md
│   ├── _run.R
│   ├── test-collapse.Rmd
│   ├── test-collapse.md
│   ├── test-inline.Rmd
│   ├── test-inline.md
│   ├── test-options.Rmd
│   ├── test-options.md
│   ├── test-results-hide.Rmd
│   └── test-results-hide.md
├── inst/
│   └── resources/
│       ├── default.css
│       ├── listing.css
│       ├── litedown.html
│       ├── litedown.latex
│       ├── server.css
│       ├── server.js
│       ├── snap.css
│       └── snap.js
├── litedown.Rproj
├── man/
│   ├── crack.Rd
│   ├── engines.Rd
│   ├── fuse_book.Rd
│   ├── fuse_env.Rd
│   ├── fuse_site.Rd
│   ├── get_context.Rd
│   ├── html_format.Rd
│   ├── litedown-package.Rd
│   ├── mark.Rd
│   ├── markdown_options.Rd
│   ├── pkg_desc.Rd
│   ├── raw_text.Rd
│   ├── reactor.Rd
│   ├── roam.Rd
│   ├── smartypants.Rd
│   ├── timing_data.Rd
│   └── vest.Rd
├── playground/
│   ├── _default.Rmd
│   └── setup.R
├── site/
│   ├── _footer.Rmd
│   ├── _litedown.yml
│   ├── action.yml
│   ├── articles.Rmd
│   ├── code.Rmd
│   ├── examples.Rmd
│   ├── index.Rmd
│   ├── manual.Rmd
│   ├── news.Rmd
│   └── playground/
│       ├── _default.R
│       └── index.html
├── tests/
│   ├── examples.R
│   ├── test-cran/
│   │   ├── test-crack.R
│   │   ├── test-fuse.R
│   │   ├── test-fuse.md
│   │   ├── test-mark.R
│   │   ├── test-mark.md
│   │   ├── test-reactor.R
│   │   ├── test-reactor.md
│   │   └── test-utils.R
│   └── test-cran.R
└── vignettes/
    └── slides.Rmd

================================================
FILE CONTENTS
================================================

================================================
FILE: .Rbuildignore
================================================
.gitignore
^.*\.Rproj$
^\.Rproj\.user$
Makefile
^\.github$
^LICENSE\.md$
^docs$
^examples$
^playground$
^site$
.*\.tar\.gz$
.*\.Rcheck$


================================================
FILE: .github/copilot-instructions.md
================================================
# Repository Instructions for Copilot

## Build and Test Instructions

``` bash
# Build the R package
R CMD build .

# Install the package
R CMD INSTALL *_*.tar.gz

# Test the package
cd tests
Rscript *.R
```

Tests are typically in `tests/testit/test-*.R` (for each `R/foo.R`, there is a
corresponding `tests/testit/test-foo.R`). In certain cases they may be in other
directories, e.g., `tests/test-cran/` (for tests to run on anywhere, including
CRAN) and `tests/test-ci/` (tests to run on CI only because they might fail on
CRAN due to Internet connection or resource limits). The conditioning is done in
top-level `*.R` under `tests/`, e.g.,

``` r
# tests/test-cran.R
testit::test_pkg(dir = 'test-cran')

# tests/test-ci.R
if (tolower(Sys.getenv('CI')) == 'true') testit::test_pkg(dir = 'test-ci')
```

Tests consist of assertions of this form:

``` r
library(testit)

assert('expectation message', {
  actual = FUN(args, ...)
  (actual %==% expected)
  # more tests of the above form, e.g.,
  (length(res) %==% 3L)
})
```

-   Use `has_error()` instead of `tryCatch()` for error testing
-   Never use `:::` to access internal functions in tests; testit exposes
    internal functions automatically, so call them directly

## Important Conventions

### R Code Style

1.  **Assignment**: Use `=` instead of `<-` for assignment
2.  **Strings**: Use single quotes for strings (e.g., `'text'`)
3.  **Indentation**: Use 2 spaces (not 4 spaces or tabs)
4.  **Compact code**: Avoid `{}` for single-expression if statements; prefer
    compact forms when possible
5.  **Roxygen documentation**: Don't use `@description` or `@details` explicitly
    — just write the description text directly after the title. Don't use
    `@title` either.
6.  **Examples**: Avoid `\dontrun{}` unless absolutely necessary. Prefer
    runnable examples that can be tested automatically.
7.  **Function definitions**: For functions with many arguments, break the line
    right after the opening `(`, indent arguments by 2 spaces, and try to wrap
    them at 80-char width.
8.  **Re-wrap code**: Always re-wrap the code after making changes to maintain
    consistent formatting and line length.
9.  **Implicit NULL**: Don't write `if (cond) foo else NULL`; the `else NULL` is
    unnecessary since R's `if` without `else` already returns `NULL`. Never
    write `return(NULL)`; use `return()` instead since R functions return `NULL`
    by default when no value is given.
10. **US spelling**: Use US spelling throughout all documentation, code
    comments, and example text (e.g., "color" not "colour", "center" not
    "centre", "summarize" not "summarise").
11. **DRY (Don't Repeat Yourself)**: Never duplicate code. When the same logic
    appears more than once, factor it into a shared helper function. This
    applies to expressions, patterns, and multi-line blocks alike.

### Check list

Always send a pull request, unless you are told otherwise. For each PR:

1.  **Every change must have tests**: Every code change must come with
    corresponding tests. If you add or fix a function, add assertions in the
    test file that cover the new or fixed behavior. Tests are the first place to
    catch regressions and errors.
2.  **Always re-roxygenize**: Run `roxygen2::roxygenize()` after changing any
    roxygen documentation to update man files
3.  **MANDATORY: `R CMD check` before `git push`**: You MUST run a comprehensive
    `R CMD check` successfully before submitting ANY code changes.
4.  **MANDATORY: Wait for CI to be green**: After pushing code, you MUST wait
    for GitHub Actions CI to complete successfully before claiming the task is
    done. Do not wait more than 5 minutes for any single CI job; if it hasn't
    finished, skip it and continue your work. Fix problems in CI as soon as any
    job has failed instead of waiting for all jobs to finish.
5.  **MANDATORY: Merge latest main before pushing**: Before pushing to a branch
    or PR, always pull and merge the latest main branch. If there are merge
    conflicts, resolve them before pushing.
6.  **Bump version in PRs**: Bump the patch version number in DESCRIPTION once
    per PR (on the first commit or when you first make changes), not on every
    commit to the PR
7.  **Never commit irrelevant files**: Don't run `git add .` blindly, as that
    might add irrelevant files such as generated output or other artifacts. Only
    add the ones you modified or created explicitly. Normally changes generated
    automatically by roxygen2 are the only exception (they should be committed).
8.  **Update NEWS.md**: When making changes, make sure to update `NEWS.md`
    accordingly to document what changed. The first heading in NEWS.md always
    represents the dev version and must be of the form `# PKG x.y` where PKG is
    the package name and x.y is the next version to be released to CRAN (note:
    x.y, not x.y.0). Usually y is bumped from the current minor version, e.g.,
    if the current dev version is 1.8.3, the next CRAN release is expected to be
    1.9.


================================================
FILE: .github/workflows/R-CMD-check.yaml
================================================
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

name: R-CMD-check

jobs:
  R-CMD-check:
    runs-on: ${{ matrix.config.os }}

    name: ${{ matrix.config.os }} (${{ matrix.config.r }})

    strategy:
      fail-fast: false
      matrix:
        config:
          - {os: ubuntu-latest, r: 'release'}
          - {os: ubuntu-latest, r: '4.4'}
          - {os: ubuntu-latest, r: '4.3'}
          - {os: ubuntu-latest, r: '4.2'}
          - {os: ubuntu-latest, r: '4.1'}
          - {os: ubuntu-latest, r: '4.0'}
          - {os: ubuntu-latest, r: '3.6'}
          - {os: ubuntu-latest, r: '3.5'}
          - {os: ubuntu-latest, r: '3.4'}
          - {os: ubuntu-latest, r: '3.3'}
          - {os: ubuntu-latest, r: '3.2'}
          - {os: ubuntu-latest, r: '3.2.0'}
          - {os: ubuntu-latest, r: 'devel', http-user-agent: 'release'}
          - {os: macOS-latest, r: 'release'}
          - {os: windows-latest, r: 'release'}

    env:
      GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
      R_KEEP_PKG_SOURCE: yes
      _R_CHECK_FORCE_SUGGESTS_: false
      _R_CHECK_RD_XREFS_: false

    steps:
      - uses: actions/checkout@HEAD

      - uses: r-lib/actions/setup-r@HEAD
        with:
          r-version: ${{ matrix.config.r }}
          http-user-agent: ${{ matrix.config.http-user-agent }}
          use-public-rspm: true

      - name: Whether to use codecov
        run: |
          echo "USE_R_CODECOV=${{ (runner.os == 'Linux' && matrix.config.r == 'release') && true || false }}" >> $GITHUB_ENV

      - uses: yihui/actions/setup-r-dependencies@HEAD
        with:
          # install the package itself as we register vignette engine
          extra-packages: . ${{ env.USE_R_CODECOV == 'true' && ' covr xml2' || '' }}
          # install dependencies for cairo_pdf device
          brew-packages: xquartz

      - uses: yihui/actions/check-r-package@HEAD

      - name: Run examples
        working-directory: examples
        run: |
          Rscript _run.R
          git diff --quiet || (git diff && exit 1)

      - name: Test coverage
        if: success() && env.USE_R_CODECOV == 'true'
        run: |
          cov = covr::package_coverage(
            quiet = FALSE,
            clean = FALSE,
            install_path = file.path(normalizePath(Sys.getenv("RUNNER_TEMP"), winslash = "/"), "package")
          )
          covr::to_cobertura(cov)
          xfun::file_string('./cobertura.xml')
        shell: Rscript {0}

      - uses: codecov/codecov-action@HEAD
        if: env.USE_R_CODECOV == 'true'
        with:
          fail_ci_if_error: ${{ github.event_name != 'pull_request' }}
          files: ./cobertura.xml
          plugins: noop
          disable_search: true

      - uses: actions/checkout@HEAD
        if: runner.os == 'macOS'
        with:
          path: gh-pages
          ref: gh-pages

      - name: Publish book
        if: runner.os == 'macOS'
        run: |
          Rscript -e 'install.packages("xfun", repos="https://yihui.r-universe.dev")'
          Rscript -e 'litedown::fuse_book("docs")'
          cd docs; cp -r *.html ../gh-pages/; cd ../gh-pages
          git config user.name github-actions
          git config user.email github-actions@github.com
          git add .
          git commit -m "update docs" && git push || true


================================================
FILE: .github/workflows/copilot-setup-steps.yml
================================================
name: Copilot Setup Steps

on:
  workflow_dispatch:
  push:
    paths:
      - .github/workflows/copilot-setup-steps.yml
  pull_request:
    paths:
      - .github/workflows/copilot-setup-steps.yml

jobs:
  copilot-setup-steps:
    runs-on: ubuntu-latest

    permissions:
      contents: read

    steps:
      - name: Checkout code
        uses: actions/checkout@HEAD

      - name: Install R
        uses: r-lib/actions/setup-r@HEAD
        with:
          use-public-rspm: true

      - uses: yihui/actions/setup-r-dependencies@HEAD
        with:
          extra-packages: roxygen2 .


================================================
FILE: .github/workflows/github-pages.yml
================================================
name: Build and deploy package site

on:
  push:
    branches: ["main"]

permissions:
  contents: read
  pages: write
  id-token: write

jobs:
  deploy:
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@HEAD
      - uses: actions/configure-pages@HEAD
        with:
          enablement: true
      - uses: yihui/litedown/site@HEAD
        with:
          site-dir: 'site'
      - uses: actions/upload-pages-artifact@HEAD
        with:
          path: 'site'
      - id: deployment
        uses: actions/deploy-pages@HEAD


================================================
FILE: .gitignore
================================================
.Rproj.user
.Rhistory
.RData
.Ruserdata
/examples/*__files/
/examples/figures/
/site/doc/
/site/_footer.md
/docs/packages.bib
*.Rcheck/
*.tar.gz


================================================
FILE: DESCRIPTION
================================================
Package: litedown
Type: Package
Title: A Lightweight Version of R Markdown
Version: 0.9.9
Authors@R: c(
    person("Yihui", "Xie", role = c("aut", "cre"), email = "xie@yihui.name", comment = c(ORCID = "0000-0003-0645-5666", URL = "https://yihui.org")),
    person("Tim", "Taylor", role = "ctb", comment = c(ORCID = "0000-0002-8587-7113")),
    person()
    )
Description: Render R Markdown to Markdown (without using 'knitr'), and Markdown
    to lightweight HTML or 'LaTeX' documents with the 'commonmark' package (instead
    of 'Pandoc'). Some missing Markdown features in 'commonmark' are also
    supported, such as raw HTML or 'LaTeX' blocks, 'LaTeX' math, superscripts,
    subscripts, footnotes, element attributes, and appendices,
    but not all 'Pandoc' Markdown features are (or will be) supported. With
    additional JavaScript and CSS, you can also create HTML slides and articles.
    This package can be viewed as a trimmed-down version of R Markdown and
    'knitr'. It does not aim at rich Markdown features or a large variety of
    output formats (the primary formats are HTML and 'LaTeX'). Book and website
    projects of multiple input documents are also supported.
Depends: R (>= 3.2.0)
Imports:
    utils,
    commonmark (>= 2.0.0),
    xfun (>= 0.55)
Suggests:
    rbibutils,
    rstudioapi,
    testit,
    tinytex
License: MIT + file LICENSE
URL: https://github.com/yihui/litedown
BugReports: https://github.com/yihui/litedown/issues
VignetteBuilder: litedown
RoxygenNote: 7.3.3
Encoding: UTF-8
Roxygen: list(markdown = TRUE)


================================================
FILE: LICENSE
================================================
YEAR: 2024-2026
COPYRIGHT HOLDER: Yihui Xie


================================================
FILE: LICENSE.md
================================================
# MIT License

Copyright (c) 2023 Posit Software, PBC
Copyright (c) 2024-2026 Yihui Xie

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.


================================================
FILE: Makefile
================================================
all:
	ln -f ../lite.js/js/snap.js ../lite.js/css/snap.css ../lite.js/css/default.css inst/resources/
	Rscript -e "Rd2roxygen::rab('.', install=TRUE)"
	rm litedown_*.tar.gz
	cd examples && Rscript _run.R


================================================
FILE: NAMESPACE
================================================
# Generated by roxygen2: do not edit by hand

S3method(print,litedown_env)
S3method(record_print,data.frame)
S3method(record_print,knitr_kable)
S3method(record_print,matrix)
S3method(record_print,tbl_df)
export(crack)
export(engines)
export(fiss)
export(fuse)
export(fuse_book)
export(fuse_env)
export(fuse_site)
export(get_context)
export(html_format)
export(latex_format)
export(mark)
export(markdown_options)
export(pkg_citation)
export(pkg_code)
export(pkg_desc)
export(pkg_manual)
export(pkg_news)
export(raw_text)
export(reactor)
export(roam)
export(sieve)
export(timing_data)
export(vest)
import(utils)
importFrom(xfun,Rscript_call)
importFrom(xfun,alnum_id)
importFrom(xfun,base64_uri)
importFrom(xfun,csv_options)
importFrom(xfun,del_empty_dir)
importFrom(xfun,dir_create)
importFrom(xfun,divide_chunk)
importFrom(xfun,download_cache)
importFrom(xfun,exit_call)
importFrom(xfun,fenced_block)
importFrom(xfun,fenced_div)
importFrom(xfun,file_exists)
importFrom(xfun,file_ext)
importFrom(xfun,grep_sub)
importFrom(xfun,html_escape)
importFrom(xfun,html_tag)
importFrom(xfun,html_value)
importFrom(xfun,in_dir)
importFrom(xfun,is_abs_path)
importFrom(xfun,is_blank)
importFrom(xfun,is_rel_path)
importFrom(xfun,loadable)
importFrom(xfun,mime_type)
importFrom(xfun,new_app)
importFrom(xfun,new_record)
importFrom(xfun,normalize_path)
importFrom(xfun,parse_only)
importFrom(xfun,prose_index)
importFrom(xfun,raw_string)
importFrom(xfun,read_all)
importFrom(xfun,read_utf8)
importFrom(xfun,record_print)
importFrom(xfun,relative_path)
importFrom(xfun,same_path)
importFrom(xfun,sans_ext)
importFrom(xfun,set_envvar)
importFrom(xfun,split_lines)
importFrom(xfun,try_error)
importFrom(xfun,try_silent)
importFrom(xfun,with_ext)
importFrom(xfun,write_utf8)


================================================
FILE: NEWS.md
================================================
# CHANGES IN litedown VERSION 0.10

- When the `output` argument of `mark()` is a `.pdf` file, Markdown will be converted to a full `.tex` file instead of a LaTeX fragment before it is compiled to PDF.

- Fixed a bug that sections in the appendix could not be cross-referenced.

- Added a meta variable `lang` for HTML output, which is the language of the document (e.g., `en-US` for US English). This variable is used in the `<html lang="...">` tag of the HTML output file. By default, the language is detected from the system locale, but you can also set it via the `lang` field under `meta` in YAML metadata, e.g., `lang: en-GB` for British English (thanks, @TimTaylor, #121).

- Fixed a bug when embedding JS resources: previously `</` was escaped to `<\/` to avoid `</script>` from being present in the JS source, but the escaping was too general (e.g., `</` may appear in a regex `/</g`). Now we only escape `</script>` to `<\/script>`.

- For `fuse_site()`, the generated site menu now automatically includes landing pages for one-level subdirectories containing `index.html` (e.g., `playground/index.html`), so subdirectory index pages appear in the navigation.

- Fixed a re-entrancy bug that chunk options `fig.path` and `cache.path` in nested `fuse()` calls caused overridden figure/cache files (thanks, @nanxstats, #127).

# CHANGES IN litedown VERSION 0.9

- Provided [a new chunk option `filter`](https://yihui.org/litedown/#sec:option-filter) to filter the output elements via a custom function. This makes it possible to re-order output elements. As a result, text output and plots from a `for`-loop can be interleaved (thanks, @reedacartwright, #106).

- The chunk option `attr.source` will default to `.lang` (where `lang` is the engine name) only when it is not provided, i.e., `NULL`. Previously the value `.lang` would still be used when `attr.source` has been provided. Now it's possible to completely override it, e.g., `attr.source = 'language-r'` as in #107 (thanks, @ThomasSoeiro).

- The chunk option `collapse = TRUE` also applies to message blocks, including warnings, messages, and errors (thanks, @ThomasSoeiro, #108).

- `pkg_desc()` gained a new argument `type` and the package description can be generated to either a table or a definition list now.

- The `embed_resources` option was buggy for deferred JS resources. Previously they were moved to `<head>`, but deferred scripts should be executed after the full DOM is ready, so they are moved before `</body>` instead if they are to be embedded.

- Fixed a bug in `pkg_manual()` that may lead to omission of certain items when building TOC.

# CHANGES IN litedown VERSION 0.8

- Added a new chunk option `fig.keep` to select plots to be kept in a code chunk (thanks, @Gabrielforest, #99). See https://yihui.org/litedown/#sec:option-fig for documentation.

- Improved support for LaTeX footnotes. The footnote identifier no longer has to be a number, and the footnote can include arbitrary elements (not necessarily a single paragraph).

- The chunk option `results = 'hide'` will imply `collapse = TRUE`, i.e., when text output is hidden, the source blocks will be merged (thanks, @jangorecki, #87). 

- `fuse(text = '# text')` will be treated as Markdown input instead of R code input (thanks, @chuxinyuan, #102).

- Fixed the bug that the chunk option `fig.path` does not work when it does not contain `/` (thanks, @J-Moravec, #88).

- `get_context('full_input')` gives the full path to the input file of `fuse()` (thanks, @rikivillalba, #104).

# CHANGES IN litedown VERSION 0.7

- `pkg_manual()` will exclude help pages with the keyword `internal` (thanks, @TimTaylor, #78).

- `pkg_news()` will add links to Github users and issue numbers if the package is hosted on Github.

- Allow `,` and `.` in superscripts and subscripts (thanks, @janlisec, #81).

- Special characters in bibliography entries such as `{\"u}` can be correctly read now (thanks, @bastistician).

- Fixed a bug that inline code expressions (`` `{lang} expr` ``) cannot be correctly located when the line has leading spaces that are not meaningful.

- Deleted vignettes `markdown-examples` and `markdown-output`. They are rendered on the package site now: https://git.yihui.org/litedown/examples/test-options.html

# CHANGES IN litedown VERSION 0.6

- Added a Markdown rendering option `offline` to download web resources when this option is set to true, so that the HTML output can be viewed offline (thanks, @TimTaylor, #73). See https://yihui.org/litedown/#sec:offline for more info.

- Added a function `get_context()` to query the `fuse()` context such as the input file path or the output format (thanks, @MichaelChirico #67, @vincentarelbundock #70).

- Added a function `raw_text()` to output raw text content in a code chunk (thanks, @vincentarelbundock, #69).

- Dropped the chunk option `ref.label` and added a new chunk option `fill`, which is more general (`ref.label = "LABEL"` can be achieved by `` `<LABEL>` `` inside a chunk). See https://yihui.org/litedown/#sec:option-fill for more information.

- Fixed a bug that `fuse()` fails to print the error location when the whole input document consists of a single chunk that throws an error (thanks, @kevinushey, yihui/knitr#2387).

- `fuse_book()` will ignore YAML headers in book chapters except for the index chapter.

# CHANGES IN litedown VERSION 0.5

- Added a wizard in `roam()` to create new `.Rmd`/`.md`/`.R` files with selected HTML features.

- Added a new engine `embed` to embed text files via a code chunk.

- Changed the meaning of the chunk option `order`: previously, higher values indicate earlier execution; now higher values indicate later execution. This is a breaking change, but the new meaning should feel more natural. For example, `order = i` means to execute the chunk in the i-th step, and `order = i - 1.5` means to move the chunk back 1.5 step in the queue so it will be executed earlier than its previous chunk. See https://yihui.org/litedown/#sec:option-order for details.

- Shortened the output format names `litedown::html_format` to `html`, and `litedown::latex_format` to `latex`. The names `litedown::*` can still be used if you like.

- Added options `dollar`, `signif`, and `power` to format numbers from inline code. See https://yihui.org/litedown/#sec:inline-code for details.

- When embedding SVG images in HTML output, embed their raw XML content instead of base64 encoding them.

- Empty table headers are removed in HTML output (they may be generated from data frames or matrices without column names).

- Added support for the chunk option `collapse = TRUE` (thanks, @J-Moravec, #40).

- Added support for the chunk option `fig.dim`, which is a shortcut for `fig.width` and `fig.height`.

- Added a new function `vest()` as another way to add CSS/JS assets to HTML output.

- Provided templates and a Github action `yihui/litedown/site` to build package websites. See https://yihui.org/litedown/#sec:pkg-site for details.

- Added an argument `examples` to `pkg_manual()` to run examples and show their output (thanks, @TimTaylor, #54).

- Fixed a bug that the default CSS wouldn't be added when a math expression exists on the page (thanks, @calvinw, #61).

- Fixed a bug that cross-references to other chapters of a book could not be resolved when previewing a single chapter.

- Fixed a bug that the file navigation by line numbers on code blocks stopped working in `litedown::roam()` due to yihui/lite.js@5e06d19.

- Fixed a bug that `R` code blocks could not be embedded when using prism.js for syntax highlighting (thanks, @TimTaylor, #53).

- `pkg_manual()` will point out the name of the problematic Rd file when the Rd file fails to convert to HTML (thanks, @BSchamberger).

- Dropped **knitr** and **rmarkdown** from the `Suggests` field in `DESCRIPTION`. Previously, **litedown** allowed `rmarkdown::render()` to use the output formats `litedown::html_format` and `litedown::latex_format`. Now `rmarkdown::render()` is no longer supported, and `litedown::fuse()` must be used instead.

# CHANGES IN litedown VERSION 0.4

- Provided an option `options(litedown.roam.cleanup = TRUE)` to clean up the `*__files/` directory after previewing `.Rmd` or `.R` files via `litedown::roam()` (thanks, @TimTaylor, #36).

- Added the keyboard shortcut `Ctrl + K` (or `Command + K` on macOS) for rendering a file to disk in the `litedown::roam()` preview.

- Cross-references also work for LaTeX output now.

- Fixed an error in the internal function `detect_pkg()` during `R CMD check` on CRAN.

- Set `options(bitmapType = 'cairo')` on macOS only when `xquartz` is available. Previously only `capabilities('cairo')` was checked, which was not enough. This option can also be manually set via `options(bitmapType)` in a code chunk if the automatic switch to `cairo` is not desired.

- Fixed the bug that indented or quoted code blocks are not correctly indented or quoted when a code expression contains multiple lines.

- Fixed the bug that the span syntax `[text](){...}` doesn't work when `text` contains markup (e.g., bold or italic).

# CHANGES IN litedown VERSION 0.3

- Added a new engine `md` to output Markdown text both verbatim and as-is, which can be useful for showing Markdown examples, e.g.,

  ````md
  ```{md}
  You can see both the _source_ and _output_ of
  this `md` chunk.
  ```
  
  You can also use `{md} the engine **inline**`.
  ````

- Added a new engine `mermaid` to generate Mermaid diagrams, e.g.,

  ````md
  ```{mermaid, fig.cap='A nice flowchart.'}
  graph TD;
      A-->B;
      A-->C;
      B-->D;
      C-->D;
  ```
  ````

- Added helper functions `pkg_desc()`, `pkg_news()`, `pkg_citation()`, `pkg_code()`, and `pkg_manual()` to get various package information for building the full package documentation as a single-file book (thanks, @jangorecki @llrs #24, @TimTaylor #22).

- LaTeX math environments such as equations can be numbered and cross-referenced now (thanks, @hturner, #32).

- Section headings containing the class name "unlisted" will be excluded in the table of contents.

- Provided a way to write `<span>` with attributes based on empty links, i.e., `[text](){.class #id ...}`. The empty URL here tells `mark()` to treat the link as a `<span>` instead of `<a>`.

- Added back/forward/refresh/print buttons to the toolbar in the `litedown::roam()` preview interface.

- Changed the behavior of `.Rmd` and `.R` file links in the `litedown::roam()` interface: previously, clicking on an `.Rmd` or `.R` filename will execute them; now it will only show their content, because fully executing the code may be expensive or even dangerous (especially when the files were not authored by you). A new "Run" button has been provided in the interface, on which you can click on to run a file in memory and preview it (i.e., the old behavior of clicking on filenames). You should use this button only if you trust the file.

- Added the JS asset [`@mathjax-config`](https://github.com/yihui/lite.js/blob/main/js/mathjax-config.js) to enable equation numbering by default when the JS math library is set to MathJax (thanks, @hturner, #32).

- Set `options(bitmapType = 'cairo')` in `fuse()` if `capabilities('cairo')` is TRUE, which will generate smaller bitmap plot files (e.g., `.png`) than using `quartz` or `Xlib`, and is also a safer option for `fuse()` to be executed in parallel (rstudio/rmarkdown#2561).

- Added a new vignette engine `litedown::book` to make it possible to build multiple vignettes into a book. To use this engine, declare `\VignetteEngine{litedown::book}` only in the book index file (e.g., `index.Rmd`) but not in other book chapter files.

- Added support for an array of multiple authors in the YAML metadata (thanks, @AlbertLei, #28). If the `author` field in YAML is an array of length > 1, each author will be written to a separate `<h2>` in HTML output, or concatenated by `\and` in LaTeX output. Note that you can also write multiple authors in a single string (e.g., `author: "Jane X and John Y"`) instead of using an array (`author: ["Jane X", "John Y"]`), in which case the string will be treated as a single author (they will be put inside a single `<h2>` in HTML output).

- Fixed the bug that the leading `-`, `+`, or `*` in a LaTeX math expression was recognized as the bullet list marker, which would invalidate the math expression (thanks, @hturner, #33).

- Changed the first `-` to `:` in automatically generated element IDs, including section, figure, and table IDs, e.g., the ID `sec-intro-methods` is changed to `sec:intro-methods`, and `fig-nice-plot` is changed to `fig:nice-plot`. You can still use `-` when manually assigning IDs to elements, e.g., `# Intro Methods {#sec-intro-methods}`. For backward compatibility, cross-references using `-` will be resolved if the `:` version of the ID can be found, e.g., `@sec-intro-methods` will be resolved to `@sec:intro-methods` if the former cannot be found but the latter can.

- Fixed a bug that when LaTeX math environments are written in raw LaTeX blocks (i.e., ```` ```{=latex}````), `mark()` will not load the math JS library such as MathJax or KaTeX unless `$ $` or `$$ $$` expressions are present in the document.

- As-is output accepts attributes via the chunk option `attr.asis` now. If provided, as-is output will be wrapped in a fenced Div with these attributes.

- Numeric output from inline code will no longer be formatted if the value is wrapped in `I()`.

- The prefix for the automatic IDs of `h1` headings has been changed from `sec:` to `chp:`. For other levels of headings, the prefix is still `sec:`.

- Provided a new option `embed_cleanup` to clean up plot files that have been embedded in HTML output (thanks, @TimTaylor, #16).

- `fuse()` supports the output format `litedown::markdown_format` now, which generates the intermediate Markdown from R Markdown without further rendering Markdown to other formats. Using this output format is equivalent to `fuse(..., output = '.md')` or `fuse(..., output = 'markdown')` (thanks, @mikmart, #35).

# CHANGES IN litedown VERSION 0.2

- A data frame (or matrix/tibble) wrapped in `I()` is fully printed to a table now by default. Without `I()`, data objects are truncated to 10 rows by default when printing to tables.

- When `options(litedown.fig.alt = TRUE)` and the chunk option `fig.alt` is unset, `fuse()` will emit reminders about the missing alt text for code chunks containing plots (thanks, @TimTaylor, #23). Providing alt text can improve the accessibility of images in HTML output. To avoid omitting the alt text inadvertently, you can set the option `litedown.fig.alt` in your `.Rprofile`.

- Added the meta variable `plain-title` for HTML output, which is the plain version of the document title (i.e., without HTML tags), and used in the `<title>` tag.

- Check boxes from `- [ ] ...` are no longer disabled in HTML output.

- The implicit latest version of jsdelivr resources will be resolved to an explicit version, e.g., `https://cdn.jsdelivr.net/npm/@xiee/utils/css/default.css` will be resolved to `https://cdn.jsdelivr.net/npm/@xiee/utils@X.Y.Z/css/default.css`, where `X.Y.Z` is the current latest version. This will make sure the HTML output containing jsdelivr resources is stable.

# CHANGES IN litedown VERSION 0.1

- Initial CRAN release.


================================================
FILE: R/format.R
================================================
is_rmd_preview = function() Sys.getenv('RMARKDOWN_PREVIEW_DIR') != ''

output_format = function(to, options, meta, ...) {
  if (is_rmd_preview()) stop(
    "It appears that you clicked the 'Knit' button in RStudio to render the document. ",
    "You are recommended to use litedown::roam() to preview or render documents instead. ",
    "Alternatively, you can add a top-level field 'knit: litedown:::knit' to the YAML metadata, ",
    "so the document can be rendered by litedown::fuse() instead of rmarkdown::render().",
    call. = FALSE
  )
  msg = 'Please render the document via litedown::fuse() instead of rmarkdown::render().'
  if ('pkgdown' %in% loadedNamespaces()) {
    warning(
      msg, '\n\nIf you intend to build a package website, you can also use litedown:',
      ' https://yihui.org/litedown/#sec:pkg-site', call. = FALSE
    )
    ns = asNamespace('rmarkdown')
    ag = merge_list(list(to = to), list(...))
    ag = ag[intersect(names(formals(ns$pandoc_options)), names(ag))]
    opts = do.call(ns$pandoc_options, ag)
    return(ns$output_format(NULL, opts))
  }
  stop(msg, call. = FALSE)
}

#' Output formats in YAML metadata
#'
#' These functions exist only for historical reasons, and should never be called
#' directly. They can be used to configure output formats in YAML, but you are
#' recommended to use the file format names instead of these function names.
#'
#' To configure output formats in the YAML metadata of the Markdown document,
#' simply use the output format names such as `html` or `latex` in the `output`
#' field in YAML, e.g.,
#'
#' ```yaml
#' ---
#' output:
#'   html:
#'     options:
#'       toc: true
#'     keep_md: true
#'   latex:
#'     latex_engine: pdflatex
#' ---
#' ```
#'
#' You can also use `litedown::html_format` instead of `html` (or
#' `litedown::latex_format` instead of `latex`) if you like.
#' @param meta,options Arguments to be passed to [mark()].
#' @param template A template file path.
#' @param keep_md,keep_tex Whether to keep the intermediate \file{.md} and
#'   \file{.tex} files generated from \file{.Rmd}.
#' @param latex_engine The LaTeX engine to compile \file{.tex} to \file{.pdf}.
#' @param citation_package The LaTeX package for processing citations. Possible
#'   values are `none`, `natbib`, and `biblatex`.
#' @note If you want to use the `Knit` button in RStudio, you must add a
#'   top-level field `knit: litedown:::knit` to the YAML metadata. See
#'   \url{https://yihui.org/litedown/#sec:knit-button} for more information.
#' @export
html_format = function(options = NULL, meta = NULL, template = NULL, keep_md = FALSE) {
  output_format('html', options, meta, template, keep_md)
}

#' @rdname html_format
#' @export
latex_format = function(
  options = NULL, meta = NULL, template = NULL, keep_md = FALSE,
  keep_tex = FALSE, latex_engine = 'xelatex', citation_package = 'natbib'
) {
  output_format()
}

# map rmarkdown arguments to markdown
map_args = function(
  toc = FALSE, toc_depth = 3, number_sections = FALSE, anchor_sections = FALSE,
  code_folding = 'none', self_contained = TRUE, math_method = 'default',
  css = NULL, includes = NULL, ...
) {
  opts = list(
    toc = toc, number_sections = number_sections, embed_resources = self_contained
  )
  meta = list(css = c('default', css))
  if (toc) opts$toc = list(depth = toc_depth)
  if (identical(
    if (is.list(math_method)) math_method$engine else math_method, 'mathjax'
  )) opts$js_math = 'mathjax'
  if (!isFALSE(anchor_sections)) {
    meta$js = c(meta$js, '@heading-anchor')
    meta$css = c(meta$css, '@heading-anchor')
  }
  # 'hide' is not supported here; if it is desired, use <script data-open=false>
  if (code_folding != 'none') meta$js = c(
    meta$js, '@fold-details'
  )
  if (is.list(includes)) meta[
    c('header_includes', 'include_before', 'include_after')
  ] = includes[c('in_header', 'before_body', 'after_body')]
  list(meta = meta, options = opts, ...)
}

# split YAML and body from text input
yaml_body = function(text, ...) {
  res = xfun::yaml_body(text, use_yaml = FALSE, ...)
  if (!is.null(yaml <- normalize_yaml(res$yaml))) res$yaml = yaml
  res
}

# normalize (rmarkdown) output formats in YAML to litedown's formats
normalize_yaml = function(x) {
  if (!length(out <- x[['output']])) {
    # if the key 'format' is provided, normalize it to 'output'
    if (length(out <- x[['format']])) x$format = NULL else return()
  }
  if (is.character(out)) out = set_names(vector('list', length(out)), out)
  if (!is.list(out))
    stop('The output format field in YAML must be either list or character')
  fmt = c(html_document = 'html', html_vignette = 'html', pdf_document = 'latex')
  for (i in intersect(names(fmt), names(out))) {
    out[[i]] = if (is.list(out[[i]])) do.call(map_args, out[[i]]) else list()
    names(out)[names(out) == i] = fmt[i]
  }
  # normalize format names `(lite|mark)down::*_format` to `*`
  names(out) = gsub('^(lite|mark)down::+([^_]+)_.*', '\\2', names(out))
  x$output = out
  x
}

# get metadata from a certain field under an output format
yaml_field = function(yaml, format, name = 'meta') {
  if (is.list(out <- yaml[['output']]) && is.list(out <- out[[format]])) {
    if (length(name) == 1) out[[name]] else out[name]
  }
}

# get output format from YAML's `output` field
yaml_format = function(yaml) {
  if (is.list(out <- yaml[['output']])) out = names(out)
  c(out, 'html')[1]
}

# determine output format based on output file name and input's YAML
detect_format = function(output, yaml) {
  res = if (is.character(output)) {
    # check if output is a known format, e.g., output = 'html'
    if (output %in% names(md_formats)) output else {
      # check file extension, e.g., output = '.pdf'
      ext = file_ext(output)
      if (ext == 'pdf') 'latex' else {
        names(which(md_formats == paste0('.', ext))) %|%
          # output = 'markdown:format', e.g., markdown:latex means the final
          # format is latex but the intermediate output should be markdown
          if (startsWith(output, 'markdown:')) intersect(
            sub('^markdown:', '', output), names(md_formats)
          )
      }
    }
  }
  # if unable to detect format from `output`, try YAML
  if (length(res) == 1) res else yaml_format(yaml)
}

md_formats = c(
  html = '.html', xml = '.xml', man = '.man', commonmark = '.markdown',
  markdown = '.md', text = '.txt', latex = '.tex'
)


================================================
FILE: R/fuse.R
================================================
new_env = function(...) new.env(..., parent = emptyenv())

#' Parse R Markdown or R scripts
#'
#' Parse input into code chunks, inline code expressions, and text fragments:
#' [crack()] is for parsing R Markdown, and [sieve()] is for R scripts.
#'
#' For R Markdown, a code chunk must start with a fence of the form ````
#' ```{lang} ````, where `lang` is the language name, e.g., `r` or `python`. The
#' body of a code chunk can start with chunk options written in "pipe comments",
#' e.g., `#| eval = TRUE, echo = FALSE` (the CSV syntax) or `#| eval: true` (the
#' YAML syntax). An inline code fragment is of the form `` `{lang} source` ``
#' embedded in Markdown text.
#' @inheritParams mark
#' @export
#' @return A list of code chunks and text blocks:
#'
#'   - Code chunks are of the form `list(source, type = "code_chunk", options,
#'   comments, ...)`: `source` is a character vector of the source code of a
#'   code chunk, `options` is a list of chunk options, and `comments` is a
#'   vector of pipe comments.
#'
#'   - Text blocks are of the form `list(source, type = "text_block", ...)`. If
#'   the text block does not contain any inline code, `source` will be a
#'   character string (lines of text concatenated by line breaks), otherwise it
#'   will be a list with members that are either character strings (normal text
#'   fragments) or lists of the form `list(source, options, ...)` (`source` is
#'   the inline code, and `options` contains its options specified inside ``
#'   `{lang, ...}` ``).
#'
#'   Both code chunks and text blocks have a list member named `lines` that
#'   stores their starting and ending line numbers in the input.
#' @examples
#' library(litedown)
#' # parse R Markdown
#' res = crack(c('```{r}\n1+1\n```', 'Hello, `pi` = `{r} pi` and `e` = `{r} exp(1)`!'))
#' str(res)
#' # evaluate inline code and combine results with text fragments
#' txt = lapply(res[[2]]$source, function(x) {
#'   if (is.character(x)) x else eval(parse(text = x$source))
#' })
#' paste(unlist(txt), collapse = '')
crack = function(input, text = NULL) {
  text = read_input(input, text); input = attr(text, 'input')
  xml = commonmark::markdown_xml(text, sourcepos = TRUE)
  rx_engine = '([a-zA-Z0-9_]+)'  # only allow these characters for engine names
  r = paste0(
    '<(code|code_block) sourcepos="(\\d+):(\\d+)-(\\d+):(\\d+)"( info="[{]+',
    rx_engine, '[^"]*?[}]")? xml:space="[^>]*>([^<]*)<'
  )
  m = match_all(xml, r, perl = TRUE)[[1]] %|% matrix(character(), 9)
  # code blocks must have non-empty info strings
  m = m[, m[2, ] != 'code_block' | m[8, ] != '', drop = FALSE]

  res = list()
  # add a block of text and the line range info
  add_block = function(l1, l2, ...) {
    res[[length(res) + 1]] <<- list(source = text[l1:l2], ..., lines = c(l1, l2))
    res
  }

  n = length(text)
  i = 1L  # the possible start line number of text blocks
  for (j in which(m[2, ] == 'code_block')) {
    # start (3) and end (5) line numbers for code chunks
    pos = as.integer(m[c(3, 5), j]); i1 = pos[1]; i2 = pos[2]
    # add the possible text block before the current code chunk
    if (i1 > i) add_block(i, i1 - 1L, type = 'text_block')
    # add the code chunk
    add_block(i1, i2, info = m[8, j], type = 'code_chunk')
    i = i2 + 1L  # the earliest line for the next text block is next line
  }
  # if there are lines remaining, they must be a text block
  if (i <= n) add_block(i, n, type = 'text_block')

  if (!length(m)) return(res)

  set_error_handler(input)

  m = m[, m[2, ] == 'code', drop = FALSE]
  # find out inline code `{lang} expr`
  rx_inline = '^\\s*[{](.+?)[}]\\s+(.+?)\\s*$'
  # look for `r expr` if `{lang}` not found (for compatibility with knitr)
  if (!any(j <- grepl(rx_inline, m[9, ])) && getOption('litedown.enable.knitr_inline', FALSE)) {
    rx_inline = '^(r) +(.+?)\\s*$'
    j = grepl(rx_inline, m[9, ])
  }
  m = m[, j, drop = FALSE]
  n_start = uapply(res, function(x) x$lines[1])  # starting line numbers
  j = findInterval(m[3, ], n_start)  # find which block each inline code belongs to
  for (i in seq_len(ncol(m))) {
    b = res[[j[i]]]; l = b$lines
    # column position is based on bytes instead of chars; needs to be adjusted to the latter
    pos = char_pos(text, as.integer(m[3:6, i]))
    i1 = pos[1]; i2 = pos[3]
    # commonmark::markdown_xml(sourcepos = TRUE) gives wrong column info when
    # the line has leading spaces (which are ignored), so doublecheck here (in
    # theory we also need to consider the case i1 != i2 but that's a little too
    # complicated and may be rare, too)
    if (i1 == i2 && grepl('^\\s+', ti <- text[i1])) {
      mi = restore_html(m[9, i])
      if (substring(ti, pos[2], pos[4]) != mi) {
        p = base::gregexpr(mi, ti, fixed = TRUE)[[1]]
        if (length(p) > 1 || p < 1) {
          save_pos(c(i1, i2)); stop(
            'Unable to locate the inline code expression ', mi,
            '. Please file an issue to https://github.com/yihui/litedown/issues ',
            'with a minimal reproducible example.'
          )
        }
        pos[2] = p; pos[4] = p + attr(p, 'match.length') - 1
      }
    }
    s = nchar(b$source)
    # calculate new position of code after we concatenate all lines of this block by \n
    b$col = c(b$col, c(
      sum(s[seq_len(i1 - l[1])] + 1) + pos[2],
      sum(s[seq_len(i2 - l[1])] + 1) + pos[4]
    ))
    b$pos = c(b$pos, pos)
    res[[j[i]]] = b
  }

  i1 = 0  # code chunk index
  # remove code fences, and extract code in text blocks
  for (j in seq_along(res)) {
    b = res[[j]]
    if (b$type == 'code_chunk') {
      code = b$source
      N = length(code)
      # a code block may be indented or inside a blockquote
      p = grep_sub('^([\t >]*)(`{3,}|~{3,}).*', '\\1\\2', code[1])
      if (length(p) == 0) stop('Possibly malformed code block fence: ', code[1])
      if (!grepl(sub('^[\t >]*', '', p), code[N])) stop(
        'The fences of the code block do not match:\n\n', code[1], '\n', code[N]
      )
      p = gsub('[`~]+$', '', p)
      if (p != '') {
        i = startsWith(code, p)
        # remove indentation or >
        code[i] = substr(code[i], nchar(p) + 1, nchar(code[i]))
        # trailing spaces in the prefix may have been trimmed: yihui/knitr#1446
        code[!i] = gsub(gsub('(.+?)\\s+$', '^\\1', p), '', code[!i])
        b$prefix = p
      }
      # possible comma-separated chunk options in header
      rx_opts = paste0('^(`{3,}|~{3,})\\s*([{]+)', rx_engine, '(.*?)\\s*[}]+\\s*$')
      o = match_one(code[1], rx_opts)[[1]]
      if (length(o)) {
        # if two or more `{` is used, we will write chunk fences to output
        if (nchar(o[3]) > 1) b$fences = c(
          sub('{{', '{', sub('}}\\s*$', '}', code[1]), fixed = TRUE), code[N]
        )
        o = if (o[5] != '') csv_options(o[5])
      }
      code = code[-c(1, N)]  # remove fences
      save_pos(b$lines)
      code = split_chunk(b$info, code)
      b[c('source', 'options', 'comments')] = code[c('code', 'options', 'src')]
      # starting line number of code
      b$code_start = b$lines[1] + 1L + length(b$comments)
      # default label is chunk-i (or parent-label-i for child documents)
      i1 = i1 + 1
      # merge chunk options from header with pipe comment options
      b$options = merge_list(list(
        label = sprintf('%s-%d', (if (isTRUE(.env$child)) reactor('label')) %||% 'chunk', i1)
      ), o, b$options)
      b$options$engine = b$info
      b$info = NULL  # the info is stored in chunk options as `engine`
    } else if (length(p <- b$col) > 0) {
      p = matrix(p, nrow = 2)
      x = one_string(b$source)
      x1 = substring(x, p[1, ], p[2, ])  # code
      # then extract normal text
      p = rbind(p[1, ] - 1, p[2, ] + 1)
      p = matrix(c(1, p, nchar(x)), nrow = 2)
      x2 = substring(x, p[1, ], p[2, ])  # text
      # get rid of left-over backticks
      N = length(x2)
      x2[1] = gsub('`+$', '', x2[1])  # trailing ` of first
      x2[N] = gsub('^`+', '', x2[N])  # leading ` of last
      # ` at both ends for text in the middle
      if (N > 2) x2[2:(N - 1)] = gsub('^`+|`+$', '', x2[2:(N - 1)])
      # see if the code is wrapped in $ $
      d1 = substring(x2, nchar(x2), nchar(x2)) == '$'
      d2 = substring(x2, 1, 1) == '$'
      dollar = d1[-N] & d2[-1]
      # position of code c(row1, col1, row2, col2)
      pos = matrix(b$pos, nrow = 4)
      x = as.list(head(c(rbind(x2, c(x1, ''))), -1))
      for (i in seq_len(N - 1)) {
        z = match_one(x1[i], rx_inline)[[1]][-1]
        p2 = pos[, i]; save_pos(p2)
        xi = list(
          source = z[2], pos = p2,
          options = csv_options(gsub('^([^,]+)', 'engine="\\1"', z[1]))
        )
        if (dollar[i]) xi$math = TRUE
        x[[2 * i]] = xi
      }
      b$source = x
    } else {
      b$source = paste(b$source, collapse = '\n')
    }
    b$pos = b$col = NULL  # positions not useful anymore
    res[[j]] = b
  }
  res
}

set_error_handler = function(input) {
  opts = options(xfun.handle_error.loc_fun = get_loc)
  oenv = as.list(.env)
  exit_call(function() { options(opts); reset_env(oenv, .env) })
  .env$input = input  # store the input name for get_loc()
}

# convert byte position to character position
char_pos = function(x, p) {
  x2 = x[p[c(1, 3)]]
  # no need to convert if no multibyte chars
  if (all(nchar(x2) == nchar(x2, 'bytes'))) return(p)
  p2 = p[c(2, 4)]
  Encoding(x2) = 'bytes'
  x2 = substr(x2, 1, p2 - 1)  # go back one char in case current column is multibyte
  Encoding(x2) = 'UTF-8'
  p[c(2, 4)] = nchar(x2) + 1L  # go forward by one char
  if (p2[2] == 0) p[4] = 0L  # boundary case: \n before the closing backtick
  p
}

#' @details For R scripts, text blocks are extracted by removing the leading
#'   `#'` tokens. All other lines are treated as R code, which can optionally be
#'   separated into chunks by consecutive lines of `#|` comments (chunk options
#'   are written in these comments). If no `#'` or `#|` tokens are found in the
#'   script, the script will be divided into chunks that contain smallest
#'   possible complete R expressions.
#' @note For simplicity, [sieve()] does not support inline code expressions.
#'   Text after `#'` is treated as pure Markdown.
#'
#'   It is a pure coincidence that the function names `crack()` and `sieve()`
#'   weakly resemble Carson Sievert's name, but I will consider adding a class
#'   name `sievert` to the returned value of `sieve()` if Carson becomes the
#'   president of the United States someday, which may make the value
#'   radioactive and introduce a new programming paradigm named _Radioactive
#'   Programming_ (in case _Reactive Programming_ is no longer fun or cool).
#' @rdname crack
#' @export
#' @examples
#'
#' # parse R code
#' res = sieve(c("#' This is _doc_.", '', '#| eval=TRUE', '# this is code', '1 + 1'))
#' str(res)
sieve = function(input, text = NULL) {
  text = read_input(input, text); input = attr(text, 'input')
  n = length(text)
  r = run_range(grepl("^#'( .+| *)$", text), is_blank(text))
  nc = ncol(r)

  # no #' or #|: split code into smallest expressions
  if (nc == 0 && !any(startsWith(text, '#| '))) {
    res = xfun::split_source(text, TRUE, TRUE)
    res = .mapply(function(code, label) {
      l = attr(code, 'lines')
      list(
        source = c(code), type = 'code_chunk', lines = l, code_start = l[1],
        options = list(engine = 'r', label = label)
      )
    }, res, sprintf('chunk-%d', seq_along(res)))
    return(res)
  }

  # split doc and code by #', and divide code by #|
  res = list()
  add_block = function(l1, l2, type = 'code_chunk', pipe = FALSE) {
    x = text[l1:l2]
    if (type == 'text_block') {
      el = list(source = one_string(sub("^#' ?", '', x)))
    } else {
      if (all(i <- is_blank(x))) return()
      # trim blank lines at both ends
      i2 = range(which(!i))  # first and last non-empty lines
      l1 = l1 + (i2[1] - 1)
      l2 = l2 - (length(i) - i2[2])
      save_pos(c(l1, l2))
      x = text[l1:l2]
      el = if (pipe) partition(x) else list(source = x)
      el$code_start = as.integer(l1) + length(el$comments)
      el$options$engine = 'r'
    }
    el$type = type
    el$lines = as.integer(c(l1, l2))
    res[[length(res) + 1]] <<- el
  }
  partition = function(code) {
    code = split_chunk('r', code)
    set_names(code[c('code', 'options', 'src')], c('source', 'options', 'comments'))
  }
  # detect #| and split a block of code into chunks
  add_chunk = function(l1, l2) {
    x = text[l1:l2]; N = length(x)
    k = run_range(startsWith(x, '#| '))[1, ]
    if ((n <- length(k)) == 0) return(add_block(l1, l2))
    if (k[1] > 1) { k = c(1, k); n = n + 1 }  # make sure to scan from start
    for (i in seq_len(n)) {
      add_block(l1 - 1 + k[i], if (i == n) l2 else l1 - 1 + k[i + 1] - 1, pipe = TRUE)
    }
  }

  set_error_handler(input)

  i = 1
  for (j in seq_len(nc)) {
    i1 = r[1, j]; i2 = r[2, j]
    if (i1 > i) add_chunk(i, i1 - 1)
    add_block(i1, i2, type = 'text_block')
    i = i2 + 1
  }
  if (i <= n) add_chunk(i, n)
  # add possibly missing chunk labels
  i = vapply(res, function(x) x$type == 'code_chunk', FALSE)
  res[i] = .mapply(function(x, label) {
    if (is.null(x$options$label)) x$options$label = label
    x
  }, res[i], sprintf('chunk-%d', seq_len(sum(i))))
  res
}

# ranges of TRUE runs in a logical vector
run_range = function(x, extend = NULL) {
  r = rle(x); l = r$lengths; i = r$values
  i2 = cumsum(l); i1 = i2 - l + 1
  i1 = i1[i]; i2 = i2[i]
  # include adjacent blank lines, e.g., a blank line before or after #' should be doc
  if (!is.null(extend)) {
    k = extend[pmax(i1 - 1, 1)]  # check if previous line is empty
    i1[k] = i1[k] - 1
    k = extend[pmin(i2 + 1, length(extend))]  # check if next line is empty
    i2[k] = i2[k] + 1
    # merge adjacent runs, e.g., 1:2 and 2:4 -> 1:4
    k = NULL
    if ((n <- length(i1)) > 1) {
      k = i2[1:(n - 1)] < i1[2:n]
      i1 = i1[c(TRUE, k)]; i2 = i2[c(k, TRUE)]
    }
  }
  rbind(i1, i2)
}

# convert knitr's inline `r code` to litedown's `{r} code`
convert_knitr = function(input) {
  x = read_utf8(input)
  r = '(?<!(^``))(?<!(\n``))`r[ #]([^`]+)\\s*`'
  i = prose_index(x)
  x[i] = gsub(r, '`{r} \\3`', x[i], perl = TRUE)
  write_utf8(x, input)
}

#' Get the `fuse()` context
#'
#' A helper function to query the [fuse()] context (such as the input file path
#' or the output format name) when called inside a code chunk.
#' @param item The name of the context item.
#' @return If the `item` is provided, return its value in the context. If
#'   `NULL`, the whole context (an environment) is returned.
#' @export
#' @examples
#' litedown::get_context('input')
#' litedown::get_context('format')
#' names(litedown::get_context())  # all available items
get_context = function(item = NULL) if (is.null(item)) .env else .env[[item]]

# return a string to indicate the error location
get_loc = function(label) {
  l = .env$source_pos; n = length(l)
  if (n == 4) l = sprintf('#%d:%d-%d:%d', l[1], l[2], l[3], l[4])  # row1:col1-row2:col2
  if (n == 2) l = sprintf('#%d-%d', l[1], l[2])  # row1-row2
  paste0(.env$input2 %|% .env$input, l, if (label != '') paste0(' [', label, ']'))
}

# save line numbers in .env to be used in error messages
save_pos = function(x) .env$source_pos = x

# line/col info for ANSI links
link_pos = function() {
  l = .env$source_pos
  sprintf('line = %d:col = %d', l[1], if (length(l) == 4) l[2] else 1)
}

# get the execution order of code/text blocks via the `order` option (lower
# values indicate earlier execution)
block_order = function(res, N = length(res)) {
  check = function(b, env) {
    if (is.null(o <- b$options[['order']])) return(NA)
    if (is_lang(o)) o = eval(o, env)
    if (length(o) == 1 && is.numeric(o)) return(o)
    save_pos(b$pos %||% b$lines)
    stop("The chunk option 'order' must be either NULL or a number.", call. = FALSE)
  }
  x = seq_len(N); e = new.env(parent = fuse_env()); e$N = N
  for (i in x) {
    b = res[[i]]; e$i = i
    if (b$type == 'code_chunk') {
      if (!is.na(o <- check(b, e))) x[i] = o
    } else if (is.list(b$source)) {
      # use the first found `order` option in all inline expressions
      for (s in b$source) if (is.list(s) && !is.na(o <- check(s, e))) {
        x[i] = o; break
      }
    }
  }
  order(x)
}

#' @description The function `fuse()` runs code from code chunks and inline code
#'   expressions in R Markdown, interweaves the results with the rest of text in
#'   the input to intermediate Markdown output (which is similar to what
#'   `knitr::knit()` does), and renders the Markdown output through `mark()` to
#'   the final output format, such as HTML or LaTeX (similar to
#'   `rmarkdown::render()`). It also works on R scripts in a way similar to
#'   `knitr::spin()`. The function `fiss()` extracts code from the input, and is
#'   similar to `knitr::purl()`.
#' @rdname mark
#' @param envir An environment in which the code is to be evaluated. It can be
#'   accessed via [fuse_env()] inside [fuse()].
#' @param quiet If `TRUE`, do not show the progress bar. If `FALSE`, the
#'   progress bar will be shown after a number of seconds, which can be set via
#'   a global [option][options] `litedown.progress.delay` (the default is `2`).
#'   THe progress bar output can be set via a global option
#'   `litedown.progress.output` (the default is [stderr()]).
#' @note For `fuse()`, you can generate the intermediate Markdown output via
#'   `output = '.md'` or `output = 'markdown'` without further calling `mark()`.
#' @seealso [sieve()], for the syntax of R scripts to be passed to [fuse()].
#' @export
#' @examples
#' library(litedown)
#' doc = c('```{r}', '1 + 1', '```', '', '$\\pi$ = `{r} pi`.')
#' fuse(doc)
#' fuse(doc, '.tex')
#' fiss(doc)
fuse = function(input, output = NULL, text = NULL, envir = parent.frame(), quiet = FALSE) {
  text = read_input(input, text); input = attr(text, 'input')
  # determine if the input is R or R Markdown
  if (r_input <- is_R(input, text)) {
    blocks = sieve(input, text)
    yaml = yaml_body(blocks[[1]]$source)$yaml
  } else {
    blocks = crack(input, text)
    yaml = yaml_body(text)$yaml
  }
  full = is_output_full(output)
  format = detect_format(output, yaml)
  output = auto_output(input, output, format)
  if (!is.null(output_base <- output_path(input, output)))
    output_base = sans_ext(output_base)

  opts = reactor()
  # clean up the figure folder on exit if it's empty
  on.exit(del_empty_dir({
    if (dir.exists(fig.dir <- opts$fig.path)) fig.dir else dirname(fig.dir)
  }), add = TRUE)

  # restore and clean up some objects on exit
  opts2 = as.list(opts); on.exit(reactor(opts2), add = TRUE)
  oenv = as.list(.env); on.exit(reset_env(oenv, .env), add = TRUE)
  # use default fig.path/cache.path instead of inheriting (#127)
  nested = isTRUE(.env$in_fuse); .env$in_fuse = TRUE
  if (nested) reactor(fig.path = NULL, cache.path = NULL)

  # set working directory if unset
  if (is_file(input) && is.null(opts$wd)) opts$wd = dirname(normalizePath(input))

  # store output dir so we can calculate relative paths for plot files later
  .env$wd_out = normalize_path(
    if (is.null(output_base)) {
      if (is.character(.env$wd_out)) .env$wd_out else '.'
    } else dirname(output_base)
  )
  # store the environment and output format
  .env$global = envir; .env$format = format

  # set default device to 'cairo_pdf' for LaTeX output, and 'png' for other formats
  if (is.null(opts$dev)) {
    opts$dev = if (format == 'latex') 'cairo_pdf' else 'png'
  }
  # set default figure and cache paths
  set_path = function(name) {
    # fig.path = output__files/ if `output` is a path, otherwise use
    # litedown__files/ (we don't use _files because of rstudio/rmarkdown#2550)
    if (is.null(p <- opts[[name]])) p = aux_path(output_base %||% 'litedown', name)
    slash = endsWith(p, '/')
    # make sure path is absolute so it will be immune to setwd() (in code chunks)
    if (is_rel_path(p)) {
      p = file.path(getwd(), p)
      # preserve trailing slash because file.path() removes it on Windows
      if (slash) p = sub('/*$', '/', p)
    }
    opts[[name]] = p
  }
  set_path('fig.path'); set_path('cache.path')

  .env$input = input
  if (is_file(input)) .env$full_input = normalizePath(input)
  res = .fuse(blocks, input, quiet)

  # save timing data if necessary
  timing_data()

  # keep the markdown output if keep_md = TRUE is set in YAML output format
  if (is_output_file(output) && isTRUE(yaml_field(yaml, format, 'keep_md'))) {
    write_utf8(res, with_ext(output, '.md'))
  }
  fuse_output(input, output, res, full)
}

# default fig/cache path
aux_path = function(base = sans_ext(file), type, file) {
  paste0(base, c(fig.path = '__files/', cache.path = '__cache/')[type])
}
fig_path = function(path) aux_path(, 'fig.path', path)

# if output = '.md' or 'markdown', no need for further mark() conversion
fuse_output = function(input, output, res, full = NULL) {
  if (is.character(output) && grepl('[.]md$|^markdown$', output)) {
    if (is_output_file(output)) {
      write_utf8(res, output)
    } else raw_string(res)
  } else {
    if (isTRUE(full)) attr(output, 'full') = TRUE
    mark(input, output, res)
  }
}

#' @rdname mark
#' @export
fiss = function(input, output = '.R', text = NULL) {
  text = read_input(input, text); input = attr(text, 'input')
  output = auto_output(input, output, NULL)
  blocks = crack(input, text)
  # TODO: what should we do for non-R code? also consider eval=FALSE and error=TRUE
  res = uapply(blocks, function(b) {
    if (b$type == 'code_chunk' && !isFALSE(b$options$purl) && b$options$engine == 'r')
      c(b$source, '')
  })
  if (is_output_file(output)) write_utf8(res, output) else raw_string(res)
}

.fuse = function(blocks, input, quiet) {
  n = length(blocks)
  nms = vapply(blocks, function(x) x$options[['label']] %||% '', character(1))
  names(blocks) = nms

  # a simple progress indicator: we need to know how many spaces we need to wipe
  # out previous progress text of the form:
  # xxx% | input_file#line1-line2 [label]
  # ...4..3          1     1     .2     1
  p_len = 4 + 3 + sum(nchar(input)) + 1 +
    (sum(nchar(sprintf('%d', blocks[[n]]$lines))) + 1) + 2 + max(nchar(nms)) + 1
  p_clr = paste0('\r', strrep(' ', p_len), '\r')  # a string to clear the progress
  p_out = getOption('litedown.progress.output', stderr())
  p_yes = FALSE; t0 = Sys.time(); td = getOption('litedown.progress.delay', 2)
  p_bar = function(x) {
    if (!quiet && (p_yes || Sys.time() - t0 > td)) {
      cat(x, sep = '', file = p_out)
      p_yes <<- TRUE
    }
  }
  # if error occurs, print error location with a clickable file link
  k = 0  # when exiting, k should be n + 1
  on_error = function() {
    if (k > n) return()  # blocks have been successfully fused
    p_bar(p_clr)
    ansi_link(input)
    message('Quitting from ', get_loc(nms[k]))
  }
  # suppress tidyverse progress bars and use cairo for bitmap devices (for
  # smaller plot files and possible parallel execution)
  opt = options(rstudio.notebook.executing = TRUE, bitmapType = bitmap_type())
  on.exit({ options(opt); on_error() }, add = TRUE)

  # the chunk option `order` determines the execution order of chunks
  o = block_order(blocks, n)
  res = character(n)
  for (i in seq_len(n)) {
    k = o[i]; b = blocks[[k]]; save_pos(b$lines)
    p_bar(c(as.character(round((i - 1)/n * 100)), '%', ' | ', get_loc(nms[k])))
    # record timing if requested
    if (!isFALSE(time <- timing_path())) t1 = Sys.time()
    res[k] = if (b$type == 'code_chunk') {
      one_string(fuse_code(b, blocks))
    } else {
      one_string(fuse_text(b), '')
    }
    if (!isFALSE(time)) record_time(Sys.time() - t1, b$lines, nms[k])
    p_bar(p_clr)
  }
  k = n + 1
  res
}

# add ANSI link on input path if supported
ansi_link = function(x) {
  if (length(x) && isTRUE(as.logical(Sys.getenv('RSTUDIO_CLI_HYPERLINKS'))))
    .env$input2 = sprintf(
      '\033]8;%s;file://%s\a%s\033]8;;\a', link_pos(), normalize_path(x), x
    )
}

# use options(bitmapType = 'cairo') for bitmap devices on macOS if possible
bitmap_type = function() {
  # xquartz has to be installed for cairo to work (I don't know why)
  if (xfun::is_macos() && capabilities('cairo') && Sys.which('xquartz') != '')
    'cairo' else .Options$bitmapType
}

time_frame = function(s = NA_character_, l = integer(), lab = NA_character_, t = NA_real_) {
  data.frame(source = s, line1 = l[1], line2 = l[2], label = lab, time = t)
}

record_time = function(x, lines, label) {
  x = as.numeric(x)
  gc(FALSE)
  d = time_frame(.env$input %||% '#text', lines, label, x)
  .env$time = append(.env$time, list(d))
}

#' Get the timing data of code chunks and text blocks in a document
#'
#' Timing can be enabled via the chunk option `time = TRUE` (e.g., set
#' [litedown::reactor]`(time = TRUE)` in the first code chunk). After it is
#' enabled, the execution time for code chunks and text blocks will be recorded.
#' This function can be called to retrieve the timing data later in the document
#' (e.g., in the last code chunk).
#' @param threshold A number (time in seconds) to subset data with. Only rows
#'   with time above this threshold are returned.
#' @param sort Whether to sort the data by time in the decreasing order.
#' @param total Whether to append the total time to the data.
#' @note By default, the data will be cleared after each call of [fuse()] and
#'   will not be available outside [fuse()]. To store the data persistently, you
#'   can set the `time` option to a file path. This is necessary if you want to
#'   get the timing data for multiple input documents (such as all chapters of a
#'   book). Each document needs to point the `time` option to the same path.
#'   When you do not need timing any more, you will need to delete this file by
#'   yourself.
#' @return A data frame containing input file paths, line numbers, chunk labels,
#'   and time. If no timing data is available, `NULL` is returned.
#' @export
timing_data = function(threshold = 0, sort = TRUE, total = TRUE) {
  d = .env$time
  if (!is.null(d)) d = do.call(rbind, d)

  if (is.character(path <- timing_path())) {
    if (file_exists(path)) {
      d2 = readRDS(path)
      if (length(input <- .env$input)) d2 = subset(d2, source != input)
      d = rbind(d2, d)
    }
    saveRDS(d, path)
  }
  if (is.null(d)) return(invisible(d))

  total = if (total) sum(d$time)
  # add edit links in the roam() mode
  if (is_roaming() && !all(i <- d$source == '#text')) {
    d$source = ifelse(i, '', sprintf(
      '%s [&#9998;](?path=%s&line=%d)', d$source, URLencode(d$source, TRUE), d$line1
    ))
  }
  d = d[d$time > threshold, ]
  if (sort) d = d[order(d$time, decreasing = TRUE), ]
  if (!is.null(total)) d = rbind(d, time_frame('Total', t = total))
  d
}

timing_path = function() {
  p = xfun::env_option('litedown.time')
  if (is.character(p) && tolower(p) %in% c('true', 'false')) p = as.logical(p)
  if (is.logical(p) || (is.character(p) && p != '')) p else reactor('time')
}

# an internal function for RStudio IDE to recognize the custom knit function
# when users hit the Knit button
knit = function(input, ...) fuse(input, envir = parent.frame())

fuse_code = function(x, blocks) {
  # merge local chunk options into global options
  old = reactor(x$options); on.exit(reactor(old), add = TRUE)
  opts = reactor()

  # delayed assignment to evaluate a chunk option only when it is actually used
  lapply(setdiff(names(opts), 'order'), function(i) {
    # skip the `order` option since it needs to be eval()ed in block_order()
    if (i != 'order' && is_lang(o <- opts[[i]]))
      delayedAssign(i, eval(o, fuse_env()), environment(), opts)
  })
  # set the working directory before evaluating anything else
  change_wd(opts$wd)

  # fuse child documents (empty the `child` option to avoid infinite recursion)
  if (length(opts$child)) return(uapply(reactor(child = NULL)$child, function(.) {
    child = .env$child; .env$child = TRUE; on.exit(.env$child <- child)
    fuse(., output = 'markdown', envir = fuse_env(), quiet = TRUE)
  }))

  lab = opts$label; lang = opts$engine

  # the source code could be from chunk options 'file' or 'code'
  test_source = function(name) {
    if (length(opts[[name]]) == 0) return(FALSE)
    if (cond <- length(x$source) > 0) warning(
      "The chunk option '", name, "' is ignored for the non-empty chunk:\n\n",
      one_string(x$source)
    )
    !cond
  }
  if (test_source('file')) {
    x$source = read_all(opts$file)
  } else if (test_source('code')) {
    x$source = opts$code
  } else {
    # use code from other chunks of the same label
    labs = if (length(x$source) == 0) which(names(blocks) == lab)
    if (length(labs)) x$source = uapply(blocks[labs], `[[`, 'source')
  }

  # resolve inline chunk references and do code interpolation
  x$source = fill_source(x$source, opts$fill, blocks)

  res = if (isFALSE(opts$eval)) list(new_source(x$source)) else {
    if (is.function(eng <- engines(lang))) eng(x) else list(
      new_source(x$source),
      new_warning(sprintf("The engine '%s' is not supported.", lang))
    )
  }

  if (!opts$include) return('')

  # if the engine result contains new chunk options, apply the new options, and
  # make sure they will be properly restored via `old` on exit
  if (length(opts_new <- attr(res, 'options'))) {
    old2 = reactor(opts_new)
    for (i in setdiff(names(old2), names(old))) old[i] = old2[i]
  }

  # decide the number of backticks to wrap up output
  fence = xfun::make_fence(c(unlist(res), x$fences))

  # deal with figure alt text, captions, and environment
  env = opts$fig.env; alt = opts$fig.alt; cap = opts$fig.cap
  att = if (is.null(att <- opts$attr.plot)) '' else paste0('{', att, '}')
  if (is.null(alt)) alt = cap
  p1 = Filter(function(x) !is_plot(x), res)
  p2 = Filter(is_plot, res)
  p3 = unlist(p2)  # vector of plot paths
  # get the relative path of the plot directory
  fig.dir = if (length(p3)) tryCatch(
    sub('^[.]/', '', paste0(dirname(relative_path(p3[1], .env$wd_out)), '/')),
    error = function(e) NULL
  )

  # record plot paths so they can be cleaned up if option embed_cleanup = true;
  # however, when cache = true, we shouldn't clean up plots since they won't be
  # regenerated next time (then they won't be found)
  if (!isTRUE(opts$cache)) .env$plot_files = c(.env$plot_files, p3)
  # recycle alt and attributes for all plots
  pn = length(p3)
  if (pn && is.null(alt)) {
    # reminder about missing alt text if this option is set to TRUE
    if (getOption('litedown.fig.alt', FALSE)) message(
      "\nPlease provide a 'fig.alt' option to the code chunk at ", get_loc(lab)
    )
    alt = ''
  }
  alt = rep(alt, length.out = pn)
  att = rep(att, length.out = pn)
  # if figure caption is provided, merge all plots in one env
  if (pn && length(cap)) res = c(xfun:::merge_record(p1), list(new_plot(p3)))
  i = 0  # plot counter

  res_show = opts$results  # normalize the 'results' option
  if (is.logical(res_show)) res_show = if (res_show) 'markup' else 'hide'

  l1 = x$code_start  # starting line number of the whole code chunk
  # filter the results if desired
  if (!is.null(opts$filter)) res = match.fun(opts$filter)(res)
  # generate markdown output
  out = lapply(res, function(x) {
    type = grep_sub('^record_', '', class(x))[1]
    if (is.na(type)) type = 'output'
    if (type == 'source') {
      if (!opts$echo) return()
      l2 = attr(x, 'lines')[1]  # starting line number of a code block
      x = one_string(x)
      if (opts$strip.white) x = trim_blank(x)
    }
    asis = if (type %in% c('output', 'asis')) {
      if (res_show == 'hide') return()
      any(c(res_show, type) == 'asis')
    } else FALSE
    if (type == 'warning' && !isTRUE(opts$warning)) return()
    if (type == 'message' && !isTRUE(opts$message)) return()
    if (type == 'plot') {
      n = length(x); i2 = i + seq_len(n); i <<- i + n
      img = sprintf(
        '![%s](<%s>)%s', alt[i2],
        if (is.null(fig.dir)) x else gsub('^.*/', fig.dir, x), att[i2]
      )
      add_cap(img, cap, lab, opts$cap.pos, env)
    } else {
      a = opts[[paste0('attr.', type)]]
      if (type == 'source') {
        # use engine name as class name when `a` is not provided
        if (is.null(a)) a = paste0('.',  lang)
        # add line numbers
        if (is_roaming()) a = c(a, lineno_attr(NA, l1 + l2 - 1))
      } else {
        if (type == 'message') x = sub('\n$', '', x)
        if (!asis) {
          opt2 = attr(x, 'opts')
          cmt = opts$comment %||% opt2$comment %||% '#> '
          if (cmt != '') {
            x = split_lines(x)
            x = paste0(cmt, x)  # comment out text output
          }
          if (is.null(a)) if (!is.null(a <- opt2$attr)) a = c(a, '.plain')
        }
      }
      if (asis) {
        if (is.null(a)) x else fenced_div(x, a)
      } else fenced_block(x, a, fence)
    }
  })
  out = dropNULL(out)

  # collapse a code block without attributes into previous adjacent code block
  # (also try to collapse for results = 'hide')
  collapse = isTRUE(opts$collapse) || res_show == 'hide'
  if (collapse && (n <- length(out)) > 1) {
    i1 = 1; k = NULL  # indices of elements to be removed from `out`
    for (i in 2:n) {
      if (i - i1 > 1) i1 = i - 1  # make sure blocks are adjacent
      o1 = out[[i1]]; n1 = length(o1); e1 = o1[n1]
      # previous block should have a closing fence ```+
      if (n1 < 3 || !grepl('^```+$', e1)) next
      o2 = out[[i]]
      if (continue_block(o1[2], e1, head(o2, 2))) {
        out[[i]] = c(o1[-n1], o2[-(1:2)])
        k = c(k, i1)  # merge previous block into current and remove previous
      }
    }
    if (length(k)) out = out[-k]
  }

  a = opts$attr.chunk
  if (length(x$fences) == 2) {
    # add a class name to the chunk output so we can style it differently
    a = c(a, '.fenced-chunk')
    out = add_fences(out, x, fence)
  }
  out = unlist(out)
  if (!is.null(a)) out = fenced_div(out, a)
  # if first line of chunk output is empty, remove it (the chunk should have had
  # an empty line before it in the source)
  if (length(out) && out[1] == '') out = out[-1]
  # add prefix (possibly indentation and > quote chars)
  if (!is.null(x$prefix)) out = gsub('^|(?<=\n)', x$prefix, out, perl = TRUE)
  out
}

# resolve `<label>` to chunk source, and evaluate `{code}` to string (to
# interpolate original source)
fill_source = function(x, fill, blocks) {
  if (isFALSE(fill)) return(x)
  x = fill_label(x, blocks)
  fill_code(x, fill)
}

fill_label = function(x, blocks) {
  r = '`<(.+?)>`'
  for (i in grep(r, x)) {
    ind = sub('^(\\s*).*', '\\1', x[i])  # possible indent
    x[i] = match_replace(x[i], r, function(z) {
      labs = sub(r, '\\1', z)  # chunk label
      j = labs %in% names(blocks)
      if (any(j)) z[j] = uapply(blocks[labs[j]], function(b) {
        s = b$source
        if ((n <- length(s)) > 0) {
          paste0(c('', rep(ind, n - 1)), s, collapse = '\n')
        } else ''
      })
      z
    })
  }
  # recursion for possible more `<label>` markers
  if (is.null(i)) x else fill_label(split_lines(x), blocks)
}

fill_code = function(x, fill) {
  r = '`\\{(.+?)}`'
  match_replace(x, r, function(z) {
    code = sub(r, '\\1', z)
    uapply(code, function(s) {
      v = eval_code(s)
      if (is.function(fill)) v = fill(v)
      one_string(v)
    })
  })
}

# temporarily change the working directory inside a function call
change_wd = function(dir) {
  if (is.character(dir)) {
    owd = setwd(dir); exit_call(function() setwd(owd))
  }
}

# add caption to an element (e.g., figure/table)
add_cap = function(x, cap, lab, pos, env, type = 'fig') {
  if (length(cap) == 0 || length(lab) == 0) return(x)
  cap = fenced_div(add_ref(lab, type, cap), '.caption')
  pos = pos %||% 'bottom'
  x = if (pos == 'top') c(cap, '', x) else c(x, '', cap)
  fenced_div(x, c(sub('^[.]?', '.', env), sprintf('#%s:%s', type, lab)))
}

# if original chunk header contains multiple curly braces (e.g., ```{{lang}}),
# include chunk fences in the output (and also pipe comments if exist)
add_fences = function(out, x, fence) {
  # remove last line of pipe comments if empty
  if ((n <- length(o <- x$comments)) > 1 && o[n] == '') o = o[-n]
  fences = list(c(x$fences[1], o), x$fences[2])
  append(lapply(fences, fenced_block, c('.md', '.code-fence'), fence), out, 1)
}

# attributes for code blocks with line numbers
lineno_attr = function(lang = NA, start = 1, auto = TRUE) c(
  if (!is.na(lang)) paste0('.', sub('^[rq]md$', 'md', tolower(lang))),
  '.line-numbers', if (auto) '.auto-numbers', sprintf('data-start="%d"', start)
)

# two blocks are continuous if first 2 elements of next block are '' and
# previous block's closing or opening fence (after removing data-start attribute)
continue_block = function(e1_open, e1_end, e2) {
  if (length(e2) != 2 || e2[1] != '') return(FALSE)
  if ((e2_open <- e2[2]) == e1_end) return(TRUE)
  e3 = sub(' data-start="[0-9]+"', '', c(e1_open, e2_open))
  if (e3[1] == e3[2]) return(TRUE)
  # remove attributes of message blocks and see if they can be collapsed
  sub(' \\{[.]plain [.](message|warning|error)}', '', e2_open) == e1_end
}

new_source = function(x) {
  len = length(x)
  structure(new_record(x, 'source'), lines = if (len) c(1L, len) else c(0L, 0L))
}
new_output = function(x) new_record(x, 'output')
new_warning = function(x) new_record(x, 'warning')
new_plot = function(x) new_record(x, 'plot')
new_asis = function(x, raw = FALSE) {
  res = new_record(x, 'asis')
  if (raw) raw_string(res) else res
}

# interleave text and plot output, e.g., t t t p p p -> t p t p t p, or t t t t
# p p -> t t p t t p
interleave = function(res) {
  p = match(c('record_output', 'record_plot'), sapply(res, function(x) class(x)[1]))
  if (any(is.na(p))) {
    warning('Both text and plot output must be present in results to be interleaved.')
    return(res)
  }
  x1 = res[[p[1]]]; n1 = length(x1)
  x2 = res[[p[2]]]; n2 = length(x2)
  n = n1 / n2
  if (n < 1) return(res)
  if (n1 %% n2 != 0) stop(
    'Length of text output (', n1, ') is not multiple of the number of plots (', n2, ').'
  )
  # interleave text and plot output: one (chunk of) text followed by one plot
  mix = unlist(lapply(seq_len(n2), function(i) {
    list(new_output(x1[(i - 1) * n + 1:n]), new_plot(x2[i]))
  }), recursive = FALSE)
  append(res[-p], mix, p[1] - 1)
}

#' Mark a character vector as raw output
#'
#' This function should be called inside a code chunk, and its effect is the
#' same as the chunk option `results = "asis"`. The input character vector will
#' be written verbatim to the output (and interpreted as Markdown).
#' @param x A character vector (each element will be treated as a line).
#' @param format An output format name, e.g., `html` or `latex`. If provided,
#'   `x` will be wrapped in a fenced code block, e.g., ```` ```{=html}````.
#' @return A character vector with a special class to indicate that it should be
#'   treated as raw output.
#' @export
#' @examples
#' litedown::raw_text(c('**This**', '_is_', '[Markdown](#).'))
#' litedown::raw_text('<b>Bold</b>', 'html')
#' litedown::raw_text('\\textbf{Bold}', 'latex')
raw_text = function(x, format = NULL) {
  if (length(fmt <- sprintf('=%s', format)) == 1) x = fenced_block(x, fmt)
  new_asis(x, TRUE)
}

is_plot = function(x) inherits(x, 'record_plot')

fuse_text = function(x) {
  if (is.character(src <- x$source)) return(one_string(src))
  res = lapply(src, function(s) {
    if (is.character(s)) s else exec_inline(s)
  })
  unlist(res)
}

exec_inline = function(x) {
  save_pos(x$pos)
  o = reactor(x$options); on.exit(reactor(o), add = TRUE)
  opts = reactor()
  if (isFALSE(opts$eval)) return('')
  change_wd(opts$wd)
  lang = x$options$engine
  if (is.function(eng <- engines(lang))) {
    one_string(eng(x, inline = TRUE))
  } else {
    warning("The inline engine '", lang, "' is not supported.")
    sprintf('`{%s} %s`', lang, x$source)
  }
}

fmt_inline = function(x, ...) {
  if (is.numeric(x) && length(x) == 1 && !inherits(x, 'AsIs'))
    sci_num(x, ...) else as.character(x)
}

# change scientific notation to LaTeX math
sci_num = function(x, math = NULL) {
  opts = reactor()
  s = x != 0 && abs(log10(abs(x))) >= opts$power
  x = format(signif(x, opts$signif), scientific = s)
  r = '^(-)?([0-9.]+)e([-+])0*([0-9]+)$'
  s = grepl(r, x)
  if (s) {
    n = match_one(x, r)[[1]]
    x = sprintf(
      '%s%s10^{%s%s}', n[2], if (n[3] == '1') '' else paste(n[3], '\\times '),
      if (n[4] == '+') '' else n[4], n[5]
    )
  }
  if (is.na(d <- opts$dollar)) d = s && !isTRUE(math)
  if (d) paste0('$', x, '$') else x
}

# similar to the base R options() interface but for litedown options / engines /
# ..., and is based on environments, which are *mutable*
new_opts = function() {
  # global chunk options
  .opts = structure(new_env(), class = c('litedown_env', 'environment'))

  opt_get = function(x, drop = length(x) == 1) {
    vs = mget(x, .opts, ifnotfound = list(NULL))
    if (drop) vs[[1]] else vs
  }
  # setter: fun(opt = val); getter: fun('opt')
  function(...) {
    v = list(...)
    n = length(v)
    if (n == 0) return(.opts)
    if (is.null(nms <- names(v))) {
      if (all(vapply(v, is.character, TRUE))) return(opt_get(unlist(v)))
      if (n > 1) warning(
        'When not all unnamed arguments are character, only the first argument is used (',
        n, ' were received).'
      )
      if (is.null(nms <- names(v <- v[[1]])) || any(nms == '')) stop(
        'When the first unnamed argument is not character, it must be a named list.'
      )
    }
    if (any(nms == '')) stop('All arguments must be either named or unnamed.')
    old = opt_get(nms, drop = FALSE)
    for (i in nms) assign(i, v[[i]], envir = .opts)
    invisible(old)
  }
}

#' Get and set chunk options
#'
#' Chunk options are stored in an environment returned by `reactor()`. Option
#' values can be queried by passing their names to `reactor()`, and set by
#' passing named values.
#' @param ... Named values (for setting) or unnamed values (for getting).
#' @return With no arguments, `reactor()` returns an environment that stores the
#'   options, which can also be used to get or set options. For example, with
#'   `opts = reactor()`, `opts$name` returns an option value, and `opts$name =
#'   value` sets an option to a value.
#'
#'   With named arguments, `reactor()` sets options and returns a list of their
#'   old values (e.g., `reactor(echo = FALSE, fig.width = 8)`). The returned
#'   list can be passed to `reactor()` later to restore the options.
#'
#'   With unnamed arguments, `reactor()` returns option values after received
#'   option names as input. If one name is received, its value is returned
#'   (e.g., `reactor('echo')`). If multiple names are received, a named list of
#'   values is returned (e.g., `reactor(c('echo', 'fig.width'))`). A special
#'   case is that if only one unnamed argument is received and it takes a list
#'   of named values, the list will be used to set options, e.g.,
#'   `reactor(list(echo = FALSE, fig.width = 8))`, which is equivalent to
#'   `reactor(echo = FALSE, fig.width = 8)`.
#' @export
#' @examples
#' # get options
#' litedown::reactor('echo')
#' litedown::reactor(c('echo', 'fig.width'))
#'
#' # set options
#' old = litedown::reactor(echo = FALSE, fig.width = 8)
#' litedown::reactor(c('echo', 'fig.width'))
#' litedown::reactor(old)  # restore options
#'
#' # use the environment directly
#' opts = litedown::reactor()
#' opts$echo
#' mget(c('echo', 'fig.width'), opts)
#' ls(opts)  # built-in options
reactor = new_opts()
reactor(
  eval = TRUE, echo = TRUE, fill = TRUE, results = TRUE, comment = NULL,
  warning = TRUE, message = TRUE, error = NA, include = TRUE,
  strip.white = TRUE, collapse = FALSE, order = 0,
  attr.source = NULL, attr.output = NULL, attr.plot = NULL, attr.chunk = NULL,
  attr.message = '.plain .message', attr.warning = '.plain .warning', attr.error = '.plain .error',
  cache = FALSE, cache.path = NULL,
  dev = NULL, dev.args = NULL, fig.path = NULL, fig.ext = NULL, fig.keep = TRUE,
  fig.width = 7, fig.height = 7, fig.dim = NULL, fig.cap = NULL, fig.alt = NULL, fig.env = '.figure',
  tab.cap = NULL, tab.env = '.table', cap.pos = NULL,
  print = NULL, print.args = NULL, time = FALSE,
  code = NULL, file = NULL, child = NULL, purl = TRUE,
  wd = NULL,
  signif = 3, power = 6, dollar = NA
)

eval_code = function(code, error = NA) {
  expr = parse_only(code)
  if (is.na(error)) eval(expr, fuse_env()) else tryCatch(
    eval(expr, fuse_env()), error = function(e) if (error) e$message else ''
  )
}

# the R engine
eng_r = function(x, inline = FALSE, ...) {
  opts = reactor()
  if (inline) {
    res = eval_code(x$source, opts$error)
    return(fmt_inline(res, x$math))
  }
  args = reactor(
    'fig.path', 'fig.ext', 'fig.keep', 'dev', 'dev.args', 'message', 'warning', 'error',
    'cache', 'print', 'print.args', 'verbose'
  )
  if (is.character(args$fig.path)) args$fig.path = paste0(args$fig.path, opts$label)
  size = list(width = opts$fig.width, height = opts$fig.height)
  # if fig.dim is provided, override fig.width and fig.height
  if (length(dm <- opts$fig.dim) == 2) size[] = as.list(dm)
  # map chunk options to record() argument names
  names(args)[1:3] = c('dev.path', 'dev.ext', 'dev.keep')
  args = dropNULL(args)
  args$dev.args = merge_list(size, opts$dev.args)
  args$cache = list(
    path = if (args$cache) opts$cache.path, vars = opts$cache.vars,
    hash = opts$cache.hash, extra = opts$cache.extra, keep = opts$cache.keep,
    id = opts$label, rw = opts$cache.rw
  )
  do.call(xfun::record, c(list(code = x$source, envir = fuse_env()), args))
}

# the Markdown engine: echo Markdown source verbatim, and also output it as-is
eng_md = function(x, inline = FALSE, ...) {
  s = x$source
  if (inline) {
    f = unlist(match_all(s, '`+'))  # how many backticks to quote the text?
    f = if (length(f)) paste0('`', max(f)) else '`'
    one_string(c(f, s, f, s), ' ')
  } else list(new_source(s), new_asis(s))
}

# the Mermaid engine: put code in ```mermaid and add caption if necessary
eng_mermaid = function(x, inline = FALSE, ...) {
  code = fenced_block(src <- x$source, 'mermaid')
  opts = reactor()
  code = add_cap(code, opts$fig.cap, opts$label, opts$cap.pos, opts$fig.env)
  if (inline) one_string(c(code, '')) else {
    list(new_source(src), new_asis(code))
  }
}

# embed a file verbatim
eng_embed = function(x, ...) {
  s = x$source; opts = reactor()
  # if chunk option `file` is empty, use source code as the list of files
  if (is.null(f <- opts$file)) {
    f = gsub('^["\']|["\']$', '', s)  # in case paths are quoted
    if (length(f) == 0) return()
    s = read_all(f)
  }
  opts_new = list(comment = '')  # don't comment out file content
  # use the filename extension as the default language name
  if (is.null(opts$attr.output) && nchar(lang <- file_ext(f[1])) > 1) {
    lang = sub('^R', '', lang)  # Rmd -> md, Rhtml -> html, etc.
    if (lang == 'nw') lang = 'tex'
    opts_new$attr.output = paste0('.', lang)
  }
  structure(list(new_output(s)), options = opts_new)
}

eng_html = function(x, inline = FALSE, html = NULL) {
  out = fenced_block(html, '=html')
  if (inline) one_string(c(out, '')) else list(new_source(x$source), new_asis(out))
}

eng_css = function(x, inline = FALSE, ...) {
  if (is.character(h <- reactor('href'))) {
    res = sprintf('<link rel="stylesheet" href="%s">', h)
    if (inline) one_string(res) else list(new_asis(res))
  } else {
    eng_html(x, inline, c('<style type="text/css">', x$source, '</style>'))
  }
}

eng_js = function(x, inline = FALSE, ...) {
  opts = reactor()
  a = list(type = opts$type, src = opts$src)
  for (i in c('type', 'src')) a[[i]] = a[[i]]  # remove NULL
  if (is.character(s <- a$src)) {
    if (!isFALSE(opts$defer)) a['defer'] = list(NULL)
    res = html_tag('script', NULL, a)
    if (inline) one_string(res) else list(new_asis(res))
  } else {
    if (identical(a$type, 'module')) a$defer = NULL
    eng_html(x, inline, html_tag('script', html_value(x$source), a))
  }
}

#' Language engines
#'
#' Get or set language engines for evaluating code chunks and inline code.
#'
#' An engine function should have three arguments:
#'
#' - `x`: An element in the [crack()] list (a code chunk or a text block).
#'
#' - `inline`: It indicates if `x` is from a code chunk or inline code.
#'
#' - `...`: Currently unused but recommended for future compatibility (more
#'   arguments might be passed to the function).
#'
#' The function should return a character value.
#' @inheritParams reactor
#' @return The usage is similar to [reactor()]: `engines('LANG')` returns an
#'   engine function for the language `LANG`, and `engines(LANG = function(x,
#'   inline = FALSE, ...) {})` sets the engine for a language.
#' @export
#' @examples
#' litedown::engines()  # built-in engines
engines = new_opts()
engines(
  r = eng_r, md = eng_md, mermaid = eng_mermaid, embed = eng_embed,
  css = eng_css, js = eng_js
)

#' @export
print.litedown_env = function(x, ...) {
  str(as.list(x, all.names = TRUE, sorted = TRUE), ...)
  invisible(x)
}


================================================
FILE: R/mark.R
================================================
#' Render Markdown, R Markdown, and R scripts
#'
#' The function `mark()` renders Markdown to an output format via the
#' \pkg{commonmark} package.
#' @param input A character vector to provide the input file path or text. If
#'   not provided, the `text` argument must be provided instead. The `input`
#'   vector will be treated as a file path if it is a single string, and points
#'   to an existing file or has a filename extension. In other cases, the vector
#'   will be treated as the `text` argument input. To avoid ambiguity, if a
#'   string should be treated as `text` input when it happens to be an existing
#'   file path or has an extension, wrap it in [I()], or simply use the `text`
#'   argument instead.
#' @param output An output file path or a filename extension (e.g., `.html`,
#'   `.tex`, `.xml`, `.man`, `.markdown`, or `.txt`). In the latter case, the
#'   output file path will use the extension on the same base filename as the
#'   input file if the `input` is a file. If `output` is not character (e.g.,
#'   `NA`), the results will be returned as a character vector instead of being
#'   written to a file. If `output` is `NULL` or an extension, and the input is
#'   a file path, the output file path will have the same base name as the input
#'   file, with an extension corresponding to the output format. The output
#'   format is retrieved from the first value in the `output` field of the YAML
#'   metadata of the `input` (e.g., `html` will generate HTML output). The
#'   `output` argument can also take an output format name (possible values are
#'   `html`, `latex`, `xml`, `man`, `commonmark`, and `text`). If no output
#'   format is detected or provided, the default is HTML.
#' @param text A character vector as the text input. By default, it is read from
#'   the `input` file if provided.
#' @param options Options to be passed to the renderer. See [markdown_options()]
#'   for details. This argument can take either a character vector of the form
#'   `"+option1 option2-option3"` (use `+` or a space to enable an option, and
#'   `-` to disable an option), or a list of the form `list(option1 = value1,
#'   option2 = value2, ...)`. A string `"+option1"` is equivalent to
#'   `list(option1 = TRUE)`, and `"-option2"` means `list(option2 = FALSE)`.
#'   Options that do not take logical values must be specified via a list, e.g.,
#'   `list(width = 30)`.
#' @param meta A named list of metadata. Elements in the metadata will be used
#'   to fill out the template by their names and values, e.g., `list(title =
#'   ...)` will replace the `$title$` variable in the template. See the Section
#'   \dQuote{YAML metadata} [in the
#'   documentation](https://yihui.org/litedown/#sec:yaml-metadata) for supported
#'   variables.
#' @return The output file path if output is written to a file, otherwise a
#'   character vector of the rendered output (wrapped in [xfun::raw_string()]
#'   for clearer printing).
#' @seealso The spec of GitHub Flavored Markdown:
#'   <https://github.github.com/gfm/>
#' @import utils
#' @export
#' @examples
#'
#' mark(c('Hello _World_!', '', 'Welcome to **litedown**.'))
#' # if input appears to be a file path but should be treated as text, use I()
#' mark(I('This is *not* a file.md'))
#' # that's equivalent to
#' mark(text = 'This is *not* a file.md')
#'
#' # output to a file
#' (mark('_Hello_, **World**!', output = tempfile()))
#'
#' # convert to other formats
#' mark('Hello _World_!', '.tex')
#' mark('Hello _**`World`**_!', 'xml')
#' mark('Hello _**`World`**_!', 'text')
mark = function(input, output = NULL, text = NULL, options = NULL, meta = list()) {
  text = read_input(input, text); input = attr(text, 'input')
  part = yaml_body(text)
  yaml = part$yaml; yaml2 = yaml_text(part, text)  # unparsed YAML
  text = part$body

  full = is_output_full(output)
  format = detect_format(output, yaml)
  output = auto_output(input, output, format)
  out_dir = dirname(output_path(input, output) %||% '.')

  # title/author/date can be provided as top-level YAML options
  meta = merge_list(
    get_option('meta', format),
    yaml[intersect(names(yaml), top_meta)],
    yaml_field(yaml, format),
    list(generator = I(paste('litedown', packageVersion('litedown')))),
    meta
  )
  meta = normalize_meta(meta)

  render_fun = tryCatch(
    getFromNamespace(paste0('markdown_', tolower(format)), 'commonmark'),
    error = function(e) {
      stop("Output format '", format, "' is not supported in commonmark.")
    }
  )

  options = merge_list(yaml_field(yaml, format, 'options'), option2list(options))
  options = normalize_options(options, format)
  options$extensions = intersect(
    names(Filter(isTRUE, options)), commonmark::list_extensions()
  )

  # build PDF for LaTeX output when the output file is .pdf or latex_engine is specified
  is_pdf = is_output_file(output) && format == 'latex' &&
    (is.character(latex_engine <- yaml_field(yaml, format, 'latex_engine')) ||
       file_ext(output) == 'pdf')

  # whether to write YAML metadata to output
  keep_yaml = isTRUE(options[['keep_yaml']])

  # if keep_yaml or format is not html/latex, don't use template; otherwise
  # check the `template` value in litedown::(html|latex)_format in YAML
  template = if (keep_yaml || !format %in% c('html', 'latex')) FALSE else
    yaml_field(yaml, format, 'template')
  # if not set there, check global option; if not set, disable template if no
  # YAML was provided (i.e., generate a fragment)
  if (is.null(template))
    template = get_option('template', format, full || 'yaml' %in% names(part) || is_pdf)
  # template = FALSE means no template; other values mean the default template
  if (!is.character(template)) template = if (!isFALSE(template))
    pkg_file('resources', sprintf('litedown.%s', format))

  render_args = options[intersect(names(formals(render_fun)), names(options))]
  render = function(x, clean = FALSE) {
    if (length(x) == 0) return(x)
    res = do.call(render_fun, c(list(text = x), render_args))
    if (clean) res = sans_p(res)
    I(res)
  }

  if (isTRUE(options[['smartypants']])) text = smartypants(text)

  # test if a feature needs to be enabled
  test_feature = function(name, pattern) {
    isTRUE(options[[name]]) && format %in% c('html', 'latex') &&
      length(grep(pattern, text, perl = TRUE))
  }

  # protect $ $ and $$ $$ math expressions for html/latex output
  if (has_math <- test_feature('latex_math', '[$]')) {
    id = id_string(text); maths = NULL
    text = xfun::protect_math(text, id)
    if (has_math <- any(grepl(paste0('`', id), text, fixed = TRUE))) {
      # temporarily replace math expressions with tokens so render() won't seem
      # them (to avoid issues like #33) and restore them later
      text = one_string(text)
      text = match_replace(text, sprintf('`%s(?s).{3,}?%s`', id, id), function(x) {
        # replace math with !id-n-id! where n is the index of the math
        tokens = sprintf('!%s-%d-%s!', id, length(maths) + seq_along(x), id)
        math = gsub(sprintf('`%s|%s`', id, id), '', x)
        maths <<- c(maths, set_names(math, tokens))
        tokens
      })
      if (format == 'html') maths = html_escape(maths)
      text = split_lines(text)
    }
  }

  p = NULL  # indices of prose
  find_prose = local({
    t = NULL
    function() {
      # return early if text has not changed
      if (!is.null(p) && identical(text, t)) return(p)
      t <<- text
      p <<- prose_index(text)
    }
  })
  # ensure a blank line after an HTML tag if followed by a code block,
  # otherwise the code block may be considered part of HTML, e.g.,
  # for <p></p>\n```\n```, the code block after </p> won't be recognized
  find_prose()
  if (length(i <- grep('</[a-z0-9]+>\\s*$', text[p]))) {
    # if the next line is not prose but code block, append \n
    k = p[!(p[i] + 1) %in% p]
    if (length(k)) text[k] = paste0(text[k], '\n')
  }

  # superscript and subscript; for now, we allow only characters alnum|*|(|) for
  # script text but can consider changing this rule upon users' request
  r2 = '(?<!`)\\^([[:alnum:]*(),.]+?)\\^(?!`)'
  if (has_sup <- test_feature('superscript', r2)) {
    id2 = id_string(text)
    find_prose()
    text[p] = match_replace(text[p], r2, function(x) {
      # place superscripts inside !id...id!
      x = gsub('^\\^|\\^$', id2, x)
      sprintf('!%s!', x)
    })
  }
  r3 = '(?<![~`[:space:]])~([[:alnum:]*(),.]+?)~(?!`)'
  if (has_sub <- test_feature('subscript', r3)) {
    id3 = id_string(text)
    find_prose()
    text[p]= match_replace(text[p], r3, function(x) {
      # place subscripts inside !id...id!
      x = gsub('^~|~$', id3, x)
      sprintf('!%s!', x)
    })
  }
  find_prose()
  # add line breaks before/after fenced Div's to wrap ::: tokens into separate
  # paragraphs or code blocks
  text[p] = sub('^([ >]*:::+ )([^ {]+)$', '\\1{.\\2}', text[p]) # ::: foo -> ::: {.foo}
  text[p] = sub(
    '^([ >]*)((:::+)( \\{.*\\})?)$',
    if (format == 'latex') '\\1\n\\1```\n\\1\\2 \\3\n\\1```\n\\1' else '\\1\n\\1\\2\n\\1',
    text[p]
  )

  id4 = id_string(text)
  if (format == 'latex') {
    # put info string inside code blocks so the info won't be lost, e.g., ```r -> ```\nr
    text = gsub(
      '^([> ]*)(```+)([^`].*)$', sprintf('\\1\\2\n\\1%s\\3%s', id4, id4), text
    )
  } else if (format == 'html' && length(p) < length(text)) {
    # hide spaces so that attributes won't be dropped: {.lang foo} -> {.lang!id!foo}
    r4 = '^([> ]*```+\\s*)(\\{.+})\\s*$'
    text = match_replace(text, r4, function(x) {
      x1 = sub(r4, '\\1', x)
      x2 = sub(r4, '\\2', x)
      x2 = gsub(' ', id4, x2, fixed = TRUE)
      paste0(x1, x2)
    })
  }

  # turn @ref into [@ref](#ref) and resolve cross-references later in JS; for
  # latex output, turn @ref to \ref{}
  r_ref = '(([a-z]+)[-:][-_[:alnum:]]+)'  # must start with letters followed by - or :
  r5 = paste0('(^|(?<=\\s|\\())@', r_ref, '(?!\\])')
  if (test_feature('cross_refs', r5)) {
    text[p] = match_replace(text[p], r5, function(x) {
      sprintf('[%s](%s)', x, sub('^@', '#', x))
    })
  }

  ret = render(text)
  ret = move_attrs(ret, format)  # apply attributes of the form {attr="value"}

  if (has_math) ret = match_replace(ret, sprintf('!%s-\\d+-%s!', id, id), function(x) {
    if (length(maths) != length(x)) warning(
      'LaTeX math expressions cannot be restored correctly (expected ',
      length(maths), ' expression(s) but found ', length(x), ' in the output).'
    )
    maths[x]
  })

  has_mermaid = FALSE

  if (format == 'html') {
    # don't disable check boxes
    ret = gsub('(<li><input type="checkbox" [^>]*?)disabled="" (/>)', '\\1\\2', ret)
    # replace <a> with <span> if href is empty but other attrs exist, so we have
    # a way to create SPANs with attributes, e.g., [text](){.foo} -> <span
    # class="foo"></span>
    ret = gsub('<a href="" ([^>]+>.*?</)a>', '<span \\1span>', ret)
    if (has_sup)
      ret = gsub(sprintf('!%s(.+?)%s!', id2, id2), '<sup>\\1</sup>', ret)
    if (has_sub)
      ret = gsub(sprintf('!%s(.+?)%s!', id3, id3), '<sub>\\1</sub>', ret)
    r4 = '<pre><code class="language-\\{=([^}]+)}">(.+?)</code></pre>\n'
    ret = match_replace(ret, r4, function(x) {
      lang = gsub(r4, '\\1', x)
      code = gsub(r4, '\\2', x)
      # restore raw html content from ```{=html}
      i1 = lang == 'html'
      x[i1] = restore_html(code[i1])
      # possible math environments
      i2 = (lang %in% c('tex', 'latex')) &
        grepl('^\\\\begin\\{[a-zA-Z*]+\\}.+\\\\end\\{[a-zA-Z*]+\\}\n$', code)
      if (any(i2)) {
        x[i2] = sprintf('<p>\n%s</p>\n', code[i2])
        has_math <<- TRUE
      }
      # discard other types of raw content blocks
      x[!(i1 | i2)] = ''
      x
    }, perl = FALSE)  # for perl = TRUE, we'd need (?s) before (.+?)
    # support mermaid
    r_mmd = '<pre><code class="language-mermaid">(.*?)</code></pre>'
    if (has_mermaid <- length(grep(r_mmd, ret))) {
      ret = gsub(r_mmd, '<pre class="mermaid">\\1</pre>', ret)
    }
    r4 = '(<pre><code class="language-)\\{([^"]+)}">'
    # deal with ```{.class1 .class2 attrs}, which is not supported by commonmark
    ret = convert_attrs(ret, r4, '\\2', function(r, z, z2) {
      z1 = sub(r, '\\1', z)
      # make sure `class` is the first attribute
      z2 = gsub('^(.+?)( +)(class="[^"]+")(.*)$', '\\3 \\1\\4', z2)
      i = grepl('^class="', z2)
      z2 = ifelse(i, sub('^class="', '', z2), paste0('"', z2))
      paste0(z1, z2, '>')
    }, 'html', function(z2) gsub(id4, ' ', restore_html(z2)))
    # some code blocks with "attributes" are verbatim ones
    ret = match_replace(ret, '```+\\s*\\{.+}', function(x) gsub(id4, ' ', x, fixed = TRUE))
    # remove empty table header
    ret = gsub('<thead>\n<tr>\n(<th[^>]*></th>\n)+</tr>\n</thead>\n', '', ret)
    # table caption: a paragraph that starts with 'Table: ' or ': ' after </table>
    ret = gsub(
      '(<table>)(?s)(.+?</table>)\n<p>(Table)?: (?s)(.+?)</p>',
      '\\1\n<caption>\\4</caption>\\2', ret, perl = TRUE
    )
    # auto identifiers
    if (isTRUE(options[['auto_identifiers']])) ret = auto_identifier(ret)
    # number sections
    if (isTRUE(options[['number_sections']])) ret = number_sections(ret)
    # build table of contents
    ret = add_toc(ret, options)
    # math
    if (!has_math) has_math = length(ret) && (
      grepl('$$</p>', ret, fixed = TRUE) || grepl('\\)</span>', ret, fixed = TRUE)
    )  # math may be from pkg_manual()'s HTML
    is_katex = TRUE
    if (has_math && length(js_math <- js_options(options[['js_math']], 'katex'))) {
      is_katex = js_math$package == 'katex'
    }
    # number figures and tables, etc.
    ret = number_refs(ret, r_ref, is_katex)
  } else if (format == 'latex') {
    if (isTRUE(options[['footnotes']])) ret = fix_footnotes(ret)  # fix footnotes
    if (has_sup)
      ret = gsub(sprintf('!%s(.+?)%s!', id2, id2), '\\\\textsuperscript{\\1}', ret)
    if (has_sub)
      ret = gsub(sprintf('!%s(.+?)%s!', id3, id3), '\\\\textsubscript{\\1}', ret)
    r4 = sprintf(
      '(\\\\begin\\{verbatim}\n)%s(.+?)%s\n(.*?\n)(\\\\end\\{verbatim}\n)', id4, id4
    )
    ret = match_replace(ret, r4, function(x) {
      info = gsub(r4, '\\2', x)
      info = gsub('^\\{|}$', '', info)
      i = info %in% c('=latex', '=tex')
      x[i] = gsub(r4, '\\3', x[i])  # restore raw ```{=latex} content
      i = !i & grepl('^=', info)
      x[i] = ''  # discard other raw content
      # TODO: support code highlighting for latex (listings or highr::hi_latex)
      x = gsub(r4, '\\1\\3\\4', x)
      x
    }, perl = FALSE)
    # for nested verbatim code blocks, the inner blocks may have leftover ```\nid4
    ret = gsub(sprintf('(```)\n%s(.*?)%s', id4, id4), '\\1\\2', ret)
    # fix horizontal rules from --- (\linethickness doesn't work)
    ret = gsub('{\\linethickness}', '{1pt}', ret, fixed = TRUE)
    ret = redefine_level(ret, options[['top_level']])
    if (isTRUE(options[['toc']])) ret = paste0('\\tableofcontents\n', ret)
  }

  pkg_cite = yaml_field(yaml, format, 'citation_package')
  if (length(pkg_cite) != 1) pkg_cite = 'natbib'
  bib = yaml[['bibliography']]
  # temporarily save the bib values when previewing a book because bib may only
  # be specified in index.Rmd but not other chapters
  if (is.character(b <- .env$current_book)) {
    if (length(bib)) .env$bib[[b]] = bib else bib = .env$bib[[b]]
  }
  if (length(bib) == 1 && grepl(',', bib)) bib = strsplit(bib, ',\\s*')[[1]]
  # add [@citation] (.bib files are assumed to be under output dir)
  if (length(bib)) {
    ret = in_dir(out_dir, add_citation(ret, bib, format))
    if (format == 'latex') meta = bib_meta(meta, bib, pkg_cite)
  }

  # convert some meta variables in case they use Markdown syntax
  if (is.character(template)) for (i in top_meta) if (meta_len <- length(meta[[i]])) {
    # if author is of length > 1, render them individually
    m_author = i == 'author' && meta_len > 1
    meta[[i]] = if (m_author) uapply(meta[[i]], render) else {
      render(meta[[i]], clean = i != 'abstract')
    }
    # also provide *_ version of top-level meta variables, containing tags/commands
    meta[[paste0(i, '_')]] = I(if (format == 'html') {
      tag = tag_meta[i]
      sprintf(
        '<div class="%s">%s</div>', i, if (tag == '') meta[[i]] else {
          one_string(sprintf('<%s>%s</%s>', tag, meta[[i]], tag))
        }
      )
    } else if (format == 'latex') {
      sprintf(cmd_meta[i], if (m_author) one_string(meta[[i]], ' \\and ') else meta[[i]])
    })
  }

  # cross references (\ref or clever \cref)
  clever = isTRUE(options[['cleveref']])
  if (format == 'latex') ret = latex_refs(ret, r_ref, clever) else clever = FALSE

  # use the template (if provided) to create a standalone document
  if (is.character(template)) {
    meta$body = I(ret)
    if (format == 'html') {
      # reset the internal js/css stored in acc_var() on exit
      on.exit(acc_var(), add = TRUE)
      # add js/css for math
      if (has_math) set_math(js_math, is_katex)
      # add js/css for syntax highlighting
      set_highlight(options, ret)
      # add js for mermaid
      if (has_mermaid && length(grep('mermaid', meta[['js']])) == 0)
        acc_var(js = '@npm/mermaid/dist/mermaid.min.js')
    }
    ret = build_output(
      format, options, template, meta, test = c(if (length(input)) dirname(input), '.')
    )
    # load the cleveref package if not loaded
    if (clever && !any(grepl('\\\\usepackage.*\\{cleveref\\}', ret, perl = TRUE)))
      ret = sub('(?=\\\\begin\\{document\\})', '\\\\usepackage{cleveref}\n', ret, perl = TRUE)
  }

  if (format == 'html') {
    ret = in_dir(out_dir, embed_resources(ret, options))
    ret = clean_html(ret)
  } else if (format == 'latex') {
    # remove \maketitle if \title is absent
    if (!grepl('\n\\title{', ret, fixed = TRUE))
      ret = gsub('\n\\maketitle\n', '\n', ret, fixed = TRUE)
  }

  if (keep_yaml) ret = one_string(c(yaml2, '', ret))

  ret = sub('\n$', '', ret)
  if (is_output_file(output)) {
    if (is_pdf) {
      tex = with_ext(output, '.tex')
      if (!isTRUE(yaml_field(yaml, format, 'keep_tex')))
        on.exit(file.remove(tex), add = TRUE)
      write_utf8(ret, tex)
      output = tinytex::latexmk(
        tex, latex_engine %||% 'xelatex',
        if (pkg_cite == 'biblatex') 'biber' else 'bibtex'
      )
    }
    # for RStudio to capture the output path when previewing the output
    if (is_rmd_preview()) message('\nOutput created: ', output)
    if (is_pdf) invisible(output) else write_utf8(ret, output)
  } else raw_string(ret, lang = paste0('.', format))
}

# insert body and meta variables into a template
build_output = function(format, options, template, meta, ...) {
  tpl = one_string(template, ...)
  if (format == 'html') {
    defaults = list(
      'css' = 'default',
      'lang' = locale_lang(),
      'plain-title' = I(str_trim(commonmark::markdown_text(meta[['title']])))
    )
    for (i in setdiff(names(defaults), names(meta))) meta[[i]] = defaults[[i]]
    # special handling for css/js "files" that have no extensions
    for (i in c('css', 'js')) {
      i2 = paste0(i, '2')  # treat css2/js2 as global base (e.g. for sites)
      meta[[i]] = resolve_files(c(meta[[i2]], meta[[i]], acc_var(i)), i)
    }
  }
  sub_vars(tpl, meta, ...)
}

# substitute all variables in template with their values
sub_vars = function(tpl, meta, ...) {
  # find all variables in the template
  vars = unlist(match_full(tpl, '[$][-_[:alnum:]]+[$]'))
  # insert $body$ at last in case the body contain any $variables$ accidentally
  if (!is.na(i <- match('$body$', vars))) vars = c(vars[-i], vars[i])
  for (v in vars) {
    tpl = sub_var(tpl, v, meta[[gsub('[$]', '', v)]], ...)
  }
  tpl
}

top_meta = c('title', 'subtitle', 'author', 'date', 'abstract')
tag_meta = c('h1', 'h2', 'h2', 'h3', '')
names(tag_meta) = top_meta
cmd_meta = c(sprintf('\\%s{%%s}', top_meta[-5]), '\\begin{abstract}\n%s\\end{abstract}')
names(cmd_meta) = top_meta

yaml_text = function(part, text) if (length(l <- part$lines) == 2) text[l[1]:l[2]]

#' Markdown rendering options
#'
#' A list of all options to control Markdown rendering. Options that are enabled
#' by default are marked by a `+` prefix, and those disabled by default are
#' marked by `-`.
#'
#' See <https://yihui.org/litedown/#sec:markdown-options> for the full list of
#' options and their documentation.
#' @return A character vector of all available options.
#' @export
#' @examples
#' # all available options
#' litedown::markdown_options()
markdown_options = function() {
  # options enabled by default
  x1 = c(
    'smart', 'embed_resources', 'embed_cleanup', 'js_math', 'js_highlight', 'footnotes',
    'superscript', 'subscript', 'latex_math', 'auto_identifiers', 'cross_refs',
    setdiff(commonmark::list_extensions(), 'tagfilter')
  )
  # options disabled by default
  x2 = c(
    'toc', 'hardbreaks', 'tagfilter', 'number_sections', 'cleveref', 'offline',
    'smartypants'
  )
  sort(c(paste0('+', x1), paste0('-', x2)))
}


================================================
FILE: R/package.R
================================================
#' A lightweight version of R Markdown
#'
#' Markdown is a plain-text format that can be converted to HTML and other
#' formats. This package can render R Markdown to Markdown, and then to an
#' output document format. The main differences between this package and
#' \pkg{rmarkdown} are that it does not use Pandoc or \pkg{knitr} (i.e., fewer
#' dependencies), and it also has fewer Markdown features.
#' @importFrom xfun alnum_id base64_uri csv_options del_empty_dir dir_create
#'   divide_chunk download_cache exit_call fenced_block fenced_div file_exists
#'   file_ext grep_sub html_escape html_tag html_value in_dir is_abs_path
#'   is_blank is_rel_path loadable mime_type new_app new_record normalize_path
#'   parse_only prose_index raw_string read_all read_utf8 record_print
#'   relative_path Rscript_call same_path sans_ext set_envvar split_lines
#'   try_error try_silent with_ext write_utf8
'_PACKAGE'

# an internal environment to store some intermediate objects
.env = new_env()

#' The `fuse()` environment
#'
#' Get the environment passed to the `envir` argument of [fuse()], i.e., the
#' environment in which code chunks and inline code are evaluated.
#' @return When called during `fuse()`, it returns the `envir` argument value of
#'   `fuse()`. When called outside `fuse()`, it returns the global environment.
#' @export
fuse_env = function() .env$global %||% globalenv()

#' @export
record_print.data.frame = function(x, ...) {
  asis = inherits(x, 'AsIs')
  if (is.null(getOption('xfun.md_table.limit')) && !'limit' %in% names(list(...))) {
    opts = options(xfun.md_table.limit = if (!asis) 10)
    on.exit(options(opts), add = TRUE)
  }
  if (asis) class(x) = setdiff(class(x), 'AsIs')
  if (inherits(x, 'tbl_df')) x = as.data.frame(x)
  tab = xfun::md_table(x, ...)
  opt = reactor()
  tab = add_cap(tab, opt$tab.cap, opt$label, opt$cap.pos %||% 'top', opt$tab.env, 'tab')
  new_record(c(tab, ''), 'asis')
}

#' @export
record_print.matrix = record_print.data.frame

#' @export
record_print.tbl_df = record_print.data.frame

#' @export
record_print.knitr_kable = function(x, ...) {
  if ((fmt <- attr(x, 'format')) %in% c('html', 'latex'))
    x = fenced_block(x, paste0('=', fmt))
  new_record(c(x, ''), 'asis')
}

# register vignette engines
.onLoad = function(lib, pkg) {
  vig_add('vignette', vig_fun(TRUE), vig_fun(FALSE))
  vig_add('book', vig_fun(TRUE, TRUE), vig_fun(FALSE, TRUE))
}

vig_add = function(name, weave, tangle) {
  tools::vignetteEngine(
    name, weave, tangle, '[.]R?md$', aspell = list(filter = vig_filter)
  )
}

# weave or tangle?
vig_fun = function(weave = TRUE, book = FALSE) {
  function(file, quiet = FALSE, ...) {
    empty_file = function() write_utf8(character(), with_ext(file, '.R'))

    # call fuse_book() to build multiple input files into a book
    if (book) return(if (weave) {
      if (quiet) {
        opt = options(litedown.progress.delay = Inf); on.exit(options(opt))
      }
      fuse_book(dirname(file), envir = globalenv())
    } else empty_file())

    # fuse() .Rmd and mark() .md
    if (grepl('[.]Rmd$', file)) {
      if (weave) fuse(file, quiet = quiet, envir = globalenv()) else {
        if (getRversion() <= '3.2.5') empty_file() else fiss(file)
      }
    } else {
      if (weave) mark(file) else empty_file()
    }
  }
}

# filter out code from document so aspell() won't spell check code
vig_filter = function(ifile, encoding) {
  res = crack(ifile)
  res = lapply(res, function(x) {
    if (x$type == 'code_chunk') return(rep('', length(x$source)))
    if (is.character(x$source)) x$source else {
      one_string(uapply(x$source, function(s) {
        if (is.character(s)) s else ''
      }), '')
    }
  })
  structure(split_lines(unlist(res)), control = '-H -t')
}

#' Print the package description, news, citation, manual pages, and source code
#'
#' Helper functions to retrieve various types of package information that can be
#' put together as the full package documentation like a \pkg{pkgdown} website.
#' These functions can be called inside any R Markdown document.
#' @param name The package name (by default, it is automatically detected from
#'   the `DESCRIPTION` file if it exists in the current working directory or
#'   upper-level directories).
#' @param type The HTML tag for the description: `table` (`<table>` with `<tr>`
#'   and `<td>`) or `dl` (definition list `<dl>` with `<dt>` and `<dd>`).
#' @return A character vector (HTML or Markdown) that will be printed as is
#'   inside a code chunk of an R Markdown document.
#'
#'   `pkg_desc()` returns an HTML table containing the package metadata.
#' @export
#' @examples
#' \dontrun{
#' litedown::pkg_desc()
#' litedown::pkg_news()
#' litedown::pkg_citation()
#' }
pkg_desc = function(name = detect_pkg(), type = c('table', 'dl')) {
  fields = c(
    'Title', 'Version', 'Description', 'Depends', 'Imports', 'Suggests',
    'License', 'URL', 'BugReports', 'VignetteBuilder', 'Authors@R', 'Author'
  )
  # read the DESCRIPTION file if pkg root is found, otherwise use installed info
  d = if (is.character(p <- attr(name, 'path'))) {
    as.list(read.dcf(file.path(p, 'DESCRIPTION'))[1, ][fields])
  } else {
    packageDescription(name, fields = fields)
  }
  names(d) = fields
  # remove single quotes on words (which are unnecessary IMO)
  for (i in c('Title', 'Description')) d[[i]] = sans_sq(d[[i]])
  d[['Author']] = one_string(pkg_authors(d), ', ')
  d[['Authors@R']] = NULL
  # convert URLs to <a>, and escape HTML in other fields
  for (i in names(d)) d[[i]] = if (!is.na(d[[i]])) {
    if (i %in% c('Description', 'URL', 'BugReports', 'Author')) {
      sans_p(commonmark::markdown_html(d[[i]], extensions = 'autolink'))
    } else html_escape(d[[i]])
  }
  d = unlist(d)
  res = one_string(switch(
    type[1],
    table = c(
      '<table class="table-full"><tbody>',
      sprintf('<tr>\n<td>%s</td>\n<td>%s</td>\n</tr>', names(d), d),
      '</tbody></table>'
    ),
    dl = c('<dl class="pkg-desc">', sprintf('<dt>%s</dt>\n<dd>%s</dd>', names(d), d), '</dl>')
  ))

  new_asis(c(res, vest(css = '@manual')))
}

# format authors, adding URL and ORCID links as appropriate
pkg_authors = function(desc, role = NULL, extra = TRUE) {
  if (is.null(a <- desc[['Authors@R']])) return(desc[['Author']])
  a = eval(parse_only(a))
  a = uapply(a, function(x) {
    if (length(role) && !any(role %in% x$role)) return()
    role = if (extra && length(x$role)) paste0('[', one_string(x$role, ', '), ']')
    name = paste(x$given, x$family)
    comment = as.list(x$comment)
    orcid = if (extra) sprintf(
      '[![ORCID iD](https://cloud.r-project.org/web/orcid.svg){.orcid}](https://orcid.org/%s)',
      comment[["ORCID"]]
    )
    link = comment[['URL']]
    if (length(link)) name = sprintf('[%s](%s)', name, link)
    one_string(c(name, orcid, role), ' ')
  })
  a
}

#' @param path For [pkg_news()], path to the `NEWS.md` file. If empty, [news()]
#'   will be called to retrieve the news entries. For [pkg_code()], path to the
#'   package root directory that contains `R/` and/or `src/` subdirectories.
#' @param recent The number of recent versions to show. By default, only the
#'   latest version's news entries are retrieved. To show the full news, set
#'   `recent = 0`.
#' @param toc Whether to add section headings to the document TOC (when TOC has
#'   been enabled for the document).
#' @param number_sections Whether to number section headings (when sections are
#'   numbered in the document).
#' @param ... Other arguments to be passed to [news()].
#' @return `pkg_news()` returns the news entries.
#' @rdname pkg_desc
#' @export
pkg_news = function(
  name = detect_pkg(), path = detect_news(name), recent = 1, toc = TRUE,
  number_sections = TRUE, ...
) {
  a = header_class(toc, number_sections)
  if (length(path) != 1 || path == '') {
    db = news(package = name, ...)
    if (recent > 0) db = head(db, recent)
    res = NULL
    for (v in unique(db$Version)) {
      df = db[db$Version == v, ]
      res = c(
        res, paste('##', name, v, a), '',
        if (all(df$Category == '')) paste0(df$HTML, '\n') else paste0(
          '### ', df$Category, a, '\n\n', df$HTML, '\n\n'
        ), ''
      )
    }
  } else {
    res = read_utf8(path)
    if (recent > 0 && length(h <- grep('^# ', res)) >= 2)
      res = res[h[1]:(h[1 + recent] - 1)]
    # lower heading levels: # -> ##, ## -> ###, etc, and add attributes
    for (i in 2:1) res = sub(sprintf('^(#{%d} .+)', i), paste0('#\\1', a), res)
    # shorten headings
    res = gsub('^## CHANGES IN ([^ ]+) VERSION( .+)', '## \\1\\2', res)
    # link Github @username and #issue
    if (length(u <- github_link(dirname(path)))) {
      r1 = '([[:alnum:]-]+)'; r2 = '([0-9]+)'
      res = gsub(paste0(' @', r1), ' [@\\1](https://github.com/\\1)', res)
      res = gsub(paste0(' #', r2), sprintf(' [#\\1](%sissues/\\1)', u), res)
      res = gsub(
        sprintf(' %s/%s#%s', r1, r1, r2),
        ' [\\1/\\2#\\3](https://github.com/\\1/\\2/issues/\\3)', res
      )
    }
  }
  new_asis(res)
}

# classes for section headings in news, code, and manual
header_class = function(toc, number_sections, md = TRUE) {
  a = c(if (!toc) 'unlisted', if (!number_sections) 'unnumbered')
  if (md && length(a)) a = paste0('.', a)
  a = one_string(a, ' ')
  if (a != '') a = if (md) paste0(' {', a, '}') else paste0(' class="', a, '"')
  a
}

#' @param pattern A regular expression to match filenames that should be treated
#'   as source code.
#' @param link Whether to add links on the file paths of source code. By
#'   default, if a GitHub repo link is detected from the `BugReports` field of
#'   the package `DESCRIPTION`, GitHub links will be added to file paths. You
#'   can also provide a string template containing the placeholder `%s` (which
#'   will be filled out with the file paths via `sprintf()`), e.g.,
#'   `https://github.com/yihui/litedown/blob/main/%s`.
#' @return `pkg_code()` returns the package source code under the `R/` and
#'   `src/` directories.
#' @rdname pkg_desc
#' @export
pkg_code = function(
  path = attr(detect_pkg(), 'path'), pattern = '[.](R|c|h|f|cpp)$', toc = TRUE,
  number_sections = TRUE, link = TRUE
) {
  if (!isTRUE(dir.exists(path))) {
    if (missing(path)) message('Please run this function in the package source directory.')
    return()
  }
  a = header_class(toc, number_sections)
  if (isTRUE(link) && length(u <- github_link(path))) link = paste0(u, 'blob/HEAD/%s')
  ds = c('R', 'src')
  ds = ds[ds %in% list.dirs(path, FALSE, FALSE)]
  flat = length(ds) == 1  # if only one dir exists, list files in a flat structure
  code = in_dir(path, lapply(ds, function(d) {
    fs = list.files(d, pattern, full.names = TRUE, recursive = TRUE)
    if (length(fs) == 0) return()
    x = uapply(fs, function(f) c(
      sprintf('##%s %s%s', if (flat) '' else '#', if (is.character(link)) {
        sprintf('[`%s`](%s)', f, sprintf(link, f))
      } else sprintf('`%s`', f), a), '',
      fenced_block(read_utf8(f), lineno_attr(file_ext(f), auto = FALSE)), ''
    ))
    e = unique(file_ext(fs))
    c(if (!flat) paste0('## ', paste0('`*.', e, '`', collapse = ' / '), a), '', x)
  }))
  new_asis(unlist(code))
}

# retrieve Github repo link from DESCRIPTION
github_link = function(path) {
  u = read.dcf(file.path(path, 'DESCRIPTION'), 'BugReports')[1, 1]
  grep_sub('^(https://github.com/[^/]+/[^/]+/).*', '\\1', u)
}

#' @return `pkg_citation()` returns the package citation in both the plain-text
#'   and BibTeX formats.
#' @rdname pkg_desc
#' @export
pkg_citation = function(name = detect_pkg()) {
  res = uapply(citation(name), function(x) {
    x = tweak_citation(x)
    unname(c(format(x, style = 'text'), fenced_block(toBibtex(x), 'latex')))
  })
  new_asis(res)
}

# dirty hack to add year if missing
tweak_citation = function(x) {
  cls = class(x)
  x = unclass(x)
  if (is.null(x[[1]]$year)) x[[1]]$year = format(Sys.Date(), '%Y')
  class(x) = cls
  x
}

#' @param overview Whether to include the package overview page, i.e., the
#'   `{name}-package.Rd` page.
#' @param examples A list of arguments to be passed to [xfun::record()] to run
#'   examples each help page, e.g., `list(dev = 'svg', dev.args = list(height =
#'   6))`. If not a list (e.g., `FALSE`), examples will not be run.
#' @return `pkg_manual()` returns all manual pages of the package in HTML.
#' @rdname pkg_desc
#' @export
pkg_manual = function(
  name = detect_pkg(), toc = TRUE, number_sections = TRUE, overview = TRUE,
  examples = list()
) {
  links = tools::findHTMLlinks('')
  # resolve internal links (will assign IDs of the form sec:man-ID to all h2)
  r = sprintf('^[.][.]/[.][.]/(%s)/html/(.+)[.]html$', name)
  i = grep(r, links)
  links[i] = paste0('#sec:man-', alnum_id(sub(r, '\\2', links[i])))
  # resolve external links to specific man pages on https://rdrr.io
  r = sprintf('^[.][.]/[.][.]/(%s)/html/', paste(xfun::base_pkgs(), collapse = '|'))
  links = sub(r, 'https://rdrr.io/r/\\1/', links)

  db = tools::Rd_db(name)  # all Rd pages
  intro = paste0(name, '-package.Rd')  # the name-package entry (package overview)
  entries = setdiff(names(db), intro)
  entries = entries[!vapply(entries, function(i) {
    kwd = uapply(db[[i]], function(x) if (attr(x, 'Rd_tag') == '\\keyword') x)
    'internal' %in% kwd
  }, logical(1))]
  db = db[c(if (overview && intro %in% names(db)) intro, entries)]
  al = lapply(db, Rd_aliases)

  cl = header_class(toc, number_sections, FALSE)
  r1 = '<code class="reqn">\\s*([^<]+?)\\s*</code>'  # inline math
  r2 = sprintf('<p[^>]*>\\s*%s\\s*</p>', r1)  # display math
  res = uapply(names(db), function(i) {
    txt = ''
    con = textConnection('txt', 'w', local = TRUE, encoding = 'UTF-8')
    tryCatch(
      tools::Rd2HTML(db[[i]], Links = links, out = con), error = function(e) {
        warning("The Rd file '", i, "' appears to be malformed.", call. = FALSE)
        stop(e)
      }, finally = close(con))
    # extract body, which may end at </main> (R 4.4.x) or </div></body> (R 4.3.x)
    txt = gsub('(?s).*?(?=<h2)', '', one_string(txt), perl = TRUE)
    txt = gsub('(</main>|</div>\\s*</body>).*', '', txt)
    # free math from <code>
    txt = gsub(r2, '<p>$$\\1$$</p>', txt)
    txt = gsub(r1, '<span>\\\\(\\1\\\\)</span>', txt)
    # run examples
    if (is.list(examples)) {
      xfun::pkg_attach(name)
      default = list(print = function(x, ...) {
        if (inherits(x, 'xfun_raw_string')) record_print(x) else
          xfun:::record_print.default(x)
      }, dev.path = 'manual/', dev.args = list(width = 9, height = 7))
      txt = run_examples(txt, merge_list(default, examples), sans_ext(i))
    }
    # remove existing ID and class
    for (a in c('id', 'class')) txt = gsub(sprintf('(<h2[^>]*?) %s="[^"]+"', a), '\\1', txt)
    if (cl != '') txt = sub('<h2', paste0('<h2', cl), txt, fixed = TRUE)
    sub('<h2', sprintf('<h2 id="sec:man-%s"', sans_ext(i)), txt, fixed = TRUE)
  })

  # extract all aliases and put them in the beginning (like a TOC)
  env = asNamespace(name)
  toc = unlist(.mapply(function(topics, target) {
    fn = uapply(topics, function(x) {
      if (is.function(env[[x]])) paste0(x, '()') else x  # add () after function names
    })
    sprintf('<a href="#sec:man-%s"><code>%s</code></a>', target, fn)
  }, al, sans_ext(names(al))))

  g = toupper(substr(unlist(al), 1, 1))
  g[!g %in% LETTERS] = 'misc'
  g = factor(g, intersect(c(LETTERS, 'misc'), g))
  toc = split(toc, g)  # group by first character
  toc = unlist(mapply(function(x, g) {
    c('<p>', sprintf('<b>-- <kbd>%s</kbd> --</b>', g), x, '</p>')
  }, toc, names(toc)))

  r = '(<a href=")[.][.]/[.][.]/([^/]+)/help/'
  res = gsub(r, '\\1https://rdrr.io/cran/\\2/man/', res)
  res = gsub(" (id|class)='([^']+)'", ' \\1="\\2"', res)  # ' -> "
  res = gsub('<h3>', '<h3 class="unnumbered unlisted">', res, fixed = TRUE)
  res = match_replace(res, '<(h[1-6])[^>]*>(?s).+?</\\1>', function(x) {
    gsub('\n', ' ', x)  # remove \n in headings so build_toc() can be correctly build TOC
  })
  res = gsub('<code style="[^"]+">', '<code>', res)
  res = gsub('<code id="[^"]+">', '<code>', res)
  res = gsub('(<code[^>]*>)\\s+', '\\1', res)
  res = gsub('\\s+(</code>)', '\\1', res)
  res = gsub('<div class="sourceCode"><pre>(.+?)</pre></div>', '<pre><code>\\1</code></pre>', res)
  res = gsub('<div class="sourceCode ([^"]+)"><pre>(.+?)</pre></div>', '<pre><code class="language-\\1">\\2</code></pre>', res)
  res = gsub('<code class="language-R"', '<code class="language-r"', res, fixed = TRUE)
  res = gsub('&#8288;', '', res, fixed = TRUE)
  res = gsub('<img src="../help/figures/', '<img src="man/figures/', res, fixed = TRUE)
  new_asis(c(toc, res, vest(css = '@manual')))
}

run_examples = function(html, config, path) {
  config$dev.path = path = paste0(config$dev.path, path)
  on.exit(del_empty_dir(dirname(path)), add = TRUE)
  r = '(?s).*?<pre><code[^>]*>(?s)(.+?)</code></pre>'
  match_replace(html, paste0('(?<=<h3>Examples</h3>)', r), function(x) {
    code = gsub(r, '\\1', x, perl = TRUE)
    code = restore_html(str_trim(code))
    nr1 = 'if (FALSE) {  ## Not run'
    nr2 = '}  ## Not run'
    code = gsub('\n?## Not run:\\s*?\n', paste0('\n', nr1, '\n'), code)
    code = gsub('\n+## End[(]Not run[)]\n*', paste0('\n', nr2, '\n'), code)
    res = do.call(xfun::record, merge_list(config, list(code = code, envir = globalenv())))
    idx = seq_along(res); cls = class(res)
    for (i in idx) {
      ri = res[[i]]; ci = class(ri)
      # disable asis output since it may contain raw HTML
      if ('record_asis' %in% ci) class(res[[i]]) = 'record_output'
      # split the dontrun block
      if ('record_source' %in% ci && !any(is.na(nr <- match(c(nr1, nr2), ri)))) {
        i1 = nr[1]; i2 = nr[2]
        new_block = function(i, ...) {
          b = trim_blank(one_string(ri[i]))
          if (is_blank(b)) b = character()
          list(structure(b, class = c(ci, ...)))
        }
        if (i1 > 1) {
          res = c(res, new_block((i1 + 1):(i2 - 1), 'fade'))
          idx = c(idx, i)
        }
        n = length(ri)
        if (i2 < n) {
          res = c(res, new_block((i2 + 1):n))
          idx = c(idx, i)
        }
        res[i] = if (i1 > 1) new_block(1:(i1 - 1)) else
          new_block((i1 + 1):(i2 - 1), 'fade')
      }
    }
    res = res[order(idx)]
    class(res) = cls
    res = one_string(c('', format(res, 'markdown')))
    res
  })
}

# detect package name and root path from current and upper dirs
detect_pkg = local({
  res = NULL  # cache the detection
  function(...) {
    if (is.null(res) || !same_path('.', attr(res, 'wd'))) res <<- .detect_pkg(...)
    res
  }
})

.detect_pkg = function(error = TRUE) {
  ds = if (xfun::is_R_CMD_check()) {
    # R CMD check's working directory is PKG_NAME.Rcheck by default
    name = grep_sub('[.]Rcheck$', '', basename(getwd()))
    # when running R CMD check, DESCRIPTION won't be under working directory but
    # ../PKG_NAME/ (on CRAN's *nix) or ./00_pkg_src/
    c(file.path('..', name), if (dir.exists('00_pkg_src'))
      dirname(list.files('.', '^DESCRIPTION$', recursive = TRUE)))
  } else name = NULL
  for (d in c(ds, './')) {
    if (!is.null(root <- xfun::proj_root(d, head(xfun::root_rules, 1)))) break
  }
  if (is.null(root)) {
    root = if (length(name)) system.file(package = name)
    if (identical(root, '')) root = NULL
  }
  if (is.null(root)) {
    if (error) stop(
      "Cannot automatically detect the package root directory from '", getwd(), "'. ",
      "You must provide the package name explicitly."
    ) else return()
  } else if (is.null(name)) {
    desc = read_utf8(file.path(root, 'DESCRIPTION'))
    name = grep_sub('^Package: (.+?)\\s*$', '\\1', desc)[1]
  }
  structure(name, path = root, wd = getwd())
}

detect_news = function(name) {
  if (isTRUE(file_exists(path <- file.path(attr(name, 'path'), 'NEWS.md'))))
    path else system.file('NEWS.md', package = name)
}

# get \alias{} names in an Rd object
Rd_aliases = function(x) {
  uapply(x, function(x) if (attr(x, 'Rd_tag') == '\\alias') as.character(x))
}


================================================
FILE: R/preview.R
================================================
#' Preview Markdown and R Markdown files
#'
#' Launch a web page to list and preview files under a directory.
#'
#' Markdown files will be converted to HTML and returned to the web browser
#' directly without writing to HTML files, to keep the directory clean during
#' the preview. Clicking on a filename will bring up an HTML preview. To see its
#' raw content, click on the link on its file size instead.
#' @param dir A directory path.
#' @param live Whether to enable live preview. If enabled, the browser page will
#'   be automatically updated upon modification of local files used by the page
#'   (e.g., the Markdown file or external CSS/JS/image files). If disabled, you
#'   can manually refresh the page to fully re-render it.
#' @param ... Other arguments to be passed to [xfun::new_app()].
#' @return A URL (invisibly) for the preview.
#' @export
roam = function(dir = '.', live = TRUE, ...) in_dir(dir, {
  # a proxy server to return files under inst/resources/
  s = new_app('.litedown', function(path, ...) {
    file_raw(pkg_file('resources', path))
  }, open = FALSE)
  # the URL needs to be translated on RStudio Server
  if (Sys.getenv('RSTUDIO_PROGRAM_MODE') == 'server' && loadable('rstudioapi'))
    s = rstudioapi::translateLocalUrl(s, TRUE)
  # load litedown assets via http://127.0.0.1:port/custom/.litedown/assets
  asset_url = function(path) paste0(s, path)

  t1 = list()  # store modification times of files
  check_time = function(path) {
    t2 = if (dir.exists(path)) {
      file.info(list.files(path, full.names = TRUE))[, 'mtime', drop = FALSE]
    } else file.mtime(path)
    t = t1[[path]]; t1[[path]] <<- t2
    !is.null(t) && !(is.null(dim(t2)) && is.na(t2)) && !identical(t2, t)
  }

  new_app('litedown', function(path, query, post, headers) {
    # set up proper default options for mark()
    opt = options(
      litedown.html.meta = list(
        css = asset_url(c('default.css', if (dir.exists(path)) 'listing.css'))
      ),
      litedown.html.options = list(embed_resources = FALSE),
      litedown.roaming = TRUE
    )
    on.exit(options(opt), add = TRUE)
    # capture errors in fuse() because I don't know if it's possible to capture
    # general errors in the handler; without capturing errors, users will see a
    # plain-text error page, which may be hard to understand
    opts = reactor(error = TRUE); on.exit(reactor(opts), add = TRUE)
    query = as.list(query)
    ext = tolower(file_ext(path))
    # we keep POSTing to the page assets' URLs, and if an asset file has been
    # modified, we return a response telling the browser to update it
    type = if (length(headers)) grep_sub(
      '.*\nlitedown-data: ([^[:space:]]+).*', '\\1', rawToChar(headers)
    )
    if (length(type) != 1) type = ''
    # we may need to check rawToChar(headers) to decide what to do for the
    # request; for now, we simply ignore request headers, and treat the POST
    # body as the type of request
    if (type == 'open') {
      xfun:::open_path(
        query[['path']] %||% path, !dir.exists(path) && is_text_file(file = path),
        as.integer(query[['line']]) %|% -1L
      )
      return(list(payload = 'done'))
    }
    # create a new file with selected features
    if (grepl('^new($|:)', type))
      return(list(payload = if (type == 'new') feature_form(path) else new_file(path, ext, type)))
    # render Rmd in new R sessions and save to file
    if (type == 'save') {
      return(if (is_lite_ext(ext)) {
        # check if the file is for a book or site
        info = proj_info(path)
        list(payload = paste('Rendered and saved:', switch(
          info$type,
          book = Rscript_call(fuse_book, list(info$root)),
          site = { Rscript_call(fuse_site, list(info$root)); info$root },
          if (ext == 'md') mark(path) else Rscript_call(fuse, list(path))
        )))
      } else {
        list(payload = paste0(
          "Unable to render '", path, "' (only ",
          paste0('.', lite_exts, collapse = ', '), " are supported)."
        ))
      })
    }
    # clean up __files/
    if (type == 'cleanup') {
      if (dir.exists(fig <- fig_path(path)) &&
          !is.null(fig_time <- .env$roam_files[[fig]]) &&
          !dir.exists(aux_path(, 'cache.path', path))) {
        fig_time2 = file.mtime(fig)
        # remove fig dir if it has not been modified since its mtime was
        # recorded last time (in file_resp()); if it has, update to new mtime so
        # we can clean it up next time when we receive the request; checking
        # mtime is necessary because the Ajax cleanup request (sent from the
        # 'beforeunload' event) may arrive _after_ file_resp() rebuilds the
        # current page when the page is refreshed, which may be counterintuitive
        # but Ajax is _asynchronous_ anyway (this took me hours to figure out)
        .env$roam_files[[fig]] = if (fig_time2 <= fig_time) {
          unlink(fig, recursive = TRUE); NULL
        } else fig_time2
      }
      return(list(payload = ''))
    }
    # TODO: should we implement Hugo's --navigateToChanged?
    if (live && type != '') {
      resp = ''
      if (type %in% c('asset', 'page')) {
        if (check_time(path)) resp = '1'
      } else if (grepl('^book:', type) && check_time(f <- sub('^book:', '', type))) {
        store_book(dirname(path), f)
        # the book file path to preview is encoded in `type = book:foo/bar.Rmd`
        resp = fuse_book(c(dirname(path), f), 'html', globalenv())
      }
      return(list(payload = resp))
    }
    res = lite_handler(path, query, post, headers)
    # inject js to communicate with the R server via POST for live preview
    p = res$payload
    if (is.null(p) || (res[['content-type']] %||% 'text/html') != 'text/html')
      return(res)
    p = sub(
      '</head>', one_string(c(
        if (live) '<meta name="live-previewer" content="litedown::roam">',
        gen_tags(asset_url('server.css')), '</head>'
      )), p, fixed = TRUE
    )
    p = sub(
      '</body>', one_string(c(gen_tags(asset_url('server.js')), '</body>')), p,
      fixed = TRUE
    )
    res$payload = p
    res
  }, ...)
})

# a handler returns list(payload, file, `content-type`, header, `status code`)
lite_handler = function(path, query, post, headers) {
  if (dir.exists(path)) list(payload = dir_page(path)) else {
    file_page(path, query[['preview']] %||% '0')
  }
}

dir_page = function(dir = '.') {
  files = list.files(dir, full.names = TRUE)
  # index.* files should appear first
  files = files[order(!sans_ext(basename(files)) == 'index')]
  # show file size and mtime
  info = function(f, b, extra = '') {
    sprintf(
      '_( [%s](<%s>){title="Raw file"} %s%s%s)_', file_size(f), b, file_time(f),
      if (is_text_file(file = f)) btn('.open', b) else '', extra
    )
  }
  # create link to preview a file
  p_link = function(f, t = f, n = 1, a = NULL) {
    btn(t, sprintf('%s?preview=%d', f, n), a)
  }
  i1 = is_lite_ext(file = files)
  # order first by folder, then by .Rmd/.R, and other files go to the end
  res = lapply(files[i1][order(grepl('^[_.]', basename(files[i1])))], function(f) {
    b = basename(f)
    fenced_div(c(
      fenced_div(c(
        p_link(b, a = NULL), info(f, b, btn('.save', b)),
        p_link(b, '.run', 2, 'title="Run in memory"')
      ), '.caption .name'),
      fenced_block(readLines(f, n = 10, encoding = 'UTF-8', warn = FALSE))
    ), '.box')
  })
  files = files[!i1]
  i2 = dir.exists(files)
  res = c(res, '', lapply(files[order(!i2, files)], function(f) {
    b = basename(f)
    if (d <- dir.exists(f)) b = paste0(b, '/')
    sprintf(
      '- [%s](<%s%s>) %s', b, b, if (d) '' else '?preview=1',
      if (d) '' else info(f, b)
    )
  }))
  mark_full(unlist(res), meta = list(title = dir_title(dir)))
}

# add directory navigation to the top of the page
file_page = function(x, preview) {
  res = file_resp(x, preview)
  if (is.null(p <- res$payload)) return(res)
  # inject navigation links to the top of the page
  nav = dir_title(x, preview)
  nav = sub('<p>', '<p class="nav-path">', nav, fixed = TRUE)
  res$payload = sub('<body>', paste0('<body>\n', nav), p, fixed = TRUE)
  res
}

# extensions that litedown can render
lite_exts = c('md', 'rmd', 'qmd', 'r')

is_lite_ext = function(ext = file_ext(file), file) tolower(ext) %in% lite_exts

# render the path to HTML if possible
file_resp = function(x, preview) {
  raw = preview == '0'  # 0: send raw response; 1: render verbatim; 2: fuse()/mark()
  ext = if (raw) '' else tolower(file_ext(x))
  if (preview == '2' && is_lite_ext(ext)) {
    # to clean up the __files/ dir if requested (via options()) and the dir
    # didn't exist before
    if (getOption('litedown.roam.cleanup', FALSE)) {
      fig = fig_path(x)
      if (!dir.exists(fig)) on.exit({
        if (dir.exists(fig)) .env$roam_files[[fig]] = file.mtime(fig)
      })
    }
    # check if the file is for a book or site
    info = proj_info(x)
    list(payload = switch(
      info$type,
      book = {
        store_book(info$root, x, info$index)
        fuse_book(if (info$index) info$root else x, full_output, globalenv())
      },
      site = fuse_site(x),
      if (ext == 'md') mark_full(x) else fuse(x, full_output, envir = globalenv())
    ))
  } else {
    type = mime_type(x)
    if (!raw && is_text_file(ext, type) &&
        !inherits(txt <- try_silent(read_utf8(x, error = TRUE)), 'try-error')) {
      list(payload = mark_full(
        fenced_block(txt, lineno_attr(if (ext == '') 'plain' else ext))
      ))
    } else {
      file_raw(x, type)
    }
  }
}

# store book dir for books to resolve number_refs() because the book may be
# partially rendered (in which case we can't resolve refs to other chapters)
store_book = function(dir, x, index = FALSE) {
  f = function(...) .mapply(
    function(n, v) .env[[paste0('current_', n)]] = v,
    c('book', 'file', 'index'), list(...)
  )
  f(dir, x, index)
  exit_call(function() f(NULL))
}

# detect project type for a directory (_litedown.yml may be in an upper-level dir)
proj_info = function(x, d = dirname(x)) {
  while (length(yaml <- yml_config(d)) == 0) {
    if (same_path(d, d2 <- file.path(d, '..'))) break
    d = d2
  }
  # use the field 'type' if provided, otherwise look for 'book' or 'site'
  type = yaml[['type']] %||% head(intersect(c('book', 'site'), names(yaml)), 1)
  root = if (length(type)) d else NA
  if (is.na(root)) type = 'default'
  # a file doesn't belong to a site if it doesn't match the site file pattern
  if (type != 'default' && x != '') {
    p = yaml[[type]][['pattern']] %||% site_pattern
    if (!grepl(p, x)) type = 'default'
  }
  list(
    type = type, root = root, yaml = yaml,
    index = !is.na(root) && is_index(x) && same_path(x, file.path(root, basename(x)))
  )
}

full_output = structure('html', full = TRUE)
# generate full HTML output (instead of fragments)
mark_full = function(...) mark(..., output = full_output)

# guess if a file is a text file
is_text_file = function(ext = file_ext(file), type = mime_type(file), file) {
  (ext %in% c('js', 'latex', 'qmd', 'tex', 'xml') || grepl('^text/', type))
}

is_roaming = function() isTRUE(getOption('litedown.roaming'))

# return a raw file response
file_raw = function(x, type = mime_type(x)) {
  list(file = normalizePath(x), `content-type` = type)
}

file_size = function(x) xfun::format_bytes(file.size(x))
file_time = function(x) format(file.mtime(x))
dir_title = function(f, preview = '1') {
  links = if (f != '.') {
    d = if (file_exists(f)) dirname(f) else f
    d = if (d == '.') character() else unlist(strsplit(d, '/'))
    b = basename(f)
    c(
      sprintf('[%s/](%s)', c('.', d), c(rev(strrep('../', seq_along(d))), './')),
      if (file_exists(f)) b, if (is_lite_ext(file = f)) c(
        btn('.save'), if (preview != '2')
          btn('.run', sprintf('%s?preview=2', b), 'title="Run"')
      ),
      if (is_text_file(file = f)) btn('.open')
    )
  }
  txt = one_string(c(sprintf('_%s:_', normalize_path('.')), links), ' ')
  move_attrs(commonmark::markdown_html(txt, smart = TRUE))
}

btn = function(t, u = '#', a = character()) {
  if (startsWith(t, '.')) {
    a = c(t, a); t = .icons[t]
  }
  a = if (is.character(a)) one_string(c('.btn-lite', a), ' ')
  a = if (is.null(a)) '' else paste0('{', a, '}')
  sprintf(' [%s](<%s>)%s ', t, u, a)
}

.icons = c(.open = '&#9998;', .run = '&#9205;', .save = '&#8623;')

new_file = function(path, ext, type, css = NULL, js = NULL) {
  if (file.exists(path)) return(sprintf("The file '%s' already exists.", path))
  on.exit(xfun:::open_path(path), add = TRUE)
  if (!is_lite_ext(ext)) return(tolower(file.create(path)))
  features = strsplit(sub('^new:', '', type), ',')[[1]]
  a = assets[features, , drop = FALSE]
  m = list(
    title = 'Comfortably Untitled',
    author = tools::toTitleCase(Sys.getenv('USERNAME', Sys.getenv('USER'))),
    date = format(Sys.Date())
  )
  if (nrow(a)) m$output$html = list(meta = list(
    css = I(na_omit(a[, 'css'])), js = I(na_omit(a[, 'js']))
  ))
  txt = c('---', xfun::taml_save(m), '---', '', 'Relax. I need some information first.')
  if (ext == 'r') txt = paste("#'", split_lines(txt))
  write_utf8(txt, path)
  'view'
}


================================================
FILE: R/site.R
================================================
#' Fuse R Markdown documents individually under a directory
#'
#' Run [fuse()] on R Markdown documents individually to generate a website.
#'
#' If a directory contains a config file `_litedown.yml`, which has a YAML field
#' `site`, the directory will be recognized as a site root directory. The YAML
#' field `output` will be applied to all R Markdown files (an individual R
#' Markdown file can provide its own `output` field in YAML to override the
#' global config). For example:
#'
#' ```yaml
#' ---
#' site:
#'   rebuild: "outdated"
#'   pattern: "[.]R?md$"
#' output:
#'   html:
#'     meta:
#'       css: ["@default"]
#'       include_before: "[Home](/) [About](/about.html)"
#'       include_after: "&copy; 2024 | [Edit]($input$)"
#' ---
#' ```
#'
#' The option `rebuild` determines whether to rebuild `.Rmd` files. Possible
#' values are:
#'
#' - `newfile`: Build an input file if it does not have a `.html` output file.
#'
#' - `outdated`: Rebuild an input file if the modification time of its `.html`
#' output file is older than the input.
#' @param input The root directory of the site, or a vector of input file paths.
#' @return Output file paths (invisibly).
#' @export
fuse_site = function(input = '.') {
  info = NULL; preview = FALSE
  inputs = if (length(input) == 1 && dir.exists(input)) {
    info = proj_info('', input)
    find_input(input, TRUE, info$yaml[['site']][['pattern']])
  } else {
    info = proj_info(input[1])
    preview = is_roaming() && length(input) == 1
    input
  }
  root = info$root
  output = with_ext(inputs, '.html')
  cfg = merge_list(list(rebuild = 'outdated'), info$yaml[['site']])
  b = cfg[['rebuild']]
  if (b == 'outdated') b = 0
  i = if (is.numeric(b)) filter_outdated(inputs, output, b) else {
    if (b == 'newfile') !file_exists(output) else TRUE
  }
  opts = yaml_field(info$yaml, 'html', c('meta', 'options'))
  opts[['meta']] = merge_list(list(
    css2 = c(site_css, '@site'), js2 = site_js,
    include_before = nav_menu(info, inputs), include_after = format(Sys.Date(), '&copy; %Y')
  ), opts[['meta']])
  opts[['options']] = merge_list(
    list(embed_resources = FALSE, toc = TRUE), opts[['options']]
  )
  out = lapply(inputs[i], function(x) {
    res = if (grepl('[.]md$', x)) {
      opts = set_site_options(opts, x, root); on.exit(options(opts))
      mark(x, full_output)
    } else {
      Rscript_call(
        function(x, opts, set, root, flag, output) {
          set(opts, x, root, list(litedown.roaming = flag))
          litedown::fuse(x, output, envir = globalenv())
        },
        list(x, opts, set_site_options, root, is_roaming(), full_output),
        fail = paste('Failed to run litedown::fuse() on', x)
      )
    }
    # resolve / to relative paths
    if (!is.na(info$root)) {
      up = relative_path(info$root, dirname(x))
      if (up == '.') up = ''
      res = match_replace(res, ' (href|src)(=")/', function(z) {
        gsub('/$', up, z)
      })
    }
    if (preview) res else write_utf8(res, with_ext(x, '.html'))
  })
  if (preview) {
    if (i) out[[1]] else xfun::file_string(output)
  } else invisible(output)
}

# common css/js for sites/books
site_css = c('@default', '@article', '@copy-button', '@heading-anchor', '@pages')
site_js = c('@sidenotes', '@appendix', '@toc-highlight', '@copy-button', '@heading-anchor', '@pages')

# set global options litedown.html.[meta|options] read from _litedown.yml
set_site_options = function(opts, input, root, extra = NULL) {
  m = opts[['meta']]
  for (i in c('include_before', 'include_after')) {
    if (!is.character(m[[i]])) next
    tag = if (i == 'include_before') 'nav' else 'footer'
    x = mark(I(one_string(m[[i]], test = c(dirname(input), root))))
    x = sprintf('<%s>%s</%s>', tag, x, tag)
    m[[i]] = sub_vars(x, list(input = I(input)))
  }
  opts[['meta']] = m
  options(c(set_names(opts, paste0('litedown.html.', names(opts))), extra))
}

filter_outdated = function(x, x2, n) {
  m1 = file.mtime(x); m2 = file.mtime(x2); is.na(m2) | m1 - m2 > n
}

# build a nav menu from filenames under root directory
nav_menu = function(info, inputs) {
  if (is.na(info$root)) return('[Home](/index.html)')
  root = sub('/+$', '', info$root)  # strip any trailing slash for reliable dirname() comparison
  b = basename(inputs[dirname(inputs) == root])
  links = sprintf(
    '[%s](/%s)', menu_name(sans_ext(ifelse(is_index(b), 'home', b))),
    if (is_roaming()) paste0(b, '?preview=2') else with_ext(b, '.html')
  )
  # add subdirs that already have an index.html (same exclusion as find_input)
  sub_dirs = list.dirs(root, recursive = FALSE)
  sub_dirs = sub_dirs[file.exists(file.path(sub_dirs, 'index.html'))]
  dnames = basename(sub_dirs)
  c(links, sprintf('[%s](/%s/)', menu_name(dnames), dnames))
}

menu_name = function(x) {
  tools::toTitleCase(gsub('[-_]', ' ', x))
}

#' Fuse multiple R Markdown documents to a single output file
#'
#' This is a helper function to [fuse()] `.Rmd` files and convert all their
#' Markdown output to a single output file, which is similar to
#' `bookdown::render_book()`, but one major differences is that all HTML output
#' is written to one file, instead of one HTML file per chapter.
#'
#' If the output format needs to be customized, the settings should be written
#' in the config file `_litedown.yml`, e.g.,
#'
#' ```yaml
#' ---
#' output:
#'   html:
#'     options:
#'       toc:
#'         depth: 4
#'   latex:
#'     meta:
#'       documentclass: "book"
#' ```
#'
#' In addition, you can configure the book via the `book` field, e.g.,
#'
#' ```yaml
#' ---
#' book:
#'   new_session: true
#'   subdir: false
#'   pattern: "[.]R?md$"
#'   chapter_before: "Information before a chapter."
#'   chapter_after: "This chapter was generated from `$input$`."
#' ---
#' ```
#'
#' The option `new_session` specifies whether to render each input file in the
#' current R session or a separate new R session; `chapter_before` and
#' `chapter_after` specify text to be added to the beginning and end of each
#' file, respectively, which accepts some variables (e.g., `$input$` is the
#' current input file path).
#' @inheritParams fuse
#' @param input A directory or a vector of file paths. By default, all
#'   `.Rmd`/`.md` files under the current working directory are used as the
#'   input, except for filenames that start with `.` or `_` (e.g., `_foo.Rmd`),
#'   or `.md` files with the same base names as `.Rmd` files (e.g., `bar.md`
#'   will not be used if `bar.Rmd` exists). For a directory `input`, the file
#'   search will be recursive if `input` ends with a slash (i.e.,
#'   sub-directories will also be searched). If a file named `index.Rmd` or
#'   `index.md` exists, it will always be treated as the first input file. Input
#'   files can also be specified in the config file `_litedown.yml` (in the
#'   `input` field under `book`).
#' @return An output file path or the output content, depending on the `output`
#'   argument.
#' @export
fuse_book = function(input = '.', output = NULL, envir = parent.frame()) {
  # when input is c(dir, file1, file2, ...), we find book files under dir, but
  # only preview file1, file2, ...
  if (dir.exists(input[1])) {
    preview = input[-1]; input = input[1]
  } else preview = NULL

  yaml = NULL
  # search for book files or read from config if input is a dir
  if (length(input) == 1 && dir.exists(input)) {
    yaml = yml_config(input)
    cfg = yaml[['book']]
    input = file.path(input, cfg[['input']]) %|%
      find_input(input, cfg[['subdir']] %||% grepl('/$', input), cfg[['pattern']])
  } else {
    # if input files are provided directly, read config from the dir of first file
    cfg = if (length(input)) {
      yaml = yml_config(dirname(input[1]))
      yaml[['book']]
    }
  }
  if (length(input) == 0) stop('No input was provided or found.')
  input = sub('^[.]/+', '', input)  # clean up the leading ./ in paths

  full = is_output_full(output)
  format = detect_format(output, yaml)
  output = auto_output(input[1], output, format)
  cfg = merge_list(list(
    new_session = FALSE, chapter_before = '', chapter_after = "Source: `$input$`"
  ), cfg)

  # provide a simpler way to configure timing in YAML; only env vars are
  # inherited in new R sessions, so we attach the timing path to R_LITEDOWN_TIME
  if (is.character(p <- cfg$time)) {
    # treat relative path as a path relative to the first input's cache dir
    if (is_rel_path(p))
      p = file.path(paste0(sans_ext(normalize_path(input[1])), '__cache'), p)
    vars = set_envvar(c(R_LITEDOWN_TIME = p))
    on.exit(set_envvar(vars), add = TRUE)
    if (file_exists(p)) {
      # filter out data from input files that do not belong to the book
      d = readRDS(p)
      if (!all(i <- d$source %in% input)) {
        d = d[i, ]; saveRDS(d, p)
      }
    } else dir_create(dirname(p))
  }

  res = lapply(preview %|% input, function(x) {
    out = if (grepl('[.]md$', x)) read_utf8(x) else {
      fmt = paste0('markdown:', format)  # generate intermediate markdown output
      if (cfg$new_session) {
        Rscript_call(fuse, list(x, fmt))
      } else {
        fuse(x, fmt, NULL, envir)
      }
    }
    # remove YAML in the preview mode or for non-index chapters since we only need the body
    if (length(preview) || isFALSE(.env$current_index) || x != input[1])
      out = sans_yaml(out)

    if (format != 'html') return(out)
    # add input filenames to the end for HTML output and wrap each file in a div
    info = function(cls) c(
      sprintf('::: {.chapter-%s .side .side-right}', cls),
      sub_vars(cfg[[sprintf('chapter_%s', cls)]], list(input = I(x))), ':::'
    )
    # for the first input, the fenced Divs should be inserted after YAML
    h = if (length(preview) == 0 && x == input[1]) {
      out = split_lines(out)
      if (length(i <- xfun:::locate_yaml(out)) >= 2) {
        i = seq_len(i[2]); h = out[i]; out = out[-i]
        h
      }
    }
    c(
      h, sprintf('::: {.chapter .body data-source="%s"}', x),
      info('before'), '', out, '', info('after'), ':::'
    )
  })
  tweak_options(format, yaml, list(
    body_class = '', css2 = c(site_css, '@book'), js2 = site_js
  ), toc = length(preview) == 0)
  fuse_output(input[1], output, unlist(res), full)
}

# read the config file _litedown.yml
yml_config = function(d) {
  if (file_exists(cfg <- file.path(d, '_litedown.yml'))) {
    yaml = xfun::taml_file(cfg)
    if (!is.null(yaml2 <- normalize_yaml(yaml))) yaml = yaml2
    yaml
  }
}

site_pattern = '[.][Rq]?md$'

# find input files under a directory
find_input = function(d, deep = grepl('/$', d), pattern = NULL) {
  if (!is.character(pattern)) pattern = site_pattern
  x = list.files(d, pattern, full.names = TRUE, recursive = deep)
  # exclude .* and _* files/dirs
  x = x[!grepl('(^|/)[_.]', gsub('^([.]+/)+', '', x))]
  # exclude readme
  x = x[tolower(basename(sans_ext(x))) != 'readme']
  # for .md files, don't include them if they have .Rmd/.qmd files
  b = sans_ext(x); i = file_ext(x) == 'md'
  x = x[!i | !(b %in% sub('[.][Rq]md$', '', x))]
  x = reorder_input(x)
  x = sub('^[.]/+', '', x)
  x
}

# move index.[Rq]md to the first
reorder_input = function(x) {
  i = is_index(x)
  index = x[i][which.min(nchar(x[i]))]  # take the shortest path
  c(index, setdiff(x, index))
}

is_index = function(x) sans_ext(basename(x)) == 'index'

# temporarily set global metadata and options (inheriting from index.Rmd)
tweak_options = function(format, yaml, meta = NULL, toc = TRUE, options = NULL) {
  nms = paste0('litedown.', format, c('.meta', '.options'))
  defaults = list(
    merge_list(
      .Options[[nms[1]]], meta, yaml_field(yaml, format, 'meta')
    ),
    merge_list(
      .Options[[nms[2]]], options,
      list(toc = toc, number_sections = TRUE, embed_resources = FALSE),
      yaml_field(yaml, format, 'options')
    )
  )
  names(defaults) = nms
  opts = options(defaults)
  exit_call(function() options(opts))
}


================================================
FILE: R/utils.R
================================================
# reset an environment using "objects" in a list
reset_env = function(x = list(), envir) {
  rm(list = ls(envir, all.names = TRUE), envir = envir)
  list2env(x, envir)
}

# counters for elements to be cross-referenced (e.g., fig, tab)
counters = local({
  db = NULL
  list(
    get = function() db,
    inc = function(name) db[[name]] <<- (db[[name]] %||% 0L) + 1L,
    del = function(name) db <<- NULL
  )
})

# use PCRE by default (which seems to handle multibyte chars better)
gregexpr = function(..., perl = TRUE) base::gregexpr(..., perl = perl)
attr = function(...) base::attr(..., exact = TRUE)  # exact attr() please
`%|%` = function(x, y) if (length(x)) x else y
if (getRversion() < '4.4.0') `%||%` = function(x, y) if (is.null(x)) y else x
set_names = function(x, nm) {
  names(x) = nm; x
}

dropNULL = function(x) x[!vapply(x, is.null, logical(1))]

# remove the <p> tag from HTML
sans_p = function(x) gsub('^<p[^>]*>|(</p>)?\n$', '', x)

# remove ugly single quotes, e.g., 'LaTeX' -> LaTeX
sans_sq = function(x) gsub("(^|\\W)'([^']+)'(\\W|$)", '\\1\\2\\3', x)

# remove YAML header (if title exists, convert it to h1)
sans_yaml = function(x) {
  if (length(x) && grepl('^---\\s*?($|\n)', x[1])) {
    res = xfun::yaml_body(split_lines(x))
    x = c(if (is.character(t <- res$yaml$title)) paste('#', t), res$body)
  }
  x
}

split_chunk = function(...) xfun::divide_chunk(..., use_yaml = FALSE)

is_lang = function(x) is.symbol(x) || is.language(x)

uapply = function(..., recursive = TRUE) unlist(lapply(...), recursive = recursive)
.mapply = function(fun, ...) base::.mapply(fun, list(...), NULL)

# convert the system locale to a BCP 47 language tag for use in the HTML lang attribute
locale_lang = function() {
  lc = Sys.getlocale('LC_CTYPE')
  # remove encoding part (e.g., ".UTF-8", ".1252")
  lc = sub('[.][^.]*$', '', lc)
  # extract language (and optional region) from POSIX-style locale (case-insensitive)
  m = regmatches(lc, regexpr('^[a-zA-Z]{2,3}([_-][a-zA-Z]{2,3})?', lc))
  if (length(m) == 0) return('')
  # normalize to BCP 47: language lowercase, region uppercase, hyphen separator
  parts = strsplit(m, '[_-]')[[1]]
  parts[1] = tolower(parts[1])
  if (length(parts) > 1) parts[2] = toupper(parts[2])
  paste(parts, collapse = '-')
}

#' Convert some ASCII strings to HTML entities
#'
#' Transform ASCII strings `(c)` (copyright), `(r)` (registered trademark),
#' `(tm)` (trademark), and fractions `n/m` into *smart* typographic HTML
#' entities.
#' @param text A character vector of the Markdown text.
#' @return A character vector of the transformed text.
#' @keywords internal
smartypants = function(text) {
  text = split_lines(text)
  i = prose_index(text)
  r = '(?<!`)\\((c|r|tm)\\)|(\\d+/\\d+)(?!`)'
  text[i] = match_replace(text[i], r, function(z) {
    y = pants[z]
    i = is.na(y)
    y[i] = z[i]
    y
  })
  text
}

# Represent some fractions with HTML entities
fracs = local({
  n1 = c(
    '1/2', '1/3', '2/3', '1/4', '3/4', '1/5', '2/5', '3/5', '4/5', '1/6', '5/6',
    '1/8', '3/8', '5/8', '7/8'
  )
  n2 = c('1/7', '1/9', '1/10')
  x2 = seq_along(n2) + 8527  # &#8528;, 8529, 8530
  set_names(c(sprintf('&frac%s;', gsub('/', '', n1)), sprintf('&#%d;', x2)), c(n1, n2))
})

pants = c(fracs, c('(c)' = '&copy;', '(r)' = '&reg;', '(tm)' = '&trade;'))

# merge a later list in arguments into a former one by name
merge_list = function(...) {
  dots = list(...)
  res  = dots[[1]]
  for (i in seq_along(dots) - 1L) {
    if (i == 0) next
    x = dots[[i + 1]]
    if (!is.list(x)) next
    res[names(x)] = x
  }
  res
}

CHARS = c(letters, LETTERS, 0:9, '!', ',', '/', ':', ';', '=', '@')

# generate a random string that is not present in provided text
id_string = function(text, lens = c(5:10, 20), times = 20) {
  for (i in lens) {
    for (j in seq_len(times)) {
      id = paste(sample(CHARS, i, replace = TRUE), collapse = '')
      if (length(grep(id, text, fixed = TRUE)) == 0) return(id)
    }
  }
  # failure should be very rare
  stop('Failed to generate a unique ID string. You may try again.')
}

# a shorthand for gregexpr() and regmatches()
match_replace = function(x, r, replace = identity, ...) {
  m = gregexpr(r, x, ...)
  regmatches(x, m) = lapply(regmatches(x, m), function(z) {
    if (length(z)) replace(z) else z
  })
  x
}

# gregexec() + regmatches() to greedy-match all substrings in regex groups
match_all = function(x, r, ...) {
  regmatches(x, base::gregexec(r, x, ...))
}
# for R < 4.1.0
if (!exists('gregexec', baseenv(), inherits = TRUE)) match_all = function(x, r, ...) {
  lapply(match_full(x, r, ...), function(z) {
    if (length(z)) do.call(cbind, match_one(z, r, ...)) else z
  })
}

# regexec() + regmatches() to match the regex once and capture substrings
match_one = function(x, r, ...) regmatches(x, regexec(r, x, ...))

# gregexpr() + regmatches() to match full strings but not substrings in regex groups
match_full = function(x, r, ...) regmatches(x, gregexpr(r, x, ...))

# if `text` is NULL and `input` is a file, read it; otherwise use the `text`
# argument as input
read_input = function(input, text) {
  if (missing(input)) input = NULL
  if (is.null(text)) {
    if (is.null(input)) stop("Either 'input' or 'text' must be provided.")
    text = if (is_file(input)) read_utf8(input) else input
  }
  structure(split_lines(text), input = if (is_file(input)) input)
}

# test if an input is a file path; if shouldn't be treated as file, use I()
is_file = function(x) {
  length(x) == 1 && !inherits(x, 'AsIs') && is.character(x) &&
    (file_ext(x) != '' || suppressWarnings(file_exists(x)))
}

is_output_file = function(x) {
  is.character(x) && !(x %in% names(md_formats) || is_ext(x))
}

is_ext = function(x) grepl('^[.]', x) && sans_ext(x) == ''

# if output has an attribute full = TRUE
is_output_full = function(x) isTRUE(attr(x, 'full'))

# test if input is R code or not (this is based on heuristics and may not be robust)
is_R = function(input, text) {
  if (is_file(input)) grepl('[.][Rrs]$', input) else {
    # if R code, it must not contain ```{, be syntactically valid, and contain
    # at least one expression (i.e., not all comments)
    !length(grep('^\\s*```+\\{', text)) && !try_error(res <- parse_only(text)) && length(res)
  }
}

# make an output filename with the format and input name
auto_output = function(input, output, format = NULL) {
  # change NULL to a filename extension
  if (is.null(output) && !is.null(format)) {
    output = md_formats[format]
    if (is.na(output)) stop(
      "The output format '", format, "' is not supported (must be ",
      xfun::join_words(names(md_formats), and = ' or ', before = "'"), ")."
    )
  }
  # non-character `output` means the output shouldn't be written to a file
  if (is.character(output)) {
    if (startsWith(output, 'markdown:')) output = 'markdown'
    # if `output` is an extension, make a full file path based on input
    if (is_ext(output)) {
      if (is_file(input)) output = with_ext(input, output)
    }
    if (is_file(input)) check_output(input, output)
  }
  output
}

# fall back to input path if output path is not available
output_path = function(input, output) {
  if (is_output_file(output)) output else if (is_file(input)) input
}

# make sure not to overwrite input file inadvertently
check_output = function(input, output) {
  if (file_exists(input) && same_path(input, output))
    stop('The output file path is the same as input: ', input)
  output
}

# substitute a variable in template `x` with its value; the variable may have
# more than one possible name, in which case we try them one by one
sub_var = function(x, name, value, ...) {
  for (i in name) {
    if (any(grepl(i, x, fixed = TRUE))) {
      return(sub(i, one_string(value, ...), x, fixed = TRUE))
    }
  }
  x
}

# unescape HTML code
restore_html = function(x) {
  x = gsub('&quot;', '"', x, fixed = TRUE)
  x = gsub('&amp;', '&', x, fixed = TRUE)
  x = gsub('&lt;', '<', x, fixed = TRUE)
  x = gsub('&gt;', '>', x, fixed = TRUE)
  x
}

#' Add CSS/JS assets to HTML output
#'
#' While CSS/JS assets can be set via the `css`/`js` keys under the `meta` field
#' of the `html` output format in YAML, this function provides another way to
#' add them, which can be called in a code chunk to dynamically add assets.
#' @param feature A character vector of features supported by CSS/JS, e.g.,
#'   `c('article', 'callout')`. See the row names of `litedown:::assets` for all
#'   available features. Each feature will be mapped to CSS/JS.
#' @param css,js Character vectors of CSS/JS assets.
#' @return A vector of `<link>` (CSS) or `<script>` (JS) tags.
#' @export
#' @examples
#' litedown:::assets[, -1]
#' # add features
#' litedown::vest(c('copy-button', 'tabsets'))
#' # add css/js directly
#' litedown::vest(css = '@tabsets', js = c('@tabsets', '@fold-details'))
vest = function(feature = NULL, css = NULL, js = NULL) {
  if (length(feature)) {
    a = assets[feature, , drop = FALSE]
    css = c(a[, 'css'], css); js = c(a[, 'js'], js)
  }
  new_asis(c(resolve_files(css, 'css'), resolve_files(js, 'js')), raw = TRUE)
}

assets = t(data.frame(
  article = c('side notes, floats, and full-width elements for articles', '@article', '@sidenotes, @appendix'),
  book = c('cover and chapter pages for books', '@book', NA),
  callout = c('frames with legends', '@callout', '@callout'),
  'center-img' = c('center images in paragraphs', NA, '@center-img'),
  'chapter-toc' = c('add TOC to each chapter', NA, '@chapter-toc'),
  'copy-button' = c('copy buttons', '@copy-button', '@copy-button'),
  default = c('default CSS', '@default', NA),
  'external-link' = c('open external links in new windows', NA, '@external-link'),
  'fold-details' = c('fold elements (e.g., code blocks)', NA, '@fold-details'),
  'heading-anchor' = c('add anchor links to headings', '@heading-anchor', '@heading-anchor'),
  'key-buttons' = c('style keyboard shortcuts', '@key-buttons', '@key-buttons'),
  pages = c('paginate HTML for printing', '@pages', '@pages'),
  'right-quote' = c('right-align quote footers', NA, '@right-quote'),
  snap = c('snap slides', '@snap', '@snap'),
  tabsets = c('create tabsets from bullet lists or sections', '@tabsets', '@tabsets'),
  'toc-highlight' = c('highlight TOC items on scroll', NA, '@toc-highlight'),
  row.names = c('description', 'css', 'js'), check.names = FALSE
))

# an HTML form for creating new files in roam()
feature_form = function(path) {
  nms = rownames(assets)
  files = list.files(if (dir.exists(path)) path else dirname(path), full.names = TRUE)
  files = basename(files[file_exists(files)])
  one_string(c(
    '<h3>New File</h3>',
    '<p><label>Filename: <input type="text" id="filename-input" list="file-list" placeholder="enter a new filename (not in the list)" /></label></p>',
    if (length(files)) c(
      '<datalist id="file-list">',
      sprintf('<option value="&#10060; %s">', html_escape(files, TRUE)),
      '</datalist>'
    ),
    '<p><b>Select HTML features</b></p>', '<p style="columns:20em;">',
    sprintf(
      '<label><input name="%s" type="checkbox" /> <a href="https://yihui.org/litedown/#sec:%s" target="_blank"><code>%s</code></a>: %s</label>',
      nms, nms, nms, html_escape(assets[, 'description'])
    ), '</p>'
  ))
}

na_omit = function(x) x[!is.na(x)]

# accumulate variable values
acc_var = local({
  db = list()
  function(...) {
    v = list(...)
    if (is.null(nms <- names(v))) {
      v = unlist(v)
      switch(length(v) + 1, db <<- list(), db[[v]], db[v])
    } else {
      for (i in nms) db[[i]] <<- c(db[[i]], v[[i]])
    }
  }
})

# set js/css variables according to the js_math option
set_math = function(o, is_katex) {
  if (is_katex) o$js = c(o$js, 'dist/contrib/auto-render.min.js')
  js = js_combine(sprintf('npm/%s%s/%s', o$package, o$version, o$js))
  js = if (is_katex) c(js, '@render-katex') else c('@mathjax-config', js)
  css = sprintf('@npm/%s%s/%s', o$package, o$version, o$css)
  acc_var(js = js, css = css)
}

# use jsdelivr's combine feature
js_combine = function(...) {
  if (length(x <- c(...))) paste0('@', paste(x, collapse = ','))
}

js_options = function(x, default) {
  d = js_default(x, default)
  x = if (is.list(x)) merge_list(d, x) else d
  if (length(x) == 0) return()
  if (x$version != '') x$version = sub('^@?', '@', x$version)
  x
}

js_default = function(x, default) {
  if (is.list(x)) x = x$package
  if (is.null(x) || isTRUE(x)) x = default
  if (is.character(x)) merge_list(js_libs[[x]], list(package = x))
}

js_libs = list(
  highlight = list(
    version = '11.7.0', style = 'xcode', js = 'build/highlight.min.js'
  ),
  katex = list(version = '', css = 'dist/katex.min.css', js = 'dist/katex.min.js'),
  mathjax = list(version = '3', js = 'es5/tex-mml-chtml.js'),
  prism = list(
    version = '1.29.0', js = 'components/prism-core.min.js'
  )
)

# set js/css variables according to the js_highlight option
set_highlight = function(options, html) {
  # if the class .line-numbers is present, add js/css for line numbers
  if (any(grepl('<code class="[^"]*line-numbers', html)))
    acc_var(js = '@code-line-numbers', css = '@code-line-numbers')

  r = '(?<=<code class="language-)([^"]+)(?=")'
  if (!any(grepl(r, html, perl = TRUE))) return()
  if (!length(o <- js_options(options[['js_highlight']], 'prism'))) return()

  p = o$package
  # return jsdelivr subpaths
  get_path = function(path) {
    t = switch(
      p, highlight = 'gh/highlightjs/cdn-release%s/%s', prism = 'npm/prismjs%s/%s'
    )
    sprintf(t, o$version, path)
  }
  # add the `prism-` prefix if necessary
  normalize_prism = function(x) {
    if (length(x) == 1 && x == 'prism') x else sub('^(prism-)?', 'prism-', x)
  }

  # if resources need to be embedded, we need to work harder to figure out which
  # js files to embed (this is quite tricky and may not be robust)
  embed = ('https' %in% options[['embed_resources']]) || is.character(options[['offline']])

  # style -> css
  css = c(if (is.null(s <- o$style)) {
    if (p == 'prism') '@prism-xcode'  # use prism-xcode.css in the lite.js repo
  } else if (is.character(s)) js_combine(get_path(switch(
    p,
    highlight = sprintf('build/styles/%s.min.css', s),
    prism = sprintf('themes/%s.min.css', normalize_prism(s))
  ))))

  # languages -> js
  get_lang = function(x) switch(
    p,
    highlight = sprintf('build/languages/%s.min.js', x),
    prism = sprintf('components/%s.min.js', normalize_prism(x))
  )
  autoloader = 'plugins/autoloader/prism-autoloader.min.js'
  o$js = c(o$js, if (!is.null(l <- o$languages)) get_lang(l) else {
    # detect <code> languages in html and load necessary language components
    lang = unlist(match_full(html, r))
    lang = gsub(' .*', '', lang)  # only use the first class name
    lang = setdiff(lang, 'plain')  # exclude known non-existent names
    f = switch(p, highlight = js_libs[[c(p, 'js')]], prism = autoloader)
    if (!embed && p == 'prism') f else {
      get_lang(lang_files(p, get_path(f), lang))
    }
  })
  js = get_path(o$js)
  if (p == 'highlight') js = c(js, 'npm/@xiee/utils/js/load-highlight.js')
  # do not combine js when they are automatically detected (this will make
  # embedding faster because each js is a separate URL that has been downloaded)
  js = if (is.null(l)) paste0('@', js) else js_combine(js)

  acc_var(js = js, css = css)
}

# figure out which language support files are needed for highlight.js/prism.js
lang_files = function(package, path, langs) {
  u = jsdelivr(path, '')
  x = download_cache$get(u, 'text')
  x = one_string(x)

  warn = function(l1, l2, url) warning(
    "Unable to recognize code blocks with language(s): ", comma_list(l2),
    ". They will not be syntax highlighted by ", package, ".js. If you can find ",
    "the right language files at ", url, ", you may mannually specify their names ",
    "in the 'languages' field of the 'js_highlight' option.",
    if (length(l1)) c(" Also remember to add ", comma_list(l1))
  )

  if (package == 'highlight') {
    # first figure out all languages bundles in highlight.js (starting with grmr_)
    x = unlist(strsplit(x, ',\n?grmr_'))
    r = '^([[:alnum:]_-]+):.+'
    x = grep(r, x, value = TRUE)
    l = gsub(r, '\\1', x)
    # then find their aliases
    a = lapply(match_full(x, '(?<=aliases:\\[)[^]]+(?=\\])'), function(z) {
      z = unlist(strsplit(z, '[",]'))
      z[!is_blank(z)]
    })
    l = c(l, unlist(a))  # all possible languages that can be highlighted
    l = setdiff(langs, l)  # languages not supported by default
    if (length(l) == 0) return()
    # check if language files exist on CDN
    d = paste0(dirname(u), '/languages/')
    l1 = uapply(l, function(z) {
      if (downloadable(sprintf('%s%s.min.js', d, z))) z
    })
    l2 = setdiff(l, l1)
    if (length(l2)) warn(l1, l2, d)
    l1
  } else {
    # dependencies and aliases (the arrays should be more than 1000 characters)
    x = unlist(match_full(x, '(?<=\\{)([[:alnum:]_-]+:\\[?"[^}]{1000,})(?=\\})'))
    if (length(x) < 2) {
      warning(
        "Unable to process Prism's autoloader plugin (", u, ") to figure out ",
        "language components automatically. Please report this message to ",
        packageDescription('litedown')$BugReports, "."
      )
      return()
    }
    x = x[1:2]
    x = lapply(match_full(x, '([[:alnum:]_-]+):(\\["[^]]+\\]|"[^"]+")'), function(z) {
      z = gsub('[]["]', '', z)
      uapply(strsplit(z, '[:,]'), function(y) {
        set_names(list(y[-1]), y[1])
      }, recursive = FALSE)
    })
    # x1 is dependencies; x2 is aliases
    x1 = x[[1]]; x2 = unlist(x[[2]])
    # normalize aliases to canonical names
    i = langs %in% names(x2)
    langs[i] = x2[langs[i]]
    # resolve dependencies via recursion
    resolve_deps = function(lang) {
      deps = x1[[lang]]
      c(lapply(deps, resolve_deps), lang)
    }
    # all languages required for this page
    l1 = unique(uapply(langs, resolve_deps))
    # languages that are officially supported
    l2 = c(names(x1), unlist(x1), x2)
    # for unknown languages, check if they exist on CDN
    d = sub('/plugins/.+', '/components/', u)
    l3 = uapply(setdiff(l1, l2), function(z) {
      if (!downloadable(sprintf('%sprism-%s.min.js', d, z))) z
    })
    l4 = setdiff(l1, l3)
    if (length(l3)) warn(l4, l3, d)
    l4
  }
}

# test if a URL can be downloaded
downloadable = function(u, type = 'text') {
  !try_error(download_cache$get(u, type))
}

# quote a vector and combine by commas
comma_list = function(x) paste0('"', x, '"', collapse = ', ')

# get an option specific to an output format
get_option = function(name, format, ...) {
  getOption(sprintf('litedown.%s.%s', format, name), ...)
}

# if a string is a file path found under test dirs, read the file; then concatenate elements by \n
one_string = function(x, by = '\n', test = NULL) {
  if (length(test) && is_file(x)) {
    p = if (is_abs_path(x)) x else xfun::existing_files(file.path(c(test, '.'), x))
    if (length(p)) x = read_utf8(p[1]) else stop("The file '", x, "' is not found")
  }
  paste(x, collapse = by)
}

# find @citation and resolve references
add_citation = function(x, bib, format = 'html') {
  if (!format %in% c('html', 'latex')) return(x)
  bib = do.call(c, lapply(bib, rbibutils::readBib, direct = TRUE, texChars = 'convert'))
  if (length(bib) == 0) return(x)
  cited = NULL
  is_html = format == 'html'
  r = if (is_html) '(?<!<code>)(\\[@[-;@ [:alnum:]]+\\]|@[-[:alnum:]]+)' else
    '(?<!\\{)(\\{\\[\\}@[-;@ [:alnum:]]+\\{\\]\\}|@[-[:alnum:]]+)'
  # [@key] for citep, and @key for citet
  x = match_replace(x, r, function(z) {
    z2 = uapply(strsplit(z, '[;@ {}]+'), function(keys) {
      bracket = any(grepl('^\\[', keys))
      if (bracket) keys = gsub('^\\[|\\]$', '', keys)
      keys = keys[keys != '']
      if (length(keys) == 0 || !all(keys %in% names(bib))) return(NA)
      if (is_html) {
        cited <<- c(cited, keys)
        cite_html(keys, bib, bracket)
      } else {
        sprintf('\\cite%s{%s}', if (bracket) 'p' else 't', one_string(keys, ','))
      }
    })
    ifelse(is.na(z2), z, z2)
  })
  if (is_html) {
    b = bib_html(bib, cited)
    d = '<div id="refs">'
    if (any(grepl(d, x, fixed = TRUE))) {
      x = sub(d, paste0(d, one_string(b)), x, fixed = TRUE)
    } else {
      x = one_string(c(x, d, b, '</div>'))
    }
  }
  x
}

# fall back to given name if family name is empty
author_name = function(x) {
  a = paste(x$family %|% x$given, collapse = ' ')
  a = gsub('\\{\\\\(.)}', '\\1', a)  # un-escape special latex chars
  html_escape(a)
}

# mimic natbib's author-year citation style for HTML output
cite_html = function(keys, bib, bracket = TRUE) {
  x = NULL; N = length(keys)
  for (i in seq_len(N)) {
    key = keys[i]; b = bib[[key]]; a = b$author; n = length(a)
    z = paste0(c(
      author_name(a[[1]]),
      if (n == 2) c('<span class="ref-and"></span>', author_name(a[[2]])),
      if (n > 2) '<span class="ref-et-al"></span>', ' ',
      if (bracket) b$year else
        c('<span class="ref-paren-open ref-paren-close">', b$year, '</span>')
    ), collapse = '')
    cls = if (bracket) c(
      if (i == 1) 'ref-paren-open', if (i == N) 'ref-paren-close',
      if (i < N) 'ref-semicolon'
    )
    z = cite_link(key, z, one_string(c('', cls), ' '))
    x = c(x, z)
  }
  one_string(x, '')
}

cite_link = function(key, text, class = '') {
  sprintf('<a href="#ref-%s" class="citation%s">%s</a>', key, class, text)
}

# html bibliography
bib_html = function(bib, keys) {
  bib = sort(bib[unique(keys)])
  keys = uapply(bib, function(x) attr(unclass(x)[[1]], 'key'))
  res = format(bib, 'html')
  paste0('<p id="ref-', keys, '"', sub('^<p', '', res))
}

# add meta variables bib-preamble and bib-end for LaTeX output
bib_meta = function(meta, bib, package) {
  bib = one_string(bib, ',')
  if (is.null(meta[['bib-preamble']])) meta[['bib-preamble']] = switch(
    package,
    none = '\\bibliographystyle{apalike}\\let\\citep\\cite\\let\\citet\\cite',
    natbib = '\\usepackage{natbib}\\bibliographystyle{abbrvnat}',
    biblatex = paste0(
      '\\usepackage[style=authoryear]{biblatex}\\addbibresource{', bib,
      '}\\let\\citep\\parencite\\let\\citet\\cite'
    )
  )
  if (is.null(meta[['bib-end']])) meta[['bib-end']] = if (package == 'biblatex')
    '\\printbibliography' else paste0('\\bibliography{', bib, '}')
  meta
}

# find headings and build a table of contents as an unordered list
build_toc = function(html, n = 3) {
  if (n <= 0) return()
  if (n > 6) n = 6
  r = sprintf('<(h[1-%d])( id="[^"]+")?[^>]*>(.+?)</\\1>', n)
  items = unlist(match_full(html, r))
  # ignore headings with class="unlisted"
  items = items[!has_class(items, 'unlisted')]
  if (length(items) <= 1) return()  # require at least 2 items in TOC
  x = gsub(r, '<toc\\2>\\3</toc>', items)  # use a tag <toc> to protect heading text
  x = gsub('<a[^>]+>|</a>', '', x)  # clean up <a>
  h = as.integer(gsub('^h', '', gsub(r, '\\1', items)))  # heading level
  s = strrep('  ', seq_len(n) - 1)  # indent
  x = paste0(s[h], '- ', x)  # create an unordered list
  x = commonmark::markdown_html(x)
  # add anchors on TOC items
  x = gsub('<toc id="([^"]+)">(.+?)</toc>', '<a href="#\\1">\\2</a>', x)
  x = gsub('</?toc>', '', x)
  # add class 'numbered' to the first <ul> if any heading is numbered
  if (length(grep('<span class="section-number[^"]*">', x)))
    x = sub('<ul>', '<ul class="numbered">', x)
  paste0('<div id="TOC">\n', x, '</div>')
}

# add TOC to the html body
add_toc = function(html, options) {
  o = options[['toc']]
  if (is.null(o) || isFALSE(o)) return(html)
  if (isTRUE(o)) o = list()
  if (!is.numeric(o$depth)) o$depth = 3
  one_string(c(build_toc(html, o$depth), html))
}

sec_levels = c('subsubsection', 'subsection', 'section', 'chapter', 'part')
# raise section levels: redefine section to chapter or part, and so on
redefine_level = function(x, top) {
  n = switch(top, chapter = 1, part = 2, 0)
  if (n == 0) return(x)
  for (i in 3:1) {
    x = gsub(sprintf('(^|\n)\\\\%s', sec_levels[i]), sprintf('\\\\%s', sec_levels[i + n]), x)
  }
  x
}

# move image attributes like `![](){#id .class width="20%"}`, heading attributes
# `# foo {#id .class}`, and fenced Div's `::: {#id .class}` into HTML tags and
# LaTeX commands
move_attrs = function(x, format = 'html') {
  if (format == 'html') {
    # images
    x = convert_attrs(x, '(<img src="[^>]+ )/>\\{([^}]+)\\}', '\\2', function(r, z, z2) {
      z1 = sub(r, '\\1', z)
      paste0(z1, z2, ' />')
    })
    # headings
    x = convert_attrs(x, '(<h[1-6])(>.+?) \\{([^}]+)\\}(</h[1-6]>)', '\\3', function(r, z, z3) {
      z1 = sub(r, '\\1 ', z)
      z24 = sub(r, '\\2\\4', z)
      paste0(z1, z3, z24)
    })
    # links
    x = convert_attrs(x, '(<a[^>]+)(>(?s).*?</a>)(\\{([^}]+)\\})?', '\\4', function(r, z, z3) {
      z1 = sub(r, '\\1', z, perl = TRUE)
      z2 = sub(r, '\\2', z, perl = TRUE)
      paste0(z1, ifelse(z3 == '', '', ' '), z3, z2)
    })
    # fenced Div's
    x = convert_attrs(x, '<p>:::+ \\{(.*?)\\}</p>', '\\1', function(r, z, z1) {
      # add attributes to the div but remove the data-latex attribute
      z1 = str_trim(gsub('(^| )data-latex="[^"]*"( |$)', ' ', z1))
      sprintf('<div%s%s>', ifelse(z1 == '', '', ' '), z1)
    })
    x = gsub('<p>:::+</p>', '</div>', x)
  } else if (format == 'latex') {
    # only support image width
    x = convert_attrs(x, '(\\\\includegraphics)(\\{[^}]+\\})\\\\\\{([^}]+)\\\\\\}', '\\3', function(r, z, z3) {
      r2 = '(^|.* )width="([^"]+)"( .*|$)'
      j = grepl(r2, z3)
      w = gsub(r2, '\\2', z3[j])
      w = gsub('\\\\', '\\', w, fixed = TRUE)
      k = grep('%$', w)
      w[k] = paste0(as.numeric(sub('%$', '', w[k])) / 100, '\\linewidth')
      z3[j] = paste0('[width=', w, ']')
      z3[!j] = ''
      z1 = sub(r, '\\1', z)
      z2 = sub(r, '\\2', z)
      paste0(z1, z3, z2)
    }, format)
    # discard most attributes for headings
    r = sprintf('(\\\\(%s)\\{.+?) \\\\\\{([^}]+)\\\\\\}(\\})', paste(sec_levels, collapse = '|'))
    x = convert_attrs(x, r, '\\3', function(r, z, z3) {
      z = gsub(r, '\\1\\4', z)
      k = has_class(z3, 'unnumbered')
      z[k] = sub('{', '*{', z[k], fixed = TRUE)
      k = has_class(z3, 'appendix')
      z[k] = '\\appendix'
      r2 = '(^|.* )id="([^"]+)".*'
      k = grepl(r2, z3)
      id = gsub(r2, '\\2', z3[k])
      z[k] = paste0(z[k], '\\label{', id, '}')
      z
    }, format)
    # fenced Div's: first class name is the environment name; options from data-latex
    r = '\n\\\\begin\\{verbatim\\}\n(:::+)( \\{([^\n]+?)\\})? \\1\n\\\\end\\{verbatim\\}\n'
    x = convert_attrs(x, r, '\\3', function(r, z, z3) {
      r1 = '(^|.*? )class="([^" ]+)[" ].*'
      r2 = ' data-latex="([^"]*)".*$'
      r3 = paste0(r1, '?', r2)
      i3 = grepl(r3, z3)
      z4 = ifelse(i3, gsub(r3, '{\\2}\\3', z3), ifelse(z3 == '', '', '{@}'))
      cls = gsub(r1, '\\2', z3)
      # fig/tab environments don't need the data-latex attribute
      i4 = !i3 & cls %in% c('figure', 'caption', 'table')
      z4[i4] = sprintf('{%s}', cls[i4])
      z3 = latex_envir(gsub('\\\\', '\\', z4, fixed = TRUE))
      z3[z3 %in% c('\\begin{@}', '\\end{@}')] = ''
      i = grep('^\\\\begin', z3)
      z3[i] = paste0('\n', z3[i])
      i = grep('^\\\\end', z3)
      z3[i] = paste0(z3[i], '\n')
      # put fig/tab captions in \caption{}
      z3 = gsub('\\begin{caption}', '\\caption{', z3, fixed = TRUE)
      z3 = gsub('\\end{caption}', '}', z3, fixed = TRUE)
      z3
    }, format)
    # remove table env generated from commonmark and use those from fenced Divs
    x = gsub('\\\\begin\\{table\\}\n(?=\\\\begin\\{tabular\\})', '', x, perl = TRUE)
    x = gsub('(?<=\\\\end\\{tabular\\}\n)\\\\end\\{table}', '', x, perl = TRUE)
  } else {
    # TODO: remove attributes for other formats
  }
  x
}

convert_attrs = function(x, r, s, f, format = 'html', f2 = identity) {
  r2 = '(?<=^| )[.#]([-:[:alnum:]]+)(?= |$)'  # should we allow other chars in ID/class?
  match_replace(x, r, function(y) {
    z = sub(r, s, y, perl = TRUE)
    if (format == 'html') {
      z = gsub('[\u201c\u201d]', '"', z)
    } else {
      z = gsub('=``', '="', z, fixed = TRUE)
      z = gsub("''( |\\\\})", '"\\1', z)
      z = gsub('\\\\([#%])', '\\1', z)
    }
    z2 = f2(z)
    # {-} is a shorthand of {.unnumbered}
    z2[z2 == '-'] = '.unnumbered'
    # convert #id to id="" and .class to class=""
    z2 = match_replace(z2, r2, function(a) {
      i = grep('^[.]', a)
      if ((n <- length(i))) {
        # merge multiple classes into one class attribute
        a[i] = sub('^[.]', '', a[i])
        a[i] = c(sprintf('class="%s"', paste(a[i], collapse = ' ')), rep('', n - 1))
        a = c(a[i], a[-i])
      }
      if (length(i <- grep('^#', a))) {
        a[i] = gsub(r2, 'id="\\1"', a[i], perl = TRUE)
        a = c(a[i], a[-i])  # make sure id is the first attribute
      }
      a
    })
    # remove spaces after class="..." (caused by merging multiple classes)
    z2 = sub('(^| )(class="[^"]+")  +', '\\1\\2 ', z2)
    f(r, y, str_trim(z2))
  })
}

str_trim = function(x) gsub('^\\s+|\\s+$', '', x)
# trim blank lines from both ends
trim_blank = function(x) gsub('^(\\s*\n)+|\n\\s*$', '', x)

# {A}, '', {B}, {C}, '', '' -> \begin{A}\end{A}\begin{B}\begin{C}\end{C}\end{B}
latex_envir = function(x, env = NULL) {
  n = length(x)
  if (n == 0) return()
  x1 = x[1]
  env2 = tail(env, 1)  # the most recent env is in the end
  env = if (x1 == '') head(env, -1) else c(env, sub('^(\\{[^}]+}).*$', '\\1', x1))
  c(if (x1 == '') paste0('\\end', env2) else paste0('\\begin', x1), latex_envir(x[-1], env))
}

# fix footnotes for LaTeX output: convert `\footnotemark[1] \footnotetext[1]{*}`
# to `\footnote{*}` (see r-lib/commonmark#32)
fix_footnotes = function(x) {
  f1 = f2 = NULL
  r = '\n\\\\footnotetext\\[(.+?)]\\{(.+?)\n\n}\n'
  x = match_replace(x, r, function(z) {
    f1 <<- c(f1, sub(r, '\\1', z))
    f2 <<- c(f2, sub(r, '\\2', z))
    ''
  }, perl = FALSE)
  f1 = sprintf('\\footnotemark[%s]', f1)
  f2 = sprintf('\\footnote{%s}', f2)
  for (i in seq_along(f1)) {
    x = sub(f1[i], f2[i], x, fixed = TRUE)
  }
  x
}

# add auto identifiers to headings
auto_identifier = function(x) {
  r = '<(h[1-6])([^>]*)>(.+?)</\\1>'
  match_replace(x, r, function(z) {
    z1 = sub(r, '\\1', z)  # tag
    z2 = sub(r, '\\2', z)  # attrs
    z3 = sub(r, '\\3', z)  # content
    i = !grepl(' id="[^"]*"', z2)  # skip headings that already have IDs
    p = ifelse(z1 == 'h1', 'chp:', 'sec:')  # h1 is chapter; h2+ are sections
    id = unique_id(paste0(p[i], alnum_id(z3[i])), 'section')
    z[i] = sprintf('<%s id="%s"%s>%s</%s>', z1[i], id, z2[i], z3[i], z1[i])
    z
  })
}

# add a number suffix to an id if it is duplicated
unique_id = function(x, empty) {
  x[x == ''] = empty
  i = duplicated(x)
  for (d in unique(x[i])) {
    k = x == d
    x[k] = paste0(x[k], '_', seq_len(sum(k)))
  }
  x
}

# test if a class name exists in attributes
has_class = function(x, class) {
  grepl(sprintf('(^| )class="([^"]+ )?%s( [^"]+)?"', class), x)
}

# number sections in HTML output
number_sections = function(x) {
  h = sub('</h([1-6])>', '\\1', unlist(match_full(x, '</h[1-6]>')))
  if (length(h) == 0) return(x)  # no headings
  h = min(as.integer(h))  # highest level of headings
  r = '<h([1-6])([^>]*)>(?!<span class="section-number)'
  n = rep(0, 6)  # counters for all levels of headings
  # when previewing a book chapter, set the start number if possible
  is_appendix = FALSE  # the start "number" for appendix is A-Z
  if (length(f <- .env$current_file)) {
    if (length(n_start <- grep_sub('^([0-9]+|[A-Z])[-_].+', '\\1', basename(f))))
      if (!(is_appendix <- grepl('^[A-Z]$', n_start))) n[1] = as.integer(n_start) - 1
  }
  k0 = 6  # level of last unnumbered heading
  match_replace(x, r, function(z) {
    z1 = as.integer(sub(r, '\\1', z, perl = TRUE))  # heading levels
    z2 = sub(r, '\\2', z, perl = TRUE)  # heading attributes
    num_sections = identity  # generate appendix numbers
    for (i in seq_along(z)) {
      k = z1[i]
      if (k < 6) n[(k + 1):6] <<- 0
      # skip unnumbered sections
      if (has_class(z2[i], 'unnumbered')) {
        k0 <<- k; next
      } else {
        # don't number headings with level lower than last unnumbered heading
        if (k > k0) next else k0 <<- 6
      }
      if (has_class(z2[i], 'appendix')) {
        if (k != h) stop(
          "The 'appendix' attribute must be on the top-level heading (",
          strrep('#', h), ').'
        )
        num_sections = local({
          a = n[k]  # an offset (highest top-level heading number before appendix)
          # number headings with A-Z or roman numerals
          num = if (sum(z1[i:length(z)] == h) - 1 > 26) as.roman else {
            function(i) LETTERS[i]
          }
          function(s) {
            if (s[1] <= a) stop(
              'An appendix section must start with the top-level heading (',
              strrep('#', h), ').'
            )
            s[1] = num(s[1] - a)
            s
          }
        })
        next
      }
      n[k] <<- n[k] + 1
      # remove leading 0's
      s = if (h > 1) n[-(1:(h - 1))] else n
      if (is_appendix) s[1] = n_start else s = num_sections(s)
      s = paste(s, collapse = '.')
      s = gsub('([.]0)+$', '', s)  # remove trailing 0's
      # if section number doesn't contain '.', assign a class 'main-number' to the number
      z[i] = paste0(z[i], sprintf(
        '<span class="section-number%s">%s</span> ',
        ifelse(grepl('[.]', s), '', ' main-number'), s
      ))
    }
    z
  })
}

# number elements such as headings and figures, etc and resolve cross-references
number_refs = function(x, r, katex = TRUE) {
  if (length(x) == 0) return(x)
  db = list()  # element numbers

  # first, find numbered section headings
  r2 = '<h[1-6][^>]*? id="([^"]+)"[^>]*><span class="section-number[^"]*">([A-Z0-9.]+)</span>'
  m = match_all(x, r2)[[1]]
  if (length(m)) {
    ids = m[2, ]
    db = as.list(set_names(m[3, ], ids))
  }

  # retrieve refs from all chapters for fuse_book()
  if (is.character(b <- .env$current_book)) db = merge_list(.env$refs[[b]], db)

  # then find and number other elements
  r2 = sprintf('<a href="#@%s"> ?</a>', r)
  db2 = list()
  x = match_replace(x, r2, function(z) {
    type = sub(r2, '\\2', z)
    id = sub(r2, '\\1', z)
    ids = split(id, type)
    db2 <<- unlist(unname(lapply(ids, function(id) set_names(seq_along(id), id))))
    sprintf('<span class="ref-number-%s">%d</span>', type, db2[id])
  })
  db = unlist(if (is.character(b)) merge_list(db, as.list(db2)) else c(db, db2))
  ids = names(db)
  if (any(i <- duplicated(ids))) warning('Duplicated IDs: ', one_string(ids[i], ', '))

  # save refs db for fuse_book()
  if (is.character(b)) .env$refs[[b]] = db

  # finally, resolve cross-references
  r2 = sprintf('<a href="#(%s)">@\\1</a>', r)
  match_replace(x, r2, function(z) {
    type = sub(r2, '\\3', z)
    id = sub(r2, '\\2', z)
    # equation numbers will be resolved in JS later
    i1 = grepl('^eq[-:]', id)
    z[i1] = sprintf('\\ref{%s}', id[i1])
    i2 = grepl('^eqn[-:]', id)
    z[i2] = sprintf('\\eqref{%s}', sub('eqn', 'eq', id[i2]))
    i3 = i1 | i2
    # KaTeX requires references to be in math, e.g., \(\ref{ID}\)
    if (katex) z[i3] = paste0('\\(', z[i3], '\\)')
    i = id %in% ids
    # for backward compatibility, if fig-id is not found, also look for fig:id
    if (any(i4 <- !i & !i3)) {
      id2 = sub('-', ':', id)
      j = i4 & (id2 %in% ids)
      id[j] = id2[j]; i[j] = TRUE; i4[j] = FALSE
    }
    if (any(i)) z[i] = sprintf(
      '<a class="cross-ref-%s" href="#%s">%s</a>', type[i], id[i], db[id[i]]
    )
    if (any(i4)) warning('Reference key(s) not found: ', one_string(id[i4], ', '))
    z
  })
}

# add a special anchor [](#@id) to text, to be used to resolved cross-references
add_ref = function(id, type, x = NULL) {
  c(sprintf('[](#@%s:%s)', type, id), x)
}

# make cross-refs for LaTeX output, to be resolved by a LaTeX engine
latex_refs = function(x, r, clever = FALSE) {
  ar = paste0('@', r)
  r0 = function(a, b) sprintf('\\\\protect\\\\hyperlink\\{%s\\}\\{%s\\}', a, b)
  r1 = r0(ar, '\\s*')  # \label{}
  r2 = r0(r, ar)  # \ref{}
  x = gsub(r1, '\\\\label{\\1}', x)
  x = gsub(r2, sprintf('\\\\%sref{\\1}', if (clever) 'c' else ''), x)
  x
}

embed_resources = function(x, options) {
  if (length(x) == 0) return(x)
  r = '\n<link[^>]*? rel="stylesheet" [^>]*>|\n<script[^>]*? src="[^"]+"[^>]*>\\s*</script>'
  x = match_replace(x, r, function(z) {
    # de-dup assets (e.g., when vest() is called multiple times on the same asset)
    z[duplicated(z)] = ''
    z
  })

  embed = c('https', 'local') %in% options[['embed_resources']]
  offline = options[['offline']]
  if (!any(embed, is.character(offline))) return(x)
  clean = options[['embed_cleanup']]

  # find images in <img> and (for slides only) comments
  rs = c(
    '(<img src=")([^"]+)("[^>]*?/>)',
    '(<!--#[^>]*? style="background-image: url\\("?)([^"]+?)("?\\);)'
  )
  for (r in rs) x = match_replace(x, r, function(z) {
    z1 = sub(r, '\\1', z)
    z2 = sub(r, '\\2', z)
    z3 = sub(r, '\\3', z)
    # skip images already base64 encoded
    for (i in grep('^data:.+;base64,.+', z2, invert = TRUE)) {
      is_svg = grepl('[.]svg$', f <- z2[i]) && grepl('^<img', z1[i])
      a = if (is_svg) str_trim(gsub('^"|/>$', '', z3[i])) else ''
      f = download_url(f, offline)
      if (is_https(f)) {
        if (embed[1]) z2[i] = if (!is_svg) download_cache$get(f, 'base64') else {
          download_cache$get(f, 'text', function(xml) process_svg(xml, a))
        }
      } else if (embed[2]) {
        if (file_exists(f <- URLdecode(f))) {
          z2[i] = if (is_svg) process_svg(read_utf8(f), a) else base64_uri(f)
          if (clean && normalize_path(f) %in% .env$plot_files) file.remove(f)
        } else {
          warning("File '", f, "' not found (hence cannot be embedded).")
        }
      }
    }
    ifelse(grepl('<svg', z2), z2, paste0(z1, z2, z3))
  })

  # CSS and JS
  r = paste0(
    '<link[^>]*? rel="stylesheet" href="([^"]+)"[^>]*>|',
    '<script([^>]*?) src="([^"]+)"([^>]*)>\\s*</script>'
  )
  x2 = NULL  # js to be appended before </body>
  x = match_replace(x, r, function(z) {
    z1 = sub(r, '\\1', z)  # css
    z2 = sub(r, '\\3', z)  # js
    js = z2 != ''
    z3 = paste0(z1, z2)
    at = sub(r, '\\2\\4', z)  # attributes for js
    # skip resources already base64 encoded
    i1 = !grepl('^data:.+;base64,.+', z3)
    z3[i1] = gen_tags(
      z3[i1], ifelse(js[i1], 'js', 'css'), embed[1], embed[2], offline, at
    )
    # js with the defer attribute and has been embedded (i.e., no src attribute)
    i = grepl(' defer($| )', at) & !grepl('^<script([^>]*?) src="', z3) & i1
    x2 <<- c(x2, z3[i])
    z3[i] = ''
    z3
  })
  # move deferred scripts to the end of <body>
  if (length(x2)) {
    x = if (length(grep('</body>', x)) != 1) {
      one_string(c(x, x2))
    } else {
      sub('</body>', one_string(c(x2, '</body>')), x, fixed = TRUE)
    }
  }
  x
}

# remove the xml/doctype declaration in svg, and add attributes
process_svg = function(x, attr) {
  while (length(x) > 0 && !grepl('^\\s*<svg .+', x[1])) x = x[-1]
  if (length(x) > 0 && !attr %in% c('', 'alt=""')) {
    x[1] = if (grepl(r <- '\\s*>\\s*$', x[1])) {
      paste0(gsub(r, ' ', x[1]), attr, '>')
    } else {
      paste(x[1], attr)
    }
  }
  one_string(x)
}

normalize_options = function(x, format = 'html') {
  g = get_option('options', format)
  x = option2list(x)
  n = names(x)
  # default options
  d = option2list(markdown_options())
  g = option2list(g)
  d[names(g)] = g  # merge global options() into default options
  d[n] = x  # then merge user-provided options
  if (!is.character(d[['top_level']])) d$top_level = 'section'
  if (isTRUE(o <- d[['offline']])) o = 'assets'
  if (!is.character(o)) o = FALSE
  d$offline = o
  d = normalize_embed(d)
  d
}

normalize_embed = function(x) {
  v = x[['embed_resources']]
  if (is.logical(v)) {
    v = if (v) 'local'
  } else {
    if (length(v) == 1 && v == 'all') v = c('local', 'https')
  }
  x[['embed_resources']] = v
  x
}

named_bool = function(x, val = TRUE) as.list(set_names(rep(val, length(x)), x))

# normalize metadata variable names: change _ to -
normalize_meta = function(x) {
  # make sure some variables are available in metadata
  x = merge_list(list(classoption = '', documentclass = 'article', body_class = 'body'), x)
  names(x) = gsub('_', '-', names(x))
  x
}

# turn '+a-b c' to list(a = TRUE, b = FALSE, c = TRUE)
option2list = function(x) {
  if (!is.character(x)) return(as.list(x))
  x = unlist(strsplit(x, '\\b(?=[+-])', perl = TRUE))
  x = unlist(strsplit(x, '\\s+'))
  x = setdiff(x, '')
  i = grepl('^-', x)
  c(named_bool(sub('^[-]', '', x[i]), FALSE), named_bool(sub('^[+]', '', x[!i])))
}

pkg_file = function(...) {
  system.file(..., package = 'litedown', mustWork = TRUE)
}

jsdelivr = function(file, dir = 'npm/@xiee/utils/') {
  ifelse(is_https(file), file, sprintf('https://cdn.jsdelivr.net/%s%s', dir, file))
}

# get the latest version of jsdelivr assets
jsd_version = local({
  vers = list()  # cache versions in current session
  # find version from local cache
  p_cache = function() {
    d = if (getRversion() >= '4.0') tools::R_user_dir('litedown', 'cache') else {
      file.path(dirname(tempdir()), 'R', 'cache', 'litedown')
    }
    file.path(d, 'jsd_versions.rds')
  }
  # update cache to a specific version
  u_cache = function(info, pkg, version, file) {
    if (!grepl('^@', version)) version = paste0('@', version)
    info[[pkg]] = list(version = version, time = Sys.time())
    saveRDS(info, file)
    version
  }
  # cache expires after one week by default
  v_cache = function(pkg, force, delta = getOption('litedown.jsdelivr.cache', 604800)) {
    if (!isTRUE(force) && file.exists(f <- p_cache())) {
      info = readRDS(f)
      if (is.character(force)) {
        u_cache(info, pkg, force, f)
      } else {
        info = info[[pkg]]
        if (!is.null(t <- info$time) && Sys.time() - t <= delta) info$version
      }
    }
  }
  # query version from jsdelivr api
  v_api = function(pkg) {
    x = tryCatch(
      read_utf8(paste0('https://data.jsdelivr.com/v1/packages/', pkg, '/resolved?specifier=latest')),
      error = function(e) v_cache(pkg, FALSE, Inf)  # fall back to local cache
    )
    v = grep_sub('.*"version":\\s*"([0-9.]+)".*', '@\\1', x)
    if (length(v)) {
      if (dir_create(dirname(f <- p_cache()))) {
        info = if (file.exists(f)) readRDS(f) else list()
        u_cache(info, pkg, v[1], f)
      }
      v[1]
    }
  }
  # force can be TRUE/FALSE/version number
  function(pkg, force = FALSE) {
    if (isFALSE(force) && is.character(v <- vers[[pkg]])) return(v)
    v = v_cache(pkg, force) %||% v_api(pkg)
    (vers[[pkg]] <<- if (length(v)) v[1] else '')
  }
})

jsd_versions = function(pkgs) uapply(pkgs, jsd_version)

# resolve the implicit latest version to current latest version
jsd_resolve = function(x) {
  if (!getOption('litedown.jsd_resolve', TRUE)) return(x)
  rs = paste0(c(
    '((?<=https://cdn.jsdelivr.net/combine/)|(?<=,))',
    '(?<=https://cdn.jsdelivr.net/)(?!combine/)'
  ), '([^/]+/(@[^/]+/)?[^/@]+)((?=/)|(?=@latest$)|$)')
  for (r in rs) x = match_replace(x, r, function(z) {
    paste0(z, jsd_versions(z))
  })
  x = sub('@latest$', '', x)
  x
}

# if both @foo and foo are present, remove foo
resolve_dups = function(x) {
  x = unique(x)
  for (i in grep('^@', x, value = TRUE)) {
    x = x[x != sub('^@', '', i)]
  }
  x
}

# add filename extensions to paths without extensions: the path should contain
# no slashes (@xiee/utils assets) or at least 2 slashes when the npm package
# name doesn't start with @ (otherwise the path should contain >= 3 slashes),
# e.g. don't add ext for npm/simple-datatables
add_ext = function(x, ext) {
  n = vapply(strsplit(x, ''), function(z) sum(z == '/'), 0)
  i = file_ext(x) == '' & (n == 0 | n > grepl('^[^/]+/@', x) + 2)
  x[i] = paste0(x[i], ext)
  x
}

# resolve CSS/JS shorthand filenames to actual paths (e.g., 'default' to 'default.css')
resolve_files = function(x, ext = 'css') {
  x = resolve_dups(x)
  if (length(x) == 0) return(x)
  min_ext = paste0('.min.', ext)

  # @foo -> jsdelivr.net/npm/@xiee/utils/ext/foo.min.ext
  i0 = grepl('^@', x)
  x[i0] = sub('^@', '', x[i0])
  # @foo@version -> @npm/@xiee/utils@version/foo
  i = i0 & !grepl('[/,]', x)
  x[i] = sub('^(.+?)@(.+)$', sprintf('npm/@xiee/utils@\\2/%s/\\1', ext), x[i])

  # if no extension is specified, use .min.ext
  x[i0] = add_ext(x[i0], min_ext)
  i = i0 & !grepl('[/,]', x)
  x[i] = jsdelivr(paste0(ext, '/', x[i]))

  # @foo/bar -> jsdelivr.net/foo/bar
  i = i0 & !grepl(',', x)
  x[i] = jsdelivr(x[i], '')

  # @foo/bar,baz -> jsdelivr.net/combine/foo/bar,foo/baz
  i = i0 & grepl(',', x)
  if (any(i)) x[i] = sapply(strsplit(x[i], ',\\s*'), function(z) {
    d = dirname(z[1])
    if (d == '.') d = paste0('npm/@xiee/utils/', ext)
    for (j in seq_along(z)) {
      if (grepl('/', z[j])) {
        d = dirname(z[j])
      } else {
        z[j] = paste(d, z[j], sep = '/')
      }
    }
    z = add_ext(z, min_ext)
    paste0('combine/', paste(z, collapse = ','))
  })
  x[i] = jsdelivr(x[i], '')
  x[i0] = jsd_resolve(x[i0])

  i = dirname(x) == '.' & file_ext(x) == '' & !file_exists(x)
  x[i] = map_assets(x[i], ext)
  x = if (ext %in% c('css', 'js')) gen_tags(x, ext) else read_all(x)
  I(x)
}

map_assets = function(x, ext) {
  if (length(x) == 0) return(x)
  x[x == 'slides'] = 'snap'  # alias slides.css -> snap.css
  # built-in resources in this package
  b1 = c(if (ext != 'js') 'default', 'snap')
  i1 = x %in% b1
  x[i1] = file.path(pkg_file('resources'), sprintf('%s.%s', x[i1], ext))
  # in case users forgot to type @ for jsdelivr assets
  b2 = sub('@', '', assets[, ext])
  i2 = x %in% b2
  x[i2] = jsdelivr(sprintf('%s/%s.min.%s', ext, x[i2], ext))
  x[i2] = jsd_resolve(x[i2])
  if (any(i3 <- !(i1 | i2))) stop(
    "Invalid '", ext, "' option: ", paste0("'", x[i3], "'", collapse 
Download .txt
gitextract_h5miewmq/

├── .Rbuildignore
├── .github/
│   ├── copilot-instructions.md
│   └── workflows/
│       ├── R-CMD-check.yaml
│       ├── copilot-setup-steps.yml
│       └── github-pages.yml
├── .gitignore
├── DESCRIPTION
├── LICENSE
├── LICENSE.md
├── Makefile
├── NAMESPACE
├── NEWS.md
├── R/
│   ├── format.R
│   ├── fuse.R
│   ├── mark.R
│   ├── package.R
│   ├── preview.R
│   ├── site.R
│   ├── utils.R
│   └── zzz.R
├── README.md
├── docs/
│   ├── 01-start.Rmd
│   ├── 02-fuse.Rmd
│   ├── 03-syntax.Rmd
│   ├── 04-mark.Rmd
│   ├── 05-assets.Rmd
│   ├── 06-widgets.Rmd
│   ├── 07-editor.Rmd
│   ├── 08-site.Rmd
│   ├── A-misc.Rmd
│   ├── _litedown.yml
│   └── index.Rmd
├── examples/
│   ├── 001-minimal.Rmd
│   ├── 001-minimal.md
│   ├── 002-attr-options.Rmd
│   ├── 002-attr-options.md
│   ├── 003-attr-callout.Rmd
│   ├── 003-attr-callout.md
│   ├── 004-caption-position.Rmd
│   ├── 004-caption-position.md
│   ├── 005-option-code.Rmd
│   ├── 005-option-code.md
│   ├── 006-option-collapse.Rmd
│   ├── 006-option-collapse.md
│   ├── 007-option-comment.Rmd
│   ├── 007-option-comment.md
│   ├── 008-option-device.Rmd
│   ├── 008-option-device.md
│   ├── 009-option-figure-decoration.Rmd
│   ├── 009-option-figure-decoration.md
│   ├── 010-option-plot-files.Rmd
│   ├── 010-option-plot-files.md
│   ├── 011-option-label.Rmd
│   ├── 011-option-label.md
│   ├── 012-option-order.Rmd
│   ├── 012-option-order.md
│   ├── 013-option-print.Rmd
│   ├── 013-option-print.md
│   ├── 014-option-purl.R
│   ├── 014-option-purl.Rmd
│   ├── 014-option-purl.md
│   ├── 015-fill-chunk.Rmd
│   ├── 015-fill-chunk.md
│   ├── 016-option-results.Rmd
│   ├── 016-option-results.md
│   ├── 017-option-strip-white.Rmd
│   ├── 017-option-strip-white.md
│   ├── 018-option-table.Rmd
│   ├── 018-option-table.md
│   ├── 019-option-verbose.Rmd
│   ├── 019-option-verbose.md
│   ├── 020-inline.Rmd
│   ├── 020-inline.md
│   ├── 021-simple-datatables.Rmd
│   ├── 021-simple-datatables.md
│   ├── 022-dygraphs.Rmd
│   ├── 022-dygraphs.md
│   ├── 023-leaflet.Rmd
│   ├── 023-leaflet.md
│   ├── 024-chart-js.Rmd
│   ├── 024-chart-js.md
│   ├── 025-option-filter.Rmd
│   ├── 025-option-filter.md
│   ├── _run.R
│   ├── test-collapse.Rmd
│   ├── test-collapse.md
│   ├── test-inline.Rmd
│   ├── test-inline.md
│   ├── test-options.Rmd
│   ├── test-options.md
│   ├── test-results-hide.Rmd
│   └── test-results-hide.md
├── inst/
│   └── resources/
│       ├── default.css
│       ├── listing.css
│       ├── litedown.html
│       ├── litedown.latex
│       ├── server.css
│       ├── server.js
│       ├── snap.css
│       └── snap.js
├── litedown.Rproj
├── man/
│   ├── crack.Rd
│   ├── engines.Rd
│   ├── fuse_book.Rd
│   ├── fuse_env.Rd
│   ├── fuse_site.Rd
│   ├── get_context.Rd
│   ├── html_format.Rd
│   ├── litedown-package.Rd
│   ├── mark.Rd
│   ├── markdown_options.Rd
│   ├── pkg_desc.Rd
│   ├── raw_text.Rd
│   ├── reactor.Rd
│   ├── roam.Rd
│   ├── smartypants.Rd
│   ├── timing_data.Rd
│   └── vest.Rd
├── playground/
│   ├── _default.Rmd
│   └── setup.R
├── site/
│   ├── _footer.Rmd
│   ├── _litedown.yml
│   ├── action.yml
│   ├── articles.Rmd
│   ├── code.Rmd
│   ├── examples.Rmd
│   ├── index.Rmd
│   ├── manual.Rmd
│   ├── news.Rmd
│   └── playground/
│       ├── _default.R
│       └── index.html
├── tests/
│   ├── examples.R
│   ├── test-cran/
│   │   ├── test-crack.R
│   │   ├── test-fuse.R
│   │   ├── test-fuse.md
│   │   ├── test-mark.R
│   │   ├── test-mark.md
│   │   ├── test-reactor.R
│   │   ├── test-reactor.md
│   │   └── test-utils.R
│   └── test-cran.R
└── vignettes/
    └── slides.Rmd
Download .txt
SYMBOL INDEX (23 symbols across 2 files)

FILE: inst/resources/server.js
  function new_req (line 2) | function new_req(url, data, callback) {
  function new_dialog (line 10) | function new_dialog(text, error, html, action = el => el.close(), callba...
  function show_dialog (line 27) | function show_dialog(resp) {
  function new_file (line 31) | function new_file() {
  function btnAction (line 100) | function btnAction(e, action) {
  function btnSave (line 120) | function btnSave(e, a) {
  function check_one (line 137) | function check_one(q, a, s) {
  function update_chapter (line 170) | function update_chapter(el, html) {
  function update_script (line 194) | function update_script(el) {
  function check_all (line 202) | function check_all() {
  function new_interval (line 210) | function new_interval() {
  function open_line (line 216) | function open_line(container, path) {

FILE: inst/resources/snap.js
  function findContainer (line 5) | function findContainer(s, n = 1) {
  function newEl (line 15) | function newEl(tag, cls) {
  function newSlide (line 40) | function newSlide(s) {
  function isSep (line 43) | function isSep(el) {
  function setAttr (line 63) | function setAttr(el, attr) {
  function reveal (line 72) | function reveal(el) {
  function startTimers (line 125) | function startTimers() {
  function setTimers (line 129) | function setTimers() {
  function toggleView (line 139) | function toggleView(e) {
  function slideHeight (line 157) | function slideHeight() {
  function setScale (line 172) | function setScale(e) {
Condensed preview — 142 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (601K chars).
[
  {
    "path": ".Rbuildignore",
    "chars": 136,
    "preview": ".gitignore\n^.*\\.Rproj$\n^\\.Rproj\\.user$\nMakefile\n^\\.github$\n^LICENSE\\.md$\n^docs$\n^examples$\n^playground$\n^site$\n.*\\.tar\\."
  },
  {
    "path": ".github/copilot-instructions.md",
    "chars": 5041,
    "preview": "# Repository Instructions for Copilot\n\n## Build and Test Instructions\n\n``` bash\n# Build the R package\nR CMD build .\n\n# I"
  },
  {
    "path": ".github/workflows/R-CMD-check.yaml",
    "chars": 3305,
    "preview": "on:\n  push:\n    branches: [main]\n  pull_request:\n    branches: [main]\n\nname: R-CMD-check\n\njobs:\n  R-CMD-check:\n    runs-"
  },
  {
    "path": ".github/workflows/copilot-setup-steps.yml",
    "chars": 588,
    "preview": "name: Copilot Setup Steps\n\non:\n  workflow_dispatch:\n  push:\n    paths:\n      - .github/workflows/copilot-setup-steps.yml"
  },
  {
    "path": ".github/workflows/github-pages.yml",
    "chars": 634,
    "preview": "name: Build and deploy package site\n\non:\n  push:\n    branches: [\"main\"]\n\npermissions:\n  contents: read\n  pages: write\n  "
  },
  {
    "path": ".gitignore",
    "chars": 145,
    "preview": ".Rproj.user\n.Rhistory\n.RData\n.Ruserdata\n/examples/*__files/\n/examples/figures/\n/site/doc/\n/site/_footer.md\n/docs/package"
  },
  {
    "path": "DESCRIPTION",
    "chars": 1555,
    "preview": "Package: litedown\nType: Package\nTitle: A Lightweight Version of R Markdown\nVersion: 0.9.9\nAuthors@R: c(\n    person(\"Yihu"
  },
  {
    "path": "LICENSE",
    "chars": 44,
    "preview": "YEAR: 2024-2026\nCOPYRIGHT HOLDER: Yihui Xie\n"
  },
  {
    "path": "LICENSE.md",
    "chars": 1112,
    "preview": "# MIT License\n\nCopyright (c) 2023 Posit Software, PBC\nCopyright (c) 2024-2026 Yihui Xie\n\nPermission is hereby granted, f"
  },
  {
    "path": "Makefile",
    "chars": 203,
    "preview": "all:\n\tln -f ../lite.js/js/snap.js ../lite.js/css/snap.css ../lite.js/css/default.css inst/resources/\n\tRscript -e \"Rd2rox"
  },
  {
    "path": "NAMESPACE",
    "chars": 1757,
    "preview": "# Generated by roxygen2: do not edit by hand\n\nS3method(print,litedown_env)\nS3method(record_print,data.frame)\nS3method(re"
  },
  {
    "path": "NEWS.md",
    "chars": 15344,
    "preview": "# CHANGES IN litedown VERSION 0.10\n\n- When the `output` argument of `mark()` is a `.pdf` file, Markdown will be converte"
  },
  {
    "path": "R/format.R",
    "chars": 6397,
    "preview": "is_rmd_preview = function() Sys.getenv('RMARKDOWN_PREVIEW_DIR') != ''\n\noutput_format = function(to, options, meta, ...) "
  },
  {
    "path": "R/fuse.R",
    "chars": 49148,
    "preview": "new_env = function(...) new.env(..., parent = emptyenv())\n\n#' Parse R Markdown or R scripts\n#'\n#' Parse input into code "
  },
  {
    "path": "R/mark.R",
    "chars": 21024,
    "preview": "#' Render Markdown, R Markdown, and R scripts\n#'\n#' The function `mark()` renders Markdown to an output format via the\n#"
  },
  {
    "path": "R/package.R",
    "chars": 20072,
    "preview": "#' A lightweight version of R Markdown\n#'\n#' Markdown is a plain-text format that can be converted to HTML and other\n#' "
  },
  {
    "path": "R/preview.R",
    "chars": 13201,
    "preview": "#' Preview Markdown and R Markdown files\n#'\n#' Launch a web page to list and preview files under a directory.\n#'\n#' Mark"
  },
  {
    "path": "R/site.R",
    "chars": 11919,
    "preview": "#' Fuse R Markdown documents individually under a directory\n#'\n#' Run [fuse()] on R Markdown documents individually to g"
  },
  {
    "path": "R/utils.R",
    "chars": 51025,
    "preview": "# reset an environment using \"objects\" in a list\nreset_env = function(x = list(), envir) {\n  rm(list = ls(envir, all.nam"
  },
  {
    "path": "R/zzz.R",
    "chars": 645,
    "preview": "# for R versions < 4.0\n\n# ignore the perl argument for regexec() if it's not supported (in R < 3.3); we\n# use perl = TRU"
  },
  {
    "path": "README.md",
    "chars": 2999,
    "preview": "```         \n  ______  \n /   ⚡  \\\n/litedown\\\n\\   ⚡    /\n \\______/\n```\n\n# R Markdown Reimagined\n\n<!-- badges: start -->\n\n"
  },
  {
    "path": "docs/01-start.Rmd",
    "chars": 9377,
    "preview": "# Get Started {#chp:start}\n\n::: epigraph\n> Things are in the saddle,\\\n> And ride mankind.\n>\n> ---Ralph Waldo Emerson, *O"
  },
  {
    "path": "docs/02-fuse.Rmd",
    "chars": 57397,
    "preview": "# Computing {#chp:fuse}\n\n::: epigraph\n> If you give someone a program, you will frustrate them for a day; if you teach\n>"
  },
  {
    "path": "docs/03-syntax.Rmd",
    "chars": 24057,
    "preview": "# Markdown Syntax {#chp:syntax}\n\n::: epigraph\n> All the tired horses in a run; how’m I gonna get any writing done!\n>\n> -"
  },
  {
    "path": "docs/04-mark.Rmd",
    "chars": 23169,
    "preview": "# Markdown Rendering {#chp:mark}\n\n::: epigraph\n> Mature mental health demands, then, an extraordinary capacity to flexib"
  },
  {
    "path": "docs/05-assets.Rmd",
    "chars": 19964,
    "preview": "# CSS/JS assets {#chp:assets}\n\n::: epigraph\n> And it seemed as though in a little while the solution would be found, and"
  },
  {
    "path": "docs/06-widgets.Rmd",
    "chars": 7023,
    "preview": "# HTML Widgets {#chp:widgets}\n\n::: epigraph\n> A well-managed factory is boring. Nothing exciting happens in it because t"
  },
  {
    "path": "docs/07-editor.Rmd",
    "chars": 6240,
    "preview": "# Authoring\n\n::: epigraph\n> I don’t hide from you that I don’t detest the countryside—having been brought\n> up there, sn"
  },
  {
    "path": "docs/08-site.Rmd",
    "chars": 10081,
    "preview": "# Books and Websites {#chp:sites}\n\n::: epigraph\n> I can at least listen without indignation to the critic who is of the "
  },
  {
    "path": "docs/A-misc.Rmd",
    "chars": 4889,
    "preview": "# Appendix {.appendix}\n\n# Miscellaneous Topics {#apd:misc}\n\n## For rmarkdown users {#sec:rmarkdown}\n\nThe **litedown** pa"
  },
  {
    "path": "docs/_litedown.yml",
    "chars": 368,
    "preview": "book:\n  chapter_before: \"[Edit this chapter](https://github.com/yihui/litedown/edit/main/$input$) on GitHub\"\n  chapter_a"
  },
  {
    "path": "docs/index.Rmd",
    "chars": 21858,
    "preview": "---\ntitle: \"litedown: R Markdown Reimagined\"\nauthor: \"Yihui Xie\"\ndate: \"`{r} Sys.Date()`\"\nbibliography: [\"packages.bib\"]"
  },
  {
    "path": "examples/001-minimal.Rmd",
    "chars": 143,
    "preview": "---\ntitle: Area of a circle\n---\n\nDefine the radius as `x`:\n\n```{r}\nx = 1 + 1\n```\n\nWhen the radius is `{r} x`, the area w"
  },
  {
    "path": "examples/001-minimal.md",
    "chars": 129,
    "preview": "---\ntitle: Area of a circle\n---\n\nDefine the radius as `x`:\n\n``` {.r}\nx = 1 + 1\n```\n\nWhen the radius is 2, the area will "
  },
  {
    "path": "examples/002-attr-options.Rmd",
    "chars": 860,
    "preview": "---\ntitle: The `attr.*` chunk options\n---\n\n1. Add an ID `#example-a` to the whole chunk.\n\n2. Add line numbers to source "
  },
  {
    "path": "examples/002-attr-options.md",
    "chars": 1089,
    "preview": "---\ntitle: The `attr.*` chunk options\n---\n\n1. Add an ID `#example-a` to the whole chunk.\n\n2. Add line numbers to source "
  },
  {
    "path": "examples/003-attr-callout.Rmd",
    "chars": 344,
    "preview": "---\ntitle: Create a callout via the option `attr.chunk`\noutput:\n  html:\n    meta:\n      css: [\"@default\", \"@callout\"]\n  "
  },
  {
    "path": "examples/003-attr-callout.md",
    "chars": 356,
    "preview": "---\ntitle: Create a callout via the option `attr.chunk`\noutput:\n  html:\n    meta:\n      css: [\"@default\", \"@callout\"]\n  "
  },
  {
    "path": "examples/004-caption-position.Rmd",
    "chars": 382,
    "preview": "---\ntitle: Caption position\n---\n\n## Default caption positions\n\n```{r, fig-bottom, fig.cap = 'Bottom figure caption.'}\npl"
  },
  {
    "path": "examples/004-caption-position.md",
    "chars": 971,
    "preview": "---\ntitle: Caption position\n---\n\n## Default caption positions\n\n``` {.r}\nplot(cars)\n```\n\n:::: {.figure #fig:fig-bottom}\n!"
  },
  {
    "path": "examples/005-option-code.Rmd",
    "chars": 272,
    "preview": "---\ntitle: The `code` option\n---\n\nDefine a code template `tpl`:\n\n```{r}\ntpl    = 'lm(mpg ~ %s, data = mtcars) |> summary"
  },
  {
    "path": "examples/005-option-code.md",
    "chars": 857,
    "preview": "---\ntitle: The `code` option\n---\n\nDefine a code template `tpl`:\n\n``` {.r}\ntpl    = 'lm(mpg ~ %s, data = mtcars) |> summa"
  },
  {
    "path": "examples/006-option-collapse.Rmd",
    "chars": 231,
    "preview": "---\ntitle: Collapse source code and text output\n---\n\nBy default, the source and output are in separate blocks:\n\n```{r}\nx"
  },
  {
    "path": "examples/006-option-collapse.md",
    "chars": 288,
    "preview": "---\ntitle: Collapse source code and text output\n---\n\nBy default, the source and output are in separate blocks:\n\n``` {.r}"
  },
  {
    "path": "examples/007-option-comment.Rmd",
    "chars": 221,
    "preview": "---\ntitle: The `comment` option\n---\n\nUse `#-> ` to comment out text output:\n\n```{r, comment = '#-> ', print = NA}\nmatrix"
  },
  {
    "path": "examples/007-option-comment.md",
    "chars": 403,
    "preview": "---\ntitle: The `comment` option\n---\n\nUse `#-> ` to comment out text output:\n\n``` {.r}\nmatrix(1:12, 3)\n```\n\n```\n#->      "
  },
  {
    "path": "examples/008-option-device.Rmd",
    "chars": 375,
    "preview": "---\ntitle: The graphics device\n---\n\nThe default (png) device with a higher resolution:\n\n```{r}\n#| chunk-a, dev.args = li"
  },
  {
    "path": "examples/008-option-device.md",
    "chars": 336,
    "preview": "---\ntitle: The graphics device\n---\n\nThe default (png) device with a higher resolution:\n\n``` {.r}\nplot(cars)\n```\n![png wi"
  },
  {
    "path": "examples/009-option-figure-decoration.Rmd",
    "chars": 865,
    "preview": "---\ntitle: Decorating figures\noutput:\n  html:\n    meta:\n      css: [\"@default\", \"@article\"]\n      js: [\"@sidenotes\"]\n---"
  },
  {
    "path": "examples/009-option-figure-decoration.md",
    "chars": 1120,
    "preview": "---\ntitle: Decorating figures\noutput:\n  html:\n    meta:\n      css: [\"@default\", \"@article\"]\n      js: [\"@sidenotes\"]\n---"
  },
  {
    "path": "examples/010-option-plot-files.Rmd",
    "chars": 373,
    "preview": "---\ntitle: Plot files\n---\n\nThe default extension for the `jpeg()` device is `jpeg`, and you can change it to `.jpg` if d"
  },
  {
    "path": "examples/010-option-plot-files.md",
    "chars": 407,
    "preview": "---\ntitle: Plot files\n---\n\nThe default extension for the `jpeg()` device is `jpeg`, and you can change it to `.jpg` if d"
  },
  {
    "path": "examples/011-option-label.Rmd",
    "chars": 177,
    "preview": "---\ntitle: Shared chunk labels\n---\n\n```{r, chunk-a}\nmessage(\"This chunk's label is chunk-a\")\n```\n\nRepeat `chunk-a` but s"
  },
  {
    "path": "examples/011-option-label.md",
    "chars": 249,
    "preview": "---\ntitle: Shared chunk labels\n---\n\n``` {.r}\nmessage(\"This chunk's label is chunk-a\")\n```\n\n``` {.plain .message}\n#> This"
  },
  {
    "path": "examples/012-option-order.Rmd",
    "chars": 313,
    "preview": "---\ntitle: Custom execution order\nabstract: \"We analyzed `{r, order = N + 1} nrow(x)` `{r} n_cyl`-cylinder cars, with an"
  },
  {
    "path": "examples/012-option-order.md",
    "chars": 258,
    "preview": "---\ntitle: Custom execution order\nabstract: \"We analyzed 14 8-cylinder cars, with an average MPG of 15.1.\"\n---\n\nSubset t"
  },
  {
    "path": "examples/013-option-print.Rmd",
    "chars": 362,
    "preview": "---\ntitle: Print objects\n---\n\nPrint objects with `base::print()`, and use different arguments for different objects.\n\n``"
  },
  {
    "path": "examples/013-option-print.md",
    "chars": 412,
    "preview": "---\ntitle: Print objects\n---\n\nPrint objects with `base::print()`, and use different arguments for different objects.\n\n``"
  },
  {
    "path": "examples/014-option-purl.R",
    "chars": 27,
    "preview": "if (TRUE) {\n  x = 1 + 1\n}\n\n"
  },
  {
    "path": "examples/014-option-purl.Rmd",
    "chars": 212,
    "preview": "This code chunk is not important for the `fiss()` output.\n\n```{r, setup, purl = FALSE}\nlitedown::reactor(fig.height = 5)"
  },
  {
    "path": "examples/014-option-purl.md",
    "chars": 195,
    "preview": "This code chunk is not important for the `fiss()` output.\n\n``` {.r}\nlitedown::reactor(fig.height = 5)\n```\n\nThis code chu"
  },
  {
    "path": "examples/015-fill-chunk.Rmd",
    "chars": 712,
    "preview": "---\ntitle: \"Fill out references in code chunks\"\n---\n\nDefine `chunk-a` that includes a reference to `chunk-b`:\n\n```{r, ch"
  },
  {
    "path": "examples/015-fill-chunk.md",
    "chars": 629,
    "preview": "---\ntitle: \"Fill out references in code chunks\"\n---\n\nDefine `chunk-a` that includes a reference to `chunk-b`:\n\n\n\n\n\n\n\n<!-"
  },
  {
    "path": "examples/016-option-results.Rmd",
    "chars": 207,
    "preview": "---\ntitle: Text output\n---\n\nDefault verbatim output:\n\n```{r, test-out}\ncat('Hello _world_!\\n')\n```\n\nHide output:\n\n```{r,"
  },
  {
    "path": "examples/016-option-results.md",
    "chars": 238,
    "preview": "---\ntitle: Text output\n---\n\nDefault verbatim output:\n\n``` {.r}\ncat('Hello _world_!\\n')\n```\n\n```\n#> Hello _world_!\n```\n\nH"
  },
  {
    "path": "examples/017-option-strip-white.Rmd",
    "chars": 243,
    "preview": "---\ntitle: Blank lines in source code\n---\n\nKeep blank lines at the beginning/end:\n\n<!-- ... -->\n\n```{r, strip.white = FA"
  },
  {
    "path": "examples/017-option-strip-white.md",
    "chars": 256,
    "preview": "---\ntitle: Blank lines in source code\n---\n\nKeep blank lines at the beginning/end:\n\n<!-- ... -->\n\n``` {.r}\n\n# a blank lin"
  },
  {
    "path": "examples/018-option-table.Rmd",
    "chars": 145,
    "preview": "---\ntitle: A simple table\n---\n\nSee @tab:simple.\n\n<!-- ... -->\n\n```{r}\n#| simple, tab.cap = 'First 3 rows of the `cars` d"
  },
  {
    "path": "examples/018-option-table.md",
    "chars": 238,
    "preview": "---\ntitle: A simple table\n---\n\nSee @tab:simple.\n\n<!-- ... -->\n\n``` {.r}\nhead(cars, 3)\n```\n\n:::: {.table #tab:simple}\n\n::"
  },
  {
    "path": "examples/019-option-verbose.Rmd",
    "chars": 369,
    "preview": "---\ntitle: Printing verbosity\n---\n\nDefault `verbose = 0`:\n\n```{r, test}\n1:5  # a visible value\nx = 1 + 1  # invisible\nfo"
  },
  {
    "path": "examples/019-option-verbose.md",
    "chars": 763,
    "preview": "---\ntitle: Printing verbosity\n---\n\nDefault `verbose = 0`:\n\n``` {.r}\n1:5  # a visible value\n```\n\n```\n#> [1] 1 2 3 4 5\n```"
  },
  {
    "path": "examples/020-inline.Rmd",
    "chars": 934,
    "preview": "---\ntitle: Inline output\n---\n\nWe know that $\\pi = `{r} pi`$, and the first 3 letters are `{r} LETTERS[1:3]`. The first 3"
  },
  {
    "path": "examples/020-inline.md",
    "chars": 912,
    "preview": "---\ntitle: Inline output\n---\n\nWe know that $\\pi = 3.14$, and the first 3 letters are A\nB\nC. The first 3 variables of `mt"
  },
  {
    "path": "examples/021-simple-datatables.Rmd",
    "chars": 881,
    "preview": "---\ntitle: \"Simple DataTables\"\n---\n\nAdd the CSS/JS assets from the [simple-datatables](https://github.com/fiduswriter/si"
  },
  {
    "path": "examples/021-simple-datatables.md",
    "chars": 3129,
    "preview": "---\ntitle: \"Simple DataTables\"\n---\n\nAdd the CSS/JS assets from the [simple-datatables](https://github.com/fiduswriter/si"
  },
  {
    "path": "examples/022-dygraphs.Rmd",
    "chars": 1159,
    "preview": "---\ntitle: \"Dygraphs\"\n---\n\nAdd the CSS/JS assets from the [dygraphs](https://github.com/danvk/dygraphs) library:\n\n```{r}"
  },
  {
    "path": "examples/022-dygraphs.md",
    "chars": 14008,
    "preview": "---\ntitle: \"Dygraphs\"\n---\n\nAdd the CSS/JS assets from the [dygraphs](https://github.com/danvk/dygraphs) library:\n\n``` {."
  },
  {
    "path": "examples/023-leaflet.Rmd",
    "chars": 1142,
    "preview": "---\ntitle: \"Leaflet\"\n---\n\nAdd the CSS/JS assets from the [leaflet](https://leafletjs.com) library:\n\n```{r}\nlitedown::ves"
  },
  {
    "path": "examples/023-leaflet.md",
    "chars": 2768,
    "preview": "---\ntitle: \"Leaflet\"\n---\n\nAdd the CSS/JS assets from the [leaflet](https://leafletjs.com) library:\n\n``` {.r}\nlitedown::v"
  },
  {
    "path": "examples/024-chart-js.Rmd",
    "chars": 688,
    "preview": "---\ntitle: \"Chart.js\"\n---\n\nImport the [chart.js](https://www.chartjs.org) library:\n\n```{r}\nlitedown::vest(js = '@npm/cha"
  },
  {
    "path": "examples/024-chart-js.md",
    "chars": 1603,
    "preview": "---\ntitle: \"Chart.js\"\n---\n\nImport the [chart.js](https://www.chartjs.org) library:\n\n``` {.r}\nlitedown::vest(js = '@npm/c"
  },
  {
    "path": "examples/025-option-filter.Rmd",
    "chars": 203,
    "preview": "---\ntitle: Filter results\n---\n\nInterleave plots with text output:\n\n```{r, results = 'asis', filter = 'interleave'}\nfor(i"
  },
  {
    "path": "examples/025-option-filter.md",
    "chars": 329,
    "preview": "---\ntitle: Filter results\n---\n\nInterleave plots with text output:\n\n``` {.r}\nfor(i in 1:3) {\n  cat('\\n## ', LETTERS[i], '"
  },
  {
    "path": "examples/_run.R",
    "chars": 674,
    "preview": "# rebuild all examples on CI and check if their output changes\nci = tolower(Sys.getenv('CI')) == 'true'\n\nfor (f in lited"
  },
  {
    "path": "examples/test-collapse.Rmd",
    "chars": 391,
    "preview": "`collapse = TRUE` will collapse messages into source blocks, too.\n\n```{r, test-a, collapse = TRUE, error = TRUE, strip.w"
  },
  {
    "path": "examples/test-collapse.md",
    "chars": 756,
    "preview": "`collapse = TRUE` will collapse messages into source blocks, too.\n\n``` {.r}\nmessage(\"this is a message\")\n#> this is a me"
  },
  {
    "path": "examples/test-inline.Rmd",
    "chars": 592,
    "preview": "---\ntitle: \"Test inline expressions\"\n---\n\nA normal inline expression: `{r} pi`.\n\nThe knitr-style inline expressions are "
  },
  {
    "path": "examples/test-inline.md",
    "chars": 549,
    "preview": "---\ntitle: \"Test inline expressions\"\n---\n\nA normal inline expression: 3.14.\n\nThe knitr-style inline expressions are igno"
  },
  {
    "path": "examples/test-options.Rmd",
    "chars": 2558,
    "preview": "## Test Markdown options\n\n```{r}\nlibrary(litedown)\n```\n\n```{r, test-a}\n# toc example\nmkd <- c('# Header 1', 'p1', '## He"
  },
  {
    "path": "examples/test-options.md",
    "chars": 19555,
    "preview": "## Test Markdown options\n\n``` {.r}\nlibrary(litedown)\n```\n\n````` {.r}\n# toc example\nmkd <- c('# Header 1', 'p1', '## Head"
  },
  {
    "path": "examples/test-results-hide.Rmd",
    "chars": 342,
    "preview": "`results = 'hide'` should try to collapse output, e.g., merge the source blocks below:\n\n```{r, chunk-a, results = 'hide'"
  },
  {
    "path": "examples/test-results-hide.md",
    "chars": 287,
    "preview": "`results = 'hide'` should try to collapse output, e.g., merge the source blocks below:\n\n``` {.r}\nnrow(iris)\nncol(iris)\ni"
  },
  {
    "path": "inst/resources/default.css",
    "chars": 3720,
    "preview": "body {\n  font-family: sans-serif;\n  max-width: 800px;\n  margin: auto;\n  padding: 1em;\n  line-height: 1.5;\n  print-color-"
  },
  {
    "path": "inst/resources/listing.css",
    "chars": 648,
    "preview": "body {\n  max-width: none;\n}\n.body, ul {\n  display: flex;\n  flex-wrap: wrap;\n}\nh1 {\n  text-align: left;\n  font-size: 1em;"
  },
  {
    "path": "inst/resources/litedown.html",
    "chars": 460,
    "preview": "<!DOCTYPE html>\n<html lang=\"$lang$\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, in"
  },
  {
    "path": "inst/resources/litedown.latex",
    "chars": 270,
    "preview": "\\documentclass[$classoption$]{$documentclass$}\n\\usepackage[T1]{fontenc}\n\\usepackage{graphicx,hyperref}\n$bib-preamble$\n$h"
  },
  {
    "path": "inst/resources/server.css",
    "chars": 599,
    "preview": ".nav-path {\n  font-size: .8em;\n  width: 100%;\n  order: -1;\n}\n.buttons {\n  float: right;\n  a {\n    margin-left: .5em;\n  }"
  },
  {
    "path": "inst/resources/server.js",
    "chars": 9638,
    "preview": "(d => {\n  function new_req(url, data, callback) {\n    const req = new XMLHttpRequest();\n    req.open('POST', url);\n    /"
  },
  {
    "path": "inst/resources/snap.css",
    "chars": 2184,
    "preview": ":root { --slide-width: 100%; }\nhtml { scroll-snap-type: y mandatory; }\nth, td { padding: .2em .5em; }\n.slide { padding: "
  },
  {
    "path": "inst/resources/snap.js",
    "chars": 7302,
    "preview": "(function(d) {\n  let p = d.body;  // container of slides; assume <body> for now\n  const s1 = ':scope > hr:not([class])',"
  },
  {
    "path": "litedown.Rproj",
    "chars": 445,
    "preview": "Version: 1.0\nProjectId: 06407e40-3cb5-4312-b9b7-2532a448baf3\n\nRestoreWorkspace: Default\nSaveWorkspace: Default\nAlwaysSav"
  },
  {
    "path": "man/crack.Rd",
    "chars": 4017,
    "preview": "% Generated by roxygen2: do not edit by hand\n% Please edit documentation in R/fuse.R\n\\name{crack}\n\\alias{crack}\n\\alias{s"
  },
  {
    "path": "man/engines.Rd",
    "chars": 1064,
    "preview": "% Generated by roxygen2: do not edit by hand\n% Please edit documentation in R/fuse.R\n\\name{engines}\n\\alias{engines}\n\\tit"
  },
  {
    "path": "man/fuse_book.Rd",
    "chars": 3648,
    "preview": "% Generated by roxygen2: do not edit by hand\n% Please edit documentation in R/site.R\n\\name{fuse_book}\n\\alias{fuse_book}\n"
  },
  {
    "path": "man/fuse_env.Rd",
    "chars": 533,
    "preview": "% Generated by roxygen2: do not edit by hand\n% Please edit documentation in R/package.R\n\\name{fuse_env}\n\\alias{fuse_env}"
  },
  {
    "path": "man/fuse_site.Rd",
    "chars": 1448,
    "preview": "% Generated by roxygen2: do not edit by hand\n% Please edit documentation in R/site.R\n\\name{fuse_site}\n\\alias{fuse_site}\n"
  },
  {
    "path": "man/get_context.Rd",
    "chars": 695,
    "preview": "% Generated by roxygen2: do not edit by hand\n% Please edit documentation in R/fuse.R\n\\name{get_context}\n\\alias{get_conte"
  },
  {
    "path": "man/html_format.Rd",
    "chars": 1907,
    "preview": "% Generated by roxygen2: do not edit by hand\n% Please edit documentation in R/format.R\n\\name{html_format}\n\\alias{html_fo"
  },
  {
    "path": "man/litedown-package.Rd",
    "chars": 995,
    "preview": "% Generated by roxygen2: do not edit by hand\n% Please edit documentation in R/package.R\n\\docType{package}\n\\name{litedown"
  },
  {
    "path": "man/mark.Rd",
    "chars": 5461,
    "preview": "% Generated by roxygen2: do not edit by hand\n% Please edit documentation in R/fuse.R, R/mark.R\n\\name{fuse}\n\\alias{fuse}\n"
  },
  {
    "path": "man/markdown_options.Rd",
    "chars": 636,
    "preview": "% Generated by roxygen2: do not edit by hand\n% Please edit documentation in R/mark.R\n\\name{markdown_options}\n\\alias{mark"
  },
  {
    "path": "man/pkg_desc.Rd",
    "chars": 3620,
    "preview": "% Generated by roxygen2: do not edit by hand\n% Please edit documentation in R/package.R\n\\name{pkg_desc}\n\\alias{pkg_desc}"
  },
  {
    "path": "man/raw_text.Rd",
    "chars": 953,
    "preview": "% Generated by roxygen2: do not edit by hand\n% Please edit documentation in R/fuse.R\n\\name{raw_text}\n\\alias{raw_text}\n\\t"
  },
  {
    "path": "man/reactor.Rd",
    "chars": 1902,
    "preview": "% Generated by roxygen2: do not edit by hand\n% Please edit documentation in R/fuse.R\n\\name{reactor}\n\\alias{reactor}\n\\tit"
  },
  {
    "path": "man/roam.Rd",
    "chars": 1039,
    "preview": "% Generated by roxygen2: do not edit by hand\n% Please edit documentation in R/preview.R\n\\name{roam}\n\\alias{roam}\n\\title{"
  },
  {
    "path": "man/smartypants.Rd",
    "chars": 540,
    "preview": "% Generated by roxygen2: do not edit by hand\n% Please edit documentation in R/utils.R\n\\name{smartypants}\n\\alias{smartypa"
  },
  {
    "path": "man/timing_data.Rd",
    "chars": 1536,
    "preview": "% Generated by roxygen2: do not edit by hand\n% Please edit documentation in R/fuse.R\n\\name{timing_data}\n\\alias{timing_da"
  },
  {
    "path": "man/vest.Rd",
    "chars": 1014,
    "preview": "% Generated by roxygen2: do not edit by hand\n% Please edit documentation in R/utils.R\n\\name{vest}\n\\alias{vest}\n\\title{Ad"
  },
  {
    "path": "playground/_default.Rmd",
    "chars": 823,
    "preview": "---\ntitle: Hello litedown\nauthor: Playground User\ndate: '`{r} Sys.Date()`'\n---\n\n# Introduction\n\nThis is a **litedown** p"
  },
  {
    "path": "playground/setup.R",
    "chars": 42,
    "preview": "webr::install('KernSmooth', quiet = TRUE)\n"
  },
  {
    "path": "site/_footer.Rmd",
    "chars": 323,
    "preview": "```{r, include = FALSE}\nget_authors = function() {\n  d = packageDescription(litedown:::detect_pkg())\n  a = litedown:::pk"
  },
  {
    "path": "site/_litedown.yml",
    "chars": 108,
    "preview": "site:\n\noutput:\n  html:\n    meta:\n      include_after: \"_footer.md\"\n    options:\n      number_sections: true\n"
  },
  {
    "path": "site/action.yml",
    "chars": 2147,
    "preview": "name: 'Build an R package site'\ndescription: 'Build an R package site with litedown that includes the description, news,"
  },
  {
    "path": "site/articles.Rmd",
    "chars": 781,
    "preview": "---\ntitle: Package vignettes\n---\n\n```{r, echo = FALSE, results = 'asis'}\npkg_name = litedown:::detect_pkg()\nres = tools:"
  },
  {
    "path": "site/code.Rmd",
    "chars": 74,
    "preview": "---\ntitle: Source code\n---\n\n```{r, echo = FALSE}\nlitedown::pkg_code()\n```\n"
  },
  {
    "path": "site/examples.Rmd",
    "chars": 1299,
    "preview": "---\ntitle: Examples\n---\n\n```{r, echo = FALSE}\nlitedown::vest(css = 'https://cdn.jsdelivr.net/gh/yihui/litedown/inst/reso"
  },
  {
    "path": "site/index.Rmd",
    "chars": 1152,
    "preview": "---\ntitle: \"`{r} pkg_name`: `{r} packageDescription(pkg_name, fields = 'Title')`\"\ndate: \"`{r} Sys.Date()`\"\n---\n\n```{r, o"
  },
  {
    "path": "site/manual.Rmd",
    "chars": 132,
    "preview": "---\ntitle: Help pages\n---\n\n```{css, echo = FALSE}\nh2 {\n  text-align: center;\n  margin-top: 2em;\n}\n```\n\n`{r} litedown::pk"
  },
  {
    "path": "site/news.Rmd",
    "chars": 102,
    "preview": "---\ntitle: News\n---\n\n```{r, echo = FALSE}\nlitedown::pkg_news(recent = 0, number_sections = FALSE)\n```\n"
  },
  {
    "path": "site/playground/_default.R",
    "chars": 406,
    "preview": "#' ---\n#' title: Hello World!\n#' ---\n\n#' Just type your R code here and run[^1]!\n\nsessionInfo()\n\n#' Package developers: "
  },
  {
    "path": "site/playground/index.html",
    "chars": 11387,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initia"
  },
  {
    "path": "tests/examples.R",
    "chars": 85,
    "preview": "if (file.exists(f <- '../examples/_run.R')) sys.source(f, globalenv(), chdir = TRUE)\n"
  },
  {
    "path": "tests/test-cran/test-crack.R",
    "chars": 3338,
    "preview": "library(testit)\n\n# helper: build a fake code chunk block\nnew_chunk = function(engine, source) {\n  list(\n    source = sou"
  },
  {
    "path": "tests/test-cran/test-fuse.R",
    "chars": 2752,
    "preview": "library(testit)\n\nassert('fuse() evaluates inline code in text blocks', {\n  src = 'Value is `{r} 1 + 1`.'\n  out = fuse(te"
  },
  {
    "path": "tests/test-cran/test-fuse.md",
    "chars": 2956,
    "preview": "# Snapshot tests for fuse()\n\n## Basic code chunk execution\n\n````r\nlibrary(litedown)\nfuse(text = c('```{r}', '1 + 1', '``"
  },
  {
    "path": "tests/test-cran/test-mark.R",
    "chars": 1169,
    "preview": "library(testit)\n\nassert('mark() with empty or trivial input produces empty output', {\n  # character(0) gives a length-0 "
  },
  {
    "path": "tests/test-cran/test-mark.md",
    "chars": 2476,
    "preview": "# Snapshot tests for mark()\n\nBasic Markdown rendering is verified below. The expected output blocks are generated and\nco"
  },
  {
    "path": "tests/test-cran/test-reactor.R",
    "chars": 1432,
    "preview": "library(testit)\n\nassert('reactor() can get multiple options at once', {\n  res = reactor(c('echo', 'eval'))\n  (is.list(re"
  },
  {
    "path": "tests/test-cran/test-reactor.md",
    "chars": 502,
    "preview": "# Snapshot tests for get_context()\n\n## get_context('format') returns the output format inside fuse()\n\n````r\nlibrary(lite"
  },
  {
    "path": "tests/test-cran/test-utils.R",
    "chars": 2509,
    "preview": "library(testit)\n\nassert('smartypants() transforms ASCII typographic markers', {\n  # all pants names produce their HTML e"
  },
  {
    "path": "tests/test-cran.R",
    "chars": 42,
    "preview": "testit::test_pkg('litedown', 'test-cran')\n"
  },
  {
    "path": "vignettes/slides.Rmd",
    "chars": 17965,
    "preview": "---\ntitle: Making HTML Slides with the **litedown** Package\nauthor: \"[Yihui Xie](https://yihui.org)\"\ndate: \"`{r} Sys.Dat"
  }
]

About this extraction

This page contains the full source code of the yihui/litedown GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 142 files (557.1 KB), approximately 174.8k tokens, and a symbol index with 23 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!