@cremadesign/menu-schema-generator
v1.0.0
Published
Browser-side class that reads a restaurant page's menu from the live DOM and attaches it to the page's schema.org Restaurant as a nested Menu (hasMenu).
Maintainers
Readme
Menu Schema Generator
A reusable, browser-side ES6 class that reads a restaurant page's menu out of the
live DOM and attaches it to that page's schema.org
Restaurant as a nested Menu (hasMenu). No build step, no dependencies, no
per-site configuration.
It finds the menu region by content density, reads sections and items by
structure rather than by fixed tag names (so it survives an h4 becoming an
h3), and writes a JSON-LD <script> — creating one, or merging into an
existing Restaurant. A bar page is modeled as a department (BarOrPub).
Features
- Zero-config — works across very different menu markups (
h4 + p,h3 + p,p > strong + em,ul > li,<br>-separated price lines) with no options. - Structure-based parsing — density-scored content root, relative-heading sectioning, role-based item detection; resilient to tag changes.
- Prices → Offers — auto-detects the page's price separator; single prices
and labeled multi-prices (Glass/Bottle, Cup/Bowl, "14 (shrimp), 15 (steak)")
become
Offers. Items without prices are still emitted. - Bar as department — a bar/landing page is detected from the URL and nested
under
Restaurant.department(BarOrPub). - Idempotent — safe to call repeatedly; merges into an existing
Restaurantin place without clobbering sibling fields, or injects a marked<script>. - Fail-closed — never throws into the page, never injects a partial result;
returns
nullon a no-op.
Usage
Include the script and call build() once the DOM is ready:
<script src="MenuSchemaGenerator.js"></script>
<script>
new MenuSchemaGenerator().build();
</script>That single call is all most pages need. The helper below adds DOM-ready timing and a debug flag read from the URL.
Drop-in initializer
// Reads ?debug=true|1|on (or a bare ?debug) from the current URL. The site
// decides the environment; the generator just receives the flag.
const isDebug = (() => {
const value = new URLSearchParams(location.search).get('debug');
return value === '' || value === 'true' || value === '1' || value === 'on';
})();
// Runs the generator once the DOM is parsed.
function initMenuSchema(options = {}) {
const start = () => new MenuSchemaGenerator({ debug: isDebug, ...options }).build();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start);
} else {
start();
}
}Debug detection lives in the host page, not the generator. The flag is read
fresh per URL — to keep diagnostics on across a multi-page menu, append
?debug=true to each page.
Per-site examples
The same initMenuSchema() call handles every layout below with zero config.
All locations on one page, split into tabs — section tabs become
MenuSections; an existing Restaurant schema on the page is matched and its
hasMenu is filled in. A "landing"/bar page is detected from the URL and modeled
as a department (BarOrPub).
initMenuSchema();
// Force/deny the department with { department: 'BarOrPub' } or { department: false }.Menu spread across separate pages (one category per page) — each page yields
one MenuSection under a generic Menu, and every page shares the same @id
(the site origin) so the pages resolve to one restaurant. A wine page nests
sub-sections (Rosé/White/Red) and emits Glass/Bottle offers automatically; a
router page or an empty page is a no-op.
initMenuSchema();A restaurant with an in-restaurant bar — the dining menu and the /bar page
are separate pages; the bar page is detected from the URL and emitted as
department: BarOrPub. Pages without prices still emit valid MenuItems
(without offers).
initMenuSchema();A single menu page — tabbed sections with strong/em item markup parse the
same as every other site.
initMenuSchema();Optional overrides (rarely needed)
initMenuSchema({
restaurantName: 'Georgia Blue',
restaurantUrl: 'https://georgiablue.net',
schemaId: 'georgia-blue-madison',
mergeStrategy: 'preserve',
currency: 'USD'
});Running code after the schema is attached
onComplete runs after every build() with { schema, injected, script,
diagnostics }: the Restaurant object (or null on a no-op), whether a menu was
written, the affected <script> element, and the debug diagnostics. A throwing
callback is caught and never breaks the page.
initMenuSchema({
onComplete({ schema, injected, script, diagnostics }) {
if (!injected) {
return; // no parseable menu on this page — nothing was written
}
// The schema was attached; react to it here.
script.id = 'menu-schema';
if (diagnostics) {
console.table(diagnostics);
}
}
});API
const generator = new MenuSchemaGenerator(options);
const restaurant = generator.build(); // the Restaurant object, or null on a no-opbuild() parses the page, injects/updates the JSON-LD <script>, returns the
Restaurant object (or null), and fires onComplete.
Options
All optional.
| Option | Purpose | Default |
|---|---|---|
| restaurantName | Override the derived restaurant name | og:site_name → cleaned <title> |
| restaurantUrl | Override the canonical URL used for @id/url | <link rel=canonical> → og:url → location.origin |
| menuName | Set Menu.name | omitted |
| schemaId | id of an existing JSON-LD script to merge into | none → match by @type |
| department | 'BarOrPub' to force a department, false to deny | auto-detected from URL |
| mergeStrategy | 'replace' or 'preserve' for an existing structured hasMenu | 'replace' |
| currency | priceCurrency for offers | 'USD' |
| rootSelector | Force the content root | density detection |
| ignoreSelector | CSS selector of subtrees to skip | DEFAULT_IGNORE_SELECTOR |
| containerPattern | RegExp of wrapper tags to recurse into | DEFAULT_CONTAINER_PATTERN |
| debug | Console warnings + a diagnostics object on the result | false |
| onComplete | Callback run after every build() (see above) | none |
ignoreSelector and containerPattern default to the static DEFAULT_* fields
at the top of the class.
Output
{
"@context": "https://schema.org",
"@type": "Restaurant",
"@id": "https://example.com",
"url": "https://example.com",
"name": "Example",
"hasMenu": {
"@type": "Menu",
"hasMenuSection": [
{
"@type": "MenuSection",
"name": "Appetizers",
"hasMenuItem": [
{
"@type": "MenuItem",
"name": "Turnip Green Bites",
"description": "…",
"offers": { "@type": "Offer", "price": "13", "priceCurrency": "USD" }
}
]
}
]
}
}For a bar page, the Menu hangs off department:
Restaurant → department: { "@type": "BarOrPub", "hasMenu": Menu }.
Notes
- Browser-only ES6. Runs in the browser against the live DOM; no Node runtime is required.
@idis the canonical origin of the restaurant, so multiple pages of the same site resolve to one entity. It needs a<link rel="canonical">,og:url, or a reallocation.origin— afile://load falls back to the file URL.- schema.org keys are emitted verbatim (
Menu,MenuSection,MenuItem,Offer,hasMenu, …).
