diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000000..ae24fa445a0 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,38 @@ +# Code review guidelines + +## General principles +- **Style:** Suggest `npm run format` or `npm run lint:fix` for formatting issues; do not comment on individual style nits. +- **Patterns:** Enforce existing Blockly patterns and official docs over new conventions. +- **Documentation:** Prefer linking to [Blockly Dev Docs](https://developers.google.com/blockly) over duplicating content in comments. +- **TSDoc:** Public APIs require TSDoc for behavior, params, and returns. Do not include implementation details or historical context unless essential. + +## Localization +- All user-visible strings must use `Blockly.Msg`. +- New strings must be added to `msg/messages.js`, `msg/json/qqq.json`, and `msg/json/en.json`. +- Link [this guide](https://developers.google.com/blockly/guides/contribute/core/add_localization_token) if strings are missing or misplaced. +- PRs that attempt to add translations for non-English strings should be redirected to TranslateWiki via the ([translation guide](hhttps://developers.google.com/blockly/guides/contribute/core/translating)). + +## Breaking changes +### Policy +- A breaking change is any non-backwards-compatible change to public APIs, behavior, UI, or browser requirements. +- **Avoid:** Prefer deprecation with migration paths over removal. +- **Compatibility:** Must support Safari 15.4+, latest Chrome, and latest Firefox. +- **Identification:** Flag breaking changes unless all of the following are true: + 1. PR description explicitly notes it. + 2. Commit type includes `!` (e.g., `feat!:`). + 3. Target branch is not `main`. + +### Breaking +- Removing/renaming public methods, properties, or classes. +- Changing signatures or behavior of existing public methods. +- Adding required methods to public interfaces. +- New keyboard shortcuts or context menu items (potential developer conflicts). +- DOM restructures affecting external CSS/JS. +- Changes to build output/consumption (e.g., ESM-only). +- Changes that affect the output of serialization. + +### Non-breaking (do not flag) +- Additive changes (new methods/properties). +- Internal refactoring (including items marked `@internal`). +- Tooling/workflow changes. +- Changes to unreleased code (non-`main` feature branches). \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6fbacad1911..130a2669f5e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,6 +27,7 @@ jobs: steps: - uses: actions/checkout@v5 with: + ref: ${{ github.ref }} persist-credentials: false - name: Reconfigure git to use HTTP authentication @@ -58,6 +59,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 + with: + ref: ${{ github.ref }} - name: Use Node.js 20.x uses: actions/setup-node@v5 @@ -75,6 +78,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 + with: + ref: ${{ github.ref }} - name: Use Node.js 20.x uses: actions/setup-node@v5 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0f403f4479a..f3a414ba529 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,17 +4,27 @@ on: workflow_dispatch: inputs: dry_run: - description: 'Dry run - print the version that would be published, but do not commit or publish anything.' + description: > + Dry run — print the version and npm dist-tag that would be used; no commit or publish. + Pick the branch to publish from with the "Use workflow from" dropdown. + Non-default branches publish to the npm dist-tag `beta` (not `latest`). required: false default: false type: boolean skip_versioning: description: > - Skip version bump - use the version already in the repo + Skip version bump — use the version already in the repo (e.g. retry after npm publish failed but the release commit is already pushed). required: false default: false type: boolean + version_override: + description: > + Optional. Full semver to publish (e.g. 12.6.0-beta.2). Skips conventional bump when set. + Leave empty for automatic versioning. + required: false + default: '' + type: string permissions: contents: write @@ -34,6 +44,7 @@ jobs: - name: Checkout uses: actions/checkout@v5 with: + ref: ${{ github.ref }} fetch-depth: 0 - name: Setup Node.js @@ -48,7 +59,7 @@ jobs: - name: Determine version bump id: bump - if: ${{ !inputs.skip_versioning }} + if: ${{ !inputs.skip_versioning && inputs.version_override == '' }} working-directory: packages/blockly run: | RELEASE_TYPE=$(npx conventional-recommended-bump --preset conventionalcommits -t blockly-) @@ -58,7 +69,35 @@ jobs: - name: Apply version bump if: ${{ !inputs.skip_versioning }} working-directory: packages/blockly - run: npm version ${{ steps.bump.outputs.release_type }} --no-git-tag-version + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + REF_NAME: ${{ github.ref_name }} + RELEASE_TYPE: ${{ steps.bump.outputs.release_type }} + VERSION_OVERRIDE: ${{ inputs.version_override }} + run: | + set -euo pipefail + if [ -n "${VERSION_OVERRIDE}" ]; then + npm version "${VERSION_OVERRIDE}" --no-git-tag-version + exit 0 + fi + if [ "${REF_NAME}" = "${DEFAULT_BRANCH}" ]; then + npm version "${RELEASE_TYPE}" --no-git-tag-version + exit 0 + fi + VERSION=$(node -p "require('./package.json').version") + if [[ "${VERSION}" == *"-beta."* ]]; then + npm version prerelease --preid=beta --no-git-tag-version + else + case "${RELEASE_TYPE}" in + major) npm version premajor --preid=beta --no-git-tag-version ;; + minor) npm version preminor --preid=beta --no-git-tag-version ;; + patch) npm version prepatch --preid=beta --no-git-tag-version ;; + *) + echo "::error title=Invalid release bump::conventional-recommended-bump returned '${RELEASE_TYPE}' (expected major, minor, or patch). Fix commits/tags or set version_override." >&2 + exit 1 + ;; + esac + fi - name: Read package version id: version @@ -68,6 +107,15 @@ jobs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "Version: $VERSION" + - name: Dry run summary + if: ${{ inputs.dry_run }} + run: | + DIST_TAG="${{ github.ref_name == github.event.repository.default_branch && 'latest' || 'beta' }}" + echo "Dry run: would publish version ${{ steps.version.outputs.version }} to npm dist-tag: ${DIST_TAG}" + if [ "${{ github.ref_name }}" != "${{ github.event.repository.default_branch }}" ]; then + echo "GitHub release would be created as prerelease." + fi + - name: Upload versioned files if: ${{ !inputs.skip_versioning }} uses: actions/upload-artifact@v4 @@ -82,10 +130,13 @@ jobs: runs-on: ubuntu-latest if: ${{ !inputs.dry_run }} environment: release + env: + NPM_DIST_TAG: ${{ github.ref_name == github.event.repository.default_branch && 'latest' || 'beta' }} steps: - name: Checkout uses: actions/checkout@v5 with: + ref: ${{ github.ref }} fetch-depth: 0 ssh-key: ${{ secrets.DEPLOY_PRIVATE_KEY }} @@ -119,7 +170,7 @@ jobs: - name: Publish to npm working-directory: packages/blockly/dist - run: npm publish --verbose + run: npm publish --tag "${NPM_DIST_TAG}" --verbose - name: Create tarball working-directory: packages/blockly @@ -131,7 +182,15 @@ jobs: GH_TOKEN: ${{ github.token }} run: | TARBALL="blockly-${{ needs.version.outputs.version }}.tgz" - gh release create "blockly-v${{ needs.version.outputs.version }}" "$TARBALL" \ - --repo "$GITHUB_REPOSITORY" \ - --title "blockly-v${{ needs.version.outputs.version }}" \ - --generate-notes + if [ "${{ github.ref_name }}" != "${{ github.event.repository.default_branch }}" ]; then + gh release create "blockly-v${{ needs.version.outputs.version }}" "$TARBALL" \ + --repo "$GITHUB_REPOSITORY" \ + --title "blockly-v${{ needs.version.outputs.version }}" \ + --generate-notes \ + --prerelease + else + gh release create "blockly-v${{ needs.version.outputs.version }}" "$TARBALL" \ + --repo "$GITHUB_REPOSITORY" \ + --title "blockly-v${{ needs.version.outputs.version }}" \ + --generate-notes + fi diff --git a/packages/blockly/core/block.ts b/packages/blockly/core/block.ts index 4c8f8084202..b74e3210219 100644 --- a/packages/blockly/core/block.ts +++ b/packages/blockly/core/block.ts @@ -1721,8 +1721,8 @@ export class Block { // Validate that each arg has a corresponding message let n = 0; - while (json['args' + n]) { - if (json['message' + n] === undefined) { + while (json[`args${n}`]) { + if (json[`message${n}`] === undefined) { throw Error( warningPrefix + `args${n} must have a corresponding message (message${n}).`, @@ -1732,14 +1732,13 @@ export class Block { } // Set basic properties of block. - // Makes styles backward compatible with old way of defining hat style. - if (json['style'] && json['style'].hat) { - this.hat = json['style'].hat; + // Handle legacy style object format for backwards compatibility + if (json['style'] && typeof json['style'] === 'object') { + this.hat = (json['style'] as {hat?: string}).hat; // Must set to null so it doesn't error when checking for style and // colour. json['style'] = null; } - if (json['style'] && json['colour']) { throw Error(warningPrefix + 'Must not have both a colour and a style.'); } else if (json['style']) { @@ -1750,12 +1749,12 @@ export class Block { // Interpolate the message blocks. let i = 0; - while (json['message' + i] !== undefined) { + while (json[`message${i}`] !== undefined) { this.interpolate( - json['message' + i], - json['args' + i] || [], + json[`message${i}`] || '', + json[`args${i}`] || [], // Backwards compatibility: lastDummyAlign aliases implicitAlign. - json['implicitAlign' + i] || json['lastDummyAlign' + i], + json[`implicitAlign${i}`] || (json as any)[`lastDummyAlign${i}`], warningPrefix, ); i++; diff --git a/packages/blockly/core/interfaces/i_json_block_definition.ts b/packages/blockly/core/interfaces/i_json_block_definition.ts new file mode 100644 index 00000000000..0b3d5767649 --- /dev/null +++ b/packages/blockly/core/interfaces/i_json_block_definition.ts @@ -0,0 +1,126 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {FieldCheckboxFromJsonConfig} from '../field_checkbox.js'; +import {FieldDropdownFromJsonConfig} from '../field_dropdown'; +import {FieldImageFromJsonConfig} from '../field_image'; +import {FieldNumberFromJsonConfig} from '../field_number'; +import {FieldTextInputFromJsonConfig} from '../field_textinput'; +import {FieldVariableFromJsonConfig} from '../field_variable'; +import {Align} from '../inputs/align.js'; + +/** + * Defines the JSON structure for a block definition. + * + * @example + * ```typescript + * const blockDef: JsonBlockDefinition = { + * type: 'custom_block', + * message0: 'move %1 steps', + * args0: [ + * { + * 'type': 'field_number', + * 'name': 'INPUT', + * }, + * ], + * previousStatement: null, + * nextStatement: null, + * }; + * ``` + */ +export interface JsonBlockDefinition { + type: string; + style?: string | null; + colour?: string | number; + output?: string | string[] | null; + previousStatement?: string | string[] | null; + nextStatement?: string | string[] | null; + outputShape?: number; + inputsInline?: boolean; + tooltip?: string; + helpUrl?: string; + extensions?: string[]; + mutator?: string; + enableContextMenu?: boolean; + suppressPrefixSuffix?: boolean; + + [key: `message${number}`]: string | undefined; + [key: `args${number}`]: JsonBlockArg[] | undefined; + [key: `implicitAlign${number}`]: string | undefined; +} + +export type JsonBlockArg = + | InputValueArg + | InputStatementArg + | InputDummyArg + | InputEndRowArg + | FieldInputArg + | FieldNumberArg + | FieldDropdownArg + | FieldCheckboxArg + | FieldImageArg + | FieldVariableArg + | UnknownArg; + +interface UnknownArg { + type: string; + [key: string]: unknown; +} + +/** Input args */ +interface InputValueArg { + type: 'input_value'; + name?: string; + check?: string | string[]; + align?: Align; +} + +interface InputStatementArg { + type: 'input_statement'; + name?: string; + check?: string | string[]; +} + +interface InputDummyArg { + type: 'input_dummy'; + name?: string; +} + +interface InputEndRowArg { + type: 'input_end_row'; + name?: string; +} + +/** Field args */ +interface FieldInputArg extends FieldTextInputFromJsonConfig { + type: 'field_input'; + name?: string; +} + +interface FieldNumberArg extends FieldNumberFromJsonConfig { + type: 'field_number'; + name?: string; +} + +interface FieldDropdownArg extends FieldDropdownFromJsonConfig { + type: 'field_dropdown'; + name?: string; +} + +interface FieldCheckboxArg extends FieldCheckboxFromJsonConfig { + type: 'field_checkbox'; + name?: string; +} + +interface FieldImageArg extends FieldImageFromJsonConfig { + type: 'field_image'; + name?: string; +} + +interface FieldVariableArg extends FieldVariableFromJsonConfig { + type: 'field_variable'; + name?: string; +} diff --git a/packages/blockly/core/shortcut_items.ts b/packages/blockly/core/shortcut_items.ts index caa8ea84c9d..3e3e3c9e4ed 100644 --- a/packages/blockly/core/shortcut_items.ts +++ b/packages/blockly/core/shortcut_items.ts @@ -217,7 +217,9 @@ export function registerCut() { if (focused instanceof BlockSvg) { focused.checkAndDelete(); } else if (isIDeletable(focused)) { + eventUtils.setGroup(true); focused.dispose(); + eventUtils.setGroup(false); } return !!copyData; }, diff --git a/packages/blockly/tests/mocha/shortcut_items_test.js b/packages/blockly/tests/mocha/shortcut_items_test.js index 99c7bd4d0f9..0a42c41ba7f 100644 --- a/packages/blockly/tests/mocha/shortcut_items_test.js +++ b/packages/blockly/tests/mocha/shortcut_items_test.js @@ -295,6 +295,11 @@ suite('Keyboard Shortcut Items', function () { this.disposeSpy = sinon.spy(this.comment, 'dispose'); this.injectionDiv.dispatchEvent(keyEvent); + + const deleteEvents = this.workspace + .getUndoStack() + .filter((e) => e.type === 'comment_delete'); + assert(deleteEvents[0].group !== ''); // Group string is not empty sinon.assert.calledOnce(this.copySpy); sinon.assert.calledOnce(this.disposeSpy); }); diff --git a/packages/blockly/tests/typescript/src/field/json_block_custom_args.ts b/packages/blockly/tests/typescript/src/field/json_block_custom_args.ts new file mode 100644 index 00000000000..9cafdbdef8e --- /dev/null +++ b/packages/blockly/tests/typescript/src/field/json_block_custom_args.ts @@ -0,0 +1,26 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {defineBlocksWithJsonArray} from 'blockly-test/core'; +import type {JsonBlockDefinition} from 'blockly-test/core/interfaces/i_json_block_definition'; + +import './different_user_input'; + +const mitosisBlockDefinition: JsonBlockDefinition = { + type: 'mitosis_block', + message0: 'split cell %1', + args0: [ + { + type: 'field_mitosis', + name: 'CELL', + cellId: 'cell-A', + }, + ], + previousStatement: null, + nextStatement: null, +}; + +defineBlocksWithJsonArray([mitosisBlockDefinition]);