镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

Commit fdcf483

Browse files
committed
feat: expose component library style metadata
1 parent ab56aeb commit fdcf483

5 files changed

Lines changed: 156 additions & 0 deletions

File tree

‎docs/api/component-library.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,13 @@ console.log(result.components, result.totalBytes)
106106

107107
Set `components` for an explicit allow-list and to override `name`, `tag`, props, events, or methods per source file. Auto-discovery is recursive, stable, ignores `*.test.stx` / `*.spec.stx`, rejects invalid or duplicate custom-element tags, and never deletes unrelated output files.
108108

109+
Each `result.components` entry includes `name`, `tag`, `source`, `module`, and:
110+
111+
- `styles`: the exact string emitted as the component's `definition.styles`, or `''` when absent. This is the compiled style-block text, before the separate CSS files are scoped or minified. It remains available with `css: false` and `minify: true`.
112+
- `shadowMode`: the resolved mode, `false`, `'open'`, or `'closed'`. A `shadowDOM: true` configuration resolves to `'open'`; the default is `false`.
113+
114+
Build tools can obtain this metadata without importing generated browser modules or executing authored imports. For a hash-based Content Security Policy, hash each nonempty `styles` string as UTF-8 (for example, `createHash('sha256').update(component.styles, 'utf8').digest('base64')` from `node:crypto`). Shadow rendering and the generated SSR helpers embed that string in a `<style>` element. Light DOM uses the separate scoped CSS files and does not inject a style element. The metadata is independent of those files, which can have different bytes due to scoping, minification, and their final newline. Include any other inline styles used by the host page in its policy separately.
115+
109116
### Progressive rendering and SSR
110117

111118
CSS is emitted separately for Light DOM and wrapped in native `@scope (<tag>)` when the source did not already provide a scope. This lets the browser paint semantic server HTML before the module downloads. When the element definition arrives, STX preserves existing markup, wires events, synchronizes typed props, and adds the `hydrated` attribute. Later changes are batched into one microtask render and restore keyed/id/name focus and text selection.

‎packages/stx/src/component-library.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1882,6 +1882,8 @@ export async function buildComponentLibrary(config: ComponentLibraryConfig): Pro
18821882
tag: component.tag,
18831883
source: component.sourcePath,
18841884
module: path.join(outputDir, `${component.tag}.js`),
1885+
styles: component.styles,
1886+
shadowMode: component.shadowMode,
18851887
})),
18861888
files,
18871889
manifest,

‎packages/stx/src/types/component-types.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,10 @@ export interface ComponentLibraryBuildResult {
164164
tag: string
165165
source: string
166166
module: string
167+
/** Exact emitted definition.styles text, before external CSS scoping/minification; empty when absent. */
168+
styles: string
169+
/** Resolved rendering mode: true normalizes to "open", and Light DOM is false. */
170+
shadowMode: false | 'open' | 'closed'
167171
}>
168172
files: string[]
169173
manifest?: string
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { WebView } from 'bun'
2+
import { expect, test } from 'bun:test'
3+
import { createHash } from 'node:crypto'
4+
import { rm } from 'node:fs/promises'
5+
import { join, relative, sep } from 'node:path'
6+
import { buildComponentLibrary } from '../../src/component-library'
7+
import { createTempDir } from '../../src/temp-dir'
8+
import { createBrowserFixture } from '../../test-utils/browser-fixture'
9+
10+
test('build-result style hashes authorize exact runtime styles under a browser hash-only CSP', async () => {
11+
const root = createTempDir('stx-library-style-csp-')
12+
let fixture: ReturnType<typeof createBrowserFixture> | undefined
13+
try {
14+
const input = join(root, 'source'), output = join(root, 'dist')
15+
await Bun.write(join(input, 'browser-leaf.ts'), 'export const title = document.documentElement.tagName')
16+
await Bun.write(join(input, 'browser.ts'), "export { title } from './browser-leaf'")
17+
const modes = [false, 'open', 'closed'] as const
18+
for (const [index, mode] of modes.entries()) {
19+
await Bun.write(join(input, `Card${index}.stx`), `<script component type="application/json">${JSON.stringify({ tag: `csp-card-${index}`, shadowDOM: mode, properties: { count: { type: 'number', default: 0 } } })}</script>
20+
<script>import { title as browserTitle } from './browser'
21+
const title = browserTitle</script>
22+
<template><p>{{ title }} {{ count }}</p></template>
23+
<style>/* Preserve café, spacing and \`quotes\` ${index} */
24+
p { color: rgb(12, 34, 56); }</style>`)
25+
}
26+
const result = await buildComponentLibrary({ inputDir: input, outputDir: output, css: false, minify: true })
27+
const hashes = result.components.filter(component => component.styles).map(component => `'sha256-${createHash('sha256').update(component.styles, 'utf8').digest('base64')}'`)
28+
const csp = `default-src 'none'; script-src 'self'; img-src 'self'; style-src ${hashes.join(' ')}; base-uri 'none'`
29+
const emitted = new Map(result.files.filter(file => file.endsWith('.js')).map(file => [`/${relative(output, file).split(sep).join('/')}`, file]))
30+
fixture = createBrowserFixture({
31+
createServer: () => Bun.serve({
32+
hostname: '127.0.0.1',
33+
port: 0,
34+
fetch(request) {
35+
const pathname = new URL(request.url).pathname
36+
if (pathname === '/') return new Response(`<!doctype html><link rel="icon" href="/favicon.ico"><script src="/probe.js"></script><script type="module" src="/bundle.js"></script>${result.components.map(component => `<${component.tag}></${component.tag}>`).join('')}`, { headers: { 'Content-Type': 'text/html', 'Content-Security-Policy': csp } })
37+
if (pathname === '/favicon.ico') return new Response(null, { status: 204 })
38+
if (pathname === '/probe.js') return new Response("globalThis.violations=[];globalThis.errors=[];addEventListener('error',event=>errors.push(event.message));addEventListener('unhandledrejection',event=>errors.push(String(event.reason)));addEventListener('securitypolicyviolation',event=>violations.push(event.effectiveDirective))", { headers: { 'Content-Type': 'text/javascript' } })
39+
const file = emitted.get(pathname)
40+
return file ? new Response(Bun.file(file), { headers: { 'Content-Type': 'text/javascript' } }) : new Response('Not found', { status: 404 })
41+
},
42+
}),
43+
createView: () => new WebView({ headless: true, backend: { type: 'chrome', url: false } }),
44+
})
45+
await fixture.view.navigate(fixture.url)
46+
const hydrated = await fixture.waitFor(`Array.from(document.querySelectorAll('csp-card-0,csp-card-1,csp-card-2')).every(element=>element.hasAttribute('hydrated'))`, value => value === true)
47+
expect(await fixture.view.evaluate('JSON.stringify(errors)')).toBe('[]')
48+
expect(await fixture.view.evaluate('JSON.stringify(violations)')).toBe('[]')
49+
expect(hydrated).toBe(true)
50+
const inspect = `(async()=>{
51+
const results=[];
52+
for(const tag of ${JSON.stringify(result.components.map(component => component.tag))}){
53+
const element=document.querySelector(tag);
54+
element.count=1;await element.updateComplete;
55+
results.push({styles:element.renderRoot.querySelector('style')?.textContent??null,color:getComputedStyle(element.renderRoot.querySelector('p')).color,text:element.renderRoot.querySelector('p').textContent});
56+
}
57+
return JSON.stringify(results);
58+
})()`
59+
const observed = JSON.parse(await fixture.view.evaluate<string>(inspect))
60+
expect(observed).toEqual(result.components.map(component => ({ styles: component.shadowMode ? component.styles : null, color: component.shadowMode ? 'rgb(12, 34, 56)' : 'rgb(0, 0, 0)', text: 'HTML 1' })))
61+
expect(await fixture.view.evaluate('JSON.stringify(violations)')).toBe('[]')
62+
// A different inline style proves the response really enforces the hashes.
63+
await fixture.view.evaluate("(()=>{const blocked=document.createElement('style');blocked.textContent='p { color: rgb(200, 0, 0); }';document.querySelector('csp-card-1').renderRoot.appendChild(blocked);return true})()")
64+
expect(await fixture.waitFor('violations.includes("style-src-elem")', value => value === true)).toBe(true)
65+
expect(await fixture.view.evaluate("getComputedStyle(document.querySelector('csp-card-1').renderRoot.querySelector('p')).color")).toBe('rgb(12, 34, 56)')
66+
}
67+
finally {
68+
fixture?.close()
69+
await rm(root, { recursive: true, force: true })
70+
}
71+
}, 30_000)
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, expect, it } from 'bun:test'
2+
import { rm } from 'node:fs/promises'
3+
import path from 'node:path'
4+
import { buildComponentLibrary } from '../../src/component-library'
5+
import { createTempDir } from '../../src/temp-dir'
6+
7+
const styles = '/* exact whitespace, café and `quotes` */\n:host { color: rgb(12, 34, 56); }\n\np::after { content: "☃"; }'
8+
9+
describe('component library style metadata', () => {
10+
it('reports exact definition styles and resolved modes independently of CSS file options', async () => {
11+
const root = createTempDir('stx-library-styles-')
12+
try {
13+
const input = path.join(root, 'source')
14+
await Bun.write(path.join(input, 'Card.stx'), `<template><p>Card</p></template><style>\n${styles.split('\n\n')[0]}\n</style><style>${styles.split('\n\n')[1]}</style>`)
15+
await Bun.write(path.join(input, 'Empty.stx'), '<template><p>No styles</p></template>')
16+
for (const [index, mode] of ([false, true, 'open', 'closed'] as const).entries()) {
17+
const output = path.join(root, `dist-${index}`)
18+
const result = await buildComponentLibrary({
19+
inputDir: input,
20+
outputDir: output,
21+
components: [{ file: 'Card.stx', tag: `metadata-card-${index}`, shadowDOM: mode }],
22+
css: index % 2 === 0,
23+
minify: true,
24+
})
25+
const component = result.components[0]
26+
expect(component.styles).toBe(styles)
27+
expect(component.shadowMode).toBe(mode === true ? 'open' : mode)
28+
// Safe fixture only: the server consuming metadata never needs this import.
29+
const { Card } = await import(component.module)
30+
expect(Card.definition.styles).toBe(component.styles)
31+
expect(Card.definition.shadowMode).toBe(component.shadowMode)
32+
const element = document.createElement(component.tag) as HTMLElement & { renderRoot: HTMLElement | ShadowRoot }
33+
document.body.appendChild(element)
34+
const inlineStyles = element.renderRoot.querySelector('style')
35+
if (mode === false) expect(inlineStyles).toBeNull()
36+
else expect(inlineStyles?.textContent).toBe(component.styles)
37+
const { renderComponent } = await import(path.join(output, 'ssr.js'))
38+
const html = renderComponent(component.tag)
39+
if (mode === false) expect(html).not.toContain('<style>')
40+
else expect(html).toContain(`<style>${component.styles}</style>`)
41+
element.remove()
42+
}
43+
const result = await buildComponentLibrary({ inputDir: input, outputDir: path.join(root, 'empty'), components: [{ file: 'Empty.stx' }], bundle: false })
44+
expect(result.components[0].styles).toBe('')
45+
expect(result.components[0].shadowMode).toBe(false)
46+
}
47+
finally { await rm(root, { recursive: true, force: true }) }
48+
})
49+
50+
it('builds and reports metadata in a DOM-free server without executing browser-only transitive imports', async () => {
51+
const root = createTempDir('stx-library-browser-import-')
52+
try {
53+
const input = path.join(root, 'source')
54+
await Bun.write(path.join(input, 'browser-leaf.ts'), 'export const label = document.documentElement.tagName')
55+
await Bun.write(path.join(input, 'browser.ts'), "export { label } from './browser-leaf'")
56+
await Bun.write(path.join(input, 'Card.stx'), `<script>import { label } from './browser'\nconst title = label</script><template><p>{{ title }}</p></template><style>${styles}</style>`)
57+
const child = Bun.spawn([process.execPath, '-e', `
58+
if ('document' in globalThis || 'HTMLElement' in globalThis) throw new Error('Expected DOM-free build');
59+
const { buildComponentLibrary } = await import(${JSON.stringify(path.resolve(import.meta.dir, '../../src/component-library.ts'))});
60+
const result = await buildComponentLibrary({ inputDir: ${JSON.stringify(input)}, outputDir: ${JSON.stringify(path.join(root, 'dist'))}, shadowDOM: 'closed', minify: true });
61+
console.log(JSON.stringify(result.components));
62+
`], { stdout: 'pipe', stderr: 'pipe' })
63+
const [stdout, stderr, exitCode] = await Promise.all([new Response(child.stdout).text(), new Response(child.stderr).text(), child.exited])
64+
expect(stderr).toBe('')
65+
expect(exitCode).toBe(0)
66+
const [component] = JSON.parse(stdout)
67+
expect(component.styles).toBe(styles)
68+
expect(component.shadowMode).toBe('closed')
69+
}
70+
finally { await rm(root, { recursive: true, force: true }) }
71+
})
72+
})

0 commit comments

Comments
 (0)