A terragrunt replacement, in TypeScript

Run Terragrunt configurations. Or read them yourself.

Substitute tghclp for the terragrunt command and your configurations run unchanged. Because it is a library first, the same parser, evaluator and workspace graph are importable — so a configuration is something your own tooling can read, not only something a binary executes.

shell
# the same configurations, a different executable
npm install -g tghclparser

tghclp init
tghclp plan
tghclp apply --all      # terragrunt run-all apply

# and the same evaluation, as a library
import { Workspace } from 'tghclparser'
Replace

The same commands

init, plan, apply, destroy, stacks, hooks and backend bootstrap. run-all is spelled --all; nothing else about a configuration changes.

Import

A library, not just a binary

The parser, evaluator and workspace graph are exported. Read a configuration from your own code instead of shelling out and parsing text back.

Install

Wherever Node runs

One npm dependency, no Go toolchain and no platform binary to match. The same package serves CI, an editor extension and an application.

Check

Behaviour compared, not assumed

Compatibility is established by running the same configuration through both executables and comparing what each produces. A difference is a bug here.

What is here

tghclparser is community supported and is not affiliated with Gruntwork or the Terragrunt project.

Tutorial

Parse your first Terragrunt configuration

In about ten minutes, you will create a small TypeScript program that parses HCL, inspects its token tree, and prints diagnostics.

Level BeginnerTime 10 minutesResult A working parser script
Before you begin

Use a supported Node.js release and a project where TypeScript can run. The examples use tsx, but any ESM-capable TypeScript runner works.

  1. Create a project

    Start in an empty directory and install the parser, its TypeScript peer, and a development runner.

    npm init -y
    npm install tghclparser typescript
    npm install --save-dev tsx
  2. Add a Terragrunt file

    Create terragrunt.hcl. This small configuration is valid enough to show structure and references.

    locals {
      environment = "development"
    }
    
    inputs = {
      environment = local.environment
      replicas    = 2
    }
  3. Parse the document

    Create inspect.ts. A document belongs to a workspace, and both use file URIs rather than plain paths.

    import { readFile } from 'node:fs/promises';
    import { resolve } from 'node:path';
    import { pathToFileURL } from 'node:url';
    import { ParsedDocument, Workspace } from 'tghclparser';
    
    const filePath = resolve('terragrunt.hcl');
    const uri = pathToFileURL(filePath).toString();
    const content = await readFile(filePath, 'utf8');
    
    const workspace = new Workspace();
    workspace.setWorkspaceRoot(pathToFileURL(process.cwd()).toString());
    
    const document = new ParsedDocument(workspace, uri, content);
    await workspace.addDocument(document);
    
    console.log('diagnostics:', document.getDiagnostics());
    console.log('root tokens:', document.getTokens()[0]?.children.length ?? 0);
  4. Run it

    npx tsx inspect.ts

    You should see an empty diagnostics array and a non-zero root-token count. You have parsed real HCL and attached it to workspace context.

  5. Inspect the token tree

    Add this traversal after the existing output. Each token records its type, display value, source range, parent, and children.

    function printToken(token, depth = 0) {
      console.log(`${'  '.repeat(depth)}${token.type}: ${token.getDisplayText()}`);
      for (const child of token.children) printToken(child, depth + 1);
    }
    
    for (const token of document.getTokens()) printToken(token);
  6. Introduce an error

    Remove the closing brace from the inputs object and run the script again. The diagnostics now contain source-positioned feedback. Restore the brace when you are done.

What you learned

You created a Workspace, parsed content into a ParsedDocument, read syntax and schema diagnostics, and traversed the token tree. Those same objects underpin completions, hovers, links, dependency graphs, and evaluation.

How-to guide

Replace terragrunt with tghclp

Run your existing Terragrunt configurations through tghclp instead, without changing them.

Install

npm install -g tghclparser

The package installs one executable, tghclp. It needs OpenTofu or Terraform on the path, the same as terragrunt does.

Substitute the command

Configurations are unchanged. Only the executable differs.

# before
terragrunt init
terragrunt plan
terragrunt run-all apply

# after
tghclp init
tghclp plan
tghclp apply --all

Note the last line: where terragrunt spells the whole-graph run run-all <command>, this spells it <command> --all.

Check a configuration before you rely on it

Validation is a separate command, so a configuration can be checked without running anything:

tghclp hcl validate --working-dir ./infrastructure

Where a configuration reads a file outside the workspace root — a deployment plan in a sibling checkout, say — name that directory. Terragrunt has no such boundary and will not have needed this.

tghclp --allow-path /srv/plans plan

Do not share a directory mid-migration

Each tool signs the files its generate blocks write, and refuses to overwrite a file bearing the other's signature. That protects hand-written files, but it means a working directory already generated into by terragrunt will refuse a tghclp run until those files are removed:

rm -f gen_*.tf gen_*.tfvars
tghclp init

Delete only files a generate block produces. Anything a person wrote is refused for the same reason and should stay.

Where behaviour is established

Compatibility is checked by running the same configuration through both executables and comparing what each produces — the generated files, the evaluated values, the refusals — rather than by reading the documentation and assuming agreement. A difference is a bug here, and worth reporting.

How-to guide

Validate Terragrunt HCL in CI

Fail a continuous-integration job when the repository contains syntax or schema diagnostics, and preserve machine-readable results.

Install and run validation

Install tghclparser as a development dependency so the validator version is pinned by your lockfile.

npm install --save-dev tghclparser
npx tghclp hcl validate --working-dir ./infrastructure

The command recursively discovers supported Terragrunt HCL files. It exits with status 1 when diagnostics exist and 0 when validation passes.

Add a GitHub Actions job

name: Validate Terragrunt HCL

on:
  pull_request:
  push:
    branches: [main]

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-node@v6
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npx tghclp hcl validate --working-dir ./infrastructure

Capture JSON diagnostics

Use --json when another tool will consume the result. Output is a JSON array only when diagnostics are present; always use the process exit status as the pass/fail signal.

npx tghclp hcl validate \
  --json \
  --working-dir ./infrastructure \
  > diagnostics.json

List only affected configurations

npx tghclp hcl validate \
  --show-config-path \
  --working-dir ./infrastructure
Experimental syntax

Language features behind an experiment gate must be enabled explicitly with --experiment <name>. For example, use --experiment deep-merge only where that feature is intentional.

CI checklist

  • Pin the package through package-lock.json.
  • Point --working-dir at the repository subtree you intend to validate.
  • Treat the exit status—not the presence of standard output—as authoritative.
  • Upload JSON output as an artifact only when a later job needs it.

How-to guide

Build a workspace dependency graph

Discover the relationships between includes, dependencies, reads, and generated stack components.

Print DOT from the command line

For visualization tools such as Graphviz, generate a DOT graph directly.

npx tghclp dag graph --working-dir ./infrastructure > graph.dot
dot -Tsvg graph.dot -o graph.svg

To graph discovered configurations through list, enable dependency edges explicitly:

npx tghclp list \
  --format=dot \
  --dependencies \
  --working-dir ./infrastructure

Build the graph in TypeScript

import { pathToFileURL } from 'node:url';
import { Workspace } from 'tghclparser';

const workspace = new Workspace();
workspace.setWorkspaceRoot(pathToFileURL('/repo/infrastructure').toString());

const root = await workspace.refreshDependencyTree();

await root?.breadthFirstTraversal(async (node, depth) => {
  console.log(`${'  '.repeat(depth)}${node.type}: ${node.name}`);
  return true;
});

Query one configuration

Use a file URI when asking for dependencies or dependents.

const unitUri = pathToFileURL(
  '/repo/infrastructure/app/terragrunt.hcl'
).toString();

const dependencies = await workspace.getDependencies(unitUri);
const dependents = await workspace.getDependents(unitUri);

console.log(dependencies.map((config) => config.targetPath));
console.log(dependents.map((config) => config.targetPath));
What counts as an edge?

The graph records include relationships, dependency blocks, dependencies.paths, files consumed by read_terragrunt_config, and explicit unit or stack targets.

How-to guide

Add language features to an editor

Turn an open Terragrunt file into diagnostics, completions, hover content, and navigable document links.

Create and register a document

import { pathToFileURL } from 'node:url';
import { ParsedDocument, Workspace } from 'tghclparser';

const rootUri = pathToFileURL('/repo/infrastructure').toString();
const fileUri = pathToFileURL('/repo/infrastructure/app/terragrunt.hcl').toString();

const workspace = new Workspace();
workspace.setWorkspaceRoot(rootUri);

const document = new ParsedDocument(workspace, fileUri, sourceText);
await workspace.addDocument(document);

Read language-service results

Positions are zero-based LSP positions: the first line and first character are both 0.

const position = { line: 8, character: 17 };

const diagnostics = document.getDiagnostics();
const completions = await document.getCompletionsAtPosition(position);
const hover = await document.getHoverInfo(position);
const links = await document.getLinks();
const token = document.findTokenAtPosition(position);

Handle edits

Update the document content, then re-add it so the workspace rebuilds relationships owned by that document.

document.setContent(updatedSourceText);
await workspace.addDocument(document);

publishDiagnostics(fileUri, document.getDiagnostics());

Handle close events

workspace.removeDocument(fileUri);
Protocol types

Diagnostics, completions, hovers, positions, and links use the types from vscode-languageserver. You can pass them directly through an LSP implementation or adapt them for another editor protocol.

Reference

Command line

The tghclp executable validates, discovers, formats, evaluates, scaffolds, and orchestrates Terragrunt configurations. It is a drop-in replacement for the terragrunt command.

Synopsis

tghclp [global options] <command> [options]

Terragrunt equivalents

Each command below does what the terragrunt command beside it does. Substituting one executable for the other requires no change to a configuration.

tghclpterragrunt
tghclp initterragrunt init
tghclp planterragrunt plan
tghclp applyterragrunt apply
tghclp apply --allterragrunt run-all apply
tghclp run -- <command>terragrunt run -- <command>
tghclp hcl validateterragrunt hcl validate
tghclp stack generateterragrunt stack generate
CommandPurpose
hcl validateRecursively validate supported HCL and JSON configurations.
hcl formatFormat HCL with the selected OpenTofu or Terraform binary.
find, fdDiscover unit and stack configurations.
list, lsList configurations as text, a tree, or DOT.
dag graphPrint the resolved configuration graph in DOT form.
renderEvaluate and print a configuration as JSON. Dependency outputs that are absent or could not be read, because the output command failed or returned no output map, leave affected fields out; stderr names them with the reason and exit status 2 marks the JSON incomplete. Invalid configuration exits 1 without JSON.
info printPrint evaluation-context paths and command details.
runValidate, run hooks, then invoke OpenTofu or Terraform without a shell.
execExecute an external command directly without a shell.
stack generateGenerate explicit stack component targets.
stack runRun a command over generated stack units.
stack outputCollect output from generated stack units.
stack cleanRemove generated stack material.
scaffoldCreate a unit configuration for a local module.
catalogPrint configured catalog entries as JSON Lines.
backendBootstrap, delete, or migrate backend state.
inspectPrint what the language service holds for a configuration as JSON. A registry module source (tfr://) is fetched first, with the ambient registry credentials, so its variables are shown.
cache clearDelete the remote modules fetched into the per-user cache, so the next use fetches them again.

Validation options

OptionMeaning
--working-dir <path>Resolve discovery and relative paths from this directory.
--jsonWrite diagnostics as a JSON array.
--show-config-pathWrite invalid configuration paths instead of diagnostic objects.
--experiment <name>Enable one named experimental language feature. Repeatable.
--help, -hPrint help for the selected command.

Discovery options

OptionMeaning
--format <format>text or json for find; text, tree, or dot for list.
--no-hiddenExclude hidden directories during discovery.
--dependenciesInclude dependency edges. Valid with DOT output.
--working-dir <path>Set the discovery root.

Global options

OptionMeaning
--allow-path <dir>Permit configurations to read this directory, which is otherwise refused for being outside the workspace root. Repeatable.
--non-interactiveAccepted and ignored; nothing here prompts. Also accepted as --terragrunt-non-interactive.
--no-color, --no-tipsAccepted and ignored. Also accepted under their --terragrunt- spellings.

Terragrunt renamed those flags when it dropped the prefix, and scripts still pass the old names, so both spellings are accepted. Only flags this honours are aliased: a legacy flag with no equivalent here — --terragrunt-download-dir, say — is refused rather than accepted and quietly dropped, because a caller that believes a flag took effect is worse off than one told it did not.

The workspace boundary comes from this tool's other life as a language server, where a configuration is opened before anyone has vouched for it. Terragrunt has no such boundary, so a layout it accepts can be one this refuses — a deployment plan kept in a sibling checkout, for instance. Name each such directory rather than widening the root, which would have to reach an ancestor broad enough to stop being a boundary at all.

tghclp --allow-path /srv/plans init

Execution behavior

run and OpenTofu shortcut commands validate the selected configuration before starting the executable. Use --tf-path <path> to select a binary; the default is tofu, or the value of TG_TF_PATH / TERRAGRUNT_TFPATH.

OptionMeaning
--all, -aRun every discovered unit in dependency order, as terragrunt's run-all does. Without it, a directory holding more than one unit is an error rather than a guess.
--working-dir <path>Directory holding the configuration to run.
--tf-path <path>The OpenTofu or Terraform executable to invoke.
tghclp run --working-dir ./infrastructure/app -- plan
tghclp plan --working-dir ./infrastructure/app
tghclp apply --all --working-dir ./infrastructure
tghclp run --tf-path terraform --working-dir ./app -- apply

The executable is spawned without a shell. Arguments are passed directly.

Generated files

A generate block writes its file with a signature line naming this tool. if_exists = "overwrite_terragrunt" then means what it says — overwrite a file this tool generated — and a file without that signature is refused rather than destroyed.

The signature is deliberately not terragrunt's own. Each tool refuses a file bearing the other's mark, so a directory generated into by one is not silently taken over by the other, and the message says which tool wrote what.

Reference

JavaScript API

Public exports from the tghclparser package root. The package provides ESM, CommonJS, and TypeScript declarations.

Core classes

new ParsedDocument(workspace, uri, content)

Parses one document immediately and exposes syntax, token, evaluation, and language-service queries.

MethodReturns / effect
getUri()The document URI.
getContent()The current source text.
setContent(content)Replaces source text and reparses the document.
getAST()The parser AST, or null when unavailable.
getTokens()Top-level Token[].
getDiagnostics()LSP Diagnostic[].
getCompletionsAtPosition(position)A promise of LSP CompletionItem[].
getHoverInfo(position)A promise of LSP markup, or null.
getLinks()A promise of LSP DocumentLink[].
findTokenAtPosition(position)The narrowest token at an LSP position, or null.
getAllLocals()A map of evaluated local values.
getAllOutputs()A map of outputs discovered from Terraform state.
evaluateValue(token, targetName?)The evaluated runtime value, when resolvable.

new Workspace()

Owns documents and configuration relationships for a workspace root.

MethodReturns / effect
setWorkspaceRoot(uri)Sets the root as a URI string.
addDocument(document)Adds or updates a parsed document and its relationships.
removeDocument(uri)Removes an open document.
findTerragruntConfigs(rootDir)Discovers configuration paths below a filesystem directory.
refreshDependencyTree()Builds and returns TreeNode<TerragruntConfig>.
getParsedDocument(uri)Loads or returns a parsed document.
getDependencies(uri)Returns direct dependency configurations.
getDependents(uri)Returns configurations that depend on the URI.
getReferencingConfigs(uri)Returns all configurations referencing the URI.
getConfigTreeRoot()Returns the most recently built graph root.
configureRemoteModules(policy, options)Sets whether registry module sources (tfr://) are fetched, and how: credentials, the host approval callback, the cache directory. Until it is called, a unit naming a remote source has no module state.
onModuleVariablesChanged(listener)Calls the listener with a unit's URI when a background fetch has landed and its diagnostics should be published again.
remoteModulesSettled()Resolves once no fetch is in flight and every waiting unit has been updated.
clearRemoteModuleCache()Deletes the fetched modules and fetches again for open units.

new ConfigEvaluator(options)

Evaluates Terragrunt configuration values with explicit environment and trust settings.

const evaluator = new ConfigEvaluator({
  environmentVariables: process.env,
  workspaceTrusted: true,
  terraformCommand: 'plan',
  terraformCliArgs: []
});
MethodReturns / effect
setWorkspaceTrusted(value)Changes the evaluator trust state.
evaluateUnit(path, content, workDir){ valid, inputs, error?, unresolved? }; evaluation errors are captured. unresolved is true when the error is a value that cannot be known yet, such as a declared dependency with no outputs, rather than a fault in the configuration.
evaluateRenderedConfig(path, content, workDir)A complete RuntimeValue; errors are thrown.
evaluateAtPosition(path, content, workDir, position)The value of the function call at the zero-based position, if any.
evaluatedSpans(path, content, workDir)EvaluatedSpan[] — { start, end, kind, value } by character offset for every expression, and every attribute name (kind says which), whose value is not already spelled out in its source; literals written as their value are left out. One evaluation of the file.

The resolveDependency(request) option reads a dependency's outputs. The evaluator finds the dependency block in the unit and the configurations merged into it, the way Terragrunt merges them, evaluates config_path and the mocks where they are written, and passes a DependencyRequest: { name, unitPath, declaredIn, targetConfigPath, targetDir, mockOutputs?, mockOutputsAllowedTerraformCommands? }, with config_path resolved against the unit's directory. The resolver resolves to { outputs: … }, or to undefined when the dependency has no outputs yet, which evaluation reports as unresolved. A dependency declared nowhere the unit merges is a fault the evaluator reports itself. Each dependency is resolved once per evaluation.

Schema.getInstance()

Returns the schema singleton used for file-kind, block, attribute, and function definitions.

Lookup methods include getFileKind, getRootBlockDefinitions, getRootAttributeDefinitions, getBlockDefinition, getAllBlockTemplates, getAllFunctions, and getFunctionDefinition. Snippet helpers and value-validation methods are also public.

FunctionRegistry.getInstance()

Returns the registry for named Terragrunt function implementations and serializable function operations.

Register implementations with registerFunction, operations with registerOperation, or a namespaced group with registerFunctionGroup. Duplicate names throw.

Utility exports

ExportPurpose
runtimeValueToPlainConverts nested typed runtime values and maps to plain JavaScript values.
TokenA source-positioned node with type, value, children, parent, decorators, and display helpers.
FunctionOperationAn ops-ts operation wrapper for a named function evaluator.
invokeFunctionOperationInvokes an operation with serialized arguments and live evaluation context.
CompletionsProviderComputes completion items from schema and document context.
DiagnosticsProviderComputes schema and reference diagnostics.
HoverProviderComputes markup content at a token.
LinkProviderResolves navigable links in a document.

Primary types

RuntimeValue preserves an explicit value type. Objects and blocks contain Map<string, RuntimeValue>; arrays contain nested runtime values. TerragruntConfig records a configuration's URI, content, incoming and outgoing relationships, source and target paths, dependency type, and optional outputs.

Reference

Supported files

File names select a schema. The parser accepts general HCL syntax, while diagnostics and completions follow the Terragrunt file kind.

FileKind and role
terragrunt.hclunit — a deployable unit configuration.
root.hclunit — a conventional named shared configuration.
*.hclunit — named shared unit configurations use the unit schema.
terragrunt.stack.hclstack — explicit unit and stack component declarations.
terragrunt.values.hclvalues — values generated or consumed by stack components; arbitrary root attributes are allowed.
terragrunt.autoinclude.hclunit-autoinclude — configuration automatically applied to matching units.
terragrunt.autoinclude.stack.hclstack-autoinclude — configuration automatically applied to matching stacks.
terragrunt.hcl.jsonJSON form of a unit configuration.
terragrunt.stack.hcl.jsonJSON form of a stack configuration.
terragrunt.values.hcl.jsonJSON values configuration; arbitrary object keys are accepted.

Discovery

The CLI's find, list, and graph discovery commands look specifically for terragrunt.hcl and terragrunt.stack.hcl. Validation recognizes the broader supported filename set.

Discovery skips .git, .scrap, .terraform, .terragrunt-cache, .terragrunt-stack, .trash, and node_modules. With --no-hidden, it skips all hidden directories.

Language regime

Version 1 follows the current Terragrunt language regime. Removed or deprecated compatibility syntax is intentionally not accepted. The schema covers unit configuration, stack components, autoincludes, features, excludes, error policies, catalogs, IaC engines, CAS source controls, and Terragrunt functions.

Explanation

How the toolkit fits together

tghclparser separates syntax, language knowledge, workspace context, and semantic evaluation because each answers a different question.

Four layers, four questions

1 · Grammar

What was written?

The Peggy grammar turns characters into an AST while retaining exact source locations.

2 · Schema

What is allowed here?

File-kind-aware definitions describe valid blocks, attributes, functions, parameters, and value types.

3 · Workspace

What does this connect to?

Documents become a graph of includes, dependencies, reads, stacks, units, and state outputs.

4 · Evaluator

What does it mean?

Typed runtime values resolve locals, references, expressions, includes, and supported functions.

Why tokens sit between syntax and features

The raw parser AST closely follows grammar rules. A ParsedDocument also builds a navigable Token tree. Tokens normalize the shapes that completions, hovers, links, diagnostics, and reference resolution need while preserving parent/child relationships and source ranges.

This keeps editor providers from each having to reinterpret raw parser nodes. It also gives callers a stable vocabulary such as block, attribute, reference, function_call, and interpolation.

Why a document needs a workspace

Terragrunt configuration is rarely meaningful in isolation. An include can expose locals, a dependency can expose state outputs, a stack can declare generated component targets, and a function can read another configuration. The workspace owns that cross-file context.

A document can still report local syntax and schema diagnostics immediately. Registering it with a workspace adds the context needed for reference-aware results and graph queries.

Why runtime values keep their types

Evaluation does not collapse everything into plain JavaScript. RuntimeValue<T> carries an explicit Terragrunt value type, and nested collections contain more runtime values. This allows providers to retain type knowledge, sensitive wrappers, and origin information while computing results. Convert to plain values only at an application boundary with runtimeValueToPlain.

Language service versus execution

Parsing and most editor assistance are observational: they analyze text. Semantic evaluation can read workspace files and invoke selected functions. Command execution goes further by starting an OpenTofu, Terraform, or explicitly requested external program. Those capabilities are kept behind separate APIs and trust checks.

Explanation

Trust and evaluation

Parsing text is different from evaluating a repository. The trust boundary makes that distinction explicit.

Parsing is local analysis

Constructing a ParsedDocument parses its supplied string, creates tokens, and computes immediate diagnostics. It does not need permission to execute arbitrary programs.

Evaluation can observe the workspace

Terragrunt semantics include functions that read files, find parent configurations, inspect environment variables, and derive paths. Accurate evaluation therefore needs a filesystem context. ConfigEvaluator refuses semantic evaluation unless workspaceTrusted is explicitly true.

Trust is a caller decision

Set workspaceTrusted: true only after your application has established that the user trusts the workspace. Do not infer trust from a file extension or from successful parsing.

Paths stay inside the workspace root

Trusted file access resolves real paths and rejects targets outside the workspace root. Resolving real paths is important because a path that looks internal can escape through a symbolic link.

Function operations separate data from capabilities

Function evaluation uses named operations. Arguments travel through a serializable dry context; live services such as filesystem access or command execution travel through a wet context. This boundary makes capability-bearing context visible instead of smuggling it into ordinary values.

Remote modules reach the network

A unit whose terraform { source } names a registry module can have that module fetched, so its variables drive input completion, hover and checks. This is the one place the language service contacts a network, and it does so only when the caller asks: Workspace.configureRemoteModules takes a policy that must be both enabled and trusted, and an approveHost callback that is asked before the first contact with each registry or git host. A workspace that never calls it leaves remote sources alone.

A registry token is sent to its registry host alone, over TLS. Every host a request reaches, including one a registry redirects to, must be named rather than addressed and must resolve outside the loopback and link-local ranges. Git runs without a shell, with prompts disabled and the repository URL kept off its command line. Only the top-level .tf files of a module are kept, read from the object store or the archive stream without unpacking anything, into an owner-only per-user cache.

The CLI does not invoke a shell

run and exec spawn the selected executable with an argument array and shell: false. Shell operators in an argument are not interpreted as shell syntax. This removes a common command-injection surface, although callers must still choose trusted executables and arguments.

Recommended integration policy

  • Allow parsing and local diagnostics before workspace trust.
  • Gate semantic evaluation and workspace file reads on explicit trust.
  • Keep the workspace root as narrow as practical.
  • Pass environment variables intentionally; they are part of evaluation input.
  • Require a separate user action before executing infrastructure commands.
  • Fetch remote modules only in a trusted workspace, and ask the user before the first contact with each host.
Press Esc to close · Search matches page titles and content