Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extended @block Reference Syntax #192

Closed
13 tasks done
amiller-gh opened this issue Sep 5, 2018 · 16 comments
Closed
13 tasks done

Extended @block Reference Syntax #192

amiller-gh opened this issue Sep 5, 2018 · 16 comments
Assignees

Comments

@amiller-gh
Copy link
Contributor

amiller-gh commented Sep 5, 2018

Summary

Implementation of this proposal will enable the following features:

  • Rename @block-reference to @block
  • Additions to the @block import syntax
    • Default @block imports from a Block module
    • Named @block imports from a Block module
    • Default Block exports from a module
    • Named Block exports from a module
  • default as a reserved name
  • css-blocks package.json config field
    • Custom Block module "main" path
  • CSS Blocks module resolution algorithm
    • Relative Block import path resolution
    • Absolute Block import path resolution
    • node_module Block import path resolution

Detailed design

Rename @block-reference to @block

After some time out in the wild, we've received feedback that typing @block-reference is onerous and looks weird.

This proposal would update @block-reference:

@block-reference foobar from "path/to/file.block.css";

To @block:

@block foobar from "path/to/file.block.css";

This has the added benefit of providing the option of backwards compatibility / deprecation for this feature change, although we are pre-1.0 and this is not required.

@block Imports

To enable a more robust module delivery system in css-blocks, this proposal would define the API options for @block as follows.

Token Definition
<block-import> ::= " @block " <blocks-list> " from " <block-path>
<blocks-list> ::= <default-block> | <named-blocks> | <default-block> " , " <named-blocks>
<default-block> ::= <ident>
<named-blocks> ::= " ( " <named-ref> { " , " <named-ref> } " ) "
<named-ref> ::= <local-name> | <aliased-block>
<aliased-block> ::= <ident> " as " <local-name>
<local-name> ::= <ident> (Note: Lexer restriction – cannot be "default")
<block-path> ::= ' " ' <any-value> ' " ' | " ' " <any-value> " ' "

Examples of this grammars use are

Default @block imports from a Block module

@block local-name from "path/to/block.css";

In the above example, the default exported block from the block file discovered at <block-path> will be available under the local name local-name.

Named @block imports from a Block module

To import a named export of a Block file:

/* Direct named import */
@block ( other-block ) from "path/to/block.css";

/* Aliased named import */
@block ( other-block as local-name ) from "path/to/block.css";

/* Alternate default block import – the following two lines are equivalent! */
@block ( default as local-name ) from "path/to/block.css";
@block local-name from "path/to/block.css";

/* Multiple named imports */
@block ( 
  other-block-1 as local-name, 
  other-block-2, 
  other-block-3 as local-name-2 
) from "path/to/block.css";

/* Multiple named imports with default import */
@block other-block, ( 
  other-block-1 as local-name, 
  other-block-2, 
  other-block-3 as local-name-2 
) from "path/to/block.css";

@block Exports

Default Block exports from a module

All rules defined in the scope of a Block file are the default export of a block. So, the following file exposes a single block called default which contains the BlockClasses :scope and .foo when imported elsewhere:

:scope { color: red; }
.foo { color: blue; }

Even if there are no declarations in a Block file, there will always be a default block exported.

Named Block exports from a module

Any imported Blocks that need to be made available from the Block file as named exports must be explicitly re-exported. A similar syntax to @block imports is used.

/* Named Export */
@export block-name;

/* Multiple Named Export */
@export ( block-name-1, block-name-2 );

/* Aliased Export */
@export ( block-name as aliased-name );

/* Block Redirects */
@export block-name, (other-block as aliased-name) from "path/to/a.block.css";

default as a reserved word

To avoid naming conflicts and import/export ambiguity, user will be unable to import or export blocks under the local name default. If this were allowed, it would become ambiguous how the imported default block would interact with the local Block definition, also called default. The following would throw a build time error of "Error: 'default' is a reserved word.":

/* Error: Can not import "block-1" as reserved word "default". */
@block ( block-1 as default ) from "block-1.block.css";

/* Error: Default Block from "block-1.block.css" must be aliased to a unique local identifier. */
@block ( default ) from "block-1.block.css";

/* Error: Can not export "block-1" as reserved word "default". */
@export ( block-1 as default ) from "block-1.block.css";

Execution order and name conflicts

@block imports are statically defined (I mean, what else is CSS good for) and parsed in order of appearance before the default Block's contents are parsed. Because of this, you can imagine that all @block references are hoisted at runtime and processed first, regardless of location in the file.

Local identifier values will be assigned Block references in order of execution. Duplicate identifiers will have their values overwritten.

@block ( block-1 as foo ) from "block-1.block.css";
:scope {
  extends: foo; /* Value of `foo` is `block-2b` */
}
@block ( block-2a as foo, block-2b as foo ) from "block-2.block.css";

css-blocks package.json config field

Modules that deliver Block files may choose to add an optional css-blocks: { ... } configuration field to their package.jsons. Here they may define configuration options for their module. At the time or writing, there is only one valid property for the options hash:

Key Default Definition
main ModuleRoot Custom Block module "main" path. Usage defined in the module resolution algorithm below.

CSS Blocks module resolution algorithm

Heavy inspired (aka: close to outright copied) from the Node.js Resolution Algorithm

import X from module at path Y

  1. If X is a core module,
    a. return the core module
    b. STOP
  2. If X begins with '/'
    a. set Y to be the module root (closest package.json)
  3. If X begins with './' or '/' or '../'
    a. LOAD_AS_FILE(Y + X)
    b. LOAD_AS_DIRECTORY(Y + X)
  4. LOAD_NODE_MODULES(X, dirname(Y))
  5. THROW "not found"

LOAD_AS_FILE(X)

  1. If X is a *.block.css file, load X as a Block. STOP
  2. If X.block.css is a file, load X.block.css as a Block. STOP
  3. If there is a preprocessor registered for the file's extension, preprocess the file and load the result as a Block. STOP

LOAD_INDEX(X)

  1. If X/index.block.css is a file, load X/index.block.css as JavaScript text. STOP
  2. If there is a preprocessor registered for the index file's extension, preprocess the file and load the result as a Block. STOP

LOAD_AS_DIRECTORY(X)

  1. If X/package.json is a file,
    a. Parse X/package.json, and look for "css-blocks.main" field.
    b. let M = X + (json main field)
    c. LOAD_AS_FILE(M)
    d. LOAD_INDEX(M)
  2. LOAD_INDEX(X)

LOAD_NODE_MODULES(X, START)

  1. let DIRS = NODE_MODULES_PATHS(START)
  2. for each DIR in DIRS:
    a. LOAD_AS_FILE(DIR/X)
    b. LOAD_AS_DIRECTORY(DIR/X)

NODE_MODULES_PATHS(START)

  1. let PARTS = path split(START)
  2. let I = count of PARTS - 1
  3. let DIRS = [GLOBAL_FOLDERS]
  4. while I >= 0,
    a. if PARTS[I] = "node_modules" CONTINUE
    b. DIR = path join(PARTS[0 .. I] + "node_modules")
    c. DIRS = DIRS + DIR
    d. let I = I - 1
  5. return DIRS

Outstanding Questions

  • Do we allow for truly private Blocks in a module? This proposal does not allow for that.
@amiller-gh amiller-gh changed the title CSS Blocks Extended @block Reference Syntax Extended @block Reference Syntax Sep 5, 2018
@wondersloth
Copy link

wondersloth commented Sep 5, 2018

Dumb question. Can you have multiple exports of a block file?

It will by default export :scope and any classes like .foo and any locally imported defined blocks?

/* block-1.block.css */
@block local-block-1 from "block-foo.css";
:scope { color: red; }
/* block-2.block.css */
// Only important from block-1.block.css would you have access to local-block-1
@block block-1 from "block-1.block.css";
:scope { color: white; }
/* block-3.block.css */
@block block-2, ( block-1 as base-block ) from "block-2.block.css";
:scope { color: blue; }
/* block-4.block.css */
@block ( block-2, base-block, default as other-block) from "block-3.block.css";

In the case above only the block-2.block.css file has access to local-block-1 correct? It doesn't percolate down.

@amiller-gh
Copy link
Contributor Author

amiller-gh commented Sep 5, 2018

You would have to explicitly import local-block-1 from block-1.block.css into block-2-block.css to have access to it in block-2's scope, so in your above example:

/* block-2.block.css */
@block block-1, ( local-block-1 ) from "block-1.block.css";
/* `local-block-1` is undefined */

Instead, if you import local-block-1 from block-1.block.css:

/* block-2.block.css */
@block block-1, ( local-block-1 ) from "block-1.block.css";
/* 
  `local-block-1` is defined and set to the default export from `block-foo.css`, 
   which was re-exported by `block-1.block.css` as `local-block-1`
*/

@chriseppstein
Copy link
Contributor

Local identifier values will be assigned Block references in order of execution. Duplicate identifiers will have their values overwritten.

Why wouldn't this be an error?

@amiller-gh
Copy link
Contributor Author

amiller-gh commented Sep 5, 2018

We can totally make it one, was opting for flexibility – I can see a scenario where devs want to test importing a block from somewhere else under the same name and instead of removing the old block reference, they just add another block ref under the original for testing in dev. In this case it can also be a warning.

I'm fine making it an error if dev time flexibility is not a strong enough argument.

@chriseppstein
Copy link
Contributor

chriseppstein commented Sep 5, 2018

@amiller-gh This issue doesn't give a motivation for the need to re-export blocks that are referenced. If the default block extends or implements other blocks, it becomes the superset interface for those blocks and the consumer of that block does not need access to the blocks that it extends or implements (and, in-fact, it would be incorrect to resolve against the base-block directly).

@amiller-gh
Copy link
Contributor Author

amiller-gh commented Sep 5, 2018

It makes creating component modules that need expose multiple blocks easy, especially for composable components. Consider our hovercard component, which needs to deliver both container and trigger blocks:

/* styles/container.block.css */
:scope { ... }
/* styles/trigger.block.css */
:scope { ... }
/* styles/index.block.css */
@block trigger from "./trigger";
@block container from "./container";

Consumers that need to then import the blocks can do so like this:

@block ( container, trigger ) from "@linkedin/hovercard";

@amiller-gh
Copy link
Contributor Author

Alternatively, if there is an obvious "primary" block for a module, the index.block.css file can contain that and expose the secondary blocks as named imports:

/* styles/trigger.block.css */
:scope { 
  block-name: "hovercard-trigger";
  ... 
}
/* styles/index.block.css */
@block trigger from "./trigger";
:scope { 
  block-name: "hovercard";
  ... 
}

Consumers that need to then import the blocks can do so like this:

@block hovercard, ( trigger ) from "@linkedin/hovercard";

@chriseppstein
Copy link
Contributor

@amiller-gh ok. That's what I thought it might be. I'm not sure that a block is the right abstraction for this. Maybe you've already thought this part through but I'd like to explore:

  1. The notion of an explicit package of blocks as a separate concept. Did you consider this? What were the pros and cons of that from your perspective?
  2. Requiring an explicit syntax to re-export a block. I'm not a big fan of adding something to a public API of a block just because it's needed to be referenced. For instance, if they reference a block to resolve a conflict, that local name is now part of the public api of the block and later removing the reference because the conflict doesn't need to be resolved anymore would be a breaking change to the public api. This further limits our ability to lint for unused identifiers locally or make such things a hard error. Did you consider an explicit export syntax? If so, why did you decide against it?

@chriseppstein
Copy link
Contributor

I'm fine making it an error if dev time flexibility is not a strong enough argument.

I like errors. They are clear and they keep me from doing dumb things and being confused about them. If I lose 1-2 min to scratching my head in confusion, it eats up a lot of the savings of not having to do an easy thing that only saves 5-10 seconds.

@amiller-gh
Copy link
Contributor Author

amiller-gh commented Sep 7, 2018

For instance, if they reference a block to resolve a conflict, that local name is now part of the public api of the block and later removing the reference because the conflict doesn't need to be resolved anymore would be a breaking change to the public api.

@chriseppstein the power of RFCs at work. You're right. Let me play counterpoint for a moment though.

I could make the argument (and I will, watch me) that removing resolutions between any internal blocks is in fact a breaking change.

Consider:

/* node_modules/@module/foo/styles/a.block.css */
:scope {
  color: red;
}
/* node_modules/@module/foo/styles/b.block.css */
@block a from "./a.block.css";
:scope {
  color: blue;
  color: resolve(a);
}
/* app/styles/main.block.css */
@block ( a, b ) from "@module/foo";
{{!-- app/templates/main.hbs --}}
<div class="a b">What color am I?</div>

In the above code snippet, the app is relying on the resolution of blocks a and b happening internally to the module. If the module were to remove the internal resolution, then the parent app's build would start to fail. This would happen, for example, if templates inside the module no longer use the two blocks on the same element so, in isolation, it appears "safe" to remove the resolution.

This seems like an easy kind of change for module maintainers to gloss over and not realize they're breaking their consumers. By giving @block references the power to change external facing APIs we're codifying that the relationship graph between block files is in fact part of the API itself.

@chriseppstein
Copy link
Contributor

@amiller-gh I wasn't arguing that removing a resolve() was never breaking change. I was arguing that there exist use cases where removing a resolve() is provably safe and when that happens it might be the last reference to a block, and when that happens, it is natural to remove the reference, which would be a breaking change that is even more subtle than removing a resolve().

An explicit @block-export <local-block-name>; makes it clear when the public API is being manipulated.

@chriseppstein
Copy link
Contributor

we're codifying that the relationship graph between block files is in fact part of the API itself.

It would codify that, but I don't think it should. There's no need for a consumer of a block to ever hold a direct reference to a block's parent block nor to the block interfaces that it implements, the block itself is the correct point of access to the local names of that block.

@amiller-gh
Copy link
Contributor Author

I don't disagree, was largely just trying to give the concept its time in court 😉

Will think through and add to the writeup what an explicit export syntax looks like. You're right, it minimizes API surface area.

@amiller-gh
Copy link
Contributor Author

Slack conversation transcript for transparency 😁

ammiller [3:04 PM]
The only problem is: I really don't like @block-export especially if we simplify imports to @block.
I know, horrible problem to have

ceppstein [3:05 PM]
everything is scoped with @block
it's just that @block has nothing that comes after it 😛

ammiller [3:06 PM]
😝
extends and implements aren't scoped, but block-name is.

ceppstein [3:07 PM]
I see you're making a case for adding prefixes
and I agree
thanks

ammiller [3:07 PM]
BAHHH NO

ammiller [3:09 PM]
Especially since a big selling point of CSS Blocks is "you're just writing CSS!"
You're suuuuure we can't get away with @export?...

ceppstein [3:10 PM]
except where you're not.
and where you're not, I want people to know they're looking at something weird

ammiller [3:10 PM]
And seeing @block and @export isn't weird enough?
Anyone who knows a bit of CSS knows they're non-standard, and they're legible enough anyone new to the platform doesn't have a jarring "wtf is that" moment.
It feels more ergonomic (I know, squishy feeling arguments)

ceppstein [3:25 PM]
I agree that it's nbd for css pros. I disagree that platform noobs won't have a "wtf is that" moment. I agree it's more ergonomic.
looking "weird" is a feature in this case.
I don't think there will be many cases for using @block-export, tbh. Mostly just for complex blocks and npm module indices.

ammiller [3:33 PM]
Is there a technical reason why we should avoid @export? Ex: Strong fear of stomping on a global reserved-ish word?
(Side note, CSS Modules uses :export { ... })
I also played with the idea of an eyeglass-exports.js style module export for specifying exported Blocks from a package, but I really like the feeling of a JS style, filesystem based, declarative approach. Its familiar and I think will reduce a lot of friction for first time module writers. Became a pretty strong -1 on the dedicated module config file in the process.

ceppstein [4:07 PM]
No specific concern re: the @export at-rule.
CSS Modules is exporting random names with random values with that. We're exporting blocks specifically, so @block-export is more descriptive 😉
Yeah, for npm modules, I could imagine a css-blocks property in package.json where blocks could be enumerated or globbed. I wouldn't object to a js-based approach depending on what we intend to do, but I don't see a reason for it right now.
I think we should also consider whether the syntax for a block name could be scoped instead of (or in addition to) destructuring at the reference point.
Actually, most of this thread should be on the github issue. can you write it up there and I'll reply

ammiller [4:18 PM]
👍 What if I just copy and paste this convo...

ceppstein [4:19 PM]
:man-shrugging:

@amiller-gh
Copy link
Contributor Author

amiller-gh commented Sep 11, 2018

I think we should also consider whether the syntax for a block name could be scoped instead of (or in addition to) destructuring at the reference point.

Not sure how we could scope the block name without referencing individual blocks from a fully qualified npm path – and at that point you're a couple small syntactic sugar features away from the destructured import syntax. What were you thinking here @chriseppstein?

amiller-gh added a commit that referenced this issue Nov 7, 2018
 - Rename @block-reference to @block
 - Default @block imports from a Block module.
 - Named @block imports from a Block module.
 - Default Block exports from a module.
 - Named Block exports from a module.
 - default as a reserved name.
 - Introduced simple parser for import/export parsing.
 - Split apart parser tests into logical packages.
amiller-gh added a commit that referenced this issue Nov 7, 2018
 - Rename @block-reference to @block
 - Default @block imports from a Block module.
 - Named @block imports from a Block module.
 - Default Block exports from a module.
 - Named Block exports from a module.
 - default as a reserved name.
 - Introduced simple parser for import/export parsing.
 - Split apart parser tests into logical packages.
@amiller-gh
Copy link
Contributor Author

Closed #196 and #112

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

No branches or pull requests

3 participants