@@ -4,8 +4,11 @@ import {
44 RETAINED_PACKAGE_NAMES ,
55} from './project-constants.ts' ;
66import { readPackages , releasePublishOrder } from './lib/package-graph.ts' ;
7+ import { extractStaticModuleSpecifiers } from './lib/typescript-ast.ts' ;
78import { OPENELEMENT_EXPORT_FILES } from '../packages/adapter-vite/src/generated-export-files.ts' ;
89import { resolve } from '@std/path' ;
10+ import { exists } from '@std/fs' ;
11+ import { walk } from '@std/fs/walk' ;
912
1013const retainedPackages = [ ...RETAINED_PACKAGE_NAMES ] . sort ( ) ;
1114const removedPackages = [ ...REMOVED_PACKAGE_NAMES ] . sort ( ) ;
@@ -167,6 +170,53 @@ const APILIST_REQUIRED_PACKAGES = [
167170 '@openelement/adapter-vite' ,
168171] ;
169172
173+ // ─── www public-import boundary (#1177, B2.3) ─────────────
174+ // The website must consume @openelement /* exactly as an external npm consumer
175+ // would: every specifier in the shipped site surface (www/app plus the build
176+ // entry points) resolves to a published export subpath of one of the five
177+ // retained packages — never a private source path. The only permitted
178+ // non-package @openelement specifiers are the www-local import-map aliases
179+ // declared in www/deno.json. www/e2e is deliberately out of scope: its probe
180+ // harness (browser-bundle.ts) bundles package sources in memory and ships
181+ // nothing.
182+
183+ export function extractWwwPackageSpecifiers ( source : string , path = 'source.ts' ) : string [ ] {
184+ const specifiers = new Set < string > ( ) ;
185+ for ( const { value } of extractStaticModuleSpecifiers ( source , path ) ) {
186+ if ( value . startsWith ( '@openelement/' ) ) specifiers . add ( value ) ;
187+ }
188+ for (
189+ const match of source . matchAll ( / \/ \* \* ? \s * @ j s x I m p o r t S o u r c e \s + ( @ o p e n e l e m e n t \/ [ ^ \s * ] + ) / g)
190+ ) {
191+ specifiers . add ( match [ 1 ] ) ;
192+ }
193+ return [ ...specifiers ] ;
194+ }
195+
196+ export function wwwImportBoundaryDrift (
197+ specifiers : readonly string [ ] ,
198+ publishedSubpaths : ReadonlyMap < string , ReadonlySet < string > > ,
199+ localAliasPrefixes : readonly string [ ] ,
200+ ) : string [ ] {
201+ const drift : string [ ] = [ ] ;
202+ for ( const specifier of specifiers ) {
203+ if ( ! specifier . startsWith ( '@openelement/' ) ) continue ;
204+ if ( localAliasPrefixes . some ( ( prefix ) => specifier . startsWith ( prefix ) ) ) continue ;
205+ const segments = specifier . split ( '/' ) ;
206+ const pkgName = segments . slice ( 0 , 2 ) . join ( '/' ) ;
207+ const subpath = segments . slice ( 2 ) . join ( '/' ) || '.' ;
208+ const published = publishedSubpaths . get ( pkgName ) ;
209+ if ( ! published ) {
210+ drift . push ( `${ specifier } does not resolve to a retained published package.` ) ;
211+ continue ;
212+ }
213+ if ( ! published . has ( subpath ) ) {
214+ drift . push ( `${ specifier } is not a published export subpath of ${ pkgName } .` ) ;
215+ }
216+ }
217+ return drift . sort ( ) ;
218+ }
219+
170220async function main ( ) : Promise < void > {
171221 for ( const dir of [ 'packages' , 'examples' , 'www/app' , 'tools/third-party-wc-smoke' ] ) {
172222 await rejectRetiredImports ( dir ) ;
@@ -391,6 +441,56 @@ async function main(): Promise<void> {
391441 }
392442 }
393443
444+ // ─── www public-import boundary (#1177, B2.3) ─────────────
445+ // Prove the shipped site surface (www/app + its build entry points) imports
446+ // only published export subpaths, so workspace resolution during in-repo
447+ // development is byte-identical to the packed npm artifacts.
448+
449+ const publishedSubpaths = new Map (
450+ packages . map ( ( pkg ) => [
451+ pkg . name ,
452+ new Set ( Object . keys ( normalizeExports ( pkg . exports ) ) ) ,
453+ ] ) ,
454+ ) ;
455+ const wwwConfig = JSON . parse ( await Deno . readTextFile ( 'www/deno.json' ) ) ;
456+ const localAliasPrefixes = Object . keys ( wwwConfig . imports ?? { } )
457+ . filter ( ( key ) => key . startsWith ( '@openelement/' ) ) ;
458+
459+ const wwwSurfaceFiles = [
460+ 'www/vite.config.ts' ,
461+ 'www/content-collections.ts' ,
462+ 'www/build-pagefind.ts' ,
463+ ] ;
464+ for await (
465+ const { path : file } of walk ( 'www/app' , {
466+ includeDirs : false ,
467+ skip : [ / ( ^ | \/ ) d i s t ( \/ | $ ) / ] ,
468+ } )
469+ ) {
470+ if ( / \. (?: t s | t s x ) $ / . test ( file ) ) wwwSurfaceFiles . push ( file ) ;
471+ }
472+ const specifierOrigins = new Map < string , string [ ] > ( ) ;
473+ for ( const file of wwwSurfaceFiles . sort ( ) ) {
474+ if ( ! await exists ( file ) ) continue ;
475+ const text = await Deno . readTextFile ( file ) ;
476+ for ( const specifier of extractWwwPackageSpecifiers ( text , file ) ) {
477+ const origins = specifierOrigins . get ( specifier ) ?? [ ] ;
478+ origins . push ( file ) ;
479+ specifierOrigins . set ( specifier , origins ) ;
480+ }
481+ }
482+ for (
483+ const item of wwwImportBoundaryDrift (
484+ [ ...specifierOrigins . keys ( ) ] ,
485+ publishedSubpaths ,
486+ localAliasPrefixes ,
487+ )
488+ ) {
489+ const specifier = item . split ( ' ' ) [ 0 ] ;
490+ const origins = specifierOrigins . get ( specifier ) ?? [ ] ;
491+ failures . push ( `www import boundary: ${ item } (imported by ${ origins . join ( ', ' ) } )` ) ;
492+ }
493+
394494 if ( failures . length > 0 ) {
395495 console . error ( 'Package surface check failed:' ) ;
396496 for ( const failure of failures ) console . error ( `- ${ failure } ` ) ;
0 commit comments