Skip to content

Commit

Permalink
feat: third party plugin (stage 1 poc) (#2702)
Browse files Browse the repository at this point in the history
* feat: add basic parse of external plugins

* feat: add external plugin previewer

* feat: able to render plugin into the plugin canvas

* fix: tsconfig error

* feat: move to new plugin infra

* feat: internally convert dom nodes to ef template

* feat: add a permission guard

* feat: support permission grant

* feat: add sdk entry

* refactor: change inject content scripts

* feat: add a mech for enable sdk

* feat: add basic sdk

* feat: add sdk

* fix: prettier

* chore: change some custom dom

* chore: change some custom dom

* feat: a minimal permission management

* feat: poor man's permission system

* fix: lockfile

* chore: remove extra entry for sdk

* fix: enable sdk

* feat: add sns context

* feat: add entry for third party plugin

* feat: add setMetadata

* feat: metadata badge

* feat: metadata badge of 3rd plugin

* feat: allow relative path

* fix: url bug
  • Loading branch information
Jack-Works authored Jun 3, 2021
1 parent 1066ab4 commit b7536f5
Show file tree
Hide file tree
Showing 56 changed files with 1,219 additions and 17 deletions.
17 changes: 17 additions & 0 deletions packages/external-plugin-previewer/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "@dimensiondev/external-plugin-previewer",
"version": "0.0.0",
"private": true,
"scripts": {
"start": "dev -- snowpack dev"
},
"dependencies": {
"@dimensiondev/maskbook-shared": "workspace:*",
"ef.js": "^0.13.6"
},
"devDependencies": {
"snowpack": "^3.0.11"
},
"main": "./dist/index.js",
"types": "./dist"
}
12 changes: 12 additions & 0 deletions packages/external-plugin-previewer/public/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>External plugin debug playground</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/dist/playground.js"></script>
</body>
</html>
14 changes: 14 additions & 0 deletions packages/external-plugin-previewer/snowpack.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Snowpack Configuration File
// See all supported options: https://www.snowpack.dev/reference/configuration

/** @type {import("snowpack").SnowpackUserConfig } */
module.exports = {
mount: {
public: { url: '/' },
src: { url: '/dist' },
},
plugins: [],
packageOptions: {},
devOptions: { port: 28194 },
buildOptions: {},
}
49 changes: 49 additions & 0 deletions packages/external-plugin-previewer/src/Components/MaskCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Card, CardContent, Typography, CardActions, Button } from '@material-ui/core'
import { hostConfig } from '../host'
import type { Component } from './index'
import { useRef } from 'react'
export const MaskCard: Component<MaskCardProps> = (props) => {
const ref = useRef<HTMLDivElement>(null)
return (
<Card ref={ref}>
<CardContent>
<Typography color="textSecondary" gutterBottom>
{String(props.caption)}
</Typography>
<Typography variant="h5" component="div">
<slot name="title" />
</Typography>
<Typography variant="body2" component="p">
<slot></slot>
</Typography>
</CardContent>
<CardActions>
<Button
onClick={() => {
const base = getContext(ref.current)?.trim()
const url = base ? new URL(props.href, base) : new URL(props.href)
hostConfig.permissionAwareOpen(url.toString())
}}
size="small">
{String(props.button)}
</Button>
</CardActions>
</Card>
)
}
MaskCard.displayName = 'mask-card'
export interface MaskCardProps {
caption: string
title: string
button: string
href: string
}
function getContext(node: Node | ShadowRoot | null): string | null {
if (!node) return null
if (node instanceof Element && node.hasAttribute('data-plugin')) {
return node.getAttribute('data-plugin')
}
if (node instanceof ShadowRoot) return getContext(node.host)
if (node.parentNode) return getContext(node.parentNode)
return null
}
10 changes: 10 additions & 0 deletions packages/external-plugin-previewer/src/Components/Translate.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { Component } from '.'

export const Translate: Component<{}> = () => {
return (
<span>
i18n: <slot></slot>
</span>
)
}
Translate.displayName = 'i18n-translate'
25 changes: 25 additions & 0 deletions packages/external-plugin-previewer/src/Components/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { createElement } from 'react'

export { MaskCard } from './MaskCard'
export { Translate } from './Translate'

export interface Component<P> {
(props: P, dispatchEvent: (event: Event) => void): React.ReactChild
displayName: string
}

export const span = createNativeTagDelegate('span')
export const div = createNativeTagDelegate('div')
export const br = createNativeTagDelegate('br', { children: false })
function createNativeTagDelegate<T extends keyof HTMLElementTagNameMap>(
tag: T,
accpetProps?: { [key in keyof HTMLElementTagNameMap[T]]?: boolean },
) {
const C: Component<{}> = () => {
// TODO: implement acceptProps
if (accpetProps?.children === false) return createElement(tag)
return createElement(tag, {}, <slot />)
}
C.displayName = tag
return C
}
73 changes: 73 additions & 0 deletions packages/external-plugin-previewer/src/DOMImpl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { setDOMImpl } from 'ef.js'
import type {} from 'react/experimental'
import type {} from 'react-dom/experimental'
import { createReactRootShadowedPartial, ReactRootShadowed } from '@dimensiondev/maskbook-shared'
import * as Components from './Components'

const createReactRootShadowed = createReactRootShadowedPartial({
preventEventPropagationList: [],
})
setDOMImpl({
Node,
document: new Proxy(document, {
get(doc, key) {
if (key === 'createElement') return createElement
const val = (doc as any)[key]
if (typeof val === 'function') return val.bind(doc)
return val
},
}),
})

function createElement(element: string, options: ElementCreationOptions) {
element = options.is || element
const _ = shouldRender(element)
const isValid = _ !== unknown
const [nativeTag, Component] = _
const DOM = document.createElement(nativeTag)
DOM.setAttribute('data-kind', element)

const shadow = DOM.attachShadow({ mode: 'open' })

const props: any = { __proto__: null }
isValid && render(Component, props, shadow)

// No attributes allowed
DOM.setAttribute = () => {}

// No need to hook event listeners

// Hook property access
const proto = Object.getPrototypeOf(DOM)
Object.setPrototypeOf(
DOM,
new Proxy(proto, {
set(target, prop, value, receiver) {
// Forward them instead.
props[prop] = value
isValid && render(Component, props, shadow)
return true
},
}),
)
return DOM
}

function render(f: Components.Component<any>, props: any, shadow: ShadowRoot) {
const root: ReactRootShadowed =
(shadow as any).__root || ((shadow as any).__root = createReactRootShadowed(shadow, { tag: 'span' }))
root.render(<HooksContainer f={() => f(props, (event) => void shadow.host.dispatchEvent(event))} />)
}
// Need use a JSX component to hold hooks
function HooksContainer(props: { f: () => React.ReactNode }) {
return <>{props.f()}</>
}

const unknown = ['span', (() => null) as any as Components.Component<any>] as const

function shouldRender(element: string): readonly [string, Components.Component<any>] {
for (const F of Object.values(Components)) {
if (F.displayName === element) return ['span', F]
}
return unknown
}
26 changes: 26 additions & 0 deletions packages/external-plugin-previewer/src/global.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
declare module 'ef.js' {
export interface DOMImpl {
Node: typeof Node
document: typeof document
}
export function setDOMImpl(impl: DOMImpl): void
export function create(template: string | TemplateStringsArray): typeof Component

// Not exported
class Component<T extends object = Record<string, any>> {
constructor(options?: ComponentConstructorOptions<T>)
$mount(opt: MountOptions): void
$destroy(): void
$methods: Record<string, Function>
$data: T
$subscribe(key: keyof T, callback: Function): void
$unsubscribe(key: keyof T, callback: Function): void
}
export interface ComponentConstructorOptions<T> {
$data: T
}
export interface MountOptions {
target: Node
}
export function t(template: TemplateStringsArray): typeof Component
}
12 changes: 12 additions & 0 deletions packages/external-plugin-previewer/src/host.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/** @internal */
export const hostConfig: HostConfig = {
permissionAwareOpen(url: string) {
return url
},
}
export interface HostConfig {
permissionAwareOpen(url: string): void
}
export function setHostConfig(host: HostConfig) {
hostConfig.permissionAwareOpen = host.permissionAwareOpen
}
32 changes: 32 additions & 0 deletions packages/external-plugin-previewer/src/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
export { setHostConfig } from './host'
export type { HostConfig } from './host'
/// <reference path="./global.d.ts" />
import { useEffect, useState } from 'react'
import { create } from 'ef.js'
import './DOMImpl'
export function MaskExternalPluginPreviewRenderer({ pluginBase, payload, script, template, onError }: RenderData) {
const [dom, setDOM] = useState<HTMLDivElement | null>(null)
useEffect(() => {
if (!dom) return
dom.setAttribute('data-plugin', pluginBase)
// This is safe. ef template does not allow any form of dynamic code execute in the template.
try {
const RemoteContent = create(template)
const instance = new RemoteContent({ $data: { payload } })
instance.$mount({ target: dom })
return () => instance.$destroy()
} catch (e) {
onError?.(e)
}
return
}, [dom, onError, payload, template, pluginBase])
return <div ref={(ref) => setDOM(ref)} />
}
export interface RenderData {
pluginBase: string
template: string
/** Currently not supported */
script: string
payload: unknown
onError?(e: Error): void
}
27 changes: 27 additions & 0 deletions packages/external-plugin-previewer/src/playground.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import React from 'react'
import { t } from 'ef.js'
import { setupPortalShadowRoot } from '@dimensiondev/maskbook-shared'
setupPortalShadowRoot({ mode: 'open' }, [])

Object.assign(globalThis, { React })

const HelloWorld = t`
>mask-card
%caption = Caption!
%title = This is preview of id {{payload.id}}
%button = Details
>mask-card
%caption = Caption!
%title = This is preview of id {{payload.id}}
%button = Details
`

const ins = new HelloWorld()
console.log('ins = ', ((globalThis as any).ins = ins))
ins.$mount({ target: document.body })
// will be set by Mask
ins.$data.payload = {
id: 1,
}

export {}
13 changes: 13 additions & 0 deletions packages/external-plugin-previewer/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "./src/",
"outDir": "./dist/",
"stripInternal": true
},
"include": ["./src/**/*"],
"ts-node": {
"transpileOnly": true,
"compilerOptions": { "module": "CommonJS" }
}
}
3 changes: 2 additions & 1 deletion packages/maskbook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@
"@dimensiondev/common-protocols": "1.6.0-20201027083702-d0ae6e2",
"@dimensiondev/contracts": "workspace:*",
"@dimensiondev/dashboard": "workspace:*",
"@dimensiondev/holoflows-kit": "0.8.0-20210317064617-6c4792c",
"@dimensiondev/icons": "workspace:*",
"@dimensiondev/external-plugin-previewer": "workspace:*",
"@dimensiondev/holoflows-kit": "0.8.0-20210317064617-6c4792c",
"@dimensiondev/kit": "0.0.0-20210221102734-0b4a937",
"@dimensiondev/mask-plugin-infra": "workspace:*",
"@dimensiondev/maskbook-shared": "workspace:*",
Expand Down
6 changes: 6 additions & 0 deletions packages/maskbook/src/content-script.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import './extension/content-script/hmr'
import Services from './extension/service'
import { status } from './setup.ui'

status.then((loaded) => {
loaded && import('./extension/content-script/tasks')
})

// The scope should be the ./ of the web page
Services.ThirdPartyPlugin.isSDKEnabled(new URL('./', location.href).href).then((result) => {
result && import('./extension/external-sdk')
})
Loading

0 comments on commit b7536f5

Please sign in to comment.