Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
50 changes: 3 additions & 47 deletions .github/workflows/call-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,22 +39,6 @@ on:
required: false
default: false
type: boolean
use-trusted-publishing:
description: |
Force npm trusted publishing (OIDC) for the publish step,
ignoring any NPM_TOKEN secret that was passed through. Useful
for verifying the trusted-publishing setup without removing the
NPM_TOKEN repository secret.
required: false
default: false
type: boolean
secrets:
NPM_TOKEN:
description: |
Optional npm auth token. When unset, the publish step uses npm
trusted publishing (OIDC) and the calling workflow must grant
`id-token: write`.
required: false
outputs:
version:
description: 'New package.json version (only set when increment-version ran)'
Expand Down Expand Up @@ -104,6 +88,9 @@ jobs:
runs-on: ubuntu-latest
permissions:
contents: read
# Required for npm trusted publishing (OIDC) — every `@lexical/*`
# package on npmjs.com is configured to trust this workflow file
# via the GitHub Actions OIDC `workflow_ref` claim.
id-token: write
env:
DRY_RUN_ARG: ${{ inputs.dry-run && '--dry-run' || '' }}
Expand All @@ -116,36 +103,5 @@ jobs:
with:
node-version: 24.x
registry-url: 'https://registry.npmjs.org'
- name: Configure npm auth
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
USE_TRUSTED_PUBLISHING: ${{ inputs.use-trusted-publishing && '1' || '' }}
run: |
if [ -n "${USE_TRUSTED_PUBLISHING:-}" ]; then
echo "npm auth: trusted publishing (OIDC) forced via use-trusted-publishing input; NPM_TOKEN ignored"
elif [ -n "${NPM_TOKEN:-}" ]; then
echo "::add-mask::$NPM_TOKEN"
echo "NODE_AUTH_TOKEN=$NPM_TOKEN" >> "$GITHUB_ENV"
echo "npm auth: using NPM_TOKEN secret"
else
echo "npm auth: using trusted publishing (OIDC); ensure this workflow is registered on npmjs.com"
fi
# Fail fast (before the build) if the token can't authenticate, so a
# bad/expired/unauthorized NPM_TOKEN surfaces clearly instead of as a
# misleading 404 from every `pnpm publish`. Skipped on dry runs and
# in trusted-publishing mode, neither of which uses a persistent
# NODE_AUTH_TOKEN to verify up front.
- name: Verify npm authentication
if: ${{ !inputs.dry-run && !inputs.use-trusted-publishing }}
run: |
if [ -z "${NODE_AUTH_TOKEN:-}" ]; then
echo "Skipping npm whoami check: no NPM_TOKEN configured, trusted publishing (OIDC) will be attempted at publish time"
exit 0
fi
if ! whoami="$(npm whoami --registry=https://registry.npmjs.org)"; then
echo "::error::npm authentication failed for https://registry.npmjs.org. The NPM_TOKEN secret is missing, expired, revoked, or not authorized to publish these packages."
exit 1
fi
echo "Authenticated to https://registry.npmjs.org as ${whoami}"
- run: pnpm run prepare-release
- run: node ./scripts/npm/release.mjs --non-interactive $DRY_RUN_ARG $IGNORE_PREVIOUSLY_PUBLISHED_ARG --channel='${{ inputs.channel }}'
35 changes: 0 additions & 35 deletions .github/workflows/nightly-release.yml

This file was deleted.

44 changes: 24 additions & 20 deletions .github/workflows/pre-release.yml
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
name: Publish to NPM
on:
# Nightly schedule (Mon-Fri 02:30 UTC) runs the prerelease path so a
# single workflow file owns all publishes — npm allows only one
# trusted-publisher config per package, so every publish has to come
# through here for OIDC to work.
schedule:
- cron: '30 2 * * 1-5'
workflow_dispatch:
inputs:
ignore-previously-published:
description: Ignore 403 errors for 'You cannot publish over the previously published versions'
required: true
default: false
type: boolean
use-trusted-publishing:
description: Ignore the NPM_TOKEN secret and force npm trusted publishing (OIDC). Use to verify the trusted-publishing setup without removing the repo secret.
required: true
default: false
type: boolean
ref:
description: 'git ref to publish from. Defaults to main; override when testing trusted publishing from a non-main branch.'
description: 'git ref to publish from. Defaults to main; override when testing from a non-main branch.'
required: true
default: main
type: string
Expand All @@ -25,13 +26,13 @@ on:
options:
- latest
- dev
- nightly
increment-version:
description: |
Bump version + push tag/branch before publish (same flow nightly uses).
Use ONLY with channel=dev for end-to-end tests of the trusted-publishing
setup. Pairing with channel=latest is refused — it would move the latest
dist-tag to a prerelease. Real latest releases must go through version.yml
first.
Use ONLY with channel=dev or channel=nightly. Pairing with
channel=latest is refused — it would move the latest dist-tag to a
prerelease. Real latest releases must go through version.yml first.
required: true
default: false
type: boolean
Expand All @@ -48,8 +49,9 @@ jobs:
guard:
# Refuse the unsafe combo: bumping a fresh version and tagging it as
# `latest` would move every user on the default dist-tag to a
# prerelease.
if: ${{ inputs.increment-version && inputs.channel == 'latest' }}
# prerelease. Only relevant for workflow_dispatch; the schedule path
# always uses channel=nightly.
if: ${{ github.event_name == 'workflow_dispatch' && inputs.increment-version && inputs.channel == 'latest' }}
runs-on: ubuntu-latest
steps:
- run: |
Expand All @@ -58,14 +60,16 @@ jobs:

release:
needs: guard
if: ${{ !failure() && !cancelled() }}
# prevents this workflow from running on forks (matters most for the
# scheduled trigger; manual dispatch is already gated to maintainers)
if: ${{ !failure() && !cancelled() && github.repository_owner == 'facebook' }}
uses: ./.github/workflows/call-release.yml
with:
increment-version: ${{ inputs.increment-version }}
# Schedule path == nightly behavior: bump + publish under nightly
# dist-tag, ignoring previously-published collisions. Manual dispatch
# uses the inputs as provided.
increment-version: ${{ github.event_name == 'schedule' || inputs.increment-version }}
publish: true
ref: ${{ inputs.ref }}
channel: ${{ inputs.channel }}
ignore-previously-published: ${{ inputs.ignore-previously-published }}
use-trusted-publishing: ${{ inputs.use-trusted-publishing }}
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
ref: ${{ github.event_name == 'schedule' && 'main' || inputs.ref }}
channel: ${{ github.event_name == 'schedule' && 'nightly' || inputs.channel }}
ignore-previously-published: ${{ github.event_name == 'schedule' || inputs.ignore-previously-published }}
6 changes: 6 additions & 0 deletions packages/lexical-extension/flow/LexicalExtension.js.flow
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import type {
LexicalNode,
NodeKey,
TextNode,
ExtensionConfigBase,
EditorState,
AnyLexicalExtension,
Expand Down Expand Up @@ -153,6 +154,11 @@ declare export function $isHorizontalRuleNode(
declare export var INSERT_HORIZONTAL_RULE_COMMAND: LexicalCommand<void>;
declare export var HorizontalRuleExtension: LexicalExtension<ExtensionConfigBase, "@lexical/extension/HorizontalRule", void, void>;

declare export var IMEExtension: LexicalExtension<ExtensionConfigBase, "@lexical/extension/IME", {
compositionKey: Signal<null | NodeKey>;
composingTextNode: Signal<null | TextNode>;
}, void>;

export type InitialStateConfig = {
updateOptions: EditorUpdateOptions;
setOptions: EditorSetOptions;
Expand Down
142 changes: 142 additions & 0 deletions packages/lexical-extension/src/IMEExtension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/

import {mergeRegister} from '@lexical/utils';
import {
$getNodeByKey,
$getSelection,
$isRangeSelection,
$isTextNode,
COMMAND_PRIORITY_BEFORE_EDITOR,
COMPOSITION_START_COMMAND,
COMPOSITION_START_TAG,
defineExtension,
type LexicalEditor,
type NodeKey,
type TextNode,
} from 'lexical';

import {effect, type Signal, signal} from './signals';

/**
* Centralizes IME composition state so extensions that react to
* composition lifecycle don't each re-implement the
* COMPOSITION_START_COMMAND + compositionend listener dance.
*
* Exposes two signals (both always-active for the editor's lifetime —
* listeners are wired up by `register`, not lazily on subscription, so
* consumers can read `.value` from anywhere without holding a
* subscription themselves):
*
* - `compositionKey` is the raw mirror — the value Lexical's own
* `$handleCompositionStart` writes to its internal `_compositionKey`,
* i.e. the `selection.anchor.key` at the moment composition starts.
* This can be a non-TextNode key when composition begins on an
* element-anchor selection (e.g. empty paragraph). Cleared on
* `compositionend`.
*
* - `composingTextNode` is the resolved view — the actual TextNode
* being composed on, or `null` while there is no TextNode-level
* composition. For an element-anchor start it stays `null` until
* the `COMPOSITION_START_TAG`-tagged update fires with the
* post-ZWSP-heuristic selection, at which point it updates to the
* new TextNode.
*
*/
export const IMEExtension = defineExtension({
build(_editor: LexicalEditor): {
compositionKey: Signal<null | NodeKey>;
composingTextNode: Signal<null | TextNode>;
} {
return {
composingTextNode: signal<null | TextNode>(null),
compositionKey: signal<null | NodeKey>(null),
};
},
name: '@lexical/extension/IME',
register(editor, _config, state) {
const {compositionKey, composingTextNode} = state.getOutput();

const removeStartCommand = editor.registerCommand(
COMPOSITION_START_COMMAND,
() => {
// `BEFORE_EDITOR` lands at the head of the EDITOR-priority
// bucket, sequenced immediately before Lexical's own
// EDITOR-priority `$handleCompositionStart` that calls
// `$setCompositionKey(anchor.key)`. Both write the same
// `selection.anchor.key`. The lower nominal priority keeps
// room for downstream extensions to override.
const selection = $getSelection();
if ($isRangeSelection(selection)) {
compositionKey.value = selection.anchor.key;
}
return false;
},
COMMAND_PRIORITY_BEFORE_EDITOR,
);

// Stage 1: react to compositionKey transitions. Resolve the key
// to a TextNode when possible. Element-anchor starts resolve to
// null here and stay null until stage 2.
const stopKeyEffect = effect(() => {
const key = compositionKey.value;
if (key === null) {
composingTextNode.value = null;
return;
}
composingTextNode.value = editor.getEditorState().read(() => {
const node = $getNodeByKey(key);
return $isTextNode(node) ? node : null;
});
});

// Stage 2: after Lexical's ZWSP heuristic inserts the actual
// composing TextNode for an element-anchor start, the
// corresponding update fires with COMPOSITION_START_TAG. The
// selection now points at the new TextNode — re-read it and
// upgrade the signal to the resolved node.
const removeUpdateListener = editor.registerUpdateListener(
({tags, editorState}) => {
if (!tags.has(COMPOSITION_START_TAG)) {
return;
}
editorState.read(() => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
return;
}
const node = selection.anchor.getNode();
if ($isTextNode(node)) {
composingTextNode.value = node;
}
});
},
);

const removeRootListener = editor.registerRootListener(rootElem => {
if (rootElem === null) {
compositionKey.value = null;
return;
}
const onCompositionEnd = () => {
compositionKey.value = null;
};
rootElem.addEventListener('compositionend', onCompositionEnd);
return () => {
rootElem.removeEventListener('compositionend', onCompositionEnd);
};
});

return mergeRegister(
removeStartCommand,
stopKeyEffect,
removeUpdateListener,
removeRootListener,
);
},
});
1 change: 1 addition & 0 deletions packages/lexical-extension/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export {
INSERT_HORIZONTAL_RULE_COMMAND,
type SerializedHorizontalRuleNode,
} from './HorizontalRuleExtension';
export {IMEExtension} from './IMEExtension';
export {
type InitialStateConfig,
InitialStateExtension,
Expand Down
Loading