Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 24 additions & 8 deletions docs/package-distribution.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | React-free text-position projection core implementing W3C `TextPositionSelector`; interactive capture, revision binding, authorization, persistence, and re-anchoring remain outside this subpath |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
| `@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 |
Expand Down Expand Up @@ -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
Expand All @@ -85,15 +93,23 @@ 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, compiles a strict TypeScript consumer,
and rejects emitted runtime references to React, React DOM, TipTap UI/view, Yjs,
naruon, contextual-orchestrator, network, credential, or database graphs. Its
ProseMirror model/state inputs remain part of the selector's structural type
contract rather than an interactive UI 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
Expand Down
9 changes: 7 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
205 changes: 205 additions & 0 deletions scripts/verify-text-position-selector-subpath-package.mjs
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;
Comment thread
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 });
}
18 changes: 18 additions & 0 deletions src/text-position-selector/index.ts
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';
57 changes: 57 additions & 0 deletions src/textPositionSelectorPackage.test.ts
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);
});
});
Loading
Loading