Repository navigation
feat(package): expose React-free text-position selector subpath #100
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
Merged
Merged
Changes from 8 commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
ee0a307
test(package): define text-position selector subpath contract
seonghobae 11f1c35
feat(package): add text-position selector entrypoint
seonghobae d073cba
build(package): add text-position selector bundle
seonghobae ae007b2
test(package): verify packed text-position selector subpath
seonghobae d1610f1
feat(package): declare text-position selector subpath
seonghobae 2e7e951
docs(package): document React-free selector subpath
seonghobae fd6973c
fix(docs): satisfy selector boundary contract
seonghobae 4f4afab
test(package): cover selector subpath exports
seonghobae 9e347e2
test(package): define selector authority-boundary review contract
seonghobae 9c9ad51
fix(package): enforce selector runtime authority boundary
seonghobae 48bbbe4
fix(docs): record active selector package authority
seonghobae 2c60e2f
Merge branch 'main' into feat/text-position-selector-subpath
opencode-agent[bot] File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
205 changes: 205 additions & 0 deletions
205
scripts/verify-text-position-selector-subpath-package.mjs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,205 @@ | ||
| import assert from 'node:assert/strict'; | ||
| import { execFileSync } from 'node:child_process'; | ||
| import { | ||
| existsSync, | ||
| mkdirSync, | ||
| mkdtempSync, | ||
| readFileSync, | ||
| renameSync, | ||
| rmSync, | ||
| symlinkSync, | ||
| writeFileSync, | ||
| } from 'node:fs'; | ||
| import { tmpdir } from 'node:os'; | ||
| import { dirname, join, resolve } from 'node:path'; | ||
| import { fileURLToPath } from 'node:url'; | ||
|
|
||
| const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); | ||
| const packageJson = JSON.parse( | ||
| readFileSync(join(repositoryRoot, 'package.json'), 'utf8'), | ||
| ); | ||
| const verificationRoot = mkdtempSync( | ||
| join(tmpdir(), 'inkspan-text-position-selector-'), | ||
| ); | ||
| const extractionDirectory = join(verificationRoot, 'extracted'); | ||
| const consumerDirectory = join(verificationRoot, 'consumer'); | ||
| const packageDirectory = join( | ||
| consumerDirectory, | ||
| 'node_modules', | ||
| ...packageJson.name.split('/'), | ||
| ); | ||
| const forbiddenRuntimePattern = | ||
| /(?:['"](?:react(?:-dom)?(?:\/[^'"]*)?|@tiptap\/[^'"]+|prosemirror-[^'"]+|yjs(?:\/[^'"]*)?|naruon(?:\/[^'"]*)?|contextual-orchestrator(?:\/[^'"]*)?)['"])/u; | ||
|
github-actions[bot] marked this conversation as resolved.
Outdated
|
||
|
|
||
| /** Execute one deterministic package-consumer command. */ | ||
| function run(command, argumentsList, cwd = repositoryRoot) { | ||
| return execFileSync(command, argumentsList, { | ||
| cwd, | ||
| encoding: 'utf8', | ||
| stdio: ['ignore', 'pipe', 'inherit'], | ||
| }); | ||
| } | ||
|
|
||
| /** Build one real npm tarball and install its files without executing scripts. */ | ||
| function preparePackage() { | ||
| mkdirSync(extractionDirectory, { recursive: true }); | ||
| mkdirSync(dirname(packageDirectory), { recursive: true }); | ||
| const packOutput = run('npm', [ | ||
| 'pack', | ||
| '--json', | ||
| '--ignore-scripts', | ||
| '--pack-destination', | ||
| verificationRoot, | ||
| ]); | ||
| const packResult = JSON.parse(packOutput)[0]; | ||
| assert.equal(packResult.name, packageJson.name); | ||
| assert.equal(packResult.version, packageJson.version); | ||
| const tarballPath = join(verificationRoot, packResult.filename); | ||
| assert.ok(existsSync(tarballPath)); | ||
| run('tar', ['-xzf', tarballPath, '-C', extractionDirectory]); | ||
| renameSync(join(extractionDirectory, 'package'), packageDirectory); | ||
| writeFileSync( | ||
| join(consumerDirectory, 'package.json'), | ||
| '{"name":"inkspan-text-position-selector-consumer","private":true,"type":"module"}\n', | ||
| 'utf8', | ||
| ); | ||
|
|
||
| // The package declares @tiptap/pm as a normal dependency. The packed fixture is | ||
| // extracted without a package-manager install, so expose the already-frozen | ||
| // repository dependency only for strict declaration resolution. | ||
| const repositoryTiptap = join(repositoryRoot, 'node_modules', '@tiptap'); | ||
| const consumerTiptap = join(consumerDirectory, 'node_modules', '@tiptap'); | ||
| assert.ok(existsSync(repositoryTiptap)); | ||
| symlinkSync(repositoryTiptap, consumerTiptap, 'dir'); | ||
| } | ||
|
|
||
| /** Prove the emitted JavaScript bundle has no interactive framework dependency. */ | ||
| function verifyReactFreeBundles() { | ||
| for (const filename of [ | ||
| 'cwl-text-position-selector.js', | ||
| 'cwl-text-position-selector.cjs', | ||
| ]) { | ||
| const bundlePath = join(packageDirectory, 'dist', filename); | ||
| const bundleSource = readFileSync(bundlePath, 'utf8'); | ||
| assert.doesNotMatch( | ||
| bundleSource, | ||
| forbiddenRuntimePattern, | ||
| `${filename} must not reference interactive framework dependencies`, | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| /** Exercise the exact public ESM and CommonJS subpath from the packed package. */ | ||
| function verifyRuntimeConsumers() { | ||
| const esmPath = join(consumerDirectory, 'consumer.mjs'); | ||
| writeFileSync( | ||
| esmPath, | ||
| `import assert from 'node:assert/strict'; | ||
| import { | ||
| TEXT_POSITION_PROJECTION_ID, | ||
| TEXT_POSITION_PROJECTION_VERSION, | ||
| TextPositionSelectorEvidenceError, | ||
| createTextPositionSelector, | ||
| } from '${packageJson.name}/text-position-selector'; | ||
| assert.equal(TEXT_POSITION_PROJECTION_ID, 'inkspan-prosemirror-text'); | ||
| assert.equal(TEXT_POSITION_PROJECTION_VERSION, 1); | ||
| assert.equal(typeof TextPositionSelectorEvidenceError, 'function'); | ||
| assert.equal(typeof createTextPositionSelector, 'function'); | ||
| `, | ||
| 'utf8', | ||
| ); | ||
|
|
||
| const cjsPath = join(consumerDirectory, 'consumer.cjs'); | ||
| writeFileSync( | ||
| cjsPath, | ||
| `const assert = require('node:assert/strict'); | ||
| const selector = require('${packageJson.name}/text-position-selector'); | ||
| assert.equal(selector.TEXT_POSITION_PROJECTION_ID, 'inkspan-prosemirror-text'); | ||
| assert.equal(selector.TEXT_POSITION_PROJECTION_VERSION, 1); | ||
| assert.equal(typeof selector.TextPositionSelectorEvidenceError, 'function'); | ||
| assert.equal(typeof selector.createTextPositionSelector, 'function'); | ||
| `, | ||
| 'utf8', | ||
| ); | ||
|
|
||
| run(process.execPath, [esmPath], consumerDirectory); | ||
| run(process.execPath, [cjsPath], consumerDirectory); | ||
| } | ||
|
|
||
| /** Compile one strict TypeScript consumer against only the public subpath. */ | ||
| function verifyDeclarationConsumer() { | ||
| const sourcePath = join(consumerDirectory, 'consumer.ts'); | ||
| const configurationPath = join(consumerDirectory, 'tsconfig.json'); | ||
| writeFileSync( | ||
| sourcePath, | ||
| `import { | ||
| TEXT_POSITION_PROJECTION_ID, | ||
| TEXT_POSITION_PROJECTION_VERSION, | ||
| TextPositionSelectorEvidenceError, | ||
| createTextPositionSelector, | ||
| type CwlEditorTextPositionSelector, | ||
| type CwlEditorTextProjectionIdentity, | ||
| type TextPositionSelectorEvidenceErrorCode, | ||
| } from '${packageJson.name}/text-position-selector'; | ||
| import type { Node as ProseMirrorNode } from '@tiptap/pm/model'; | ||
| import type { Selection } from '@tiptap/pm/state'; | ||
| declare const documentNode: ProseMirrorNode; | ||
| declare const selection: Selection; | ||
| const result = createTextPositionSelector(documentNode, selection); | ||
| const selector: CwlEditorTextPositionSelector = result.selector; | ||
| const projection: CwlEditorTextProjectionIdentity = result.textProjection; | ||
| const code: TextPositionSelectorEvidenceErrorCode = 'segmenter_unavailable'; | ||
| const failure = new TextPositionSelectorEvidenceError(code); | ||
| void [ | ||
| selector.start, | ||
| selector.end, | ||
| projection.id === TEXT_POSITION_PROJECTION_ID, | ||
| projection.version === TEXT_POSITION_PROJECTION_VERSION, | ||
| failure.code, | ||
| ]; | ||
| `, | ||
| 'utf8', | ||
| ); | ||
| writeFileSync( | ||
| configurationPath, | ||
| `${JSON.stringify( | ||
| { | ||
| compilerOptions: { | ||
| noEmit: true, | ||
| strict: true, | ||
| skipLibCheck: false, | ||
| module: 'NodeNext', | ||
| moduleResolution: 'NodeNext', | ||
| target: 'ES2022', | ||
| lib: ['ES2022', 'DOM', 'DOM.Iterable'], | ||
| types: [], | ||
| }, | ||
| files: ['./consumer.ts'], | ||
| }, | ||
| null, | ||
| 2, | ||
| )}\n`, | ||
| 'utf8', | ||
| ); | ||
| const compilerPath = join( | ||
| repositoryRoot, | ||
| 'node_modules', | ||
| 'typescript', | ||
| 'bin', | ||
| 'tsc', | ||
| ); | ||
| assert.ok(existsSync(compilerPath)); | ||
| run(process.execPath, [compilerPath, '--project', configurationPath], consumerDirectory); | ||
| } | ||
|
|
||
| try { | ||
| preparePackage(); | ||
| verifyReactFreeBundles(); | ||
| verifyRuntimeConsumers(); | ||
| verifyDeclarationConsumer(); | ||
| console.log( | ||
| `Verified packed ${packageJson.name}/text-position-selector through React-free ESM, CommonJS, and strict TypeScript consumers.`, | ||
| ); | ||
| } finally { | ||
| rmSync(verificationRoot, { recursive: true, force: true }); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| /** | ||
| * React-free W3C text-position selector projection surface. | ||
| * | ||
| * This subpath exposes only deterministic projection primitives. Interactive | ||
| * editor-handle capture and exact revision binding remain on the root Inkspan | ||
| * editor contract. | ||
| */ | ||
| export { | ||
| TEXT_POSITION_PROJECTION_ID, | ||
| TEXT_POSITION_PROJECTION_VERSION, | ||
| TextPositionSelectorEvidenceError, | ||
| createTextPositionSelector, | ||
| } from '../textPositionSelectorEvidence.js'; | ||
| export type { | ||
| CwlEditorTextPositionSelector, | ||
| CwlEditorTextProjectionIdentity, | ||
| TextPositionSelectorEvidenceErrorCode, | ||
| } from '../textPositionSelectorEvidence.js'; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| import { existsSync, readFileSync } from 'node:fs'; | ||
| import { resolve } from 'node:path'; | ||
|
|
||
| import { describe, expect, it } from 'vitest'; | ||
| import { | ||
| TEXT_POSITION_PROJECTION_ID, | ||
| TEXT_POSITION_PROJECTION_VERSION, | ||
| TextPositionSelectorEvidenceError, | ||
| createTextPositionSelector, | ||
| } from './text-position-selector/index.js'; | ||
|
|
||
| /** Read one repository file as UTF-8 text. */ | ||
| function repositoryFile(path: string): string { | ||
| return readFileSync(resolve(process.cwd(), path), 'utf8'); | ||
| } | ||
|
|
||
| const packageMetadata = JSON.parse(repositoryFile('package.json')) as { | ||
| exports: Record<string, unknown>; | ||
| scripts: Record<string, string>; | ||
| }; | ||
|
|
||
| describe('React-free text-position selector package contract', () => { | ||
| it('exposes the deterministic selector core through the source subpath', () => { | ||
| expect(TEXT_POSITION_PROJECTION_ID).toBe('inkspan-prosemirror-text'); | ||
| expect(TEXT_POSITION_PROJECTION_VERSION).toBe(1); | ||
| expect(typeof createTextPositionSelector).toBe('function'); | ||
| expect(new TextPositionSelectorEvidenceError('segmenter_unavailable')).toMatchObject({ | ||
| name: 'TextPositionSelectorEvidenceError', | ||
| code: 'segmenter_unavailable', | ||
| }); | ||
| }); | ||
|
|
||
| it('declares one independently consumable ESM CommonJS and TypeScript subpath', () => { | ||
| expect(packageMetadata.exports['./text-position-selector']).toEqual({ | ||
| types: './dist/text-position-selector/index.d.ts', | ||
| import: './dist/cwl-text-position-selector.js', | ||
| require: './dist/cwl-text-position-selector.cjs', | ||
| }); | ||
| expect(packageMetadata.scripts.build).toContain( | ||
| 'vite build --config vite.text-position-selector.config.ts', | ||
| ); | ||
| expect(packageMetadata.scripts['verify:package']).toContain( | ||
| 'verify-text-position-selector-subpath-package.mjs', | ||
| ); | ||
| expect(existsSync(resolve(process.cwd(), 'vite.text-position-selector.config.ts'))).toBe(true); | ||
| expect(existsSync(resolve(process.cwd(), 'src/text-position-selector/index.ts'))).toBe(true); | ||
| }); | ||
|
|
||
| it('keeps the public distribution guide explicit about the React-free boundary', () => { | ||
| const guide = repositoryFile('docs/package-distribution.md'); | ||
| expect(guide).toContain( | ||
| '`@contextualwisdomlab/cwl-editor/text-position-selector`', | ||
| ); | ||
| expect(guide).toMatch(/React-free[^\n]*text-position/iu); | ||
| expect(guide).toMatch(/ESM[^\n]*CommonJS[^\n]*strict TypeScript/iu); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.