ProjectEvergreen / greenwood

Greenwood is your workbench for the web, embracing web standards from the ground up to empower your stack from front to back.
https://www.greenwoodjs.io
MIT License
96 stars 9 forks source link

support custom pages through theme packs #681

Open thescientist13 opened 3 years ago

thescientist13 commented 3 years ago

Type of Change

Summary

Coming out of #570 / #669 was that currently Context plugins (or more specifically theme packs) are limited to just providing additional templates/ directories. Although Greenwood can effectively resolve anything during development, it can only build pages that are part of its graph, so being able to ship an actual page as part of a plugin is not possible and is already being tracked as part of #21 . (so this may be more of a reminder to update the docs / examples is all, but may require some work as well, we shall see)

Details

For example, like in greenwood-starter-presentation I would like to provide the actual home / landing / index.html page for users, to act as the entire interface to all their content (slides).

<!DOCTYPE html>
<html>
  <head>
    <script type="module" src="/components/presenter-mode.js"></script>
    <script type="module" src="/components/slide-list.js"></script>
    <script type="module" src="/components/slide-viewer.js"></script>
    <script type="module">
      import client from '@greenwood/plugin-graphql/core/client';
      import GraphQuery from '@greenwood/plugin-graphql/queries/graph';

      client.query({
        query: GraphQuery
      }).then((response) => {
        const urlParams = new URLSearchParams(window.location.search);
        const selectedSlideId = urlParams.get('selectedSlideId');
        const slides = response.data.graph.filter(slide => slide.id !== 'index');
        const currentSlideIndex = selectedSlideId ? slides.findIndex(slide => slide.id === selectedSlideId) : 0;

        document.querySelector('presenter-mode').setAttribute('slides', JSON.stringify(slides));
        document.querySelector('slide-list').setAttribute('slides', JSON.stringify(slides));
        document.querySelector('slide-viewer').setAttribute('slide', JSON.stringify(slides[currentSlideIndex]));
      });
    </script>

    <script>
      document.addEventListener('slide-selected', (slide) => {
        document.querySelector('slide-viewer').setAttribute('slide', JSON.stringify(slide.detail));
      })
    </script>

    <style>
      body {
        background-color: #e8dcd2;
      }

      main {
        min-width: 1024px;
      }

      header {
        width: 90%;
      }

      header > * {
        float: right;
      }

      .column {
        display: flex;
        flex-direction: column;
        flex-basis: 100%;
        flex: 1;
        min-height: 300px;
      }

      .left {
        float: left;
        min-width: 23%;

      }

      .right {
        min-width: 67%;
      }

      footer {
        margin-top: 20px;
      }

      footer a {
        text-decoration: none;
      }

      footer a:visited {
        color: #020202;
      }

      footer h4, header h1 {
        width: 90%;
        margin: 0 auto;
        padding: 0;
        text-align: center;
      }
    </style>
  </head>
  <body>
    <main>
      <section>
        <header>
          <presenter-mode></presenter-mode>
        </header>
      </section>

      <section>
        <div class="column left">
          <h1>Slides</h1>
          <slide-list></slide-list>
        </div>

        <div class="column right">
          <h1>Current Slide</h1>
          <slide-viewer></slide-viewer>
        </div>
      </section>

      <section>
        <footer>
          <h4>
            <a href="https://www.greenwoodjs.io/">GWD/PPT &#9672 Built with GreenwoodJS</a>
          </h4>
        </footer>
      </section>
    </main>
  </body>
</html>

So from a user perspective, they would literally only have this for their directory layout, with everything else coming from the plugin (note: you can pull in overrides on a per page already! 💥 )

Literally a user would only actually have this in their repo

src/
  assets/
    background.png 
  pages/
    slides/
      1.md
      2.md
      .
      .
   styles/
     overrides.css
thescientist13 commented 3 years ago

A couple options I attempted at the moment, as we don't have support for #21 yet.

Using a Resource

One option I tried was using Resource in a couple ways, as the primary advantage here is that the user of the theme pack doesn't have to do anything, the plugin author can handle this entirely from their end.

Resolve

One way could be to just "hijack" the path to / and override by providing a path to index.html

class ThemePackPageOverrideResource extends ResourceInterface {
  constructor(compilation, options) {
    super(compilation, options);
    this.extensions = ['.html'];
  }

  async shouldResolve(url) {
    console.debug('ThemePackPageOverrideResource shouldResolve', url)
    return Promise.resolve(url.replace(this.compilation.context.userWorkspace, '') === '/');
  }

  async resolve(url) {
    return Promise.resolve(path.join(__dirname, 'dist/pages/index.html'));
  }
}

However, it looks like Greenwood heavily filters what counts as a page so maybe we have to add some sort of "simple" check like

path.extname(url) === 'html' && fs.existsSync(url)

After getting it to work

% git diff
diff --git a/packages/cli/src/plugins/resource/plugin-standard-html.js b/packages/cli/src/plugins/resource/plugin-standard-html.js
index ed181169..39b0ae81 100644
--- a/packages/cli/src/plugins/resource/plugin-standard-html.js
+++ b/packages/cli/src/plugins/resource/plugin-standard-html.js
@@ -30,16 +30,21 @@ const getPageTemplate = (barePath, templatesDir, template, contextPlugins = [])
   const customPluginDefaultPageTemplates = getCustomPageTemplates(contextPlugins, 'page');
   const customPluginPageTemplates = getCustomPageTemplates(contextPlugins, template);

+  console.debug('pageIsHtmlPath', pageIsHtmlPath);
+  console.debug('barePath', barePath);
   if (template && customPluginPageTemplates.length > 0 || fs.existsSync(`${templatesDir}/${template}.html`)) {
     // use a custom template, usually from markdown frontmatter
     contents = customPluginPageTemplates.length > 0
       ? fs.readFileSync(`${customPluginPageTemplates[0]}/${template}.html`, 'utf-8')
       : fs.readFileSync(`${templatesDir}/${template}.html`, 'utf-8');
-  } else if (fs.existsSync(`${barePath}.html`) || fs.existsSync(pageIsHtmlPath)) {
+  } else if (fs.existsSync(barePath) || fs.existsSync(`${barePath}.html`) || fs.existsSync(pageIsHtmlPath)) {
     // if the page is already HTML, use that as the template
+    console.debug('// if the page is already HTML, use that as the template');
     const indexPath = fs.existsSync(pageIsHtmlPath)
       ? pageIsHtmlPath
-      : `${barePath}.html`;
+      : fs.existsSync(barePath)
+        ? barePath
+        :`${barePath}.html`;

     contents = fs.readFileSync(indexPath, 'utf-8');
   } else if (customPluginDefaultPageTemplates.length > 0 || fs.existsSync(`${templatesDir}/page.html`)) {
@@ -269,12 +274,16 @@ class StandardHtmlResource extends ResourceInterface {
       ? `${pagesDir}${relativeUrl}index`
       : `${pagesDir}${relativeUrl.replace('.html', '')}`;

-    return Promise.resolve(this.extensions.indexOf(path.extname(relativeUrl)) >= 0 || path.extname(relative
Url) === '') &&
-    (fs.existsSync(`${barePath}.html`) || barePath.substring(barePath.length - 5, barePath.length) === 'ind
ex')
-    || fs.existsSync(`${barePath}.md`) || fs.existsSync(`${barePath.substring(0, barePath.lastIndexOf(`${pa
th.sep}index`))}.md`);
+    return Promise.resolve((fs.existsSync(url) && path.extname(url)) === '.html' ||
+      this.extensions.indexOf(path.extname(relativeUrl)) >= 0 || path.extname(relativeUrl) === '') &&
+      (fs.existsSync(`${barePath}.html`) || barePath.substring(barePath.length - 5, barePath.length) === 'i
ndex')
+      || fs.existsSync(`${barePath}.md`) || fs.existsSync(`${barePath.substring(0, barePath.lastIndexOf(`${
path.sep}index`))}.md`);
   }

   async serve(url) {
+    console.debug('serve HTML url@@@@@@@@@@@@@@@', url);
+    console.debug('text', path.extname(url));
+    console.debug('true', fs.existsSync(url) && path.extname(url) === '.html');
     return new Promise(async (resolve, reject) => {
       try {
         const config = Object.assign({}, this.compilation.config);
@@ -286,14 +295,17 @@ class StandardHtmlResource extends ResourceInterface {
         let body = '';
         let template = null;
         let processedMarkdown = null;
-        const barePath = normalizedUrl.endsWith(path.sep)
-          ? `${pagesDir}${normalizedUrl}index`
-          : `${pagesDir}${normalizedUrl.replace('.html', '')}`;
+        const barePath = fs.existsSync(url) && path.extname(url) === '.html'
+          ? url
+          : normalizedUrl.endsWith(path.sep)
+            ? `${pagesDir}${normalizedUrl}index`
+            : `${pagesDir}${normalizedUrl.replace('.html', '')}`;
         const isMarkdownContent = fs.existsSync(`${barePath}.md`)
           || fs.existsSync(`${barePath.substring(0, barePath.lastIndexOf(`${path.sep}index`))}.md`)
           || fs.existsSync(`${barePath.replace(`${path.sep}index`, '.md')}`);

-        if (isMarkdownContent) {
+        console.debug('barePath??????', barePath);
+        if (!(fs.existsSync(url) && path.extname(url) === '.html') && isMarkdownContent) {
           const markdownPath = fs.existsSync(`${barePath}.md`)
             ? `${barePath}.md`
             : fs.existsSync(`${barePath.substring(0, barePath.lastIndexOf(`${path.sep}index`))}.md`)

seeing this issue now running yarn serve though 😫

done prerendering all pages
copying assets/ directory...
copying graph.json...
Initializing project config
Initializing project workspace contexts
Generating graph of workspace files...
Started production test server at localhost:8080

  Error: ENOENT: no such file or directory, open '/Users/owenbuckley/Workspace/github/repos/knowing-your-tco/public/index.html'

Intercept

Trying to intercept on the default HTML response from [plugin-standard-html]() so as to re-write it on the fly. Unfortunately, this takes the page out of the flow of the templating process that's done in the earlier serve phase.

class ThemePackPageOverrideResource extends ResourceInterface {
  constructor(compilation, options) {
    super(compilation, options);
    this.extensions = ['*'];
  }

  async shouldIntercept(url) {
    console.debug('ThemePackPageOverrideResource shouldIntercept', url)
    return Promise.resolve(url.replace(this.compilation.context.userWorkspace, '') === '/');
  }

  async intercept(url) {
    console.debug('ThemePackPageOverrideResource intercept', url)
    return Promise.resolve({ body: 'hello world!'});
  }
}

This means that you get the content of index.html but without

So this just means that the plugin author just has to merge the content of their own index.html into the default returned by Greenwood. I suppose since Greenwood bring along with it an html parser, plugins could piggy back off this and do it just like Greenwood does it?

Templates

Another quick option, the would require user "intervention" is by publishing index.html as a template and then having the user specify that in their project.

src/
  assets/
    background.png 
  pages/
    index.md
    slides/
      1.md
      2.md
      .
      .
   styles/
     overrides.css

index.md

---
template: 'index'
---

Naturally the downside here is the user has to do something on their side, but it's a pretty low effort / impact step, and is explicit, as opposed to the above solution, which is very much implicit. However, in this case, this isn't really a template, it's the home / landing page so... 🤷‍♂️

I suppose the better alternative will be when we can allow direct users of Greenwood to push content directly into the graph, but I think it doesn't necessarily hurts if Greenwood is robust enough to allow for a large degree of flexibility in how its APIs are used. As long as they are clear and stable and consistent, it should be a net positive for everyone.


Either way, here are something we can document for now at least.