Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
4bbdaec
chore: move Python package into workspace
Aug 13, 2026
a963e61
test: establish shared conformance contract
Aug 13, 2026
b9dff2e
feat: add TypeScript package
Aug 13, 2026
9feb1da
ci: add coordinated package release automation
Aug 13, 2026
26d8e0a
docs: migrate site to Docusaurus
Aug 13, 2026
6902182
feat: make docs language selection site-wide
Aug 13, 2026
d8f6978
docs: complete language-specific guides
Aug 13, 2026
25f60fe
feat: expose versioned documentation
Aug 13, 2026
b4047f8
feat: standardise TypeScript block factory names
Aug 13, 2026
870fbba
docs: improve API reference generation and navigation
Aug 13, 2026
e991480
test: enforce comprehensive conformance coverage
Aug 13, 2026
c418f39
ci: give stacked pull requests check runs
Aug 14, 2026
051fb38
Merge branch 'codex/monorepo-foundation' into codex/shared-conformance
Aug 14, 2026
eb11ad6
Merge branch 'codex/shared-conformance' into codex/typescript-package
Aug 14, 2026
039edb3
Merge branch 'codex/release-automation' into codex/docusaurus-platform
Aug 14, 2026
024df24
ci: give stacked pull requests check runs
Aug 14, 2026
46296d4
ci: give stacked pull requests check runs
Aug 14, 2026
61927db
Merge branch 'codex/typescript-package' into codex/release-automation
Aug 14, 2026
dd47942
Merge branch 'codex/docusaurus-platform' into codex/docs-language-nav…
Aug 14, 2026
a1651de
Merge branch 'codex/docs-language-navigation' into codex/docs-guides
Aug 14, 2026
82b2cbc
Merge branch 'codex/docs-guides' into codex/docs-version-history
Aug 14, 2026
9903b2f
Merge branch 'codex/docs-version-history' into codex/typescript-api-n…
Aug 14, 2026
6acc43f
Merge branch 'codex/typescript-api-names' into codex/api-reference-re…
Aug 14, 2026
b3b9673
Merge branch 'codex/api-reference-rendering' into codex/conformance-c…
Aug 14, 2026
5acbed8
ci: build the smoke package before switching to the matrix Node
Aug 14, 2026
5aa69a4
ci: pin setup-uv to an existing tag
Aug 14, 2026
ea34267
Merge branch 'codex/typescript-package' into codex/release-automation
Aug 14, 2026
f7a03b6
Merge branch 'codex/release-automation' into codex/docusaurus-platform
Aug 14, 2026
3391fbc
Merge branch 'codex/docusaurus-platform' into codex/docs-language-nav…
Aug 14, 2026
5fa4a6c
Merge branch 'codex/docs-language-navigation' into codex/docs-guides
Aug 14, 2026
aa8e2b7
Merge branch 'codex/docs-guides' into codex/docs-version-history
Aug 14, 2026
8c4863f
Merge branch 'codex/docs-version-history' into codex/typescript-api-n…
Aug 14, 2026
7ccaf67
Merge branch 'codex/typescript-api-names' into codex/api-reference-re…
Aug 14, 2026
8b8b17c
Merge branch 'codex/api-reference-rendering' into codex/conformance-c…
Aug 14, 2026
7341f8a
ci: alias the package name for docs-example typechecking
Aug 14, 2026
43e37f5
Merge branch 'codex/docusaurus-platform' into codex/docs-language-nav…
Aug 14, 2026
a83ed9e
Merge branch 'codex/docs-language-navigation' into codex/docs-guides
Aug 14, 2026
2a04eca
Merge branch 'codex/docs-guides' into codex/docs-version-history
Aug 14, 2026
9d39f67
Merge branch 'codex/docs-version-history' into codex/typescript-api-n…
Aug 14, 2026
cb03b54
Merge branch 'codex/typescript-api-names' into codex/api-reference-re…
Aug 14, 2026
5cef2b2
Merge branch 'codex/api-reference-rendering' into codex/conformance-c…
Aug 14, 2026
f5e040e
ci: resolve the package alias in vitest and skip the unused uv cache
Aug 14, 2026
d506a26
Merge branch 'codex/docusaurus-platform' into codex/docs-language-nav…
Aug 14, 2026
1d5c543
Merge branch 'codex/docs-language-navigation' into codex/docs-guides
Aug 14, 2026
76fe53e
Merge branch 'codex/docs-guides' into codex/docs-version-history
Aug 14, 2026
9099455
Merge branch 'codex/docs-version-history' into codex/typescript-api-n…
Aug 14, 2026
c205bc9
Merge branch 'codex/typescript-api-names' into codex/api-reference-re…
Aug 14, 2026
c6744f2
Merge branch 'codex/api-reference-rendering' into codex/conformance-c…
Aug 14, 2026
41b5fa0
Merge remote-tracking branch 'origin/master' into codex/conformance-c…
Aug 14, 2026
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const payload = message({

- `python/` — the established Python package.
- `typescript/` — the ESM TypeScript package.
- `spec/` — 79 valid fixtures, shared invalid cases, and limits.
- `spec/` — 82 valid fixtures, shared invalid cases, capability coverage, and limits.
- `docs/` — the Docusaurus documentation site.

See the [documentation site](https://nicklambourne.github.io/slackblocks/) for installation and usage.
Expand Down
11 changes: 6 additions & 5 deletions docs/docs/contributing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,12 @@ The Python and TypeScript conformance suites both exercise every ID in `spec/man

## Add or change a Block Kit feature

1. Add or update canonical JSON in `spec/fixtures/valid/` and register it in `spec/manifest.json`.
2. Add invalid behavior to `spec/fixtures/invalid/manifest.json` when the feature introduces a validation rule.
3. Update `spec/limits.json` for shared scalar constraints.
4. Implement the feature idiomatically in both packages, or record a reason in the affected skip list.
5. Add language-native tests and update the relevant guide or executable example.
1. Add or update canonical JSON in `spec/fixtures/valid/`, register it in `spec/manifest.json`, and link the exact official Slack reference used to validate it.
2. Map every new JSON-producing capability to at least one valid fixture in `spec/coverage.json`.
3. Add invalid behavior to `spec/fixtures/invalid/manifest.json` when the feature introduces a validation rule. Every scalar leaf in `spec/limits.json` must have a matching invalid case.
4. Update `spec/limits.json` for shared scalar constraints.
5. Implement the feature idiomatically in both packages, or record a reason in the affected skip list.
6. Add language-native tests and update the relevant guide or executable example.

When introducing a cross-language API, serialized JSON and validation outcomes are shared; public naming and construction style are language-native.

Expand Down
2 changes: 2 additions & 0 deletions python/slackblocks/blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
EmailInput,
ExternalMultiSelectMenu,
ExternalSelectMenu,
FileInput,
NumberInput,
PlainTextInput,
RadioButtonGroup,
Expand Down Expand Up @@ -85,6 +86,7 @@
RichTextInput,
EmailInput,
URLInput,
FileInput,
)


Expand Down
4 changes: 1 addition & 3 deletions python/slackblocks/elements.py
Original file line number Diff line number Diff line change
Expand Up @@ -404,11 +404,9 @@ def __init__(
self.max_files = validate_int(max_files, min_value=1, max_value=10, allow_none=True)

def _resolve(self) -> dict[str, Any]:
# FileInput currently does not emit the "type" attribute; this is
# preserved from prior behaviour. The pre-existing #154 export work
# surfaced this class but did not change its rendering contract.
return resolve(
{
**self._attributes(),
"action_id": self.action_id,
"filetypes": self.filetypes,
"max_files": self.max_files,
Expand Down
244 changes: 233 additions & 11 deletions python/test/conformance/test_conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,35 @@
import pytest

from slackblocks import (
ActionsBlock,
Button,
ConfirmationDialogue,
ContextBlock,
DividerBlock,
FileInput,
HeaderBlock,
HomeTabView,
Image,
ImageBlock,
InputBlock,
LengthError,
MarkdownBlock,
MissingRequiredError,
ModalView,
MutualExclusivityError,
NumberInput,
Option,
OptionGroup,
OverflowMenu,
PlainText,
PlainTextInput,
RangeError,
SectionBlock,
SlackFile,
StaticSelectMenu,
Text,
TypeMismatchError,
VideoBlock,
)
from slackblocks.errors import InvalidUsageError

Expand All @@ -50,10 +58,23 @@ def test_valid_manifest_covers_the_entire_fixture_corpus() -> None:
for path in (SPEC_ROOT / "fixtures" / "valid").rglob("*.json")
}
assert manifest["spec_version"] == "1.0.0"
assert len(fixture_ids) == 79
assert fixture_ids
assert len(fixture_ids) == len(manifest["fixtures"])
assert fixture_ids == files


def test_every_shared_json_capability_has_an_official_fixture() -> None:
manifest = load_json(SPEC_ROOT / "manifest.json")
coverage = load_json(SPEC_ROOT / "coverage.json")
fixtures = {fixture["id"]: fixture for fixture in manifest["fixtures"]}
assert coverage["spec_version"] == manifest["spec_version"]
for capability, fixture_ids in coverage["capabilities"].items():
assert fixture_ids, capability
for fixture_id in fixture_ids:
assert fixture_id in fixtures, f"{capability} -> {fixture_id}"
assert fixtures[fixture_id]["slack_docs"].startswith("https://docs.slack.dev/")


def test_every_valid_fixture_is_exercised_by_a_python_construction_test() -> None:
manifest = load_json(SPEC_ROOT / "manifest.json")
test_source = "\n".join(
Expand All @@ -78,18 +99,197 @@ def option(value: str = "a") -> Option:
return Option(text=PlainText("A"), value=value)


LIMITS = load_json(SPEC_ROOT / "limits.json")


def video(**overrides: object) -> VideoBlock:
inputs: dict[str, object] = {
"alt_text": "Video",
"thumbnail_url": "https://example.com/thumbnail.png",
"title": "Title",
"video_url": "https://example.com/video.mp4",
**overrides,
}
return VideoBlock(**inputs) # type: ignore[arg-type]


INVALID_CASES: dict[str, Callable[[], object]] = {
"text-empty": lambda: Text(""),
"text-too-long": lambda: Text("x" * 3001),
"button-action-id-too-long": lambda: Button(
text="A", action_id="x" * (LIMITS["action_id"]["max_length"] + 1)
),
"button-text-too-long": lambda: Button(
text="x" * (LIMITS["button"]["text"]["max_length"] + 1), action_id="a"
),
"button-url-too-long": lambda: Button(
text="A",
action_id="a",
url="x" * (LIMITS["button"]["url"]["max_length"] + 1),
),
"button-value-too-long": lambda: Button(
text="A",
action_id="a",
value="x" * (LIMITS["button"]["value"]["max_length"] + 1),
),
"confirmation-title-too-long": lambda: ConfirmationDialogue(
title="x" * (LIMITS["confirmation"]["title"]["max_length"] + 1),
text="Text",
confirm="Yes",
deny="No",
),
"confirmation-text-too-long": lambda: ConfirmationDialogue(
title="Title",
text="x" * (LIMITS["confirmation"]["text"]["max_length"] + 1),
confirm="Yes",
deny="No",
),
"confirmation-confirm-too-long": lambda: ConfirmationDialogue(
title="Title",
text="Text",
confirm="x" * (LIMITS["confirmation"]["confirm"]["max_length"] + 1),
deny="No",
),
"confirmation-deny-too-long": lambda: ConfirmationDialogue(
title="Title",
text="Text",
confirm="Yes",
deny="x" * (LIMITS["confirmation"]["deny"]["max_length"] + 1),
),
"option-text-too-long": lambda: Option(
text="x" * (LIMITS["option"]["text"]["max_length"] + 1), value="a"
),
"option-value-too-long": lambda: option("x" * (LIMITS["option"]["value"]["max_length"] + 1)),
"option-description-too-long": lambda: Option(
text="A",
value="a",
description="x" * (LIMITS["option"]["description"]["max_length"] + 1),
),
"option-group-label-too-long": lambda: OptionGroup(
label="x" * (LIMITS["option_group"]["label"]["max_length"] + 1),
options=[option()],
),
"option-group-empty": lambda: OptionGroup(label="Group", options=[]),
"option-group-too-many-options": lambda: OptionGroup(
label="Group",
options=[
option(str(index))
for index in range(LIMITS["option_group"]["options"]["max_items"] + 1)
],
),
"select-placeholder-too-long": lambda: StaticSelectMenu(
action_id="a",
options=[option()],
placeholder="x" * (LIMITS["select"]["placeholder"]["max_length"] + 1),
),
"select-too-many-options": lambda: StaticSelectMenu(
action_id="a",
options=[
option(str(index)) for index in range(LIMITS["select"]["options"]["max_items"] + 1)
],
),
"select-too-many-option-groups": lambda: StaticSelectMenu(
action_id="a",
option_groups=[
OptionGroup(label=f"Group {index}", options=[option()])
for index in range(LIMITS["select"]["option_groups"]["max_items"] + 1)
],
),
"overflow-empty": lambda: OverflowMenu("a", []),
"overflow-too-many-options": lambda: OverflowMenu(
"a",
[option(str(index)) for index in range(LIMITS["overflow"]["options"]["max_items"] + 1)],
),
"file-input-max-files-too-small": lambda: FileInput(
action_id="a", max_files=LIMITS["file_input"]["max_files"]["min"] - 1
),
"file-input-max-files-too-large": lambda: FileInput(
action_id="a", max_files=LIMITS["file_input"]["max_files"]["max"] + 1
),
"plain-text-input-max-length-too-large": lambda: PlainTextInput(
action_id="a", max_length=LIMITS["plain_text_input"]["max_length"]["max"] + 1
),
"actions-too-many-elements": lambda: ActionsBlock(
elements=[
Button(text="A", action_id=f"a-{index}")
for index in range(LIMITS["actions"]["elements"]["max_items"] + 1)
]
),
"context-too-many-elements": lambda: ContextBlock(
elements=[Text("A") for _ in range(LIMITS["context"]["elements"]["max_items"] + 1)]
),
"header-text-too-long": lambda: HeaderBlock("x" * (LIMITS["header"]["text"]["max_length"] + 1)),
"image-url-too-long": lambda: ImageBlock(
image_url="x" * (LIMITS["image"]["image_url"]["max_length"] + 1),
alt_text="Alt",
),
"image-alt-text-too-long": lambda: ImageBlock(
image_url="https://example.com/image.png",
alt_text="x" * (LIMITS["image"]["alt_text"]["max_length"] + 1),
),
"input-label-too-long": lambda: InputBlock(
label="x" * (LIMITS["input"]["label"]["max_length"] + 1),
element=PlainTextInput(action_id="a"),
),
"input-hint-too-long": lambda: InputBlock(
label="Label",
hint="x" * (LIMITS["input"]["hint"]["max_length"] + 1),
element=PlainTextInput(action_id="a"),
),
"markdown-empty": lambda: MarkdownBlock(""),
"markdown-too-long": lambda: MarkdownBlock(
"x" * (LIMITS["markdown"]["text"]["max_length"] + 1)
),
"section-text-too-long": lambda: SectionBlock(
"x" * (LIMITS["section"]["text"]["max_length"] + 1)
),
"section-too-many-fields": lambda: SectionBlock(
fields=["x"] * (LIMITS["section"]["fields"]["max_items"] + 1)
),
"section-field-too-long": lambda: SectionBlock(
fields=["x" * (LIMITS["section"]["fields"]["item_max_length"] + 1)]
),
"video-alt-text-empty": lambda: video(alt_text=""),
"video-alt-text-too-long": lambda: video(
alt_text="x" * (LIMITS["video"]["alt_text"]["max_length"] + 1)
),
"video-title-too-long": lambda: video(title="x" * (LIMITS["video"]["title"]["max_length"] + 1)),
"video-author-name-too-long": lambda: video(
author_name="x" * (LIMITS["video"]["author_name"]["max_length"] + 1)
),
"video-description-too-long": lambda: video(
description="x" * (LIMITS["video"]["description"]["max_length"] + 1)
),
"video-provider-name-too-long": lambda: video(
provider_name="x" * (LIMITS["video"]["provider_name"]["max_length"] + 1)
),
"view-missing-blocks": lambda: HomeTabView(blocks=[]),
"view-too-many-blocks": lambda: HomeTabView(
blocks=[DividerBlock() for _ in range(LIMITS["view"]["blocks"]["max_items"] + 1)]
),
"view-private-metadata-too-long": lambda: HomeTabView(
blocks=[DividerBlock()],
private_metadata="x" * (LIMITS["view"]["private_metadata"]["max_length"] + 1),
),
"view-callback-id-too-long": lambda: HomeTabView(
blocks=[DividerBlock()],
callback_id="x" * (LIMITS["view"]["callback_id"]["max_length"] + 1),
),
"view-title-too-long": lambda: ModalView(
title="x" * (LIMITS["view"]["title"]["max_length"] + 1),
blocks=[DividerBlock()],
),
"view-close-too-long": lambda: ModalView(
title="Title",
close="x" * (LIMITS["view"]["close"]["max_length"] + 1),
blocks=[DividerBlock()],
),
"view-submit-too-long": lambda: ModalView(
title="Title",
submit="x" * (LIMITS["view"]["submit"]["max_length"] + 1),
blocks=[DividerBlock()],
),
"section-missing-content": lambda: SectionBlock(),
"section-text-too-long": lambda: SectionBlock("x" * 3001),
"section-too-many-fields": lambda: SectionBlock(fields=["x"] * 11),
"section-field-too-long": lambda: SectionBlock(fields=["x" * 2001]),
"header-text-too-long": lambda: HeaderBlock("x" * 151),
"button-text-too-long": lambda: Button(text="x" * 76, action_id="a"),
"button-action-id-too-long": lambda: Button(text="A", action_id="x" * 256),
"option-value-too-long": lambda: option("x" * 76),
"overflow-too-many-options": lambda: OverflowMenu("a", [option(str(i)) for i in range(6)]),
"static-select-options-and-groups": lambda: StaticSelectMenu(
"a",
options=[option()],
Expand All @@ -101,12 +301,17 @@ def option(value: str = "a") -> Option:
"number-input-inverted-range": lambda: NumberInput(
is_decimal_allowed=True, min_value=2, max_value=1
),
"file-input-max-files-out-of-range": lambda: FileInput(action_id="a", max_files=11),
"context-invalid-element": lambda: ContextBlock(elements=[DividerBlock()]),
"input-invalid-element": lambda: InputBlock(label="Label", element=Button("A", "a")),
"view-missing-blocks": lambda: HomeTabView(blocks=[]),
}


def scalar_paths(value: object, prefix: tuple[str, ...] = ()) -> set[str]:
if not isinstance(value, dict):
return {".".join(prefix)}
return {path for key, nested in value.items() for path in scalar_paths(nested, (*prefix, key))}


ERROR_CATEGORIES = {
LengthError: "length-exceeded",
RangeError: "out-of-range",
Expand Down Expand Up @@ -134,6 +339,23 @@ def test_invalid_case_category(case_id: str, expected_category: str) -> None:
assert category == expected_category


def test_invalid_manifest_covers_every_scalar_limit() -> None:
cases = load_json(SPEC_ROOT / "fixtures" / "invalid" / "manifest.json")["cases"]
covered = {case["constraint"] for case in cases}
assert scalar_paths(LIMITS) <= covered


def test_invalid_manifest_has_unique_case_ids_and_constraints() -> None:
cases = load_json(SPEC_ROOT / "fixtures" / "invalid" / "manifest.json")["cases"]
assert len({case["id"] for case in cases}) == len(cases)
assert len({case["constraint"] for case in cases}) == len(cases)


def test_every_invalid_case_has_a_python_construction() -> None:
cases = load_json(SPEC_ROOT / "fixtures" / "invalid" / "manifest.json")["cases"]
assert set(INVALID_CASES) == {case["id"] for case in cases}


def test_python_skiplist_is_empty() -> None:
entries = [
line
Expand Down
Loading
Loading