Skip to content

Parse section as a standalone tag - #1309

Draft
stephanie-shopify wants to merge 4 commits into
mainfrom
standalone-section-tag
Draft

stephanie-shopify wants to merge 4 commits into
mainfrom
standalone-section-tag

Conversation

@stephanie-shopify

@stephanie-shopify stephanie-shopify commented Sep 29, 2026 •

Copy link
Copy Markdown

WHY are these changes introduced?

h2 env pull wraps values that need quoting in double quotes and backslash-escapes \, " and tabs (added in #3050). dotenv (used by h2 dev, h2 env push and h2 deploy via cli-kit's readAndParseDotEnv) has no general escape syntax. In double-quoted values it only expands \n and \r. So escaped characters end up in the parsed value:

Storefront value Written by env pull today Read back by dotenv
a\b "a\\b" a\\b
say "hi" "say \"hi\"" say \"hi\"
a<TAB>b "a\tb" a\tb (literal backslash-t)

Separately, $ and backticks aren't escaped inside the double quotes. Shell-sourcing isn't a supported use of the generated file, but if someone runs set -a; . .env, $(...) and backtick values execute.

WHAT is this pull request doing?

Changes quoteEnvValue in env pull:

  • Values that don't need quoting are unchanged (KEY=abc123).
  • Otherwise, values are single-quoted when they contain no ' or line breaks. dotenv reads single-quoted values literally, and so do POSIX shells, so $, backticks, \ and " all survive and nothing expands if the file is sourced.
  • Values with a ' or line breaks fall back to double quotes with only \n / \r escaped, because that's all dotenv unescapes.

Tests assert round-trips through readAndParseDotEnv instead of the exact file format. That covers a fresh file and patching an existing .env that has comments, neighbouring keys and multiline values (plus a re-pull being a no-op). There's also a POSIX-only test that shell-sources the output and checks nothing executes.

Known limitations (none of these are new):

  • A value containing ' or a line break and both " and # still can't be written in a way dotenv parses back. As far as I can tell dotenv has no way to write it.
  • Values that need the double-quote fallback and also contain $ or backticks are still not safe to shell-source. That's unsupported anyway.
  • Vite's loadEnv (only used by mini-oxygen as a fallback when no env bindings are passed) runs dotenv-expand, so $VAR in values is still expanded on that path regardless of quoting. That's out of scope here.

HOW to test your changes?

cd packages/cli
pnpm test src/commands/hydrogen/env/

On the old implementation, the round-trip, patch-existing-file and shell-sourcing tests fail.

I also fuzzed this locally with about 20k random values made of quotes, $, backticks, backslashes, #, whitespace, control chars and line breaks, using cli-kit 3.80.4 / dotenv 16.4.7:

  • dotenv round-trip failures went from ~49% to ~3%. The remainder are all the "' or newline plus " and #" case above.
  • No value that round-tripped before fails now.
  • Re-pulling onto an existing file is stable and doesn't touch neighbouring keys.

Manual (not done yet):

  1. On a linked storefront, set a variable to something like pa$s"\word and another to it's.
  2. Run h2 env pull and confirm they're written as 'pa$s"\word' and "it's".
  3. Run h2 dev and confirm the worker sees the exact values.

Post-merge steps

@shopify/cli pins @shopify/cli-hydrogen, so after the next cli-hydrogen release the pin needs bumping in Shopify/cli for most users to get this.

Checklist

  • I've read the Contributing Guidelines
  • I've considered possible cross-platform impacts (Mac, Linux, Windows) (the shell-sourcing test is skipped on Windows)
  • I've added a changeset if this PR contains user-facing or functional changes. Test changes or internal-only config changes do not require a changeset.
  • I've added tests to cover my changes
  • I've added or updated the documentation

* +section+ is a standalone tag, so +{% endsection %}+ parses as an unknown
* tag. Ruby Liquid reports it the same way.
*/
export function checkEndsectionTag(node: LiquidTag, context: Context): void {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure if this is correct behavior to add

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This moves existing behavior rather than adding new behavior. On main, LiquidSyntaxError already reports Unknown tag 'endsection', from inside checkSectionTag via the section node's blockEndPosition. That only exists because of the hybrid block form. With section standalone, endsection is its own LiquidTag node, so the report moves to a checker keyed on endsection. The message and range are the same, and it matches Ruby Liquid (Shopify core asserts Unknown tag 'endsection' for the legacy block form, and the parity corpus expects it).

That said, it's only here because the parser change moves endsection. If we keep the hybrid tag and only fix the scan (see the thread on ast.test.ts), this goes away and checkSectionTag stays as it is on main.

// Re-export all tag definition types so existing imports from './environment' keep working.
export {
TagKind,
isStructuralEndTag,

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this added?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Plumbing, not API. liquid-tags.ts already imports TagKind from ../environment, so I put the helper there too. But the re-export comment says it's there "so existing imports keep working", and the tag files import from ../tag-definitions directly, so a new symbol arguably doesn't belong here. environment isn't exported from the package index.ts, so nothing external sees it. If we keep this approach I'll import from ../tag-definitions and drop the re-export.

}
});

it('should throw on orphaned endsection', () => {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why was this here? why does removing hybrid tag change it?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On main, any end tag for a registered tag with no opener is a parse error ({% endrender %}, {% endif %}, …). section is registered, so a lone {% endsection %} threw too. With the hybrid form, a paired endsection was legitimate, and this test pinned that difference: paired parses, orphan throws.

Once section isn't a block, every endsection is effectively an orphan. Left alone, the legacy {% section %}…{% endsection %} form would go from "Unknown tag, other checks still run" to "whole file fails to parse" (theme-check skips every other check on files that fail to parse). So I exempted endsection from that throw, which is what flips this test. Other tags' end-tag errors are unchanged, and there's a regression test for that.

This, the checker move and the new export all follow from removing the block form. They also cause a few remaining differences on already-invalid Liquid, which the tophat caught: different messages for a lone endsection, one in {% liquid %} and one inside HTML, and Prettier output changes for those. <p>{% section 'x' %}y{% endsection %}</p> gets mangled.

Alternative with zero behavior change: keep section hybrid and replace the per-tag forward scan with one pass that pairs each section with its endsection (like matching parentheses), per document and per {% liquid %} block. That's still linear in the worst case, and theme-check, Prettier and all existing tests stay as on main. Removing the block form could then be a separate follow-up where the behavior change is the explicit point. I'm leaning that way. I'll check with CP, since removing hybrid was their suggestion.

Ruby Liquid has no block form for section, but the parser treated it as a
hybrid tag and scanned forward for a matching endsection on every section
tag (both in documents and in {% liquid %} bodies). Files with many section
tags parsed in quadratic time.

Make section a plain TagKind.Tag and remove the hybrid tag kind. Theme
Check still reports a stray endsection as "Unknown tag 'endsection'" by
mapping the parser error.
The previous commit made a stray endsection throw "Attempting to close
LiquidTag 'section'". In a full Theme Check run that turns the file's AST into
an error, so the only offense was LiquidHTMLSyntaxError and every other check
skipped the file.

Only tags with a body (Block/Raw) have end tags, so only those now throw the
structural close errors. endsection parses as an unknown tag like any other
end* name, and LiquidSyntaxError reports it as "Unknown tag 'endsection'".
laxRecoverTagMarkup only recovers Tag/Block kinds, so section (previously
Hybrid) was never recovered. Now that section is a Tag it would be, and
{% section 'x' junk %} would recover and render, while Ruby's section tag
raises in every error mode. Exclude it explicitly to keep the previous
behavior.
The previous change stopped throwing structural close errors for every
registered tag without a body, so stray {% endrender %}, {% endecho %},
{% endsections %}, etc. silently parsed as unknown tags instead of failing
as they do on main. Only endsection needs the exception. Restore the
existing behavior for everything else and add a regression test.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant