# Liquid Best Practices for Modyo MCP

> **Critical Guide**: This document addresses the most common and impactful mistakes when working with Liquid templates in Modyo. Read this before creating widgets or templates.

---

## Table of Contents

1. [Field Name Notation (CRITICAL)](#field-name-notation-critical)
2. [Conditional Rendering](#conditional-rendering)
3. [Empty State Handling](#empty-state-handling)
4. [Content Access Patterns](#content-access-patterns)
5. [Menu Access Patterns](#menu-access-patterns)
6. [Common Pitfalls](#common-pitfalls)

---

## Field Name Notation (CRITICAL)

### ⚠️ The Problem

**This is the #1 most common error** causing widgets to appear empty even when content exists.

### ✅ CORRECT - Use Bracket Notation with Exact Field Names

```liquid
{{ entry.fields['Product Name'] }}
{{ banner.fields['Title'] }}
{{ banner.fields['CTA Text'] }}
{{ banner.fields['CTA Link'] }}
{{ product.fields['Icon'] }}
```

### ❌ WRONG - Do NOT Use Dot Notation or Snake Case

```liquid
{{ entry.fields.product_name }}     <!-- WILL NOT WORK -->
{{ banner.fields.title }}            <!-- WILL NOT WORK -->
{{ banner.fields.cta_text }}         <!-- WILL NOT WORK -->
{{ product.fields.icon }}            <!-- WILL NOT WORK -->
```

### Why This Matters

- **Field names in Modyo Content UI**: Use title case with spaces (e.g., "Product Name")
- **Liquid access pattern**: Must use **exact match** with bracket notation
- **Never convert to snake_case** or use dot notation
- **Case-sensitive**: "Product Name" ≠ "product name" ≠ "product_name"

### Discovery Pattern

When you create a content type with `type-create` or `type-update`, use `type-get` to see the exact field names:

```bash
# Get content type schema
mcp__modyo-mcp__type-get({ platformSlug, spaceId, typeId })

# Response will show:
{
  "fields": [
    {
      "id": 123,
      "name": "Product Name",  ← THIS IS THE EXACT NAME TO USE
      "type": "string"
    }
  ]
}
```

Then in your widget Liquid code:

```liquid
{% assign entries = spaces['public-content'].types['product_card'].entries %}
{% for product in entries %}
  <h3>{{ product.fields['Product Name'] }}</h3>  ← EXACT MATCH
{% endfor %}
```

---

## Conditional Rendering

### Always Check Field Existence Before Rendering

Optional fields should be conditionally rendered to prevent empty elements and improve UX.

### ✅ CORRECT Pattern

```liquid
{% if banner.fields['CTA Text'] and banner.fields['CTA Link'] %}
  <a href="{{ banner.fields['CTA Link'] }}" class="btn btn-primary">
    {{ banner.fields['CTA Text'] }}
  </a>
{% endif %}
```

### ❌ WRONG Pattern

```liquid
<!-- This will render empty button if no CTA data -->
<a href="{{ banner.fields['CTA Link'] }}" class="btn btn-primary">
  {{ banner.fields['CTA Text'] }}
</a>
```

### Complex Conditional Example

```liquid
{% if product.fields['Featured Image'] %}
  <img src="{{ product.fields['Featured Image'].url }}"
       alt="{{ product.fields['Image Alt Text'] | default: product.fields['Product Name'] }}">
{% else %}
  <div class="placeholder-image">
    <i class="bi bi-image" aria-hidden="true"></i>
  </div>
{% endif %}
```

### Checking Multiple Conditions

```liquid
{% if entry.fields['Published'] and entry.fields['Visible'] %}
  {% if entry.fields['Start Date'] %}
    {% assign start_date = entry.fields['Start Date'] | date: '%s' %}
    {% assign now = 'now' | date: '%s' %}
    {% if start_date <= now %}
      <!-- Render entry -->
    {% endif %}
  {% else %}
    <!-- Render entry (no date restriction) -->
  {% endif %}
{% endif %}
```

---

## Empty State Handling

### Always Provide Feedback When No Content Exists

Widgets should gracefully handle empty states to improve UX.

### ✅ CORRECT Pattern

```liquid
{% assign entries = spaces['public-content'].types['news_article'].entries %}

{% if entries.size > 0 %}
  <div class="news-grid">
    {% for article in entries limit: 3 %}
      <article class="news-card">
        <h3>{{ article.fields['Headline'] }}</h3>
        <p>{{ article.fields['Summary'] }}</p>
      </article>
    {% endfor %}
  </div>
{% else %}
  <div class="empty-state text-center py-5">
    <i class="bi bi-newspaper" style="font-size: 3rem; color: #ccc;" aria-hidden="true"></i>
    <p class="mt-3 text-muted">No news articles available at this time.</p>
  </div>
{% endif %}
```

### Empty State Best Practices

1. **Use semantic HTML**: `<div class="empty-state">` not generic `<div>`
2. **Provide helpful icon**: Bootstrap Icons, Font Awesome, or SVG
3. **Clear message**: Tell users why nothing is showing
4. **Suggest action**: "Check back soon" or "Browse our products"
5. **Style appropriately**: Muted colors, centered, adequate spacing

---

## Content Access Patterns

### Accessing Content from Spaces

```liquid
<!-- Access all entries of a type -->
{% assign products = spaces['public-content'].types['product_card'].entries %}

<!-- Access by UUID (stable identifier) -->
{% assign entry = spaces['public-content'].types['product_card'].entries | by_uuid: '1234-5678-abcd' %}

<!-- Access by slug (for published entries) -->
{% assign entry = spaces['public-content'].types['product_card'].entries | by_slug: 'premium-account' %}

<!-- Filter entries -->
{% assign featured = spaces['public-content'].types['product_card'].entries | where: "fields['Featured']", true %}

<!-- Sort entries -->
{% assign sorted = spaces['public-content'].types['product_card'].entries | sort: "fields['Order']" %}

<!-- Limit results -->
{% assign top_three = spaces['public-content'].types['product_card'].entries | limit: 3 %}
```

### Widget Variables for Dynamic Content

Widgets should use variables to be reusable across different content:

```liquid
<!-- Widget variables (set when adding widget to page) -->
{% assign space_uid = widget.variables.space_uid | default: 'public-content' %}
{% assign type_uid = widget.variables.type_uid | default: 'product_card' %}
{% assign limit = widget.variables.limit | default: 6 %}

<!-- Access content dynamically -->
{% assign entries = spaces[space_uid].types[type_uid].entries | limit: limit %}
```

### Filtering with Variables

```liquid
{% assign category_filter = widget.variables.category_filter %}
{% assign entries = spaces[space_uid].types[type_uid].entries %}

{% if category_filter and category_filter != 'all' %}
  {% assign entries = entries | where: "fields['Category']", category_filter %}
{% endif %}

{% assign show_featured = widget.variables.show_featured_only | default: false %}
{% if show_featured %}
  {% assign entries = entries | where: "fields['Featured']", true %}
{% endif %}
```

---

## Menu Access Patterns

### Accessing Navigation Menus

Menus are accessed by their **slug** (case-sensitive):

```liquid
<!-- Access menu by slug -->
{% assign main_menu = menus['main'] %}
{% assign footer_menu = menus['footer-te-puede-interesar'] %}

<!-- WRONG - menus don't have IDs in Liquid -->
{% assign menu = menus[123] %}  <!-- WILL NOT WORK -->
```

### Always Check Menu Existence

```liquid
{% if menus['footer-informate'] %}
  <nav aria-label="{{ menus['footer-informate'].name }}">
    <h6>{{ menus['footer-informate'].name }}</h6>
    <ul>
      {% for item in menus['footer-informate'].items | visible_items %}
        <li>
          <a href="{{ item.url }}">{{ item.label }}</a>
        </li>
      {% endfor %}
    </ul>
  </nav>
{% endif %}
```

### Filter Menu Items

```liquid
<!-- Only visible items -->
{% assign items = menus['main'].items | visible_items %}

<!-- Top-level items only (no children) -->
{% assign top_items = menus['main'].items | visible_items | where: "parent_id", nil %}

<!-- Items with children (for dropdowns) -->
{% for item in menus['main'].items | visible_items %}
  {% if item.children.size > 0 %}
    <li class="dropdown">
      <a href="{{ item.url }}">{{ item.label }}</a>
      <ul class="dropdown-menu">
        {% for child in item.children | visible_items %}
          <li><a href="{{ child.url }}">{{ child.label }}</a></li>
        {% endfor %}
      </ul>
    </li>
  {% else %}
    <li><a href="{{ item.url }}">{{ item.label }}</a></li>
  {% endif %}
{% endfor %}
```

### Menu Item Properties

```liquid
{% for item in menus['main'].items | visible_items %}
  <a href="{{ item.url }}"
     {% if item.target %}target="{{ item.target }}"{% endif %}
     {% if item.target == '_blank' %}rel="noopener noreferrer"{% endif %}>
    {{ item.label }}
  </a>
{% endfor %}
```

---

## Common Pitfalls

### 1. ❌ Using `.name` Instead of `['Name']` on Content Fields

```liquid
<!-- WRONG -->
{{ entry.fields.name }}

<!-- CORRECT -->
{{ entry.fields['Name'] }}
```

**Exception**: Menu properties DO use dot notation:
```liquid
{{ menus['main'].name }}  <!-- Correct: menu property -->
{{ item.label }}           <!-- Correct: menu item property -->
```

### 2. ❌ Not Filtering Visible Menu Items

```liquid
<!-- WRONG - shows hidden items -->
{% for item in menus['main'].items %}

<!-- CORRECT - only visible items -->
{% for item in menus['main'].items | visible_items %}
```

### 3. ❌ Hardcoding Menu Names in Snippets

```liquid
<!-- LESS FLEXIBLE -->
<h6>Te puede interesar</h6>

<!-- MORE FLEXIBLE - uses actual menu name -->
<h6>{{ menus['footer-te-puede-interesar'].name }}</h6>
```

**Note**: If menu name rendering fails (returns blank), hardcode as fallback. This was discovered in Iteration 10.

### 4. ❌ Not Handling Asset URLs Properly

```liquid
<!-- WRONG - asset field is an object, not a string -->
<img src="{{ product.fields['Image'] }}">

<!-- CORRECT - access the url property -->
<img src="{{ product.fields['Image'].url }}"
     alt="{{ product.fields['Image Alt Text'] }}">
```

### 5. ❌ Forgetting CSP Nonce in Inline Styles/Scripts

```liquid
<!-- WRONG - will be blocked by CSP -->
<style>
  .custom { color: blue; }
</style>

<!-- CORRECT - includes CSP nonce -->
<style nonce="{{csp_nonce}}">
  .custom { color: blue; }
</style>
```

### 6. ❌ Using Inline Event Handlers

```liquid
<!-- WRONG - CSP blocks inline event handlers -->
<button onclick="doSomething()">Click</button>

<!-- CORRECT - use event listeners in script -->
<button id="myButton">Click</button>
<script nonce="{{csp_nonce}}">
  document.getElementById('myButton').addEventListener('click', function() {
    // doSomething
  });
</script>
```

### 7. ❌ Not Using Site-Relative URLs

```liquid
<!-- WRONG - hardcodes domain -->
<a href="https://mysite.modyo.cloud/products">Products</a>

<!-- CORRECT - site-relative -->
<a href="/products">Products</a>
```

### 8. ❌ Not Escaping User-Generated Content

```liquid
<!-- POTENTIALLY UNSAFE -->
<div>{{ entry.fields['User Comment'] }}</div>

<!-- SAFER - escape HTML -->
<div>{{ entry.fields['User Comment'] | escape }}</div>

<!-- OR use strip_html for plain text -->
<div>{{ entry.fields['User Comment'] | strip_html }}</div>
```

---

## Quick Reference Checklist

Before publishing a widget or template, verify:

- [ ] ✅ Using bracket notation for ALL content fields: `fields['Name']`
- [ ] ✅ Field names match exactly (case-sensitive, including spaces)
- [ ] ✅ Optional fields have conditional rendering (`{% if %}`)
- [ ] ✅ Empty states provided for collections (`{% if entries.size > 0 %}`)
- [ ] ✅ Menu items filtered by visibility (`| visible_items`)
- [ ] ✅ Asset URLs accessed via `.url` property
- [ ] ✅ CSP nonce included in inline `<style>` and `<script>` tags
- [ ] ✅ No inline event handlers (`onclick`, etc.)
- [ ] ✅ Site-relative URLs used (`/path` not `https://domain.com/path`)
- [ ] ✅ Widget variables used for reusability
- [ ] ✅ Accessibility attributes included (ARIA labels, alt text)

---

## Related Documentation

- [CSS Organization Guide](./CSS_ORGANIZATION.md) - CSS templates vs snippets
- [Template Workflows Guide](./TEMPLATE_WORKFLOWS.md) - Safe template editing patterns
- [Widget Workflows Guide](./WIDGET_WORKFLOWS.md) - Widget ID stability and publishing
- [Modyo Liquid Documentation](https://docs.modyo.com/en/platform/channels/liquid-markup.html)

---

## Testing Your Liquid Code

### Local Testing Pattern

1. **Create content entries first** - Liquid won't show anything if content doesn't exist
2. **Publish content** - Draft entries aren't accessible to widgets
3. **Test with varied data** - Some entries with optional fields, some without
4. **Test empty states** - Remove all entries to see empty state
5. **Test filtering** - Verify category/featured filters work correctly

### Common Debug Steps

```liquid
<!-- Debug: Show all available spaces -->
{% for space in spaces %}
  <p>Space: {{ space.uid }}</p>
{% endfor %}

<!-- Debug: Show all types in a space -->
{% for type in spaces['public-content'].types %}
  <p>Type: {{ type.uid }}</p>
{% endfor %}

<!-- Debug: Show entry count -->
<p>Entries: {{ spaces['public-content'].types['product_card'].entries.size }}</p>

<!-- Debug: Show first entry's fields -->
{% assign first = spaces['public-content'].types['product_card'].entries | first %}
{% if first %}
  <pre>{{ first.fields | json }}</pre>
{% endif %}
```

---

**Last Updated**: October 23, 2025
**Source**: Iteration 9 findings from mcp-improvements.md
