Skip to content

Commit 8faba89

Browse files
feat(sdui-parser)!: one parser body in @objectstack/sdui-parser (#25712 2g PR 1) (#26211)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoAqVfFyhsxKuFcEP9YTBj25)_ Part of #25712 (4b 2g PR 1: one parser body; Tier S). Part of objectstack#25677. Clause-②: yes (narrowing: `validateTree` now also judges the nodes held in a manifest entry's declared `slots`, so a caller whose manifest carries `slots` gets diagnostics on slot-held nodes it did not get before) Metadata-protocol: none ## 维护者速读(草稿) - **改了什么:** - 按 2g 裁定(#25712 记录 6418729849,E+B),把渲染器那份 `@object-ui/sdui-parser` 多出来的东西并进 `@objectstack/sdui-parser`,包括节点槽位校验、已退役的 `body` 写法、html 层来源标记,以及它多导出的符号。 - `body-dialect.ts` 和 `provenance.ts` 从 MIT 包整体搬入,保留 MIT 声明,并随包发布 `LICENSE-MIT`。 - 两份不一致的地方按本包规则:非 enum 的 `type-mismatch` / `member-type-mismatch` 仍然一律是 error;`unconsumed-widget-option` 仍写成字面量。 - `SchemaNode` / `ValidationResult` 改名为 `ParsedSchemaNode` / `ManifestValidationResult`,旧名保留为 `@deprecated` 别名,不删任何导出。 - **哪些原来能保存的现在会被拒:没有。** 保存门(`os validate` / `build` / `lint` 和 `os serve` 的保存入口)读的是仓里跟踪的 `sdui.manifest.json`,也就是 `@objectstack/console` 随包带的那份。它 106 个条目里 0 个带 `slots`,所以新增的槽位遍历在保存门上无事可做。只有自己用 `slotsFor` 生成清单的调用方才会走到它,今天只有渲染器,而渲染器那份解析器本来就在遍历。 - **为什么改:** 两份解析器靠 `check:sdui-lockstep` 维持同一套语法和诊断码,其余部分各自漂移。本 PR 是 2g 的第 1 步。PR 2 把 ui 的 122 个引用改到本包并删除 ui 那份;PR 3 退役 lockstep 门。 - **风险与代价(含回滚):** 库层面的收窄,带 BREAKING 标注,按 `patch` 发布(元数据协议不变)。`parseJsx` 产出的节点多了一个 JSON 看不见的 symbol 标记。回滚 = revert 本 PR。 - **你要做的:** 无(Tier S)。 ## 席位核对 (left for the dispatch seat) ## Why Two copies of the constrained JSX parser existed: - `@objectstack/sdui-parser` (`packages/sdui-parser`), which the save gate runs; - `@object-ui/sdui-parser` (`ui/packages/sdui-parser`), which the renderer runs. `check:sdui-lockstep` held them to one grammar region, one diagnostic-code set and one containment predicate, and they drifted everywhere else. Tesla's 2g ruling keeps `@objectstack/sdui-parser` as the one package with no forwarding release, deletes the old package, rewrites its importers and takes `@object-ui/sdui-parser` out of the fixed group. This PR is step 1: the body becomes one, here. The plan, module by module, is #25712 comment 6419004222. ## What changes ### Source, module by module | Module | Winner | What this PR does | |---|---|---| | `input-type.ts` | n/a | Unchanged; the copies were identical. | | `codegen.ts` | ui | The generated `.d.ts` header gains `/* eslint-disable */` and names `@objectstack/sdui-parser`. No checked-in output carries the header. | | `parse.ts` | ui | `ParsedSchemaNode`. Every element is stamped with `markHtmlTierNode` at production. The LOCKSTEP comments go; the `forbidden-attr` rationale no longer leans on the gate. | | `types.ts` | ui | Adds `ParsedSchemaNode`, `ManifestValidationResult` and `ManifestComponent.slots`. Keeps `SchemaNode` and `ValidationResult` as `@deprecated` aliases. | | `index.ts` | ui | The renderer copy's superset of exports (below) and `manifestFromConfigs`' `slotsFor` option. The `binding` note keeps this copy's pointer to the single record in `types.ts`. | | `validate.ts` | ui code, **os severity** | Adds the node-slot walk (objectui#11170) and the `body` replacement in the unknown-prop branch. `checkType` and `checkMemberTypes` keep this package's rule: every mismatch is an `error`. The LOCKSTEP comments go. | | `dashboard-widget-options.ts` | os code, ui header | The emitted `code:` stays an inline literal, because of `check:dispatcher-error-vocabulary`. The header takes the renderer's census derivation, re-rooted to the `ui/` paths now in this tree, and keeps the spec-side drift section that `check:widget-option-census` holds. | | `body-dialect.ts` | moved in | MIT notice kept. | | `provenance.ts` | moved in | MIT notice kept. The registry key `@object-ui/sdui-parser.html-tier-node` is unchanged, because it is the cross-instance contract. | **New exports:** - `HTML_TIER_NODE`, `isHtmlTierNode`, `markHtmlTierNode`; - `acceptsChildren`, `CHILD_LIST_KEY`, `SDUI_BASE_PROPS`, `SduiBaseProp`, `SduiBasePropScope`; - `checkRetiredBodyDialect`, `RETIRED_CHILD_LIST_KEY`; - `NodeSlotDeclarationLike`, `assertFullyLoaded`, `RegistryConfigLike.lazy`. These cover every name the 2g-b importer inventory needs. ### Licence - `packages/sdui-parser/LICENSE-MIT` is a verbatim copy of `ui/packages/sdui-parser/LICENSE` (MIT, Copyright (c) 2024 ObjectQL), added to `files`. `npm pack --dry-run` lists it. `scripts/check-published-files.mjs` registers it in `EXTRA_ENTRIES` with its reason, because the gate admits no unexplained `files` entry. - The two moved modules open with a `/*! … */` notice that names the licence, the copyright line and `LICENSE-MIT`. esbuild keeps it in both `dist/index.js` and `dist/index.mjs`. - The four ported tests that carried the renderer's MIT header keep it, with its pointer retargeted to `LICENSE-MIT`. - The `license` field stays `Apache-2.0`. ### Behaviour, measured - **The save gate's verdicts do not move.** `scripts/gen-sdui-manifest-node.mjs` generates the tracked `sdui.manifest.json` through `manifestFromConfigs` without `slotsFor`, so 0 of its 106 entries carry `slots`. `packages/console/build-dist.sh` copies that file over the console's `dist/sdui.manifest.json`, and that copy is what `resolveSduiManifest` gives `os validate`, `build`, `lint` and `os serve`'s save door. The slot walk only works for a caller that builds a manifest with `slotsFor`. Today that is the renderer, whose own copy already walks the slots. - **`body`.** The message now names `children`. A `body` child list under a component that takes no children draws `not-a-container`, the code the `children` spelling draws. Both stay warnings. - The two sentences drop the renderer copy's `objectui#6771`, because `check:doc-authoring` refuses a tracker id in author-facing text (the module header keeps the citation). The renderer copy's text, `("body" was retired by objectui#6771)`, becomes `("body" is its retired spelling)`. `node-slot-walk-11170` pins the new text. - **The tree.** `parseJsx` elements carry an enumerable, symbol-keyed marker. `JSON.stringify` and `Object.keys` do not see it. The consumers' suites pass unchanged (Verification). ### Tests (`packages/sdui-parser/src/__tests__`) - **Ported whole (7):** `base-props-bind-hidden-11008`, `base-props-one-list-11044`, `body-dialect-6771`, `input-type-union`, `node-slot-walk-11170`, `provenance`, `tier`. Path pointers in comments are re-rooted to `ui/`. `provenance`'s "objectui-only" sentence now says what was true until this change. - **ui-only cases merged into this package's twins (7):** - `containment-declared-slot` +3: the base key, the `body` spelling, and the `isContainer` projection. The import now comes from `../index.js`. - `dashboard-widget-options` +3: the retired-sub-caption shapes and their control. - `type-attribute-collision` +1: the no-`type` regression guard with provenance. Re-ablated: restoring `{ type: tag, ...props }` keeps all nine green, so the header now says "nine". - The plan's estimate was 12; the other five were already here under different titles or quote styles. `literal-subset`'s `__proto__` case is covered by this file's `it.each`, and `member-kind`'s two by `member-type-mismatch`. - **Exact twins, so not ported (4):** `compile`, `html-tier-manifest-10735`, `inert-expression-6598`, `kanban-quick-add-8285`. - **New:** `deprecated-type-aliases.test.ts` (2 cases) pins both aliases at type level. Negative control: re-pointing one fails `pnpm typecheck` with TS2344. - **Left for PR 2 (5),** because each imports `@object-ui/*`, React or the spec, or reads ui sources: `injected-component-input-6950`, `inline-whitespace.test.tsx`, `render.test.tsx`, `spec-symbol-batch7`, `dashboard-widget-options-census`. - **Severity.** No ported case pinned `warning` for a non-enum mismatch, so no expectation flipped. ### What this leaves for PR 2 and PR 3 - **PR 2** rewrites importers to `@objectstack/sdui-parser`. Old names are not for new code. - Once ui imports this package, a non-enum mismatch is an `error` in the Console too. - `ui/scripts/__tests__/unconsumed-widget-option-claim-6186.test.ts` reads the ui copy's `dashboard-widget-options.ts` path, and the census header here points at the census test's current path. Both move with it. - The old `body` message text is pinned in `ui/packages/components/src/renderers/__tests__/base-props-one-list-11044.test.tsx` and quoted in `ui/content/docs/components/layout/semantic.mdx`. - `scripts/sdui-manifest.sources/sdui-parser.json` records `ui/packages/sdui-parser` as a registry source. - **PR 3** retires `check:sdui-lockstep`. It is green on this head (9 and 9 sources agree), so the PRs can land in either order. ## Verification Head `930df7bda`, on `main` `6b9355299`. 24 files, +1794 / −191, in two commits. Tier S: no governed path (each checked with `check-governed-merges --test`), under 3000 lines. - **`@objectstack/sdui-parser`:** `vitest run`, 22 files, 349 tests pass. `pnpm typecheck` and `pnpm build` (tsup + `check-dts-emitted`) exit 0. - **Consumers, built against this dist:** `packages/lint` 164 files / 7356 tests pass; `packages/metadata-protocol` 266 files / 21539 tests pass (3 files / 19 tests skipped by their own guards). - **`@objectstack/spec` `vitest --project repo`:** - On `1951e860e`: 1 of 1279 failed, `visibility-alias-keys-retirement`. It is the same failure CI's Test Core (1/8) showed on that head. - Its packages/** walk now reaches the ported `base-props-one-list-11044.test.ts`, whose fixture gives the SDUI tier's deprecated-but-accepted `visibleOn` base prop a value. Under `ui/` the walk never read it. - `930df7bda` adds the file to the walk's structural exclusions with its reason. That file then passes 12/12, and main's same shard is green. - **`pnpm check:sdui-lockstep`:** OK in-tree. The grammar region is byte-identical (214 lines), all 25 codes agree, the containment predicate agrees, across 9 and 9 sources. PR 3 can land before or after this one. - **Changeset gates:** - `check-changeset-no-major --event` with this body's declaration: level axis ✓, patch with `yes (narrowing)` and `none`. - `check-adr-0087-registration`: ✓, `not-required (no-migration-prescription)`. - **Gate sweep:** `node scripts/pm/dispatch-gates.mjs --commands` derives 120 gates on `930df7bda`. All 120 ran and exited 0. `--ran` reconciles 120 derived, 120 run, 0 NOT-MEASURED, 0 UNRUN. - Four spec gates that read built declarations (`check:api-surface`, `check:dual-source-exports`, `check:entry-nameability`, `check:exported-any`) first refused with exit 3 on a stale local spec `dist`. They were re-run green after `pnpm --filter @objectstack/spec build`. - Earlier passes: - Pass 1 (81 gates, pre-fix head) found `check-issue-citations`, `check:doc-authoring` and `check:published-files` red; see Behaviour and Licence. - Pass 2 (100 gates, `1951e860e`) was 100/100 green. - **Push battery** (`GITHUB_ACTIONS=true GITHUB_EVENT_NAME=push node scripts/pm/check-dispatch-gates.mjs`) on `930df7bda`: 2182 cases pass, 0 fail, EXIT 0. It was also 2182/0 on `1951e860e`. - **CI on `930df7bda`:** 41 success, 8 skipped, 0 failed; mergeable `clean`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01A6SEitNngAMugGbuJHWsDS --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6633075 commit 8faba89

24 files changed

Lines changed: 1794 additions & 191 deletions
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
'@objectstack/sdui-parser': patch
3+
---
4+
5+
feat(sdui-parser)!: one parser body: the renderer copy's additions join `@objectstack/sdui-parser` (#25712)
6+
7+
Clause-②: yes (narrowing: `validateTree` now also judges the nodes held in a manifest entry's declared `slots`, so a caller whose manifest carries `slots` gets diagnostics on slot-held nodes it did not get before)
8+
Metadata-protocol: none
9+
10+
Two copies of this parser existed: this package, which the save gate runs, and `@object-ui/sdui-parser`, which the renderer runs. `check:sdui-lockstep` held them to one grammar and one code set, and they drifted everywhere else. This change makes this package the one body. The renderer moves onto it and the second copy is deleted in follow-up changes under the same card.
11+
12+
**Added (from the renderer's copy).**
13+
14+
- Node-slot validation (objectui#11170). `ManifestComponent.slots` lists the positions besides `children` where a renderer reads nodes, such as `trigger`, `items[].content` and `regions[].components`. `validateTree` descends each one the way it descends `children`. `manifestFromConfigs` gains a `slotsFor` option that writes the list, and a manifest built without it carries none, so the walk follows `children` alone.
15+
- The retired `body` child-list key (objectui#6771). Before, `body` drew the bare `unknown-prop`. Now the message names `children`. A `body` child list under a component that takes no children draws `not-a-container`, the code the `children` spelling draws. Both remain warnings. Exported as `checkRetiredBodyDialect` and `RETIRED_CHILD_LIST_KEY`.
16+
- html-tier provenance (objectui#4000). `parseJsx` stamps every element it builds with an enumerable symbol-keyed marker, `Symbol.for('@object-ui/sdui-parser.html-tier-node')`. `JSON.stringify` and `Object.keys` do not see it. Exported as `HTML_TIER_NODE`, `isHtmlTierNode` and `markHtmlTierNode`. The registry key keeps its original package name on purpose, so a node either copy stamped reads the same.
17+
- More exports:
18+
- `acceptsChildren`, `CHILD_LIST_KEY`, `SDUI_BASE_PROPS`, `SduiBaseProp` and `SduiBasePropScope`;
19+
- `assertFullyLoaded`, the manifest generators' guard against lazy stubs, with `RegistryConfigLike.lazy`;
20+
- `NodeSlotDeclarationLike`.
21+
- The generated `.d.ts` header gains `/* eslint-disable */` and names this package.
22+
23+
**Renamed, with the old names kept.** `ParsedSchemaNode` is the parsed node and `ManifestValidationResult` is `validateTree`'s result. These are the names the renderer's copy took so it would not collide with `@object-ui/types`' `SchemaNode` or the spec's `ValidationResult` (objectui#6349, objectui#3161). `SchemaNode` and `ValidationResult` stay exported as `@deprecated` aliases of them, so nothing exported is removed.
24+
25+
**Unchanged.**
26+
27+
- Every `type-mismatch` and `member-type-mismatch` is still an `error`. The renderer's copy graded them `error` only beside an `enum` arm; this package's rule is the one kept.
28+
- `unconsumed-widget-option` is still emitted as an inline literal.
29+
- The grammar, the 25 diagnostic codes and the containment predicate.
30+
31+
**BREAKING** (a narrowing of `validateTree` for a manifest that carries `slots`), shipped as `patch` because no metadata protocol kind moves. The save gate's verdicts do not move, and that was measured: the manifest that `objectstack validate`, `build`, `lint` and `serve` read is the tracked `sdui.manifest.json`, which `@objectstack/console` ships. It carries `slots` on 0 of its 106 entries, because its generator calls `manifestFromConfigs` without `slotsFor`. So no save that was accepted before is refused now. The walk has work only for a caller that builds its manifest with `slotsFor`, and today that is the renderer, whose own copy already walks the slots.
32+
33+
Migration, for a TypeScript caller: none is required. To move off the deprecated names, change `SchemaNode` to `ParsedSchemaNode` and `ValidationResult` to `ManifestValidationResult`.
34+
35+
`body-dialect.ts` and `provenance.ts` moved in from the MIT-licensed `@object-ui/sdui-parser` and keep that licence. Each carries the notice, and the package now ships `LICENSE-MIT`.
36+
37+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition, stored row or stored shape is added, removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is `validateTree`, a function of this library, for a manifest that carries the new `slots` key: nodes held in those positions are now judged. No manifest the save gate reads carries that key (the tracked `sdui.manifest.json`: 0 of 106 entries), so no stored or authored page changes verdict there. A caller who builds a slotted manifest gets diagnostics on slot-held nodes, and the repair is the page author's per diagnostic, which no ledger entry can derive. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed, since the two renamed types stay exported as deprecated aliases (not runtime-interface-only, type-surface-only or runtime-export-retired). -->

‎packages/sdui-parser/LICENSE-MIT‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2024 ObjectQL
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

‎packages/sdui-parser/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@
4949
"files": [
5050
"dist",
5151
"README.md",
52-
"CHANGELOG.md"
52+
"CHANGELOG.md",
53+
"LICENSE-MIT"
5354
],
5455
"engines": {
5556
"node": ">=22.0.0"
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE-MIT file beside this package's manifest.
7+
*/
8+
9+
/**
10+
* `bind` and `hidden` are base props of the tier (objectui#11008).
11+
*
12+
* `BaseSchema` (`@object-ui/types`) declares both for every node, and no
13+
* registration declares either as an input. Before they joined `BASE_PROPS`,
14+
* the undeclared-key branch answered every authored one with `unknown-prop`
15+
* ("has no prop"), including on the nodes that honour it: `SchemaRenderer`
16+
* reads `hidden` for every node, and every `useDataScope` renderer reads
17+
* `bind`. This file pins the TIER's reading of a manifest; the live-registry
18+
* half — `list`, `tree-view` and `data-table` — is
19+
* `ui/packages/components/src/renderers/__tests__/bind-base-prop-parser-tier-11008.test.tsx`.
20+
*
21+
* Three facts, kept apart because each one fails differently:
22+
*
23+
* - both keys draw nothing, in every value shape `BaseSchema` admits for
24+
* them — the fix;
25+
* - a near-miss spelling (`bindTo`, `hide`) on the SAME node still draws
26+
* `unknown-prop` naming it — the lit control. Without it, "draws nothing"
27+
* would also pass against a tier that stopped warning on undeclared keys;
28+
* - a registration that DECLARES `placeholder` keeps its `type-mismatch`.
29+
* `placeholder` is a `BaseSchema` member too, and it was left out of the
30+
* `every-node` members because they skip the declared-input lookup. It
31+
* joined later as a `where-undeclared` member (objectui#11044), which a
32+
* declared input outranks: this row goes red if it is ever made an
33+
* `every-node` member the way `bind` and `hidden` were.
34+
*/
35+
import { describe, expect, it } from 'vitest';
36+
import { manifestFromConfigs, validateTree } from '../index.js';
37+
import type { Diagnostic, Manifest, SchemaElement } from '../types.js';
38+
39+
const manifest: Manifest = manifestFromConfigs([
40+
// a leaf that declares neither key, as no live registration does
41+
{ type: 'leaf', namespace: 'ui', inputs: [{ name: 'content', type: 'string' }] },
42+
// the input-family shape: `placeholder` declared as a typed input
43+
{ type: 'field', namespace: 'ui', inputs: [{ name: 'placeholder', type: 'string' }] },
44+
] as unknown as Parameters<typeof manifestFromConfigs>[0]);
45+
46+
const diagnose = (node: unknown): Diagnostic[] =>
47+
validateTree(node as SchemaElement, manifest).diagnostics;
48+
49+
const LEAF = { type: 'leaf', content: 'x' };
50+
51+
/** Every value shape `BaseSchema` declares for the two keys. */
52+
const BASE_VALUES: Record<'bind' | 'hidden', unknown[]> = {
53+
bind: ['users', 'app.settings.users'],
54+
hidden: [true, false, '${data.role !== "admin"}', { dialect: 'cel', source: "record.status == 'draft'" }],
55+
};
56+
57+
describe('objectui#11008 — `bind` and `hidden` are base props of the parser tier', () => {
58+
it('the control node draws nothing', () => {
59+
expect(diagnose(LEAF)).toEqual([]);
60+
});
61+
62+
it.each(Object.keys(BASE_VALUES) as Array<keyof typeof BASE_VALUES>)(
63+
'`%s` draws no diagnostic, in every value shape BaseSchema admits',
64+
(key) => {
65+
const drew = BASE_VALUES[key]
66+
.map((value) => [value, diagnose({ ...LEAF, [key]: value })] as const)
67+
.filter(([, found]) => found.length > 0)
68+
.map(([value, found]) => `${JSON.stringify(value)} → ${JSON.stringify(found.map((d) => d.code))}`);
69+
expect(drew).toEqual([]);
70+
},
71+
);
72+
73+
it.each([
74+
['bindTo', 'users'],
75+
['hide', true],
76+
] as const)('lit control: the near-miss `%s` still draws one `unknown-prop` naming it', (key, value) => {
77+
const found = diagnose({ ...LEAF, [key]: value });
78+
expect(found.map((d) => d.code)).toEqual(['unknown-prop']);
79+
expect(found[0]!.message).toContain(`"${key}"`);
80+
});
81+
82+
it('a registration that declares `placeholder` keeps its type check — why `placeholder` is not `every-node`', () => {
83+
expect(diagnose({ type: 'field', placeholder: 'Search…' })).toEqual([]);
84+
const found = diagnose({ type: 'field', placeholder: 42 });
85+
expect(found.map((d) => d.code)).toEqual(['type-mismatch']);
86+
expect(found[0]!.message).toContain('"placeholder"');
87+
});
88+
});

0 commit comments

Comments
 (0)