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 += ``; ++ } ++ }); ++ ++ // 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 = () => ` + + + + + +`; + +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 = ` +
    + ${sections + .map((section) => { + const { heading, items, link } = section; + const isActiveHeading = currentRoute.startsWith(link) ? "active" : ""; + + return ` +

    + ${heading} +

    +
      + ${items + .map((item) => { + const { label, route } = item; + const itemClass = + route === currentRoute + ? styles.compactMenuSectionListItemActive + : styles.compactMenuSectionListItem; + + return ` +
    • + ${label} +
    • + `; + }) + .join("")} +
    + `; + }) + .join("")} +
    +
    + +
    + + ${sections + .map((section) => { + const { heading, items, link } = section; + const isActiveHeading = currentRoute.startsWith(link) ? "active" : ""; + + return ` +

    + ${heading} +

    +
      + ${items + .map((item) => { + const { label, route } = item; + const itemClass = + route === currentRoute + ? styles.compactMenuSectionListItemActive + : styles.compactMenuSectionListItem; + + return ` +
    • + ${label} +
    • + `; + }) + .join("")} +
    + `; + }) + .join("")} +
    +
    + `; + } +} + +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 = ` +
    +

    On This Page

    +
      + ${tableOfContents + .map((item) => { + const { content, slug } = item; + + return ` +
    1. + ${content} +
    2. + `; + }) + .join("")} +
    +
    +
    + +
    + +
      + ${tableOfContents + .map((item) => { + const { content, slug } = item; + + return ` +
    1. + ${content} +
    2. + `; + }) + .join("")} +
    +
    +
    + `; + } +} + +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 `
    ` or `` natively can) and update the page with HTML as needed. + +> You can see a complete hybrid project example in this [demonstration repo](https://github.com/thescientist13/greenwood-htmx/). + +## Installation + +As with most libraries, just install **htmx.org** as a dependency using your favorite package manager: + +```shell +npm i htmx.org +``` + +## Example + +As a basic example, let's create a `` in the client side that can send a request to an API route as `FormData`, which sends an HTML response back. + +### Frontend + +First we'll create our frontend including htmx in a ` + + + + + + + + +

    + + +``` + +### Backend + +Now let's add our API endpoint: + +```js +// src/pages/api/greeting.js +export async function handler(request) { + const formData = await request.formData(); + const name = formData.has("name") ? formData.get("name") : "Greenwood"; + const body = `Hello ${name}! 👋`; + + return new Response(body, { + headers: new Headers({ + "Content-Type": "text/html", + }), + }); +} +``` + +Now when the form is submitted, htmx will make a request to our backend API and output the returned HTML to the page. 🎯 diff --git a/src/pages/guides/ecosystem/index.md b/src/pages/guides/ecosystem/index.md new file mode 100644 index 00000000..9bca7b8b --- /dev/null +++ b/src/pages/guides/ecosystem/index.md @@ -0,0 +1,54 @@ +--- +order: 3 +layout: guides +--- + + +

    This section of our Guides content will cover examples of using libraries and tools that are based on, or work well with developing for web standards, and in particular Web Components. As is Greenwood's philosophy, we want stay out of your way, and as long as the tool embraces web standards, it should just work out of the box.

    +
    + +In most cases an `npm install` should be all you need to use any third party library and then include it in a ` + + + +``` + +Or to use something CSS based like [**Open Props**](https://open-props.style), simply install it from **npm** and reference the CSS file through a `` tag. Easy! + +```shell +npm i open-props +``` + +```html + + + + + + + + + +

    Welcome to my website!

    + + +``` diff --git a/src/pages/guides/ecosystem/lit.md b/src/pages/guides/ecosystem/lit.md new file mode 100644 index 00000000..94608c36 --- /dev/null +++ b/src/pages/guides/ecosystem/lit.md @@ -0,0 +1,75 @@ +--- +title: Lit +layout: guides +order: 1 +tocHeading: 2 +--- + +# Lit + +[**Lit**](https://lit.dev/) builds on top of the Web Components standards, adding additional developer experience ergonomics like reactivity, declarative templates and reducing boilerplate. Lit also has support for SSR (server-side rendering), which Greenwood supports through a plugin. + +> You can see a complete hybrid project example in this [demonstration repo](https://github.com/thescientist13/greenwood-lit-ssr). + +## Installation + +As with most libraries, just install **lit** with your favorite package manager as a dependency. + +```shell +npm i lit +``` + +Now you can start writing Lit based Web Components! + +```html + + + + + + + + + + +``` + +That's it! + +## SSR + +To enable [Lit and SSR](https://lit.dev/docs/ssr/overview/) you can install our Greenwood [plugin](https://github.com/ProjectEvergreen/greenwood/tree/master/packages/plugin-renderer-lit) and add it to your _greenwood.config.js_. + +```js +import { greenwoodPluginRendererLit } from "@greenwood/plugin-renderer-lit"; + +export default { + plugins: [greenwoodPluginRendererLit()], +}; +``` + +> Please see the [README](https://github.com/ProjectEvergreen/greenwood/blob/master/packages/plugin-renderer-lit/README.md) to learn more about full usage details and caveats. diff --git a/src/pages/guides/ecosystem/storybook.md b/src/pages/guides/ecosystem/storybook.md new file mode 100644 index 00000000..4221314d --- /dev/null +++ b/src/pages/guides/ecosystem/storybook.md @@ -0,0 +1,258 @@ +--- +layout: guides +order: 4 +tocHeading: 2 +--- + +# Storybook + +[**Storybook**](https://storybook.js.org/) is a developer tool for authoring components in isolation with interactive demonstrations and documentation. This guide will give a high level overview of setting up Storybook and integrating with any Greenwood specific features. + +> You can see an example (this website's own repo!) [here](https://github.com/ProjectEvergreen/www.greenwoodjs.dev). + +## Setup + +We recommend using the [Storybook CLI](https://storybook.js.org/docs/get-started/instal) to setup a project from scratch: + +```shell +npx storybook@latest init +``` + +As part of the prompts, we suggest the following answers to project type (**web_components**) and builder (**Vite**): + +```shell +✔ Do you want to manually choose a Storybook project type to install? … yes +? Please choose a project type from the following list: › - Use arrow-keys. Return to submit. + ↑ webpack_react + nextjs + vue3 + angular + ember +❯ web_components + html + qwik + preact + ↓ svelte + +We were not able to detect the right builder for your project. Please select one: › - Use arrow-keys. Return to submit. +❯ Vite + Webpack 5 +``` + +## Usage + +You should now be good to start writing your first story! 📚 + +```js +// src/components/footer/footer.js +export default class Footer extends HTMLElement { + connectedCallback() { + this.innerHTML = ` +
    +

    Greenwood

    + +
    + `; + } +} + +customElements.define("app-footer", Footer); +``` + +```js +// src/components/footer/footer.stories.js +import "./footer.js"; + +export default { + title: "Components/Footer", +}; + +const Template = () => ""; + +export const Primary = Template.bind({}); +``` + +## Static Assets + +To help with resolving any static assets used in your stories, you can configure [`staticDirs`](https://storybook.js.org/docs/api/main-config/main-config-static-dirs) to point to your Greenwood workspace. + +```js +const config = { + //... + + staticDirs: ["../src"], +}; + +export default config; +``` + +## Import Attributes + +As [Vite does not support Import Attributes](https://github.com/vitejs/vite/issues/14674), we will need to create a _vite.config.js_ and write a [custom plugin](https://vitejs.dev/guide/api-plugin) to work around this. + +In this example we are handling for CSS Module scripts: + +```js +import { defineConfig } from "vite"; +import fs from "fs/promises"; +import path from "path"; +// 1) import the greenwood plugin and lifecycle helpers +import { greenwoodPluginStandardCss } from "@greenwood/cli/src/plugins/resource/plugin-standard-css.js"; +import { readAndMergeConfig } from "@greenwood/cli/src/lifecycles/config.js"; +import { initContext } from "@greenwood/cli/src/lifecycles/context.js"; + +// 2) initialize Greenwood lifecycles +const config = await readAndMergeConfig(); +const context = await initContext({ config }); +const compilation = { context, config }; + +// 3) initialize the plugin +const standardCssResource = greenwoodPluginStandardCss.provider(compilation); + +// 4) customize Vite +function transformConstructableStylesheetsPlugin() { + return { + name: "transform-constructable-stylesheets", + enforce: "pre", + resolveId: (id, importer) => { + if ( + // you'll need to configure this `importer` line to the location of your own components + importer?.indexOf("/src/components/") >= 0 && + id.endsWith(".css") && + !id.endsWith(".module.css") + ) { + // add .type so Constructable Stylesheets are not precessed by Vite's default pipeline + return path.join(path.dirname(importer), `${id}.type`); + } + }, + load: async (id) => { + if (id.endsWith(".css.type")) { + const filename = id.slice(0, -5); + const contents = await fs.readFile(filename, "utf-8"); + const url = new URL(`file://${id.replace(".type", "")}`); + // "coerce" native constructable stylesheets into inline JS so Vite / Rollup do not complain + const request = new Request(url, { + headers: { + Accept: "text/javascript", + }, + }); + const response = await standardCssResource.intercept(url, request, new Response(contents)); + const body = await response.text(); + + return body; + } + }, + }; +} + +export default defineConfig({ + // 5) add it the plugins option + plugins: [transformConstructableStylesheetsPlugin()], +}); +``` + +Phew, should be all set now. + +## Resource Plugins + +If you're using one of Greenwood's [resource plugins](/docs/plugins/), you'll need a _vite.config.js_ so we can create a custom transformation plugin that can leverage Greenwood's plugins to automatically handle custom transformations. + +For example, if you're using Greenwood's [Raw Plugin](https://github.com/ProjectEvergreen/greenwood/tree/master/packages/plugin-import-raw), you'll need to add a Vite plugin to handle this transformation. + +```js +import { defineConfig } from "vite"; +import fs from "fs/promises"; +import path from "path"; +import { greenwoodPluginStandardCss } from "@greenwood/cli/src/plugins/resource/plugin-standard-css.js"; +// 1) import the greenwood plugin and lifecycle helpers +import { greenwoodPluginImportRaw } from "@greenwood/plugin-import-raw"; +import { readAndMergeConfig } from "@greenwood/cli/src/lifecycles/config.js"; +import { initContext } from "@greenwood/cli/src/lifecycles/context.js"; + +// 2) initialize Greenwood lifecycles +const config = await readAndMergeConfig(); +const context = await initContext({ config }); +const compilation = { context, config }; + +// 3) initialize the plugin +const rawResource = greenwoodPluginImportRaw()[0].provider(compilation); + +// 4) customize Vite +function transformRawImports() { + return { + name: "transform-raw-imports", + enforce: "pre", + load: async (id) => { + if (id.endsWith("?type=raw")) { + const filename = id.slice(0, -9); + const contents = await fs.readFile(filename, "utf-8"); + const response = await rawResource.intercept(null, null, new Response(contents)); + const body = await response.text(); + + return body; + } + }, + }; +} + +export default defineConfig({ + // 5) add it the plugins option + plugins: [transformRawImports()], +}); +``` + +## Content as Data + +If you are using any of Greenwood's [content as data](/docs/content-as-data/) features, you'll want to configure Storybook for mocking `fetch` calls in your stories. + +1. First, install the [**storybook-addon-fetch-mock**](https://storybook.js.org/addons/storybook-addon-fetch-mock) addon + ```shell + $ npm i -D storybook-addon-fetch-mock + ``` +1. Then add it to your _.storybook/main.js_ configuration file as an **addon** + + ```js + const config = { + // ... + + addons: [ + // your plugins here... + "storybook-addon-fetch-mock", + ], + }; + + export default config; + ``` + +1. Then in your story files, configure your Story to return mock data + + ```js + import "./blog-posts-list.js"; + import pages from "../../stories/mocks/graph.json"; + + export default { + // ... + + // configure fetchMock + parameters: { + fetchMock: { + mocks: [ + { + matcher: { + url: "http://localhost:1985/graph.json", + response: { + body: pages, + }, + }, + }, + ], + }, + }, + }; + + const Template = () => ""; + + export const Primary = Template.bind({}); + ``` + +> To quickly get a "mock" graph to use in your stories, you can run `greenwood build` and copy the _graph.json_ file from the build output directory. diff --git a/src/pages/guides/ecosystem/tailwind.md b/src/pages/guides/ecosystem/tailwind.md new file mode 100644 index 00000000..7827545f --- /dev/null +++ b/src/pages/guides/ecosystem/tailwind.md @@ -0,0 +1,105 @@ +--- +layout: guides +order: 3 +tocHeading: 2 +--- + +# Tailwind + +[**Tailwind**](https://tailwindcss.com/) is a CSS utility library providing all the modern features and capabilities of CSS in a compact, composable, and efficient way. + +> You can see an example in this [demonstration repo](https://github.com/AnalogStudiosRI/www.tuesdaystunes.tv). + +## Installation + +As Tailwind is a PostCSS plugin, you'll need to take a couple of extra steps to get things setup for the first time, but for the most part you can just follow the steps listed on the [Tailwind docs](https://tailwindcss.com/docs/installation/using-postcss). + +1. Let's install Tailwind and needed dependencies into our project, including Greenwood's [PostCSS plugin](https://github.com/ProjectEvergreen/greenwood/tree/master/packages/plugin-postcss) + + ```shell + npm install -D @greenwood/plugin-postcss tailwindcss autoprefixer + ``` + +1. Now run the Tailwind CLI to initialize our project with Tailwind + + ```shell + npx tailwindcss init + ``` + +1. Create _**two**_ PostCSS configuration files (two files are needed in Greenwood to support ESM / CJS interop) + + ```js + // postcss.config.cjs (CJS) + module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, + }; + + // postcss.config.mjs (ESM) + export default { + plugins: [(await import("tailwindcss")).default, (await import("autoprefixer")).default], + }; + ``` + +1. Create a _tailwind.config.js_ file and configure accordingly for your project + + ```js + /** @type {import('tailwindcss').Config} */ + export default { + content: ["./src/**/*.{html,js}"], + theme: {}, + plugins: [], + }; + ``` + +1. Add the PostCSS plugin to your _greenwood.config.js_ + + ```js + import { greenwoodPluginPostCss } from "@greenwood/plugin-postcss"; + + export default { + plugins: [greenwoodPluginPostCss()], + }; + ``` + +## Usage + +1. Now you'll want to create an "entry" point CSS file to include the initial Tailwind `@import`s. + + ```css + /* src/styles/main.css */ + @tailwind base; + @tailwind components; + @tailwind utilities; + ``` + +1. And include that in your layouts or pages + + ```html + + + + + + + + + + ``` + +Now you're ready to start using Tailwind! 🎯 + +```html + + + + + + + +

    Welcome to my website!

    + + +``` diff --git a/src/pages/guides/ecosystem/web-test-runner.md b/src/pages/guides/ecosystem/web-test-runner.md new file mode 100644 index 00000000..f480e675 --- /dev/null +++ b/src/pages/guides/ecosystem/web-test-runner.md @@ -0,0 +1,245 @@ +--- +layout: guides +order: 5 +tocHeading: 2 +--- + +# Web Test Runner + +[**Web Test Runner**](https://modern-web.dev/docs/test-runner/overview/) is a developer tool created by the [Modern Web](https://modern-web.dev/) team that helps with facilitating the testing Web Components, especially being able to test them in a real browser. This guide will give a high level over of setting up WTR and integrating with any Greenwood specific capabilities. + +> You can see an example project (this website's own repo!) [here](https://github.com/ProjectEvergreen/www.greenwoodjs.dev). + +## Setup + +For the sake of this guide, we will be covering a minimal setup but you are free to extends things as much as you need. + +1. First, let's install WTR and the JUnit Reporter. You can use your favorite package manager + + ```shell + npm i -D @web/test-runner @web/test-runner-junit-reporter + ``` + +1. You'll also want something like [**chai**](https://www.chaijs.com/) to write your assertions with + + ```shell + npm i -D @esm-bundle/chai + ``` + +1. Next, create a basic _web-test-runner.config.js_ configuration file + + ```js + import path from "path"; + import { defaultReporter } from "@web/test-runner"; + import { junitReporter } from "@web/test-runner-junit-reporter"; + + export default { + // customize your spec pattern here + files: "./src/**/*.spec.js", + // enable this if you're using npm / node_modules + nodeResolve: true, + // optionally configure reporters and coverage + reporters: [ + defaultReporter({ reportTestResults: true, reportTestProgress: true }), + junitReporter({ + outputPath: "./reports/test-results.xml", + }), + ], + coverage: true, + coverageConfig: { + reportDir: "./reports", + }, + }; + ``` + +## Usage + +With everything install and configured, you should now be good to start writing your tests! 🏆 + +```js +// src/components/footer/footer.js +export default class Footer extends HTMLElement { + connectedCallback() { + this.innerHTML = ` +
    +

    Greenwood

    + +
    + `; + } +} + +customElements.define("app-footer", Footer); +``` + +```js +// src/components/footer/footer.spec.js +describe("Components/Footer", () => { + let footer; + + before(async () => { + footer = document.createElement("app-footer"); + document.body.appendChild(footer); + + await footer.updateComplete; + }); + + describe("Default Behavior", () => { + it("should not be null", () => { + expect(footer).not.equal(undefined); + expect(footer.querySelectorAll("footer").length).equal(1); + }); + + it("should have the expected heading", () => { + const header = footer.querySelectorAll("footer .heading"); + + expect(header.length).equal(1); + expect(header[0].textContent).to.equal("Greenwood"); + }); + + it("should have the expected logo image", () => { + const logo = footer.querySelectorAll("footer img[src]"); + + expect(logo.length).equal(1); + expect(logo[0]).not.equal(undefined); + }); + }); + + after(() => { + footer.remove(); + footer = null; + }); +}); +``` + +## Static Assets + +If you are seeing logging about static assets returning 404 + +```shell + 🚧 404 network requests: + - assets/my-image.png +``` + +You can create a custom middleware in your _web-test-runner.config.js_ to resolve these requests to your local workspace: + +```js +import path from "path"; +import { defaultReporter } from "@web/test-runner"; +import { junitReporter } from "@web/test-runner-junit-reporter"; + +export default { + // ... + + middleware: [ + function resolveAssets(context, next) { + const { url } = context.request; + + if (url.startsWith("/assets")) { + context.request.url = path.join(process.cwd(), "src", url); + } + + return next(); + }, + ], +}; +``` + +## Resource Plugins + +If you're using one of Greenwood's [resource plugins](/docs/plugins/), you'll need to customize WTR manually through [its plugins option](https://modern-web.dev/docs/test-runner/plugins/) so it can leverage the Greenwood plugins your using to automatically to handle these custom transformations. + +For example, if you're using Greenwood's [Raw Plugin](https://github.com/ProjectEvergreen/greenwood/tree/master/packages/plugin-import-raw), you'll need to add a plugin transformation and stub out the signature. + +```js +import path from "path"; +import fs from "fs/promises"; +import { defaultReporter } from "@web/test-runner"; +import { junitReporter } from "@web/test-runner-junit-reporter"; +// 1) import the greenwood plugin and lifecycle helpers +import { greenwoodPluginImportRaw } from "@greenwood/plugin-import-raw"; +import { readAndMergeConfig } from "@greenwood/cli/src/lifecycles/config.js"; +import { initContext } from "@greenwood/cli/src/lifecycles/context.js"; + +// 2) initialize Greenwood lifecycles +const config = await readAndMergeConfig(); +const context = await initContext({ config }); +const compilation = { context, config }; + +// 3) initialize the plugin +const rawResource = greenwoodPluginImportRaw()[0].provider(compilation); + +export default { + // ... + + // 4) add it the plugins option + plugins: [ + { + name: "import-raw", + async transform(context) { + const { url } = context.request; + + if (url.endsWith("?type=raw")) { + const contents = await fs.readFile(new URL(`.${url}`, import.meta.url), "utf-8"); + const response = await rawResourcePlugin.intercept(null, null, new Response(contents)); + const body = await response.text(); + + return { + body, + headers: { "Content-Type": "application/javascript" }, + }; + } + }, + }, + ], +}; +``` + +## Content as Data + +If you are using any of Greenwood's [content as data](/docs/content-as-data/) features, you'll want to have your tests handle mocking of `fetch` calls. This can be done with a simple override of `window.fetch` + +```js +import { expect } from "@esm-bundle/chai"; +import graph from "../../stories/mocks/graph.json" with { type: "json" }; +import "./blog-posts-list.js"; + +// override fetch to return a promise that resolves to our mock data +window.fetch = function () { + return new Promise((resolve) => { + resolve(new Response(JSON.stringify(graph))); + }); +}; + +// now we can test components as normal +describe("Components/Blog Posts List", () => { + let list; + + before(async () => { + 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); + }); + + it("should render list items for all our blog posts", () => { + expect(list.querySelectorAll("ul").length).to.be.equal(1); + expect(list.querySelectorAll("ul li").length).to.be.greaterThan(1); + }); + + // ... + }); + + after(() => { + list.remove(); + list = null; + }); +}); +``` + +> To quickly get a "mock" graph to use in your stories, you can run `greenwood build` and copy the _graph.json_ file from the build output directory. diff --git a/src/pages/guides/getting-started/going-further.md b/src/pages/guides/getting-started/going-further.md new file mode 100644 index 00000000..7ff49cd8 --- /dev/null +++ b/src/pages/guides/getting-started/going-further.md @@ -0,0 +1,202 @@ +--- +order: 3 +layout: guides +tocHeading: 2 +--- + +# Going Further + +Now that we've had a chance to introduce some of the [basics](/guides/getting-started/key-concepts/) of Greenwood and having [walked through](/guides/getting-started/walkthrough/) putting together a basic site, let's take a moment showcase some of the additional capabilities and use cases you can leverage in Greenwood. + +## Prerendering + +A fair observation from the walkthrough might be that the header and footer are really just producing static content. While the footer calculates a year, that really only needs to be done once at build time. Since Greenwood can easily server render Web Components leaning on [DOM based hydration techniques](https://web.dev/articles/declarative-shadow-dom#component_hydration), we can make a couple useful optimizations here. + +First, we can enable the [`prerender`](/docs/config/#prerender) flag in a _greenwood.config.js_ file which will do a one-time server render for any custom element tags in our HTML. + +```js +export default { + prerender: true, +}; +``` + +Now if we look in the HTML output for any of our pages, we will see pre-rendered HTML for the footer inside a `