Skip to content

Commit 184ad6f

Browse files
schloerkecpsievert
andauthored
feat: Add ui.page_html() and express.ui.page_opts(html=) for a complete, author-owned HTML document as the UI (#2475)
Co-authored-by: Carson Sievert <cpsievert1@gmail.com>
1 parent 02bd8ce commit 184ad6f

16 files changed

Lines changed: 551 additions & 60 deletions

File tree

.claude/references/architecture.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,30 @@ HTML is generated using the `htmltools` package:
8282
Asset vendoring (bslib CSS/JS, theme presets, `make upgrade-html-deps`) is
8383
covered in `.claude/references/assets.md`.
8484

85+
### App UI types
86+
87+
`App(ui=)` accepts a `Tag`/`TagList`, a `Path` to a complete HTML file, a
88+
`ui.page_html()` result (a complete HTML document with Shiny's dependencies
89+
prefixed onto the app author's), or a function taking a `Request` and returning
90+
any of those (except for `Path`, see below).
91+
92+
**Every UI *value* type (except for `Path`) must work in both positions: passed
93+
directly, and returned by a UI function.** The function form is what bookmarking requires --
94+
`App._init_bookmarking()` rejects a static UI, since the UI has to be
95+
reconstructed from the bookmarked state -- so a type supported only when passed
96+
directly is silently unavailable to bookmark-enabled apps.
97+
98+
The page-level dependency set (requirejs, jQuery, Shiny) has one definition,
99+
`html_dependencies._page_deps()`. Both page forms -- the tag tree and the
100+
complete document -- splat it, so they cannot drift.
101+
102+
### `Path` exception
103+
104+
`Path` is the deliberate exception: it names a file to read once at startup
105+
rather than a UI value, so it is handled in `App.__init__()` and rejected in
106+
`_render_page()`. A UI function that wants to serve a file returns
107+
`ui.page_html(path)`, which makes the per-pageview file read explicit.
108+
85109
## Input/Output Bindings
86110

87111
Client-server communication works through bindings:

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717

1818
* Added `session.allow_reconnect()`, the Python counterpart to Shiny for R's `session$allowReconnect()`. Call it with `True` to let the browser reconnect to its session (showing a countdown dialog instead of the "Disconnected from server" overlay) when the hosting environment keeps sessions alive after a client disconnects, or with `"force"` to attempt the reconnect anywhere. (#2441)
1919

20+
* Added `ui.page_html()`, for apps whose UI is a complete HTML document they own (e.g. the `index.html` a JS bundler emits) rather than one built from `ui.page_*()` components. It takes the document as a string or a `Path`, plus optional `extra_deps=`. Pass the result as `App(ui=)`, or return it from a UI function (`App(ui=lambda request: ...)`, which is what bookmarking requires): the document is served as-is, with Shiny's own HTML dependencies -- plus any in `extra_deps=` -- inserted at `<meta name="shiny-dependency-placeholder" content="">` (or a custom `deps_replace_pattern=`), and their files served by the app. In Express, use `ui.page_opts(html=)`, which routes the whole app through `ui.page_html()`: top-level UI markup is dropped (the document already is the page) but its HTML dependencies are kept. This is the Python counterpart to Shiny for R's `shinyApp(ui = htmlTemplate("index.html", document_ = TRUE))` with `attachDependencies()`. (#2462)
21+
2022
### Improvements
2123

2224
* The README and the `shiny skills` CLI help now explain that [`library-skills`](https://library-skills.io) must be run from your own project directory, since it installs the bundled Agent Skills of the packages that project has installed. The previous wording left that precondition implicit, so running the command from an empty directory or from a clone of py-shiny silently installed nothing. (#2447)

docs/_quartodoc-core.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ quartodoc:
2222
- ui.page_bootstrap
2323
- ui.page_auto
2424
- ui.page_output
25+
- ui.page_html
2526
- title: UI Layouts
2627
desc: Control the layout of multiple UI components.
2728
contents:

docs/_quartodoc-express.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ quartodoc:
5959
desc: Tools for creating, arranging, and styling UI components.
6060
contents:
6161
- express.ui.page_opts
62+
- express.ui.page_html
6263
- express.ui.sidebar
6364
- express.ui.layout_columns
6465
- express.ui.layout_column_wrap

shiny/_app.py

Lines changed: 97 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131

3232
if TYPE_CHECKING:
3333
from htmltools import Tagified
34+
3435
from starlette.requests import Request
3536
from starlette.responses import HTMLResponse, JSONResponse, Response
3637
from starlette.types import ASGIApp, Message, Receive, Scope, Send
@@ -48,10 +49,11 @@
4849
BookmarkSaveDirFn,
4950
BookmarkStore,
5051
)
51-
from .html_dependencies import jquery_deps, require_deps, shiny_deps
52+
from .html_dependencies import _page_deps
5253
from .http_staticfiles import FileResponse, StaticFiles
5354
from .session._session import AppSession, Inputs, Outputs, Session, session_context
5455
from .types import MISSING, MISSING_TYPE
56+
from .ui._page import DEPS_PLACEHOLDER, PageHtmlDocument, page_html
5557

5658
T = TypeVar("T")
5759

@@ -75,7 +77,19 @@ class App:
7577
similar, with layouts and controls nested inside). You can
7678
also pass a function that takes a :class:`~starlette.requests.Request` and
7779
returns a UI definition, if you need the UI definition to be created dynamically
78-
for each pageview.
80+
for each pageview -- which is also what bookmarking requires. Finally, it can
81+
be a complete HTML document that you own: either a :class:`~pathlib.Path` to an
82+
HTML file, or a call to :func:`~shiny.ui.page_html`, which additionally lets
83+
you attach your own :class:`~htmltools.HTMLDependency` objects. Such a document
84+
is served as-is, and must contain
85+
``<meta name="shiny-dependency-placeholder" content="">`` (or the
86+
``deps_replace_pattern=`` passed to ``ui.page_html()``) to
87+
mark where Shiny's HTML dependencies are inserted.
88+
89+
A ``Tag``, ``TagList``, or ``ui.page_html()`` result may equally be *returned
90+
by* the function above, for a UI that varies per pageview. A ``Path`` may not:
91+
it names a file to read once at startup, so a function that wants to serve a
92+
file should return ``ui.page_html(path)``, which reads it per call.
7993
server
8094
A function which is called once for each session, ensuring that each session is
8195
independent.
@@ -140,7 +154,7 @@ def server(input: Inputs, output: Outputs, session: Session):
140154
``SafeException`` messages bypass sanitization regardless of this setting.
141155
"""
142156

143-
ui: RenderedHTML | Callable[[Request], Tag | TagList]
157+
ui: RenderedHTML | Callable[[Request], Tag | TagList | PageHtmlDocument]
144158
server: Callable[[Inputs, Outputs, Session], None]
145159

146160
_bookmark_save_dir_fn: BookmarkSaveDirFn | None | MISSING_TYPE
@@ -153,8 +167,9 @@ def __init__(
153167
Tag
154168
| TagList
155169
| Tagified
156-
| Callable[[Request], Tag | TagList | Tagified]
170+
| Callable[[Request], Tag | TagList | Tagified | PageHtmlDocument]
157171
| Path
172+
| PageHtmlDocument
158173
),
159174
server: (
160175
Callable[[Inputs], None] | Callable[[Inputs, Outputs, Session], None] | None
@@ -243,17 +258,20 @@ def __init__(
243258
if is_async_callable(cast(Callable[[Request], Any], ui)):
244259
raise TypeError("App UI cannot be a coroutine function")
245260
# Dynamic UI: just store the function for later
246-
self.ui = cast("Callable[[Request], Tag | TagList]", ui)
261+
self.ui = cast("Callable[[Request], Tag | TagList | PageHtmlDocument]", ui)
247262
elif isinstance(ui, Path):
248263
if not ui.is_absolute():
249264
raise ValueError("Path to UI must be absolute")
250265

251-
self.ui = self._render_page_from_file(ui, lib_prefix=self.lib_prefix)
266+
# Read once, here: a `Path` names a file to serve, not a per-pageview UI
267+
# value, so it is not something a UI function may return.
268+
self.ui = self._render_page(page_html(ui), lib_prefix=self.lib_prefix)
252269

253270
else:
254271
# Static UI: render the UI now and save the results
255272
self.ui = self._render_page(
256-
cast("Tag | TagList", ui), lib_prefix=self.lib_prefix
273+
cast("Tag | TagList | PageHtmlDocument", ui),
274+
lib_prefix=self.lib_prefix,
257275
)
258276

259277
def init_starlette_app(self) -> starlette.applications.Starlette:
@@ -499,38 +517,74 @@ def _register_web_dependency(self, dep: HTMLDependency) -> None:
499517

500518
self._registered_dependencies[dep_name] = dep
501519

502-
def _render_page(self, ui: Tag | TagList, lib_prefix: str) -> RenderedHTML:
503-
# Use presence of the Bootstrap dependency as a signal that the UI uses a
504-
# shiny.ui.page_*() function, in which case the Shiny CSS is already included.
505-
has_bootstrap = any(dep.name == "bootstrap" for dep in ui.get_dependencies())
506-
# Make sure requirejs, jQuery, and Shiny come before any other dependencies.
507-
# (see require_deps() for a comment about why we even include it)
508-
# Compose a new TagList so this works for any UI input shape, including
509-
# pre-tagified (and immutable) TagifiedTag/TagifiedTagList values that
510-
# express mode produces (`run_express(...).tagify()` in `express/_run.py`).
511-
ui_res = TagList(
512-
require_deps(),
513-
jquery_deps(),
514-
*shiny_deps(include_css=not has_bootstrap),
515-
ui,
516-
)
517-
rendered = HTMLDocument(ui_res).render(lib_prefix=lib_prefix)
518-
self._ensure_web_dependencies(rendered["dependencies"])
519-
return rendered
520+
def _render_page(
521+
self,
522+
ui: Tag | TagList | PageHtmlDocument,
523+
lib_prefix: str,
524+
) -> RenderedHTML:
525+
# Every UI *value* type must be handled here, and nowhere else. This is the one
526+
# place both `App(ui=)` and a UI function's return value are rendered, so
527+
# handling a type here is what makes it work in both positions. Adding a UI
528+
# type without doing so silently supports it in only one of them.
529+
#
530+
# `Path` is the exception, and is handled in `__init__()`: it names a file to
531+
# read once at startup, not a UI value, so a UI function may not return one.
532+
if isinstance(ui, Path):
533+
raise TypeError(
534+
"A UI function cannot return a `Path`. Return `ui.page_html(path)`"
535+
" instead, which makes it clear the file is read on every call."
536+
)
537+
538+
if isinstance(ui, HTMLDocument):
539+
# Not supported: `HTMLDocument` builds a document out of a tag tree and
540+
# hoists the dependencies it finds there into <head> in tree order, so
541+
# Shiny's could only be appended after the app author's -- jQuery would
542+
# load after the scripts that need it. Pass the tags themselves instead
543+
# and let Shiny build the document.
544+
raise TypeError(
545+
"An `HTMLDocument` cannot be used as a UI. Pass its contents (a `Tag`"
546+
" or `TagList`) instead, and Shiny will build the document -- or, for"
547+
" a complete HTML document of your own, use `ui.page_html()`."
548+
)
520549

521-
def _render_page_from_file(self, file: Path, lib_prefix: str) -> RenderedHTML:
522-
with open(file, "r") as f:
523-
page_html = f.read()
550+
if isinstance(ui, HTMLTextDocument):
551+
if not isinstance(ui, PageHtmlDocument):
552+
raise TypeError(
553+
"A complete HTML document used as a UI must be created with"
554+
" `ui.page_html()`, which is what prefixes Shiny's own"
555+
" HTML dependencies onto the app author's."
556+
)
524557

525-
doc = HTMLTextDocument(
526-
page_html,
527-
deps=[require_deps(), jquery_deps(), *shiny_deps(include_css=True)],
528-
deps_replace_pattern='<meta name="shiny-dependency-placeholder" content="">',
529-
)
558+
# Render the document as-is: wrapping it in an HTMLDocument would nest
559+
# <html> inside <html>.
560+
rendered = ui.render(lib_prefix=lib_prefix)
530561

531-
rendered = doc.render(lib_prefix=lib_prefix)
532-
self._ensure_web_dependencies(rendered["dependencies"])
562+
# `render()` replaces `deps_replace_pattern` with the dependency markup,
563+
# or silently does nothing if the pattern isn't in the document. The
564+
# document always has dependencies, so a missing manifest means the
565+
# pattern wasn't found -- i.e. we'd serve a page with no Shiny on it.
566+
if "application/html-dependencies" not in rendered["html"]:
567+
raise ValueError(
568+
"The UI document does not contain the string that marks where"
569+
" Shiny's HTML dependencies are inserted, so they could not be"
570+
" inserted. Add the `deps_replace_pattern=` passed to"
571+
" `ui.page_html()` (by default,"
572+
f" `{DEPS_PLACEHOLDER}`) to the document."
573+
)
574+
else:
575+
# Use presence of the Bootstrap dependency as a signal that the UI uses a
576+
# shiny.ui.page_*() function, in which case the Shiny CSS is already
577+
# included.
578+
has_bootstrap = any(
579+
dep.name == "bootstrap" for dep in ui.get_dependencies()
580+
)
581+
# Compose a new TagList so this works for any UI input shape, including
582+
# pre-tagified (and immutable) TagifiedTag/TagifiedTagList values that
583+
# express mode produces (`run_express(...).tagify()` in `express/_run.py`).
584+
ui_res = TagList(*_page_deps(include_css=not has_bootstrap), ui)
585+
rendered = HTMLDocument(ui_res).render(lib_prefix=lib_prefix)
533586

587+
self._ensure_web_dependencies(rendered["dependencies"])
534588
return rendered
535589

536590
# ==========================================================================
@@ -559,7 +613,14 @@ def set_bookmark_restore_dir_fn(self, bookmark_restore_dir_fn: BookmarkDirFn):
559613

560614

561615
def is_uifunc(
562-
x: Path | Tag | TagList | Tagified | Callable[[Request], Tag | TagList | Tagified],
616+
x: (
617+
Path
618+
| Tag
619+
| TagList
620+
| Tagified
621+
| Callable[[Request], Tag | TagList | Tagified | PageHtmlDocument]
622+
| PageHtmlDocument
623+
),
563624
) -> bool:
564625
if (
565626
isinstance(x, Path)
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
from pathlib import Path
2+
3+
from shiny import App, Inputs, Outputs, Session, render, ui
4+
5+
app_ui = ui.page_html(Path(__file__).parent / "index.html")
6+
7+
8+
def server(input: Inputs, output: Outputs, session: Session):
9+
@render.text
10+
def greeting():
11+
return "Hello from the server!"
12+
13+
14+
app = App(app_ui, server)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from pathlib import Path
2+
3+
from shiny.express import render, ui
4+
5+
ui.page_opts(html=Path(__file__).parent / "index.html")
6+
7+
8+
@render.text
9+
def greeting():
10+
return "Hello from the server!"
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<title>A page Shiny did not build</title>
5+
<!-- A meta tag that is replaced with shiny's dependencies (and any provided `extra_deps=`) -->
6+
<meta name="shiny-dependency-placeholder" content="">
7+
</head>
8+
<body>
9+
<h1>A complete HTML document, served as-is</h1>
10+
<div id="greeting" class="shiny-text-output"></div>
11+
</body>
12+
</html>

shiny/express/_run.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from ..bookmark._types import BookmarkStore
2020
from ..session import Inputs, Outputs, Session, get_current_session, session_context
2121
from ..types import MISSING, MISSING_TYPE
22+
from ..ui._page import PageHtmlDocument
2223
from ._is_express import find_magic_comment_mode
2324
from ._recall_context import RecallContextManager
2425
from ._stub_session import ExpressStubSession
@@ -131,7 +132,9 @@ def create_express_app(file: Path, package_name: str) -> App:
131132
# catch them here and convert them to a different type of error, because uvicorn
132133
# specifically catches AttributeErrors and prints an error message that is
133134
# misleading for Shiny Express. https://github.com/posit-dev/py-shiny/issues/937
134-
app_ui = run_express(file, package_name).tagify()
135+
ui_res = run_express(file, package_name)
136+
# A `PageHtmlDocument` (from `ui.page_opts(html=)`) has no `.tagify()`.
137+
app_ui = ui_res if isinstance(ui_res, PageHtmlDocument) else ui_res.tagify()
135138

136139
except AttributeError as e:
137140
raise RuntimeError(e) from e
@@ -143,7 +146,10 @@ def create_express_app(file: Path, package_name: str) -> App:
143146
def app_ui_wrapper(request: Request):
144147
# Stub session used to pass `app_opts()` checks.
145148
with session_context(ExpressStubSession()):
146-
return run_express(file, package_name).tagify()
149+
wrapper_ui_res = run_express(file, package_name)
150+
if isinstance(wrapper_ui_res, PageHtmlDocument):
151+
return wrapper_ui_res
152+
return wrapper_ui_res.tagify()
147153

148154
app_ui = app_ui_wrapper
149155

@@ -176,7 +182,10 @@ def express_server(input: Inputs, output: Outputs, session: Session):
176182
return app
177183

178184

179-
def run_express(file: Path, package_name: str | None = None) -> Tag | TagList:
185+
def run_express(
186+
file: Path,
187+
package_name: str | None = None,
188+
) -> Tag | TagList | PageHtmlDocument:
180189
"""
181190
Run the code in a Shiny Express app file and return the UI. This is to be run in
182191
both the UI-rendering phase and the server-rendering phase of a Shiny Express app.
@@ -198,7 +207,7 @@ def run_express(file: Path, package_name: str | None = None) -> Tag | TagList:
198207
tree = DisplayFuncsTransformer().visit(tree)
199208
tree = ast.fix_missing_locations(tree)
200209

201-
ui_result: Tag | TagList = TagList()
210+
ui_result: Tag | TagList | PageHtmlDocument = TagList()
202211

203212
def set_result(x: object):
204213
nonlocal ui_result
@@ -271,7 +280,9 @@ def set_result(x: object):
271280
sys.displayhook = prev_displayhook
272281

273282

274-
_top_level_recall_context_manager: RecallContextManager[Tag] | None = None
283+
_top_level_recall_context_manager: (
284+
RecallContextManager[Tag | PageHtmlDocument] | None
285+
) = None
275286

276287

277288
def reset_top_level_recall_context_manager() -> None:
@@ -281,7 +292,9 @@ def reset_top_level_recall_context_manager() -> None:
281292
_top_level_recall_context_manager = page_auto_cm()
282293

283294

284-
def get_top_level_recall_context_manager() -> RecallContextManager[Tag]:
295+
def get_top_level_recall_context_manager() -> (
296+
RecallContextManager[Tag | PageHtmlDocument]
297+
):
285298
if _top_level_recall_context_manager is None:
286299
raise RuntimeError("No top-level recall context manager has been set.")
287300

shiny/express/ui/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
hover_opts,
5151
include_css,
5252
include_js,
53+
page_html,
5354
hide_offcanvas,
5455
input_action_button,
5556
input_action_link,
@@ -246,6 +247,7 @@
246247
"bind_task_button",
247248
"input_task_button",
248249
"input_text",
250+
"page_html",
249251
"panel_title",
250252
"input_text_area",
251253
"insert_accordion_panel",

0 commit comments

Comments
 (0)