Parameterized Routes
Observable Framework documentation: Parameterized Routes Source: https://observablehq.com/framework/params
Parameterized routes allow a single Markdown source file or page loader to generate many pages, or a single data loader to generate many files.
A parameterized route is denoted by square brackets, such as [param], in a file or directory name. For example, the following project structure could be used to generate a page for many products:
.
├─ src
│ ├─ index.md
│ └─ products
│ └─ [product].md
└─ ⋯
(File and directory names can also be partially parameterized such as prefix-[param].md or [param]-suffix.md, or contain multiple parameters such as [year]-[month]-[day].md.)
The dynamicPaths config option would then specify the list of product pages:
export default {
dynamicPaths: [
"/products/100736",
"/products/221797",
"/products/399145",
"/products/475651",
…
]
};
Rather than hard-coding the list of paths as above, you’d more commonly use code to enumerate them, say by querying a database for products. In this case, you can either use top-level await or specify the dynamicPaths config option as a function that returns an async iterable. For example, using Postgres.js you might say:
import postgres from "postgres";
const sql = postgres(); // Note: uses psql environment variables
export default {
async *dynamicPaths() {
for await (const {id} of sql`SELECT id FROM products`.cursor()) {
yield `/products/${id}`;
}
}
};
Params in JavaScript
Within a parameterized page, <code>observable.params.<i>param</i></code> exposes the value of the parameter <code><i>param</i></code> to JavaScript fenced code blocks and inline expressions, and likewise for any imported local modules with parameterized routes. For example, to display the value of the product parameter in Markdown:
The current product is ${observable.params.product}.
Since parameter values are known statically at build time, you can reference parameter values in calls to FileAttachment. For example, to load the JSON file /products/[product].json for the corresponding product from the page /products/[product].md, you could say:
const info = FileAttachment(`${observable.params.product}.json`).json();
This is an exception: otherwise FileAttachment only accepts a static string literal as an argument since Framework uses static analysis to find referenced files. If you need more flexibility, consider using a page loader to generate the page.
Params in data loaders
Parameter values are passed as command-line arguments such as --product=42 to parameterized data loaders. In a JavaScript data loader, you can use parseArgs from node:util to parse command-line arguments.
For example, here is a parameterized data loader sales-[product].csv.js that generates a CSV of daily sales totals for a particular product by querying a PostgreSQL database:
import {parseArgs} from "node:util";
import {csvFormat} from "d3-dsv";
import postgres from "postgres";
const sql = postgres(); // Note: uses psql environment variables
const {
values: {product}
} = parseArgs({
options: {product: {type: "string"}}
});
const sales = await sql`
SELECT
DATE(sale_date) AS sale_day,
SUM(quantity) AS total_quantity_sold,
SUM(total_amount) AS total_sales_amount
FROM
sales
WHERE
product_id = ${product}
GROUP BY
DATE(sale_date)
ORDER BY
sale_day
`;
process.stdout.write(csvFormat(sales));
await sql.end();
Using the above data loader, you could then load sales-42.csv to get the daily sales data for product 42.
Params in page loaders
As with data loaders, parameter values are passed as command-line arguments such as --product=42 to parameterized page loaders. In a JavaScript page loader, you can use parseArgs from node:util to parse command-line arguments. You can then bake parameter values into the resulting page code, or reference them dynamically in client-side JavaScript using observable.params.
For example, here is a parameterized page loader sales-[product].md.js that renders a chart with daily sales numbers for a particular product, loading the data from the parameterized data loader sales-[product].csv.js shown above:
import {parseArgs} from "node:util";
const {
values: {product}
} = parseArgs({
options: {product: {type: "string"}}
});
process.stdout.write(`# Sales of product ${product}
~~~js
const sales = FileAttachment(\`sales-${product}.csv\`).csv({typed: true});
~~~
~~~js
Plot.plot({
x: {interval: "day", label: null},
y: {grid: true},
marks: [
Plot.barY(sales, {x: "sale_day", y: "total_sales_amount", tip: true}),
Plot.ruleY([0])
]
})
~~~
`);
In a page generated by a JavaScript page loader, you typically don’t reference observable.params; instead, bake the current parameter values directly into the generated code. (You can still reference observable.params in the generated client-side JavaScript if you want to.) Framework’s theme previews are implemented as parameterized page loaders; see their source for a practical example.
Precedence
If multiple sources match a particular route, Framework choses the most-specific match. Exact matches are preferred over parameterized matches, and higher directories (closer to the root) are given priority over lower directories.
For example, for the page /product/42, the following sources might be considered:
/product/42.md(exact match on static file)/product/42.md.js(exact match on page loader)/product/[id].md(parameterized static file)/product/[id].md.js(parameterized page loader)/[category]/42.md(static file in parameterized directory)/[category]/42.md.js(page loader in parameterized directory)/[category]/[product].md(etc.)/[category]/[product].md.js
(For brevity, only JavaScript page loaders are shown above; in practice Framework will consider all registered interpreters when checking for page loaders. Archive data loaders are also not shown.)