From 14e8f61cf17f9744824f90a9b4a0628ffa48d21f Mon Sep 17 00:00:00 2001 From: Jeremy Mouton Date: Sat, 7 Nov 2026 14:19:09 +0100 Subject: [PATCH] feat: enhance supply-chain security and CI/CD setup - Added minimumReleaseAge to pnpm-workspace.yaml to prevent resolving versions published less than 24 hours ago. - Introduced dependabot configuration for automated dependency updates in .github/dependabot.yml. - Created a CI workflow in .github/workflows/ci.yml to automate testing and auditing of the codebase. - Implemented CSS value sanitization helpers in packages/core/src/schema/sanitizeStyle.ts to prevent CSS injection. - Added UUID generation utility in packages/core/src/utils/generateId.ts for unique block ID creation. - Configured Vite standalone builds for core and React packages in their respective vite.standalone.config.ts files. - Developed a post-build script in scripts/finalize-build.mjs to handle TypeScript declaration files and optional CSS copying. --- .github/dependabot.yml | 27 + .github/workflows/ci.yml | 35 + .github/workflows/deploy.yml | 35 +- .github/workflows/publish.yml | 102 +- CHANGELOG.md | 32 + README.md | 85 +- docs/collaboration.md | 12 +- eslint.config.js | 5 +- examples/basic/package.json | 17 +- examples/basic/src/main.tsx | 1 - examples/basic/vite.config.ts | 8 +- package.json | 20 +- packages/core/README.md | 12 +- packages/core/package.json | 64 +- packages/core/src/blocks/blockToNode.ts | 8 +- packages/core/src/editor/Editor.ts | 20 +- packages/core/src/index.ts | 14 + packages/core/src/plugins/blockIdPlugin.ts | 4 +- packages/core/src/schema/index.ts | 4 + .../core/src/schema/marks/backgroundColor.ts | 21 +- packages/core/src/schema/marks/textColor.ts | 18 +- packages/core/src/schema/nodes/column.ts | 7 +- packages/core/src/schema/nodes/columnList.ts | 7 +- packages/core/src/schema/nodes/embed.ts | 25 +- packages/core/src/schema/nodes/heading.ts | 19 +- packages/core/src/schema/nodes/image.ts | 15 +- packages/core/src/schema/nodes/paragraph.ts | 9 +- packages/core/src/schema/nodes/tableCell.ts | 19 +- packages/core/src/schema/sanitizeStyle.ts | 79 + packages/core/src/utils/generateId.ts | 49 + packages/core/vite.config.ts | 47 +- packages/core/vite.standalone.config.ts | 43 + packages/react/package.json | 35 +- packages/react/src/components/LinkPopover.tsx | 11 +- packages/react/vite.config.ts | 51 +- packages/react/vite.standalone.config.ts | 38 + pnpm-lock.yaml | 2765 +++++++---------- pnpm-workspace.yaml | 4 + scripts/finalize-build.mjs | 33 + 39 files changed, 1922 insertions(+), 1878 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 packages/core/src/schema/sanitizeStyle.ts create mode 100644 packages/core/src/utils/generateId.ts create mode 100644 packages/core/vite.standalone.config.ts create mode 100644 packages/react/vite.standalone.config.ts create mode 100644 scripts/finalize-build.mjs diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9b067d4 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,27 @@ +version: 2 +updates: + # npm dependencies of the pnpm workspace (root, packages/*, examples/*) + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + # Same supply-chain delay as pnpm's minimumReleaseAge + cooldown: + default-days: 3 + groups: + prosemirror: + patterns: ['prosemirror-*'] + dev-tooling: + dependency-type: development + update-types: [minor, patch] + + # GitHub Actions (pinned by commit SHA) + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + cooldown: + default-days: 3 + groups: + actions: + patterns: ['*'] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f16c522 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,35 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # pnpm version comes from the "packageManager" field of package.json + - uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '24' + cache: 'pnpm' + - run: pnpm install --frozen-lockfile + - run: pnpm lint + # Build first: the react package and the example type-check against the built core + - run: pnpm build + - run: pnpm typecheck + # Fails on known vulnerabilities in the dependencies shipped to users + - name: Audit production dependencies + run: pnpm audit --prod diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 55c6e65..c3bb825 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,30 +5,34 @@ on: branches: [main] workflow_dispatch: -permissions: - contents: read - pages: write - id-token: write +permissions: {} + +concurrency: + group: pages + cancel-in-progress: false jobs: build: runs-on: ubuntu-latest + permissions: + contents: read env: GITHUB_PAGES: true steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - version: 8 - - uses: actions/setup-node@v4 + persist-credentials: false + # pnpm version comes from the "packageManager" field of package.json + - uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1 + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: '20' + node-version: '24' cache: 'pnpm' - - run: pnpm install - - run: pnpm --filter @labbs/openblock-core build && pnpm --filter @labbs/openblock-react build + - run: pnpm install --frozen-lockfile + - run: pnpm --filter "./packages/*" build - run: pnpm --filter @labbs/openblock-example-basic build - - uses: actions/configure-pages@v4 - - uses: actions/upload-pages-artifact@v3 + - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 + - uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: './examples/basic/dist' @@ -38,6 +42,9 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest needs: build + permissions: + pages: write + id-token: write steps: - - uses: actions/deploy-pages@v4 + - uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1 id: deployment diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5bbfe48..d54e4e2 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,62 +1,94 @@ -name: Publish to GitHub Packages +name: Publish + +# Runs when a GitHub Release is published (tag vX.Y.Z or vX.Y.Z-prerelease): +# 1. publishes @labbs/openblock-core and @labbs/openblock-react to GitHub Packages +# 2. attaches the ready-to-use builds to the Release: +# - standalone browser bundles (openblock-core.esm.js / .iife.js, openblock-react.esm.js) +# - openblock.css +# - the npm tarballs (installable with `npm install `, no registry token needed) +# - SHA256SUMS.txt on: release: types: [published] +permissions: {} + +concurrency: + group: publish-${{ github.event.release.tag_name }} + cancel-in-progress: false + jobs: publish: runs-on: ubuntu-latest permissions: - contents: read - packages: write + contents: write # upload release assets + packages: write # publish to GitHub Packages steps: - - uses: actions/checkout@v4 - - - uses: pnpm/action-setup@v2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - version: 8 + persist-credentials: false + + # pnpm version comes from the "packageManager" field of package.json + - uses: pnpm/action-setup@73e46992e05b19abd31f2f3c20e0e7370d762115 # v6.1.1 - - uses: actions/setup-node@v4 + # No dependency cache in the release job (avoids cache poisoning) + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: - node-version: '20' - cache: 'pnpm' + node-version: '24' registry-url: 'https://npm.pkg.github.com' scope: '@labbs' + package-manager-cache: false - - name: Extract version from tag + - name: Extract and validate version from tag id: version + env: + TAG: ${{ github.event.release.tag_name }} run: | - # Remove 'v' prefix from tag (v1.0.0-beta2 -> 1.0.0-beta2) - VERSION=${GITHUB_REF_NAME#v} - echo "version=$VERSION" >> $GITHUB_OUTPUT - echo "Publishing version: $VERSION" + VERSION="${TAG#v}" + if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]]; then + echo "::error::Tag '$TAG' is not a valid version (expected vX.Y.Z or vX.Y.Z-prerelease)" + exit 1 + fi + # Pre-releases (1.2.0-beta.1) are published under the "next" dist-tag + if [[ "$VERSION" == *-* ]]; then DIST_TAG=next; else DIST_TAG=latest; fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "dist_tag=$DIST_TAG" >> "$GITHUB_OUTPUT" + echo "Publishing version $VERSION (dist-tag: $DIST_TAG)" - - name: Update package versions + - run: pnpm install --frozen-lockfile + + # `workspace:*` dependencies are rewritten to the exact version by `pnpm publish` + - name: Set package versions + env: + VERSION: ${{ steps.version.outputs.version }} run: | - VERSION=${{ steps.version.outputs.version }} - # Update core package version - cd packages/core - npm pkg set version="$VERSION" - cd ../react - # Update react package version - npm pkg set version="$VERSION" - # Update dependency on core to use exact version - npm pkg set dependencies.@labbs/openblock-core="$VERSION" - - - run: pnpm install --no-frozen-lockfile + for pkg in packages/core packages/react; do + (cd "$pkg" && npm pkg set version="$VERSION") + done - name: Build packages - run: | - pnpm --filter @labbs/openblock-core build - pnpm --filter @labbs/openblock-react build + run: pnpm --filter "./packages/*" build - - name: Publish @labbs/openblock-core - run: pnpm --filter @labbs/openblock-core publish --access public --no-git-checks + - name: Publish to GitHub Packages env: NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DIST_TAG: ${{ steps.version.outputs.dist_tag }} + run: pnpm --filter "./packages/*" publish --access public --no-git-checks --tag "$DIST_TAG" - - name: Publish @labbs/openblock-react - run: pnpm --filter @labbs/openblock-react publish --access public --no-git-checks + - name: Prepare release assets + run: | + mkdir -p release + pnpm --filter "./packages/*" pack --pack-destination "$PWD/release" + cp packages/core/dist/standalone/openblock-core.esm.js* release/ + cp packages/core/dist/standalone/openblock-core.iife.js* release/ + cp packages/react/dist/standalone/openblock-react.esm.js* release/ + cp packages/core/dist/styles/editor.css release/openblock.css + (cd release && sha256sum -- * > SHA256SUMS.txt) + ls -la release + + - name: Upload assets to the GitHub Release env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ github.token }} + TAG: ${{ github.event.release.tag_name }} + run: gh release upload "$TAG" release/* --clobber --repo "$GITHUB_REPOSITORY" diff --git a/CHANGELOG.md b/CHANGELOG.md index b0aadb3..97ad539 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,38 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Security +- **prosemirror-view upgraded to 1.42.9** — fixes an XSS in paste handling (GHSA, affected `< 1.42.3`). +- **CSS injection** — style-bearing attributes (`textColor` / `backgroundColor` marks, `textAlign`, table cell + `colwidth` / `backgroundColor`, column `width`, columnList `gap`, image `width` / `alignment`, heading `level`) are + now validated in both `parseDOM` and `toDOM`. Values arriving through JSON, Y.js collaboration or raw `data-*` + attributes in pasted HTML can no longer inject arbitrary CSS (e.g. tracking `background-image: url(...)` or + full-page `position: fixed` overlays). +- **Embed sandbox** — generic embeds pointing at the host page's own origin no longer get `allow-same-origin` + (combined with `allow-scripts` it lets a same-origin page escape the iframe sandbox). +- `editor.setLink()` ignores unsafe hrefs (`javascript:`…) instead of storing them in the document. +- React `LinkPopover`: "open link" validates the href before calling `window.open`. + +### Added +- **CommonJS builds** (`require('@labbs/openblock-core')`) alongside ESM, with bundled `.d.ts` / `.d.cts` declarations. +- **Standalone browser bundles**: `openblock-core.iife.js` (`window.OpenBlock`), `openblock-core.esm.js`, + `openblock-react.esm.js` — exported as `@labbs/openblock-core/standalone`, `@labbs/openblock-core/standalone/iife` + and `@labbs/openblock-react/standalone`. +- Release workflow attaches the npm tarballs, standalone bundles, `openblock.css` and `SHA256SUMS.txt` to each + GitHub Release; pre-release tags publish under the `next` dist-tag. +- Public sanitization helpers: `sanitizeLinkHref`, `sanitizeImageSrc`, `sanitizeEmbedUrl`, `sanitizeCssColor`, + `sanitizeTextAlign`, `toNonNegativeNumber`, and `generateId`. +- CI workflow (lint, build, type-check, production dependency audit) and Dependabot configuration. + +### Changed +- Removed the `uuid` runtime dependency (block IDs use the Web Crypto API). +- React peer dependency is now `^18.0.0 || ^19.0.0`; ref props accept React 19 `RefObject`. +- `main` now points to the CommonJS build (`module` / `exports.import` still resolve to ESM). +- Tooling: Vite 8, vite-plugin-dts 5, TypeScript 6.0, ESLint 10, pnpm 10 (pinned via `packageManager`), + Node.js 24 in CI; GitHub Actions pinned by commit SHA. + ## [1.0.0] - 2026-01-25 ### Added diff --git a/README.md b/README.md index b5775ab..923b005 100644 --- a/README.md +++ b/README.md @@ -44,18 +44,83 @@ editor.pm.setNodeAttrs(pos, attrs) ### Installation -First, configure npm to use GitHub Packages for the `@labbs` scope. Create or edit `.npmrc` in your project: +Each [GitHub Release](https://github.com/Labbs/openblock/releases) provides three ways to get OpenBlock: + +#### 1. From GitHub Packages (npm registry) + +GitHub Packages requires authentication, even for public packages. Create a +[personal access token](https://github.com/settings/tokens) with the `read:packages` scope, then add to your project's `.npmrc`: ``` @labbs:registry=https://npm.pkg.github.com +//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} ``` -Then install the packages: - ```bash npm install @labbs/openblock-core @labbs/openblock-react ``` +#### 2. From the Release tarballs (no registry, no token) + +The npm packages are attached to every release and can be installed directly from their URL: + +```bash +VERSION=1.0.0 +npm install \ + https://github.com/Labbs/openblock/releases/download/v$VERSION/labbs-openblock-core-$VERSION.tgz \ + https://github.com/Labbs/openblock/releases/download/v$VERSION/labbs-openblock-react-$VERSION.tgz +``` + +Install both tarballs in the same command: `@labbs/openblock-react` depends on the exact same version of `@labbs/openblock-core`. + +#### 3. Standalone browser bundles (no build step) + +Each release also ships self-contained, minified builds. Download them and serve them with your app +(GitHub release downloads cannot be hot-linked from a ` + +``` + +ES modules work the same way (`import { OpenBlockEditor } from '/vendor/openblock-core.esm.js'`). +For React without a bundler, resolve the bare imports with an import map. Map `@labbs/openblock-core` to the +core bundle so the page only loads one copy of ProseMirror: + +```html + + +``` + +When installed from npm, the same files are available as `@labbs/openblock-core/standalone`, +`@labbs/openblock-core/standalone/iife` and `@labbs/openblock-react/standalone`. + +The npm packages ship ESM and CommonJS builds (`import` and `require` both work) with bundled TypeScript declarations. + ### React ```tsx @@ -326,17 +391,29 @@ See [Custom Blocks Guide](docs/custom-blocks.md) for more details. ## Development +Requires Node.js >= 20.19 and pnpm 10 (the exact version is pinned in `package.json` → `corepack enable` picks it up). + ```bash # Install dependencies pnpm install -# Build all packages +# Build all packages (ESM + CJS + standalone bundles) pnpm build +# Lint and type-check +pnpm lint +pnpm typecheck + # Run example in dev mode pnpm dev ``` +### Releasing + +Publishing a GitHub Release with a `vX.Y.Z` tag (or `vX.Y.Z-beta.1` for a pre-release, published under the `next` dist-tag) triggers +`.github/workflows/publish.yml`. It publishes both packages to GitHub Packages and attaches the tarballs, +standalone bundles, CSS and checksums to the release. + ## License [Apache-2.0](LICENSE) diff --git a/docs/collaboration.md b/docs/collaboration.md index 05eb497..59b96c1 100644 --- a/docs/collaboration.md +++ b/docs/collaboration.md @@ -12,12 +12,12 @@ npm install yjs y-prosemirror y-websocket pnpm add yjs y-prosemirror y-websocket ``` -> `yjs` and `y-prosemirror` are optional peer dependencies of `@openblock/core`. They are only needed if you use collaboration features. +> `yjs` and `y-prosemirror` are optional peer dependencies of `@labbs/openblock-core`. They are only needed if you use collaboration features. ## Quick Start ```typescript -import { OpenBlockEditor } from '@openblock/core'; +import { OpenBlockEditor } from '@labbs/openblock-core'; import * as Y from 'yjs'; import { WebsocketProvider } from 'y-websocket'; import { ySyncPlugin, yCursorPlugin, yUndoPlugin } from 'y-prosemirror'; @@ -121,7 +121,7 @@ You can still call `editor.enableHistory()` later to add it at runtime. ## Full Example: Toggle Collaboration On/Off ```typescript -import { OpenBlockEditor } from '@openblock/core'; +import { OpenBlockEditor } from '@labbs/openblock-core'; import * as Y from 'yjs'; import { WebsocketProvider } from 'y-websocket'; import { ySyncPlugin, yCursorPlugin, yUndoPlugin } from 'y-prosemirror'; @@ -168,14 +168,14 @@ document.getElementById('collab-toggle')?.addEventListener('click', () => { ## Using with React ```tsx -import { useOpenBlockEditor } from '@openblock/react'; +import { useOpenBlock, OpenBlockView } from '@labbs/openblock-react'; import * as Y from 'yjs'; import { WebsocketProvider } from 'y-websocket'; import { ySyncPlugin, yCursorPlugin, yUndoPlugin } from 'y-prosemirror'; import { useEffect, useRef } from 'react'; function CollaborativeEditor({ roomId }: { roomId: string }) { - const { editor, ref } = useOpenBlockEditor({}); + const editor = useOpenBlock({}); const providerRef = useRef(null); useEffect(() => { @@ -201,7 +201,7 @@ function CollaborativeEditor({ roomId }: { roomId: string }) { }; }, [editor, roomId]); - return
; + return ; } ``` diff --git a/eslint.config.js b/eslint.config.js index 153a50f..afde6a8 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,10 +1,11 @@ import eslint from '@eslint/js'; +import { defineConfig } from 'eslint/config'; import tseslint from 'typescript-eslint'; import globals from 'globals'; -export default tseslint.config( +export default defineConfig( eslint.configs.recommended, - ...tseslint.configs.recommended, + tseslint.configs.recommended, { languageOptions: { globals: { diff --git a/examples/basic/package.json b/examples/basic/package.json index b4c5442..3b3635e 100644 --- a/examples/basic/package.json +++ b/examples/basic/package.json @@ -6,19 +6,20 @@ "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "typecheck": "tsc --noEmit -p ." }, "dependencies": { "@labbs/openblock-core": "workspace:*", "@labbs/openblock-react": "workspace:*", - "react": "^18.3.1", - "react-dom": "^18.3.1" + "react": "^19.3.1", + "react-dom": "^19.3.1" }, "devDependencies": { - "@types/react": "^18.3.18", - "@types/react-dom": "^18.3.5", - "@vitejs/plugin-react": "^4.3.4", - "typescript": "^5.7.2", - "vite": "^6.0.7" + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@vitejs/plugin-react": "^6.2.1", + "typescript": "~6.0.3", + "vite": "^8.3.6" } } diff --git a/examples/basic/src/main.tsx b/examples/basic/src/main.tsx index 4a0f2de..dee6b69 100644 --- a/examples/basic/src/main.tsx +++ b/examples/basic/src/main.tsx @@ -1,4 +1,3 @@ -import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App'; diff --git a/examples/basic/vite.config.ts b/examples/basic/vite.config.ts index f5278f6..33232dc 100644 --- a/examples/basic/vite.config.ts +++ b/examples/basic/vite.config.ts @@ -1,6 +1,6 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; -import path from 'path'; +import path from 'node:path'; export default defineConfig({ plugins: [react()], @@ -8,9 +8,9 @@ export default defineConfig({ resolve: { alias: { // Resolve @labbs/openblock packages to their source folders for live development - '@labbs/openblock-core/styles/editor.css': path.resolve(__dirname, '../../packages/core/src/styles/editor.css'), - '@labbs/openblock-core': path.resolve(__dirname, '../../packages/core/src'), - '@labbs/openblock-react': path.resolve(__dirname, '../../packages/react/src'), + '@labbs/openblock-core/styles/editor.css': path.resolve(import.meta.dirname, '../../packages/core/src/styles/editor.css'), + '@labbs/openblock-core': path.resolve(import.meta.dirname, '../../packages/core/src'), + '@labbs/openblock-react': path.resolve(import.meta.dirname, '../../packages/react/src'), }, }, }); diff --git a/package.json b/package.json index e47844b..221bed9 100644 --- a/package.json +++ b/package.json @@ -27,17 +27,19 @@ "dev": "pnpm -r --parallel dev", "lint": "eslint packages/*/src", "test": "pnpm -r test", - "clean": "pnpm -r clean" + "clean": "pnpm -r clean", + "typecheck": "pnpm -r typecheck" }, "devDependencies": { - "@eslint/js": "^9.39.2", - "eslint": "^9.39.2", - "globals": "^17.1.0", - "typescript": "^5.7.2", - "typescript-eslint": "^8.53.1" + "@eslint/js": "^10.0.1", + "eslint": "^10.14.0", + "globals": "^17.14.0", + "typescript": "~6.0.3", + "typescript-eslint": "^8.72.1" }, "engines": { - "node": ">=18.0.0", - "pnpm": ">=8.0.0" - } + "node": ">=20.19.0", + "pnpm": ">=10.0.0" + }, + "packageManager": "pnpm@10.34.9" } diff --git a/packages/core/README.md b/packages/core/README.md index 86d54e3..4ad7d9f 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -1,4 +1,4 @@ -# @openblock/core +# @labbs/openblock-core Framework-agnostic core for the OpenBlock rich text editor. @@ -12,16 +12,16 @@ Framework-agnostic core for the OpenBlock rich text editor. ## Installation ```bash -npm install @openblock/core +npm install @labbs/openblock-core # or -pnpm add @openblock/core +pnpm add @labbs/openblock-core ``` ## Quick Start ```typescript -import { OpenBlockEditor } from '@openblock/core'; -import '@openblock/core/styles/editor.css'; +import { OpenBlockEditor } from '@labbs/openblock-core'; +import '@labbs/openblock-core/styles/editor.css'; const editor = new OpenBlockEditor({ element: document.getElementById('editor'), @@ -77,7 +77,7 @@ editor.pm.addPlugin(myPlugin); Import the default styles, which use CSS variables compatible with shadcn/ui: ```typescript -import '@openblock/core/styles/editor.css'; +import '@labbs/openblock-core/styles/editor.css'; ``` Or create your own styles targeting `.openblock-editor` and `.ProseMirror`. diff --git a/packages/core/package.json b/packages/core/package.json index 2604bc0..704bdc7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -4,16 +4,28 @@ "description": "Framework-agnostic core for OpenBlock editor", "license": "Apache-2.0", "type": "module", - "main": "./dist/index.js", + "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { ".": { + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./standalone": { "types": "./dist/index.d.ts", - "import": "./dist/index.js" + "default": "./dist/standalone/openblock-core.esm.js" }, + "./standalone/iife": "./dist/standalone/openblock-core.iife.js", "./styles": "./dist/styles/editor.css", - "./styles/editor.css": "./dist/styles/editor.css" + "./styles/editor.css": "./dist/styles/editor.css", + "./package.json": "./package.json" }, "files": [ "dist" @@ -31,38 +43,42 @@ "document-editor" ], "scripts": { - "build": "vite build && mkdir -p dist/styles && cp src/styles/editor.css dist/styles/", + "build": "vite build && vite build -c vite.standalone.config.ts && node ../../scripts/finalize-build.mjs --css src/styles/editor.css", "dev": "vite build --watch", + "typecheck": "tsc --noEmit -p .", "lint": "eslint src", "clean": "rm -rf dist" }, "dependencies": { - "prosemirror-commands": "^1.6.2", - "prosemirror-dropcursor": "^1.8.1", - "prosemirror-gapcursor": "^1.3.2", - "prosemirror-history": "^1.4.1", - "prosemirror-inputrules": "^1.4.0", - "prosemirror-keymap": "^1.2.2", - "prosemirror-model": "^1.24.1", - "prosemirror-schema-list": "^1.4.1", - "prosemirror-state": "^1.4.3", - "prosemirror-transform": "^1.10.2", - "prosemirror-view": "^1.37.1", - "uuid": "^11.0.5" + "prosemirror-commands": "^1.7.2", + "prosemirror-dropcursor": "^1.8.4", + "prosemirror-gapcursor": "^1.4.1", + "prosemirror-history": "^1.5.1", + "prosemirror-inputrules": "^1.5.1", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.12", + "prosemirror-schema-list": "^1.6.0", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.12.5", + "prosemirror-view": "^1.42.9" }, "devDependencies": { - "@types/uuid": "^10.0.0", - "typescript": "^5.7.2", - "vite": "^6.0.7", - "vite-plugin-dts": "^4.4.0" + "@microsoft/api-extractor": "^7.60.1", + "typescript": "~6.0.3", + "vite": "^8.3.6", + "vite-plugin-dts": "^5.1.2" }, "peerDependencies": { - "yjs": "^13.0.0", - "y-prosemirror": "^1.0.0" + "y-prosemirror": "^1.3.0", + "yjs": "^13.6.0" }, "peerDependenciesMeta": { - "yjs": { "optional": true }, - "y-prosemirror": { "optional": true } + "yjs": { + "optional": true + }, + "y-prosemirror": { + "optional": true + } }, "sideEffects": [ "*.css" diff --git a/packages/core/src/blocks/blockToNode.ts b/packages/core/src/blocks/blockToNode.ts index e93ddd2..95aa6bf 100644 --- a/packages/core/src/blocks/blockToNode.ts +++ b/packages/core/src/blocks/blockToNode.ts @@ -5,7 +5,7 @@ */ import { Schema, Node as PMNode, Mark } from 'prosemirror-model'; -import { v4 as uuid } from 'uuid'; +import { generateId } from '../utils/generateId'; import type { Block, InlineContent, TextStyles } from './types'; @@ -40,11 +40,11 @@ export function blockToNode(schema: Schema, block: Block): PMNode { if (!nodeType) { // Fallback to paragraph for unknown block types console.warn(`Unknown block type: ${block.type}, falling back to paragraph`); - return schema.node('paragraph', { id: block.id || uuid() }); + return schema.node('paragraph', { id: block.id || generateId() }); } const attrs = { - id: block.id || uuid(), + id: block.id || generateId(), ...block.props, }; @@ -182,7 +182,7 @@ export function blocksToDoc(schema: Schema, blocks?: Block[]): PMNode { if (!blocks || blocks.length === 0) { // Create empty paragraph as default content return schema.node('doc', null, [ - schema.node('paragraph', { id: uuid() }), + schema.node('paragraph', { id: generateId() }), ]); } diff --git a/packages/core/src/editor/Editor.ts b/packages/core/src/editor/Editor.ts index e140a30..02bc60b 100644 --- a/packages/core/src/editor/Editor.ts +++ b/packages/core/src/editor/Editor.ts @@ -26,11 +26,11 @@ import { EditorView } from 'prosemirror-view'; import { EditorState, Transaction, Plugin, Selection } from 'prosemirror-state'; import { Schema } from 'prosemirror-model'; import { history as createHistoryPlugin } from 'prosemirror-history'; -import { v4 as uuid } from 'uuid'; +import { generateId } from '../utils/generateId'; import { ProseMirrorAPI } from '../pm/ProseMirrorAPI'; import { EditorConfig, defaultConfig, EditorEvents, EventHandler, CollaborationConfig } from './EditorConfig'; -import { createSchema } from '../schema'; +import { createSchema, sanitizeLinkHref } from '../schema'; import { createPlugins, undo, redo } from '../plugins'; import { injectStyles } from '../styles/injectStyles'; import { createPlaceholderPlugin } from '../plugins/placeholderPlugin'; @@ -314,7 +314,7 @@ export class OpenBlockEditor { const insertPos = placement === 'before' ? found.pos : found.pos + found.node.nodeSize; const nodes = blocks.map((block) => - blockToNode(this._schema, { ...block, id: block.id || uuid() } as Block) + blockToNode(this._schema, { ...block, id: block.id || generateId() } as Block) ); const tr = this.pm.createTransaction(); @@ -581,11 +581,19 @@ export class OpenBlockEditor { /** * Add a link to the current selection. * + * Only http:, https:, mailto:, tel: and relative URLs are accepted; + * unsafe hrefs (e.g. `javascript:`) are ignored. + * * @param href - The URL for the link * @param title - Optional title attribute */ setLink(href: string, title?: string): void { - this.pm.addMark('link', { href, title }); + const safeHref = sanitizeLinkHref(href); + if (safeHref === null) { + console.warn(`[OpenBlock] Ignoring link with unsafe href: ${href}`); + return; + } + this.pm.addMark('link', { href: safeHref, title }); } /** Remove link from the current selection. */ @@ -789,8 +797,8 @@ export class OpenBlockEditor { const blockId = blockNode.attrs.id; // Create the list structure: list > listItem > paragraph - const paragraph = paragraphType.create({ id: uuid() }, content); - const listItem = listItemType.create({ id: uuid() }, paragraph); + const paragraph = paragraphType.create({ id: generateId() }, content); + const listItem = listItemType.create({ id: generateId() }, paragraph); const list = listNodeType.create({ id: blockId }, listItem); const tr = this.pm.createTransaction(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 99ef959..098bb50 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -105,6 +105,20 @@ export { linkMark, } from './schema'; +// Sanitization helpers - validate user-provided URLs and CSS values before +// rendering them (useful in custom blocks / node views) +export { + sanitizeLinkHref, + sanitizeImageSrc, + sanitizeEmbedUrl, + sanitizeCssColor, + sanitizeTextAlign, + toNonNegativeNumber, +} from './schema'; + +// Unique block ID generator (random v4 UUID) +export { generateId } from './utils/generateId'; + // =========================================================================== // BLOCKS // =========================================================================== diff --git a/packages/core/src/plugins/blockIdPlugin.ts b/packages/core/src/plugins/blockIdPlugin.ts index 99fda70..ef967f1 100644 --- a/packages/core/src/plugins/blockIdPlugin.ts +++ b/packages/core/src/plugins/blockIdPlugin.ts @@ -7,7 +7,7 @@ */ import { Plugin, PluginKey } from 'prosemirror-state'; -import { v4 as uuid } from 'uuid'; +import { generateId } from '../utils/generateId'; /** * Plugin key for the block ID tracking plugin. @@ -64,7 +64,7 @@ export function createBlockIdPlugin(): Plugin { if (!id || seenIds.has(id)) { nodesToUpdate.push({ pos, - attrs: { ...node.attrs, id: uuid() }, + attrs: { ...node.attrs, id: generateId() }, }); } else { seenIds.add(id); diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts index 0e973d9..948e575 100644 --- a/packages/core/src/schema/index.ts +++ b/packages/core/src/schema/index.ts @@ -46,3 +46,7 @@ export { textColorMark, backgroundColorMark, } from './marks'; + +// Sanitization helpers (also used by the React bindings and custom blocks) +export { sanitizeLinkHref, sanitizeImageSrc, sanitizeEmbedUrl } from './sanitizeUrl'; +export { sanitizeCssColor, sanitizeTextAlign, toNonNegativeNumber } from './sanitizeStyle'; diff --git a/packages/core/src/schema/marks/backgroundColor.ts b/packages/core/src/schema/marks/backgroundColor.ts index 8dd82d4..80aad87 100644 --- a/packages/core/src/schema/marks/backgroundColor.ts +++ b/packages/core/src/schema/marks/backgroundColor.ts @@ -6,11 +6,17 @@ import type { MarkSpec, DOMOutputSpec, Mark } from 'prosemirror-model'; +import { sanitizeCssColor } from '../sanitizeStyle'; + /** * Background color mark spec. * * Renders as `` in HTML. * + * The color is validated (hex, color functions, named colors and + * `var(--x)` only): invalid colors are ignored when parsing DOM and not + * rendered (attrs can also arrive through JSON, bypassing parseDOM). + * * @example * ```typescript * // In block content: @@ -31,29 +37,32 @@ export const backgroundColorMark: MarkSpec = { { style: 'background-color', getAttrs(value: string) { - return { color: value }; + const color = sanitizeCssColor(value); + return color === null ? false : { color }; }, }, { tag: 'span[data-background-color]', getAttrs(dom: HTMLElement) { - return { color: dom.getAttribute('data-background-color') }; + const color = sanitizeCssColor(dom.getAttribute('data-background-color')); + return color === null ? false : { color }; }, }, { tag: 'mark', getAttrs(dom: HTMLElement) { - const bgColor = dom.style.backgroundColor; - return { color: bgColor || '#ffff00' }; + return { color: sanitizeCssColor(dom.style.backgroundColor) ?? '#ffff00' }; }, }, ], toDOM(mark: Mark): DOMOutputSpec { + const color = sanitizeCssColor(mark.attrs.color); + if (color === null) return ['span', 0]; return [ 'span', { - 'data-background-color': mark.attrs.color, - style: `background-color: ${mark.attrs.color}; border-radius: 2px; padding: 0 2px;`, + 'data-background-color': color, + style: `background-color: ${color}; border-radius: 2px; padding: 0 2px;`, }, 0, ]; diff --git a/packages/core/src/schema/marks/textColor.ts b/packages/core/src/schema/marks/textColor.ts index cbfa2b0..105312e 100644 --- a/packages/core/src/schema/marks/textColor.ts +++ b/packages/core/src/schema/marks/textColor.ts @@ -6,11 +6,17 @@ import type { MarkSpec, DOMOutputSpec, Mark } from 'prosemirror-model'; +import { sanitizeCssColor } from '../sanitizeStyle'; + /** * Text color mark spec. * * Renders as `` in HTML. * + * The color is validated (hex, color functions, named colors and + * `var(--x)` only): invalid colors are ignored when parsing DOM and not + * rendered (attrs can also arrive through JSON, bypassing parseDOM). + * * @example * ```typescript * // In block content: @@ -31,22 +37,26 @@ export const textColorMark: MarkSpec = { { style: 'color', getAttrs(value: string) { - return { color: value }; + const color = sanitizeCssColor(value); + return color === null ? false : { color }; }, }, { tag: 'span[data-text-color]', getAttrs(dom: HTMLElement) { - return { color: dom.getAttribute('data-text-color') }; + const color = sanitizeCssColor(dom.getAttribute('data-text-color')); + return color === null ? false : { color }; }, }, ], toDOM(mark: Mark): DOMOutputSpec { + const color = sanitizeCssColor(mark.attrs.color); + if (color === null) return ['span', 0]; return [ 'span', { - 'data-text-color': mark.attrs.color, - style: `color: ${mark.attrs.color}`, + 'data-text-color': color, + style: `color: ${color}`, }, 0, ]; diff --git a/packages/core/src/schema/nodes/column.ts b/packages/core/src/schema/nodes/column.ts index 0a3a3d5..6030bc2 100644 --- a/packages/core/src/schema/nodes/column.ts +++ b/packages/core/src/schema/nodes/column.ts @@ -21,6 +21,7 @@ import { NodeSpec, DOMOutputSpec, Node as PMNode } from 'prosemirror-model'; import { getBlockIdAttrs, blockIdToDOM, safeParseInt } from '../blockIdAttrs'; +import { toNonNegativeNumber } from '../sanitizeStyle'; /** * Column node spec for ProseMirror. @@ -54,11 +55,13 @@ export const columnNode: NodeSpec = { ], toDOM(node: PMNode): DOMOutputSpec { + // Attrs can come from JSON / collaboration, so re-validate here. + const width = toNonNegativeNumber(node.attrs.width) ?? 50; const attrs: Record = { 'data-column': '', - 'data-width': String(node.attrs.width), + 'data-width': String(width), class: 'ob-column', - style: `flex: ${node.attrs.width} 0 0; min-width: 0;`, + style: `flex: ${width} 0 0; min-width: 0;`, ...blockIdToDOM(node), }; diff --git a/packages/core/src/schema/nodes/columnList.ts b/packages/core/src/schema/nodes/columnList.ts index d76f994..c95b457 100644 --- a/packages/core/src/schema/nodes/columnList.ts +++ b/packages/core/src/schema/nodes/columnList.ts @@ -21,6 +21,7 @@ import { NodeSpec, DOMOutputSpec, Node as PMNode } from 'prosemirror-model'; import { getBlockIdAttrs, blockIdToDOM, safeParseInt } from '../blockIdAttrs'; +import { toNonNegativeNumber } from '../sanitizeStyle'; /** * ColumnList node spec for ProseMirror. @@ -53,11 +54,13 @@ export const columnListNode: NodeSpec = { ], toDOM(node: PMNode): DOMOutputSpec { + // Attrs can come from JSON / collaboration, so re-validate here. + const gap = toNonNegativeNumber(node.attrs.gap) ?? 16; const attrs: Record = { 'data-column-list': '', - 'data-gap': String(node.attrs.gap), + 'data-gap': String(gap), class: 'ob-column-list', - style: `gap: ${node.attrs.gap}px`, + style: `gap: ${gap}px`, ...blockIdToDOM(node), }; diff --git a/packages/core/src/schema/nodes/embed.ts b/packages/core/src/schema/nodes/embed.ts index cac3338..aa4e559 100644 --- a/packages/core/src/schema/nodes/embed.ts +++ b/packages/core/src/schema/nodes/embed.ts @@ -152,7 +152,7 @@ export const embedNode: NodeSpec = { frameborder: '0', allowfullscreen: 'true', allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture', - sandbox: 'allow-scripts allow-same-origin allow-presentation allow-popups', + sandbox: getIframeSandbox(embedUrl), referrerpolicy: 'strict-origin-when-cross-origin', loading: 'lazy', }, @@ -170,6 +170,29 @@ export const embedNode: NodeSpec = { }, }; +/** + * Returns the sandbox for an embed iframe. + * + * Most providers need `allow-same-origin` (cookies, storage), but combined + * with `allow-scripts` it lets a document from the host page's own origin + * remove its sandbox and script the editor. It is therefore dropped when + * the embed URL is same-origin with the current page (or cannot be parsed). + */ +function getIframeSandbox(embedUrl: string): string { + const sandbox = 'allow-scripts allow-presentation allow-popups'; + // Outside a browser (e.g. server-side serialization) the host origin is + // unknown: keep the provider-friendly sandbox. + if (typeof window === 'undefined' || !window.location) { + return `${sandbox} allow-same-origin`; + } + try { + if (new URL(embedUrl).origin === window.location.origin) return sandbox; + } catch { + return sandbox; + } + return `${sandbox} allow-same-origin`; +} + /** * Gets the embed URL for a given provider and embed ID. * diff --git a/packages/core/src/schema/nodes/heading.ts b/packages/core/src/schema/nodes/heading.ts index fc9947a..8f703c9 100644 --- a/packages/core/src/schema/nodes/heading.ts +++ b/packages/core/src/schema/nodes/heading.ts @@ -9,6 +9,15 @@ import type { NodeSpec, DOMOutputSpec, Node as PMNode } from 'prosemirror-model'; import { blockIdAttr, getBlockIdAttrs, blockIdToDOM } from '../blockIdAttrs'; +import { sanitizeTextAlign } from '../sanitizeStyle'; + +/** + * Clamps a heading level to an integer in 1-6 (defaults to 1). + */ +function sanitizeLevel(value: unknown): number { + const level = Number(value); + return Number.isInteger(level) && level >= 1 && level <= 6 ? level : 1; +} /** * Heading node spec. @@ -40,14 +49,16 @@ export const headingNode: NodeSpec = { getAttrs: (dom: HTMLElement) => ({ ...getBlockIdAttrs(dom), level, - textAlign: dom.style.textAlign || 'left', + textAlign: sanitizeTextAlign(dom.style.textAlign), }), })), toDOM(node: PMNode): DOMOutputSpec { const attrs: Record = { ...blockIdToDOM(node) }; - if (node.attrs.textAlign && node.attrs.textAlign !== 'left') { - attrs.style = `text-align: ${node.attrs.textAlign}`; + // Attrs can come from JSON / collaboration, so re-validate here. + const textAlign = sanitizeTextAlign(node.attrs.textAlign); + if (textAlign !== 'left') { + attrs.style = `text-align: ${textAlign}`; } - return [`h${node.attrs.level}`, attrs, 0]; + return [`h${sanitizeLevel(node.attrs.level)}`, attrs, 0]; }, }; diff --git a/packages/core/src/schema/nodes/image.ts b/packages/core/src/schema/nodes/image.ts index fc78468..c45fd09 100644 --- a/packages/core/src/schema/nodes/image.ts +++ b/packages/core/src/schema/nodes/image.ts @@ -10,9 +10,18 @@ import type { NodeSpec } from 'prosemirror-model'; import { getBlockIdAttrs, blockIdToDOM, safeParseInt } from '../blockIdAttrs'; import { sanitizeImageSrc } from '../sanitizeUrl'; +import { toNonNegativeNumber } from '../sanitizeStyle'; export type ImageAlignment = 'left' | 'center' | 'right'; +const IMAGE_ALIGNMENTS: readonly string[] = ['left', 'center', 'right']; + +function sanitizeAlignment(value: unknown): ImageAlignment { + return typeof value === 'string' && IMAGE_ALIGNMENTS.includes(value) + ? (value as ImageAlignment) + : 'center'; +} + export const imageNode: NodeSpec = { group: 'block', // Image is atomic - no editable content inside @@ -39,7 +48,7 @@ export const imageNode: NodeSpec = { alt: img?.getAttribute('alt') || '', caption: element.querySelector('figcaption')?.textContent || '', width: safeParseInt(img?.getAttribute('data-width'), null), - alignment: element.getAttribute('data-alignment') || 'center', + alignment: sanitizeAlignment(element.getAttribute('data-alignment')), }; }, }, @@ -59,9 +68,11 @@ export const imageNode: NodeSpec = { }, ], toDOM: (node) => { - const { alt, caption, width, alignment } = node.attrs; + const { alt, caption } = node.attrs; // Attrs can come from JSON (not only parseDOM), so re-validate here. const src = sanitizeImageSrc(node.attrs.src) ?? ''; + const width = toNonNegativeNumber(node.attrs.width); + const alignment = sanitizeAlignment(node.attrs.alignment); const figureAttrs: Record = { class: `openblock-image openblock-image--${alignment}`, diff --git a/packages/core/src/schema/nodes/paragraph.ts b/packages/core/src/schema/nodes/paragraph.ts index a530d9b..52c90b9 100644 --- a/packages/core/src/schema/nodes/paragraph.ts +++ b/packages/core/src/schema/nodes/paragraph.ts @@ -9,6 +9,7 @@ import type { NodeSpec, DOMOutputSpec, Node as PMNode } from 'prosemirror-model'; import { blockIdAttr, getBlockIdAttrs, blockIdToDOM } from '../blockIdAttrs'; +import { sanitizeTextAlign } from '../sanitizeStyle'; /** * Valid text alignment values. @@ -45,14 +46,16 @@ export const paragraphNode: NodeSpec = { const element = dom as HTMLElement; return { ...getBlockIdAttrs(element), - textAlign: element.style.textAlign || 'left', + textAlign: sanitizeTextAlign(element.style.textAlign), }; }, }], toDOM(node: PMNode): DOMOutputSpec { const attrs: Record = { ...blockIdToDOM(node) }; - if (node.attrs.textAlign && node.attrs.textAlign !== 'left') { - attrs.style = `text-align: ${node.attrs.textAlign}`; + // Attrs can come from JSON / collaboration, so re-validate here. + const textAlign = sanitizeTextAlign(node.attrs.textAlign); + if (textAlign !== 'left') { + attrs.style = `text-align: ${textAlign}`; } return ['p', attrs, 0]; }, diff --git a/packages/core/src/schema/nodes/tableCell.ts b/packages/core/src/schema/nodes/tableCell.ts index 8573cca..a4332b1 100644 --- a/packages/core/src/schema/nodes/tableCell.ts +++ b/packages/core/src/schema/nodes/tableCell.ts @@ -25,6 +25,7 @@ import { NodeSpec, DOMOutputSpec, Node as PMNode } from 'prosemirror-model'; import { getBlockIdAttrs, blockIdToDOM, safeParseInt } from '../blockIdAttrs'; +import { sanitizeCssColor, toNonNegativeNumber } from '../sanitizeStyle'; /** * Reads the shared cell attributes (id, colwidth, backgroundColor) from a @@ -42,7 +43,7 @@ function getCellAttrs(dom: HTMLElement): Record { return { ...getBlockIdAttrs(dom), colwidth: widths.length > 0 ? widths : null, - backgroundColor: dom.style.backgroundColor || null, + backgroundColor: sanitizeCssColor(dom.style.backgroundColor), }; } @@ -58,13 +59,19 @@ function cellToDOMAttrs(node: PMNode, className: string): Record const styles: string[] = []; - if (node.attrs.colwidth) { - attrs['data-colwidth'] = node.attrs.colwidth.join(','); - styles.push(`width: ${node.attrs.colwidth[0]}px`); + // Attrs can come from JSON / collaboration (not only parseDOM), so + // re-validate everything that ends up in the style attribute. + const colwidth = Array.isArray(node.attrs.colwidth) + ? node.attrs.colwidth.map(toNonNegativeNumber).filter((w: number | null): w is number => w !== null) + : []; + if (colwidth.length > 0) { + attrs['data-colwidth'] = colwidth.join(','); + styles.push(`width: ${colwidth[0]}px`); } - if (node.attrs.backgroundColor) { - styles.push(`background-color: ${node.attrs.backgroundColor}`); + const backgroundColor = sanitizeCssColor(node.attrs.backgroundColor); + if (backgroundColor) { + styles.push(`background-color: ${backgroundColor}`); } if (styles.length > 0) { diff --git a/packages/core/src/schema/sanitizeStyle.ts b/packages/core/src/schema/sanitizeStyle.ts new file mode 100644 index 0000000..30b6317 --- /dev/null +++ b/packages/core/src/schema/sanitizeStyle.ts @@ -0,0 +1,79 @@ +/** + * CSS value sanitization helpers for the OpenBlock schema. + * + * Node and mark attributes can reach `toDOM` without going through + * `parseDOM`: from JSON (`setDocument`, `insertBlocks`), from a + * collaboration peer (Y.js) or from raw `data-*` attributes in pasted HTML. + * Interpolating such values into a `style` attribute unchecked allows CSS + * injection (e.g. `red; background-image: url(https://tracker)` or a + * full-page `position: fixed` overlay). These helpers whitelist the value + * formats each attribute actually needs. + * + * @module + */ + +/** Maximum accepted length for a CSS color value. */ +const MAX_COLOR_LENGTH = 64; + +const HEX_COLOR_RE = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i; +/** + * Color functions. Arguments may only contain digits, letters (units, + * `none`), whitespace, `.`, `%`, `,`, `/`, `+` and `-`: no parentheses, + * quotes, colons, semicolons or backslashes, so nothing can escape the + * declaration. + */ +const COLOR_FUNCTION_RE = /^(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch)\([\w\s.%,/+-]*\)$/i; +/** Named colors and keywords such as `transparent` or `currentcolor`. */ +const NAMED_COLOR_RE = /^[a-z]+$/i; +/** Custom property reference, e.g. `var(--ob-highlight)`. */ +const CSS_VAR_RE = /^var\(--[\w-]+\)$/i; + +/** + * Validates a CSS color value. + * + * Accepts hex colors, color functions (`rgb()`, `hsl()`, `oklch()`...), + * named colors and `var(--custom-property)` references. + * + * @returns The trimmed color if safe, or null if it must be rejected. + */ +export function sanitizeCssColor(value: unknown): string | null { + if (typeof value !== 'string') return null; + const color = value.trim(); + if (color.length === 0 || color.length > MAX_COLOR_LENGTH) return null; + if ( + HEX_COLOR_RE.test(color) || + COLOR_FUNCTION_RE.test(color) || + NAMED_COLOR_RE.test(color) || + CSS_VAR_RE.test(color) + ) { + return color; + } + return null; +} + +/** + * Text alignment values supported by text blocks. + */ +export type TextAlign = 'left' | 'center' | 'right' | 'justify'; + +const TEXT_ALIGNS: ReadonlySet = new Set(['left', 'center', 'right', 'justify']); + +/** + * Normalizes a text alignment value; anything unknown becomes 'left'. + */ +export function sanitizeTextAlign(value: unknown): TextAlign { + return typeof value === 'string' && TEXT_ALIGNS.has(value) ? (value as TextAlign) : 'left'; +} + +/** + * Converts a value to a finite, non-negative number. + * + * Accepts numbers and numeric strings (as found in JSON or `data-*` + * attributes). + * + * @returns The number, or null if the value is not a finite number >= 0. + */ +export function toNonNegativeNumber(value: unknown): number | null { + const num = typeof value === 'string' && value.trim() !== '' ? Number(value) : value; + return typeof num === 'number' && Number.isFinite(num) && num >= 0 ? num : null; +} diff --git a/packages/core/src/utils/generateId.ts b/packages/core/src/utils/generateId.ts new file mode 100644 index 0000000..03ff8b8 --- /dev/null +++ b/packages/core/src/utils/generateId.ts @@ -0,0 +1,49 @@ +/** + * Block ID generation. + * + * Generates RFC 4122 version 4 UUIDs using the platform's Web Crypto API, + * so the core package has no runtime dependency for ID generation. + * + * @module + */ + +const HEX: string[] = Array.from({ length: 256 }, (_, i) => (i + 0x100).toString(16).slice(1)); + +/** + * Builds a v4 UUID from cryptographically secure random bytes. + * + * Used when `crypto.randomUUID` is unavailable: it only exists in secure + * contexts (HTTPS / localhost), while `crypto.getRandomValues` is available + * everywhere. + */ +function uuidFromRandomValues(): string { + const bytes = new Uint8Array(16); + globalThis.crypto.getRandomValues(bytes); + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 4122 variant + + return ( + HEX[bytes[0]] + HEX[bytes[1]] + HEX[bytes[2]] + HEX[bytes[3]] + '-' + + HEX[bytes[4]] + HEX[bytes[5]] + '-' + + HEX[bytes[6]] + HEX[bytes[7]] + '-' + + HEX[bytes[8]] + HEX[bytes[9]] + '-' + + HEX[bytes[10]] + HEX[bytes[11]] + HEX[bytes[12]] + + HEX[bytes[13]] + HEX[bytes[14]] + HEX[bytes[15]] + ); +} + +/** + * Generates a unique block ID (a random v4 UUID). + * + * @example + * ```typescript + * generateId(); // '3b241101-e2bb-4255-8caf-4136c566a962' + * ``` + */ +export function generateId(): string { + const cryptoApi = globalThis.crypto; + if (typeof cryptoApi?.randomUUID === 'function') { + return cryptoApi.randomUUID(); + } + return uuidFromRandomValues(); +} diff --git a/packages/core/vite.config.ts b/packages/core/vite.config.ts index 7125f90..614b049 100644 --- a/packages/core/vite.config.ts +++ b/packages/core/vite.config.ts @@ -1,35 +1,40 @@ import { defineConfig } from 'vite'; import dts from 'vite-plugin-dts'; -import { resolve } from 'path'; +import { resolve } from 'node:path'; + +import pkg from './package.json' with { type: 'json' }; + +/** + * Library build for bundlers / Node: ESM (`index.js`) + CommonJS + * (`index.cjs`), with every dependency left external, plus a single bundled + * `index.d.ts`. + * + * The self-contained browser bundles are built by `vite.standalone.config.ts`. + */ +const external = [ + ...Object.keys(pkg.dependencies), + ...Object.keys(pkg.peerDependencies), +]; export default defineConfig({ plugins: [ dts({ - insertTypesEntry: true, + tsconfigPath: resolve(import.meta.dirname, 'tsconfig.json'), + bundleTypes: true, }), ], build: { lib: { - entry: resolve(__dirname, 'src/index.ts'), - name: 'OpenBlockCore', - formats: ['es'], - fileName: 'index', + entry: resolve(import.meta.dirname, 'src/index.ts'), + formats: ['es', 'cjs'], + fileName: (format) => (format === 'es' ? 'index.js' : 'index.cjs'), }, - rollupOptions: { - external: [ - 'prosemirror-commands', - 'prosemirror-dropcursor', - 'prosemirror-gapcursor', - 'prosemirror-history', - 'prosemirror-inputrules', - 'prosemirror-keymap', - 'prosemirror-model', - 'prosemirror-schema-list', - 'prosemirror-state', - 'prosemirror-transform', - 'prosemirror-view', - 'uuid', - ], + rolldownOptions: { + // Also externalize deep imports (e.g. 'prosemirror-view/style/...') + external: (id) => external.some((dep) => id === dep || id.startsWith(`${dep}/`)), + output: { + exports: 'named', + }, }, sourcemap: true, minify: false, diff --git a/packages/core/vite.standalone.config.ts b/packages/core/vite.standalone.config.ts new file mode 100644 index 0000000..447f034 --- /dev/null +++ b/packages/core/vite.standalone.config.ts @@ -0,0 +1,43 @@ +import { defineConfig } from 'vite'; +import { resolve } from 'node:path'; + +/** + * Standalone browser bundles: everything (ProseMirror included) bundled + * into a single minified file, usable without a bundler. + * + * - `dist/standalone/openblock-core.esm.js` → `