DanielXMoore / Civet

A TypeScript superset that favors more types and less typing
https://civet.dev
MIT License
1.55k stars 33 forks source link

JSX children as code #561

Closed texastoland closed 2 months ago

texastoland commented 1 year ago

I ran into a confusing DX issue experimenting with JSX in the playground.

Working example:

const Component = (items: Item[]) =>
  <ul>
    <For each=items()>
      (item) =>
        <li>{
          if item.type === "link"
            <a href=item.url>some link title: {item.title}
          else
            <p>{item.content}
        }

Despite being a React instructor (but also CoffeeScript in the past) I intuitively expected Civet to work without braces.

Broken example:

const Component = (items: Item[]) =>
  <ul>
    for item of items
      <li>
        if item.type === "link"
          <a href=item.url>some link title: {item.title}
        else
          <p>{item.content}

I think code would be a better default for all JSX nodes not only https://civet.dev/cheatsheet#function-children as in:

Proposed example:

const Component = (items: Item[]) =>
  <ul>
    for item of items
      <li>
        if item.type === "link"
          <a href=item.url> "some link title: " item.title
        else
          <p> item.content

Alternatively signal code children with a marker like do:

const Component = (items: Item[]) =>
  <ul> do
    for item of items
      <li> do
        if item.type === "link"
          <a href=item.url>some link title: {item.title}
        else
          <p>{item.content}
edemaine commented 1 year ago

Yes, we've discussed something similar in the #jsx channel on Discord. Thanks for putting this proposal together though!

One exception with your proposed example: I don't think "some link title: " item.title should work. (This looks like a potential function call...?) More intuitive to me would be "some link title: " + item.title or `some link title: ${item.title}`.

Multiple Children Code Blocks

But I think the bigger issue is how to have multiple code children. Digging up old posts, here's an example I put together with a proposal for treating each top-level line as its own braced block:

<div>
  user := getUser()
  if user?
    {name} = user
    <h1> `Welcome ${name}!`
  <h2>Posts</h2>
  for post of posts
    <div .post> post.jsx()
↓↓↓
let user
<div>
  {user = getUser(), void 0}
  {user != null ?
    {name} = user,
    <h1> {`Welcome ${user.name}!`} </h1>
  : void 0
  }
  <h2>Posts</h2>
  {posts.map(post =>
    <div class="post"> {post.jsx()}
  }
</div>

Basically the rule is "wrap each non-JSX child of JSX with braces". If you wanted multiple lines to correspond to a single braced child, you could wrap in do. The tricky work here is hoisting the let user out of the user := getUser() code, which should maybe be the second thing to do. But even that should be doable; we do similar things with if user := getUser() today.

Same-Line Children

Another issue is whether we want same-line children to behave differently from different-line children. For example, you could imagine <h1>Hello produces a string when Hello is on the same line as <h1>. Or it could be a flag...

Automatic Fragments with Strings

Another proposal I see is to automatically wrap strings and tags in a fragment, like so:

<span>
  if message.deleted
    " "
    <span .label.label-danger>Deleted
↓↓↓
<span>
  {message.deleted ?
    <>
      {" "}
      <span .label.label-danger>Deleted</span>
    </>
  : void 0}
</span>

Or maybe " " and <span .label.label-danger>Deleted need to be on the same line? But then it's harder to add a trailing space.

Let us know what you think about these further ideas/extensions!

texastoland commented 1 year ago

My original XY problem is I want to map (or for 🤯)/if/switch without pairing (or remembering) braces (curlies or round). Any solution that accomplishes that would be a substantial DX improvement over JSX 🍾

texastoland commented 1 year ago

I reread your feedback 🤓

I don't think "some link title: " item.title should work.

Agreed 👍🏼

Basically the rule is "wrap each non-JSX child of JSX with braces".

Your example makes sense!

The tricky work here is hoisting ...

Example (I didn't follow)?

Let us know what you think about these further ideas/extensions!

Treating raw strings like Hello as identifiers and quoted strings as … well strings solves ambiguities/spacing. It's how ReScript currently works based on multiple prior JSX proposals (including 1 by Seb).

<div>
  user := getUser()

  if user?
    {name} = user
    <h1> `Welcome ${name}!`

  <h2> "Posts"

  for post of posts
    <div .post> post.jsx()

For context the h2 in ReScript would almost be:

<h2> "Posts" </h2>

Except it must be wrapped to satisfy types which requires braces again:

<h2> {"Posts"->React.string} </h2>
edemaine commented 1 year ago

I have a new related proposal, which is to use > to enter an indented code block — essentially a new way to go from XML mode to Civet mode, like { but that doesn't need a closing }. This is similar to the proposal "Alternatively signal code children with a marker like do" in the root post, but with > in place of do. The motivations for > are:

Examples:

Component := (items: Item[]) =>
  <ul>>  // reminds me of Pug's trailing dot
    for item of items
      <li>>
        if item.type === "link"
          <a href=item.url>some link title: >item.title
        else
          <p>>item.content
<div>
  > if props.warning
    <div .warning>
      >props.warning
  > if loggedIn()
    <Inbox>
    <Logout>
  > else  // special case to allow an else at same indentation level
    <Login>
↓↓↓
<div>
  {props.warning ?
    <div class="warning">
      {props.warning}
    </div> : void 0
  }
  {loggedIn() ?
    <>
      <Inbox/>
      <Logout/>
    </>
  : <Login/>
 } 
</div>
<label>
  Comment
  >if count > 1
    's'
  :
↓↓↓
<label>
  Comment
  {count > 1 ? 's' : void 0}
  :
</label>

We could allow > for property values too. This looks a little weird, because > also means "end the tag", but it's not ambiguous after an =:

<Show when=>
  data = getData()
  data.has data.stuff
fallback=>
  <div>Nothing to see here.
>
  Welcome!
↓↓↓
<Show when={
  data = getData(),
  data.has(data.stuff)
} fallback={
  <div>Nothing to see here.</div>
}>
  Welcome!
</Show>

I think I would personally prefer explicit blocks like this, as opposed to implicitly treating all children as code, because it makes it easy to be explicit about when you want multiple code blocks (as above) vs. when you want one code block that requires multiple lines:

>do
  data := getData() |> formatData
  data.sort()
  for item of data
    <li>>item.title

The other advantage is that it's fully backward compatible, so we can do it without a flag like "civet jsxCode". That said, I think there's room for both approaches; I'm just more clear on what the > feature looks like.

In "civet jsxCode" mode, there's a question of whether the children block should be treated as one code block or as multiple. Above I proposed that each top-level statement become its own block, which is usually what I want, but I admit this isn't particularly intuitive. (You could always wrap in do to combine statements.) Alternatively, Daniel seems to be leaning toward a single code block, which could return an array if you want to return multiple results. (This works for React but not fine-grained systems like Solid.) If we go this way, I think a nice add-on would be for top-level bulleted arrays (#803) to automatically be treated like multiple code blocks:

<div>
  . if props.warning
    <div .warning>
      . 'Warning: '
      . props.warning
  . if loggedIn()
      <Inbox>
      <Logout>
    else
      <Login>
↓↓↓
<div>
  {props.warning ?
    <div class="warning">
      {'Warning: '}
      {props.warning}
    </div> : void 0
  }
  {loggedIn() ?
    <>
      <Inbox/>
      <Logout/>
    </>
  : <Login/>
 } 
</div>
texastoland commented 11 months ago

My 2¢ with a grain of salt because I'm not actively using Civet 🙏🏼

I like the concept of entering curly mode via some marker. But >> and >= look confusing to me. I think as programmers we've formed an intuition they're always balanced like quotes or brackets. I'm sure there's a reason do wouldn't work here but couldn't dig it up in the thread?

Alternatively (brainstorming inspired by Pug/Vue hybrid) := might be rare enough to not conflict with user code:

<div>:=
  if props.warning <div .warning>:= props.warning
  if loggedIn()
    <Inbox>
    <Logout>
  else <Login>
<Show
  when:=
    data = getData()
    data.has data.stuff
  fallback:= <div> Nothing to see here.
> Welcome!
edemaine commented 11 months ago

when:= looks quite nice for blocks of code assigned to attributes. On the other hand, I find := a little weird for children within tags. I'm also not sure it would work well for multiple code-block children; I think your first example would have to be:

<div>
  := if props.warning then <div .warning>:= props.warning
  := if loggedIn()
    <Inbox>
    <Logout>
  else <Login>

(otherwise the code block would be treated as a single code block with one return value, whereas you need multiple here)

Note that := has a specific meaning in Civet, which is const definition. I'm not sure it's the right connotation here, but maybe we can come up with some different notation that everyone would enjoy...


Unrelated, I had another idea for "civet jsxCode" mode (where children are code by default), which is to use --- separators between code blocks. Thus:

<div>
  if props.warning
    <div .warning>
      'Warning: '
      ---
      props.warning
  ---
  if loggedIn()
    <Inbox>
    <Logout>
  else
    <Login>

Currently --- has no meaning in code, and it's somewhat natural given YAML/Markdown separators. (I used one earlier on in this message!) Conversely, --- does take a lot of space. It also might be easy to forget one (but > and other notations have the same issue).

edemaine commented 11 months ago

By the way, back to an unambiguous backward-compatible notation, an option other than > would be &, which in JSX can only be used in special ways (character codes, in particular with a semicolon within the same word). Does this look any better?

<Show when=&
  data = getData()
  data.has data.stuff
fallback=&
  <div>Nothing to see here.
>
  Welcome!
<div>
  & if props.warning
    <div .warning>
      &props.warning
  & if loggedIn()
    <Inbox>
    <Logout>
  else
    <Login>

I like that it's a different character from <> of tags. The obvious connection is to Civet's & shorthand function blocks. On the one hand, these are code blocks, so it seems like a reasonable similarity. On the other hand, this use of & behaves very differently; & currently refers to the function parameter, which it isn't doing here.

Some other ideas for other symbols:

I'm wondering whether we could have two options for breaking backward compatibility:

texastoland commented 11 months ago

I think do is most compelling to me. --- takes second though. A lot of your examples add a character to the beginning of each line. My intuition would be that a character after a tag would apply to the entire nested scope. Re. backwards compatibility you aren't 1.0 yet right? Not sure if you could provide a code mod for yourself and other users. Anyway keep up the interesting work!

mixty1 commented 11 months ago

I haven’t fully delved into the problems of this issue, but I think that it is still necessary to somehow make the following syntax possible:

<div>
  if items
    <h1> "List of items:"
    <ul> for item in items
      <li> <span> item
  else
    <span> "No items found"

or as the author of the issue suggested:

const Component = (items: Item[]) =>
  <ul>
     for item of items
       <li>
         if item.type === "link"
           <a href=item.url>some link title: {item.title}
         else
           <p>{item.content}

Here's how in Imba language. Of course, it’s not jsx, but this syntax is much nicer than prefixes of different characters (. , & = := ; -) in each line of an expression.

Is it very difficult to implement this?

<div>
  user := getUser()
  if user?
    {name} = user
    <h1> `Welcome ${name}!`
  <h2>Posts</h2>
  for post of posts
    <div .post> post.jsx()
↓↓↓
let user
<div>
  {user = getUser(), void 0}
  {user != null ?
    {name} = user,
    <h1> {`Welcome ${user.name}!`} </h1>
  : void 0
  }
  <h2>Posts</h2>
  {posts.map(post =>
    <div class="post"> {post.jsx()}
  }
</div>
edemaine commented 11 months ago

If I understand correctly, this is the same as my original proposal. Yes, I think this should be an option, via a directive of "civet jsxCode" or "civet jsxCodeMulti" or something (to distinguish between wanting one code block child vs. multiple code block children).

mixty1 commented 11 months ago

Yeah, even if it is an additional option like jsxCode, it will be cool. My main desire is to avoid of any characters(. , & := ; etc.) at the beginning of each line. like this:

<div>
  & if props.warning
    <div .warning>
      &props.warning
  & if loggedIn()
    <Inbox>
    <Logout>
  else
    <Login>

or this:

<div>
  if props.warning
    <div .warning>
      'Warning: '
      ---
      props.warning
  ---
  if loggedIn()
    <Inbox>
    <Logout>
  else
    <Login>
<div>
  . if props.warning
    <div .warning>
      . 'Warning: '
      . props.warning
  . if loggedIn()
      <Inbox>
      <Logout>
    else
      <Login>

Anyway, thanks for your work. I really like civet 🙂

texastoland commented 11 months ago

Here's how in Imba language

FWIW Imba is another Coffee fork I quite enjoy for toy projects that probably influenced my expectations how Civet would behave (as well as ReScript mentioned in the description).

cie commented 9 months ago

Hi all! An idea..

const Component = (items: Item[]) =>
  <ul>
    {for item of items}
      <li>
        {if item.type === "link"}
          <a href=item.url>some link title: {item.title}
        {else}
          <p>{item.content}

A new syntactic construct is introduced: JSX {} with indented children. It treats the children as if it was a <></> fragment indented into the contents of {}. But if an indented {} contains a continuation of a statement (else, case/when, patterns in switch, while after do), it is treated as part of that statement.

Other examples:

<ul>
  {if !items.length}
    No items.
  {else items.map (item) =>}
    <li>{item.text}

<ResizeObserver>
  {({width, height}) =>}
    {width}x{height}

<span>
  {&.toLowerCase()}
    {name}

<div>
  {switch value}
    {{type: "user"}}
      {value.name}
    {else}
      Invalid type