Elderjs / elderjs

Elder.js is an opinionated static site generator and web framework for Svelte built with SEO in mind.
https://elderguide.com/tech/elderjs/
MIT License
2.11k stars 52 forks source link

Support hydratable actions #237

Open eight04 opened 2 years ago

eight04 commented 2 years ago

Since a hydratable component can only be a leaf node, it is hard to add js code to parent without hydrating the entire sub-tree.

Workaround

Create an empty client component:

<!-- Home.svelte -->
<script>
import RandomBGColor from "../../components/RandomBGColor.svelte";
</script>
<div class="container">
  <RandomBGColor hydrate-client={{}}/>
  ... lots of content ...
</div>
<!-- RandomBDColor.svelte -->
<script>
import {onMount} from "svelte";
let root;
onMount(() => {
  root.parentNode.parentNode.style.backgroundColor = getRandomColor();
});
</script>
<span bind:this={root}></span>

Proposal

Support hydratable actions:

<!-- Home.svelte -->
<script>
import randomBGColor from "../../components/random-bg.js";
</script>
<div class="container" hydrate-use:randomBGColor={{...someAdditionalProps}}>
  ... lots of content ...
</div>
// random-bg.js
export default function randomBGColor(node, props) {
  node.style.backgroundColor = getRandomColor();
}

I think it should similar to but simpler than hydratable components?

nickreese commented 2 years ago

The way we've handled more trivial cases is to use <svelte:head> with JS inside it. Not ideal and not as elegant as what you are proposing but also a lot less complexity in the preprocessing stage.

At this moment I can't commit time to developing this functionality out but am open to PRs for it if they include test and a documentation PR as well.

My main concern stems from how these are perceived to interact with Svelte use:actions which I am not 100% up to speed on.

eight04 commented 2 years ago

how these are perceived to interact with Svelte use:actions

They don't. In the proposal, the action function lives in a standalone JS file, which makes it impossible to use svelte reactive syntax. Therefore it is just a simple function receiving an element.


After digging into Elderjs source code, I found that Elderjs doesn't strictly depend on svelte. Currently it works like this:

  1. Compile components into two types: ssr and dom.
  2. Prepare the page, render HTML via component.render, and collect component css, head, etc.
  3. Process shortcodes. In this phase, it may create more component wrappers on the page, which acts like the mount target for client components.
  4. Finalize the script loader, generate a list of components, props, loading strategy, etc, which can be used in the browser.

Only the first step is directly bound to svelte. The 2nd~4th steps can actually be applied to any component framework, even a native web component, and a simple function that getting a mount target.

Here is the idea, abstract everything into a single html attribute: ejs-mount=[componentName, props, options]. The preprocessor will preprocess the template into this diretive:

<!-- svelte component -->
<Foo hydrate-client={props} hydrate-options={options} />
<!-- after preprocess -->
<div class="foo-component" ejs-mount={JSON.stringify(["Foo", props, options])}></div>
<!-- svelte action -->
<div class="container" hydrate-use:Foo> ... </div>
<!-- after preprocess -->
<div class="container" ejs-mount={JSON.stringify(["Foo", , {prerender: false}])}> ... </div>
<!-- don't prerender by default when the tag doesn't close immediately or the component doesn't support ssr -->

The postprocessor should know nothing about the framework. It will collect ejs-mount in the html, recursively render innerHTML, and push the data to the script loader:

<!-- rendered html -->
<div class="foo-component" ejs-mount="[&quote;...]"></div>
<!-- after postprocess -->
<div class="foo-component" ejs-id="fooXYZ"> ... prerendered content ...</div>
<!-- rendered html --> 
<div class="container" ejs-mount="[&quot;...]"> ... </div>
<!-- after postprocess -->
<div class="container" ejs-id="fooXYZ"> ... </div>

You can even ssr a vue component in svelte template via ejs-mount.


Each framework should provide a factory function to decorate rollup/esbuild config to add their own plugin/preprocessor.

Each framework should compile their components into a common interface:

// client
export default (target, props) => void;

// ssr
export const render = (page, props) => {head, html, css};

As the result, the action component can be implemented as an empty framework. It just have to rollup src/components/*.js into client component folder.

eight04 commented 2 years ago

Each framework should compile their components into a common interface:

Instead of using default export, it should probably use a private name so it won't change the signature of the component module?

// client
export const __ejs_mount = (component, target, props) => void;

// ssr
export const __ejs_render = (component, page, props) => {head, html, css};
nickreese commented 2 years ago

how these are perceived to interact with Svelte use:actions

They don't. In the proposal, the action function lives in a standalone JS file, which makes it impossible to use svelte reactive syntax. Therefore it is just a simple function receiving an element.

Yep, mainly concerned with the hydrate-use naming.


@eight04 You nailed it. The early versions of Elder.js were designed to be front end framework agnostic. I had long term visions of implementing vue, react, solid, and building the pattern you are proposing ultimately with the idea of creating a bundle hook which would allow plugins to manage the bundling of various frameworks. I still love this long term vision and I think astro has proven there is a demand for it.

Relevant history:

Very early versions of Elder.js used plain string literals within a custom html template tag that supported jsx style syntax.

Then to mount a svelte component, you imported the svelte template tag resulting in something like this:

// ... imports, etc

modules.export = ({helpers, props}) => html`
<body>
${svelte({props, for, the, component})}
</body>
`

The helpers function included things like helpers.head('string here') which would allow you to push an arbitrary string to the headStack and there was one for each stack that existed in the early days... the most important of which were css, header, footer.

If we do implement the signatures you show above, I'd encourage us to also accept footer scripts. This was a trade off I was bummed I couldn't have when going all-in on svelte.

Pros/Cons

Pros: Writing sites with html templates was amazing in vscode. Out of the box I found several plugins that allowed writing css and html with full syntax highlighting. The site generation was blazing fast... and the build processes were much better (no compiling server svelte templates).

Cons: The biggest con was that early testers REALLY wanted to write in svelte or vue for their templates and strangely the JSX like syntax in HTML really confused people.

Why this context:

If I had to do it all over again, I would have probably stuck with just plain JS functions or html template tags for the server side templating (both layouts and routes) and not married Elder.js so closely to svelte.

I share this because I am 100% open to this proposal and will fully support whoever is spearheading it.

The biggest issues in my mind are:

  1. Let's not create our own meta syntax like astro has. I think plain JS or html template tags with functions provided by community plugins is the way to go. The pluggable nature of Elder.js allows us to also make plugin helpers available to these JS templates so the html template tag could just be a plugin.
  2. As far as frameworks, I think we should think through all of the desired hydration options and support them out of the box but let plugins deal with the framework specific bundling process via a bundle hook.
  3. If we implement a bundle hook, we should decide if we want esbuild, vite, rollup, or something else made available on that hook. I'm really not a fan of writing/maintaining blunder code due to the constant evolution. The big thing to keep in mind here is that it should be possible to do HMR on the server and client if we use something like vite we may be able to get it for low effort and unlock the existing template ecosystems.
  4. If a bundle hook is implemented, it should only be run on the master process during build. We should also focus on making sure the DX is excellent as this would be the time to do it. This may require a mild refactor of how we handle the ssr functionality.

Instead of using default export, it should probably use a private name so it won't change the signature of the component module?

Chances are you probably have better understandings of the implications of this. 👍

As you've probably seen from the code, I don't come from an engineering background. I just know what I wanted to build and have learned while writing Elder.js.

100% open to improvements and ideas. My biggest bottleneck at the moment is dev time.

eight04 commented 2 years ago

Yep, mainly concerned with the hydrate-use naming.

If those "actions" are implemented as empty components (vanilla component?), it might make more sense to re-use the hydrate-client syntax. For example:

<!-- Home.svelte -->
<script>
// use a special suffix to determine components from regular scripts?
import randomBGColor from "../../components/randomBGColor.vanilla.js";
</script>
<div class="container" hydrate-client:randomBGColor>
  ... lots of content ...
</div>
nickreese commented 2 years ago

@eight04 I'm good with this syntax.


Few additional notes that hit me while reviewing #240 that should be considered should we want to allow other front end frameworks. This should probably be moved into a new ticket if we want to pursue it, but in the effort of keeping ideas together, here are some thoughts:

eight04 commented 2 years ago
  • On the topic of CSS, will each framework give Elder.js the full css from all of the components they bundle? If so how should we manage that?

We can ask components to include their CSS via static import. Then make a transform function that:

For example:

// compiled Foo.svelte (ssr)
... compiled component code ...
import "path/to/foo.svelte.css";
import "other/css/file.css";
// after transform
... compiled component code ...
import _css0 from "path/to/foo.svelte.css";
import _css1 from  "other/css/file.css";
export const _css = [_css0, _css1];

Add another transform function transforming CSS code to a default export:

function transform(code, id) {
  if (id.endsWith('.css')) {
    // and maybe minify css here
    return {code: `export default ${JSON.stringify(code)}};
  }
}
eight04 commented 2 years ago
  • The backstory on this is that svelte when in SSR mode only renders the truthy CSS so the backflips we do to track CSS dependencies in the rollupPlugin are needed to get all of the CSS. I don't know how this works in other frameworks, but as long as we can get full the css from them and drop it into an external file we're good.

Does it mean that Elderjs collects CSS from all components? If a component is only used by a single page, will its CSS be injected to the whole site?

That is, if we don't want to support settings.css === 'inline', there is no need to extract CSS from rendered components in the proposal?

nickreese commented 2 years ago

We can ask components to include their CSS via static import. Then make a transform function that:

This is roughly what the Svelte Rollup Plugin does and could work depending on our full implementation.

Does it mean that Elderjs collects CSS from all components? If a component is only used by a single page, will its CSS be injected to the whole site?

Yes, exactly. The style.css that is generated by Elder.js includes all of the css from all of the components. (minified and deduplicated)

That is, if we don't want to support settings.css === 'inline', there is no need to extract CSS from rendered components in the https://github.com/Elderjs/elderjs/issues/237#issuecomment-1059814925?

We still need a component to give us the CSS so we can compile it into a style.css file regardless if we use the inline option or not.

eight04 commented 2 years ago

Here is a POC based on dev-html-parser: https://github.com/eight04/elderjs/compare/dev-html-parser...eight04:dev-bundle I have only modified rollup plugin so esbuild will not work.

When working on the POC, I found that some changes in #240 (e.g. renaming inlineSvelteComponent to inlineComponent) overlap with it. Do you want me to revert them and send multiple smaller PRs? or to merge all changes into a large PR?

Roadmap:

  1. Switch to <ejswrapper>/ejs-mount=""/ejs-id="".
  2. Add html parser, fixes #227, #226, #233
  3. Pull out svelte framework from Elderjs core, rename API e.g. inlineSvelteComponent. Still WIP, haven't looked into esbuild.
  4. (Optional) Simplify the process of generating bundled CSS. Still WIP, haven't looked into esbuild.
  5. Implement vanilla js component, fixes #237.
eight04 commented 2 years ago

In the POC, we use a rollup-plugin-elder plugin to transform the result of rollup-plugin-svelte, which is not supported by esbuild: https://github.com/evanw/esbuild/issues/1902

In esbuild, you can only generate contents in the load hook, so we either have to:

  1. Create a single plugin to composite svelte and elder (and other frameworks) plugins into a single load hook. We have to mock esbuild plugin API so this is probably the hardest. The advantage is that we don't have to maintain framework compilers and users can reuse options for those plugins.
  2. Tell frameworks that they should generate code with a specific interface (adapter). Elderjs can provide some helpers.
  3. Modify bundled dist, add adapter after the build and find a way to bind CSS to the importer component.
  4. Let framework plugins provide a compiler function with a common interface e.g.
    ({
      code: string,
      type: 'ssr' | 'client',
      filename: string, 
      compilerOptions: {/* framework specific options */}
    }) => {js: {code: string, map: SourceMap}, css: {code: string, map: SourceMap}}

    Therefore Elderjs can build the load hook around the compiler.

In either way, source map will be broken unless we use something like sorcery.

nickreese commented 2 years ago

@eight04 getting a chance to test this now. Carved out a couple hours.

When working on the POC, I found that some changes in https://github.com/Elderjs/elderjs/pull/240 (e.g. renaming inlineSvelteComponent to inlineComponent) overlap with it. Do you want me to revert them and send multiple smaller PRs? or to merge all changes into a large PR?

Large PR is fine with me.

nickreese commented 2 years ago

@eight04

When trying to run it on the template project. I’m getting a ‘frameworks’ is not an interable error but after some investigation (adding export { default as svelteFramework } from './svelte’; to index.ts and below to the rollup.config.js I got it working.

const { getRollupConfig, svelteFramework } = require('@elderjs/elderjs');
const svelteConfig = require('./svelte.config');
module.exports = [...getRollupConfig({ frameworks: [svelteFramework()] })];

Overall, amazing PoC. I can see how this can be extended to support other frameworks pretty easily. Pretty cool how you used the rollup-plugin-svelte and their emitting of CSS to our advantage… I’m wondering if that is a standard pattern. Unfortunately my rollup expertise is just good enough to get things done and not any deeper than that.

Super exciting.

Notes on the PoC:


Regarding esbuild, I’m sure you saw the esbuild implementation we have. I do think that we should go with whatever leads to less maintenance and using the existing framework plugins as you did with rollup-plugin-svelte. That said, the ideal solution may not be esbuild. I went that route initially because the bundler wars weren’t complete but today vite seems to have broad adoption and supports the same api as rollup.

https://vitejs.dev/guide/api-plugin.html

This could be a win. I also know that they support HMR for both the client and the server, so that could be a win for us. I haven’t looked at the implementation.

I'll do some more reading tonight.

eight04 commented 2 years ago
  • Where I the frameworks ‘default’ being set for defaultsDeep to work?

The PoC doesn't set a default framework. That's probably why you got a frameworks is not iterable error.

In the rollup plugin in transform the getFramework(importee) could return multiple results

getFramework only returns a single Framework.

are you thinking we’d allow svelte components to import components from other frameworks?

Yes I think it is possible. It is also the core feature to support "actions". For example:

<!-- Layout.svelte -->
<script>
import Foo from "../components/Foo.component.js";
</script>
<div class="container" hydrate-client:Foo>
  ... contents ...
</div>

You can also statically render other components from a different framework:

<!-- Layout.svelte -->
<script>
import Foo from "../components/Foo.vue";
</script>
<div class="container">
  <Foo hydrate-client={{a: "b"}} hydrate-options={{loading: "none"}}/>
</div>

Technically, there is no need to import the component via import since hydrated-components are resolved in mountComponentsInHtml. However, we still need the import statement to make rollup bundles CSS correctly.

  • I think we still need to handle the css sort order right? I didn’t see that implemented.

They are sorted by the import order like JS. I did see that we have some logic to sort CSS files, but personally I don't need this feature.

  • __ejs_mount isn’t used yet, right? I don’t see it getting invoked.

Yeah I forgot to modify the script loader i.e. hydrateComponent.ts. It should initiate the component by calling comp.__ejs_mount instead of new comp.default.


For esbuild, I'm trying the 3rd approach i.e.

3. Modify bundled dist, add adapter after the build and find a way to bind CSS to the importer component.

Since esbuild places bundled CSS along with the JS file with the same filename, we can easily component CSS by changing the extension e.g. ___ELDER___/compiled/components/Foo.js -> ___ELDER___/compiled/components/Foo.css. It is also possible to get all component CSS by generating an entry script e.g. ejs_css_collector.js which imports all components. Then we will get ejs_css_collector.css including css from all of them.

nickreese commented 2 years ago

100% right on both accounts. First time seeing some in action. 👀


CSS sorting: We want node_modules > layouts > routes > components order to be preserved as this is the order they should be in the style.css to prevent css specificity issues.

Investigating how SSR is done in vite... it is likely it won't work with Elder.js' rendering model. Will do some more digging, but it does seem your plan with 3. above is a good one.

eight04 commented 2 years ago

Here is the PoC for esbuild: https://github.com/eight04/elderjs/compare/dev-bundle...eight04:dev-esbuild

Esbuild sorts CSS by the import order too. It seems that it records input filenames inside bundled CSS when minify: false. Therefore it is possible to split CSS into chunks, sort by priority, and merge again.

Notes:

  1. frameworks array is added to elder.config.js. We should probably also use this setting in rollup.
  2. Watch mode is probably broken.

The logic for watch mode is probably broken in the PoC. In my mind, we need:

  1. Let bundler enter the watch mode, which will compile components when the source changes.
  2. Trigger a re-bundle when component entries change, including:
    1. A component is added/removed.
    2. elder.config.js changes i.e. get new frameworks, plugins, path to components changes, etc. (What if elder.config.js depends on other files?)
  3. Let server always do a full single page build on request (F5)? It seems that currently we have to restart the server on specific events.
  4. Notify the browser to refresh the page when
    1. Bundle finished.
    2. A source file is added e.g. a new markdown.
    3. A source file is changed e.g. a markdown is modified. (Is it possible to know whether the page depends on modified files?)
eight04 commented 2 years ago

nvestigating how SSR is done in vite... it is likely it won't work with Elder.js' rendering model. Will do some more digging, but it does seem your plan with 3. above is a good one.

Since HMR happens at client side, I guess we have to move the entire render process into client to support HMR. Or create some kinds of modules to communicate with the server and replace statically rendered HTML.

nickreese commented 2 years ago

Diving into the PoC now and have 4 hours chunked out to play with this and think about how to add other frameworks.

  1. Let server always do a full single page build on request (F5)? It seems that currently we have to restart the server on specific events.

I think we could also possibly detect the changes on the server and clear the require cache to prevent a full server restart which reruns the bootstrap hook. This may require some magic in the component resolution process (findSvelteComponent) if the hash changes. (probably just disabling the cache)

nickreese commented 2 years ago

Needed to bump svelte to ^3.46.4 to get things to work. Seems to work well.

Exciting.


As you can probably tell bundling is not my strong suit and it looks like you’ve got a solid grasp of what is needed so I spent my time:

  1. Ensuring that indeed vite won’t fit our needs. I like the idea because we could manage a single bundler API instead of two separate ones and get HMR for “free”.
  2. Exploring what it would take to implement other frameworks. Honestly I’ve only ever done partial hydration with svelte so this is new territory for me.

Vite:

I can’t seem to get a vite working with SSR. Our model really doesn’t match there. The amount of plumbing we’d need to do would be wild based on my current understanding. We would basically need to build in memory the entry file for each request, this alone isn’t too difficult, the hard part is with how Elder.js handles hydration and mounting the components. It just doesn’t seem to mesh with vite's model of how one builds a website.

Even if we figured all of this out, it appears we’d need to write our own HMR code.

This was a deep rabbit hole but I feel confident that we’re not losing anything, not going with vite.

Other Frameworks:

  1. Preprocessing things isn’t universal in the compilers for Vue and React the way it is for Svelte. That said, we’ll need to likely implement our own preprocess step if it isn’t offered by the compiler when we’re in ssr mode. I believe the majority of the preprocess.ts can be used across Vue/React/Solid/ because we’re just basically passing data to the server and replacing it with the html output… then on the server hydrating to that spot.

  2. When it comes to writing our own adapters there is a lot to be learned from Astro as they’ve gone down this journey just recently: https://github.com/withastro/astro/tree/main/packages/integrations

Questions:

  1. Do we think frameworks is the right naming? Or do we use integrations as there may be other common things such as TailwindCSS, Swup, PartyTown, etc? I’m game for either.
  2. As I’ve explored the problem space the only real functionality I see missing with our bundling pipeline is offering postcss handling. I know this is natively handled by the svelte preprocess package… but as we branch to other frameworks I’m not seeing that support so we may want to consider adding a postcss step. By default maybe that is where we do the css compression? You know bundling better than I do.

Things of note:

  1. Vue’s rollup plugin is no longer maintained and they’re suggesting a move to vite. There is however an esbuild plugin. https://github.com/apeschar/esbuild-vue
  2. Current Elder.js tailwinds integration: https://github.com/27leaves/elderjs-tailwind-starter/blob/main/svelte.config.js

Super well done man. Amazed at your progress on this. I've got notes on the esbuild code, but honestly I think you've got it all under control based on your #fixme comments.

nickreese commented 2 years ago

Thinking more on this, if we moved to plain html templating as discussed above, maybe preprocessing isn’t required as it is only really a syntactic sugar. Basically this would leave us with a simple function to inline components (that matches the preprocessing).

eight04 commented 2 years ago

2. When it comes to writing our own adapters there is a lot to be learned from Astro as they’ve gone down this journey just recently: https://github.com/withastro/astro/tree/main/packages/integrations

After checking their code, I found that there are two problems in our adapter:

  1. We don't support children. Probably because of https://github.com/sveltejs/svelte/issues/6360. IDK if it can be supported in other frameworks.
  2. We should separate client and ssr adapter i.e. put __ejs_render and __ejs_mount in different files. SSR adapter may import node-specific packages while client adapter cannot.
  1. Do we think frameworks is the right naming? Or do we use integrations as there may be other common things such as TailwindCSS, Swup, PartyTown, etc? I’m game for either.

integrations sounds good (or adapters?). I also wonder how should we distribute them? Move them to separated packages? e.g.

// elder.config.js
const svelte = require("@elderjs/integration-svelte");
const vue = require("elderjs-integration-vue"); // 3rd-party plugins
module.exports = {
  ...
  integrations: [svelte(), vue()]
}

2. As I’ve explored the problem space the only real functionality I see missing with our bundling pipeline is offering postcss handling. I know this is natively handled by the svelte preprocess package… but as we branch to other frameworks I’m not seeing that support so we may want to consider adding a postcss step. By default maybe that is where we do the css compression? You know bundling better than I do.

There are three ways to apply postcss transform:

  1. As a preprocessor for each framework:

    • Svelte itself supports a preprocess hook and we can easily setup the preprocessor with svelte-preprocess.
    • For vue, the compiler itself is built on postcss and we can customize it via options.style.postcssPlugins, options.style.postcssPlugins, etc, in rollup-plugin-vue.
    • For vanilla components, we may use a plugin like rollup-plugin-postcss. I wonder if it works well with other frameworks since it may transform the same CSS file twice (svelte preprocess and rollup-plugin-postcss). Otherwise we have to write our own logic to transform CSS imported by vanilla components.

    With this method, we have to pass postcss options separately into each framework (or use postcss.config.js? IDK if their preprocessor will pick the configuration automatically.)

  2. Apply postcss transform in rollup-plugin-elder. Obviously this only works in rollup since we can't modify CSS files generated by other plugins in esbuild.

  3. Apply postcss transform after bundling:

Thinking more on this, if we moved to plain html templating as discussed above, maybe preprocessing isn’t required as it is only really a syntactic sugar. Basically this would leave us with a simple function to inline components (that matches the preprocessing).

Something like this?

<!-- Layout.svelte -->
<script>
import {hydrateComponent} from "@elderjs/elderjs/helper";
</script>
<div class="container">
  {@html hydrateComponent({name: "Foo", props: {a: "b"}})}
</div>
<div class="container2" {...hydrateComponent({name: "Bar", type: "attrObject"})}>
  ... lots of contents ...
</div>

One thing comes into my mind: in vue, we can only inline raw HTML inside a wrapper element via v-html: https://vuejs.org/api/built-in-directives.html#v-html

  1. There will be two wrappers for a single component, <ejswrapper> and the element for v-html.
  2. This will only work for HTML-based template.

I think we can provide a helper function (it can produce raw HTML <ejswrapper> or shortcode {{component/}}) which might fit better in react (jsx) and vanilla components, while still let each framework decide what is the best way to inline a hydrate component and whether they want to use a preprocessor.

nickreese commented 2 years ago

integrations sounds good (or adapters?). I also wonder how should we distribute them? Move them to separated packages? e.g.

I like adapters, good call.

I also wonder how should we distribute them? Move them to separated packages? e.g.

This is a good idea.

There are three ways to apply postcss transform...

You've definitely got this under control. Whichever you think works best given the problem space I'm good with.

After checking their code, I found that there are two problems in our adapter:

  1. We don't support children. Probably because of [Manually mount slots sveltejs/svelte#6360](https://github.com/sveltejs/svelte/issues/6360). IDK if it can be supported in other frameworks.
  2. We should separate client and ssr adapter i.e. put __ejs_render and __ejs_mount in different files. SSR adapter may import node-specific packages while client adapter cannot.
  1. In my mind the use case for children or slots in Elder.js goes away in given that we have strong constructs around SSR only components such as Layout.svelte and the individual routes files which are ssr only. Our model of partial hydration allows isolated 'apps' where interactivity is needed. Allowing for children adds composability at the cost of a ton of complexity and inter-framework conflicts and mounting complexity. (I.E. someone using a react parent, with a svelte child, which includes a vue child... impossible to hydrate I believe). If someone wants to use slots, got for it, just hydrate a root component first then use slots within that root component.

  2. I think breaking the two adapters apart is a good idea.

I think we can provide a helper function (it can produce raw HTML <ejswrapper> or shortcode {{component/}}) which might fit better in react (jsx) and vanilla components, while still let each framework decide what is the best way to inline a hydrate component and whether they want to use a preprocessor.

Great idea, we have inlineShortcode already... I also think that since we have the component shortcode and it mounts/hydrates already this should be plug and play.


Adapters: How would Plain JS Components Be Bundled?

How would we support plain js like components in the bundling process? Below is an early ssr only menu component modified to roughly match our current spec.

In my mind, it highlights 2 questions:

  1. How do components add to various stacks not represented by our current module spec? footerStack, customJsStack, etc?
  2. What does bundling look like for Plain JS components?
// modified menu.js component
module.exports = (props) => {
  return {
    css: `
       .menu-toggle-wrapper {
           padding-right: 15px;
       }
       #menu-toggle {
           font-size: 12px;
           padding: 0.25rem 0.5rem;
           font-weight: bold;
       }
       #menu-toggle[data-open='true'] {
           background-color: $dark-blue;
           color: $white;
       }
     `,
    js: `
       document.getElementById('menu-toggle').addEventListener('click', function(e){
           if(e.target.dataset.open === 'false'){
               document.getElementById('sub-nav').classList.remove('d-none');
               e.target.dataset.open = true;
           } else {
               document.getElementById('sub-nav').classList.add('d-none');
               e.target.dataset.open = false;
           }
       });
       `,

    html: `
        <div class="menu-toggle-wrapper d-md-none">
            <div id="menu-toggle" class="btn btn-small btn-outline-dark-blue" data-open="false">MENU</div>
        </div>
        <div id="sub-nav" class="row flex-nowrap justify-content-between align-items-center d-none d-md-flex flex-column flex-md-row m-md-0 p-md-0">
            <ul>
                ${
                    (props.links
                    .map((l) => {
                        return `
                    <a href="${l.permalink}" ${l.permalink === page.permalink ? "active" : ""}>${l.label}</a>
                `;
                    })
                    .reduce((out, cv) => out + cv),
                    "")
                }
            </ul>
        </div>`,
  };
};

It seems that we could just modify the ssr adapter to:

export const __ejs_render = (comp, props) => {
  const { head, html, css, js, footer } = (comp.default || comp).render(props);
  // push css, js, footer to the respective stacks?
  return { head, html, css, js, footer };
};

Upgrade Path: Server/Hydrated Components

Beyond bundling, one thing worth considering if we move to plain js for templating is what the upgrade path looks like for existing sites. My team and I have 5+ sites to upgrade so today I was working through how we communicate how to mount server side only templates/layouts.

Here is what I had imagined migrating the default layout to:

// layout.js
export default ({ request, data, helpers, settings, templateHtml }) => {
  // a template is assumed to be HTML if it returns a string?
  return { html:`
    <div class="container ${request.route}">

      <!-- hydrateOptions is quiet verbose... too big of a breaking change to move to 'options'? -->
      ${helpers.component({ name: 'nav', props: { a: 'b' }, hydrateOptions: { loading: 'eager' } })}

      ${templateHtml}

      <!-- assumed to be server because no hydrate options? -->
      ${helpers.component({ name: 'footer', props: { links: [{ href: '/', text: 'home' }] } })}
    </div>
  `};
};

Then for routes:

// ./src/routes/home/Home.js (I feel that this should probably be standardized into ./src/routes/home/template.js)
export default ({ request, data, helpers, settings }) => {
  // assumed to return html if it returns a string?
  return { html: `
      <!-- The existing route structure would access "Home.svelte" by default...
      ...the only problem is that we should probably make the template name available on the "request" object in Elder.ts
      as currently we allow templates to be explicitly defined in the routes... not just determined by the name. 

      This would be a server side only "Svelte Template" in current Elder.js lingo.
      -->
      ${helpers.component({ name: request.route, props: { request, data, helpers, settings } })}
  ` };
};

Having played with the idea of hydrateComponent and serverComponent helpers, I think component is easier to teach in our docs.

This pattern will also demystify why "Svelte Templates" and "Svelte Layouts" in Elder.js get these magical helpers and why these magical helpers can't be hydrated.


Absolutely love the progress. I'll drop you a note on your GitHub email to talk about getting you maintainer access.

eight04 commented 2 years ago

2. When it comes to writing our own adapters there is a lot to be learned from Astro as they’ve gone down this journey just recently: https://github.com/withastro/astro/tree/main/packages/integrations

Another problem, our render function is sync instead of async. Async is required for vue: https://www.npmjs.com/package/@vue/server-renderer


I think a vanilla component will look like this:

// Menu.component.js
// vanilla components will have `.component.js` extension (.ts, .mjs, etc?)

// CSS can be written in CSS file
// also note that currently returning CSS in `render` function only works when elderConfig.css === 'inline'.
import "./Menu.css";

export const render = process.env.componentType === "server" && (props) =>
  `... <ul>${props.link.map(...)}</ul> ...`;

export default function (node) {
  // node is the mount target
  node.getElementById('menu-toggle').addEventListener('click', function(e){
       if(e.target.dataset.open === 'false'){
           node.getElementById('sub-nav').classList.remove('d-none');
           e.target.dataset.open = true;
       } else {
           node.getElementById('sub-nav').classList.add('d-none');
           e.target.dataset.open = false;
       }
   });
}
// Layout.component.js

import {render as footer} from "../components/Footer.component.js";

export const render = ({ request, data, helpers, settings, templateHtml }) => `
    <div class="container ${request.route}">
      ${helpers.component({ name: 'Nav', props: { a: 'b' }, hydrateOptions: { loading: 'eager' } })}

      ${templateHtml}

      <!-- if footer is also a vanilla component, we can use its render() directly, which will be faster -->
      <!-- Footer.component.js won't be hydrated -->
      ${footer({links: [...]})}
    </div>
  `;
// src/routes/home/Home.component.js
export const render = ({ request, data, helpers, settings }) => {
  // this would probably resolve to ___ELDER___/compiled/components/Home.js
  // which can be compiled from src/components/Home.svelte, src/components/Home.vue, etc.
  const comp = require(helper.findComponent(request.route).ssr);
  const result = comp.__ejs_render(comp, {request, data, helpers, settings});
  return {
    head: result.head,
    html: `
      <!-- note that we can't use helpers.component() in this case, because some props can't be JSON.stringify -->
      ${result.html}
  `
  };
};

Note that you can still use Home.svelte as a route entry (or layout, etc). Instead of forcing users to use plain js, we provide vanilla component as an option (with better performance, perhaps.)

  1. How do components add to various stacks not represented by our current module spec? footerStack, customJsStack, etc?

SSR components can access Page in the render function so it is possible to modify stacks. Though I think this should be an internal API and users should probably use shortcode for customJs? I have never used customJS so it's hard to tell how it can be implemented without an example.


I'm willing to be a maintainer. We will have some platform issues e.g. package-lock.json conflict. Either I have to prepare the same environment as yours, or just let you fix them after making the pull request.

nickreese commented 2 years ago

@eight04 anything I can do to help move the ball forward on this?

eight04 commented 2 years ago

I'm quite busy until May 15 so I may not be able to wrap the PoC into a PR.

Stuff that still needs to be done:

  1. Find a way to sort CSS, or remove this feature. It is harder if we want to support esbuild since we can't inspect file content from the load hook anymore.
  2. Support async __ejs_render.
  3. Support putting __ejs_render and __ejs_mount in different files.
  4. Support postcss.
  5. Rename elderConfig.frameworks to elderConfig.adapters, and use it in rollup.
  6. Fix the watch mode. IDK if there is a good way to write tests for esbuild watch mode. We may have to wrap rollup/esbuild into a common interface to make testing easier.
  7. Implement vanilla component adapter.