diff --git a/.storybook/main.js b/.storybook/main.js
index c44947dd..e0948259 100644
--- a/.storybook/main.js
+++ b/.storybook/main.js
@@ -3,7 +3,12 @@
// https://github.com/vitejs/vite/discussions/15532
const config = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
- addons: ["@storybook/addon-links", "@storybook/addon-essentials", "@chromatic-com/storybook"],
+ addons: [
+ "@storybook/addon-links",
+ "@storybook/addon-essentials",
+ "@chromatic-com/storybook",
+ "storybook-addon-fetch-mock",
+ ],
framework: {
name: "@storybook/web-components-vite",
options: {},
diff --git a/package-lock.json b/package-lock.json
index fa265cde..6231cab2 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -44,6 +44,7 @@
"remark-github": "^10.0.1",
"rimraf": "^5.0.5",
"storybook": "^8.0.6",
+ "storybook-addon-fetch-mock": "^2.0.1",
"stylelint": "^16.4.0",
"stylelint-prettier": "^5.0.0",
"vite": "^5.2.8"
@@ -4991,9 +4992,10 @@
}
},
"node_modules/@web/dev-server-core": {
- "version": "0.7.1",
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/@web/dev-server-core/-/dev-server-core-0.7.2.tgz",
+ "integrity": "sha512-Q/0jpF13Ipk+qGGQ+Yx/FW1TQBYazpkfgYHHo96HBE7qv4V4KKHqHglZcSUxti/zd4bToxX1cFTz8dmbTlb8JA==",
"dev": true,
- "license": "MIT",
"dependencies": {
"@types/koa": "^2.11.6",
"@types/ws": "^7.4.0",
@@ -6995,6 +6997,17 @@
"node": ">= 0.8"
}
},
+ "node_modules/core-js": {
+ "version": "3.38.1",
+ "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.38.1.tgz",
+ "integrity": "sha512-OP35aUorbU3Zvlx7pjsFdu1rGNnD4pgw/CWoYzRY3t2EzoVT7shKHY1dlAy3f41cGIO7ZDPQimhGFTlEYkG/Hw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/core-js"
+ }
+ },
"node_modules/core-js-compat": {
"version": "3.36.1",
"dev": true,
@@ -8245,6 +8258,71 @@
"pend": "~1.2.0"
}
},
+ "node_modules/fetch-mock": {
+ "version": "9.11.0",
+ "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-9.11.0.tgz",
+ "integrity": "sha512-PG1XUv+x7iag5p/iNHD4/jdpxL9FtVSqRMUQhPab4hVDt80T1MH5ehzVrL2IdXO9Q2iBggArFvPqjUbHFuI58Q==",
+ "dev": true,
+ "dependencies": {
+ "@babel/core": "^7.0.0",
+ "@babel/runtime": "^7.0.0",
+ "core-js": "^3.0.0",
+ "debug": "^4.1.1",
+ "glob-to-regexp": "^0.4.0",
+ "is-subset": "^0.1.1",
+ "lodash.isequal": "^4.5.0",
+ "path-to-regexp": "^2.2.1",
+ "querystring": "^0.2.0",
+ "whatwg-url": "^6.5.0"
+ },
+ "engines": {
+ "node": ">=4.0.0"
+ },
+ "funding": {
+ "type": "charity",
+ "url": "https://www.justgiving.com/refugee-support-europe"
+ },
+ "peerDependencies": {
+ "node-fetch": "*"
+ },
+ "peerDependenciesMeta": {
+ "node-fetch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fetch-mock/node_modules/path-to-regexp": {
+ "version": "2.4.0",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-2.4.0.tgz",
+ "integrity": "sha512-G6zHoVqC6GGTQkZwF4lkuEyMbVOjoBKAEybQUypI1WTkqinCOrq2x6U2+phkJ1XsEMTy4LjtwPI7HW+NVrRR2w==",
+ "dev": true
+ },
+ "node_modules/fetch-mock/node_modules/tr46": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz",
+ "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==",
+ "dev": true,
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/fetch-mock/node_modules/webidl-conversions": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
+ "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==",
+ "dev": true
+ },
+ "node_modules/fetch-mock/node_modules/whatwg-url": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-6.5.0.tgz",
+ "integrity": "sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ==",
+ "dev": true,
+ "dependencies": {
+ "lodash.sortby": "^4.7.0",
+ "tr46": "^1.0.1",
+ "webidl-conversions": "^4.0.2"
+ }
+ },
"node_modules/fetch-retry": {
"version": "5.0.6",
"dev": true,
@@ -10037,6 +10115,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/is-subset": {
+ "version": "0.1.1",
+ "resolved": "https://registry.npmjs.org/is-subset/-/is-subset-0.1.1.tgz",
+ "integrity": "sha512-6Ybun0IkarhmEqxXCNw/C0bna6Zb/TkfUX9UbwJtK6ObwAVCxmAP308WWTHviM/zAqXk05cdhYsUsZeGQh99iw==",
+ "dev": true
+ },
"node_modules/is-symbol": {
"version": "1.0.4",
"dev": true,
@@ -11177,11 +11261,23 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/lodash.isequal": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
+ "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
+ "dev": true
+ },
"node_modules/lodash.merge": {
"version": "4.6.2",
"dev": true,
"license": "MIT"
},
+ "node_modules/lodash.sortby": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
+ "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==",
+ "dev": true
+ },
"node_modules/lodash.template": {
"version": "4.5.0",
"dev": true,
@@ -13143,6 +13239,16 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/querystring": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.1.tgz",
+ "integrity": "sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg==",
+ "deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.",
+ "dev": true,
+ "engines": {
+ "node": ">=0.4.x"
+ }
+ },
"node_modules/queue-microtask": {
"version": "1.2.3",
"dev": true,
@@ -14454,6 +14560,18 @@
"url": "https://opencollective.com/storybook"
}
},
+ "node_modules/storybook-addon-fetch-mock": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/storybook-addon-fetch-mock/-/storybook-addon-fetch-mock-2.0.1.tgz",
+ "integrity": "sha512-s9fVg8sv03tHU8jeidruI9fzt8LPYfo27mAYKCEoXsBzVK8KA4XTyHOVLkYXIRs9Y6QGOAvksBfZR3PmAqwvYg==",
+ "dev": true,
+ "dependencies": {
+ "fetch-mock": "^9.11.0"
+ },
+ "peerDependencies": {
+ "@storybook/preview-api": "^7.0.0 || ^8.0.0 || next"
+ }
+ },
"node_modules/stream-read-all": {
"version": "3.0.1",
"dev": true,
diff --git a/package.json b/package.json
index dfed9201..63813aaa 100644
--- a/package.json
+++ b/package.json
@@ -68,6 +68,7 @@
"remark-github": "^10.0.1",
"rimraf": "^5.0.5",
"storybook": "^8.0.6",
+ "storybook-addon-fetch-mock": "^2.0.1",
"stylelint": "^16.4.0",
"stylelint-prettier": "^5.0.0",
"vite": "^5.2.8"
diff --git a/patches/@greenwood+cli+0.30.0-alpha.6.patch b/patches/@greenwood+cli+0.30.0-alpha.6.patch
index 85af4c95..47d7c9f1 100644
--- a/patches/@greenwood+cli+0.30.0-alpha.6.patch
+++ b/patches/@greenwood+cli+0.30.0-alpha.6.patch
@@ -133,43 +133,49 @@ index 8dbf281..a6f252e 100644
const mergedHtml = pageRoot && pageRoot.querySelector('html').rawAttrs !== ''
? ``
diff --git a/node_modules/@greenwood/cli/src/lib/walker-package-ranger.js b/node_modules/@greenwood/cli/src/lib/walker-package-ranger.js
-index 5ce30c1..11195a7 100644
+index 5ce30c1..a92bb3d 100644
--- a/node_modules/@greenwood/cli/src/lib/walker-package-ranger.js
+++ b/node_modules/@greenwood/cli/src/lib/walker-package-ranger.js
-@@ -217,15 +217,30 @@ async function walkPackageJson(packageJson = {}) {
+@@ -215,17 +215,33 @@ async function walkPackageJson(packageJson = {}) {
+ return importMap;
+ }
- function mergeImportMap(html = '', map = {}) {
- // es-modules-shims breaks on dangling commas in an importMap :/
+-function mergeImportMap(html = '', map = {}) {
+- // es-modules-shims breaks on dangling commas in an importMap :/
- const danglingComma = html.indexOf('"imports": {}') > 0 ? '' : ',';
-+ const hasImportMap = html.indexOf('script type="importmap"') > 0;
-+ const danglingComma = hasImportMap && html.indexOf('"imports": {}') > 0 ? '' : ',';
- const importMap = JSON.stringify(map).replace('}', '').replace('{', '');
-
+- const importMap = JSON.stringify(map).replace('}', '').replace('{', '');
+-
- const merged = html.replace('"imports": {', `
- "imports": {
- ${importMap}${danglingComma}
- `);
--
++function mergeImportMap(html = '', map = {}, shouldShim = false) {
++ const importMapType = shouldShim ? 'importmap-shim' : 'importmap';
++ const hasImportMap = html.indexOf(`script type="${importMapType}"`) > 0;
++ const danglingComma = hasImportMap ? ',' : '';
++ const importMap = JSON.stringify(map, null, 2).replace('}', '').replace('{', '');
++
++ if (Object.entries(map).length === 0) {
++ return html;
++ }
+
- return merged;
-+ // TODO looks like this was never working correctly!? :o
-+ // console.log({ hasImportMap, html, map, danglingComma, importMap });
+ if (hasImportMap) {
+ return html.replace('"imports": {', `
+ "imports": {
+ ${importMap}${danglingComma}
+ `);
+ } else {
-+ // TODO this needs tp account for import map shim polyfill config
+ return html.replace('
', `
+
-+
-+ `)
++ `);
+ }
}
@@ -280,7 +286,7 @@ index 9c31a23..807a79e 100644
if (plugins && plugins.length > 0) {
diff --git a/node_modules/@greenwood/cli/src/lifecycles/graph.js b/node_modules/@greenwood/cli/src/lifecycles/graph.js
-index a1d16e5..7c9437e 100644
+index a1d16e5..d73c515 100644
--- a/node_modules/@greenwood/cli/src/lifecycles/graph.js
+++ b/node_modules/@greenwood/cli/src/lifecycles/graph.js
@@ -5,13 +5,33 @@ import { checkResourceExists, requestAsObject } from '../lib/resource-utils.js';
@@ -480,7 +486,7 @@ index a1d16e5..7c9437e 100644
}),
request
});
-@@ -221,66 +192,93 @@ const generateGraph = async (compilation) => {
+@@ -221,66 +192,97 @@ const generateGraph = async (compilation) => {
layout = ssrFrontmatter.layout || layout;
title = ssrFrontmatter.title || title;
imports = ssrFrontmatter.imports || imports;
@@ -543,9 +549,13 @@ index a1d16e5..7c9437e 100644
+ customData.tableOfContents = [];
+
+ if (fileContents && customData.tocHeading > 0 && customData.tocHeading <= 6) {
++ // console.log({ route, fileContents, customData });
++ // console.log('===========================');
++ // console.log(customData.tocHeading);
+ // parse markdown for table of contents and output to json
+ customData.tableOfContents = toc(fileContents).json;
-+ customData.tableOfContents.shift();
++ // not sure why we were shiting here?
++ // customData.tableOfContents.shift();
+
+ // parse table of contents for only the pages user wants linked
+ if (customData.tableOfContents.length > 0 && customData.tocHeading > 0) {
@@ -619,7 +629,7 @@ index a1d16e5..7c9437e 100644
} else {
console.debug(`Unhandled extension (${extension}) for route => ${route}`);
}
-@@ -320,11 +318,11 @@ const generateGraph = async (compilation) => {
+@@ -320,11 +322,11 @@ const generateGraph = async (compilation) => {
{
...oldGraph,
outputPath: '/404.html',
@@ -634,7 +644,7 @@ index a1d16e5..7c9437e 100644
}
];
}
-@@ -346,8 +344,7 @@ const generateGraph = async (compilation) => {
+@@ -346,8 +348,7 @@ const generateGraph = async (compilation) => {
}
graph.push({
@@ -688,7 +698,7 @@ index 20de63b..6e98d05 100644
if (process.env.__GWD_COMMAND__ === 'develop') { // eslint-disable-line no-underscore-dangle
diff --git a/node_modules/@greenwood/cli/src/plugins/resource/plugin-content-as-data.js b/node_modules/@greenwood/cli/src/plugins/resource/plugin-content-as-data.js
new file mode 100644
-index 0000000..5d299de
+index 0000000..36185b1
--- /dev/null
+++ b/node_modules/@greenwood/cli/src/plugins/resource/plugin-content-as-data.js
@@ -0,0 +1,54 @@
@@ -707,12 +717,12 @@ index 0000000..5d299de
+ }
+
+ async shouldIntercept(url, request, response) {
-+ return response.headers.get('Content-Type')?.indexOf(this.contentType[0]) >= 0;
++ return response.headers.get('Content-Type')?.indexOf(this.contentType[0]) >= 0 && process.env.__GWD_COMMAND__ === 'develop'; // eslint-disable-line no-underscore-dangle
+ }
+
+ async intercept(url, request, response) {
+ const body = await response.text();
-+ const newBody = mergeImportMap(body, importMap);
++ const newBody = mergeImportMap(body, importMap, this.compilation.config.polyfills.importMaps);
+
+ // TODO how come we need to forward headers, shouldn't mergeResponse do that for us?
+ return new Response(newBody, {
@@ -748,7 +758,7 @@ index 0000000..5d299de
+export { greenwoodPluginContentAsData };
\ No newline at end of file
diff --git a/node_modules/@greenwood/cli/src/plugins/resource/plugin-node-modules.js b/node_modules/@greenwood/cli/src/plugins/resource/plugin-node-modules.js
-index 286c2de..a336aed 100644
+index 286c2de..bd662ae 100644
--- a/node_modules/@greenwood/cli/src/plugins/resource/plugin-node-modules.js
+++ b/node_modules/@greenwood/cli/src/plugins/resource/plugin-node-modules.js
@@ -10,7 +10,7 @@ import replace from '@rollup/plugin-replace';
@@ -760,34 +770,33 @@ index 286c2de..a336aed 100644
let importMap;
-@@ -98,15 +98,16 @@ class NodeModulesResource extends ResourceInterface {
+@@ -75,7 +75,6 @@ class NodeModulesResource extends ResourceInterface {
+ async intercept(url, request, response) {
+ const { context, config } = this.compilation;
+ const { importMaps } = config.polyfills;
+- const importMapType = importMaps ? 'importmap-shim' : 'importmap';
+ const importMapShimScript = importMaps ? '' : '';
+ let body = await response.text();
+ const hasHead = body.match(/\(.*)<\/head>/s);
+@@ -97,15 +96,10 @@ class NodeModulesResource extends ResourceInterface {
+ ? await walkPackageJson(userPackageJson)
: importMap || {};
- // apply import map and shim for users
-- body = body.replace('', `
--
-- ${importMapShimScript}
+- // apply import map and shim for users
++ body = mergeImportMap(body, importMap, importMaps);
+ body = body.replace('', `
+
+ ${importMapShimScript}
-
-- `);
-+ body = mergeImportMap(body, importMap);
-+ // body = body.replace('', `
-+ //
-+ // ${importMapShimScript}
-+ //
-+ // `);
+ `);
return new Response(body);
- }
diff --git a/node_modules/@greenwood/cli/src/plugins/resource/plugin-standard-html.js b/node_modules/@greenwood/cli/src/plugins/resource/plugin-standard-html.js
-index 06223cf..ab63448 100644
+index 06223cf..b372e38 100644
--- a/node_modules/@greenwood/cli/src/plugins/resource/plugin-standard-html.js
+++ b/node_modules/@greenwood/cli/src/plugins/resource/plugin-standard-html.js
@@ -5,7 +5,6 @@
@@ -884,7 +893,7 @@ index 06223cf..ab63448 100644
body = await getUserScripts(body, this.compilation);
if (processedMarkdown) {
-@@ -171,15 +150,32 @@ class StandardHtmlResource extends ResourceInterface {
+@@ -171,15 +150,30 @@ class StandardHtmlResource extends ResourceInterface {
body = body.replace(/\(.*)<\/content-outlet>/s, `${ssrBody.replace(/\$/g, '$$$')}`);
}
@@ -898,15 +907,13 @@ index 06223cf..ab63448 100644
+ body = body.replace(new RegExp(interpolatedFrontmatter, 'g'), needle);
+ }
+
-+ // TODO
-+ // const activeFrontmatterForwardKeys = ['route', 'label'];
++ const activeFrontmatterForwardKeys = ['route', 'label', 'title'];
+
-+ // for (const key of activeFrontmatterForwardKeys) {
-+ // console.log({ key })
-+ // const interpolatedFrontmatter = '\\$\\{globalThis.page.' + key + '\\}';
++ for (const key of activeFrontmatterForwardKeys) {
++ const interpolatedFrontmatter = '\\$\\{globalThis.page.' + key + '\\}';
+
-+ // body = body.replace(new RegExp(interpolatedFrontmatter, 'g'), matchingRoute[key]);
-+ // }
++ body = body.replace(new RegExp(interpolatedFrontmatter, 'g'), matchingRoute[key]);
++ }
+
+ for (const collection in this.compilation.collections) {
+ const interpolatedFrontmatter = '\\$\\{globalThis.collection.' + collection + '\\}';
diff --git a/patches/wc-compiler+0.14.0.patch b/patches/wc-compiler+0.14.0.patch
index 838ddc2f..298e0216 100644
--- a/patches/wc-compiler+0.14.0.patch
+++ b/patches/wc-compiler+0.14.0.patch
@@ -23,3 +23,102 @@ index be289a3..db07eb9 100644
}
}
+diff --git a/node_modules/wc-compiler/src/wcc.js b/node_modules/wc-compiler/src/wcc.js
+index 35884d4..13e5aaa 100644
+--- a/node_modules/wc-compiler/src/wcc.js
++++ b/node_modules/wc-compiler/src/wcc.js
+@@ -32,16 +32,27 @@ async function renderComponentRoots(tree, definitions) {
+ const { tagName } = node;
+
+ if (definitions[tagName]) {
++ // console.log('renderComponentRoots', { tagName });
+ const { moduleURL } = definitions[tagName];
+- const elementInstance = await initializeCustomElement(moduleURL, tagName, node.attrs, definitions);
+- const elementHtml = elementInstance.shadowRoot
++ // console.log({ node });
++ const elementInstance = await initializeCustomElement(moduleURL, tagName, node, definitions);
++ const hasShadow = elementInstance.shadowRoot;
++ const elementHtml = hasShadow
+ ? elementInstance.getInnerHTML({ includeShadowRoots: true })
+ : elementInstance.innerHTML;
+ const elementTree = parseFragment(elementHtml);
++ const hasLight = elementTree.childNodes > 0;
+
+- node.childNodes = node.childNodes.length === 0
++ // console.log('elementHtml', { elementHtml });
++ // console.log('elementTree', { elementTree });
++ // console.log('elementTree.childNodes', elementTree.childNodes);
++ // console.log('node.childNodes', node.childNodes);
++
++ node.childNodes = node.childNodes.length === 0 && hasLight > 0 && !hasShadow
+ ? elementTree.childNodes
+- : [...elementTree.childNodes, ...node.childNodes];
++ : hasShadow
++ ? [...elementTree.childNodes, ...node.childNodes]
++ : elementTree.childNodes;
+ } else {
+ console.warn(`WARNING: customElement <${tagName}> is not defined. You may not have imported it yet.`);
+ }
+@@ -138,7 +149,10 @@ async function getTagName(moduleURL) {
+ return tagName;
+ }
+
+-async function initializeCustomElement(elementURL, tagName, attrs = [], definitions = [], isEntry, props = {}) {
++async function initializeCustomElement(elementURL, tagName, node = {}, definitions = [], isEntry, props = {}) {
++ const { attrs = [], childNodes = [] } = node;
++ // console.log('initializeCustomElement', { node });
++
+ if (!tagName) {
+ const depth = isEntry ? 1 : 0;
+ registerDependencies(elementURL, definitions, depth);
+@@ -157,6 +171,41 @@ async function initializeCustomElement(elementURL, tagName, attrs = [], definiti
+
+ if (element) {
+ const elementInstance = new element(data); // eslint-disable-line new-cap
++ let innerHTML = elementInstance.innerHTML || '';
++
++ // TODO
++ // 1. Needs to be recursive
++ // 2. ~~Needs to handle attributes~~
++ // 3. Needs to handle duplicate content
++ // 4. Needs to handle self closing tags
++ // 5. handle all node types
++ childNodes.forEach((child) => {
++ const { nodeName, attrs = [] } = child;
++
++ if (nodeName !== '#text') {
++ innerHTML += `<${nodeName}`;
++
++ if (attrs.length > 0) {
++ attrs.forEach(attr => {
++ innerHTML += ` ${attr.name}="${attr.value}"`;
++ });
++ }
++
++ innerHTML += '>';
++
++ child.childNodes.forEach((c) => {
++ if (c.nodeName === '#text') {
++ innerHTML += c.value;
++ }
++ });
++
++ innerHTML += `${nodeName}>`;
++ }
++ });
++
++ // console.log({ innerHTML });
++ elementInstance.innerHTML = innerHTML;
++ // console.log('=================');
+
+ attrs.forEach((attr) => {
+ elementInstance.setAttribute(attr.name, attr.value);
+@@ -207,7 +256,7 @@ async function renderFromHTML(html, elements = []) {
+ const definitions = [];
+
+ for (const url of elements) {
+- await initializeCustomElement(url, undefined, undefined, definitions, true);
++ registerDependencies(url, definitions, 1);
+ }
+
+ const elementTree = getParse(html)(html);
diff --git a/src/assets/guides/full-stack-web-components.webp b/src/assets/guides/full-stack-web-components.webp
new file mode 100644
index 00000000..cce8b8d9
Binary files /dev/null and b/src/assets/guides/full-stack-web-components.webp differ
diff --git a/src/assets/guides/getting-started-going-further-prerendering.webp b/src/assets/guides/getting-started-going-further-prerendering.webp
new file mode 100644
index 00000000..00cfc4e8
Binary files /dev/null and b/src/assets/guides/getting-started-going-further-prerendering.webp differ
diff --git a/src/assets/guides/getting-started-repo-styled.webp b/src/assets/guides/getting-started-repo-styled.webp
new file mode 100644
index 00000000..49c59efc
Binary files /dev/null and b/src/assets/guides/getting-started-repo-styled.webp differ
diff --git a/src/assets/guides/getting-started-repo-unstyled-partial.webp b/src/assets/guides/getting-started-repo-unstyled-partial.webp
new file mode 100644
index 00000000..2170edfb
Binary files /dev/null and b/src/assets/guides/getting-started-repo-unstyled-partial.webp differ
diff --git a/src/assets/guides/gh-pages-branch-commits.png b/src/assets/guides/gh-pages-branch-commits.png
new file mode 100644
index 00000000..a81e3770
Binary files /dev/null and b/src/assets/guides/gh-pages-branch-commits.png differ
diff --git a/src/assets/guides/gh-pages-branch.png b/src/assets/guides/gh-pages-branch.png
new file mode 100644
index 00000000..d8399864
Binary files /dev/null and b/src/assets/guides/gh-pages-branch.png differ
diff --git a/src/assets/guides/greenwood-docker-desktop.webp b/src/assets/guides/greenwood-docker-desktop.webp
new file mode 100644
index 00000000..29826b91
Binary files /dev/null and b/src/assets/guides/greenwood-docker-desktop.webp differ
diff --git a/src/assets/guides/greenwood-starter-presentation.png b/src/assets/guides/greenwood-starter-presentation.png
new file mode 100644
index 00000000..aec3c371
Binary files /dev/null and b/src/assets/guides/greenwood-starter-presentation.png differ
diff --git a/src/assets/guides/repo-github-pages-config.png b/src/assets/guides/repo-github-pages-config.png
new file mode 100644
index 00000000..004d5538
Binary files /dev/null and b/src/assets/guides/repo-github-pages-config.png differ
diff --git a/src/components/blog-posts-list/blog-posts-list.spec.js b/src/components/blog-posts-list/blog-posts-list.spec.js
new file mode 100644
index 00000000..f9918ccb
--- /dev/null
+++ b/src/components/blog-posts-list/blog-posts-list.spec.js
@@ -0,0 +1,98 @@
+import { expect } from "@esm-bundle/chai";
+import "./blog-posts-list.js";
+import graph from "../../stories/mocks/graph.json" with { type: "json" };
+
+// https://stackoverflow.com/questions/45425169/intercept-fetch-api-requests-and-responses-in-javascript
+window.fetch = function () {
+ return new Promise((resolve) => {
+ resolve(new Response(JSON.stringify(graph)));
+ });
+};
+
+describe("Components/Blog Posts List", () => {
+ let list;
+ let expectedBlogPosts = [];
+
+ before(async () => {
+ expectedBlogPosts = graph
+ .filter((page) => page.data.published)
+ .sort((a, b) =>
+ new Date(a.data.published).getTime() > new Date(b.data.published).getTime() ? -1 : 1,
+ );
+
+ list = document.createElement("app-blog-posts-list");
+ document.body.appendChild(list);
+
+ await list.updateComplete;
+ });
+
+ describe("Default Behavior", () => {
+ it("should not be null", () => {
+ expect(list).not.equal(undefined);
+ expect(list.querySelectorAll("ul").length).to.equal(1);
+ });
+
+ it("should have the expected number of blog post as tags", () => {
+ const items = list.querySelectorAll("ul li");
+
+ expect(items.length).to.equal(expectedBlogPosts.length);
+ });
+
+ it("should have the expected number of blog post as tags", () => {
+ const items = list.querySelectorAll("ul li");
+
+ expect(items.length).to.equal(expectedBlogPosts.length);
+ });
+
+ it("should have the expected number of anchor tags with the right content as data", () => {
+ const anchors = list.querySelectorAll("ul li a[href]");
+
+ expect(anchors.length).to.equal(expectedBlogPosts.length);
+
+ anchors.forEach((anchor, i) =>
+ expect(anchor.getAttribute("href")).to.equal(expectedBlogPosts[i].route),
+ );
+ });
+
+ it("should have the expected number of image tags with the right content as data", () => {
+ const images = list.querySelectorAll("ul li a img[alt]");
+
+ expect(images.length).to.equal(expectedBlogPosts.length);
+
+ images.forEach((image, i) => {
+ const post = expectedBlogPosts[i];
+ const src = post.data.coverImage ?? "/assets/greenwood-logo-leaf.svg";
+
+ expect(image.getAttribute("src")).to.equal(src);
+ expect(image.getAttribute("alt")).to.equal(post.title);
+ });
+ });
+
+ it("should have the expected heading with the right content", () => {
+ const headings = list.querySelectorAll("h2");
+
+ expect(headings.length).to.equal(expectedBlogPosts.length);
+
+ headings.forEach((heading, i) => {
+ expect(heading.textContent).to.equal(expectedBlogPosts[i].title);
+ });
+ });
+
+ it("should have the expected abstract with the right content", () => {
+ const paragraphs = list.querySelectorAll("p");
+
+ expect(paragraphs.length).to.equal(expectedBlogPosts.length);
+
+ paragraphs.forEach((paragraph, i) => {
+ expect(paragraph.textContent.replace(/\'/g, "'")).to.equal(
+ expectedBlogPosts[i].data.abstract,
+ );
+ });
+ });
+ });
+
+ after(() => {
+ list.remove();
+ list = null;
+ });
+});
diff --git a/src/components/blog-posts-list/blog-posts-list.stories.js b/src/components/blog-posts-list/blog-posts-list.stories.js
new file mode 100644
index 00000000..7bb5c5b1
--- /dev/null
+++ b/src/components/blog-posts-list/blog-posts-list.stories.js
@@ -0,0 +1,24 @@
+import "./blog-posts-list.js";
+import pages from "../../stories/mocks/graph.json";
+
+export default {
+ title: "Components/Blog Posts List",
+ parameters: {
+ fetchMock: {
+ mocks: [
+ {
+ matcher: {
+ url: "http://localhost:1985/graph.json",
+ response: {
+ body: pages,
+ },
+ },
+ },
+ ],
+ },
+ },
+};
+
+const Template = () => "";
+
+export const Primary = Template.bind({});
diff --git a/src/components/capabilities/capabilities.css b/src/components/capabilities/capabilities.css
index f1dc4fc0..3b77344c 100644
--- a/src/components/capabilities/capabilities.css
+++ b/src/components/capabilities/capabilities.css
@@ -109,14 +109,6 @@ pre {
margin: 0;
}
-@media (max-width: 760px) {
- code[class*="language-"],
- pre[class*="language-"],
- .token {
- font-size: 0.9rem;
- }
-}
-
@media (min-width: 768px) {
.container {
padding: var(--size-fluid-4);
diff --git a/src/components/capabilities/capabilities.spec.js b/src/components/capabilities/capabilities.spec.js
new file mode 100644
index 00000000..9b20cf1f
--- /dev/null
+++ b/src/components/capabilities/capabilities.spec.js
@@ -0,0 +1,107 @@
+import { expect } from "@esm-bundle/chai";
+import "./capabilities.js";
+
+describe("Components/Capabilities", () => {
+ let capabilities;
+
+ before(async () => {
+ capabilities = document.createElement("app-capabilities");
+
+ const capability1 = document.createElement("div");
+ const capability2 = document.createElement("div");
+
+ capability1.setAttribute("class", "capabilities-content item1");
+ capability1.innerHTML = `
+ Hybrid Routing
+ html.svg
+ Greenwood is HTML first by design. Start from just an index.html file or leverage hybrid, file-system based routing to easily achieve static and dynamic pages side-by-side. Single Page Applications (SPA) also supported.
+
+
+ src/
+ pages/
+ api/
+ search.js # API route
+ index.html # Static (SSG)
+ products.js # Dynamic (SSR), or emit as static with pre-rendering
+ about.md # markdown also supported
+
+ `;
+
+ capability2.setAttribute("class", "capabilities-content item2");
+ capability2.innerHTML = `
+ Web Components
+ web-components.svg
+ Greenwood makes it possible to author real isomorphic Web Components, using Light or Shadow DOM, re-using that same definition across the server and client side. Combined with Web APIs like Constructable Stylesheets and Import Attributes, Web Components make for a compelling solution as the web's own component model.
+
+
+ // src/components/card.js
+ import themeSheet from "../styles/theme.css" with { type: "css" };
+ import cardSheet from "./card.css" with { type: "css" };
+
+ class Card extends HTMLElement {
+ connectedCallback() {
+ if (!this.shadowRoot) {
+ const thumbnail = this.getAttribute("thumbnail");
+ const title = this.getAttribute("title");
+ const template = document.createElement("template");
+
+ template.innerHTML = '...'
+ this.attachShadow({ mode: "open" });
+ this.shadowRoot.appendChild(template.content.cloneNode(true));
+ }
+
+ this.shadowRoot.adoptedStyleSheets = [themeSheet, cardSheet];
+ }
+ }
+
+ customElements.define("app-card", Card);
+
+ `;
+
+ document.body.appendChild(capability1);
+ document.body.appendChild(capability2);
+ document.body.appendChild(capabilities);
+
+ await capabilities.updateComplete;
+ });
+
+ describe("Default Behavior", () => {
+ it("should not be null", () => {
+ expect(capabilities).not.equal(undefined);
+ });
+
+ it("should have the expected number of list items from user generated content", () => {
+ const items = capabilities.shadowRoot.querySelectorAll(".sections li");
+
+ expect(items.length).to.equal(2);
+ });
+
+ it("should have the expected capability headings", () => {
+ const headings = capabilities.shadowRoot.querySelectorAll(".sections .capability-heading");
+
+ expect(headings.length).to.equal(2);
+ expect(headings[0].innerHTML).to.contain("Hybrid Routing");
+ expect(headings[1].innerHTML).to.contain("Web Components");
+ });
+
+ it("should have the expected default description content from item 1 in the content section", () => {
+ const content = capabilities.shadowRoot.querySelectorAll(".content");
+
+ expect(content.length).to.equal(1);
+ expect(content[0].innerHTML).to.contain("Greenwood is HTML first by design.");
+ });
+
+ it("should have the expected default code content from item 1 in the snippet section", () => {
+ const snippet = capabilities.shadowRoot.querySelectorAll(".snippet");
+
+ expect(snippet.length).to.equal(1);
+ expect(snippet[0].innerHTML).to.contain(
+ "Dynamic (SSR), or emit as static with pre-rendering",
+ );
+ });
+ });
+ after(() => {
+ capabilities.remove();
+ capabilities = null;
+ });
+});
diff --git a/src/components/capabilities/capabilities.stories.js b/src/components/capabilities/capabilities.stories.js
new file mode 100644
index 00000000..39727796
--- /dev/null
+++ b/src/components/capabilities/capabilities.stories.js
@@ -0,0 +1,58 @@
+import "./capabilities.js";
+
+export default {
+ title: "Components/Capabilities",
+};
+
+const Template = () => `
+
+
Hybrid Routing
+
html.svg
+
Greenwood is HTML first by design. Start from just an index.html file or leverage hybrid, file-system based routing to easily achieve static and dynamic pages side-by-side. Single Page Applications (SPA) also supported.
+
+
+ src/
+ pages/
+ api/
+ search.js # API route
+ index.html # Static (SSG)
+ products.js # Dynamic (SSR), or emit as static with pre-rendering
+ about.md # markdown also supported
+
+
+
+
+
Web Components
+
web-components.svg
+
Greenwood makes it possible to author real isomorphic Web Components, using Light or Shadow DOM, re-using that same definition across the server and client side. Combined with Web APIs like Constructable Stylesheets and Import Attributes, Web Components make for a compelling solution as the web's own component model.
+
+
+ // src/components/card.js
+ import themeSheet from "../styles/theme.css" with { type: "css" };
+ import cardSheet from "./card.css" with { type: "css" };
+
+ class Card extends HTMLElement {
+ connectedCallback() {
+ if (!this.shadowRoot) {
+ const thumbnail = this.getAttribute("thumbnail");
+ const title = this.getAttribute("title");
+ const template = document.createElement("template");
+
+ template.innerHTML = '...'
+ this.attachShadow({ mode: "open" });
+ this.shadowRoot.appendChild(template.content.cloneNode(true));
+ }
+
+ this.shadowRoot.adoptedStyleSheets = [themeSheet, cardSheet];
+ }
+ }
+
+ customElements.define("app-card", Card);
+
+
+
+
+
+`;
+
+export const Primary = Template.bind({});
diff --git a/src/components/copy-to-clipboard/copy-to-clipboard.spec.js b/src/components/copy-to-clipboard/copy-to-clipboard.spec.js
new file mode 100644
index 00000000..fdc51548
--- /dev/null
+++ b/src/components/copy-to-clipboard/copy-to-clipboard.spec.js
@@ -0,0 +1,32 @@
+import { expect } from "@esm-bundle/chai";
+import "./copy-to-clipboard.js";
+
+describe("Components/Copy To Clipboard", () => {
+ const content = "npx @greenwood/init@latest my-app";
+ let ctc;
+
+ before(async () => {
+ ctc = document.createElement("app-ctc");
+
+ ctc.setAttribute("content", content);
+ document.body.appendChild(ctc);
+
+ await ctc.updateComplete;
+ });
+
+ describe("Default Behavior", () => {
+ it("should not be null", () => {
+ expect(ctc).not.equal(undefined);
+ });
+
+ it("should have an icon with the user provided content set", () => {
+ const icon = ctc.shadowRoot.querySelectorAll("[title='Copy to clipboard']");
+
+ expect(icon.length).to.equal(1);
+ });
+ });
+ after(() => {
+ ctc.remove();
+ ctc = null;
+ });
+});
diff --git a/src/components/copy-to-clipboard/copy-to-clipboard.stories.js b/src/components/copy-to-clipboard/copy-to-clipboard.stories.js
new file mode 100644
index 00000000..3b939ae6
--- /dev/null
+++ b/src/components/copy-to-clipboard/copy-to-clipboard.stories.js
@@ -0,0 +1,9 @@
+import "./copy-to-clipboard.js";
+
+export default {
+ title: "Components/Copy To Clipboard",
+};
+
+const Template = () => "";
+
+export const Primary = Template.bind({});
diff --git a/src/components/get-started/get-started.module.css b/src/components/get-started/get-started.module.css
index 3468f075..20c3e431 100644
--- a/src/components/get-started/get-started.module.css
+++ b/src/components/get-started/get-started.module.css
@@ -67,6 +67,8 @@
.snippet pre {
padding: var(--size-px-4) var(--size-px-1);
+ background-color: transparent;
+ color: var(--color-black);
}
.snippet app-ctc {
diff --git a/src/components/get-started/get-started.stories.js b/src/components/get-started/get-started.stories.js
index 76d2a82c..dd62a217 100644
--- a/src/components/get-started/get-started.stories.js
+++ b/src/components/get-started/get-started.stories.js
@@ -1,3 +1,4 @@
+import "../copy-to-clipboard/copy-to-clipboard.js";
import "./get-started.js";
export default {
diff --git a/src/components/header/header.module.css b/src/components/header/header.module.css
index e28f3c18..5ef3c568 100644
--- a/src/components/header/header.module.css
+++ b/src/components/header/header.module.css
@@ -1,8 +1,9 @@
.container {
display: flex;
justify-content: space-between;
- padding: 0 var(--size-4);
+ padding: 0 var(--size-4) var(--size-2);
margin: 0;
+ border-bottom: 2px dotted var(--color-gray);
}
.logoLink {
@@ -56,7 +57,7 @@
width: fit-content;
border: var(--border-size-1) solid #4d4d4d45;
border-radius: var(--radius-6);
- padding: var(--size-2) var(--size-3);
+ padding: var(--size-1);
align-items: center;
justify-content: center;
cursor: pointer;
@@ -113,7 +114,7 @@
@media screen and (min-width: 480px) {
.container {
- padding: 0 var(--size-10);
+ padding: 0 var(--size-10) var(--size-3);
}
}
@@ -138,6 +139,10 @@
.logoLink svg.greenwood-logo-full {
width: 60%;
}
+
+ .socialTray {
+ padding: var(--size-1) var(--size-2);
+ }
}
@media screen and (min-width: 1024px) {
diff --git a/src/components/heading-box/heading-box.js b/src/components/heading-box/heading-box.js
new file mode 100644
index 00000000..2e4b4dfc
--- /dev/null
+++ b/src/components/heading-box/heading-box.js
@@ -0,0 +1,18 @@
+import styles from "./heading-box.module.css";
+
+export default class HeadingBox extends HTMLElement {
+ connectedCallback() {
+ const heading = this.getAttribute("heading");
+
+ this.innerHTML = `
+
+
${heading}
+
+ ${this.innerHTML}
+
+
+ `;
+ }
+}
+
+customElements.define("app-heading-box", HeadingBox);
diff --git a/src/components/heading-box/heading-box.module.css b/src/components/heading-box/heading-box.module.css
new file mode 100644
index 00000000..613b975f
--- /dev/null
+++ b/src/components/heading-box/heading-box.module.css
@@ -0,0 +1,20 @@
+.container {
+ background-color: var(--color-prism-bg);
+ padding: var(--size-5) var(--size-3) var(--size-3);
+ border-radius: var(--radius-3);
+ color: var(--color-white);
+}
+
+.heading {
+ font-size: var(--font-size-3) !important;
+ background-color: var(--color-white);
+ display: inline-block;
+ padding: var(--size-2) var(--size-4);
+ border-radius: var(--radius-3);
+ box-shadow: var(--shadow-3);
+ color: var(--color-black);
+}
+
+.slotted {
+ margin: var(--size-4) 0 var(--size-1);
+}
diff --git a/src/components/heading-box/heading-box.spec.js b/src/components/heading-box/heading-box.spec.js
new file mode 100644
index 00000000..fc550e3d
--- /dev/null
+++ b/src/components/heading-box/heading-box.spec.js
@@ -0,0 +1,45 @@
+import { expect } from "@esm-bundle/chai";
+import "./heading-box.js";
+
+describe("Components/Heading Box", () => {
+ const HEADING = "Heading goes here";
+ const CONTENT = "Content goes here
";
+ let headingBox;
+
+ before(async () => {
+ headingBox = document.createElement("app-heading-box");
+
+ headingBox.setAttribute("heading", HEADING);
+ headingBox.innerHTML = CONTENT;
+
+ document.body.appendChild(headingBox);
+
+ await headingBox.updateComplete;
+ });
+
+ describe("Default Behavior", () => {
+ it("should not be null", () => {
+ expect(headingBox).not.equal(undefined);
+ expect(headingBox.querySelectorAll("div:not([role='details'])").length).equal(1);
+ });
+
+ it("should have the expected heading content set as an attribute", () => {
+ const heading = headingBox.querySelectorAll("[role='heading']");
+
+ expect(heading.length).to.equal(1);
+ expect(heading[0].textContent).equal(HEADING);
+ });
+
+ it("should have the expected 'slotted' content", () => {
+ const slotted = headingBox.querySelectorAll("[role='details']");
+
+ expect(slotted.length).to.equal(1);
+ expect(slotted[0].innerHTML.trim()).equal(CONTENT);
+ });
+ });
+
+ after(() => {
+ headingBox.remove();
+ headingBox = null;
+ });
+});
diff --git a/src/components/heading-box/heading-box.stories.js b/src/components/heading-box/heading-box.stories.js
new file mode 100644
index 00000000..1d091a3b
--- /dev/null
+++ b/src/components/heading-box/heading-box.stories.js
@@ -0,0 +1,13 @@
+import "./heading-box.js";
+
+export default {
+ title: "Components/Heading Box",
+};
+
+const Template = () => `
+
+ This section of our Guides content will cover some of the hosting options you can use to deploy your Greenwood project.
+
+`;
+
+export const Primary = Template.bind({});
diff --git a/src/components/hero-banner/hero-banner.module.css b/src/components/hero-banner/hero-banner.module.css
index 2f5f7ebd..06fff364 100644
--- a/src/components/hero-banner/hero-banner.module.css
+++ b/src/components/hero-banner/hero-banner.module.css
@@ -87,6 +87,8 @@
align-content: center;
display: inline;
vertical-align: text-top;
+ background-color: transparent;
+ color: var(--color-black);
}
.snippet app-ctc {
diff --git a/src/components/hero-banner/hero-banner.stories.js b/src/components/hero-banner/hero-banner.stories.js
index ec7c7167..13b421b0 100644
--- a/src/components/hero-banner/hero-banner.stories.js
+++ b/src/components/hero-banner/hero-banner.stories.js
@@ -1,3 +1,4 @@
+import "../copy-to-clipboard/copy-to-clipboard.js";
import "./hero-banner.js";
export default {
diff --git a/src/components/run-anywhere/platforms.json b/src/components/run-anywhere/platforms.json
index 32c22035..f4dd6b3c 100644
--- a/src/components/run-anywhere/platforms.json
+++ b/src/components/run-anywhere/platforms.json
@@ -2,21 +2,21 @@
{
"name": "Vercel",
"icon": "vercel",
- "link": "/guide/deploy/vercel/"
+ "link": "/guides/hosting/vercel/"
},
{
"name": "Netlify",
"icon": "netlify",
- "link": "/guide/deploy/netlify/"
+ "link": "/guides/hosting/netlify/"
},
{
"name": "NodeJS",
"icon": "nodejs",
- "link": "/guide/deploy/nodejs/"
+ "link": "/guides/hosting/#self-hosting"
},
{
"name": "GitHub",
"icon": "github",
- "link": "/guide/deploy/github-pages/"
+ "link": "/guides/hosting/github/"
}
]
diff --git a/src/components/side-nav/side-nav.js b/src/components/side-nav/side-nav.js
new file mode 100644
index 00000000..cc2d1a3d
--- /dev/null
+++ b/src/components/side-nav/side-nav.js
@@ -0,0 +1,108 @@
+import { getContentByRoute } from "@greenwood/cli/src/data/queries.js";
+import styles from "./side-nav.module.css";
+
+export default class SideNav extends HTMLElement {
+ async connectedCallback() {
+ const heading = this.getAttribute("heading");
+ const route = this.getAttribute("route");
+ const currentRoute = this.getAttribute("current-route");
+ const content = (await getContentByRoute(route)).filter((page) => page.route !== route);
+ const sections = [];
+
+ content.forEach((item) => {
+ const segments = item.route
+ .replace(`${route}`, "")
+ .split("/")
+ .filter((segment) => segment !== "");
+ const parent = content.find((page) => page.route === `${route}${segments[0]}/`);
+
+ if (!sections[parent.data.order - 1]) {
+ sections[parent.data.order - 1] = {
+ link: parent.route,
+ heading: parent.label, // TODO title not populating
+ items: [],
+ };
+ }
+
+ if (parent.route !== item.route) {
+ sections[parent.data.order - 1].items[item.data.order - 1] = item;
+ }
+ });
+
+ this.innerHTML = `
+
+
+ `;
+ }
+}
+
+customElements.define("app-side-nav", SideNav);
diff --git a/src/components/side-nav/side-nav.module.css b/src/components/side-nav/side-nav.module.css
new file mode 100644
index 00000000..922e56d0
--- /dev/null
+++ b/src/components/side-nav/side-nav.module.css
@@ -0,0 +1,119 @@
+.fullMenu {
+ display: none;
+}
+
+.compactMenu,
+.fullMenu {
+ & a {
+ color: var(--color-black);
+ text-decoration: none;
+ }
+
+ & a:hover {
+ opacity: 0.7;
+ }
+}
+
+.compactMenuCloseButton {
+ background: transparent;
+ font-size: var(--font-size-5);
+ cursor: pointer;
+ border: none;
+ padding: 0 12px;
+ width: 100%;
+ text-align: right;
+}
+
+.compactMenuPopoverTrigger {
+ background-color: var(--color-white);
+ border: none;
+ padding: var(--size-2);
+ border-radius: var(--radius-2);
+ box-shadow: var(--shadow-3);
+ color: var(--color-black);
+
+ & #indicator {
+ display: inline-block;
+ }
+}
+
+.compactMenu:has(#compact-menu:popover-open) {
+ & #indicator {
+ animation: rotateindicatoropen 1s ease;
+ animation-fill-mode: forwards;
+ }
+}
+
+@keyframes rotateindicatoropen {
+ from {
+ transform: rotate(0deg);
+ }
+
+ to {
+ transform: rotate(180deg);
+ }
+}
+
+.compactMenuPopover {
+ top: 150px;
+ width: 100%;
+ padding: var(--size-4);
+ background-color: var(--color-gray);
+ overflow-y: scroll;
+ height: auto;
+}
+
+.compactMenuSectionHeading {
+ margin: 0 0 var(--size-2) 0;
+
+ & a {
+ font-family: var(--font-primary-bold);
+ }
+}
+
+.compactMenuSectionHeading {
+ & a.active,
+ & a:hover {
+ text-decoration: underline;
+ }
+
+ & a.active::before {
+ content: "\027A5";
+ }
+}
+
+.compactMenuSectionList {
+ list-style: none;
+ margin: 0 0 var(--size-6) 0;
+
+ & a:hover {
+ opacity: 0.7;
+ }
+}
+
+.compactMenuSectionListItemActive {
+ & a {
+ border-left: var(--size-1) solid black;
+ border-radius: var(--radius-2);
+ background-color: white;
+ padding: 0 var(--size-2);
+ min-width: 200px;
+ display: inline-block;
+ box-shadow: var(--shadow-3);
+ margin-left: -16px;
+ }
+}
+
+.compactMenuSectionListItem {
+ margin: var(--size-1);
+}
+
+@media (min-width: 1200px) {
+ .fullMenu {
+ display: block;
+ }
+
+ .compactMenu {
+ display: none;
+ }
+}
diff --git a/src/components/side-nav/side-nav.spec.js b/src/components/side-nav/side-nav.spec.js
new file mode 100644
index 00000000..6e39b72d
--- /dev/null
+++ b/src/components/side-nav/side-nav.spec.js
@@ -0,0 +1,177 @@
+import { expect } from "@esm-bundle/chai";
+import "./side-nav.js";
+import graph from "../../stories/mocks/graph.json" with { type: "json" };
+
+// https://stackoverflow.com/questions/45425169/intercept-fetch-api-requests-and-responses-in-javascript
+window.fetch = function () {
+ return new Promise((resolve) => {
+ resolve(new Response(JSON.stringify(graph)));
+ });
+};
+
+// attributes
+const ROUTE = "/guides/";
+const HEADING = "Guides";
+const CURRENT_ROUTE = "/guides/getting-started/key-concepts/";
+
+describe("Components/Side Nav", () => {
+ let nav;
+ let expectedGuidesContent = [];
+ let expectedHeadingsContent = [];
+ let expectedSectionsContent = [];
+
+ before(async () => {
+ expectedGuidesContent = graph.filter((page) => page.route.startsWith(ROUTE));
+ expectedHeadingsContent = expectedGuidesContent.filter(
+ (page) => page.route.split("/").filter((segment) => segment !== "").length === 2,
+ );
+ expectedSectionsContent = expectedGuidesContent.filter(
+ (page) => page.route.split("/").filter((segment) => segment !== "").length > 2,
+ );
+
+ nav = document.createElement("app-side-nav");
+ nav.setAttribute("route", ROUTE);
+ nav.setAttribute("heading", HEADING);
+ nav.setAttribute("current-route", CURRENT_ROUTE);
+
+ document.body.appendChild(nav);
+ await nav.updateComplete;
+ });
+
+ describe("Default Behavior - Full Menu", () => {
+ let fullMenu;
+
+ before(async () => {
+ fullMenu = nav.querySelector("[data-full]");
+ });
+
+ it("should not be null", () => {
+ expect(fullMenu).not.equal(undefined);
+ });
+
+ it("should have the expected number of section heading links", () => {
+ const links = fullMenu.querySelectorAll("[role='heading'] a");
+
+ expect(links.length).to.equal(expectedHeadingsContent.length);
+ });
+
+ it("should have the expected content for section headings links", () => {
+ const links = fullMenu.querySelectorAll("[role='heading'] a");
+
+ links.forEach((link) => {
+ const pages = expectedHeadingsContent.filter(
+ (page) => page.route === link.getAttribute("href"),
+ );
+
+ expect(pages.length).to.equal(1);
+ expect(pages[0].label).to.equal(link.textContent);
+ });
+ });
+
+ it("should have the expected number of section links", () => {
+ const links = fullMenu.querySelectorAll("li a");
+
+ expect(links.length).to.equal(expectedSectionsContent.length);
+ });
+
+ it("should have the expected content for section links", () => {
+ const links = fullMenu.querySelectorAll("li a");
+
+ links.forEach((link) => {
+ const pages = expectedSectionsContent.filter(
+ (page) => page.route === link.getAttribute("href"),
+ );
+
+ expect(pages.length).to.equal(1);
+ expect(pages[0].label).to.equal(link.textContent);
+ });
+ });
+
+ after(() => {
+ fullMenu = null;
+ });
+ });
+
+ describe("Default Behavior - Compact Menu", () => {
+ let compactMenu;
+ let popoverSelector = "compact-menu";
+
+ before(async () => {
+ compactMenu = nav.querySelector("[data-compact]");
+ });
+
+ it("should not be null", () => {
+ expect(compactMenu).not.equal(undefined);
+ });
+
+ it("should have the expected popover trigger element", () => {
+ const trigger = compactMenu.querySelectorAll(
+ `[popovertarget="${popoverSelector}"]:not([popovertargetaction])`,
+ );
+
+ expect(trigger.length).to.equal(1);
+ expect(trigger[0].textContent).to.contain(HEADING);
+ });
+
+ it("should have the expected popover element", () => {
+ const popover = compactMenu.querySelectorAll(`#${popoverSelector}[popover="manual"]`);
+
+ expect(popover.length).to.equal(1);
+ });
+
+ it("should have the expected popover close button", () => {
+ const closeButton = compactMenu.querySelectorAll(
+ `[popover="manual"] [popovertarget="${popoverSelector}"]`,
+ );
+
+ expect(closeButton.length).to.equal(1);
+ });
+
+ it("should have the expected number of section heading links", () => {
+ const links = compactMenu.querySelectorAll("[role='heading'] a");
+
+ expect(links.length).to.equal(expectedHeadingsContent.length);
+ });
+
+ it("should have the expected content for section headings links", () => {
+ const links = compactMenu.querySelectorAll("[role='heading'] a");
+
+ links.forEach((link) => {
+ const pages = expectedHeadingsContent.filter(
+ (page) => page.route === link.getAttribute("href"),
+ );
+
+ expect(pages.length).to.equal(1);
+ expect(pages[0].label).to.equal(link.textContent);
+ });
+ });
+
+ it("should have the expected number of section links", () => {
+ const links = compactMenu.querySelectorAll("li a");
+
+ expect(links.length).to.equal(expectedSectionsContent.length);
+ });
+
+ it("should have the expected content for section links", () => {
+ const links = compactMenu.querySelectorAll("li a");
+
+ links.forEach((link) => {
+ const pages = expectedSectionsContent.filter(
+ (page) => page.route === link.getAttribute("href"),
+ );
+
+ expect(pages.length).to.equal(1);
+ expect(pages[0].label).to.equal(link.textContent);
+ });
+ });
+
+ after(() => {
+ compactMenu = null;
+ });
+ });
+
+ after(() => {
+ nav.remove();
+ nav = null;
+ });
+});
diff --git a/src/components/side-nav/side-nav.stories.js b/src/components/side-nav/side-nav.stories.js
new file mode 100644
index 00000000..9c31f684
--- /dev/null
+++ b/src/components/side-nav/side-nav.stories.js
@@ -0,0 +1,25 @@
+import "./side-nav.js";
+import pages from "../../stories/mocks/graph.json";
+
+export default {
+ title: "Components/Side Nav",
+ parameters: {
+ fetchMock: {
+ mocks: [
+ {
+ matcher: {
+ url: "http://localhost:1985/graph.json",
+ response: {
+ body: pages,
+ },
+ },
+ },
+ ],
+ },
+ },
+};
+
+const Template = () =>
+ "";
+
+export const Primary = Template.bind({});
diff --git a/src/components/table-of-contents/table-of-contents.js b/src/components/table-of-contents/table-of-contents.js
new file mode 100644
index 00000000..a625eea2
--- /dev/null
+++ b/src/components/table-of-contents/table-of-contents.js
@@ -0,0 +1,59 @@
+import { getContent } from "@greenwood/cli/src/data/queries.js";
+import styles from "./table-of-contents.module.css";
+
+export default class TableOfContents extends HTMLElement {
+ async connectedCallback() {
+ const route = this.getAttribute("route") ?? "";
+ const page = (await getContent()).find((page) => page.route === route);
+ const { tableOfContents = [] } = page?.data ?? {};
+
+ if (tableOfContents.length === 0) {
+ return;
+ }
+
+ this.innerHTML = `
+
+
+ `;
+ }
+}
+
+customElements.define("app-toc", TableOfContents);
diff --git a/src/components/table-of-contents/table-of-contents.module.css b/src/components/table-of-contents/table-of-contents.module.css
new file mode 100644
index 00000000..0f42d6fc
--- /dev/null
+++ b/src/components/table-of-contents/table-of-contents.module.css
@@ -0,0 +1,120 @@
+.fullMenu {
+ display: none;
+
+ & h2 {
+ margin: 0 0 var(--size-2);
+ text-decoration: underline;
+ }
+}
+
+.compactMenu,
+.fullMenu {
+ & a {
+ color: var(--color-black);
+ text-decoration: none;
+ }
+
+ & ol {
+ list-style-type: lower-roman;
+ margin: var(--size-2) 0;
+ }
+}
+
+.compactMenuPopover {
+ top: 150px;
+ width: auto;
+ min-height: 150px;
+ margin: 0 var(--size-2);
+ border: 1px solid var(--color-gray);
+ box-shadow: var(--shadow-3);
+ padding: var(--size-2);
+}
+
+.compactMenuTrigger {
+ background-color: var(--color-white);
+ color: var(--color-black);
+ border: none;
+ padding: var(--size-2);
+ border-radius: var(--radius-2);
+ box-shadow: var(--shadow-3);
+
+ & #indicator {
+ display: inline-block;
+ }
+}
+
+.compactMenu:has(#onthispage:popover-open) {
+ & #indicator {
+ animation: rotateindicatoropen 1s ease;
+ animation-fill-mode: forwards;
+ }
+}
+
+@keyframes rotateindicatoropen {
+ from {
+ transform: rotate(0deg);
+ }
+
+ to {
+ transform: rotate(180deg);
+ }
+}
+
+.compactMenuItem {
+ margin: var(--size-2) var(--size-1) var(--size-2) var(--size-6);
+ padding: 0 var(--size-2);
+
+ & a {
+ color: var(--color-black);
+ }
+}
+
+.compactMenuHeading {
+ font-family: var(--font-primary-bold);
+ text-decoration: underline;
+}
+
+.compactMenuCloseButton {
+ background: transparent;
+ font-size: var(--font-size-5);
+ cursor: pointer;
+ border: none;
+ padding: 0 12px;
+ width: 100%;
+ text-align: right;
+}
+
+@media (min-width: 1200px) {
+ .fullMenu {
+ display: block;
+ border-left: 1px solid var(--color-gray);
+ border-top: 1px solid var(--color-gray);
+ border-bottom: 1px solid var(--color-gray);
+ box-shadow: var(--shadow-3);
+ padding: var(--size-2) 0 0 var(--size-7);
+
+ & a,
+ & li {
+ font-size: var(--font-size-1);
+ }
+
+ & a:hover {
+ opacity: 0.7;
+ }
+ }
+
+ .compactMenu {
+ display: none;
+ }
+}
+
+@media (min-width: 1440px) {
+ .fullMenu {
+ display: block;
+
+ & a,
+ & li {
+ font-size: var(--font-size-3);
+ }
+ }
+}
diff --git a/src/components/table-of-contents/table-of-contents.spec.js b/src/components/table-of-contents/table-of-contents.spec.js
new file mode 100644
index 00000000..f45729e3
--- /dev/null
+++ b/src/components/table-of-contents/table-of-contents.spec.js
@@ -0,0 +1,134 @@
+import { expect } from "@esm-bundle/chai";
+import "./table-of-contents.js";
+import graph from "../../stories/mocks/graph.json" with { type: "json" };
+
+// https://stackoverflow.com/questions/45425169/intercept-fetch-api-requests-and-responses-in-javascript
+window.fetch = function () {
+ return new Promise((resolve) => {
+ resolve(new Response(JSON.stringify(graph)));
+ });
+};
+
+// attributes
+const ROUTE = "/guides/getting-started/key-concepts/";
+
+describe("Components/Table of Contents", () => {
+ const HEADING = "On This Page";
+ let toc;
+ let expectedTocPage = [];
+ let expectedHeadingsContent = [];
+
+ before(async () => {
+ expectedTocPage = graph.find((page) => page.route === ROUTE);
+
+ toc = document.createElement("app-toc");
+ toc.setAttribute("route", ROUTE);
+
+ document.body.appendChild(toc);
+ await toc.updateComplete;
+ });
+
+ describe("Default Behavior - Full Menu", () => {
+ let fullMenu;
+
+ before(async () => {
+ fullMenu = toc.querySelector("[data-full]");
+ });
+
+ it("should not be null", () => {
+ expect(fullMenu).not.equal(undefined);
+ });
+
+ it("should have the expected heading text", () => {
+ const heading = fullMenu.querySelectorAll("[role='heading']");
+
+ expect(heading.length).to.equal(1);
+ expect(heading[0].textContent).to.equal(HEADING);
+ });
+
+ it("should have the expected number of ToC links in an ordered list", () => {
+ const links = fullMenu.querySelectorAll("ol li a");
+ const table = expectedTocPage.data.tableOfContents;
+
+ expect(links.length).to.equal(table.length);
+
+ links.forEach((link, i) => {
+ const tocItem = expectedTocPage.data.tableOfContents[i];
+ const { slug, content } = tocItem;
+
+ expect(link.getAttribute("href")).to.equal(`#${slug}`);
+ expect(link.textContent).to.equal(content);
+ });
+ });
+
+ after(() => {
+ fullMenu = null;
+ });
+ });
+
+ describe("Default Behavior - Compact Menu", () => {
+ let compactMenu;
+ let popoverSelector = "onthispage";
+
+ before(async () => {
+ compactMenu = toc.querySelector("[data-compact]");
+ });
+
+ it("should not be null", () => {
+ expect(compactMenu).not.equal(undefined);
+ });
+
+ it("should have the expected popover trigger element", () => {
+ const trigger = compactMenu.querySelectorAll(
+ `[popovertarget="${popoverSelector}"]:not([popovertargetaction])`,
+ );
+
+ expect(trigger.length).to.equal(1);
+ expect(trigger[0].textContent).to.contain(HEADING);
+ });
+
+ it("should have the expected popover element", () => {
+ const popover = compactMenu.querySelectorAll(`#${popoverSelector}[popover="manual"]`);
+
+ expect(popover.length).to.equal(1);
+ });
+
+ it("should have the expected popover close button", () => {
+ const closeButton = compactMenu.querySelectorAll(
+ `[popover="manual"] [popovertarget="${popoverSelector}"]`,
+ );
+
+ expect(closeButton.length).to.equal(1);
+ });
+
+ it("should have the expected number of section heading links", () => {
+ const links = compactMenu.querySelectorAll("[role='heading'] a");
+
+ expect(links.length).to.equal(expectedHeadingsContent.length);
+ });
+
+ it("should have the expected number of ToC links in an ordered list", () => {
+ const links = compactMenu.querySelectorAll("ol li a");
+ const table = expectedTocPage.data.tableOfContents;
+
+ expect(links.length).to.equal(table.length);
+
+ links.forEach((link, i) => {
+ const tocItem = expectedTocPage.data.tableOfContents[i];
+ const { slug, content } = tocItem;
+
+ expect(link.getAttribute("href")).to.equal(`#${slug}`);
+ expect(link.textContent).to.equal(content);
+ });
+ });
+
+ after(() => {
+ compactMenu = null;
+ });
+ });
+
+ after(() => {
+ toc.remove();
+ toc = null;
+ });
+});
diff --git a/src/components/table-of-contents/table-of-contents.stories.js b/src/components/table-of-contents/table-of-contents.stories.js
new file mode 100644
index 00000000..94f2c937
--- /dev/null
+++ b/src/components/table-of-contents/table-of-contents.stories.js
@@ -0,0 +1,24 @@
+import "./table-of-contents.js";
+import pages from "../../stories/mocks/graph.json";
+
+export default {
+ title: "Components/Table of Contents",
+ parameters: {
+ fetchMock: {
+ mocks: [
+ {
+ matcher: {
+ url: "http://localhost:1985/graph.json",
+ response: {
+ body: pages,
+ },
+ },
+ },
+ ],
+ },
+ },
+};
+
+const Template = () => "";
+
+export const Primary = Template.bind({});
diff --git a/src/layouts/guides.html b/src/layouts/guides.html
new file mode 100644
index 00000000..9e8f502a
--- /dev/null
+++ b/src/layouts/guides.html
@@ -0,0 +1,166 @@
+
+
+ Greenwood - ${globalThis.page.title}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/pages/guides/ecosystem/htmx.md b/src/pages/guides/ecosystem/htmx.md
new file mode 100644
index 00000000..46a5bf93
--- /dev/null
+++ b/src/pages/guides/ecosystem/htmx.md
@@ -0,0 +1,70 @@
+---
+title: htmx
+label: htmx
+layout: guides
+order: 2
+tocHeading: 2
+---
+
+# htmx
+
+[**htmx**](https://htmx.org/) is a JavaScript library for enhancing existing HTML elements with ["hypermedia controls"](https://htmx.org/essays/hypermedia-clients/), which effectively allows any element to make requests (like a `