diff --git a/package-lock.json b/package-lock.json index 87bdf627..ef210c32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3780,6 +3780,20 @@ "@lit-labs/ssr-dom-shim": "^1.0.0" } }, + "node_modules/@material/web": { + "version": "1.0.0-pre.1", + "resolved": "https://registry.npmjs.org/@material/web/-/web-1.0.0-pre.1.tgz", + "integrity": "sha512-wDeQvRPBO/JY5l8K8Fgvkdfnj42uhCJbxn7a3IlQadqoBFoignMogfDrwfTxtV6HHOELktYlHkXgCN8RxgtrIQ==", + "dependencies": { + "lit": "^2.3.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@material/web/node_modules/tslib": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz", + "integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==" + }, "node_modules/@mrmlnc/readdir-enhanced": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz", @@ -23814,6 +23828,7 @@ "license": "Apache-2.0", "dependencies": { "@lit-labs/task": "^2.0.0", + "@material/web": "^1.0.0-pre.1", "@webcomponents/catalog-api": "^0.0.0", "lit": "^2.6.0", "lit-analyzer": "^1.2.1" @@ -26804,6 +26819,22 @@ "@lit-labs/ssr-dom-shim": "^1.0.0" } }, + "@material/web": { + "version": "1.0.0-pre.1", + "resolved": "https://registry.npmjs.org/@material/web/-/web-1.0.0-pre.1.tgz", + "integrity": "sha512-wDeQvRPBO/JY5l8K8Fgvkdfnj42uhCJbxn7a3IlQadqoBFoignMogfDrwfTxtV6HHOELktYlHkXgCN8RxgtrIQ==", + "requires": { + "lit": "^2.3.0", + "tslib": "^2.4.0" + }, + "dependencies": { + "tslib": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.4.1.tgz", + "integrity": "sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA==" + } + } + }, "@mrmlnc/readdir-enhanced": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/@mrmlnc/readdir-enhanced/-/readdir-enhanced-2.2.1.tgz", @@ -27671,6 +27702,7 @@ "version": "file:packages/site-client", "requires": { "@lit-labs/task": "^2.0.0", + "@material/web": "*", "@webcomponents/catalog-api": "^0.0.0", "lit": "^2.6.0", "lit-analyzer": "^1.2.1" @@ -27696,7 +27728,7 @@ "@types/koa": "^2.13.5", "@types/koa__cors": "^3.3.0", "@types/koa__router": "^12.0.0", - "@types/koa-bodyparser": "*", + "@types/koa-bodyparser": "^4.3.10", "@types/koa-conditional-get": "^2.0.0", "@types/koa-etag": "^3.0.0", "@types/koa-static": "^4.0.2", diff --git a/packages/site-client/package.json b/packages/site-client/package.json index f9df9217..77bafd21 100644 --- a/packages/site-client/package.json +++ b/packages/site-client/package.json @@ -87,6 +87,7 @@ }, "dependencies": { "@lit-labs/task": "^2.0.0", + "@material/web": "^1.0.0-pre.1", "@webcomponents/catalog-api": "^0.0.0", "lit": "^2.6.0", "lit-analyzer": "^1.2.1" diff --git a/packages/site-client/src/pages/catalog/wco-catalog-search.ts b/packages/site-client/src/pages/catalog/wco-catalog-search.ts index c44a63f8..35978da3 100644 --- a/packages/site-client/src/pages/catalog/wco-catalog-search.ts +++ b/packages/site-client/src/pages/catalog/wco-catalog-search.ts @@ -6,6 +6,12 @@ import {html, css, LitElement} from 'lit'; import {customElement, query, state} from 'lit/decorators.js'; + +import '@material/web/textfield/outlined-text-field.js'; +import type {MdOutlinedTextField} from '@material/web/textfield/outlined-text-field.js'; + +import '@material/web/icon/icon.js'; + import type {CustomElement} from '@webcomponents/catalog-api/lib/schema.js'; import './wco-element-card.js'; @@ -27,17 +33,30 @@ export class WCOCatalogSearch extends LitElement { grid-auto-rows: 200px; gap: 8px; } + + md-outlined-text-field { + width: 40em; + --md-outlined-field-container-shape-start-start: 28px; + --md-outlined-field-container-shape-start-end: 28px; + --md-outlined-field-container-shape-end-start: 28px; + --md-outlined-field-container-shape-end-end: 28px; + } `; - @query('input') - private _search!: HTMLInputElement; + @query('#search') + private _search!: MdOutlinedTextField; @state() private _elements: Array | undefined; render() { return html` -

Search:

+
+

Web Components.org Catalog

+ search +
${this._elements?.map( (e) => html`` diff --git a/packages/site-server/src/lib/catalog/routes/catalog-page.ts b/packages/site-server/src/lib/catalog/routes/catalog-page.ts index c89ecd4b..7624f7dc 100644 --- a/packages/site-server/src/lib/catalog/routes/catalog-page.ts +++ b/packages/site-server/src/lib/catalog/routes/catalog-page.ts @@ -11,8 +11,105 @@ import {html} from 'lit'; import {Readable} from 'stream'; import Router from '@koa/router'; +import {LitElementRenderer} from '@lit-labs/ssr/lib/lit-element-renderer.js'; +import {ElementRenderer} from '@lit-labs/ssr/lib/element-renderer.js'; + import '@webcomponents/internal-site-client/lib/pages/catalog/wco-catalog-page.js'; +type Interface = { + [P in keyof T]: T[P]; +}; + +// TODO (justinfagnani): Update Lit SSR to use this type for +// ElementRendererConstructor +export type ElementRendererConstructor = (new ( + tagName: string +) => Interface) & + typeof ElementRenderer; + +// Excludes the given tag names from being handled by the given renderer. +// Returns a subclass of the renderer that returns `false` for matches() +// for any element in the list of tag names. +const excludeElements = ( + renderer: ElementRendererConstructor, + excludedTagNames: Array +) => { + return class ExcludeElementRenderer extends renderer { + static matchesClass( + ceClass: typeof HTMLElement, + tagName: string, + attributes: Map + ) { + console.log('matchesClass', tagName, !excludedTagNames.includes(tagName)); + return excludedTagNames.includes(tagName) + ? false + : super.matchesClass(ceClass, tagName, attributes); + } + }; +}; + +const replacements = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + // Note ' was not defined in the HTML4 spec, and is not supported by very + // old browsers like IE8, so a codepoint entity is used instead. + "'": ''', +}; + +/** + * Replaces characters which have special meaning in HTML (&<>"') with escaped + * HTML entities ("&", "<", etc.). + */ +const escapeHtml = (str: string) => + str.replace( + /[&<>"']/g, + (char) => replacements[char as keyof typeof replacements] + ); + +// The built-in FallbackRenderer incorrectly causes a +// shadow root to be rendered, which breaks hydration +class FallbackRenderer extends ElementRenderer { + static matchesClass( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _ceClass: typeof HTMLElement, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _tagName: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _attributes: Map + ) { + return true; + } + private readonly _attributes: {[name: string]: string} = {}; + + override setAttribute(name: string, value: string) { + this._attributes[name] = value; + } + + override *renderAttributes() { + for (const [name, value] of Object.entries(this._attributes)) { + if (value === '' || value === undefined || value === null) { + yield ` ${name}`; + } else { + yield ` ${name}="${escapeHtml(value)}"`; + } + } + } + + connectedCallback() { + // do nothing + } + attributeChangedCallback() { + // do nothing + } + *renderLight() { + // do nothing + } + + declare renderShadow: ElementRenderer['renderShadow']; +} + export const handleCatalogRoute = async ( context: ParameterizedContext< DefaultState, @@ -25,11 +122,19 @@ export const handleCatalogRoute = async ( globalThis.location = new URL(context.URL.href) as unknown as Location; context.body = Readable.from( - renderPage({ - title: `Web Components Catalog`, - initScript: '/js/catalog/boot.js', - content: html``, - }) + renderPage( + { + title: `Web Components Catalog`, + initScript: '/js/catalog/boot.js', + content: html``, + }, + { + elementRenderers: [ + excludeElements(LitElementRenderer, ['md-outlined-text-field']), + FallbackRenderer, + ], + } + ) ); context.type = 'html'; context.status = 200; diff --git a/packages/site-server/src/lib/dev-server.ts b/packages/site-server/src/lib/dev-server.ts index 41ab6466..b67d0ec3 100644 --- a/packages/site-server/src/lib/dev-server.ts +++ b/packages/site-server/src/lib/dev-server.ts @@ -29,6 +29,8 @@ startDevServer({ router.routes() as Middleware, ], watch: true, - nodeResolve: true, + nodeResolve: { + exportConditions: ['development'], + }, }, }); diff --git a/packages/site-server/src/test/catalog/search_test.ts b/packages/site-server/src/test/catalog/search_test.ts index acdc6298..e0fe2c58 100644 --- a/packages/site-server/src/test/catalog/search_test.ts +++ b/packages/site-server/src/test/catalog/search_test.ts @@ -34,4 +34,13 @@ test('Finds a button', async () => { assert.ok(result.elements.length > 2); }); +test('Search page SSRs', async () => { + const response = await request('/catalog'); + assert.equal(response.status, 200); + const result = await response.text(); + + // If the page SSR's, we'll have declarative shadow roots in it. + assert.match(result, '