Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add support for server side Portals #167

Merged
merged 14 commits into from
Oct 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/labeler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ workspace:

## PACKAGES

pkg/portal-gun:
- 'packages/portal-gun/**'

pkg/cut-short:
- 'packages/cut-short/**'

Expand Down
8 changes: 8 additions & 0 deletions docs/astro.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,14 @@ export default defineConfig({
variant: 'success',
},
},
{
label: 'Portal Gun',
link: '/portal-gun',
badge: {
text: 'NEW',
variant: 'success',
},
},
{
label: 'Content Utilities',
collapsed: false,
Expand Down
84 changes: 84 additions & 0 deletions docs/src/content/docs/portal-gun.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
---
title: Portal Gun
packageName: '@inox-tools/portal-gun'
description: Transport HTML elements through your page during rendering using Portals.
---

When an element requires rendering outside of the usual component hierarchy, challenges related to stacking contents and z-index can interfere with the desired intention or look of an application[^1]. Portal Gun enables server-side portal functionality for Astro projects, allowing a component to render an element somewhere else in the document.

## Installing the integration

import { PackageManagers } from 'starlight-package-managers';

<PackageManagers type="exec" pkg="astro" args="add @inox-tools/portal-gun" />

## How to use

Use a `<portal-gate>` element with a `to` attribute to send all children to the target portal:

```html
<portal-gate to="group">
<dialog>
<p>Dialog</p>
</dialog>
</portal-gate>
```

### Target Portal

You can define output portals anywhere on the page. They are replaced with every element teleported to it.

Use a `<portal-landzone>` element with a `name` attribute:

```html
<portal-landzone name="group"></portal-landzone>
```

### ID boundary portals

Any element with an `id` has two implicit portals targets:

- `start:#<id>`: the children of the source portals are prepended to the children of the target element
- `end:#<id>`: the children of the source portals are append to the children of the target element

Given the following document:

```html
<div id="element-id">
<p>Hello</p>
</div>

<portal-gate to="start:#element-id">
<p>Before</p>
</portal-gate>
<portal-gate to="after:#element-id">
<p>After</p>
</portal-gate>
```

The HTML sent to the client will be:

```html
<div id="element-id">
<p>Before</p>
<p>Hello</p>
<p>After</p>
</div>
```

### Global portals

The `<head>` and `<body>` elements have implicit portals for prepending and appending:

- `start:head`: targets the start of the `<head>` element
- `end:head`: targets the end of the `<head>` element, right before `</head>`
- `start:body`: targets the start of the `<body>` element
- `end:body`: targets the end of the `<body>` element, right before `</body>`

## Caveats

Enabling portals disables [response streaming](https://docs.astro.build/en/recipes/streaming-improve-page-performance/). The entire document has to be generated in order for the elements in the portals to be teleported to the appropriate place. After that, the updated document is sent to the client.

---

[^1]: Taken from SolidJS' [`Portal`](https://docs.solidjs.com/concepts/control-flow/portal) component.
25 changes: 25 additions & 0 deletions packages/portal-gun/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<p align="center">
<img alt="InoxTools" width="350px" src="https://github.com/Fryuni/inox-tools/blob/main/assets/shield.png?raw=true"/>
</p>

# Portal Gun

Transport HTML elements through your page during rendering using Portals.

## Install

```js
npm i @inox-tools/portal-gun
```

Add the integration to your `astro.config.mjs`:

```js
// astro.config.mjs
import { defineConfig } from 'astro';
import portalGun from '@inox-tools/portal-gun';

export default defineConfig({
integrations: [portalGun()],
});
```
3 changes: 3 additions & 0 deletions packages/portal-gun/npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules
*.log
src
65 changes: 65 additions & 0 deletions packages/portal-gun/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
{
"name": "@inox-tools/portal-gun",
"version": "0.0.0",
"description": "Transport HTML elements through your page during rendering using Portals.",
"keywords": [
Fryuni marked this conversation as resolved.
Show resolved Hide resolved
"astro-integration",
"withastro",
"astro",
"ui",
"utils"
],
"license": "MIT",
"author": "Luiz Ferraz <[email protected]>",
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./runtime/*": {
"types": "./dist/runtime/index.d.ts",
"default": "./dist/runtime/index.js"
}
},
"files": [
"dist",
"src",
"virtual.d.ts"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"prepublish": "pnpm run build",
"test": "vitest run",
"test:dev": "vitest"
},
"dependencies": {
"@inox-tools/runtime-logger": "workspace:^",
"@inox-tools/utils": "workspace:^",
"astro-integration-kit": "catalog:",
Fryuni marked this conversation as resolved.
Show resolved Hide resolved
"debug": "catalog:",
"rehype": "catalog:"
},
"devDependencies": {
"@inox-tools/astro-tests": "workspace:",
"@types/hast": "catalog:",
"@types/node": "catalog:",
"@vitest/ui": "catalog:",
"jest-extended": "catalog:",
"astro": "catalog:",
"tsup": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
"vitest": "catalog:"
},
"peerDependencies": {
"astro": "catalog:lax",
"preact": "*"
},
"peerDependenciesMeta": {
"preact": {
"optional": true
}
}
}
28 changes: 28 additions & 0 deletions packages/portal-gun/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { createResolver, defineIntegration } from 'astro-integration-kit';
import { z } from 'astro/zod';
import { debug } from './internal/debug.js';
import { runtimeLogger } from '@inox-tools/runtime-logger';

export default defineIntegration({
name: '@inox-tools/portal-gun',
optionsSchema: z.never().optional(),
setup() {
const { resolve } = createResolver(import.meta.url);

return {
hooks: {
'astro:config:setup': (params) => {
runtimeLogger(params, {
name: 'portal-gun',
});

debug('Injecting middleware');
params.addMiddleware({
order: 'pre',
entrypoint: resolve('./runtime/middleware.js'),
});
},
},
};
},
});
5 changes: 5 additions & 0 deletions packages/portal-gun/src/internal/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const ENTRY_PORTAL_TAG = 'portal-gate';
export const EXIT_PORTAL_TAG = 'portal-landzone';

export const PREPEND_PREFIX = 'start:';
export const APPEND_PREFIX = 'end:';
7 changes: 7 additions & 0 deletions packages/portal-gun/src/internal/debug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import debugC from 'debug';

export const debug = debugC('inox-tools:portal-gun');

export const getDebug = (segment?: string) => {
Fryuni marked this conversation as resolved.
Show resolved Hide resolved
return segment ? debug.extend(segment) : debug;
};
Empty file.
131 changes: 131 additions & 0 deletions packages/portal-gun/src/runtime/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import type { MiddlewareHandler } from 'astro';
import { rehype } from 'rehype';
import type * as hast from 'hast';
import * as visitor from '@inox-tools/utils/unist/visit';
import { debug } from '../internal/debug.js';
import { logger } from '@it-astro:logger:portal-gun';
import {
APPEND_PREFIX,
ENTRY_PORTAL_TAG,
EXIT_PORTAL_TAG,
PREPEND_PREFIX,
} from '../internal/constants.js';

const processor = rehype();

export const onRequest: MiddlewareHandler = async (_, next) => {
const response = await next();
if (response.headers.get('content-type')?.includes('text/html') !== true) {
return response;
}

const body = await response.text();
const tree = processor.parse(body);

const portalContents = new Map<string, hast.ElementContent[][]>();
const landingPortals: Array<{
name: string;
landContent: () => void;
}> = [];

function portalIn(node: hast.Element, parents: hast.Parent[]): visitor.VisitorResult {
const name = node.properties?.to;
if (typeof name !== 'string') {
logger.warn('Incoming portal without valid target');
debug('Incoming portal without target', node.properties);
return;
}

debug(`Sending ${node.children.length} children to portal ${name}`);

const content = portalContents.get(name) ?? [];
content.push(node.children);
portalContents.set(name, content);

const parent = parents.at(-1);
const index = parent?.children.indexOf(node);

if (parent !== undefined && index !== undefined) {
parent.children.splice(index, 1);
return [visitor.CONTINUE, index];
}
}

function portalOut(node: hast.Element, parents: hast.Parent[]): visitor.VisitorResult {
const name = node.properties?.name;
if (parents.length === 0 || typeof name !== 'string') {
logger.warn('Outgoing portal without valid name');
debug('Outgoing portal without name', node.properties);
return;
}

landingPortals.push({
name: name,
landContent: () => {
const content = portalContents.get(name) ?? [];
const parent = parents.at(-1)!;
parent.children.splice(
parent.children.indexOf(node),
1,
...node.children,
...content.flat(1)
);
},
});
}

// Activate all the portals
visitor.visitParents({
tree,
test: 'element',
leave: (node, parents) => {
let boundaryPortalName = node.tagName;
switch (node.tagName) {
case EXIT_PORTAL_TAG:
return portalOut(node, parents);
case ENTRY_PORTAL_TAG:
return portalIn(node, parents);
case 'body':
case 'head':
break;
default: {
const id = node.properties.id;
if (typeof id === 'string') {
boundaryPortalName = `#${id}`;
break;
} else {
return;
}
}
}

debug(`Adding boundary portals to ${boundaryPortalName}`);

landingPortals.push({
name: PREPEND_PREFIX + boundaryPortalName,
landContent: () => {
const content = portalContents.get(PREPEND_PREFIX + boundaryPortalName) ?? [];
node.children.unshift(...content.flat(1));
},
});
landingPortals.push({
name: APPEND_PREFIX + boundaryPortalName,
landContent: () => {
const content = portalContents.get(APPEND_PREFIX + boundaryPortalName) ?? [];
node.children.push(...content.flat(1));
},
});
},
});

// Land all elements through the portals
// We do this backwards to properly handle nested portals
for (let i = landingPortals.length - 1; i >= 0; i--) {
const portal = landingPortals[i];
portal.landContent();
}

const newBody = processor.stringify(tree);

return new Response(newBody, response);
};
14 changes: 14 additions & 0 deletions packages/portal-gun/tests/basic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { loadFixture } from '@inox-tools/astro-tests/astroFixture';
import { beforeAll } from 'vitest';
import { defineCommonTests } from './common.js';

const fixture = await loadFixture({
root: './fixture/basic',
outDir: 'dist/static',
});

beforeAll(async () => {
await fixture.build({});
});

defineCommonTests((path) => fixture.readFile(`${path}/index.html`));
Loading
Loading