diff --git a/docs/package-distribution.md b/docs/package-distribution.md index 5f983368..d3f16ccb 100644 --- a/docs/package-distribution.md +++ b/docs/package-distribution.md @@ -16,6 +16,7 @@ integrations. | `@contextualwisdomlab/cwl-editor/converter` | Framework-independent base64 and data-URI utilities | | `@contextualwisdomlab/cwl-editor/envelope-identity` | Framework-independent identity-only envelope routing for bounded schema identity inspection; migration remains host-owned | | `@contextualwisdomlab/cwl-editor/revision-evidence` | Framework-independent revision evidence and document-transition evidence for local content equality/lineage claims | +| `@contextualwisdomlab/cwl-editor/text-position-selector` | `implemented_on_active_pr` — React-free text-position projection core implementing W3C `TextPositionSelector`; interactive capture, revision binding, authorization, persistence, and re-anchoring remain outside this subpath | | `@contextualwisdomlab/cwl-editor/styles.css` | Editor layout and theming | | `@contextualwisdomlab/cwl-editor/fonts.css` | Full offline KR/EN/JP/SC/TC/VI font bundle | | `@contextualwisdomlab/cwl-editor/fonts-latin.css` | Smaller Latin/Vietnamese font bundle | @@ -55,11 +56,18 @@ embedded in the npm tarball. and collaboration entrypoints. It is declared in Inkspan's package dependencies so the consumer's package manager installs and resolves it; it is not merely a type-only dependency. -- The framework-independent autosave, converter, envelope-identity, and - revision-evidence entrypoints do not require React UI, a mounted editor, naruon, - contextual-orchestrator, a database, provider credentials, or host transport. - Their individual package-consumer gates additionally prevent framework - dependencies from leaking into subpaths whose public contracts exclude them. +- The framework-independent autosave, converter, envelope-identity, + revision-evidence, and text-position-selector entrypoints do not require React + UI, a mounted editor, naruon, contextual-orchestrator, a database, provider + credentials, or host transport. Their individual package-consumer gates + additionally prevent framework dependencies from leaking into subpaths whose + public contracts exclude them. +- The text-position-selector subpath deliberately exposes only the deterministic + projection constants, error type, selector constructor, and public value + types. It does not expose the React imperative handle that captures editor + state or bind a selector to a document revision. Hosts remain responsible for + annotation identifiers/bodies, source-resource identity, authorization, + tenancy, persistence, audit, and cross-revision re-anchoring. - Envelope identity output is routing metadata only. It does not accept an unsupported document generation as current semantics and does not move schema registry, migration, persistence, rollback, or authorization authority into @@ -85,15 +93,27 @@ production library build. The verification chain: 3. confirms required licenses, declarations, styles, and font assets ship; 4. rejects internal source, tests, demos, Office files, coverage output, and workflow files from the npm tarball; -5. imports the root, collaboration, converter, autosave, envelope-identity, and - revision-evidence surfaces through their dedicated packed-consumer checks, - including framework-free isolation where that is part of the public contract; +5. imports the root, collaboration, converter, autosave, envelope-identity, + revision-evidence, and text-position-selector surfaces through their dedicated + packed-consumer checks, including framework-free isolation where that is part + of the public contract; 6. exercises supported ESM/CommonJS entrypoints and compiles strict TypeScript consumers against the published declaration surfaces; 7. resolves public CSS and font subpaths; and 8. fails when a declared public export is absent, mispackaged, or coupled to a runtime graph that its public contract excludes. +The text-position-selector package check builds a real npm tarball, consumes the +public subpath through ESM and CommonJS, and compiles a strict TypeScript +consumer. Its emitted JavaScript rejects **any external runtime module import**, +so framework, network, database, credential-provider, and model-SDK clients +cannot enter the selector bundle through module dependencies. A separate check +rejects **ambient network and credential authority** such as `fetch`, +`XMLHttpRequest`, `WebSocket`, `EventSource`, `process.env`, `import.meta.env`, +`Deno.env`, and `Bun.env`. Type-only ProseMirror model/state inputs remain part +of the selector's structural contract and introduce no interactive runtime +authority. + A version is release-ready only when this package gate, repository-wide 100% TypeScript coverage, production builds, the Python Office matrix, applicable browser/document-fidelity evidence, and required security/review/release gates diff --git a/package.json b/package.json index 521fbd4b..a217a651 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,11 @@ "import": "./dist/cwl-revision-evidence.js", "require": "./dist/cwl-revision-evidence.cjs" }, + "./text-position-selector": { + "types": "./dist/text-position-selector/index.d.ts", + "import": "./dist/cwl-text-position-selector.js", + "require": "./dist/cwl-text-position-selector.cjs" + }, "./styles.css": "./dist/cwl-editor.css", "./fonts.css": "./src/fonts/fonts.css", "./fonts-latin.css": "./src/fonts/fonts-latin.css", @@ -89,7 +94,7 @@ ], "scripts": { "dev": "vite", - "build": "tsc --noEmit && vite build && vite build --config vite.collaboration.config.ts && vite build --config vite.converter.config.ts && vite build --config vite.envelope-identity.config.ts && vite build --config vite.revision-evidence.config.ts && vite build --config vite.autosave.config.ts && node ./scripts/copy-styles.mjs", + "build": "tsc --noEmit && vite build && vite build --config vite.collaboration.config.ts && vite build --config vite.converter.config.ts && vite build --config vite.envelope-identity.config.ts && vite build --config vite.revision-evidence.config.ts && vite build --config vite.autosave.config.ts && vite build --config vite.text-position-selector.config.ts && node ./scripts/copy-styles.mjs", "build:demo": "vite build --config vite.demo.config.ts", "fonts": "node ./scripts/fetch-fonts.mjs", "preview": "vite preview", @@ -98,7 +103,7 @@ "test:watch": "vitest", "coverage": "vitest run --coverage", "test:package-config": "node --test ./scripts/revision-evidence-consumer-config.test.mjs ./scripts/release-metadata.test.mjs", - "verify:package": "pnpm run test:package-config && node ./tests/package/verify-package.mjs && node ./scripts/verify-canonical-envelope-package.mjs && node ./scripts/verify-revision-evidence-package.mjs && node ./scripts/verify-framework-free-revision-evidence-package.mjs && node ./scripts/verify-framework-free-envelope-identity-package.mjs && node ./tests/package/verify-framework-free-autosave-package.mjs && node ./scripts/verify-text-position-selector-package.mjs" + "verify:package": "pnpm run test:package-config && node ./tests/package/verify-package.mjs && node ./scripts/verify-canonical-envelope-package.mjs && node ./scripts/verify-revision-evidence-package.mjs && node ./scripts/verify-framework-free-revision-evidence-package.mjs && node ./scripts/verify-framework-free-envelope-identity-package.mjs && node ./tests/package/verify-framework-free-autosave-package.mjs && node ./scripts/verify-text-position-selector-package.mjs && node ./scripts/verify-text-position-selector-subpath-package.mjs" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", diff --git a/scripts/verify-text-position-selector-subpath-package.mjs b/scripts/verify-text-position-selector-subpath-package.mjs new file mode 100644 index 00000000..08fb3d98 --- /dev/null +++ b/scripts/verify-text-position-selector-subpath-package.mjs @@ -0,0 +1,219 @@ +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('/'), +); + +// The selector bundle is intentionally self-contained. Type-only ProseMirror +// declarations are allowed, but emitted JavaScript must not acquire runtime +// authority through any external static/dynamic import, re-export, or require. +const externalRuntimeImportPattern = + /(?:\bimport\s*(?:\(\s*['"][^'"]+['"]\s*\)|(?:[^'"\n;]*?\sfrom\s*)?['"][^'"]+['"])|\bexport\s+[^'"\n;]*?\sfrom\s*['"][^'"]+['"]|\brequire\s*\(\s*['"][^'"]+['"]\s*\))/u; + +// A self-contained bundle must also remain free of ambient network and common +// environment-backed credential authority even when no module import is needed. +const ambientAuthorityPattern = + /(?:\bfetch\s*\(|\bXMLHttpRequest\b|\bWebSocket\b|\bEventSource\b|\bprocess\.env\b|\bimport\.meta\.env\b|\bDeno\.env\b|\bBun\.env\b)/u; + +/** 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 emitted JavaScript carries no external or ambient runtime authority. */ +function verifyAuthorityFreeBundles() { + 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, + externalRuntimeImportPattern, + `${filename} must not import external runtime authority`, + ); + assert.doesNotMatch( + bundleSource, + ambientAuthorityPattern, + `${filename} must not reference ambient network or credential authority`, + ); + } +} + +/** 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(); + verifyAuthorityFreeBundles(); + verifyRuntimeConsumers(); + verifyDeclarationConsumer(); + console.log( + `Verified packed ${packageJson.name}/text-position-selector through authority-bounded ESM, CommonJS, and strict TypeScript consumers.`, + ); +} finally { + rmSync(verificationRoot, { recursive: true, force: true }); +} diff --git a/src/text-position-selector/index.ts b/src/text-position-selector/index.ts new file mode 100644 index 00000000..96e20fef --- /dev/null +++ b/src/text-position-selector/index.ts @@ -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'; diff --git a/src/textPositionSelectorPackage.test.ts b/src/textPositionSelectorPackage.test.ts new file mode 100644 index 00000000..dcb1cfcd --- /dev/null +++ b/src/textPositionSelectorPackage.test.ts @@ -0,0 +1,84 @@ +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; + scripts: Record; +}; + +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 active implementation and the React-free boundary', () => { + const guide = repositoryFile('docs/package-distribution.md'); + expect(guide).toContain( + '`@contextualwisdomlab/cwl-editor/text-position-selector`', + ); + expect(guide).toMatch( + /text-position-selector`\s*\|\s*`implemented_on_active_pr`[^\n]*React-free/iu, + ); + expect(guide).toMatch(/ESM[^\n]*CommonJS[^\n]*strict TypeScript/iu); + }); + + it('binds packed verification to no external runtime imports or ambient network and credential authority', () => { + const verifier = repositoryFile( + 'scripts/verify-text-position-selector-subpath-package.mjs', + ); + const guide = repositoryFile('docs/package-distribution.md'); + + expect(verifier).toContain('externalRuntimeImportPattern'); + expect(verifier).toContain('ambientAuthorityPattern'); + for (const requiredBoundary of [ + 'fetch', + 'XMLHttpRequest', + 'WebSocket', + 'EventSource', + 'process\\.env', + 'import\\.meta\\.env', + 'Deno\\.env', + 'Bun\\.env', + 'require', + ]) { + expect(verifier).toContain(requiredBoundary); + } + expect(guide).toMatch(/any external runtime module import/iu); + expect(guide).toMatch(/ambient network[^.]*credential/iu); + }); +}); diff --git a/vite.text-position-selector.config.ts b/vite.text-position-selector.config.ts new file mode 100644 index 00000000..e99d3280 --- /dev/null +++ b/vite.text-position-selector.config.ts @@ -0,0 +1,33 @@ +import { resolve } from 'node:path'; +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; + +// React-free selector projection build: ZERO React, React DOM, TipTap UI/view, +// Yjs, network, credential, persistence, naruon, orchestrator, or model imports. +// ProseMirror model/state appear only in erased TypeScript input types. +export default defineConfig({ + plugins: [ + dts({ + include: [ + 'src/text-position-selector', + 'src/textPositionSelectorEvidence.ts', + ], + exclude: ['src/**/*.test.ts', 'src/**/*.test.tsx', 'src/**/*.spec.ts'], + rollupTypes: false, + entryRoot: 'src', + }), + ], + build: { + emptyOutDir: false, + lib: { + entry: resolve(__dirname, 'src/text-position-selector/index.ts'), + name: 'InkspanTextPositionSelector', + fileName: (format) => + format === 'es' + ? 'cwl-text-position-selector.js' + : 'cwl-text-position-selector.cjs', + formats: ['es', 'cjs'], + }, + sourcemap: true, + }, +});