diff --git a/packages/cli/src/lib/api-route-worker.js b/packages/cli/src/lib/api-route-worker.js
index 44a289b87..2388d057e 100644
--- a/packages/cli/src/lib/api-route-worker.js
+++ b/packages/cli/src/lib/api-route-worker.js
@@ -1,33 +1,6 @@
// https://github.com/nodejs/modules/issues/307#issuecomment-858729422
import { parentPort } from "node:worker_threads";
-import { transformKoaRequestIntoStandardRequest } from "./resource-utils.js";
-
-// based on https://stackoverflow.com/questions/57447685/how-can-i-convert-a-request-object-into-a-stringifiable-object-in-javascript
-async function responseAsObject(response) {
- if (!(response instanceof Response)) {
- throw Object.assign(new Error(), {
- name: "TypeError",
- message: "Argument must be a Response object",
- });
- }
- response = response.clone();
-
- function stringifiableObject(obj) {
- const filtered = {};
- for (const key in obj) {
- if (["boolean", "number", "string"].includes(typeof obj[key]) || obj[key] === null) {
- filtered[key] = obj[key];
- }
- }
- return filtered;
- }
-
- return {
- ...stringifiableObject(response),
- headers: Object.fromEntries(response.headers),
- body: await response.text(),
- };
-}
+import { transformKoaRequestIntoStandardRequest, responseAsObject } from "./resource-utils.js";
async function executeRouteModule({ href, request, params }) {
const { body, headers = {}, method, url } = request;
diff --git a/packages/cli/src/lib/resource-utils.js b/packages/cli/src/lib/resource-utils.js
index dee4dc1f7..950f7a5e7 100644
--- a/packages/cli/src/lib/resource-utils.js
+++ b/packages/cli/src/lib/resource-utils.js
@@ -254,6 +254,16 @@ function transformKoaRequestIntoStandardRequest(url, request) {
});
}
+function stringifiableObject(obj) {
+ const filtered = {};
+ for (const key in obj) {
+ if (["boolean", "number", "string"].includes(typeof obj[key]) || obj[key] === null) {
+ filtered[key] = obj[key];
+ }
+ }
+ return filtered;
+}
+
// https://stackoverflow.com/questions/57447685/how-can-i-convert-a-request-object-into-a-stringifiable-object-in-javascript
async function requestAsObject(_request) {
if (!(_request instanceof Request)) {
@@ -268,16 +278,6 @@ async function requestAsObject(_request) {
let headers = Object.fromEntries(request.headers);
let format;
- function stringifiableObject(obj) {
- const filtered = {};
- for (const key in obj) {
- if (["boolean", "number", "string"].includes(typeof obj[key]) || obj[key] === null) {
- filtered[key] = obj[key];
- }
- }
- return filtered;
- }
-
if (contentType.includes("application/x-www-form-urlencoded")) {
const formData = await request.formData();
const params = {};
@@ -305,12 +305,30 @@ async function requestAsObject(_request) {
};
}
+// based on https://stackoverflow.com/questions/57447685/how-can-i-convert-a-request-object-into-a-stringifiable-object-in-javascript
+async function responseAsObject(response) {
+ if (!(response instanceof Response)) {
+ throw Object.assign(new Error(), {
+ name: "TypeError",
+ message: "Argument must be a Response object",
+ });
+ }
+ response = response.clone();
+
+ return {
+ ...stringifiableObject(response),
+ headers: Object.fromEntries(response.headers),
+ body: await response.text(),
+ };
+}
+
export {
checkResourceExists,
mergeResponse,
modelResource,
normalizePathnameForWindows,
requestAsObject,
+ responseAsObject,
resolveForRelativeUrl,
trackResourcesForRoute,
transformKoaRequestIntoStandardRequest,
diff --git a/packages/cli/src/plugins/renderer/plugin-renderer-default.js b/packages/cli/src/plugins/renderer/plugin-renderer-default.js
index ff806f543..b7d2c7cdc 100644
--- a/packages/cli/src/plugins/renderer/plugin-renderer-default.js
+++ b/packages/cli/src/plugins/renderer/plugin-renderer-default.js
@@ -4,6 +4,7 @@ const greenwoodPluginRendererDefault = {
provider: () => {
return {
executeModuleUrl: new URL("../../lib/execute-route-module.js", import.meta.url),
+ apiRouteWorkerUrl: new URL("../../lib/api-route-worker.js", import.meta.url),
};
},
};
diff --git a/packages/cli/src/plugins/resource/plugin-api-routes.js b/packages/cli/src/plugins/resource/plugin-api-routes.js
index 1d2414cef..4af6a5164 100644
--- a/packages/cli/src/plugins/resource/plugin-api-routes.js
+++ b/packages/cli/src/plugins/resource/plugin-api-routes.js
@@ -44,11 +44,13 @@ class ApiRoutesResource {
: undefined;
if (process.env.__GWD_COMMAND__ === "develop") {
- const workerUrl = new URL("../../lib/api-route-worker.js", import.meta.url);
+ const apiRouterWorkerUrl = this.compilation.config.plugins
+ .find((plugin) => plugin.type === "renderer")
+ .provider().apiRouteWorkerUrl;
const req = await requestAsObject(request);
const response = await new Promise((resolve, reject) => {
- const worker = new Worker(workerUrl);
+ const worker = new Worker(apiRouterWorkerUrl);
worker.on("message", (result) => {
resolve(result);
diff --git a/packages/plugin-renderer-lit/README.md b/packages/plugin-renderer-lit/README.md
index d4c1b8ba6..651e7da6c 100644
--- a/packages/plugin-renderer-lit/README.md
+++ b/packages/plugin-renderer-lit/README.md
@@ -4,6 +4,8 @@
A Greenwood plugin for using [**Lit**'s SSR capabilities](https://github.com/lit/lit/tree/main/packages/labs/ssr) as a custom server-side renderer instead of Greenwood's default renderer (WCC). This plugin also gives the ability to statically [(pre) render entire pages and layouts](https://greenwoodjs.dev/docs/reference/rendering-strategies/#prerendering) to output completely static sites. For more information and complete docs on Greenwood, please visit [our website](https://www.greenwoodjs.dev).
+You can see [this repo](https://github.com/thescientist13/greenwood-lit-ssr) for a full demo of an isomorphic Lit SSR project with SSR pages and API routes, deployed to Vercel.
+
> This package assumes you already have `@greenwood/cli` installed.
## Prerequisite
@@ -70,6 +72,44 @@ Types should automatically be inferred through this package's exports map, but c
import type { LitRendererPlugin } from '@greenwood/plugin-renderer-lit';
```
+## API Routes
+
+If you're using CSS Module Scripts and [API Routes](https://greenwoodjs.dev/docs/pages/api-routes/), you will need to make sure to import [Lit's CSS Register Hook](https://github.com/lit/lit/tree/main/packages/labs/ssr-dom-shim#css-nodejs-customization-hook) at the top of your function handler.
+
+```js
+// make sure to import this first!
+import "@lit-labs/ssr-dom-shim/register-css-hook.js";
+import { render } from '@lit-labs/ssr';
+import { collectResult } from '@lit-labs/ssr/lib/render-result.js'
+import { html } from 'lit';
+import { unsafeHTML } from 'lit/directives/unsafe-html.js';
+import { getProducts } from '../../services/products.ts';
+import '../../components/card/card.ts';
+
+export async function handler() {
+ const products = (await getProducts());
+ const body = await collectResult(render(html`
+ ${
+ unsafeHTML(products.map((item, idx) => {
+ const { title, thumbnail } = item;
+
+ return `
+
+ `;
+ }).join(''))
+ }
+ `));
+
+ return new Response(body, {
+ headers: new Headers({
+ 'Content-Type': 'text/html'
+ })
+ });
+}
+```
## Caveats
@@ -82,8 +122,6 @@ import type { LitRendererPlugin } from '@greenwood/plugin-renderer-lit';
_**Note**: As `LitElement` [only renders into Shadow Roots](https://github.com/lit/lit/issues/3080#issuecomment-1165158794), for pages and layouts this plugin will extract the HTML contents of the SSR'd `` tag and output those content into the **Light DOM** of your page._
-> See [this repo](https://github.com/thescientist13/greenwood-lit-ssr) for a full demo of isomorphic Lit SSR with SSR pages and API routes deployed to Vercel serverless functions.
-
## Usage
Add this plugin to your _greenwood.config.js_:
diff --git a/packages/plugin-renderer-lit/src/api-route-worker.js b/packages/plugin-renderer-lit/src/api-route-worker.js
new file mode 100644
index 000000000..c42e41666
--- /dev/null
+++ b/packages/plugin-renderer-lit/src/api-route-worker.js
@@ -0,0 +1,37 @@
+// https://github.com/nodejs/modules/issues/307#issuecomment-858729422
+import { parentPort } from "node:worker_threads";
+import {
+ transformKoaRequestIntoStandardRequest,
+ responseAsObject,
+} from "@greenwood/cli/src/lib/resource-utils.js";
+import "@lit-labs/ssr-dom-shim/register-css-hook.js";
+
+async function executeRouteModule({ href, request, params }) {
+ const { body, headers = {}, method, url } = request;
+ const contentType = headers["content-type"] || "";
+ // @ts-expect-error see https://github.com/microsoft/TypeScript/issues/42866
+ const { handler } = await import(new URL(href));
+ const format = contentType.startsWith("application/json") ? JSON.parse(body) : body;
+
+ // handling of serialized FormData across Worker threads
+ if (contentType.startsWith("x-greenwood/www-form-urlencoded")) {
+ headers["content-type"] = "application/x-www-form-urlencoded";
+ }
+
+ const response = await handler(
+ transformKoaRequestIntoStandardRequest(new URL(url), {
+ method,
+ header: headers,
+ body: format,
+ }),
+ {
+ params,
+ },
+ );
+
+ parentPort.postMessage(await responseAsObject(response));
+}
+
+parentPort.on("message", async (task) => {
+ await executeRouteModule(task);
+});
diff --git a/packages/plugin-renderer-lit/src/index.js b/packages/plugin-renderer-lit/src/index.js
index 594e70bbc..bb586af7c 100755
--- a/packages/plugin-renderer-lit/src/index.js
+++ b/packages/plugin-renderer-lit/src/index.js
@@ -76,6 +76,7 @@ const greenwoodPluginRendererLit = () => {
provider: () => {
return {
executeModuleUrl: new URL("./execute-route-module.js", import.meta.url),
+ apiRouteWorkerUrl: new URL("./api-route-worker.js", import.meta.url),
};
},
},
diff --git a/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/greenwood.config.js b/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/greenwood.config.js
new file mode 100644
index 000000000..0eaca34bd
--- /dev/null
+++ b/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/greenwood.config.js
@@ -0,0 +1,5 @@
+import { greenwoodPluginRendererLit } from "../../../src/index.js";
+
+export default {
+ plugins: [greenwoodPluginRendererLit()],
+};
diff --git a/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/loaders-develop.import-attributes.spec.js b/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/loaders-develop.import-attributes.spec.js
new file mode 100644
index 000000000..34215a54b
--- /dev/null
+++ b/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/loaders-develop.import-attributes.spec.js
@@ -0,0 +1,118 @@
+/*
+ * Use Case
+ * Run Greenwood build command with a static site and only prerendering the content (no JS!) and using import attributes
+ *
+ * User Result
+ * Should generate a bare bones Greenwood build with correctly templated out HTML from a LitElement.
+ *
+ * User Command
+ * greenwood build
+ *
+ * User Config
+ * import { greenwoodPluginRendererLit } from '@greenwood/plugin-renderer-lit';
+ *
+ * {
+ * prerender: true
+ * plugins: [
+ * greenwoodPluginRendererLit()
+ * ]
+ * }
+ *
+ * User Workspace
+ * src/
+ * components/
+ * card/
+ * card.js
+ * card.css
+ * pages/
+ * api/
+ * fragment.js
+ */
+import { expect } from "chai";
+import { JSDOM } from "jsdom";
+import path from "node:path";
+import { getOutputTeardownFiles } from "../../../../../test/utils.js";
+import { Runner } from "gallinago";
+import { fileURLToPath } from "node:url";
+
+describe("Develop Greenwood With Custom Lit Renderer for SSR: ", function () {
+ const LABEL = "For SSR with Import Attributes";
+ const cliPath = path.join(process.cwd(), "packages/cli/src/bin.js");
+ const outputPath = fileURLToPath(new URL(".", import.meta.url));
+ const hostname = "http://127.0.0.1:1984";
+ let runner;
+
+ before(function () {
+ this.context = {
+ publicDir: path.join(outputPath, "public"),
+ };
+ runner = new Runner(false, true);
+ });
+
+ describe(LABEL, function () {
+ before(async function () {
+ await runner.setup(outputPath);
+
+ await new Promise((resolve, reject) => {
+ runner
+ .runCommand(cliPath, "develop", {
+ onStdOut: (message) => {
+ if (message.includes(`Started local development server at http://localhost:1984`)) {
+ resolve();
+ }
+ },
+ })
+ .catch(reject);
+ });
+ });
+
+ describe("Serve command with API route server rendering LitElement components as an HTML response with import attributes", function () {
+ let resp;
+ let html;
+ let dom;
+
+ before(async function () {
+ resp = await fetch(`${hostname}/api/fragment`);
+ html = await resp.text();
+ dom = new JSDOM(html);
+ });
+
+ it("should have a response status of 200", function (done) {
+ expect(resp.status).to.equal(200);
+
+ done();
+ });
+
+ it("should have a Content-Type header of text/html", function (done) {
+ const type = resp.headers.get("Content-Type");
+
+ expect(type).to.equal("text/html");
+
+ done();
+ });
+
+ it("should have the expected number of components for a single search result", function (done) {
+ const cards = dom.window.document.querySelectorAll(
+ 'app-card template[shadowrootmode="open"]',
+ );
+ const cardDom = new JSDOM(cards[0].innerHTML);
+
+ expect(cards.length).to.equal(1);
+ // TODO this should be using real data from attributes (see issue with static in card.js)
+ expect(cardDom.window.document.querySelectorAll("h3")[0].textContent).to.equal(
+ "1) Product 1",
+ );
+ expect(cardDom.window.document.querySelectorAll("img")[0].getAttribute("src")).to.equal(
+ "product1.png",
+ );
+
+ done();
+ });
+ });
+ });
+
+ after(async function () {
+ await runner.teardown(getOutputTeardownFiles(outputPath));
+ await runner.stopCommand();
+ });
+});
diff --git a/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/package.json b/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/package.json
new file mode 100644
index 000000000..8c42594ed
--- /dev/null
+++ b/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/package.json
@@ -0,0 +1,7 @@
+{
+ "name": "plugin-prerender-lit-develop-import-attributes",
+ "type": "module",
+ "dependencies": {
+ "lit": "^3.1.0"
+ }
+}
diff --git a/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/src/components/card/card.css b/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/src/components/card/card.css
new file mode 100644
index 000000000..1293dd047
--- /dev/null
+++ b/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/src/components/card/card.css
@@ -0,0 +1,19 @@
+h3 {
+ font-size: 1.85rem;
+}
+
+button {
+ background: var(--color-accent);
+ color: var(--color-white);
+ padding: 1rem 2rem;
+ border: 0;
+ font-size: 1rem;
+ border-radius: 5px;
+ cursor: pointer;
+}
+
+img {
+ max-width: 500px;
+ min-width: 500px;
+ width: 100%;
+}
diff --git a/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/src/components/card/card.js b/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/src/components/card/card.js
new file mode 100644
index 000000000..9075f5dd5
--- /dev/null
+++ b/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/src/components/card/card.js
@@ -0,0 +1,40 @@
+import { LitElement, html } from "lit";
+import sheet from "./card.css" with { type: "css" };
+
+export default class Card extends LitElement {
+ static properties = {
+ title: { type: String },
+ thumbnail: { type: String },
+ };
+
+ static styles = [sheet];
+
+ constructor() {
+ super();
+
+ this.title;
+ this.thumbnail;
+ }
+
+ selectItem() {
+ alert(`selected item is => ${this.title}!`);
+ }
+
+ render() {
+ const { title = "Foo", thumbnail = "bar.png" } = this;
+
+ if (!title && !thumbnail) {
+ return;
+ }
+
+ return html`
+
+
${title}
+

+
+
+ `;
+ }
+}
+
+customElements.define("app-card", Card);
diff --git a/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/src/pages/api/fragment.js b/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/src/pages/api/fragment.js
new file mode 100644
index 000000000..3dc99721d
--- /dev/null
+++ b/packages/plugin-renderer-lit/test/cases/loaders-develop.import-attributes/src/pages/api/fragment.js
@@ -0,0 +1,41 @@
+import "@lit-labs/ssr-dom-shim/register-css-hook.js";
+import { render } from "@lit-labs/ssr";
+import { collectResult } from "@lit-labs/ssr/lib/render-result.js";
+import { html } from "lit";
+import { unsafeHTML } from "lit/directives/unsafe-html.js";
+import "../../components/card/card.js";
+
+export const isolation = true;
+
+export async function handler() {
+ const products = [
+ {
+ title: "Product 1",
+ thumbnail: "product1.png",
+ },
+ ];
+ const body = await collectResult(
+ render(html`
+ ${unsafeHTML(
+ products
+ .map((item, idx) => {
+ const { title, thumbnail } = item;
+
+ return `
+
+ `;
+ })
+ .join(""),
+ )}
+ `),
+ );
+
+ return new Response(body, {
+ headers: new Headers({
+ "Content-Type": "text/html",
+ }),
+ });
+}
diff --git a/packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/greenwood.config.js b/packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/greenwood.config.js
new file mode 100644
index 000000000..0eaca34bd
--- /dev/null
+++ b/packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/greenwood.config.js
@@ -0,0 +1,5 @@
+import { greenwoodPluginRendererLit } from "../../../src/index.js";
+
+export default {
+ plugins: [greenwoodPluginRendererLit()],
+};
diff --git a/packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/loaders-serve.import-attributes.spec.js b/packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/loaders-serve.import-attributes.spec.js
new file mode 100644
index 000000000..05e005b99
--- /dev/null
+++ b/packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/loaders-serve.import-attributes.spec.js
@@ -0,0 +1,118 @@
+/*
+ * Use Case
+ * Run Greenwood build command with a static site and only prerendering the content (no JS!) and using import attributes
+ *
+ * User Result
+ * Should generate a bare bones Greenwood build with correctly templated out HTML from a LitElement.
+ *
+ * User Command
+ * greenwood build
+ *
+ * User Config
+ * import { greenwoodPluginRendererLit } from '@greenwood/plugin-renderer-lit';
+ *
+ * {
+ * plugins: [
+ * greenwoodPluginRendererLit()
+ * ]
+ * }
+ *
+ * User Workspace
+ * src/
+ * components/
+ * card/
+ * card.js
+ * card.css
+ * pages/
+ * api/
+ * fragment.js
+ */
+import { expect } from "chai";
+import { JSDOM } from "jsdom";
+import path from "node:path";
+import { getOutputTeardownFiles } from "../../../../../test/utils.js";
+import { Runner } from "gallinago";
+import { fileURLToPath } from "node:url";
+
+describe("Build Greenwood With Custom Lit Renderer for SSR: ", function () {
+ const LABEL = "For SSR with Import Attributes";
+ const cliPath = path.join(process.cwd(), "packages/cli/src/bin.js");
+ const outputPath = fileURLToPath(new URL(".", import.meta.url));
+ const hostname = "http://localhost:8080";
+ let runner;
+
+ before(function () {
+ this.context = {
+ publicDir: path.join(outputPath, "public"),
+ };
+ runner = new Runner(false, true);
+ });
+
+ describe(LABEL, function () {
+ before(async function () {
+ await runner.setup(outputPath);
+ await runner.runCommand(cliPath, "build");
+
+ await new Promise((resolve, reject) => {
+ runner
+ .runCommand(cliPath, "serve", {
+ onStdOut: (message) => {
+ if (message.includes("Started server at http://localhost:8080")) {
+ resolve();
+ }
+ },
+ })
+ .catch(reject);
+ });
+ });
+
+ describe("Serve command with API route server rendering LitElement components as an HTML response with import attributes", function () {
+ let resp;
+ let html;
+ let dom;
+
+ before(async function () {
+ resp = await fetch(`${hostname}/api/fragment`);
+ html = await resp.text();
+ dom = new JSDOM(html);
+ });
+
+ it("should have a response status of 200", function (done) {
+ expect(resp.status).to.equal(200);
+
+ done();
+ });
+
+ it("should have a Content-Type header of text/html", function (done) {
+ const type = resp.headers.get("Content-Type");
+
+ expect(type).to.equal("text/html");
+
+ done();
+ });
+
+ it("should have the expected number of components for a single search result", function (done) {
+ const cards = dom.window.document.querySelectorAll(
+ 'app-card template[shadowrootmode="open"]',
+ );
+ const cardDom = new JSDOM(cards[0].innerHTML);
+
+ expect(cards.length).to.equal(1);
+ // TODO this should be using real data from attributes (see issue with static in card.js)
+ expect(cardDom.window.document.querySelectorAll("h3")[0].textContent).to.equal(
+ "1) Product 1",
+ );
+ expect(cardDom.window.document.querySelectorAll("img")[0].getAttribute("src")).to.equal(
+ "product1.png",
+ );
+
+ done();
+ });
+ });
+ });
+
+ after(async function () {
+ await runner.teardown(getOutputTeardownFiles(outputPath));
+ await runner.stopCommand();
+ });
+});
diff --git a/packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/package.json b/packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/package.json
new file mode 100644
index 000000000..d0f01837e
--- /dev/null
+++ b/packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/package.json
@@ -0,0 +1,7 @@
+{
+ "name": "plugin-prerender-lit-serve-import-attributes",
+ "type": "module",
+ "dependencies": {
+ "lit": "^3.1.0"
+ }
+}
diff --git a/packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/src/components/card/card.css b/packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/src/components/card/card.css
new file mode 100644
index 000000000..1293dd047
--- /dev/null
+++ b/packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/src/components/card/card.css
@@ -0,0 +1,19 @@
+h3 {
+ font-size: 1.85rem;
+}
+
+button {
+ background: var(--color-accent);
+ color: var(--color-white);
+ padding: 1rem 2rem;
+ border: 0;
+ font-size: 1rem;
+ border-radius: 5px;
+ cursor: pointer;
+}
+
+img {
+ max-width: 500px;
+ min-width: 500px;
+ width: 100%;
+}
diff --git a/packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/src/components/card/card.js b/packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/src/components/card/card.js
new file mode 100644
index 000000000..9075f5dd5
--- /dev/null
+++ b/packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/src/components/card/card.js
@@ -0,0 +1,40 @@
+import { LitElement, html } from "lit";
+import sheet from "./card.css" with { type: "css" };
+
+export default class Card extends LitElement {
+ static properties = {
+ title: { type: String },
+ thumbnail: { type: String },
+ };
+
+ static styles = [sheet];
+
+ constructor() {
+ super();
+
+ this.title;
+ this.thumbnail;
+ }
+
+ selectItem() {
+ alert(`selected item is => ${this.title}!`);
+ }
+
+ render() {
+ const { title = "Foo", thumbnail = "bar.png" } = this;
+
+ if (!title && !thumbnail) {
+ return;
+ }
+
+ return html`
+
+
${title}
+

+
+
+ `;
+ }
+}
+
+customElements.define("app-card", Card);
diff --git a/packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/src/pages/api/fragment.js b/packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/src/pages/api/fragment.js
new file mode 100644
index 000000000..3dc99721d
--- /dev/null
+++ b/packages/plugin-renderer-lit/test/cases/loaders-serve.import-attributes/src/pages/api/fragment.js
@@ -0,0 +1,41 @@
+import "@lit-labs/ssr-dom-shim/register-css-hook.js";
+import { render } from "@lit-labs/ssr";
+import { collectResult } from "@lit-labs/ssr/lib/render-result.js";
+import { html } from "lit";
+import { unsafeHTML } from "lit/directives/unsafe-html.js";
+import "../../components/card/card.js";
+
+export const isolation = true;
+
+export async function handler() {
+ const products = [
+ {
+ title: "Product 1",
+ thumbnail: "product1.png",
+ },
+ ];
+ const body = await collectResult(
+ render(html`
+ ${unsafeHTML(
+ products
+ .map((item, idx) => {
+ const { title, thumbnail } = item;
+
+ return `
+
+ `;
+ })
+ .join(""),
+ )}
+ `),
+ );
+
+ return new Response(body, {
+ headers: new Headers({
+ "Content-Type": "text/html",
+ }),
+ });
+}