library(dplyr)
library(purrr)
library(quanteda)
library(shiny)
library(stringr)
library(tibble)
library(tidyr)
source("R/inaugural-corpus.R")
paragraphs <- inaugural_paragraphs()
inaugural_tokens <- paragraphs |>
select(paragraph_id, paragraph) |>
tibble::deframe() |>
corpus() |>
tokens(remove_punct = TRUE)
find_in_context <- function(word, window = 5L) {
kwic(
inaugural_tokens,
pattern = word,
window = window,
valuetype = "fixed",
case_insensitive = TRUE
) |>
as.data.frame() |>
as_tibble() |>
transmute(
paragraph_id = docname,
before = pre,
keyword,
after = post
)
}Creating a small text app
Present a keyword-in-context app without inactive controls on the lesson page
Leila helps a local history class examine inaugural speeches. The class wants a search box that displays words near a match. Those results help readers decide which passages to examine.
Interactive app creation provides an interface for using an NLP function without writing R code. In Shiny, the UI is the page people see. The server is the R function that reads the input and computes the output.
This lesson builds the app object and examines its server logic during rendering. It does not start the app inside this static page. A printed text box would appear interactive but have no Shiny process to respond.
This lesson shows how to:
- define a small Shiny UI, server function, and app object
- provide a keyword-in-context search from quanteda
- trim text input on the server
- validate text input on the server
- examine Shiny server logic with
testServer() - show UI HTML as text without placing live controls on the page
- state the limits of a server-only test
Build the search function first
The app will search paragraphs from the inaugural-address corpus bundled with quanteda. A keyword-in-context display, or KWIC, puts the matched word in the middle and a few nearby words on each side. Lesson 25 introduced KWIC as an inspection tool. Here an app provides access to that function.
| Item | Value |
|---|---|
| paragraphs | 1377 |
| speeches | 60 |
| Shiny version | 1.14.0 |
| matching rule | fixed, case-insensitive token matching |
The important choice is valuetype = "fixed". Without it, quanteda treats the pattern as glob syntax by default. In this app, a typed asterisk is a literal asterisk, not a request to match every token.
Define the app without starting it
The UI has one visible label, one text input, a short status line, and a table for matches. The server trims the typed word before req() receives it. It then uses validate(need()) to display a message for invalid input.
max_word_chars <- 30L
max_rows_shown <- 8L
ui <- fluidPage(
title = "Inaugural KWIC explorer",
lang = "en",
h2("Inaugural KWIC explorer"),
p(
"Type one word. Press Enter. You can use hyphenated words but not spaces."
),
textInput(
inputId = "word",
label = "Word to find (one word, 30 characters or fewer)",
value = "",
placeholder = "liberty",
updateOn = "blur"
),
textOutput("status"),
tableOutput("matches")
)
server <- function(input, output, session) {
context_rows <- reactive({
word <- str_trim(input$word %||% "")
req(nzchar(word))
validate(
need(nchar(word) <= max_word_chars, "Enter at most 30 characters."),
need(!str_detect(word, "\\s"), "Enter one word, with no spaces.")
)
find_in_context(word)
})
output$status <- renderText({
rows <- context_rows()
word <- str_trim(input$word %||% "")
if (nrow(rows) == 0L) {
paste0(
"No word in these paragraphs matches '",
word,
"'. Marks at the start or end of a word, such as commas, are not part of the word here."
)
} else {
shown <- min(nrow(rows), max_rows_shown)
paste0(
"Matches: ",
nrow(rows),
if_else(nrow(rows) == 1L, " line", " lines"),
" for '",
word,
"'. Displayed lines: ",
shown,
"."
)
}
})
output$matches <- renderTable({
context_rows() |>
slice_head(n = max_rows_shown)
})
}
app <- shinyApp(ui = ui, server = server)reactive() manages the KWIC calculation so both outputs use the same current rows after input changes. %||% converts a missing input to empty text. req() stops without a displayed message until a word exists. validate(need()) stops with a visible message when input violates the appβs rules. renderText() and renderTable() convert R values to page text and a table.
fluidPage() builds the app page. h2() and p() add a heading and a paragraph. The title argument names the browser tab. lang = "en" identifies English for screen readers.
When Shiny serves the app, it places the title in the page head. It places the language on the <html> element. Neither appears in the printed body HTML. shinyApp() combines the UI and server for Shiny to serve together.
With updateOn = "blur", search executes only when the reader presses Enter or leaves the box. Results therefore do not receive a new announcement after every keystroke. testServer() cannot examine that browser behavior.
Typed text passes only through renderText() and renderTable(), not through raw HTML. These functions treat markup-like input as text. The server enforces length and spacing limits because users can bypass browser-side checks.
The server keeps the typed word only in the current Shiny sessionβs memory. It writes nothing to disk, prints nothing, logs nothing, and uses no cache. That does not make every hosted app private. A hosting service still has process logs, and a developer could add logging later. The boundary here is narrower: this example app does not store or log the userβs word.
Show the UI as text
The lesson page must not contain a live Shiny form. The chunk prints UI HTML as plain console output. The browser therefore shows text, not a working input.
ui_html <- paste(as.character(ui), collapse = "\n")
cat(ui_html)<div class="container-fluid">
<h2>Inaugural KWIC explorer</h2>
<p>Type one word. Press Enter. You can use hyphenated words but not spaces.</p>
<div class="form-group shiny-input-container">
<label class="control-label" id="word-label" for="word">Word to find (one word, 30 characters or fewer)</label>
<input id="word" type="text" class="shiny-input-text form-control" value="" placeholder="liberty" data-update-on="blur"/>
</div>
<div id="status" class="shiny-text-output"></div>
<div id="matches" class="shiny-html-output shiny-table-output"></div>
</div>
The text output lets a reader examine the generated label and input. In the printed HTML, the labelβs for="word" matches the inputβs id="word". That link supplies the boxβs name to a screen reader. The placeholder liberty is only a hint. This page does not accept typed input.
Examine server behavior
testServer() executes the server function without starting a browser. session$setInputs() simulates typed input. The test can then read the serverβs outputs.
The helper starts a new test session for each typed value and records the result. tryCatch() records a stop, such as a failed need() check, instead of halting the page with an error.
run_server_case <- function(typed_text) {
result <- NULL
shiny::testServer(server, {
session$setInputs(word = typed_text)
status <- tryCatch(
list(type = "output", text = output$status, class = NA_character_),
error = \(error) {
list(
type = "condition",
text = conditionMessage(error),
class = class(error)[[1]]
)
}
)
found <- tryCatch(context_rows(), error = \(error) NULL)
result <<- tibble(
rows = if (is.null(found)) NA_integer_ else nrow(found),
result_type = status$type,
server_result = status$text,
condition_class = status$class,
rows_shown = list(if (is.null(found)) NULL else slice_head(found, n = max_rows_shown))
)
})
result
}
server_cases <- tribble(
~case, ~typed_text,
"known word", "liberty",
"empty text", "",
"three spaces", " ",
"two words", "free people",
"over 30 characters", strrep("a", 31L),
"literal asterisk", "*",
"markup-like text", "<b>",
"punctuated word", "liberty,",
"no matching word", "zzzzzz"
)
case_results <- server_cases |>
mutate(result = map(typed_text, run_server_case)) |>
unnest(result)The HTML table escapes recorded server results, so the browser displays markup characters as text. It does not interpret hostile-looking input as page markup.
A condition row means the server stopped instead of returning output. req() stops with an empty message, while validate(need()) stops with the message shown. βNot applicableβ marks a Rows cell where the server stopped before returning any rows.
| Case | Typed text | Rows | Result type | Server result |
|---|---|---|---|---|
| known word | liberty | 118 | output | Matches: 118 lines for 'liberty'. Displayed lines: 8. |
| empty text | "" | not applicable | condition | |
| three spaces | " " | not applicable | condition | |
| two words | free people | not applicable | condition | Enter one word, with no spaces. |
| over 30 characters | aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa | not applicable | condition | Enter at most 30 characters. |
| literal asterisk | * | 0 | output | No word in these paragraphs matches '*'. Marks at the start or end of a word, such as commas, are not part of the word here. |
| markup-like text | <b> | 0 | output | No word in these paragraphs matches '<b>'. Marks at the start or end of a word, such as commas, are not part of the word here. |
| punctuated word | liberty, | 0 | output | No word in these paragraphs matches 'liberty,'. Marks at the start or end of a word, such as commas, are not part of the word here. |
| no matching word | zzzzzz | 0 | output | No word in these paragraphs matches 'zzzzzz'. Marks at the start or end of a word, such as commas, are not part of the word here. |
The table below is the first app table for liberty, captured from the same server test. It shows the first eight KWIC rows even though the full result has 118 lines.
| Paragraph ID | Before | Keyword | After |
|---|---|---|---|
| 1789-Washington-p03 | of the sacred fire of | liberty | and the destiny of the |
| 1797-Adams-p04 | and secure the blessings of | liberty | The public disquisitions discussions and |
| 1797-Adams-p09 | ardent patriotism and love of | liberty | to independence and peace to |
| 1797-Adams-p13 | of the world of virtuous | liberty | continue His blessing upon this |
| 1801-Jefferson-p02 | harmony and affection without which | liberty | and even life itself are |
| 1801-Jefferson-p02 | blood and slaughter his long-lost | liberty | it was not wonderful that |
| 1801-Jefferson-p04 | which alone leads to peace | liberty | and safety |
| 1805-Jefferson-p08 | breathing an ardent love of | liberty | and independence and occupying a |
Limits of server tests
Server tests do not exercise layout, browser JavaScript, focus order, keyboard operation, or screen-reader output. They cannot examine Shinyβs live-region announcements in a browser. An app for public users requires browser tests and manual accessibility checks.
To start the app locally:
- Open an R session at the project root.
- Execute the chunks in βBuild the search function firstβ.
- Execute the chunks in βDefine the app without starting itβ.
- Execute
shiny::runApp(app).
The app remains available only while that R session serves it.
What question does this app answer?
The app helps a reader examine context lines. It does not say a word is important, calculate a topic estimate, or deploy a model. KWIC counts depend on the token rules, punctuation handling, and case policy. Here the unit is the token stream created from reconstructed paragraphs, and matching is fixed and case-insensitive.
What to remember
- A Shiny app has a UI object, a server function, and an app object.
- A static lesson page should not print a live-looking Shiny input with no server behind it.
- Trim text before
req()so whitespace-only input pauses instead of searching. - Use server-side
need()checks for invalid values such as spaces or too many characters. - Fixed matching keeps
*and(from becoming pattern syntax. testServer()examines server logic, not the browser experience.
The static lesson page demonstrates app construction and server tests. Start the interactive app in a local R session.
Sources
- Mastering Shiny: Your first Shiny app
- Mastering Shiny: Testing
- Shiny reference for
req() - Shiny reference for
validate()andneed() - Shiny article on server function testing
- Mastering Shiny: Security
- OWASP Logging Cheat Sheet
- W3C Web Content Accessibility Guidelines 2.2
- W3C WAI forms tutorial on labels and instructions
- UK Government Digital Service accessibility tools audit
- quanteda
kwic()reference