clone

a browser-first diagram editor with live svg previews, local drafts and encrypted, accountless sharing

feat: add Tabular Recipe Notation diagrams

Dami committed b7f3a8b
commit
b7f3a8b4d196566a46636cc442b5c37121aef601
parent
dc7cd2538bf29b7317dd6cf0a0fb92b7b89ebcc8
author
Dami <dami.etoile@gmail.com>
49 files changed, +1558 -49
Mapps/docs/data/diagram-types.mjs41++
Mapps/docs/docs/collaboration/working-and-saved-state.md2+-
Mapps/docs/docs/create/index.md2+-
Aapps/docs/docs/create/types/trn.md77++++
Mapps/docs/docs/style/presentation.md7+-
Mapps/docs/docs/style/svg-normalization.md4+-
Aapps/docs/docs/style/types/trn.md31++
Mapps/docs/scripts/check-docs.mjs6+
Mapps/docs/scripts/generate-docs.mjs14+-
Mapps/docs/src/components/TypeGrid/index.js2+-
Mapps/docs/static/diagram-types.json19+
Aapps/docs/static/examples/trn.json5+
Mapps/docs/static/llms-full.txt123+++++++-
Mapps/docs/static/llms.txt1+
Mapps/docs/static/skills/diagramming/SKILL.md2+
Mapps/editor/src/details-document.js30++-
Mapps/editor/src/diagram-types.js1+
Mapps/editor/src/example-defaults.js1+
Mapps/editor/src/examples.js2+
Mapps/editor/src/format-icons.js2+-
Mapps/editor/src/launcher-catalog.js5+-
Mapps/editor/src/main.js12+-
Mapps/editor/src/renderer-units.js3+
Mapps/editor/test/details-document.test.js8+
Mapps/editor/test/editor-languages.test.js2+-
Mapps/editor/test/launcher-catalog.test.js4+-
Mapps/editor/test/renderer-units.test.js5+-
Mapps/editor/test/svg-contract.test.js21+-
Aexamples/diagrams/marshmallows.trn38++
Aexamples/diagrams/zenith.trn103++++++
Mrenderers/README.md8+-
Mrenderers/catalog/src/catalog.ts6+
Mrenderers/catalog/test/catalog.test.ts4+-
Mrenderers/catalog/test/gateway.test.ts2+-
Arenderers/edge/config/units/trn.jsonc10+
Mrenderers/edge/src/adapters/index.ts2+
Arenderers/edge/src/adapters/trn.ts28++
Mrenderers/edge/src/adapters/types.ts2+-
Arenderers/edge/src/languages/trn-renderer.ts527++++++++++++++++++++++++++++++
Arenderers/edge/src/languages/trn.ts197+++++++++++
Arenderers/edge/src/units/trn.ts9+
Mrenderers/edge/test/adapters.test.ts1+
Arenderers/edge/test/trn.test.ts162+++++++++
Mrenderers/scripts/smoke-units.mjs1+
Mrenderers/shared/engines.ts1+
Mservices/api/test/aliases.test.ts4+-
Mshared/svg/index.d.ts4+-
Mshared/svg/index.js64+++-
Mskills/diagramming/SKILL.md2+
M apps/docs/data/diagram-types.mjs +41 -0
@@ -383,6 +383,47 @@ export const diagramTypes = [
383383 rendererOptions: [],384384 upstream: 'https://ctan.org/pkg/pgf',385385 },386+ {387+ id: 'trn',388+ label: 'TRN',389+ fullName: 'Tabular Recipe Notation',390+ category: 'Processes and recipes',391+ format: 'Tabular Recipe Notation text',392+ summary: 'TRN stands for Tabular Recipe Notation. It shows how ingredients and intermediate outcomes combine into one or more final results.',393+ use: 'Use TRN for recipes, crafting trees, assembly instructions, and other nested transformations.',394+ syntax: ['Optionally begin with `.layout combined` or `.layout individual`; the default is `combined`.', 'Declare each leaf value with `ingredient`.', 'Declare a meaningful final or reusable result with an `outcome` block.', 'Keep incidental conversions, such as ore to bars, as steps inside the outcome that consumes them.', 'Add a full-width preparation row with `instruction`.', 'Add a quoted display label after an identifier when needed.', 'Each `+` adds an input row from top to bottom.', 'Each `->` adds an operation column from left to right.', 'Add `portion` after an outcome label or identifier when the result has a useful quantity.', 'Use `{portion}` in an action to embed that outcome quantity.', 'Start comments with `#`; OCR importers should record uncertain text in comments instead of guessing.'],395+ language: 'text',396+ example: [397+ '.layout combined',398+ '',399+ 'ingredient flour "Bread flour"',400+ 'ingredient water',401+ 'ingredient yeast',402+ 'ingredient salt',403+ '',404+ 'outcome bread portion 1 loaf {',405+ ' + flour 500 g',406+ ' + water 350 ml',407+ ' + yeast 7 g',408+ ' + salt 10 g',409+ ' -> mix',410+ ' -> knead',411+ ' -> proof for 1 hour',412+ ' -> bake {portion} at 230°C for 30 minutes',413+ '}',414+ ],415+ styleSummary: 'TRN colors complete relationship zones as branches. Adjacent zones receive different normalized colors, while ingredients run downward and operations run right.',416+ sourceStyle: ['Use `.layout combined` for one merged table or `.layout individual` for dependency-positioned recipe tables.', 'Order `+` lines as the ingredient rows should appear from top to bottom.', 'Order `->` lines as operations should proceed from left to right.', 'Use outcomes for meaningful reusable or final results; keep incidental conversions inside the consuming outcome.', 'Use short action labels because operation text is vertical.'],417+ rendererOptions: [],418+ limitations: ['The source selects one layout; the SVG does not embed an interactive layout toggle.'],419+ origin: {420+ creator: 'Michael Chu',421+ site: 'Cooking for Engineers',422+ siteUrl: 'https://www.cookingforengineers.com/',423+ note: 'Diagram.zip implements a text DSL and SVG renderer for Chu’s tabular recipe concept.',424+ },425+ upstream: 'https://www.cookingforengineers.com/2004/08/recipe-summaries-standards-and.html',426+ },386427 {387428 id: 'umlet',388429 label: 'UMLet',
M apps/docs/docs/collaboration/working-and-saved-state.md +1 -1
M apps/docs/docs/create/index.md +1 -1
A apps/docs/docs/create/types/trn.md +77 -0
@@ -0,0 +1,77 @@
1+---2+id: create-trn3+slug: /create/types/trn4+title: TRN5+description: Create a TRN diagram in diagram.zip.6+sidebar_label: TRN7+---8+9+import DiagramExample from '@site/src/components/DiagramExample';10+11+# TRN12+13+TRN stands for Tabular Recipe Notation. It shows how ingredients and intermediate outcomes combine into one or more final results.14+15+## Use this type16+17+Use TRN for recipes, crafting trees, assembly instructions, and other nested transformations.18+19+## Source format20+21+The source format is **Tabular Recipe Notation text**. Enter the source in the diagram.zip editor.22+23+## Syntax essentials24+25+- Optionally begin with `.layout combined` or `.layout individual`; the default is `combined`.26+- Declare each leaf value with `ingredient`.27+- Declare a meaningful final or reusable result with an `outcome` block.28+- Keep incidental conversions, such as ore to bars, as steps inside the outcome that consumes them.29+- Add a full-width preparation row with `instruction`.30+- Add a quoted display label after an identifier when needed.31+- Each `+` adds an input row from top to bottom.32+- Each `->` adds an operation column from left to right.33+- Add `portion` after an outcome label or identifier when the result has a useful quantity.34+- Use `{portion}` in an action to embed that outcome quantity.35+- Start comments with `#`; OCR importers should record uncertain text in comments instead of guessing.36+37+### Example38+39+```text40+.layout combined41+42+ingredient flour "Bread flour"43+ingredient water44+ingredient yeast45+ingredient salt46+47+outcome bread portion 1 loaf {48+ + flour 500 g49+ + water 350 ml50+ + yeast 7 g51+ + salt 10 g52+ -> mix53+ -> knead54+ -> proof for 1 hour55+ -> bake {portion} at 230°C for 30 minutes56+}57+```58+59+<DiagramExample engine="trn" label="TRN" sourceUrl="/examples/trn.json" />60+61+## Origin62+63+TRN originates with [Michael Chu](https://www.cookingforengineers.com/) at [Cooking for Engineers](https://www.cookingforengineers.com/). Diagram.zip implements a text DSL and SVG renderer for Chu’s tabular recipe concept.64+65+## Limitations66+67+- The source selects one layout; the SVG does not embed an interactive layout toggle.68+69+## Related pages70+71+- [Style TRN](/style/types/trn)72+- [General presentation settings](/style/presentation)73+- [Open TRN in the editor](https://diagram.zip/?type=trn)74+75+## Upstream reference76+77+[TRN documentation](https://www.cookingforengineers.com/2004/08/recipe-summaries-standards-and.html)
M apps/docs/docs/style/presentation.md +6 -1
M apps/docs/docs/style/svg-normalization.md +2 -2
A apps/docs/docs/style/types/trn.md +31 -0
M apps/docs/scripts/check-docs.mjs +6 -0
@@ -121,8 +121,11 @@ const llms = await source(join(site, 'static', 'llms.txt'))
121121 const llmsFull = await source(join(site, 'static', 'llms-full.txt'))122122 assert(llms.includes('https://docs.diagram.zip/create/'), 'llms.txt does not link the editor workspace guide.')123123 assert(llms.includes('https://docs.diagram.zip/style/presentation/'), 'llms.txt does not link the Details presentation guide.')124+assert(llms.includes('TRN stands for Tabular Recipe Notation'), 'llms.txt does not expand TRN.')125+assert(llms.includes('Michael Chu at Cooking for Engineers'), 'llms.txt does not credit the origin of TRN.')124126 assert(llmsFull.includes('# General presentation settings'), 'llms-full.txt does not include the presentation guide.')125127 assert(llmsFull.includes('# Working state and published state'), 'llms-full.txt does not include the working-state guide.')128+assert(llmsFull.includes('TRN originates with [Michael Chu]'), 'llms-full.txt does not credit the origin of TRN.')126129 127130 const expectedSkillIds = [...diagramSkillIds].sort()128131 const skillEntries = await readdir(skills, { withFileTypes: true })
@@ -180,6 +183,9 @@ for (const entry of diagramSkills) {
180183 181184 const workshopOpenAI = await source(join(skills, 'diagram-workshop', 'agents', 'openai.yaml'))182185 assert(/allow_implicit_invocation:\s*false/.test(workshopOpenAI), 'Diagram workshop must disable implicit OpenAI invocation.')186+const publishedDiagramming = await source(join(publicSkills, 'diagramming', 'SKILL.md'))187+assert(publishedDiagramming.includes('Use Tabular Recipe Notation (`trn`) for recipes'), 'Published diagramming skill does not expand or route TRN.')188+assert(publishedDiagramming.includes('Michael Chu at Cooking for Engineers'), 'Published diagramming skill does not credit the origin of TRN.')183189 184190 for (const id of expected) {185191 const type = diagramTypes.find((item) => item.id === id)
M apps/docs/scripts/generate-docs.mjs +10 -4
@@ -89,15 +89,19 @@ function example(type, interactive = true) {
8989 return `${heading}\n\n${note}\`\`\`${fence(type.language)}\n${content}\n\`\`\`\n${rendered}`9090 }9191 92-function options(type) {93- return `| Option | Values | Purpose |\n| --- | --- | --- |\n${type.rendererOptions.map((item) => `| \`${inline(item.name)}\` | ${inline(item.values)} | ${inline(item.description)} |`).join('\n')}\n`92+function options(items) {93+ return `| Option | Values | Purpose |\n| --- | --- | --- |\n${items.map((item) => `| \`${inline(item.name)}\` | ${inline(item.values)} | ${inline(item.description)} |`).join('\n')}\n`9494 }9595 9696 function syntaxPage(type, interactive = true) {9797 const limitations = lines(type.limitations)9898 const article = articleFor(type.label)99+ const origin = type.origin100+ ? `## Origin\n\n${type.label} originates with [${type.origin.creator}](${type.origin.siteUrl}) at [${type.origin.site}](${type.origin.siteUrl}). ${type.origin.note}\n\n`101+ : ''99102 return frontmatter(type, 'create', `Create ${article} ${type.label} diagram in diagram.zip.`) +100103 `${interactive ? "import DiagramExample from '@site/src/components/DiagramExample';\n\n" : ''}# ${type.label}\n\n${type.summary}\n\n## Use this type\n\n${type.use}\n\n## Source format\n\nThe source format is **${type.format}**. Enter the source in the diagram.zip editor.\n\n## Syntax essentials\n\n${lines(type.syntax).map((item) => `- ${item}`).join('\n')}\n\n${example(type, interactive)}\n` +104+(origin) +101105 (limitations.length ? `## Limitations\n\n${limitations.map((item) => `- ${item}`).join('\n')}\n\n` : '') +102106 `## Related pages\n\n- [Style ${type.label}](${stylePath(type.id)})\n- [General presentation settings](/style/presentation)\n- [Open ${type.label} in the editor](${editorLink(type.id)})\n\n## Upstream reference\n\n[${type.label} documentation](${type.upstream})\n`103107 }
@@ -106,7 +110,7 @@ function stylePage(type) {
106110 const limitations = lines(type.limitations)107111 const article = articleFor(type.label)108112 const rendererOptions = type.rendererOptions?.length109- ? `\n## Renderer options\n\n${options(type)}\n`113+ ? `\n## Renderer options\n\n${options(type.rendererOptions)}\n`110114 : ''111115 return frontmatter(type, 'style', `Style a ${type.label} diagram in diagram.zip.`) +112116 `# Style ${type.label}\n\n${type.styleSummary}\n\nStart with the [general presentation settings](/style/presentation). Edit shared or raw presentation in Details, and keep renderer-specific styling in Source.\n\n## Source controls\n\n${lines(type.sourceStyle).map((item) => `- ${item}`).join('\n')}\n${rendererOptions}` +
@@ -117,6 +121,7 @@ function stylePage(type) {
117121 const catalog = diagramTypes.map((type) => ({118122 id: type.id,119123 label: type.label,124+ ...(type.fullName === undefined ? {} : { fullName: type.fullName }),120125 category: type.category,121126 format: type.format,122127 language: type.language,
@@ -125,6 +130,7 @@ const catalog = diagramTypes.map((type) => ({
125130 style: stylePath(type.id),126131 editor: editorLink(type.id),127132 example: `/examples/${type.id}.json`,133+ ...(type.origin === undefined ? {} : { origin: type.origin }),128134 }))129135 const coreDocPaths = [130136 join(docs, 'create', 'index.md'),
@@ -158,7 +164,7 @@ await Promise.all(diagramTypes.flatMap((type) => [
158164 await Promise.all(diagramSkills.map(({ id }) => cp(join(skillsDir, id), join(publicSkillsDir, id), { recursive: true })))159165 await writeFile(join(staticDir, 'diagram-types.json'), `${JSON.stringify({ version: 1, diagramTypes: catalog }, null, 2)}\n`)160166 await writeFile(join(staticDir, 'diagram-skills.json'), `${JSON.stringify({ version: 1, groups: skillGroups, skills: skillCatalog, standards: standardsSupport }, null, 2)}\n`)161-await writeFile(join(staticDir, 'llms.txt'), `# diagram.zip documentation\n\n> Choose the semantic story first. Use Source for notation and Details for metadata and presentation.\n\n- [Diagram routing skill](https://docs.diagram.zip/skills/diagramming/SKILL.md): Select a diagram family from the reader's question.\n- [Machine-readable semantic catalog](https://docs.diagram.zip/diagram-skills.json): Inspect every skill, use case, exclusion, role, and invocation constraint.\n\n${skillIndex()}\n\n## Renderer and source formats\n\n${diagramTypes.map((type) => `- [${type.label} syntax](https://docs.diagram.zip${syntaxPath(type.id)}/): Create ${articleFor(type.label)} ${type.label} diagram. [Style](https://docs.diagram.zip${stylePath(type.id)}/).`).join('\n')}\n\n## General pages\n\n- [Editor workspace](https://docs.diagram.zip/create/)\n- [Editable SVG files and local drafts](https://docs.diagram.zip/create/): Reopen validated enriched SVG files without publishing.\n- [Details JSON and presentation](https://docs.diagram.zip/style/presentation/)\n- [SVG normalization and version contracts](https://docs.diagram.zip/style/svg-normalization/)\n- [Share a diagram](https://docs.diagram.zip/collaboration/sharing/)\n- [Password encryption](https://docs.diagram.zip/collaboration/encryption/)\n- [Working state and saved state](https://docs.diagram.zip/collaboration/working-and-saved-state/)\n\n## Structured catalogs\n\n- [diagram-skills.json](https://docs.diagram.zip/diagram-skills.json)\n- [diagram-types.json](https://docs.diagram.zip/diagram-types.json)\n- [Full documentation text](https://docs.diagram.zip/llms-full.txt)\n- [Source repository](https://github.com/FelineStateMachine/diagramzip)\n`)167+await writeFile(join(staticDir, 'llms.txt'), `# diagram.zip documentation\n\n> Choose the semantic story first. Use Source for notation and Details for metadata and presentation.\n\n- [Diagram routing skill](https://docs.diagram.zip/skills/diagramming/SKILL.md): Select a diagram family from the reader's question.\n- [Machine-readable semantic catalog](https://docs.diagram.zip/diagram-skills.json): Inspect every skill, use case, exclusion, role, and invocation constraint.\n\n${skillIndex()}\n\n## Renderer and source formats\n\n${diagramTypes.map((type) => `- [${type.label} syntax](https://docs.diagram.zip${syntaxPath(type.id)}/): ${type.fullName === undefined ? '' : `${type.label} stands for ${type.fullName}. `}Create ${articleFor(type.label)} ${type.label} diagram. [Style](https://docs.diagram.zip${stylePath(type.id)}/).${type.origin === undefined ? '' : ` Origin: [${type.origin.creator} at ${type.origin.site}](${type.origin.siteUrl}).`}`).join('\n')}\n\n## General pages\n\n- [Editor workspace](https://docs.diagram.zip/create/)\n- [Editable SVG files and local drafts](https://docs.diagram.zip/create/): Reopen validated enriched SVG files without publishing.\n- [Details JSON and presentation](https://docs.diagram.zip/style/presentation/)\n- [SVG normalization and version contracts](https://docs.diagram.zip/style/svg-normalization/)\n- [Share a diagram](https://docs.diagram.zip/collaboration/sharing/)\n- [Password encryption](https://docs.diagram.zip/collaboration/encryption/)\n- [Working state and saved state](https://docs.diagram.zip/collaboration/working-and-saved-state/)\n\n## Structured catalogs\n\n- [diagram-skills.json](https://docs.diagram.zip/diagram-skills.json)\n- [diagram-types.json](https://docs.diagram.zip/diagram-types.json)\n- [Full documentation text](https://docs.diagram.zip/llms-full.txt)\n- [Source repository](https://github.com/FelineStateMachine/diagramzip)\n`)162168 const llmsPath = join(staticDir, 'llms.txt')163169 const llmsIndex = await readFile(llmsPath, 'utf8')164170 await writeFile(llmsPath, llmsIndex.replace('Working state and saved state', 'Working state and published state'))
M apps/docs/src/components/TypeGrid/index.js +1 -1
M apps/docs/static/diagram-types.json +19 -0
A apps/docs/static/examples/trn.json +5 -0
M apps/docs/static/llms-full.txt +118 -5
@@ -49,7 +49,7 @@ edit links remain alias-based and do not depend on this query.
4949 5050 ## Source, Details, and preview5151 52-The left pane has **Source** and **Details** tabs. Source contains the selected diagram language. Details contains validated JSON for metadata and presentation.52+The left pane has **Source** and **Details** tabs. Source contains the selected diagram language. Details contains validated JSON for metadata, renderer options, and presentation.5353 5454 Edit the title directly in the top bar for a faster naming workflow. The value stays synchronized with `title` in Details.5555
@@ -107,12 +107,13 @@ sidebar_position: 1
107107 108108 # General presentation settings109109 110-Open the **Details** tab in the left pane. It contains a validated JSON document for metadata and presentation.110+Open the **Details** tab in the left pane. It contains a validated JSON document for metadata, renderer options, and presentation.111111 112112 ```json113113 {114114 "title": "Service flow",115115 "description": "A request moving through the public API.",116+ "options": {},116117 "presentation": {117118 "appearance": "auto-transparent",118119 "background": "",
@@ -126,6 +127,10 @@ Open the **Details** tab in the left pane. It contains a validated JSON document
126127 127128 The diagnostics dock reports invalid JSON or schema values. Diagram.zip keeps the last valid preview until the document becomes valid again.128129 130+## Renderer options131+132+Put renderer-specific settings in the top-level `options` object. Option values may be strings, numbers, or booleans. The Style page for each diagram type lists its supported keys.133+129134 ## Appearance130135 131136 `raw` keeps the safe renderer output. It enables the custom background, padding, and frame values in the presentation document.
@@ -301,8 +306,8 @@ The [renderer catalog](https://diagram.zip/render/v1/catalog) reports every engi
301306 | Canonical schema | `1` | The canonical SVG structure changes incompatibly. |302307 | Normalizer | `svg-normalizer-2` | Sanitization, annotation, or serialization behavior changes. |303308 | Profile | `neutral-svg-semantic-2` | Renderer recognition or role assignment changes. |304-| Palette | `diagramzip-palette-1` | Materialized role colors change. |305-| Materializer | `svg-materializer-2` | Appearance rules, padding, frame, or output assembly changes. |309+| Palette | `diagramzip-palette-2` | Materialized role colors change. |310+| Materializer | `svg-materializer-3` | Appearance rules, padding, frame, or output assembly changes. |306311 | Renderer | Catalog version and build | Engine output or its integration changes. |307312 308313 These versions advance independently. A renderer upgrade does not require a schema change, and a palette update does not require new geometry.
@@ -598,7 +603,7 @@ The working state is what you currently see and edit. It includes the diagram
598603 source, type, renderer options, presentation settings, title, and description.599604 600605 Source and Details use separate editor tabs. Details is a JSON document that601-contains the title, description, and presentation settings.606+contains the title, description, renderer options, and presentation settings.602607 603608 Invalid Details JSON remains in the local draft. The editor keeps the last604609 valid preview and blocks file export, publishing, sharing, privacy, and type
@@ -2789,6 +2794,114 @@ Start with the [general presentation settings](/style/presentation). Edit shared
27892794 27902795 ---27912796 2797+---2798+id: create-trn2799+slug: /create/types/trn2800+title: TRN2801+description: Create a TRN diagram in diagram.zip.2802+sidebar_label: TRN2803+---2804+2805+# TRN2806+2807+TRN stands for Tabular Recipe Notation. It shows how ingredients and intermediate outcomes combine into one or more final results.2808+2809+## Use this type2810+2811+Use TRN for recipes, crafting trees, assembly instructions, and other nested transformations.2812+2813+## Source format2814+2815+The source format is **Tabular Recipe Notation text**. Enter the source in the diagram.zip editor.2816+2817+## Syntax essentials2818+2819+- Optionally begin with `.layout combined` or `.layout individual`; the default is `combined`.2820+- Declare each leaf value with `ingredient`.2821+- Declare a meaningful final or reusable result with an `outcome` block.2822+- Keep incidental conversions, such as ore to bars, as steps inside the outcome that consumes them.2823+- Add a full-width preparation row with `instruction`.2824+- Add a quoted display label after an identifier when needed.2825+- Each `+` adds an input row from top to bottom.2826+- Each `->` adds an operation column from left to right.2827+- Add `portion` after an outcome label or identifier when the result has a useful quantity.2828+- Use `{portion}` in an action to embed that outcome quantity.2829+- Start comments with `#`; OCR importers should record uncertain text in comments instead of guessing.2830+2831+### Example2832+2833+```text2834+.layout combined2835+2836+ingredient flour "Bread flour"2837+ingredient water2838+ingredient yeast2839+ingredient salt2840+2841+outcome bread portion 1 loaf {2842+ + flour 500 g2843+ + water 350 ml2844+ + yeast 7 g2845+ + salt 10 g2846+ -> mix2847+ -> knead2848+ -> proof for 1 hour2849+ -> bake {portion} at 230°C for 30 minutes2850+}2851+```2852+2853+## Origin2854+2855+TRN originates with [Michael Chu](https://www.cookingforengineers.com/) at [Cooking for Engineers](https://www.cookingforengineers.com/). Diagram.zip implements a text DSL and SVG renderer for Chu’s tabular recipe concept.2856+2857+## Limitations2858+2859+- The source selects one layout; the SVG does not embed an interactive layout toggle.2860+2861+## Related pages2862+2863+- [Style TRN](/style/types/trn)2864+- [General presentation settings](/style/presentation)2865+- [Open TRN in the editor](https://diagram.zip/?type=trn)2866+2867+## Upstream reference2868+2869+[TRN documentation](https://www.cookingforengineers.com/2004/08/recipe-summaries-standards-and.html)2870+2871+---2872+id: style-trn2873+slug: /style/types/trn2874+title: TRN2875+description: Style a TRN diagram in diagram.zip.2876+sidebar_label: TRN2877+---2878+2879+# Style TRN2880+2881+TRN colors complete relationship zones as branches. Adjacent zones receive different normalized colors, while ingredients run downward and operations run right.2882+2883+Start with the [general presentation settings](/style/presentation). Edit shared or raw presentation in Details, and keep renderer-specific styling in Source.2884+2885+## Source controls2886+2887+- Use `.layout combined` for one merged table or `.layout individual` for dependency-positioned recipe tables.2888+- Order `+` lines as the ingredient rows should appear from top to bottom.2889+- Order `->` lines as operations should proceed from left to right.2890+- Use outcomes for meaningful reusable or final results; keep incidental conversions inside the consuming outcome.2891+- Use short action labels because operation text is vertical.2892+## Limitations2893+2894+- The source selects one layout; the SVG does not embed an interactive layout toggle.2895+2896+## Related pages2897+2898+- [Create a TRN diagram](/create/types/trn)2899+- [General presentation settings](/style/presentation)2900+- [SVG normalization and version contracts](/style/svg-normalization)2901+- [Open TRN in the editor](https://diagram.zip/?type=trn)2902+2903+---2904+27922905 ---27932906 id: create-umlet27942907 slug: /create/types/umlet
M apps/docs/static/llms.txt +1 -0
M apps/docs/static/skills/diagramming/SKILL.md +2 -0
M apps/editor/src/details-document.js +28 -2
@@ -8,7 +8,7 @@ export const DETAILS_DOCUMENT_SCHEMA = {
88 $schema: 'https://json-schema.org/draft/2020-12/schema',99 $id: DETAILS_SCHEMA_URI,1010 title: 'Diagram details',11- description: 'Metadata and presentation settings for the current diagram.',11+ description: 'Metadata, renderer options, and presentation settings for the current diagram.',1212 type: 'object',1313 additionalProperties: false,1414 required: ['title', 'description', 'presentation'],
@@ -23,6 +23,13 @@ export const DETAILS_DOCUMENT_SCHEMA = {
2323 maxLength: 2000,2424 description: 'A plain-text description of what the diagram shows.',2525 },26+ options: {27+ type: 'object',28+ maxProperties: 64,29+ propertyNames: { pattern: '^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$' },30+ additionalProperties: { type: ['string', 'number', 'boolean'] },31+ description: 'Renderer-specific options.',32+ },2633 presentation: {2734 type: 'object',2835 additionalProperties: false,
@@ -59,6 +66,7 @@ export function detailsDocumentFor(state = {}) {
5966 return {6067 title: meta.title,6168 description: meta.description,69+ options: normalizeRendererOptions(state.options),6270 presentation,6371 }6472 }
@@ -73,6 +81,7 @@ export function detailsStateWithTitle(state, title) {
7381 }7482 return {7583 meta: normalizeMetadata({ ...state.meta, title }),84+ options: normalizeRendererOptions(state.options),7685 presentation: normalizePresentation(state.presentation),7786 }7887 }
@@ -85,7 +94,7 @@ export function parseDetailsDocument(source) {
8594 throw new Error(`Details must be valid JSON. ${error instanceof Error ? error.message : ''}`.trim())8695 }8796 assertObject(value, 'Details must be a JSON object.')88- assertKeys(value, ['title', 'description', 'presentation'], 'details')97+ assertKeys(value, ['title', 'description', 'options', 'presentation'], 'details')8998 assertRequired(value, ['title', 'description', 'presentation'], 'details')9099 if (typeof value.title !== 'string' || value.title.length > 200) {91100 throw new Error('title must be a string with at most 200 characters.')
@@ -105,10 +114,27 @@ export function parseDetailsDocument(source) {
105114 }106115 return {107116 meta: normalizeMetadata({ title: value.title, description: value.description }),117+ options: normalizeRendererOptions(value.options),108118 presentation,109119 }110120 }111121 122+function normalizeRendererOptions(options = {}) {123+ assertObject(options, 'options must be a JSON object.')124+ const entries = Object.entries(options)125+ if (entries.length > 64) throw new Error('options cannot contain more than 64 properties.')126+ const normalized = {}127+ for (const [name, value] of entries) {128+ if (!/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(name)) throw new Error(`options contains invalid property "${name}".`)129+ if (!['string', 'number', 'boolean'].includes(typeof value) || (typeof value === 'number' && !Number.isFinite(value))) {130+ throw new Error(`option "${name}" must be a string, number, or boolean.`)131+ }132+ if (String(value).length > 4_096) throw new Error(`option "${name}" is too large.`)133+ normalized[name] = value134+ }135+ return normalized136+}137+112138 function assertObject(value, message) {113139 if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error(message)114140 }
M apps/editor/src/diagram-types.js +1 -0
M apps/editor/src/example-defaults.js +1 -0
M apps/editor/src/examples.js +2 -0
M apps/editor/src/format-icons.js +1 -1
M apps/editor/src/launcher-catalog.js +3 -2
M apps/editor/src/main.js +8 -4
@@ -3,6 +3,7 @@ import { exampleStateFor } from './examples.js'
33 import { exampleStateForTheme } from './example-defaults.js'44 import {55 DETAILS_MODEL_URI,6+ detailsDocumentFor,67 detailsStateWithTitle,78 parseDetailsDocument,89 serializeDetailsDocument,
@@ -299,6 +300,7 @@ for (const { id, label } of diagramTypes) {
299300 const initialState = await loadInitialState()300301 detailsState = {301302 meta: normalizeMetadata(initialState.meta),303+ options: initialState.options ?? {},302304 presentation: normalizePresentation(initialState.presentation),303305 }304306 typePicker.value = initialState.type
@@ -750,7 +752,7 @@ function currentState() {
750752 return {751753 type: typePicker.value,752754 source: sourceEditor.getValue(),753- options: {},755+ options: detailsState.options,754756 meta: detailsState.meta,755757 presentation: detailsState.presentation,756758 }
@@ -923,9 +925,11 @@ function ensureValidDetails() {
923925 }924926 925927 function setDetailsState(nextDetails) {928+ const normalized = detailsDocumentFor(nextDetails)926929 detailsState = {927- meta: normalizeMetadata(nextDetails.meta),928- presentation: normalizePresentation(nextDetails.presentation),930+ meta: normalizeMetadata({ title: normalized.title, description: normalized.description }),931+ options: normalized.options,932+ presentation: normalizePresentation(normalized.presentation),929933 }930934 detailsValid = true931935 detailsError = ''
@@ -1020,7 +1024,7 @@ function applyState(state, commit = true) {
10201024 activeType = state.type10211025 typePicker.value = state.type10221026 updateTypeCommand()1023- setDetailsState({ meta, presentation })1027+ setDetailsState({ meta, options: state.options ?? {}, presentation })10241028 syncPreviewAppearanceControls()10251029 sourceEditor.setDocument({ source: state.source, diagramType: state.type })10261030 if (commit) commitState()
M apps/editor/src/renderer-units.js +3 -0
M apps/editor/test/details-document.test.js +8 -0
M apps/editor/test/editor-languages.test.js +1 -1
M apps/editor/test/launcher-catalog.test.js +2 -2
M apps/editor/test/renderer-units.test.js +3 -2
M apps/editor/test/svg-contract.test.js +19 -2
@@ -91,8 +91,8 @@ test('materializes explicit and automatic palettes without changing diagram geom
9191 const automatic = materializeSvg(semanticCanonical, 'auto-transparent')9292 9393 for (const output of [light, dark, automatic]) {94- assert.match(output, /data-dz-materializer="svg-materializer-2"/)95- assert.match(output, /data-dz-palette="diagramzip-palette-1"/)94+ assert.match(output, /data-dz-materializer="svg-materializer-3"/)95+ assert.match(output, /data-dz-palette="diagramzip-palette-2"/)9696 assert.match(output, /<rect x="1" y="1" width="8" height="8"/)9797 assert.doesNotMatch(output, /<(?:rect|path|polygon)[^>]*data-dz-role="canvas"/)9898 }
@@ -179,6 +179,23 @@ test('applies D2, PlantUML, Svgbob, and neutral SVG family roles', () => {
179179 }180180 })181181 182+test('normalizes TRN table structure and relationship zones', () => {183+ const trn = canonicalizeSvg(184+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 40"><rect class="trn-canvas" width="80" height="40" fill="#fbf8dc"></rect><text class="trn-title" fill="#111827">Bread</text><rect class="trn-table-surface" y="10" width="80" height="30" fill="white"></rect><path class="trn-zone-shape trn-branch-4" fill="white" stroke="#65a268"></path><text class="trn-operation-label" fill="#111827">mix</text></svg>',185+ metadata, 'trn', 'diagramzip-trn@13',186+ )187+188+ assert.match(trn, /data-dz-profile="trn-semantic-4"/)189+ assert.match(trn, /class="trn-zone-shape trn-branch-4"[^>]*data-dz-fill="accent-4"[^>]*data-dz-stroke="line-muted"/)190+ assert.match(trn, /class="trn-operation-label"[^>]*data-dz-fill="on-accent"/)191+ const light = materializeSvg(trn, 'light-transparent')192+ assert.match(light, /:root\[data-dz-profile="trn-semantic-4"\]\{--dz-accent-1:#dbeafe;--dz-accent-2:#fef3c7;--dz-accent-3:#ede9fe;--dz-accent-4:#ccfbf1;--dz-on-accent:#0f172a;/)193+ const dark = materializeSvg(trn, 'dark-transparent')194+ assert.match(dark, /data-dz-appearance="dark-transparent"/)195+ assert.match(dark, /--dz-accent-4:#34d399/)196+ assert.doesNotMatch(dark, /--dz-accent-1:#dbeafe/)197+})198+182199 test('themes every Svgbob stroke without treating open curves as object surfaces', () => {183200 const canonical = canonicalizeSvg(184201 '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 20" class="svgbob"><path id="curve" d="M 8,2 A 8,8 0,0,0 8,18" class="nofill"></path><path id="closed" d="M20 2L30 2L30 18L20 18Z" class="nofill"></path><line id="plain" x1="2" y1="10" x2="6" y2="10"></line><rect id="box" x="32" y="2" width="6" height="16" class="solid nofill"></rect></svg>',
A examples/diagrams/marshmallows.trn +38 -0
A examples/diagrams/zenith.trn +103 -0
@@ -0,0 +1,103 @@
1+# Zenith crafting story for current Desktop, Console, and Mobile Terraria.2+# Outcomes name meaningful crafted items. Material conversions stay as steps inside them.3+# This example follows the Corruption route through Light's Bane.4+5+.layout individual6+7+ingredient hellstone8+ingredient obsidian9+ingredient demonite_ore "Demonite Ore"10+ingredient jungle_spores "Jungle Spores"11+ingredient stinger12+ingredient vine13+ingredient muramasa14+ingredient soul_of_fright "Soul of Fright"15+ingredient soul_of_might "Soul of Might"16+ingredient soul_of_sight "Soul of Sight"17+ingredient hallowed_bar "Hallowed Bar"18+ingredient chlorophyte_ore "Chlorophyte Ore"19+ingredient broken_hero_sword "Broken Hero Sword"20+ingredient copper_ore "Copper Ore"21+ingredient meowmere22+ingredient star_wrath "Star Wrath"23+ingredient influx_waver "Influx Waver"24+ingredient horseman_blade "The Horseman's Blade"25+ingredient seedler26+ingredient starfury27+ingredient bee_keeper "Bee Keeper"28+ingredient enchanted_sword "Enchanted Sword"29+30+outcome volcano {31+ + obsidian 2032+ + hellstone 6033+ -> smelt 20 Hellstone Bars34+ -> forge Volcano35+}36+37+outcome lights_bane "Light's Bane" {38+ + demonite_ore 3039+ -> smelt 10 Demonite Bars40+ -> forge Light's Bane41+}42+43+outcome blade_of_grass {44+ + jungle_spores 1545+ + stinger 1246+ + vine 347+ -> forge Blade of Grass48+}49+50+outcome nights_edge "Night's Edge" {51+ + lights_bane52+ + blade_of_grass53+ + muramasa54+ + volcano55+ -> combine Night's Edge56+}57+58+outcome true_nights_edge "True Night's Edge" {59+ + nights_edge60+ + soul_of_fright 2061+ + soul_of_might 2062+ + soul_of_sight 2063+ -> forge True Night's Edge64+}65+66+outcome excalibur {67+ + hallowed_bar 1268+ -> forge Excalibur69+}70+71+outcome true_excalibur {72+ + excalibur73+ + chlorophyte_ore 12074+ -> smelt 24 Chlorophyte Bars75+ -> forge True Excalibur76+}77+78+outcome terra_blade {79+ + true_nights_edge80+ + true_excalibur81+ + broken_hero_sword82+ -> forge Terra Blade83+}84+85+outcome copper_shortsword {86+ + copper_ore 1587+ -> smelt 5 Copper Bars88+ -> forge Copper Shortsword89+}90+91+outcome zenith {92+ + copper_shortsword93+ + starfury94+ + enchanted_sword95+ + bee_keeper96+ + terra_blade97+ + seedler98+ + horseman_blade99+ + influx_waver100+ + star_wrath101+ + meowmere102+ -> forge Zenith103+}
M renderers/README.md +4 -4
@@ -11,7 +11,7 @@ This directory contains the complete Cloudflare renderer plane for diagram.zip.
1111 | `shared/` | Engine identities shared by the catalog and runtime units |1212 | `scripts/` | Cross-runtime deployment and smoke-test orchestration |1313 14-All 30 catalog engines have a final renderer path. No renderer uses the Fly.io application. No renderer can use an origin fallback.14+All 31 catalog engines have a final renderer path. No renderer uses the Fly.io application. No renderer can use an origin fallback.1515 1616 Each engine has a hostname:1717
@@ -37,7 +37,7 @@ npm --prefix edge run deploy:dry-run
3737 3838 Run `npm --prefix edge run smoke:renderers` after all units are deployed. This command checks one repository fixture or capability response for each catalog engine. It checks coverage and SVG structure. It does not compare pixels.3939 40-Run `npm --prefix edge run smoke:catalog` against a local catalog Worker. This command checks the health route, the 30-engine catalog, and the absence of the old render proxy.40+Run `npm --prefix edge run smoke:catalog` against a local catalog Worker. This command checks the health route, the 31-engine catalog, and the absence of the old render proxy.4141 4242 ## HTTP unit contract4343
@@ -79,12 +79,12 @@ Some units translate source into another engine. The pipeline header lists each
7979 8080 | Runtime | Count | Engines |8181 | --- | ---: | --- |82-| Worker JavaScript | 9 | BPMN, Bytefield, Excalidraw, Nomnoml, Structurizr, UMLet, Vega, Vega-Lite, WaveDrom |82+| Worker JavaScript | 10 | BPMN, Bytefield, Excalidraw, Nomnoml, Structurizr, TRN, UMLet, Vega, Vega-Lite, WaveDrom |8383 | Worker Python | 8 | BlockDiag, SeqDiag, ActDiag, NwDiag, PacketDiag, RackDiag, Symbolator, WireViz |8484 | Worker WebAssembly | 11 | PlantUML, C4 PlantUML, GraphViz, DBML, ERD, D2, Ditaa, GoAT, Pikchr, Svgbob, TikZ |8585 | Browser Run | 2 | Mermaid, diagrams.net |8686 87-The total is 30 of 30 engines. The Fly.io Java rendering image is not part of this renderer plane.87+The total is 31 of 31 engines. The Fly.io Java rendering image is not part of this renderer plane.8888 8989 ## Output policy9090
M renderers/catalog/src/catalog.ts +6 -0
M renderers/catalog/test/catalog.test.ts +2 -2
M renderers/catalog/test/gateway.test.ts +1 -1
A renderers/edge/config/units/trn.jsonc +10 -0
M renderers/edge/src/adapters/index.ts +2 -0
A renderers/edge/src/adapters/trn.ts +28 -0
M renderers/edge/src/adapters/types.ts +1 -1
A renderers/edge/src/languages/trn-renderer.ts +527 -0
@@ -0,0 +1,527 @@
1+import type { TrnDocument, TrnInput, TrnOutcome, TrnStage } from './trn'2+3+const MIN_ROW_HEIGHT = 284+const MIN_LABEL_WIDTH = 2105+const MAX_LABEL_WIDTH = 3906+const OPERATION_WIDTH = 347+const OPERATION_FONT_SIZE = 108+const OPERATION_TEXT_PADDING = 149+const MARGIN = 1210+const HEADER_HEIGHT = 2811+const ROOT_GAP = 112+const RECIPE_GAP = 2013+const RECIPE_COLUMN_GAP = 6414+const RECIPE_LINK_CLEARANCE = 415+const FONT_FAMILY = 'Arial,Helvetica,sans-serif'16+const GRID_COLOR = '#65a268'17+const BRANCH_COLORS = ['#dbeafe', '#fef3c7', '#ede9fe', '#ccfbf1'] as const18+19+interface LeafLayout {20+ row: number21+ input: TrnInput22+ label: string23+}24+25+interface StageLayout {26+ outcome: TrnOutcome27+ stage: TrnStage28+ column: number29+ startRow: number30+ endRow: number31+ arms: Array<{ startColumn: number; endColumn: number; startRow: number; endRow: number }>32+ branchColor: number33+}34+35+interface Layout {36+ leaves: LeafLayout[]37+ stages: StageLayout[]38+ rows: number39+ columns: number40+}41+42+interface Geometry {43+ labelWidth: number44+ columnWidths: number[]45+ columnOffsets: number[]46+ rowHeights: number[]47+ rowOffsets: number[]48+ bodyHeight: number49+ tableWidth: number50+}51+52+function escapeXml(value: string): string {53+ return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;')54+}55+56+function textWidth(value: string, fontSize: number, padding: number): number {57+ return value.length * fontSize * 0.56 + padding58+}59+60+function clamp(value: number, minimum: number, maximum: number): number {61+ return Math.max(minimum, Math.min(maximum, value))62+}63+64+function rowY(row: number, bodyY: number, geometry: Geometry): number {65+ return bodyY + geometry.rowOffsets[row]!66+}67+68+function ingredientText(leaf: LeafLayout): string {69+ return leaf.input.quantity === '' ? leaf.label : `${leaf.input.quantity} ${leaf.label}`70+}71+72+function actionText(item: StageLayout): string {73+ return item.stage.action.replaceAll('{portion}', item.outcome.portion)74+}75+76+function layoutTree(document: TrnDocument): Layout {77+ const layout: Layout = { leaves: [], stages: [], rows: 0, columns: 0 }78+ let nextRow = 079+80+ function leaf(input: TrnInput): { startRow: number; endRow: number; column: number } {81+ const ingredient = document.ingredients.get(input.id)!82+ const row = nextRow83+ nextRow += 184+ layout.leaves.push({ row, input, label: ingredient.label })85+ return { startRow: row, endRow: row, column: 0 }86+ }87+88+ function outcome(value: TrnOutcome): { startRow: number; endRow: number; column: number } {89+ let startRow = -190+ let endRow = -191+ let previousColumn = 092+93+ for (let stageIndex = 0; stageIndex < value.stages.length; stageIndex += 1) {94+ const stage = value.stages[stageIndex]!95+ let dependencyColumn = previousColumn96+ const inputs: Array<{ startRow: number; endRow: number; column: number }> = []97+98+ for (const input of stage.inputs) {99+ const child = document.outcomes.get(input.id)100+ const placed = child === undefined ? leaf(input) : outcome(child)101+ inputs.push(placed)102+ if (startRow < 0) startRow = placed.startRow103+ endRow = Math.max(endRow, placed.endRow)104+ dependencyColumn = Math.max(dependencyColumn, placed.column)105+ }106+107+ const column = dependencyColumn + 1108+ const arms: StageLayout['arms'] = []109+ for (const input of inputs) {110+ if (input.column + 1 < column) {111+ arms.push({112+ startColumn: input.column + 1,113+ endColumn: column - 1,114+ startRow: input.startRow,115+ endRow: input.endRow,116+ })117+ }118+ }119+ layout.stages.push({ outcome: value, stage, column, startRow, endRow, arms, branchColor: 0 })120+ previousColumn = column121+ layout.columns = Math.max(layout.columns, column)122+ }123+124+ return { startRow, endRow, column: previousColumn }125+ }126+127+ for (const root of document.roots) {128+ outcome(root)129+ nextRow += ROOT_GAP130+ }131+ layout.rows = Math.max(1, nextRow - ROOT_GAP)132+ assignBranchColors(layout.stages)133+ return layout134+}135+136+function layoutOutcome(document: TrnDocument, outcome: TrnOutcome): Layout {137+ const layout: Layout = { leaves: [], stages: [], rows: 0, columns: 0 }138+ let nextRow = 0139+ let startRow = -1140+ let endRow = -1141+ let previousColumn = 0142+143+ for (const stage of outcome.stages) {144+ const inputs: Array<{ startRow: number; endRow: number; column: number }> = []145+ for (const input of stage.inputs) {146+ const value = document.ingredients.get(input.id) ?? document.outcomes.get(input.id)!147+ const row = nextRow148+ nextRow += 1149+ layout.leaves.push({ row, input, label: value.label })150+ inputs.push({ startRow: row, endRow: row, column: 0 })151+ if (startRow < 0) startRow = row152+ endRow = row153+ }154+155+ const column = previousColumn + 1156+ const arms: StageLayout['arms'] = inputs157+ .filter(input => input.column + 1 < column)158+ .map(input => ({159+ startColumn: input.column + 1,160+ endColumn: column - 1,161+ startRow: input.startRow,162+ endRow: input.endRow,163+ }))164+ layout.stages.push({ outcome, stage, column, startRow, endRow, arms, branchColor: 0 })165+ previousColumn = column166+ layout.columns = column167+ }168+169+ layout.rows = Math.max(1, nextRow)170+ assignBranchColors(layout.stages)171+ return layout172+}173+174+function outcomesInDependencyOrder(document: TrnDocument): TrnOutcome[] {175+ const ordered: TrnOutcome[] = []176+ const visited = new Set<string>()177+ function visit(outcome: TrnOutcome): void {178+ if (visited.has(outcome.id)) return179+ visited.add(outcome.id)180+ for (const stage of outcome.stages) {181+ for (const input of stage.inputs) {182+ const child = document.outcomes.get(input.id)183+ if (child !== undefined) visit(child)184+ }185+ }186+ ordered.push(outcome)187+ }188+ for (const root of document.roots) visit(root)189+ return ordered190+}191+192+function stageCells(item: StageLayout): Set<string> {193+ const cells = new Set<string>()194+ for (let row = item.startRow; row <= item.endRow; row += 1) cells.add(`${item.column}:${row}`)195+ for (const arm of item.arms) {196+ for (let column = arm.startColumn; column <= arm.endColumn; column += 1) {197+ for (let row = arm.startRow; row <= arm.endRow; row += 1) cells.add(`${column}:${row}`)198+ }199+ }200+ return cells201+}202+203+function zonesTouch(left: Set<string>, right: Set<string>): boolean {204+ for (const value of left) {205+ const [column, row] = value.split(':').map(Number) as [number, number]206+ if (right.has(`${column - 1}:${row}`) || right.has(`${column + 1}:${row}`)207+ || right.has(`${column}:${row - 1}`) || right.has(`${column}:${row + 1}`)) return true208+ }209+ return false210+}211+212+function assignBranchColors(stages: StageLayout[]): void {213+ const cells = stages.map(stageCells)214+ const neighbors = stages.map(() => new Set<number>())215+ for (let left = 0; left < stages.length; left += 1) {216+ for (let right = left + 1; right < stages.length; right += 1) {217+ if (!zonesTouch(cells[left]!, cells[right]!)) continue218+ neighbors[left]!.add(right)219+ neighbors[right]!.add(left)220+ }221+ }222+223+ const colors = Array.from({ length: stages.length }, () => -1)224+ function colorNext(): boolean {225+ let selected = -1226+ let selectedSaturation = -1227+ let selectedDegree = -1228+ for (let index = 0; index < stages.length; index += 1) {229+ if (colors[index] !== -1) continue230+ const saturation = new Set([...neighbors[index]!].map(neighbor => colors[neighbor]!).filter(color => color >= 0)).size231+ const degree = neighbors[index]!.size232+ if (saturation > selectedSaturation || (saturation === selectedSaturation && degree > selectedDegree)) {233+ selected = index234+ selectedSaturation = saturation235+ selectedDegree = degree236+ }237+ }238+ if (selected === -1) return true239+ const unavailable = new Set([...neighbors[selected]!].map(neighbor => colors[neighbor]!).filter(color => color >= 0))240+ for (let color = 0; color < BRANCH_COLORS.length; color += 1) {241+ if (unavailable.has(color)) continue242+ colors[selected] = color243+ if (colorNext()) return true244+ colors[selected] = -1245+ }246+ return false247+ }248+249+ if (!colorNext()) throw new Error('TRN relationship zones could not be colored without adjacent matches.')250+ stages.forEach((stage, index) => { stage.branchColor = colors[index]! })251+}252+253+function geometryFor(layout: Layout): Geometry {254+ const longestIngredient = Math.max(...layout.leaves.map(leaf => textWidth(ingredientText(leaf), 12, 18)))255+ const labelWidth = Math.ceil(clamp(longestIngredient, MIN_LABEL_WIDTH, MAX_LABEL_WIDTH))256+ const columnWidths = Array.from({ length: layout.columns }, () => OPERATION_WIDTH)257+ const columnOffsets: number[] = []258+ let offset = labelWidth259+ for (const width of columnWidths) {260+ columnOffsets.push(offset)261+ offset += width262+ }263+ const rowHeights = Array.from({ length: layout.rows }, () => MIN_ROW_HEIGHT)264+ const stagesBySpan = [...layout.stages].sort((left, right) =>265+ (left.endRow - left.startRow) - (right.endRow - right.startRow))266+ for (const item of stagesBySpan) {267+ const requiredHeight = Math.ceil(textWidth(actionText(item), OPERATION_FONT_SIZE, OPERATION_TEXT_PADDING))268+ const rows = item.endRow - item.startRow + 1269+ const availableHeight = rowHeights.slice(item.startRow, item.endRow + 1)270+ .reduce((total, height) => total + height, 0)271+ let deficit = Math.max(0, requiredHeight - availableHeight)272+ for (let row = item.startRow; row <= item.endRow && deficit > 0; row += 1) {273+ const rowsLeft = item.endRow - row + 1274+ const addition = Math.ceil(deficit / rowsLeft)275+ rowHeights[row]! += addition276+ deficit -= addition277+ }278+ }279+ const rowOffsets: number[] = []280+ let bodyHeight = 0281+ for (const height of rowHeights) {282+ rowOffsets.push(bodyHeight)283+ bodyHeight += height284+ }285+ return { labelWidth, columnWidths, columnOffsets, rowHeights, rowOffsets, bodyHeight, tableWidth: offset }286+}287+288+function leafSvg(leaf: LeafLayout, geometry: Geometry, bodyY: number): string {289+ const text = ingredientText(leaf)290+ const x = MARGIN + 7291+ const height = geometry.rowHeights[leaf.row]!292+ const top = rowY(leaf.row, bodyY, geometry)293+ const y = top + height / 2 + 4294+ const available = geometry.labelWidth - 14295+ const length = Math.max(1, Math.floor(available / 6.7))296+ const shown = text.length <= length ? text : `${text.slice(0, length - 1)}…`297+ return `<g class="trn-ingredient" data-value-id="${escapeXml(leaf.input.id)}"><title>${escapeXml(text)}</title><rect class="trn-ingredient-cell" x="${MARGIN}" y="${top}" width="${geometry.labelWidth}" height="${height}" fill="white" stroke="${GRID_COLOR}" stroke-width="1"/><text class="trn-ingredient-label" x="${x}" y="${y}" fill="#111827" font-size="12" font-family="${FONT_FAMILY}">${escapeXml(shown)}</text></g>`298+}299+300+function stageSvg(item: StageLayout, geometry: Geometry, bodyY: number): string {301+ const x = MARGIN + geometry.columnOffsets[item.column - 1]!302+ const y = rowY(item.startRow, bodyY, geometry)303+ const width = geometry.columnWidths[item.column - 1]!304+ const height = geometry.rowOffsets[item.endRow]! + geometry.rowHeights[item.endRow]! - geometry.rowOffsets[item.startRow]!305+ const result = item.outcome.portion === '' ? item.outcome.label : `${item.outcome.label} (${item.outcome.portion})`306+ const action = actionText(item)307+ const cells = stageCells(item)308+ const columnLeft = (column: number): number => MARGIN + geometry.columnOffsets[column - 1]!309+ const columnRight = (column: number): number => columnLeft(column) + geometry.columnWidths[column - 1]!310+ const edges: Array<{ start: [number, number]; end: [number, number] }> = []311+ for (const value of cells) {312+ const [column, row] = value.split(':').map(Number) as [number, number]313+ const left = columnLeft(column)314+ const right = columnRight(column)315+ const top = rowY(row, bodyY, geometry)316+ const bottom = top + geometry.rowHeights[row]!317+ if (!cells.has(`${column}:${row - 1}`)) edges.push({ start: [left, top], end: [right, top] })318+ if (!cells.has(`${column + 1}:${row}`)) edges.push({ start: [right, top], end: [right, bottom] })319+ if (!cells.has(`${column}:${row + 1}`)) edges.push({ start: [right, bottom], end: [left, bottom] })320+ if (!cells.has(`${column - 1}:${row}`)) edges.push({ start: [left, bottom], end: [left, top] })321+ }322+ const pointKey = (point: [number, number]): string => `${point[0]}:${point[1]}`323+ const edgesByStart = new Map<string, number[]>()324+ edges.forEach((edge, index) => {325+ const key = pointKey(edge.start)326+ const indexes = edgesByStart.get(key) ?? []327+ indexes.push(index)328+ edgesByStart.set(key, indexes)329+ })330+ const usedEdges = new Set<number>()331+ const paths: string[] = []332+ for (let index = 0; index < edges.length; index += 1) {333+ if (usedEdges.has(index)) continue334+ const first = edges[index]!335+ usedEdges.add(index)336+ const points: Array<[number, number]> = [first.start, first.end]337+ let current = first.end338+ while (pointKey(current) !== pointKey(first.start)) {339+ const nextIndex = (edgesByStart.get(pointKey(current)) ?? []).find(candidate => !usedEdges.has(candidate))340+ if (nextIndex === undefined) throw new Error('TRN relationship zone has an open boundary.')341+ usedEdges.add(nextIndex)342+ current = edges[nextIndex]!.end343+ points.push(current)344+ }345+ paths.push(`M${points.map(point => `${point[0]} ${point[1]}`).join('L')}Z`)346+ }347+ const zonePath = paths.join('')348+ return [349+ `<g class="trn-operation-zone" data-outcome-id="${escapeXml(item.outcome.id)}" data-column="${item.column}" data-cell-count="${cells.size}" data-arm-count="${item.arms.length}" data-branch-color="${item.branchColor + 1}">`,350+ `<title>${escapeXml(action)} → ${escapeXml(result)}</title>`,351+ `<path class="trn-zone-shape trn-branch-${item.branchColor + 1}" d="${zonePath}" fill="${BRANCH_COLORS[item.branchColor]}" stroke="${GRID_COLOR}" stroke-width="1"/>`,352+ `<text class="trn-operation-label" x="${x + width / 2}" y="${y + height / 2}" text-anchor="middle" dominant-baseline="middle" fill="#111827" font-size="${OPERATION_FONT_SIZE}" font-family="${FONT_FAMILY}" transform="rotate(90 ${x + width / 2} ${y + height / 2})">${escapeXml(action)}</text>`,353+ '</g>',354+ ].join('')355+}356+357+function instructionSvg(document: TrnDocument, tableY: number, tableWidth: number): string {358+ return document.instructions.map((instruction, index) => {359+ const y = tableY + index * MIN_ROW_HEIGHT360+ return `<g class="trn-instruction"><rect class="trn-instruction-cell" x="${MARGIN}" y="${y}" width="${tableWidth}" height="${MIN_ROW_HEIGHT}" fill="white" stroke="${GRID_COLOR}" stroke-width="1"/><text class="trn-instruction-label" x="${MARGIN + tableWidth / 2}" y="${y + MIN_ROW_HEIGHT / 2 + 4}" text-anchor="middle" fill="#111827" font-size="12" font-family="${FONT_FAMILY}">${escapeXml(instruction.text)}</text></g>`361+ }).join('')362+}363+364+function renderCombinedTrn(document: TrnDocument): string {365+ const layout = layoutTree(document)366+ const geometry = geometryFor(layout)367+ const tableY = MARGIN + HEADER_HEIGHT368+ const instructionHeight = document.instructions.length * MIN_ROW_HEIGHT369+ const bodyY = tableY + instructionHeight370+ const width = MARGIN * 2 + geometry.tableWidth371+ const height = MARGIN * 2 + HEADER_HEIGHT + instructionHeight + geometry.bodyHeight372+ const title = document.roots.map(root => root.portion === '' ? root.label : `${root.label} (yields ${root.portion})`).join(' + ')373+ const instructions = instructionSvg(document, tableY, geometry.tableWidth)374+ const leaves = layout.leaves.map(item => leafSvg(item, geometry, bodyY)).join('')375+ const stages = layout.stages.map(item => stageSvg(item, geometry, bodyY)).join('')376+377+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" class="trn-diagram trn-layout-combined" data-layout="combined">378+ <title>${escapeXml(title)} TRN</title>379+ <desc>Tabular Recipe Notation with ingredients in rows and operations spanning the rows they combine.</desc>380+ <rect class="trn-canvas" data-dz-role="canvas" x="0" y="0" width="${width}" height="${height}" fill="#fbf8dc"/>381+ <text class="trn-title" x="${MARGIN + 7}" y="${MARGIN + 19}" fill="#111827" font-size="14" font-weight="700" font-family="${FONT_FAMILY}">${escapeXml(title)}</text>382+ <rect class="trn-table-surface" x="${MARGIN}" y="${tableY}" width="${geometry.tableWidth}" height="${instructionHeight + geometry.bodyHeight}" fill="white" stroke="${GRID_COLOR}" stroke-width="1"/>383+ ${instructions}384+ ${leaves}385+ ${stages}386+</svg>`387+}388+389+function renderIndividualTrn(document: TrnDocument): string {390+ const title = document.roots.map(root => root.portion === '' ? root.label : `${root.label} (yields ${root.portion})`).join(' + ')391+ const recipes = outcomesInDependencyOrder(document).map(outcome => {392+ const layout = layoutOutcome(document, outcome)393+ const geometry = geometryFor(layout)394+ return { outcome, layout, geometry, height: HEADER_HEIGHT + geometry.bodyHeight }395+ })396+ const recipeById = new Map(recipes.map(recipe => [recipe.outcome.id, recipe]))397+ const dependencies = new Map(recipes.map(recipe => [398+ recipe.outcome.id,399+ [...new Set(recipe.outcome.stages.flatMap(stage => stage.inputs.map(input => input.id)))]400+ .map(id => document.outcomes.get(id))401+ .filter((outcome): outcome is TrnOutcome => outcome !== undefined),402+ ]))403+ const depths = new Map<string, number>()404+ function depthOf(outcome: TrnOutcome): number {405+ const known = depths.get(outcome.id)406+ if (known !== undefined) return known407+ const children = dependencies.get(outcome.id) ?? []408+ const depth = children.length === 0 ? 0 : Math.max(...children.map(depthOf)) + 1409+ depths.set(outcome.id, depth)410+ return depth411+ }412+ const maximumDepth = Math.max(...recipes.map(recipe => depthOf(recipe.outcome)))413+ const columnWidths = Array.from({ length: maximumDepth + 1 }, (_, depth) =>414+ Math.max(...recipes.filter(recipe => depthOf(recipe.outcome) === depth).map(recipe => recipe.geometry.tableWidth)))415+ const columnOffsets: number[] = []416+ let columnOffset = 0417+ for (const columnWidth of columnWidths) {418+ columnOffsets.push(columnOffset)419+ columnOffset += columnWidth + RECIPE_COLUMN_GAP420+ }421+ const centers = new Map<string, number>()422+ for (let depth = 0; depth <= maximumDepth; depth += 1) {423+ const column = recipes.filter(recipe => depthOf(recipe.outcome) === depth)424+ .map(recipe => {425+ const children = dependencies.get(recipe.outcome.id) ?? []426+ const target = children.length === 0427+ ? Number.POSITIVE_INFINITY428+ : children.reduce((total, child) => total + centers.get(child.id)!, 0) / children.length429+ return { recipe, target }430+ })431+ if (depth === 0) {432+ let cursor = 0433+ for (const item of column) {434+ centers.set(item.recipe.outcome.id, cursor + item.recipe.height / 2)435+ cursor += item.recipe.height + RECIPE_GAP436+ }437+ continue438+ }439+ column.sort((left, right) => left.target - right.target)440+ let bottom = 0441+ for (const item of column) {442+ const center = Math.max(item.target, bottom + item.recipe.height / 2)443+ centers.set(item.recipe.outcome.id, center)444+ bottom = center + item.recipe.height / 2 + RECIPE_GAP445+ }446+ }447+ const treeHeight = Math.max(...recipes.map(recipe => centers.get(recipe.outcome.id)! + recipe.height / 2))448+ const width = columnOffset - RECIPE_COLUMN_GAP + MARGIN * 2449+ const instructionHeight = document.instructions.length * MIN_ROW_HEIGHT450+ const instructionY = MARGIN + HEADER_HEIGHT451+ const treeBaseY = instructionY + instructionHeight + (instructionHeight > 0 ? RECIPE_GAP : 0)452+ const dependencyLinks = recipes.flatMap(parent => (dependencies.get(parent.outcome.id) ?? []).map(child => ({ parent, child })))453+ const treeY = treeBaseY454+ const instructions = instructionSvg(document, instructionY, width - MARGIN * 2)455+ const recipeRects = recipes.map(recipe => {456+ const depth = depthOf(recipe.outcome)457+ const top = treeY + centers.get(recipe.outcome.id)! - recipe.height / 2458+ return {459+ id: recipe.outcome.id,460+ left: columnOffsets[depth]! + MARGIN - RECIPE_LINK_CLEARANCE,461+ right: columnOffsets[depth]! + MARGIN + recipe.geometry.tableWidth + RECIPE_LINK_CLEARANCE,462+ top: top - RECIPE_LINK_CLEARANCE,463+ bottom: top + recipe.height + RECIPE_LINK_CLEARANCE,464+ }465+ })466+467+ const recipeSvg = recipes.map(recipe => {468+ const depth = depthOf(recipe.outcome)469+ const recipeX = columnOffsets[depth]!470+ const recipeY = Math.round(centers.get(recipe.outcome.id)! - recipe.height / 2)471+ const tableY = HEADER_HEIGHT472+ const bodyY = tableY473+ const recipeTitle = recipe.outcome.portion === ''474+ ? recipe.outcome.label475+ : `${recipe.outcome.label} (${recipe.outcome.portion})`476+ const leaves = recipe.layout.leaves.map(item => leafSvg(item, recipe.geometry, bodyY)).join('')477+ const stages = recipe.layout.stages.map(item => stageSvg(item, recipe.geometry, bodyY)).join('')478+ return `<g class="trn-individual-recipe" data-recipe-id="${escapeXml(recipe.outcome.id)}" data-depth="${depth}" transform="translate(${recipeX} ${treeY + recipeY})"><text class="trn-title trn-recipe-title" x="${MARGIN + 7}" y="19" fill="#111827" font-size="14" font-weight="700" font-family="${FONT_FAMILY}">${escapeXml(recipeTitle)}</text><rect class="trn-table-surface" x="${MARGIN}" y="${tableY}" width="${recipe.geometry.tableWidth}" height="${recipe.geometry.bodyHeight}" fill="white" stroke="${GRID_COLOR}" stroke-width="1"/>${leaves}${stages}</g>`479+ }).join('')480+ const links = dependencyLinks.map(({ parent, child }) => {481+ const childRecipe = recipeById.get(child.id)!482+ const childDepth = depthOf(child)483+ const parentDepth = depthOf(parent.outcome)484+ const startX = columnOffsets[childDepth]! + MARGIN + childRecipe.geometry.tableWidth485+ const startY = treeY + centers.get(child.id)! + HEADER_HEIGHT / 2486+ const endX = columnOffsets[parentDepth]! + MARGIN487+ const endY = treeY + centers.get(parent.outcome.id)! + HEADER_HEIGHT / 2488+ const startGutterX = columnOffsets[childDepth]! + MARGIN + columnWidths[childDepth]! + RECIPE_COLUMN_GAP / 2489+ const endGutterX = columnOffsets[parentDepth]! + MARGIN - RECIPE_COLUMN_GAP / 2490+ const relevantRects = recipeRects.filter(rect => rect.id !== child.id && rect.id !== parent.outcome.id491+ && rect.left < endGutterX && rect.right > startGutterX)492+ const candidates = [...new Set([493+ startY,494+ endY,495+ ...relevantRects.flatMap(rect => [rect.top, rect.bottom]),496+ treeY + treeHeight + RECIPE_LINK_CLEARANCE,497+ ])]498+ const hallwayY = candidates499+ .filter(y => relevantRects.every(rect => y <= rect.top || y >= rect.bottom))500+ .sort((left, right) => {501+ const leftCost = Math.abs(startY - left) + Math.abs(endY - left)502+ const rightCost = Math.abs(startY - right) + Math.abs(endY - right)503+ return leftCost - rightCost || Math.abs(startY - left) - Math.abs(startY - right)504+ })[0]!505+ const adjacent = parentDepth - childDepth === 1506+ const route = adjacent507+ ? `M${startX} ${startY}H${startGutterX}V${endY}H${endX}`508+ : `M${startX} ${startY}H${startGutterX}V${hallwayY}H${endGutterX}V${endY}H${endX}`509+ const routeKind = adjacent ? 'gutter' : 'hallway'510+ return `<path class="trn-recipe-link" data-from="${escapeXml(child.id)}" data-to="${escapeXml(parent.outcome.id)}" data-route="${routeKind}" d="${route}" fill="none" stroke="${GRID_COLOR}" stroke-width="1.5"/>`511+ }).join('')512+ const height = treeY + treeHeight + MARGIN513+514+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" width="${width}" height="${height}" class="trn-diagram trn-layout-individual" data-layout="individual">515+ <title>${escapeXml(title)} individual TRN recipes</title>516+ <desc>Tabular Recipe Notation with one table per produced outcome, arranged left to right by dependency depth.</desc>517+ <rect class="trn-canvas" data-dz-role="canvas" x="0" y="0" width="${width}" height="${height}" fill="#fbf8dc"/>518+ <text class="trn-title" x="${MARGIN + 7}" y="${MARGIN + 19}" fill="#111827" font-size="14" font-weight="700" font-family="${FONT_FAMILY}">${escapeXml(title)} — individual recipes</text>519+ ${instructions}520+ <g class="trn-recipe-links">${links}</g>521+ ${recipeSvg}522+</svg>`523+}524+525+export function renderTrn(document: TrnDocument): string {526+ return document.layout === 'individual' ? renderIndividualTrn(document) : renderCombinedTrn(document)527+}
A renderers/edge/src/languages/trn.ts +197 -0
@@ -0,0 +1,197 @@
1+import { RenderError } from '../runtime/errors'2+3+export interface TrnIngredient {4+ id: string5+ label: string6+ line: number7+}8+9+export interface TrnInput {10+ id: string11+ quantity: string12+ line: number13+}14+15+export interface TrnStage {16+ inputs: TrnInput[]17+ action: string18+ line: number19+}20+21+export interface TrnOutcome {22+ id: string23+ label: string24+ portion: string25+ stages: TrnStage[]26+ line: number27+}28+29+export type TrnLayout = 'combined' | 'individual'30+31+export interface TrnDocument {32+ layout: TrnLayout33+ ingredients: Map<string, TrnIngredient>34+ outcomes: Map<string, TrnOutcome>35+ instructions: Array<{ text: string; line: number }>36+ roots: TrnOutcome[]37+}38+39+const IDENTIFIER = '[A-Za-z][A-Za-z0-9_]*'40+41+function sourceError(line: number, message: string): never {42+ throw new RenderError(422, 'invalid_source', `Line ${line}: ${message}`)43+}44+45+function displayName(id: string): string {46+ return id.replaceAll('_', ' ').replace(/\b\w/g, character => character.toUpperCase())47+}48+49+function quotedLabel(value: string, line: number): string {50+ try {51+ return JSON.parse(`"${value}"`) as string52+ } catch {53+ return sourceError(line, 'The display label is not a valid quoted string.')54+ }55+}56+57+function validateReferences(document: TrnDocument): void {58+ const referencedOutcomes = new Set<string>()59+ for (const outcome of document.outcomes.values()) {60+ for (const stage of outcome.stages) {61+ for (const input of stage.inputs) {62+ if (document.outcomes.has(input.id)) referencedOutcomes.add(input.id)63+ else if (!document.ingredients.has(input.id)) sourceError(input.line, `Unknown value '${input.id}'.`)64+ }65+ }66+ }67+68+ const visiting = new Set<string>()69+ const visited = new Set<string>()70+ function visit(id: string, path: string[]): void {71+ if (visiting.has(id)) sourceError(document.outcomes.get(id)!.line, `Outcome cycle: ${[...path, id].join(' -> ')}.`)72+ if (visited.has(id)) return73+ visiting.add(id)74+ const outcome = document.outcomes.get(id)!75+ for (const stage of outcome.stages) {76+ for (const input of stage.inputs) {77+ if (document.outcomes.has(input.id)) visit(input.id, [...path, id])78+ }79+ }80+ visiting.delete(id)81+ visited.add(id)82+ }83+ for (const id of document.outcomes.keys()) visit(id, [])84+85+ document.roots = [...document.outcomes.values()].filter(outcome => !referencedOutcomes.has(outcome.id))86+ if (document.roots.length === 0) sourceError(1, 'The document has no unconsumed outcome to render.')87+}88+89+export function parseTrn(source: string): TrnDocument {90+ const document: TrnDocument = {91+ layout: 'combined',92+ ingredients: new Map(),93+ outcomes: new Map(),94+ instructions: [],95+ roots: [],96+ }97+ let current: { id: string; label: string; portion: string; line: number; stages: TrnStage[]; pending: TrnInput[] } | null = null98+ let layoutDeclared = false99+ let declarationsStarted = false100+101+ const lines = source.replaceAll('\r\n', '\n').replaceAll('\r', '\n').split('\n')102+ for (let index = 0; index < lines.length; index += 1) {103+ const lineNumber = index + 1104+ const line = lines[index]!.trim()105+ if (line === '' || line.startsWith('#')) continue106+107+ if (current !== null) {108+ if (line === '}') {109+ if (current.pending.length > 0) sourceError(lineNumber, `Outcome '${current.id}' has inputs without a following step.`)110+ if (current.stages.length === 0) sourceError(current.line, `Outcome '${current.id}' must contain at least one step.`)111+ const portionStage = current.stages.find(stage => stage.action.includes('{portion}'))112+ if (portionStage !== undefined && current.portion === '') {113+ sourceError(portionStage.line, `Action in '${current.id}' uses {portion}, but the outcome has no portion.`)114+ }115+ const outcome: TrnOutcome = {116+ id: current.id,117+ label: current.label,118+ portion: current.portion,119+ stages: current.stages,120+ line: current.line,121+ }122+ document.outcomes.set(outcome.id, outcome)123+ current = null124+ continue125+ }126+127+ const inputMatch = line.match(new RegExp(`^\\+\\s+(${IDENTIFIER})(?:\\s+(.+))?$`))128+ if (inputMatch !== null) {129+ current.pending.push({ id: inputMatch[1]!, quantity: inputMatch[2]?.trim() ?? '', line: lineNumber })130+ continue131+ }132+ const stepMatch = line.match(/^->\s+(.+)$/)133+ if (stepMatch !== null) {134+ if (current.stages.length === 0 && current.pending.length === 0) {135+ sourceError(lineNumber, `The first step in '${current.id}' needs an input.`)136+ }137+ current.stages.push({ inputs: current.pending, action: stepMatch[1]!.trim(), line: lineNumber })138+ current.pending = []139+ continue140+ }141+ sourceError(lineNumber, "Expected '+ value', '-> action', or '}'.")142+ }143+144+ const layoutMatch = line.match(/^\.layout\s+(combined|individual)\s*$/)145+ if (layoutMatch !== null) {146+ if (declarationsStarted) sourceError(lineNumber, 'The .layout directive must appear before recipe declarations.')147+ if (layoutDeclared) sourceError(lineNumber, 'The .layout directive may only appear once.')148+ document.layout = layoutMatch[1] as TrnLayout149+ layoutDeclared = true150+ continue151+ }152+ if (line.startsWith('.layout')) sourceError(lineNumber, "Expected '.layout combined' or '.layout individual'.")153+154+ const instructionMatch = line.match(/^instruction\s+"((?:[^"\\]|\\.)*)"\s*$/)155+ if (instructionMatch !== null) {156+ declarationsStarted = true157+ document.instructions.push({ text: quotedLabel(instructionMatch[1]!, lineNumber), line: lineNumber })158+ continue159+ }160+161+ const ingredientMatch = line.match(new RegExp(`^ingredient\\s+(${IDENTIFIER})(?:\\s+"((?:[^"\\\\]|\\\\.)*)")?\\s*$`))162+ if (ingredientMatch !== null) {163+ declarationsStarted = true164+ const id = ingredientMatch[1]!165+ if (document.ingredients.has(id) || document.outcomes.has(id)) sourceError(lineNumber, `Duplicate value '${id}'.`)166+ document.ingredients.set(id, {167+ id,168+ label: ingredientMatch[2] === undefined ? displayName(id) : quotedLabel(ingredientMatch[2], lineNumber),169+ line: lineNumber,170+ })171+ continue172+ }173+174+ const outcomeMatch = line.match(new RegExp(`^outcome\\s+(${IDENTIFIER})(?:\\s+"((?:[^"\\\\]|\\\\.)*)")?(?:\\s+portion\\s+(.+?))?\\s*\\{\\s*$`))175+ if (outcomeMatch !== null) {176+ declarationsStarted = true177+ const id = outcomeMatch[1]!178+ if (document.ingredients.has(id) || document.outcomes.has(id)) sourceError(lineNumber, `Duplicate value '${id}'.`)179+ current = {180+ id,181+ label: outcomeMatch[2] === undefined ? displayName(id) : quotedLabel(outcomeMatch[2], lineNumber),182+ portion: outcomeMatch[3]?.trim() ?? '',183+ line: lineNumber,184+ stages: [],185+ pending: [],186+ }187+ continue188+ }189+190+ sourceError(lineNumber, "Expected an instruction, ingredient, or outcome declaration.")191+ }192+193+ if (current !== null) sourceError(current.line, `Outcome '${current.id}' is missing its closing brace.`)194+ if (document.outcomes.size === 0) sourceError(1, 'The document must declare at least one outcome.')195+ validateReferences(document)196+ return document197+}
A renderers/edge/src/units/trn.ts +9 -0
M renderers/edge/test/adapters.test.ts +1 -0
A renderers/edge/test/trn.test.ts +162 -0
@@ -0,0 +1,162 @@
1+import { describe, expect, it } from 'vitest'2+import zenith from '../../../examples/diagrams/zenith.trn?raw'3+import marshmallows from '../../../examples/diagrams/marshmallows.trn?raw'4+import { trnAdapter } from '../src/adapters/trn'5+import { parseTrn } from '../src/languages/trn'6+import type { RenderRequest } from '../src/runtime/types'7+8+function request(source: string, options: Record<string, string> = {}): RenderRequest {9+ return {10+ engine: 'trn',11+ source,12+ format: 'svg',13+ options,14+ metadata: { title: '', description: '' },15+ presentation: { background: '', padding: 0, frame: false },16+ }17+}18+19+describe('TRN language', () => {20+ it('parses the full Zenith tree and infers its single root', () => {21+ const document = parseTrn(zenith)22+23+ expect(document.ingredients.size).toBe(22)24+ expect(document.outcomes.size).toBe(10)25+ expect(document.layout).toBe('individual')26+ expect(document.roots.map(root => root.id)).toEqual(['zenith'])27+ expect(document.outcomes.get('zenith')?.stages[0]?.inputs).toHaveLength(10)28+ expect(document.outcomes.get('true_nights_edge')?.label).toBe("True Night's Edge")29+ })30+31+ it('renders ingredients as rows and operations as spanning table cells', async () => {32+ const result = await trnAdapter.render(request(zenith.replace('.layout individual', '.layout combined')), new AbortController().signal)33+34+ expect(result.body).toContain('<title>Zenith TRN</title>')35+ expect(result.body).toContain('data-outcome-id="terra_blade"')36+ expect(result.body).toContain('class="trn-ingredient"')37+ expect(result.body).toContain('class="trn-operation-zone"')38+ expect(result.body).toContain('data-column="6"')39+ expect(result.body).toContain('class="trn-zone-shape')40+ expect(result.body).toContain('transform="rotate(90')41+ expect(result.body).not.toContain('Moon Lord drop')42+ })43+44+ it('flattens outcomes into dependency-ordered individual recipe tables', async () => {45+ const result = await trnAdapter.render(request(zenith), new AbortController().signal)46+ const recipes = [...result.body.matchAll(/class="trn-individual-recipe" data-recipe-id="([^"]+)"/g)]47+ .map(match => match[1])48+49+ expect(result.body).toContain('data-layout="individual"')50+ expect(recipes).toHaveLength(10)51+ expect(recipes.slice(0, 2)).toEqual(['copper_shortsword', 'lights_bane'])52+ expect(recipes.at(-1)).toBe('zenith')53+ expect(result.body).not.toContain('data-recipe-id="chlorophyte_bars"')54+ expect(result.body).toContain('>smelt 24 Chlorophyte Bars</text>')55+ expect(result.body).toContain('data-recipe-id="zenith" data-depth="4"')56+ expect(result.body.match(/class="trn-recipe-link"/g)).toHaveLength(9)57+ expect(result.body.match(/data-route="hallway"/g)).toHaveLength(2)58+ })59+60+ it('validates the source layout directive and rejects renderer options', () => {61+ expect(() => parseTrn(zenith.replace('.layout individual', '.layout grid')))62+ .toThrow(/Expected '.layout combined' or '.layout individual'/)63+ expect(() => parseTrn('.layout combined\n.layout individual\ningredient flour\noutcome bread {\n + flour\n -> bake\n}'))64+ .toThrow(/may only appear once/)65+ expect(() => parseTrn('ingredient flour\n.layout individual\noutcome bread {\n + flour\n -> bake\n}'))66+ .toThrow(/must appear before recipe declarations/)67+ expect(() => trnAdapter.render(request(zenith, { color: 'blue' }), new AbortController().signal))68+ .toThrow(/Unsupported TRN option: color/)69+ })70+71+ it('renders the reference marshmallow recipe with its preparation row', async () => {72+ const document = parseTrn(marshmallows)73+ const result = await trnAdapter.render(request(marshmallows), new AbortController().signal)74+75+ expect(document.instructions.map(item => item.text)).toEqual(['Grease 9x13-in. pan and powder with powdered sugar'])76+ expect(document.layout).toBe('combined')77+ expect(document.roots[0]).toMatchObject({ id: 'marshmallows', portion: 'about 40 large marshmallows' })78+ expect(result.body).toContain('Marshmallows (yields about 40 large marshmallows)')79+ expect(result.body).toContain('class="trn-instruction-cell"')80+ expect(result.body).toContain('mix until marshmallow has fluffed up')81+ expect(result.body).toContain('data-outcome-id="marshmallows" data-column="7"')82+ const colors = [...result.body.matchAll(/class="trn-operation-zone"[^>]*data-branch-color="(\d)"/g)].map(match => match[1])83+ expect(colors).toHaveLength(8)84+ const touchingPairs: Array<[number, number]> = [[0, 1], [0, 2], [1, 2], [1, 3], [2, 3], [3, 4], [4, 5], [5, 6], [6, 7]]85+ for (const [left, right] of touchingPairs) {86+ expect(colors[left]!).not.toBe(colors[right]!)87+ }88+ })89+90+ it('rejects unknown values and dependency cycles with source locations', () => {91+ expect(() => parseTrn('outcome bread {\n + flour\n -> mix\n}')).toThrow(/Line 2: Unknown value 'flour'/)92+ expect(() => parseTrn('outcome a {\n + b\n -> make\n}\noutcome b {\n + a\n -> make\n}')).toThrow(/Outcome cycle/)93+ expect(() => parseTrn('ingredient flour\noutcome bread {\n + flour\n -> bake {portion}\n}')).toThrow(/Line 4: Action in 'bread' uses \{portion\}, but the outcome has no portion/)94+ })95+96+ it('supports sequential steps and interpolates optional portions in actions', async () => {97+ const source = 'ingredient flour\noutcome bread portion 1 loaf {\n + flour 500 g\n -> mix\n -> bake {portion}\n}'98+ const document = parseTrn(source)99+ const result = await trnAdapter.render(request(source), new AbortController().signal)100+101+ expect(document.outcomes.get('bread')).toMatchObject({ portion: '1 loaf' })102+ expect(document.outcomes.get('bread')?.stages).toHaveLength(2)103+ expect(result.body).toContain('>bake 1 loaf</text>')104+ expect(result.body).not.toContain('{portion}')105+ })106+107+ it('expands row height to fit a vertical action phrase without shrinking it', async () => {108+ const source = `ingredient ore109+outcome blade {110+ + ore111+ -> forge a finished copper shortsword112+}`113+ const result = await trnAdapter.render(request(source), new AbortController().signal)114+ const ingredientCell = result.body.match(/class="trn-ingredient-cell"[^>]*height="(\d+)"/)115+116+ expect(Number(ingredientCell?.[1])).toBeGreaterThan(28)117+ expect(result.body).toContain('font-size="10"')118+ expect(result.body).toContain('>forge a finished copper shortsword</text>')119+ })120+121+ it('merges waiting cells and grows operations across the inputs they consume', async () => {122+ const source = `ingredient gelatin123+ingredient water124+ingredient sugar125+ingredient syrup126+ingredient salt127+ingredient vanilla128+ingredient coating129+130+outcome bloomed {131+ + gelatin132+ + water133+ -> soak134+}135+136+outcome cooked {137+ + sugar138+ + syrup139+ -> boil140+}141+142+outcome marshmallows {143+ + bloomed144+ + cooked145+ -> drizzle146+ + salt147+ -> mix148+ + vanilla149+ -> cool150+ -> cut151+ + coating152+ -> powder153+}`154+ const result = await trnAdapter.render(request(source), new AbortController().signal)155+156+ expect(result.body).not.toContain('class="trn-flow-cell"')157+ expect(result.body).toContain('data-outcome-id="marshmallows" data-column="3" data-cell-count="7" data-arm-count="1"')158+ expect(result.body).toContain('data-outcome-id="marshmallows" data-column="4" data-cell-count="9" data-arm-count="1"')159+ expect(result.body).toContain('data-outcome-id="marshmallows" data-column="6" data-cell-count="12" data-arm-count="1"')160+ expect(result.body).toContain('height="196"')161+ })162+})
M renderers/scripts/smoke-units.mjs +1 -0
M renderers/shared/engines.ts +1 -0
M services/api/test/aliases.test.ts +2 -2
M shared/svg/index.d.ts +2 -2
M shared/svg/index.js +58 -6
@@ -12,8 +12,8 @@ const SAFE_DATA_FONT = /^data:(?:font\/(?:woff2?|opentype|truetype)|application\
1212 export const SVG_SCHEMA = '1'1313 export const EDITABLE_SVG_SCHEMA = '1'1414 export const NORMALIZER_BUILD = 'svg-normalizer-2'15-export const MATERIALIZER_BUILD = 'svg-materializer-2'16-export const PALETTE_BUILD = 'diagramzip-palette-1'15+export const MATERIALIZER_BUILD = 'svg-materializer-3'16+export const PALETTE_BUILD = 'diagramzip-palette-2'1717 export const RAW_PROFILE = 'safe-raw-1'1818 export const APPEARANCES = Object.freeze([1919 'raw',
@@ -87,6 +87,11 @@ const AUTHORED_NEUTRAL_NORMALIZATION = capability(
8787 'adaptive',8888 ['Neutral canvas, ink, and line paint adapts; authored non-neutral paint remains renderer-defined.'],8989 )90+const TRN_NORMALIZATION = capability(91+ 'trn-semantic-4',92+ 'semantic',93+ ['Four relationship-zone branch colors adapt to the selected appearance and adjacent zones never share a color.'],94+)9095 9196 const PROFILE_BY_ENGINE = Object.freeze({9297 graphviz: GRAPHVIZ_NORMALIZATION,
@@ -119,6 +124,7 @@ const PROFILE_BY_ENGINE = Object.freeze({
119124 diagramsnet: PRESENTATION_NORMALIZATION,120125 excalidraw: AUTHORED_NEUTRAL_NORMALIZATION,121126 tikz: AUTHORED_NEUTRAL_NORMALIZATION,127+ trn: TRN_NORMALIZATION,122128 })123129 124130 const VERSION_PATTERNS = Object.freeze({
@@ -152,6 +158,7 @@ const VERSION_PATTERNS = Object.freeze({
152158 diagramsnet: /diagrams\.net@29\.6\.1/,153159 excalidraw: /@excalidraw\/excalidraw@0\.18\.1/,154160 tikz: /@planktimerr\/tikzjax@1\.0\.63/,161+ trn: /diagramzip-trn@13/,155162 })156163 157164 export function normalizationFor(engine, rendererVersion = '') {
@@ -710,6 +717,39 @@ function resolveSiblingTextContrast(root) {
710717 visit(root)711718 }712719 720+function applyTrnProfile(root) {721+ const visit = (node, inGrid = false) => {722+ if (node.type === 'text') return723+ const classes = classNames(node)724+ const name = localName(node)725+ const nextInGrid = inGrid || classes.has('trn-grid')726+727+ if (classes.has('trn-canvas')) node.attributes.set('data-dz-role', 'canvas')728+ if (classes.has('trn-header')) {729+ node.attributes.set('data-dz-fill', 'surface-2')730+ node.attributes.set('data-dz-stroke', 'line-muted')731+ }732+ if (classes.has('trn-table-surface')) node.attributes.set('data-dz-fill', 'surface-1')733+ if (classes.has('trn-ingredient-cell') || classes.has('trn-flow-cell') || classes.has('trn-instruction-cell')) {734+ node.attributes.set('data-dz-fill', 'surface-1')735+ node.attributes.set('data-dz-stroke', 'line-muted')736+ }737+ if (classes.has('trn-zone-fill') || classes.has('trn-zone-shape')) {738+ const branch = [1, 2, 3, 4].find(index => classes.has(`trn-branch-${index}`)) ?? 1739+ node.attributes.set('data-dz-fill', `accent-${branch}`)740+ }741+ if (classes.has('trn-zone-border') || classes.has('trn-zone-shape')) node.attributes.set('data-dz-stroke', 'line-muted')742+ if (nextInGrid && SHAPE_ELEMENTS.has(name)) node.attributes.set('data-dz-stroke', 'line-muted')743+ if (classes.has('trn-title') || classes.has('trn-ingredient-label') || classes.has('trn-instruction-label')) {744+ node.attributes.set('data-dz-fill', 'ink')745+ }746+ if (classes.has('trn-operation-label')) node.attributes.set('data-dz-fill', 'on-accent')747+748+ for (const child of node.children) visit(child, nextInGrid)749+ }750+ visit(root)751+}752+713753 function applyProfile(root, capability, engine) {714754 if (capability.profile === 'graphviz-15-semantic-2') applyGraphvizProfile(root, engine)715755 else if (capability.profile === 'd2-0.7-semantic-2') applyD2Profile(root)
@@ -726,6 +766,7 @@ function applyProfile(root, capability, engine) {
726766 else if (engine === 'wavedrom') applyWavedromProfile(root)727767 }728768 else if (capability.profile === 'authored-neutral-semantic-1') applyNeutralSvgProfile(root, engine)769+ else if (capability.profile === 'trn-semantic-4') applyTrnProfile(root)729770 if (capability.conformance !== 'presentation-only') resolveSiblingTextContrast(root)730771 }731772
@@ -951,16 +992,23 @@ export function materializePresentation(canonical, presentation) {
951992 const LIGHT_PALETTE = Object.freeze({952993 canvas: '#f8fafc', surface1: '#ffffff', surface2: '#f1f5f9', surface3: '#e2e8f0',953994 ink: '#0f172a', inkMuted: '#475569', line: '#334155', lineMuted: '#94a3b8',954- accent1: '#2563eb', accent2: '#7c3aed', accent3: '#c2410c', onAccent: '#ffffff', frame: '#cbd5e1',995+ accent1: '#2563eb', accent2: '#7c3aed', accent3: '#c2410c', accent4: '#047857', onAccent: '#ffffff', frame: '#cbd5e1',955996 })956997 const DARK_PALETTE = Object.freeze({957998 canvas: '#0f172a', surface1: '#1e293b', surface2: '#273449', surface3: '#334155',958999 ink: '#f8fafc', inkMuted: '#cbd5e1', line: '#e2e8f0', lineMuted: '#94a3b8',959- accent1: '#60a5fa', accent2: '#c4b5fd', accent3: '#fb923c', onAccent: '#0f172a', frame: '#475569',1000+ accent1: '#60a5fa', accent2: '#c4b5fd', accent3: '#fb923c', accent4: '#34d399', onAccent: '#0f172a', frame: '#475569',1001+})1002+const TRN_LIGHT_BRANCH_PALETTE = Object.freeze({1003+ accent1: '#dbeafe', accent2: '#fef3c7', accent3: '#ede9fe', accent4: '#ccfbf1', onAccent: '#0f172a',9601004 })9611005 9621006 function paletteVariables(palette) {963- return `--dz-canvas:${palette.canvas};--dz-surface-1:${palette.surface1};--dz-surface-2:${palette.surface2};--dz-surface-3:${palette.surface3};--dz-ink:${palette.ink};--dz-ink-muted:${palette.inkMuted};--dz-line:${palette.line};--dz-line-muted:${palette.lineMuted};--dz-accent-1:${palette.accent1};--dz-accent-2:${palette.accent2};--dz-accent-3:${palette.accent3};--dz-on-accent:${palette.onAccent};--dz-frame:${palette.frame};`1007+ return `--dz-canvas:${palette.canvas};--dz-surface-1:${palette.surface1};--dz-surface-2:${palette.surface2};--dz-surface-3:${palette.surface3};--dz-ink:${palette.ink};--dz-ink-muted:${palette.inkMuted};--dz-line:${palette.line};--dz-line-muted:${palette.lineMuted};--dz-accent-1:${palette.accent1};--dz-accent-2:${palette.accent2};--dz-accent-3:${palette.accent3};--dz-accent-4:${palette.accent4};--dz-on-accent:${palette.onAccent};--dz-frame:${palette.frame};`1008+}1009+1010+function branchPaletteVariables(palette) {1011+ return `--dz-accent-1:${palette.accent1};--dz-accent-2:${palette.accent2};--dz-accent-3:${palette.accent3};--dz-accent-4:${palette.accent4};--dz-on-accent:${palette.onAccent};`9641012 }9651013 9661014 function materializerCss(appearance) {
@@ -969,7 +1017,11 @@ function materializerCss(appearance) {
9691017 const variables = automatic9701018 ? `:root{${paletteVariables(LIGHT_PALETTE)}}@media(prefers-color-scheme:dark){:root{${paletteVariables(DARK_PALETTE)}}}`9711019 : `:root{${paletteVariables(scheme)}}`972- return `${variables}[data-dz-role="canvas"]:not([data-dz-owned="materializer"]){display:none!important}[data-dz-fill="none"]{fill:none!important}[data-dz-fill="surface-1"]{fill:var(--dz-surface-1)!important}[data-dz-fill="surface-2"]{fill:var(--dz-surface-2)!important}[data-dz-fill="surface-3"]{fill:var(--dz-surface-3)!important}[data-dz-fill="ink"]{fill:var(--dz-ink)!important;color:var(--dz-ink)!important}[data-dz-fill="ink-muted"]{fill:var(--dz-ink-muted)!important;color:var(--dz-ink-muted)!important}[data-dz-fill="line"]{fill:var(--dz-line)!important}[data-dz-fill="accent-1"]{fill:var(--dz-accent-1)!important}[data-dz-fill="accent-2"]{fill:var(--dz-accent-2)!important}[data-dz-fill="accent-3"]{fill:var(--dz-accent-3)!important}[data-dz-fill="on-accent"]{fill:var(--dz-on-accent)!important;color:var(--dz-on-accent)!important}[data-dz-stroke="line"]{stroke:var(--dz-line)!important}[data-dz-stroke="line-muted"]{stroke:var(--dz-line-muted)!important}[data-dz-stroke="accent-1"]{stroke:var(--dz-accent-1)!important}[data-dz-stroke="accent-2"]{stroke:var(--dz-accent-2)!important}[data-dz-stroke="accent-3"]{stroke:var(--dz-accent-3)!important}`1020+ const trnSelector = ':root[data-dz-profile="trn-semantic-4"]'1021+ const trnVariables = automatic1022+ ? `${trnSelector}{${branchPaletteVariables(TRN_LIGHT_BRANCH_PALETTE)}}@media(prefers-color-scheme:dark){${trnSelector}{${branchPaletteVariables(DARK_PALETTE)}}}`1023+ : appearance.startsWith('light-') ? `${trnSelector}{${branchPaletteVariables(TRN_LIGHT_BRANCH_PALETTE)}}` : ''1024+ return `${variables}${trnVariables}[data-dz-role="canvas"]:not([data-dz-owned="materializer"]){display:none!important}[data-dz-fill="none"]{fill:none!important}[data-dz-fill="surface-1"]{fill:var(--dz-surface-1)!important}[data-dz-fill="surface-2"]{fill:var(--dz-surface-2)!important}[data-dz-fill="surface-3"]{fill:var(--dz-surface-3)!important}[data-dz-fill="ink"]{fill:var(--dz-ink)!important;color:var(--dz-ink)!important}[data-dz-fill="ink-muted"]{fill:var(--dz-ink-muted)!important;color:var(--dz-ink-muted)!important}[data-dz-fill="line"]{fill:var(--dz-line)!important}[data-dz-fill="accent-1"]{fill:var(--dz-accent-1)!important}[data-dz-fill="accent-2"]{fill:var(--dz-accent-2)!important}[data-dz-fill="accent-3"]{fill:var(--dz-accent-3)!important}[data-dz-fill="accent-4"]{fill:var(--dz-accent-4)!important}[data-dz-fill="on-accent"]{fill:var(--dz-on-accent)!important;color:var(--dz-on-accent)!important}[data-dz-stroke="line"]{stroke:var(--dz-line)!important}[data-dz-stroke="line-muted"]{stroke:var(--dz-line-muted)!important}[data-dz-stroke="accent-1"]{stroke:var(--dz-accent-1)!important}[data-dz-stroke="accent-2"]{stroke:var(--dz-accent-2)!important}[data-dz-stroke="accent-3"]{stroke:var(--dz-accent-3)!important}[data-dz-stroke="accent-4"]{stroke:var(--dz-accent-4)!important}`9731025 }9741026 9751027 function canonicalBounds(root) {
M skills/diagramming/SKILL.md +2 -0