feat: add queryable Assets resources (#5946)

Closes #5919
Ref #5920

## Outcome

Extend the existing Assets system resource with optional queries for
Markdown, JSON, and standard file metadata. Unconfigured Assets
resources retain the existing fetch-all behavior and activate none of
the content-query infrastructure.

Files are the authoring model. A standalone content engine converts an
immutable snapshot of only the files required by configured queries into
one bounded, deterministic runtime database. Builder preview, SSR, and
SSG use the same query semantics without requiring a database service
for a published site.

## Final architecture

```text
Markdown / JSON files
        │
        ▼
ContentSource adapter
(FS or Webstudio Assets)
        │ immutable snapshot
        ▼
ContentCompilationPlan
(union of configured query requirements)
        │
        ▼
ContentCompiler
(parse, normalize, discover fields, enforce budget)
        │
        ▼
ContentArtifact
(bounded, deterministic, immutable JSON)
        │
        ▼
ContentDatabase
(filter, OR groups, sort, paginate, project, read content)
        │
        ├── Builder preview
        ├── SSR runtime
        └── SSG build only
```

The authoring/storage layer owns mutable files. The content engine
consumes immutable snapshots and produces immutable, disposable
artifacts.


Query authoring is a separate OpenAPI-driven path:

```text
Assets REST OpenAPI operation
+ standard request JSON Schema
+ dynamic field paths
        │
        ▼
Generic OpenAPI adapter
        │ resolved schemas, controls, paths, and transport mapping
        ▼
Generic query-builder core and React renderer
        │ arbitrary provider-defined value
        ▼
Assets REST request adapter
        │
        ▼
{ query: value }
```

The selected standard OpenAPI operation is the only source of query UI
semantics. Request parameters and JSON Schema describe the editable
data; the generic renderer derives controls and owns layout.

### Activation and compilation

- The compiler does not index every asset. It combines all reachable
configured Assets-resource queries into one `ContentCompilationPlan`.
- Static constraints such as folder, path, and file type select
candidate files. Dynamic values such as a route-bound slug cannot reduce
the build-time candidate set.
- The plan contains the union of fields used by filtering, sorting,
routing, projection, excerpts, and requested full content.
- Documents shared by multiple resources are stored once in one
deployment-wide runtime database.
- Markdown/frontmatter, JSON properties, excerpts, and full bodies are
prepared only when required.
- No configured content query means no parsing, artifact, or shipped
runtime dependency. Fresh generated apps need no content-runtime
cleanup; incremental generation removes stale integration if a
previously configured query is deleted.

### Runtime database limit

- `CONTENT_DATABASE_MAX_BYTES` limits the complete serialized runtime
database, not source files, images, or unrelated assets.
- The default is `500 * 1024` bytes and is presented as 500 KiB.
- Counted data includes indexed metadata, frontmatter/JSON properties,
excerpts, the field catalog, serialization overhead, and full Markdown
bodies only when requested.
- Prepublish validation builds and serializes the exact database that
will be attached to the immutable build, so it knows the unbounded size,
bounded size, included documents, omitted documents, and affected
resources.
- Overflow never stores partial documents. Candidates are ordered by
`createdAt` descending, canonical path ascending, and stable document ID
ascending. Missing dates follow dated documents. A document that does
not fit is skipped while smaller later documents are still considered.
- Exceeding the limit warns but does not block publishing.
- Builder preview can report exact bounded versus unbounded results when
dynamic expressions have concrete preview values. Prepublish warnings
remain conservative for future route values.

### Snapshot consistency

The source contract guarantees a consistent compilation, while adapters
provide the mechanism:

- Webstudio Assets captures asset metadata and immutable content
references.
- Filesystem sources capture file identity and verify files did not
change before accepting the artifact.
- A changed source retries once, then fails clearly instead of producing
a mixed-revision database.
- This requires no universal snapshot service, source-tree copy, or new
versioned-asset infrastructure.

### Package ownership

- `@webstudio-is/content-engine`: runtime artifact schema, query
execution, field catalog, compact runtime statistics.
- `@webstudio-is/content-engine/compiler`: source snapshots, compilation
plans, Markdown/JSON parsing, deterministic compilation, byte
accounting, and compiler diagnostics. This entry exists to keep
Remark/YAML/future MDX dependencies out of runtime bundles.
- `@webstudio-is/query-builder`: resolves standard OpenAPI operation
schemas into an internal provider-neutral editor model, validation,
mutations, and source serialization. It owns no Assets,
provider-authored UI contract, or fixed REST-query semantics.
- `@webstudio-is/query-builder/react`: container-neutral schema renderer
whose layout is renderer policy. It remains separate from the content
engine.
- SDK: Webstudio Assets/Resource domain models and mapping Webstudio
expressions into generic content requirements.
- Protocol: external Assets REST/OpenAPI transport contracts.
- Asset uploader/repository: asset and folder mutations, generic
metadata, immutable byte references, and storage access. Its hosted
adapter supplies Webstudio Assets snapshots and parsed-metadata cache
persistence to the content engine; parsing, artifact creation, and query
semantics remain engine-owned.
- Builder and generated-app integrations decide when to compile, cache,
embed, load, or discard a database.

### Runtime lifecycle

- Builder caches immutable `ContentDatabase` instances by source
revision plus compilation-plan hash and coalesces concurrent
compilation. Matching operations reuse the same instance; field
discovery uses a dedicated minimal all-properties plan that excludes
excerpts and bodies.
- SSR attaches the validated bounded database to the build and includes
the small runtime only when required.
- SSG uses the database only while generating pages and omits it and the
runtime from final output unless a future runtime-query feature requires
them.
- Local development compiles from the filesystem and uses identical
query semantics.
- Published pages query their local artifact; they do not call Builder
REST endpoints.
- Published artifacts need no external index store, invalidation,
garbage collection, SaaS content proxy, new bucket, or new environment
variable.

## Implemented foundation

These existing parts remain useful and should be moved behind the final
ownership boundaries rather than reimplemented.

- [x] Upgrade the existing Assets system resource in place and preserve
unconfigured fetch-all behavior.
- [x] Implement typed filtering, nested boolean groups, deterministic
sorting, pagination, projections, dynamic values, and content hydration.
- [x] Add Markdown frontmatter, excerpt, JSON-property, and
field-catalog extraction.
- [x] Add reusable query-control implementations and a separately
imported React UI as a foundation for the final OpenAPI-driven contract.
- [x] Add Assets query authoring and dynamic field discovery as existing
behavior to migrate into the Assets OpenAPI operation.
- [x] Add synchronized visual and editable expression authoring.
- [x] Unify Assets REST operations under `/rest/assets/*`.
- [x] Define authenticated collection, item, content, upload, folder,
query, field-catalog, and OpenAPI operations.
- [x] Support Builder cookie authentication and public API token
authentication.
- [x] Add Builder preview, MCP/CLI operations, SSR execution, SSG path
generation, and conditional activation.
- [x] Add revision-keyed parsed metadata persistence for the hosted
Builder.
- [x] Add focused query, API, authorization, Builder, CLI, SSR, SSG,
migration, and fixture coverage.

## Remaining implementation plan

### 1. Establish the content engine

- [x] Rename the generic `@webstudio-is/asset-resource` implementation
to `@webstudio-is/content-engine`.
- [x] Move existing behavior instead of creating a parallel
implementation.
- [x] Keep the primary entry runtime-only and add only the compiler
entry needed for bundle separation.
- [x] Remove the old package after all consumers migrate.
- [x] Ensure the engine has no Builder, SDK, asset-uploader, PostgreSQL,
R2, or deployment-target dependency.

### 2. Define source snapshots and compilation plans

- [x] Introduce `ContentSource.openSnapshot()` and a revision-bearing
`ContentSourceSnapshot`.
- [x] Include canonical path, stable document ID, content type,
immutable content reference, size, and optional `createdAt`.
- [x] Implement Webstudio Assets and filesystem snapshot guarantees
using their adapter-specific mechanisms.
- [x] Introduce `ContentCompilationPlan` for the union of reachable
configured resource requirements.
- [x] Separate statically selectable candidates from dynamic runtime
predicates.
- [x] Add mutation-during-compilation tests for replacement, deletion,
folder movement, and filesystem changes.

### 3. Separate storage from content processing

- [x] Keep upload, replacement, folders, byte storage, revisions, and
generic metadata in the Assets repository/uploader.
- [x] Move parsing, excerpts, field discovery, query execution, and
artifact creation into the content engine.
- [x] Remove content-index coordination from asset mutation business
logic.
- [x] Preserve original source `createdAt` across content replacement
and expose it as optional queryable metadata.
- [x] Keep hosted parsed-document persistence behind the content
compiler's optional cache interface.

### 4. Build deterministic compilation

- [x] Move the existing Markdown, frontmatter, JSON, canonicalization,
hashing, document-building, and tests into the compiler.
- [x] Keep frontmatter behind a dedicated module and continue using the
existing Remark/Micromark pipeline.
- [x] Parse only candidate files and only the tiers required by the
compilation plan.
- [x] Deduplicate documents and fields required by multiple resources.
- [x] Define a versioned, deterministic `ContentArtifact` with integrity
information.
- [x] Keep verbose parse and omission diagnostics outside the runtime
artifact.
- [x] Reject incompatible artifact versions cleanly.

### 5. Enforce the deployment-wide database budget

- [x] Replace the old asset-index limit with
`CONTENT_DATABASE_MAX_BYTES`, defaulting to 500 KiB.
- [x] Have applications read configuration and pass the byte limit into
the compiler.
- [x] Measure the exact serialized database, including selected bodies
and overhead.
- [x] Implement deterministic skip-and-continue packing of complete
documents.
- [x] Return compiler diagnostics with unbounded/bounded bytes,
included/omitted counts, omitted identities, largest contributors, and
affected resources.
- [x] Expose only compact artifact statistics at runtime through
`ContentDatabase.getStats()`.
- [x] Add exact boundary, deterministic ordering, deduplication, and
oversized-document tests.

### 6. Finalize the runtime database

- [x] Define `ContentDatabase` over an immutable artifact.
- [x] Preserve filtering, nested AND/OR groups, sorting, offset/limit,
projection, field references, and content retrieval.
- [x] Expose `getFieldCatalog()` and compact `getStats()`.
- [x] Keep Markdown/YAML/compiler dependencies out of the runtime entry.
- [x] Add execution guards and maximum-size runtime benchmarks.

### 7. Make query authoring OpenAPI-driven

The selected standard OpenAPI operation is the complete query-authoring
contract. The query builder resolves parameters and request-body JSON
Schema, derives its internal editor model, and applies renderer-owned
layout policy. It must not embed or fetch a second capabilities/UI
language through `x-webstudio-query` or another side channel.

- [x] Remove `x-webstudio-query` and all vendor query-definition
metadata from the Assets OpenAPI operation.
- [x] Resolve local OpenAPI references and derive the editable request
value, defaults, validation, labels, choices, arrays, objects, and
unions solely from standard operation parameters and request-body JSON
Schema.
- [x] Represent dynamic Assets field paths with standard JSON Schema
vocabulary in the `queryAssets` request schema.
- [x] Keep the normalized editor representation internal to
query-builder; do not expose another provider-authored capabilities
contract.
- [x] Remove provider-controlled layout metadata such as `group`; make
compact scalar packing and all other layout renderer policy.
- [x] Keep Webstudio expression binding as a consumer/editor concern
layered over schema values, not part of the OpenAPI contract.
- [x] Preserve the existing Assets filter, sort, output selection,
full-content/range selection, limit/offset, editable source, and
configured-field behavior.
- [x] Keep the Assets resource adapter responsible for serializing the
edited query into its request body.
- [x] Remove the obsolete Assets query-capabilities endpoint, URL, and
UI-side capability parsing.
- [x] Add a non-Assets OpenAPI proof with differently named parameters
and no vendor metadata.
- [x] Verify the generic packages contain no Assets imports, fixed
Assets property contract, or provider-authored layout model.
- [x] Update focused core, React, SDK, protocol/OpenAPI, Builder, and
source round-trip tests.

### 8. Manage ContentDatabase lifecycle in Builder

- [x] Cache databases by source revision plus normalized
compilation-plan hash.
- [x] Coalesce concurrent compilation requests.
- [x] Reuse cached database instances across field discovery,
autocomplete, query preview, and response preview when their normalized
compilation plans match.
- [x] Replace it when source revision or query requirements change.
- [x] Preserve the optional revision-keyed hosted parsed-document cache
without making it a source of truth.
- [x] Show exact preview warnings when concrete values reveal missing
bounded results.

### 9. Materialize and verify immutable builds

- [x] Compile and serialize the exact deployment-wide database during
prepublish validation.
- [x] Attach that validated bounded database to the immutable build
without rebuilding it from later asset state.
- [x] Show non-blocking prepublish warnings with database bytes,
omissions, and potentially affected resources.
- [x] Avoid claiming exact future route matches when dynamic values are
unknown.
- [x] Include the database/runtime only when a reachable configured
query requires them.
- [x] Ensure detail resources requesting Markdown content include all
statically eligible bodies needed by dynamic routes.

### 10. Integrate all targets

- [x] SSR: load the build's bounded database and conditionally include
the small runtime.
- [x] SSG: query the same database during generation, then omit
database/runtime output when no runtime query is needed.
- [x] Local/CLI: compile from the filesystem source with the same
semantics.
- [x] Preserve overview pagination, alternative route fields,
draft-as-user-data behavior, and full article rendering.
- [x] Verify ordinary Assets and fresh query-free sites perform no
parser, artifact, content-runtime dependency install, or cleanup work.

### 11. Correct API and package ownership

- [x] Move external Assets REST/OpenAPI contracts to
`@webstudio-is/protocol`.
- [x] Keep Webstudio domain models and Assets system-resource
configuration in the SDK.
- [x] Keep both cookie and token authentication behavior on the unified
Assets REST surface.
- [x] Remove pass-through route wrappers/reexports that add no transport
behavior.
- [x] Keep published runtime queries local to the artifact rather than
routing them through Builder REST.

### 12. Remove superseded deployment infrastructure

- [x] Remove external deployment-index upload, fetch, invalidation,
cleanup, and garbage-collection code.
- [x] Remove the SaaS asset-origin adapter requirement; close
webstudio-is/webstudio-saas#652 as superseded because it has no
independent purpose.
- [x] Remove index-bucket compatibility configuration and avoid adding
Infisical variables.
- [x] Remove obsolete external-index paths, package dependencies,
routes, scripts, and tests.
- [x] Consolidate unreleased intermediate migrations where safe and
retain only schema required by the final hosted cache/API design.

### 13. Verification and release readiness

- [x] Move and preserve existing tests while changing ownership; add new
tests only for changed contracts and behavior.
- [x] Run content-engine compiler/runtime tests, query-builder tests,
SDK/protocol/API tests, Builder tests, CLI tests, and affected
typechecks.
- [x] Run formatting, generated-file idempotence, fixtures, clean
Supabase-compatible migrations, E2E shards, and complete agent
evaluations.
- [x] Verify SSR and SSG conditional inclusion and query-free generated
package manifests.
- [x] Run the five-article MCP blog workflow: authoring, field
discovery, overview, dynamic detail routes, full Markdown rendering,
publishing, and published-site verification.
- [x] Run a maximum-budget database test and a
mutation-during-compilation test.
- [x] Perform a final architecture, security, API compatibility,
migration, bundle, correctness, and DRY review.
- [x] Reconcile every checkbox and replace the verification section with
results from the final architecture before release.

## Future MDX work

MDX is intentionally separate from this release.

- The compiler contract should allow a future build-only MDX parser.
- MDX compilation must not enter the published runtime.
- Embedded JSX must use an explicit compiled representation rather than
runtime source evaluation.
- Webstudio components, tokens, styles, and component allowlists remain
Webstudio integration concerns.
- The future MDX work should reuse `ContentArtifact` and
`ContentDatabase` where practical.

## Superseded design

The following earlier design choices are no longer the target
architecture:

- GROQ or GraphQL as the Assets query language.
- One external or long-lived index object per resource/query.
- A deployment index fetched by the Worker from SaaS/CDN infrastructure.
- Lazy production reads of source Markdown bodies.
- A separate index bucket, asset origin, invalidation service, or
garbage collector.
- A 4 MiB published index limit.
- Rebuilding an old build's database from the current mutable asset set.
- Bundling query infrastructure into projects without a configured
content query.

## Verification baseline

The embedded runtime-database architecture previously passed the checks
below. The OpenAPI-driven query-authoring change additionally passed:

- Query-builder core: 4 files / 19 tests.
- Query-builder React: 1 file / 7 tests.
- SDK Assets source/config: 1 file / 7 tests.
- Protocol Assets OpenAPI: 1 file / 9 tests, including independent
OpenAPI validation.
- Builder Assets OpenAPI description: 1 file / 5 tests.
- Query-builder, React, SDK, protocol, and Builder typechecks.
- Package-boundary validation and formatting of every affected file.

Local verification baseline:

- Content engine: 15 files / 104 tests.
- Asset uploader: 24 files / 175 tests.
- Builder: 204 files / 2,423 tests.
- CLI: 54 files / 866 tests.
- Complete workspace typecheck, formatting, lint, package boundaries,
generated fixtures, and fixture builds.
- The complete migration history applied successfully to a fresh
Supabase-compatible database, including every migration after the
consolidated Assets migration.
- Builder production build and full local E2E suite passed.
- Complete agent evaluations passed: authenticated page (170,859 ms),
design input (207,101 ms), and font assets (67,050 ms).
- Final review additionally verified correct REST edit/view/build
permits, cookie and public-token read paths, immutable snapshot retries,
exact database budgeting, query-free dependency omission, and SSR/SSG
conditional runtime inclusion.

Final staging verification passed against the embedded runtime-database
architecture: MCP uploaded and updated five Markdown articles,
discovered dynamic frontmatter fields, previewed a five-item sorted
overview and one full-body slug query, updated both Assets resources,
found zero binding issues, rendered `/blog` and `/blog/dynamic-routes`
at HTTP 200, published build version 266, and verified both published
routes at HTTP 200 with the expected article metadata and Markdown body.
The workflow also found and fixed offset-form asset timestamps failing
canonical datetime validation and made MCP `preview.start({ port: 0 })`
correctly select an available local port.

## Release constraints

- No new SaaS content service, bucket, or environment variable.
- No published-site database dependency.
- No parser/compiler dependency in query-free sites or runtime-only
bundles.
- The validated content database is immutable build output.
- Publishing warnings do not block deployment.
- The selected standard OpenAPI operation and its request JSON Schema
are the only source of query UI semantics.
This commit is contained in:
Oleg Isonen authored and GitHub committed 2026-07-31 00:17:46 +02:00
1 parent 7bc9942774
commit efbc3098c4
582 files changed
+49577 -8969

No files matched your search

+4
View File
@@ -61,6 +61,10 @@ jobs:
- name: pnpm deploy
run: pnpm --filter 'ssg-cloudflare-pages' deploy "${{ github.workspace }}/../ssg-template"
- name: Verify optional Assets build dependency is omitted
run: test ! -e node_modules/@webstudio-is/content-engine/package.json
working-directory: ${{ github.workspace }}/../ssg-template
- name: Make archive
run: |
tar --use-compress-program="zstd -19" -cf ssg-template.tar.zst ssg-template
+4
View File
@@ -62,6 +62,10 @@ jobs:
- name: pnpm deploy
run: pnpm --filter 'webstudio-cloudflare-template' deploy "${{ github.workspace }}/../cloudflare-template"
- name: Verify optional Assets build dependency is omitted
run: test ! -e node_modules/@webstudio-is/content-engine/package.json
working-directory: ${{ github.workspace }}/../cloudflare-template
- name: Make archive
run: |
tar --use-compress-program="zstd -19" -cf cloudflare-template.tar.zst cloudflare-template
+3
View File
@@ -65,6 +65,9 @@ jobs:
exit "$sync_status"
fi
- name: Build CLI content runtime
run: mkdir -p packages/cli/lib && pnpm --filter webstudio build:content-runtime
- name: Testing cli build command
run: pnpm --filter='./fixtures/*' run --parallel fixtures:build
+1 -1
View File
@@ -147,7 +147,7 @@ jobs:
const results = [
await assertSize('./fixtures/ssg-cloudflare-pages/dist/client', 356),
await assertSize('./fixtures/react-router-netlify/build/client', 376),
await assertSize('./fixtures/webstudio-features/build/client', 3312),
await assertSize('./fixtures/webstudio-features/build/client', 3684),
]
for (const result of results) {
if (result.passed) {
+2 -1
View File
@@ -11,6 +11,7 @@
"**/*.yaml",
"**/*.toml",
"**/*.jsonc",
"packages/cli/templates/**"
"packages/cli/templates/**",
"fixtures/**/__generated__/**"
]
}
+3 -3
View File
@@ -1,10 +1,10 @@
# To override environment variables, add a .env.development file in the same directory.
# For more details, visit: https://vitejs.dev/guide/env-and-mode#env-files
DATABASE_URL=postgresql://postgres:pass@localhost:55432/webstudio?pgbouncer=true
DIRECT_URL=postgresql://postgres:pass@localhost:55432/webstudio
DATABASE_URL=postgresql://supabase_admin:pass@localhost:55432/webstudio?pgbouncer=true
DIRECT_URL=postgresql://supabase_admin:pass@localhost:55432/webstudio
PGPORT=55432
POSTGRES_DB=webstudio
POSTGRES_USER=postgres
POSTGRES_USER=supabase_admin
POSTGRES_PASSWORD=pass
AUTH_SECRET=0000
@@ -11,7 +11,7 @@ import {
import { BrushCleaningIcon, PlusIcon } from "@webstudio-is/icons";
import { useRef, useState } from "react";
import { useStore } from "@nanostores/react";
import { isTextFileAsset } from "@webstudio-is/sdk";
import { getAssetUrl, isTextFileAsset } from "@webstudio-is/sdk";
import { AssetManager } from "~/builder/shared/asset-manager";
import { AssetUpload, type AssetUploadHandle } from "~/builder/shared/assets";
import { openDeleteUnusedAssetsDialog } from "~/builder/shared/asset-manager/delete-unused-assets";
@@ -22,7 +22,6 @@ import type { Publish } from "~/shared/pubsub";
import { useImageAssetCanvasDrag } from "./use-image-asset-canvas-drag";
import { TextFileEditor } from "~/builder/features/text-file-editor/text-file-editor";
import { CreateTextFileDialog } from "~/builder/features/text-file-editor/create-text-file-dialog";
import { getAssetUrl } from "~/builder/shared/assets/asset-utils";
export const AssetsPanel = ({
publish,
@@ -11,12 +11,9 @@ import {
theme,
} from "@webstudio-is/design-system";
import { TrashIcon, PlusIcon } from "@webstudio-is/icons";
import { isLiteralExpression } from "@webstudio-is/sdk";
import { isLiteralExpression } from "@webstudio-is/expression";
import { computeExpression } from "@webstudio-is/project-build/runtime";
import {
BindingControl,
BindingPopover,
} from "~/builder/shared/binding-popover";
import { BindableExpressionControl } from "~/builder/shared/bindable-expression";
import { $pageRootScope } from "./page-utils";
type Meta = {
@@ -75,45 +72,35 @@ const MetadataItem = (props: {
<Label htmlFor={contentId} css={{ gridArea: "content" }}>
Content
</Label>
<BindingControl>
{props.showBindingControls && (
<BindingPopover
scope={scope}
aliases={aliases}
variant={isLiteralExpression(props.content) ? "default" : "bound"}
value={props.content}
onChange={(value) => {
props.onChange(props.property, value);
}}
onRemove={(evaluatedValue) => {
props.onChange(
props.property,
JSON.stringify(evaluatedValue ?? "")
);
}}
/>
<BindableExpressionControl
expression={props.content}
value={String(content ?? "")}
bound={isLiteralExpression(props.content) === false}
allowBindingOverwrite={false}
showBinding={props.showBindingControls}
scope={scope}
aliases={aliases}
onChangeValue={(value) =>
props.onChange(props.property, JSON.stringify(value))
}
onChangeExpression={(value) => props.onChange(props.property, value)}
onRemove={(value) =>
props.onChange(props.property, JSON.stringify(value ?? ""))
}
renderControl={({ value, readOnly, onChangeValue }) => (
<InputErrorsTooltip errors={undefined}>
<InputField
css={{ gridArea: "content-input" }}
disabled={props.disabled || readOnly}
color={typeof content !== "string" ? "error" : undefined}
id={contentId}
property="path"
value={value}
onChange={(event) => onChangeValue(event.target.value)}
/>
</InputErrorsTooltip>
)}
<InputErrorsTooltip errors={undefined}>
<InputField
css={{
gridArea: "content-input",
}}
disabled={
props.disabled || isLiteralExpression(props.content) === false
}
color={typeof content !== "string" ? "error" : undefined}
id={contentId}
property="path"
value={content}
onChange={(event) => {
props.onChange(
props.property,
JSON.stringify(event.target.value)
);
}}
/>
</InputErrorsTooltip>
</BindingControl>
/>
<Grid
css={{
gridArea: "button",
@@ -5,8 +5,8 @@ import {
findPageByIdOrPath,
getPageDraftabilityError,
isPageDraft,
isLiteralExpression,
} from "@webstudio-is/sdk";
import { isLiteralExpression } from "@webstudio-is/expression";
import {
theme,
Button,
@@ -16,16 +16,10 @@ import {
rawTheme,
theme,
} from "@webstudio-is/design-system";
import {
documentTypes,
isLiteralExpression,
type Pages,
} from "@webstudio-is/sdk";
import { isLiteralExpression } from "@webstudio-is/expression";
import { documentTypes, type Pages } from "@webstudio-is/sdk";
import { HomeIcon, InfoCircleIcon } from "@webstudio-is/icons";
import {
BindingControl,
BindingPopover,
} from "~/builder/shared/binding-popover";
import { BindableExpressionControl } from "~/builder/shared/bindable-expression";
import { computeExpression } from "@webstudio-is/project-build/runtime";
import { $permissions } from "~/shared/nano-states";
import { $pageRootScope } from "../page-utils";
@@ -123,6 +117,13 @@ const StatusField = ({
}) => {
const id = useId();
const { variableValues, scope, aliases } = useStore($pageRootScope);
const parseStatus = (value: string) => {
if (value === "") {
return;
}
const number = Number(value);
return Number.isNaN(number) || String(number) !== value ? value : number;
};
return (
<Grid gap={1}>
<Flex align="center" gap={1}>
@@ -150,42 +151,39 @@ const StatusField = ({
/>
</Tooltip>
</Flex>
<BindingControl>
{showBindingControls && (
<BindingPopover
scope={scope}
aliases={aliases}
variant={isLiteralExpression(value) ? "default" : "bound"}
value={value}
onChange={onChange}
onRemove={(evaluatedValue) =>
onChange(JSON.stringify(evaluatedValue ?? ""))
}
/>
<BindableExpressionControl
expression={value ?? ""}
value={String(computeExpression(value, variableValues) ?? "")}
bound={value !== undefined && isLiteralExpression(value) === false}
allowBindingOverwrite={false}
showBinding={showBindingControls}
scope={scope}
aliases={aliases}
parseValue={parseStatus}
onChangeValue={(value) => {
const status = parseStatus(value);
if (status === undefined) {
onChange(undefined);
return;
}
onChange(JSON.stringify(status));
}}
onChangeExpression={onChange}
onRemove={(value) => onChange(JSON.stringify(value ?? ""))}
renderControl={({ value, readOnly, onChangeValue }) => (
<InputErrorsTooltip errors={errors}>
<InputField
inputMode="numeric"
color={errors && "error"}
id={id}
placeholder="200"
disabled={disabled || readOnly}
value={value}
onChange={(event) => onChangeValue(event.target.value)}
/>
</InputErrorsTooltip>
)}
<InputErrorsTooltip errors={errors}>
<InputField
inputMode="numeric"
color={errors && "error"}
id={id}
placeholder="200"
disabled={disabled || isLiteralExpression(value) === false}
value={String(computeExpression(value, variableValues) ?? "")}
onChange={(event) => {
if (event.target.value === "") {
onChange(undefined);
} else {
const number = Number(event.target.value);
const status =
Number.isNaN(number) || String(number) !== event.target.value
? event.target.value
: number;
onChange(JSON.stringify(status));
}
}}
/>
</InputErrorsTooltip>
</BindingControl>
/>
</Grid>
);
};
@@ -249,30 +247,30 @@ const RedirectField = ({
</Tooltip>
</Flex>
<BindingControl>
{showBindingControls && (
<BindingPopover
scope={scope}
aliases={aliases}
variant={isLiteralExpression(value) ? "default" : "bound"}
value={value}
onChange={onChange}
onRemove={(evaluatedValue) =>
onChange(JSON.stringify(evaluatedValue ?? ""))
}
/>
<BindableExpressionControl
expression={value ?? ""}
value={computePageSettingsText(value, variableValues)}
bound={value !== undefined && isLiteralExpression(value) === false}
allowBindingOverwrite={false}
showBinding={showBindingControls}
scope={scope}
aliases={aliases}
onChangeValue={(value) => onChange(JSON.stringify(value))}
onChangeExpression={onChange}
onRemove={(value) => onChange(JSON.stringify(value ?? ""))}
renderControl={({ value, readOnly, onChangeValue }) => (
<InputErrorsTooltip errors={errors}>
<InputField
color={errors && "error"}
id={id}
placeholder="/another-path"
disabled={disabled || readOnly}
value={value}
onChange={(event) => onChangeValue(event.target.value)}
/>
</InputErrorsTooltip>
)}
<InputErrorsTooltip errors={errors}>
<InputField
color={errors && "error"}
id={id}
placeholder="/another-path"
disabled={disabled || isLiteralExpression(value) === false}
value={computePageSettingsText(value, variableValues)}
onChange={(event) => onChange(JSON.stringify(event.target.value))}
/>
</InputErrorsTooltip>
</BindingControl>
/>
</Grid>
);
};
@@ -10,11 +10,8 @@ import {
TextArea,
theme,
} from "@webstudio-is/design-system";
import { isLiteralExpression } from "@webstudio-is/sdk";
import {
BindingControl,
BindingPopover,
} from "~/builder/shared/binding-popover";
import { isLiteralExpression } from "@webstudio-is/expression";
import { BindableExpressionControl } from "~/builder/shared/bindable-expression";
import { computeExpression } from "@webstudio-is/project-build/runtime";
import type {
PageSettingsErrors,
@@ -44,30 +41,30 @@ const LanguageField = ({
return (
<Grid gap={1}>
<Label htmlFor={id}>Language</Label>
<BindingControl>
{showBindingControls && (
<BindingPopover
scope={scope}
aliases={aliases}
variant={isLiteralExpression(value) ? "default" : "bound"}
value={value}
onChange={onChange}
onRemove={(evaluatedValue) =>
onChange(JSON.stringify(evaluatedValue ?? ""))
}
/>
<BindableExpressionControl
expression={value}
value={computePageSettingsText(value, variableValues)}
bound={isLiteralExpression(value) === false}
allowBindingOverwrite={false}
showBinding={showBindingControls}
scope={scope}
aliases={aliases}
onChangeValue={(value) => onChange(JSON.stringify(value))}
onChangeExpression={onChange}
onRemove={(value) => onChange(JSON.stringify(value ?? ""))}
renderControl={({ value, readOnly, onChangeValue }) => (
<InputErrorsTooltip errors={errors}>
<InputField
color={errors && "error"}
id={id}
placeholder="en-US"
disabled={disabled || readOnly}
value={value}
onChange={(event) => onChangeValue(event.target.value)}
/>
</InputErrorsTooltip>
)}
<InputErrorsTooltip errors={errors}>
<InputField
color={errors && "error"}
id={id}
placeholder="en-US"
disabled={disabled || isLiteralExpression(value) === false}
value={computePageSettingsText(value, variableValues)}
onChange={(event) => onChange(JSON.stringify(event.target.value))}
/>
</InputErrorsTooltip>
</BindingControl>
/>
</Grid>
);
};
@@ -146,150 +143,120 @@ export const SearchSection = ({
<Grid gap={1}>
<Label htmlFor={titleId}>Title</Label>
<BindingControl>
{showBindingControls && (
<BindingPopover
scope={scope}
aliases={aliases}
variant={isLiteralExpression(values.title) ? "default" : "bound"}
value={values.title}
onChange={(value) => {
onChange({
field: "title",
value,
});
}}
onRemove={(evaluatedValue) => {
onChange({
field: "title",
value: JSON.stringify(evaluatedValue ?? ""),
});
}}
/>
<BindableExpressionControl
expression={values.title}
value={title}
bound={isLiteralExpression(values.title) === false}
allowBindingOverwrite={false}
showBinding={showBindingControls}
scope={scope}
aliases={aliases}
onChangeValue={(value) =>
onChange({ field: "title", value: JSON.stringify(value) })
}
onChangeExpression={(value) => onChange({ field: "title", value })}
onRemove={(value) =>
onChange({ field: "title", value: JSON.stringify(value ?? "") })
}
renderControl={({ value, readOnly, onChangeValue }) => (
<InputErrorsTooltip errors={errors.title}>
<InputField
color={errors.title && "error"}
id={titleId}
name="title"
placeholder="My awesome project - About"
disabled={canEditTitle === false || readOnly}
value={value}
onChange={(event) => onChangeValue(event.target.value)}
/>
</InputErrorsTooltip>
)}
<InputErrorsTooltip errors={errors.title}>
<InputField
color={errors.title && "error"}
id={titleId}
name="title"
placeholder="My awesome project - About"
disabled={
canEditTitle === false ||
isLiteralExpression(values.title) === false
}
value={title}
onChange={(event) => {
onChange({
field: "title",
value: JSON.stringify(event.target.value),
});
}}
/>
</InputErrorsTooltip>
</BindingControl>
/>
</Grid>
<Grid gap={1}>
<Label htmlFor={descriptionId}>Description</Label>
<BindingControl>
{showBindingControls && (
<BindingPopover
scope={scope}
aliases={aliases}
variant={
isLiteralExpression(values.description) ? "default" : "bound"
}
value={values.description}
onChange={(value) => {
onChange({
field: "description",
value,
});
}}
onRemove={(evaluatedValue) => {
onChange({
field: "description",
value: JSON.stringify(evaluatedValue ?? ""),
});
}}
/>
)}
<InputErrorsTooltip errors={errors.description}>
<TextArea
color={errors.description ? "error" : undefined}
id={descriptionId}
name="description"
disabled={
canEditDescription === false ||
isLiteralExpression(values.description) === false
}
value={description}
onChange={(value) => {
onChange({
field: "description",
value: JSON.stringify(value),
});
}}
autoGrow
maxRows={10}
/>
</InputErrorsTooltip>
</BindingControl>
<BindingControl>
<Grid
flow={"column"}
gap={1}
justify={"start"}
align={"center"}
css={{ py: theme.spacing[2] }}
>
{showBindingControls && (
<BindingPopover
scope={scope}
aliases={aliases}
variant={
isLiteralExpression(values.excludePageFromSearch)
? "default"
: "bound"
}
value={values.excludePageFromSearch}
onChange={(value) => {
onChange({
field: "excludePageFromSearch",
value,
});
}}
onRemove={(evaluatedValue) => {
onChange({
field: "excludePageFromSearch",
value: JSON.stringify(evaluatedValue ?? ""),
});
}}
<BindableExpressionControl
expression={values.description}
value={description}
bound={isLiteralExpression(values.description) === false}
allowBindingOverwrite={false}
showBinding={showBindingControls}
scope={scope}
aliases={aliases}
onChangeValue={(value) =>
onChange({ field: "description", value: JSON.stringify(value) })
}
onChangeExpression={(value) =>
onChange({ field: "description", value })
}
onRemove={(value) =>
onChange({
field: "description",
value: JSON.stringify(value ?? ""),
})
}
renderControl={({ value, readOnly, onChangeValue }) => (
<InputErrorsTooltip errors={errors.description}>
<TextArea
color={errors.description ? "error" : undefined}
id={descriptionId}
name="description"
disabled={canEditDescription === false || readOnly}
value={value}
onChange={onChangeValue}
autoGrow
maxRows={10}
/>
)}
<Checkbox
id={excludePageFromSearchId}
disabled={
canEditExcludePageFromSearch === false ||
isLiteralExpression(values.excludePageFromSearch) === false
}
checked={excludePageFromSearch}
onCheckedChange={() => {
const newValue = !excludePageFromSearch;
onChange({
field: "excludePageFromSearch",
value: newValue.toString(),
});
}}
/>
<InputErrorsTooltip errors={errors.excludePageFromSearch}>
<Label htmlFor={excludePageFromSearchId}>
Exclude this page from search results
</Label>
</InputErrorsTooltip>
</Grid>
</BindingControl>
)}
/>
<BindableExpressionControl
expression={values.excludePageFromSearch}
value={excludePageFromSearch}
bound={isLiteralExpression(values.excludePageFromSearch) === false}
allowBindingOverwrite={false}
showBinding={showBindingControls}
scope={scope}
aliases={aliases}
onChangeValue={(value) =>
onChange({
field: "excludePageFromSearch",
value: value.toString(),
})
}
onChangeExpression={(value) =>
onChange({ field: "excludePageFromSearch", value })
}
onRemove={(value) =>
onChange({
field: "excludePageFromSearch",
value: JSON.stringify(value ?? ""),
})
}
renderControl={({ value, readOnly, onChangeValue }) => (
<Grid
flow={"column"}
gap={1}
justify={"start"}
align={"center"}
css={{ py: theme.spacing[2] }}
>
<Checkbox
id={excludePageFromSearchId}
disabled={canEditExcludePageFromSearch === false || readOnly}
checked={value}
onCheckedChange={() => onChangeValue(!value)}
/>
<InputErrorsTooltip errors={errors.excludePageFromSearch}>
<Label htmlFor={excludePageFromSearchId}>
Exclude this page from search results
</Label>
</InputErrorsTooltip>
</Grid>
)}
/>
</Grid>
<LanguageField
@@ -7,17 +7,14 @@ import {
InputField,
Text,
} from "@webstudio-is/design-system";
import {
BindingControl,
BindingPopover,
} from "~/builder/shared/binding-popover";
import { BindableExpressionControl } from "~/builder/shared/bindable-expression";
import type {
PageSettingsErrors,
PageSettingsValues,
} from "@webstudio-is/project-build/runtime";
import { ImageControl } from "~/shared/project-settings";
import { $assets } from "~/shared/sync/data-stores";
import { isLiteralExpression } from "@webstudio-is/sdk";
import { isLiteralExpression } from "@webstudio-is/expression";
import { $pageRootScope } from "../page-utils";
import { ImageInfo } from "../image-info";
import { SocialPreview } from "../social-preview";
@@ -58,47 +55,39 @@ export const SocialImageSection = ({
settings will be used. The optimal dimensions for the image are 1200x630
px or larger with a 1.91:1 aspect ratio.
</Text>
<BindingControl>
{showBindingControls && (
<BindingPopover
scope={scope}
aliases={aliases}
variant={
isLiteralExpression(values.socialImageUrl) ? "default" : "bound"
}
value={values.socialImageUrl}
onChange={(value) => {
onChange({
field: "socialImageUrl",
value,
});
}}
onRemove={(evaluatedValue) => {
onChange({
field: "socialImageUrl",
value: JSON.stringify(evaluatedValue ?? ""),
});
}}
/>
<BindableExpressionControl
expression={values.socialImageUrl}
value={socialImageUrl}
bound={isLiteralExpression(values.socialImageUrl) === false}
allowBindingOverwrite={false}
showBinding={showBindingControls}
scope={scope}
aliases={aliases}
onChangeValue={(value) => {
onChange({ field: "socialImageUrl", value: JSON.stringify(value) });
onChange({ field: "socialImageAssetId", value: "" });
}}
onChangeExpression={(value) =>
onChange({ field: "socialImageUrl", value })
}
onRemove={(value) =>
onChange({
field: "socialImageUrl",
value: JSON.stringify(value ?? ""),
})
}
renderControl={({ value, readOnly, onChangeValue }) => (
<InputErrorsTooltip errors={errors.socialImageUrl}>
<InputField
placeholder="https://www.url.com"
disabled={disabled || readOnly}
color={errors.socialImageUrl && "error"}
value={value}
onChange={(event) => onChangeValue(event.target.value)}
/>
</InputErrorsTooltip>
)}
<InputErrorsTooltip errors={errors.socialImageUrl}>
<InputField
placeholder="https://www.url.com"
disabled={
disabled || isLiteralExpression(values.socialImageUrl) === false
}
color={errors.socialImageUrl && "error"}
value={socialImageUrl}
onChange={(event) => {
onChange({
field: "socialImageUrl",
value: JSON.stringify(event.target.value),
});
onChange({ field: "socialImageAssetId", value: "" });
}}
/>
</InputErrorsTooltip>
</BindingControl>
/>
<Grid gap={1} flow={"column"}>
<ImageControl
onAssetIdChange={(socialImageAssetId) => {
@@ -1,11 +1,8 @@
import { useId } from "react";
import { useStore } from "@nanostores/react";
import { Grid, Label, Text, TextArea } from "@webstudio-is/design-system";
import { isLiteralExpression } from "@webstudio-is/sdk";
import {
BindingControl,
BindingPopover,
} from "~/builder/shared/binding-popover";
import { isLiteralExpression } from "@webstudio-is/expression";
import { BindableExpressionControl } from "~/builder/shared/bindable-expression";
import { computeExpression } from "@webstudio-is/project-build/runtime";
import type {
PageSettingsErrors,
@@ -31,39 +28,37 @@ export const TextContentSection = ({
<Text color="subtle">The plain text content served for this page.</Text>
<Grid gap={1}>
<Label htmlFor={contentId}>Text</Label>
<BindingControl>
<BindingPopover
scope={scope}
aliases={aliases}
variant={isLiteralExpression(values.content) ? "default" : "bound"}
value={values.content}
onChange={(value) => {
onChange({ field: "content", value });
}}
onRemove={(evaluatedValue) => {
onChange({
field: "content",
value: JSON.stringify(evaluatedValue ?? ""),
});
}}
/>
<TextArea
id={contentId}
color={errors.content ? "error" : undefined}
disabled={isLiteralExpression(values.content) === false}
value={String(
computeExpression(values.content, variableValues) ?? ""
)}
onChange={(value) => {
onChange({
field: "content",
value: JSON.stringify(value),
});
}}
autoGrow
maxRows={20}
/>
</BindingControl>
<BindableExpressionControl
expression={values.content}
value={String(
computeExpression(values.content, variableValues) ?? ""
)}
bound={isLiteralExpression(values.content) === false}
allowBindingOverwrite={false}
scope={scope}
aliases={aliases}
onChangeValue={(value) =>
onChange({ field: "content", value: JSON.stringify(value) })
}
onChangeExpression={(value) => onChange({ field: "content", value })}
onRemove={(value) =>
onChange({
field: "content",
value: JSON.stringify(value ?? ""),
})
}
renderControl={({ value, readOnly, onChangeValue }) => (
<TextArea
id={contentId}
color={errors.content ? "error" : undefined}
disabled={readOnly}
value={value}
onChange={onChangeValue}
autoGrow
maxRows={20}
/>
)}
/>
</Grid>
</Grid>
);
@@ -1,11 +1,7 @@
import { useState, type JSX } from "react";
import { useStore } from "@nanostores/react";
import {
type PageTemplate,
type Page,
type Pages,
isLiteralExpression,
} from "@webstudio-is/sdk";
import { type PageTemplate, type Page, type Pages } from "@webstudio-is/sdk";
import { isLiteralExpression } from "@webstudio-is/expression";
import {
Button,
DialogClose,
@@ -0,0 +1,67 @@
import { describe, expect, test } from "vitest";
import { getContentDatabasePublishWarning } from "./content-database-publish-warning";
describe("content database publish warning", () => {
test("reports bounded files and dynamic resources", () => {
expect(
getContentDatabasePublishWarning({
stats: {
usedBytes: 500 * 1024,
maxBytes: 500 * 1024,
includedDocumentCount: 80,
omittedDocumentCount: 20,
truncated: true,
},
potentiallyAffectedResources: [
{ id: "overview", name: "Blog overview" },
{ id: "detail", name: "Blog article" },
],
hasDynamicValues: true,
})
).toEqual({
includedDocumentCount: 80,
totalDocumentCount: 100,
omittedDocumentCount: 20,
usedKiB: 500,
maxKiB: 500,
affectedResourceNames: ["Blog overview", "Blog article"],
affectedResourceKind: "dynamic",
});
});
test("does not warn for a complete database", () => {
expect(
getContentDatabasePublishWarning({
stats: {
usedBytes: 100,
maxBytes: 500 * 1024,
includedDocumentCount: 5,
omittedDocumentCount: 0,
truncated: false,
},
potentiallyAffectedResources: [],
hasDynamicValues: false,
})
).toBeUndefined();
});
test("identifies statically affected resources", () => {
expect(
getContentDatabasePublishWarning({
stats: {
usedBytes: 1025,
maxBytes: 2048,
includedDocumentCount: 1,
omittedDocumentCount: 1,
truncated: true,
},
potentiallyAffectedResources: [{ id: "overview", name: "Overview" }],
hasDynamicValues: false,
})
).toMatchObject({
usedKiB: 2,
affectedResourceNames: ["Overview"],
affectedResourceKind: "static",
});
});
});
@@ -0,0 +1,48 @@
type ContentDatabasePublishDiagnostics = {
stats: {
usedBytes: number;
maxBytes: number;
includedDocumentCount: number;
omittedDocumentCount: number;
truncated: boolean;
};
potentiallyAffectedResources: { id: string; name: string }[];
hasDynamicValues: boolean;
};
export type ContentDatabasePublishWarning = {
includedDocumentCount: number;
totalDocumentCount: number;
omittedDocumentCount: number;
usedKiB: number;
maxKiB: number;
affectedResourceNames: string[];
affectedResourceKind: "dynamic" | "static" | undefined;
};
export const getContentDatabasePublishWarning = (
diagnostics: ContentDatabasePublishDiagnostics | undefined
) => {
if (diagnostics?.stats.truncated !== true) {
return;
}
const { stats } = diagnostics;
const affectedResourceNames = diagnostics.potentiallyAffectedResources.map(
({ name }) => name
);
return {
includedDocumentCount: stats.includedDocumentCount,
totalDocumentCount:
stats.includedDocumentCount + stats.omittedDocumentCount,
omittedDocumentCount: stats.omittedDocumentCount,
usedKiB: Math.ceil(stats.usedBytes / 1024),
maxKiB: Math.ceil(stats.maxBytes / 1024),
affectedResourceNames,
affectedResourceKind:
affectedResourceNames.length === 0
? undefined
: diagnostics.hasDynamicValues
? "dynamic"
: "static",
} satisfies ContentDatabasePublishWarning;
};
@@ -75,7 +75,10 @@ import {
import { AddDomain } from "./add-domain";
import { humanizeString } from "~/shared/string-utils";
import { trpcClient, nativeClient } from "~/shared/trpc/trpc-client";
import { type Templates } from "@webstudio-is/sdk";
import {
createReachableAssetContentCompilationPlan,
type Templates,
} from "@webstudio-is/sdk";
import { DomainCheckbox, domainToPublishName } from "./domain-checkbox";
import { CopyToClipboard } from "~/shared/copy-to-clipboard";
import { $openProjectSettings } from "~/shared/nano-states/project-settings";
@@ -102,6 +105,10 @@ import {
runPrePublishAudit,
type PrePublishAuditFinding,
} from "@webstudio-is/project-build/runtime";
import {
getContentDatabasePublishWarning,
type ContentDatabasePublishWarning,
} from "./content-database-publish-warning";
const PrePublishAuditMessage = ({
finding,
@@ -183,6 +190,64 @@ const getPrePublishAuditMessages = () => {
};
};
const loadContentDatabasePublishWarning = async (projectId: string) => {
const plan = createReachableAssetContentCompilationPlan({
props: [...$props.get().values()],
dataSources: [...$dataSources.get().values()],
resources: [...$resources.get().values()],
});
if (plan === undefined) {
return;
}
return getContentDatabasePublishWarning(
await nativeClient.build.contentDatabasePublishDiagnostics.query({
projectId,
})
);
};
const ContentDatabasePublishWarningMessage = ({
warning,
}: {
warning: ContentDatabasePublishWarning;
}) => (
<>
The published content database will include {warning.includedDocumentCount}{" "}
of {warning.totalDocumentCount} files ({warning.usedKiB} of {warning.maxKiB}{" "}
KiB). {warning.omittedDocumentCount} files will be omitted.
{warning.affectedResourceKind === "dynamic" && (
<>
{" "}
Queries with route or variable values may be incomplete in:{" "}
{warning.affectedResourceNames.join(", ")}.
</>
)}
{warning.affectedResourceKind === "static" && (
<>
{" "}
Potentially affected Assets resources:{" "}
{warning.affectedResourceNames.join(", ")}.
</>
)}
</>
);
const showContentDatabasePublishWarning = async ({
projectId,
setWarning,
}: {
projectId: Project["id"];
setWarning: (warning: JSX.Element) => void;
}) => {
const warning = await loadContentDatabasePublishWarning(projectId);
if (warning === undefined) {
return;
}
const message = <ContentDatabasePublishWarningMessage warning={warning} />;
toast.warn(message);
setWarning(message);
};
type ChangeProjectDomainProps = {
project: Project;
projectState: "idle" | "submitting";
@@ -412,12 +477,20 @@ const ChangeProjectDomain = ({
};
const $restrictedFeatures = computed(
[$pages, $projectSettings, $dataSources, $instances, $permissions],
(pages, projectSettings, dataSources, instances, permissions) =>
[
$pages,
$projectSettings,
$dataSources,
$resources,
$instances,
$permissions,
],
(pages, projectSettings, dataSources, resources, instances, permissions) =>
getRestrictedFeatures({
pages,
projectSettings,
dataSources,
resources,
instances,
permissions,
})
@@ -645,6 +718,20 @@ const Publish = ({
}
startTransition(async () => {
try {
await showContentDatabasePublishWarning({
projectId: project.id,
setWarning: setPublishWarning,
});
} catch (error) {
const message =
error instanceof Error
? error.message
: "Content database validation failed";
toast.error(message);
setPublishError(message);
return;
}
await publish(domains);
});
};
@@ -795,6 +882,11 @@ const PublishStatic = ({
try {
setIsPendingOptimistic(true);
await showContentDatabasePublishWarning({
projectId,
setWarning: setPublishWarning,
});
const result = await nativeClient.domain.publish.mutate({
projectId,
destination: "static",
@@ -6,6 +6,7 @@ import {
type Instance,
type Page,
type Pages,
type Resource,
} from "@webstudio-is/sdk";
import {
getRestrictedFeatures,
@@ -68,18 +69,21 @@ const getFeatures = ({
projectSettings,
permissions,
dataSources = new Map<string, DataSource>(),
resources = new Map<string, Resource>(),
instances = new Map<string, Instance>(),
}: {
pages: Pages | undefined;
projectSettings?: ProjectSettings;
permissions: Partial<RestrictedFeaturesPermissions>;
dataSources?: Map<string, DataSource>;
resources?: Map<string, Resource>;
instances?: Map<string, Instance>;
}) =>
getRestrictedFeatures({
pages,
projectSettings,
dataSources,
resources,
instances,
permissions: { ...defaultPermissions, ...permissions },
});
@@ -219,4 +223,64 @@ describe("getRestrictedFeatures", () => {
"Resource variable",
]);
});
test("identifies an Assets resource as a restricted custom-domain feature", () => {
const resources = new Map<string, Resource>([
[
"assets-resource",
{
id: "assets-resource",
name: "Assets",
control: "system",
method: "post",
url: '"/$resources/assets"',
searchParams: [],
headers: [],
body: "({ query: {} })",
},
],
]);
const dataSources = new Map<string, DataSource>([
[
"assets-variable",
{
type: "resource",
id: "assets-variable",
resourceId: "assets-resource",
scopeInstanceId: homePage.rootInstanceId,
name: "assets",
},
],
]);
const instances = new Map<string, Instance>([
[
homePage.rootInstanceId,
{
type: "instance",
id: homePage.rootInstanceId,
component: "ws:element",
children: [],
},
],
]);
const features = getFeatures({
pages: createPages(),
permissions: { allowDynamicData: false },
dataSources,
resources,
instances,
});
expect([...features.keys()]).toEqual(["Assets resource"]);
expect(
getFeatures({
pages: createPages(),
permissions: { allowDynamicData: true },
dataSources,
resources,
instances,
}).size
).toBe(0);
});
});
@@ -1,10 +1,12 @@
import type { ReactNode } from "react";
import {
getPublishablePages,
isAssetsResource,
isPathnamePattern,
type DataSource,
type Instance,
type Pages,
type Resources,
} from "@webstudio-is/sdk";
import { findPageAndSelectorByInstanceId } from "@webstudio-is/project-build/runtime";
import type { ProjectSettings } from "@webstudio-is/project-build";
@@ -27,12 +29,14 @@ export const getRestrictedFeatures = ({
pages,
projectSettings,
dataSources,
resources,
instances,
permissions,
}: {
pages: Pages | undefined;
projectSettings?: ProjectSettings;
dataSources: Map<string, DataSource>;
resources: Resources;
instances: Map<string, Instance>;
permissions: RestrictedFeaturesPermissions;
}) => {
@@ -89,9 +93,15 @@ export const getRestrictedFeatures = ({
if (publishablePageIds.has(navigate.pageId) === false) {
continue;
}
features.set("Resource variable", {
navigate,
});
const resource = resources.get(dataSource.resourceId);
features.set(
resource !== undefined && isAssetsResource(resource)
? "Assets resource"
: "Resource variable",
{
navigate,
}
);
}
}
}
@@ -0,0 +1,77 @@
import { createRoot, type Root } from "react-dom/client";
import { act } from "react-dom/test-utils";
import { afterEach, expect, test, vi } from "vitest";
import {
createAssetQueryJsonSchema,
createAssetResourceOpenApi,
} from "@webstudio-is/protocol/asset-resource-api";
import { AssetQueryForm, __testing__ } from "./asset-query-form";
const { loadAssetQueryDefinition } = __testing__;
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
let root: Root | undefined;
afterEach(() => {
act(() => root?.unmount());
root = undefined;
document.body.innerHTML = "";
});
test("renders a centered message while the OpenAPI description is loading", () => {
const container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
<AssetQueryForm
scope={{}}
aliases={new Map()}
fetchDescription={() => new Promise(() => {})}
/>
);
});
expect(container.textContent).toContain("Loading query editor…");
});
test("loads the external query schema declared by OpenAPI", async () => {
const openApi = createAssetResourceOpenApi({
builderSessionCookieName: "session",
querySchemaReference: "/rest/assets/query-schema.json",
});
const querySchema = createAssetQueryJsonSchema({
catalog: {
format: "webstudio-builder-asset-field-catalog",
version: 1,
canonicalRevision: `sha256:${"a".repeat(64)}`,
documentCount: 1,
fields: {
title: {
queryPath: ["properties", "title"],
types: ["string"],
occurrences: 1,
},
},
},
});
const fetchDescription = vi.fn(async (input: RequestInfo | URL) =>
Response.json(
String(input).includes("query-schema.json") ? querySchema : openApi
)
);
const definition = await loadAssetQueryDefinition(fetchDescription);
expect(fetchDescription).toHaveBeenCalledTimes(2);
expect(fetchDescription.mock.calls[1][0]).toBe(
"http://localhost:3000/rest/assets/query-schema.json"
);
expect(definition.fields).toContainEqual(
expect.objectContaining({ path: ["properties", "title"] })
);
});
@@ -0,0 +1,210 @@
import { useEffect, useMemo, useState } from "react";
import { useStore } from "@nanostores/react";
import {
createDefaultStructuredAssetQueryResourceConfiguration,
createStructuredAssetQueryResourceBody,
parseStructuredAssetQueryResourceBody,
type Resource,
type StructuredAssetQueryFilterBinding,
type StructuredAssetQueryResourceConfiguration,
} from "@webstudio-is/sdk";
import { assetsOpenApiUrl } from "@webstudio-is/sdk/runtime";
import {
addConfiguredQueryFields,
createQuerySourceCodec,
loadOpenApiQueryDefinition,
type QueryDefinition,
} from "@webstudio-is/query-builder";
import {
getQueryConditions,
getQueryFieldKey,
} from "@webstudio-is/query-builder/runtime";
import { Text, theme } from "@webstudio-is/design-system";
import { $assets } from "~/shared/sync/data-stores";
import { BindableQueryBuilder } from "~/builder/shared/query-builder";
import { fetch as builderFetch } from "~/shared/fetch.client";
import { CenteredPanelMessage, Row } from "./shared";
type AssetQueryDefinition = QueryDefinition<
string,
StructuredAssetQueryFilterBinding["operator"]
>;
const defaultConfiguration: StructuredAssetQueryResourceConfiguration =
createDefaultStructuredAssetQueryResourceConfiguration();
const normalizeConfiguration = (
value: StructuredAssetQueryResourceConfiguration
): StructuredAssetQueryResourceConfiguration => ({
...value,
where: "field" in value.where ? { all: [value.where] } : value.where,
});
const loadAssetQueryDefinition = async (
fetchDescription: typeof globalThis.fetch
) => {
const response = await fetchDescription(assetsOpenApiUrl);
if (response.ok === false) {
throw new Error("Builder Assets OpenAPI request failed");
}
const descriptionUrl =
response.url || new URL(assetsOpenApiUrl, window.location.href).href;
const descriptionOrigin = new URL(descriptionUrl).origin;
const definition = await loadOpenApiQueryDefinition({
document: await response.json(),
documentUrl: descriptionUrl,
operationId: "queryAssets",
loadReference: async (url) => {
if (new URL(url).origin !== descriptionOrigin) {
throw new Error("Cross-origin OpenAPI references are unsupported");
}
const reference = await fetchDescription(url);
if (reference.ok === false) {
throw new Error("Builder Assets query schema request failed");
}
return await reference.json();
},
});
return definition as AssetQueryDefinition;
};
export const AssetQueryForm = ({
resource,
scope,
aliases,
sourceContainer,
fetchDescription = builderFetch,
}: {
resource?: Resource;
scope: Record<string, unknown>;
aliases: Map<string, string>;
sourceContainer?: Element | null;
fetchDescription?: typeof globalThis.fetch;
}) => {
const assets = useStore($assets);
const initial = useMemo(
() =>
parseStructuredAssetQueryResourceBody(resource?.body) ??
defaultConfiguration,
[resource?.body]
);
const [configuration, setConfiguration] =
useState<StructuredAssetQueryResourceConfiguration>(() =>
normalizeConfiguration(initial)
);
const [baseDefinition, setBaseDefinition] = useState<AssetQueryDefinition>();
const [descriptionError, setDescriptionError] = useState<string>();
const configuredPaths = useMemo(
() =>
[
...getQueryConditions<StructuredAssetQueryFilterBinding>(
configuration.where
).map(({ field }) => field),
...configuration.sort.map(({ field }) => field),
...(configuration.output.mode === "fields"
? configuration.output.fields
: []),
].sort((left, right) =>
getQueryFieldKey(left).localeCompare(getQueryFieldKey(right))
),
[configuration]
);
const definition = useMemo(
() =>
baseDefinition === undefined
? undefined
: addConfiguredQueryFields({
definition: baseDefinition,
paths: configuredPaths,
fallbackType: "string",
}),
[baseDefinition, configuredPaths]
);
const configurationError = useMemo(() => {
if (definition === undefined) {
return;
}
try {
createQuerySourceCodec<
string,
StructuredAssetQueryFilterBinding["operator"],
StructuredAssetQueryResourceConfiguration
>(definition).format(configuration);
} catch {
return "Complete every Assets query field.";
}
}, [configuration, definition]);
useEffect(() => {
setConfiguration(normalizeConfiguration(initial));
}, [initial, resource?.id]);
const body =
configurationError === undefined
? createStructuredAssetQueryResourceBody(configuration)
: (resource?.body ?? "");
useEffect(() => {
let ignore = false;
loadAssetQueryDefinition(fetchDescription)
.then((definition) => {
if (ignore) {
return;
}
setBaseDefinition(definition);
setDescriptionError(undefined);
})
.catch(() => {
if (ignore === false) {
setDescriptionError("Unable to load the Assets API description.");
}
});
return () => {
ignore = true;
};
}, [assets, fetchDescription]);
return (
<>
<input
type="hidden"
name="asset-query-valid"
value={configurationError === undefined ? "true" : "false"}
/>
<input type="hidden" name="header-name" value="Content-Type" />
<input type="hidden" name="header-value" value='"application/json"' />
<input type="hidden" name="body" value={body} />
{descriptionError !== undefined ? (
<CenteredPanelMessage color="destructive">
{descriptionError}
</CenteredPanelMessage>
) : definition !== undefined ? (
<BindableQueryBuilder<
string,
StructuredAssetQueryFilterBinding["operator"],
StructuredAssetQueryResourceConfiguration
>
key={resource?.id}
value={configuration}
capabilities={definition}
scope={scope}
aliases={aliases}
sourceContainer={sourceContainer}
sectionPaddingInline={theme.panel.paddingInline}
onChange={(value) => {
setConfiguration(value);
}}
/>
) : (
<CenteredPanelMessage>Loading query editor…</CenteredPanelMessage>
)}
{descriptionError === undefined && configurationError !== undefined && (
<Row>
<Text color="destructive">{configurationError}</Text>
</Row>
)}
</>
);
};
export const __testing__ = { loadAssetQueryDefinition };
@@ -0,0 +1,16 @@
import { describe, expect, test } from "vitest";
import { isDatabaseSizeNearLimit } from "./content-database-diagnostics";
describe("content database diagnostics", () => {
test("warns when the database reaches 90% of its limit", () => {
expect(isDatabaseSizeNearLimit({ usedBytes: 899, maxBytes: 1_000 })).toBe(
false
);
expect(isDatabaseSizeNearLimit({ usedBytes: 900, maxBytes: 1_000 })).toBe(
true
);
expect(isDatabaseSizeNearLimit({ usedBytes: 1_000, maxBytes: 1_000 })).toBe(
true
);
});
});
@@ -0,0 +1,70 @@
import type { AssetQueryPreviewDiagnostics } from "@webstudio-is/content-engine";
import { PanelBanner, Text } from "@webstudio-is/design-system";
import prettyBytes from "pretty-bytes";
import {
RequestDiagnosticsContent,
RequestDiagnosticsRow,
RequestDiagnosticsTable,
} from "./request-inspector";
const databaseSizeWarningRatio = 0.9;
export const isDatabaseSizeNearLimit = ({
usedBytes,
maxBytes,
}: Pick<AssetQueryPreviewDiagnostics, "usedBytes" | "maxBytes">) =>
usedBytes >= maxBytes * databaseSizeWarningRatio;
export const ContentDatabaseDiagnostics = ({
value,
}: {
value: AssetQueryPreviewDiagnostics;
}) => {
const totalDocumentCount =
value.includedDocumentCount + value.omittedDocumentCount;
const candidateFilesLabel = `${totalDocumentCount} candidate ${totalDocumentCount === 1 ? "file" : "files"}`;
const omittedFilesLabel = `${value.omittedDocumentCount} ${value.omittedDocumentCount === 1 ? "file" : "files"}`;
const isNearLimit = isDatabaseSizeNearLimit(value);
return (
<RequestDiagnosticsContent>
<PanelBanner variant={value.truncated ? "warning" : "success"}>
<Text>
{value.truncated
? `${value.includedDocumentCount} of ${candidateFilesLabel} fit in the content database. ${omittedFilesLabel} may be omitted from published query results.`
: totalDocumentCount === 1
? "The candidate file fits in the content database."
: `All ${candidateFilesLabel} fit in the content database.`}
</Text>
</PanelBanner>
<RequestDiagnosticsTable>
<RequestDiagnosticsRow label="Scope" value="Query preview" />
<RequestDiagnosticsRow
label="Status"
value={value.truncated ? "Truncated" : "Complete"}
/>
<RequestDiagnosticsRow
label="Database size"
value={prettyBytes(value.usedBytes)}
valueColor={isNearLimit ? "destructive" : undefined}
description="To reduce the database size, select only the output fields your pages use, narrow filters to fewer files, and avoid full file content when a byte range is enough."
/>
<RequestDiagnosticsRow
label="Database limit"
value={prettyBytes(value.maxBytes)}
/>
<RequestDiagnosticsRow
label="Estimated full size"
value={prettyBytes(value.unboundedBytes)}
/>
<RequestDiagnosticsRow
label="Included files"
value={value.includedDocumentCount}
/>
<RequestDiagnosticsRow
label="Omitted files"
value={value.omittedDocumentCount}
/>
</RequestDiagnosticsTable>
</RequestDiagnosticsContent>
);
};
@@ -1,18 +1,9 @@
import { useStore } from "@nanostores/react";
import { Grid, Switch, theme } from "@webstudio-is/design-system";
import {
BindingControl,
BindingPopover,
} from "~/builder/shared/binding-popover";
import { BindableExpressionControl } from "~/builder/shared/bindable-expression";
import { validatePrimitiveValue } from "@webstudio-is/project-build/runtime";
import {
type ControlProps,
$selectedInstanceScope,
updateExpressionValue,
useBindingState,
humanizeAttribute,
} from "../shared";
import { type ControlProps, humanizeAttribute } from "../shared";
import { PropertyLabel } from "../property-label";
import { useBindableControl } from "./use-bindable-control";
export const BooleanControl = ({
meta,
@@ -22,12 +13,10 @@ export const BooleanControl = ({
onChange,
}: ControlProps<"boolean">) => {
const label = humanizeAttribute(meta.label || propName);
const { scope, aliases } = useStore($selectedInstanceScope);
const expression =
prop?.type === "expression" ? prop.value : JSON.stringify(computedValue);
const { overwritable, variant } = useBindingState(
prop?.type === "expression" ? prop.value : undefined
);
const binding = useBindableControl({
boundExpression: prop?.type === "expression" ? prop.value : undefined,
fallbackExpression: JSON.stringify(computedValue),
});
return (
<Grid
@@ -39,33 +28,27 @@ export const BooleanControl = ({
align="center"
gap="2"
>
<PropertyLabel name={propName} readOnly={overwritable === false} />
<BindingControl>
<Switch
disabled={overwritable === false}
checked={Boolean(computedValue ?? false)}
onCheckedChange={(value) => {
if (prop?.type === "expression") {
updateExpressionValue(prop.value, value);
} else {
onChange({ type: "boolean", value });
}
}}
/>
<BindingPopover
scope={scope}
aliases={aliases}
validate={(value) => validatePrimitiveValue(value, label)}
variant={variant}
value={expression}
onChange={(newExpression) =>
onChange({ type: "expression", value: newExpression })
}
onRemove={(evaluatedValue) =>
onChange({ type: "boolean", value: Boolean(evaluatedValue) })
}
/>
</BindingControl>
<PropertyLabel
name={propName}
readOnly={binding.bindingState.overwritable === false}
/>
<BindableExpressionControl
{...binding}
value={Boolean(computedValue ?? false)}
validate={(value) => validatePrimitiveValue(value, label)}
onChangeValue={(value) => onChange({ type: "boolean", value })}
onChangeExpression={(value) => onChange({ type: "expression", value })}
onRemove={(value) =>
onChange({ type: "boolean", value: Boolean(value) })
}
renderControl={({ value, readOnly, onChangeValue }) => (
<Switch
disabled={readOnly}
checked={value}
onCheckedChange={onChangeValue}
/>
)}
/>
</Grid>
);
};
@@ -1,20 +1,14 @@
import { useId } from "react";
import { useStore } from "@nanostores/react";
import { Checkbox, CheckboxAndLabel } from "@webstudio-is/design-system";
import {
BindingControl,
BindingPopover,
} from "~/builder/shared/binding-popover";
import { BindableExpressionControl } from "~/builder/shared/bindable-expression";
import {
type ControlProps,
VerticalLayout,
Label,
$selectedInstanceScope,
updateExpressionValue,
useBindingState,
humanizeAttribute,
} from "../shared";
import { PropertyLabel } from "../property-label";
import { useBindableControl } from "./use-bindable-control";
const add = (array: string[], item: string) => {
if (array.includes(item)) {
@@ -46,66 +40,61 @@ export const CheckControl = ({
const id = useId();
const label = humanizeAttribute(meta.label || propName);
const { scope, aliases } = useStore($selectedInstanceScope);
const expression =
prop?.type === "expression" ? prop.value : JSON.stringify(computedValue);
const { overwritable, variant } = useBindingState(
prop?.type === "expression" ? prop.value : undefined
);
const binding = useBindableControl({
boundExpression: prop?.type === "expression" ? prop.value : undefined,
fallbackExpression: JSON.stringify(computedValue),
});
return (
<VerticalLayout
label={
<PropertyLabel name={propName} readOnly={overwritable === false} />
<PropertyLabel
name={propName}
readOnly={binding.bindingState.overwritable === false}
/>
}
>
<BindingControl>
{options.map((option) => (
<CheckboxAndLabel key={option}>
<Checkbox
disabled={overwritable === false}
checked={value.includes(option)}
onCheckedChange={(checked) => {
const newValue = checked
? add(value, option)
: remove(value, option);
if (prop?.type === "expression") {
updateExpressionValue(prop.value, newValue);
} else {
onChange({ type: "string[]", value: newValue });
}
}}
id={`${id}:${option}`}
/>
<Label htmlFor={`${id}:${option}`}>{option}</Label>
</CheckboxAndLabel>
))}
<BindingPopover
scope={scope}
aliases={aliases}
validate={(value) => {
const valid =
Array.isArray(value) &&
value.every((item) => typeof item === "string");
if (value !== undefined && valid === false) {
return `${label} expects an array of strings`;
}
}}
variant={variant}
value={expression}
onChange={(newExpression) =>
onChange({ type: "expression", value: newExpression })
<BindableExpressionControl
{...binding}
value={value}
onChangeValue={(value) => onChange({ type: "string[]", value })}
onChangeExpression={(value) => onChange({ type: "expression", value })}
validate={(value) => {
const valid =
Array.isArray(value) &&
value.every((item) => typeof item === "string");
if (value !== undefined && valid === false) {
return `${label} expects an array of strings`;
}
onRemove={(evaluatedValue) =>
onChange({
type: "string[]",
value: Array.isArray(evaluatedValue)
? evaluatedValue.map((item) => String(item))
: [],
})
}
/>
</BindingControl>
}}
onRemove={(value) =>
onChange({
type: "string[]",
value: Array.isArray(value)
? value.map((item) => String(item))
: [],
})
}
renderControl={({ value, readOnly, onChangeValue }) => (
<>
{options.map((option) => (
<CheckboxAndLabel key={option}>
<Checkbox
disabled={readOnly}
checked={value.includes(option)}
onCheckedChange={(checked) =>
onChangeValue(
checked ? add(value, option) : remove(value, option)
)
}
id={`${id}:${option}`}
/>
<Label htmlFor={`${id}:${option}`}>{option}</Label>
</CheckboxAndLabel>
))}
</>
)}
/>
</VerticalLayout>
);
};
@@ -1,4 +1,3 @@
import { useStore } from "@nanostores/react";
import { useState } from "react";
import {
Button,
@@ -16,9 +15,9 @@ import {
import { InfoCircleIcon } from "@webstudio-is/icons";
import { CodeEditor } from "~/shared/code-editor";
import {
BindingControl,
BindingPopover,
} from "~/builder/shared/binding-popover";
BindableExpressionControl,
updateBindableValue,
} from "~/builder/shared/bindable-expression";
import {
validateHtmlEmbedCode,
type HtmlEmbedCodeError,
@@ -28,12 +27,10 @@ import { useDraftValue } from "~/builder/shared/use-draft-value";
import {
type ControlProps,
VerticalLayout,
updateExpressionValue,
$selectedInstanceScope,
useBindingState,
humanizeAttribute,
} from "../shared";
import { PropertyLabel } from "../property-label";
import { useBindableControl } from "./use-bindable-control";
export type CodeIssue = HtmlEmbedCodeError & {
severity?: "error" | "warning";
};
@@ -141,21 +138,19 @@ export const CodeControl = ({
}
}
if (prop?.type === "expression") {
updateExpressionValue(prop.value, storedValue);
} else {
onChange({ type: "string", value: storedValue });
}
updateBindableValue({
expression: prop?.type === "expression" ? prop.value : undefined,
value: storedValue,
onChangeValue: (value) => onChange({ type: "string", value }),
});
},
{ autoSave: behavior?.autoSave ?? true }
);
const { scope, aliases } = useStore($selectedInstanceScope);
const expression =
prop?.type === "expression" ? prop.value : JSON.stringify(computedValue);
const { overwritable, variant } = useBindingState(
prop?.type === "expression" ? prop.value : undefined
);
const binding = useBindableControl({
boundExpression: prop?.type === "expression" ? prop.value : undefined,
fallbackExpression: JSON.stringify(computedValue),
});
const errorInfo = (
<ErrorInfo
@@ -173,69 +168,69 @@ export const CodeControl = ({
<VerticalLayout
label={
<Flex gap="1" align="center">
<PropertyLabel name={propName} readOnly={overwritable === false} />
<PropertyLabel
name={propName}
readOnly={binding.bindingState.overwritable === false}
/>
{errorInfo}
</Flex>
}
>
<BindingControl>
<CodeEditor
lang={lang}
title={
<DialogTitle
maximizable
suffix={
<DialogTitleActions>
<DialogMaximize />
<DialogClose />
</DialogTitleActions>
}
>
<Flex gap="1" align="center">
<Text variant="labels">Code editor</Text>
{errorInfo}
</Flex>
</DialogTitle>
}
readOnly={overwritable === false}
invalid={error !== undefined && error.severity !== "warning"}
value={localValue.value}
onChange={(value) => {
setError(undefined);
localValue.set(value);
}}
onChangeComplete={localValue.save}
/>
<BindingPopover
scope={scope}
aliases={aliases}
validate={(value) =>
behavior
? behavior.validateBinding(value, label)
: validatePrimitiveValue(value, label)
}
variant={variant}
value={expression}
onChange={(newExpression) =>
onChange({ type: "expression", value: newExpression })
}
onRemove={(evaluatedValue) => {
if (behavior) {
const fixedValue = behavior.getFixedValue(evaluatedValue, label);
if (fixedValue.success === false) {
setError({
message: fixedValue.message,
value: String(evaluatedValue),
});
return;
}
onChange({ type: "string", value: fixedValue.value });
<BindableExpressionControl
{...binding}
value={localValue.value}
validate={(value) =>
behavior
? behavior.validateBinding(value, label)
: validatePrimitiveValue(value, label)
}
onChangeValue={(value) => onChange({ type: "string", value })}
onChangeExpression={(value) => onChange({ type: "expression", value })}
onRemove={(value) => {
if (behavior) {
const fixedValue = behavior.getFixedValue(value, label);
if (fixedValue.success === false) {
setError({
message: fixedValue.message,
value: String(value),
});
return;
}
onChange({ type: "string", value: String(evaluatedValue) });
}}
/>
</BindingControl>
onChange({ type: "string", value: fixedValue.value });
return;
}
onChange({ type: "string", value: String(value) });
}}
renderControl={({ readOnly }) => (
<CodeEditor
lang={lang}
title={
<DialogTitle
maximizable
suffix={
<DialogTitleActions>
<DialogMaximize />
<DialogClose />
</DialogTitleActions>
}
>
<Flex gap="1" align="center">
<Text variant="labels">Code editor</Text>
{errorInfo}
</Flex>
</DialogTitle>
}
readOnly={readOnly}
invalid={error !== undefined && error.severity !== "warning"}
value={localValue.value}
onChange={(value) => {
setError(undefined);
localValue.set(value);
}}
onChangeComplete={localValue.save}
/>
)}
/>
</VerticalLayout>
);
};
@@ -1,22 +1,19 @@
import { useStore } from "@nanostores/react";
import { useId } from "react";
import { Flex, InputField, theme } from "@webstudio-is/design-system";
import {
BindingControl,
BindingPopover,
} from "~/builder/shared/binding-popover";
BindableExpressionControl,
updateBindableValue,
} from "~/builder/shared/bindable-expression";
import { validatePrimitiveValue } from "@webstudio-is/project-build/runtime";
import { useDraftValue } from "~/builder/shared/use-draft-value";
import {
type ControlProps,
VerticalLayout,
updateExpressionValue,
$selectedInstanceScope,
useBindingState,
humanizeAttribute,
} from "../shared";
import { SelectAsset } from "./select-asset";
import { PropertyLabel } from "../property-label";
import { useBindableControl } from "./use-bindable-control";
const UrlInput = ({
id,
@@ -61,45 +58,44 @@ export const FileControl = ({
(value) => {
if (value === undefined) {
return;
} else if (prop?.type === "expression") {
updateExpressionValue(prop.value, value);
} else {
onChange({ type: "string", value });
}
updateBindableValue({
expression: prop?.type === "expression" ? prop.value : undefined,
value,
onChangeValue: (value) => onChange({ type: "string", value }),
});
}
);
const label = humanizeAttribute(meta.label || propName);
const { scope, aliases } = useStore($selectedInstanceScope);
const expression =
prop?.type === "expression" ? prop.value : JSON.stringify(computedValue);
const { overwritable, variant } = useBindingState(
prop?.type === "expression" ? prop.value : undefined
);
const binding = useBindableControl({
boundExpression: prop?.type === "expression" ? prop.value : undefined,
fallbackExpression: JSON.stringify(computedValue),
});
return (
<VerticalLayout label={<PropertyLabel name={propName} />}>
<Flex css={{ gap: theme.spacing[3] }} direction="column" justify="center">
<BindingControl>
<UrlInput
id={id}
readOnly={overwritable === false}
localValue={localStringValue}
/>
<BindingPopover
scope={scope}
aliases={aliases}
validate={(value) => validatePrimitiveValue(value, label)}
variant={variant}
value={expression}
onChange={(newExpression) =>
onChange({ type: "expression", value: newExpression })
}
onRemove={(evaluatedValue) =>
onChange({ type: "string", value: String(evaluatedValue) })
}
/>
</BindingControl>
<BindableExpressionControl
{...binding}
value={localStringValue.value}
validate={(value) => validatePrimitiveValue(value, label)}
onChangeValue={(value) =>
onChange({ type: "string", value: value ?? "" })
}
onChangeExpression={(value) =>
onChange({ type: "expression", value })
}
onRemove={(value) =>
onChange({ type: "string", value: String(value) })
}
renderControl={({ readOnly }) => (
<UrlInput
id={id}
readOnly={readOnly}
localValue={localStringValue}
/>
)}
/>
<SelectAsset
prop={prop?.type === "asset" ? prop : undefined}
accept={meta.accept}
@@ -1,23 +1,17 @@
import { useState } from "react";
import { useStore } from "@nanostores/react";
import { isLiteralExpression } from "@webstudio-is/sdk";
import { isLiteralExpression } from "@webstudio-is/expression";
import { useDraftValue } from "~/builder/shared/use-draft-value";
import {
type ControlProps,
VerticalLayout,
updateExpressionValue,
$selectedInstanceScope,
useBindingState,
} from "../shared";
BindableExpressionControl,
updateBindableValue,
} from "~/builder/shared/bindable-expression";
import { type ControlProps, VerticalLayout } from "../shared";
import {
ExpressionEditor,
formatValue,
} from "~/builder/shared/expression-editor";
import {
BindingControl,
BindingPopover,
} from "~/builder/shared/binding-popover";
import { PropertyLabel } from "../property-label";
import { useBindableControl } from "./use-bindable-control";
export const JsonControl = ({
prop,
@@ -37,49 +31,46 @@ export const JsonControl = ({
try {
// wrap into parens to treat object expression as value instead of block
const parsedValue = eval(`(${value})`);
if (prop?.type === "expression") {
updateExpressionValue(prop.value, parsedValue);
} else {
onChange({ type: "json", value: parsedValue });
}
updateBindableValue({
expression: prop?.type === "expression" ? prop.value : undefined,
value: parsedValue,
onChangeValue: (value) => onChange({ type: "json", value }),
});
} catch {
// empty block
}
});
const { scope, aliases } = useStore($selectedInstanceScope);
const expression = prop?.type === "expression" ? prop.value : valueString;
const { overwritable, variant } = useBindingState(
prop?.type === "expression" ? prop.value : undefined
);
const binding = useBindableControl({
boundExpression: prop?.type === "expression" ? prop.value : undefined,
fallbackExpression: valueString,
});
return (
<VerticalLayout
label={
<PropertyLabel name={propName} readOnly={overwritable === false} />
<PropertyLabel
name={propName}
readOnly={binding.bindingState.overwritable === false}
/>
}
>
<BindingControl>
<ExpressionEditor
color={error ? "error" : undefined}
readOnly={overwritable === false}
value={localValue.value}
onChange={localValue.set}
onChangeComplete={localValue.save}
/>
<BindingPopover
scope={scope}
aliases={aliases}
variant={variant}
value={expression}
onChange={(newExpression) =>
onChange({ type: "expression", value: newExpression })
}
onRemove={(evaluatedValue) =>
onChange({ type: "json", value: evaluatedValue })
}
/>
</BindingControl>
<BindableExpressionControl
{...binding}
value={computedValue}
onChangeValue={(value) => onChange({ type: "json", value })}
onChangeExpression={(value) => onChange({ type: "expression", value })}
onRemove={(value) => onChange({ type: "json", value })}
renderControl={({ readOnly }) => (
<ExpressionEditor
color={error ? "error" : undefined}
readOnly={readOnly}
value={localValue.value}
onChange={localValue.set}
onChangeComplete={localValue.save}
/>
)}
/>
</VerticalLayout>
);
};
@@ -1,20 +1,17 @@
import { useId, useState } from "react";
import { useStore } from "@nanostores/react";
import { InputField } from "@webstudio-is/design-system";
import {
BindingControl,
BindingPopover,
} from "~/builder/shared/binding-popover";
import { useDraftValue } from "~/builder/shared/use-draft-value";
import {
BindableExpressionControl,
updateBindableValue,
} from "~/builder/shared/bindable-expression";
import {
type ControlProps,
ResponsiveLayout,
updateExpressionValue,
$selectedInstanceScope,
useBindingState,
humanizeAttribute,
} from "../shared";
import { PropertyLabel } from "../property-label";
import { useBindableControl } from "./use-bindable-control";
export const NumberControl = ({
meta,
@@ -31,11 +28,11 @@ export const NumberControl = ({
Number.isNaN(number) ? "" : number,
(value) => {
if (typeof value === "number") {
if (prop?.type === "expression") {
updateExpressionValue(prop.value, value);
} else {
onChange({ type: "number", value });
}
updateBindableValue({
expression: prop?.type === "expression" ? prop.value : undefined,
value,
onChangeValue: (value) => onChange({ type: "number", value }),
});
}
if (value === "") {
setIsInvalid(true);
@@ -44,59 +41,63 @@ export const NumberControl = ({
);
const label = humanizeAttribute(meta.label || propName);
const { scope, aliases } = useStore($selectedInstanceScope);
const expression =
prop?.type === "expression" ? prop.value : JSON.stringify(computedValue);
const { overwritable, variant } = useBindingState(
prop?.type === "expression" ? prop.value : undefined
);
const binding = useBindableControl({
boundExpression: prop?.type === "expression" ? prop.value : undefined,
fallbackExpression: JSON.stringify(computedValue),
});
return (
<ResponsiveLayout
label={
<PropertyLabel name={propName} readOnly={overwritable === false} />
<PropertyLabel
name={propName}
readOnly={binding.bindingState.overwritable === false}
/>
}
>
<BindingControl>
<InputField
id={id}
disabled={overwritable === false}
type="number"
value={localValue.value}
color={isInvalid ? "error" : undefined}
onChange={({ target: { valueAsNumber, value } }) => {
localValue.set(Number.isNaN(valueAsNumber) ? value : valueAsNumber);
setIsInvalid(false);
}}
onBlur={localValue.save}
onKeyDown={(event) => {
if (event.key === "Enter") {
localValue.save();
}
}}
/>
<BindingPopover
scope={scope}
aliases={aliases}
validate={(value) => {
if (value !== undefined && typeof value !== "number") {
return `${label} expects a number value`;
}
}}
variant={variant}
value={expression}
onChange={(newExpression) =>
onChange({ type: "expression", value: newExpression })
<BindableExpressionControl
{...binding}
value={localValue.value}
validate={(value) => {
if (value !== undefined && typeof value !== "number") {
return `${label} expects a number value`;
}
onRemove={(evaluatedValue) => {
const number = Number(evaluatedValue);
onChange({
type: "number",
value: Number.isNaN(number) ? 0 : number,
});
}}
/>
</BindingControl>
}}
onChangeValue={(value) => {
if (typeof value === "number") {
onChange({ type: "number", value });
}
}}
onChangeExpression={(value) => onChange({ type: "expression", value })}
onRemove={(value) => {
const number = Number(value);
onChange({
type: "number",
value: Number.isNaN(number) ? 0 : number,
});
}}
renderControl={({ readOnly }) => (
<InputField
id={id}
disabled={readOnly}
type="number"
value={localValue.value}
color={isInvalid ? "error" : undefined}
onChange={({ target: { valueAsNumber, value } }) => {
localValue.set(
Number.isNaN(valueAsNumber) ? value : valueAsNumber
);
setIsInvalid(false);
}}
onBlur={localValue.save}
onKeyDown={(event) => {
if (event.key === "Enter") {
localValue.save();
}
}}
/>
)}
/>
</ResponsiveLayout>
);
};
@@ -1,20 +1,9 @@
import { useId } from "react";
import { useStore } from "@nanostores/react";
import { RadioGroup, Radio, RadioAndLabel } from "@webstudio-is/design-system";
import {
BindingControl,
BindingPopover,
} from "~/builder/shared/binding-popover";
import {
type ControlProps,
VerticalLayout,
Label,
$selectedInstanceScope,
updateExpressionValue,
useBindingState,
humanizeAttribute,
} from "../shared";
import { BindableExpressionControl } from "~/builder/shared/bindable-expression";
import { type ControlProps, VerticalLayout, Label } from "../shared";
import { PropertyLabel } from "../property-label";
import { useStringOptions } from "./use-string-options";
export const RadioControl = ({
meta,
@@ -23,21 +12,14 @@ export const RadioControl = ({
computedValue,
onChange,
}: ControlProps<"radio" | "inline-radio">) => {
const value = computedValue === undefined ? undefined : String(computedValue);
// making sure that the current value is in the list of options
const options =
value === undefined || meta.options.includes(value)
? meta.options
: [value, ...meta.options];
const id = useId();
const label = humanizeAttribute(meta.label || propName);
const { scope, aliases } = useStore($selectedInstanceScope);
const expression =
prop?.type === "expression" ? prop.value : JSON.stringify(computedValue);
const { overwritable, variant } = useBindingState(
prop?.type === "expression" ? prop.value : undefined
);
const { options, overwritable, binding } = useStringOptions({
meta,
prop,
propName,
computedValue,
onChange,
});
return (
<VerticalLayout
@@ -45,51 +27,24 @@ export const RadioControl = ({
<PropertyLabel name={propName} readOnly={overwritable === false} />
}
>
<BindingControl>
<RadioGroup
disabled={overwritable === false}
name="value"
value={value}
onValueChange={(value) => {
if (prop?.type === "expression") {
updateExpressionValue(prop.value, value);
} else {
onChange({ type: "string", value });
}
}}
>
{options.map((value) => (
<RadioAndLabel key={value}>
<Radio value={value} id={`${id}:${value}`} />
<Label htmlFor={`${id}:${value}`}>{value}</Label>
</RadioAndLabel>
))}
</RadioGroup>
<BindingPopover
scope={scope}
aliases={aliases}
validate={(value) => {
if (
value !== undefined &&
meta.options.includes(String(value)) === false
) {
const formatter = new Intl.ListFormat(undefined, {
type: "disjunction",
});
const options = formatter.format(meta.options);
return `${label} expects one of ${options}`;
}
}}
variant={variant}
value={expression}
onChange={(newExpression) =>
onChange({ type: "expression", value: newExpression })
}
onRemove={(evaluatedValue) =>
onChange({ type: "string", value: String(evaluatedValue) })
}
/>
</BindingControl>
<BindableExpressionControl
{...binding}
renderControl={({ value, readOnly, onChangeValue }) => (
<RadioGroup
disabled={readOnly}
name="value"
value={value}
onValueChange={onChangeValue}
>
{options.map((value) => (
<RadioAndLabel key={value}>
<Radio value={value} id={`${id}:${value}`} />
<Label htmlFor={`${id}:${value}`}>{value}</Label>
</RadioAndLabel>
))}
</RadioGroup>
)}
/>
</VerticalLayout>
);
};
@@ -17,12 +17,9 @@ import {
NestedInputButton,
theme,
} from "@webstudio-is/design-system";
import { isLiteralExpression, type Resource } from "@webstudio-is/sdk";
import {
BindingControl,
BindingPopover,
type BindingVariant,
} from "~/builder/shared/binding-popover";
import { isLiteralExpression } from "@webstudio-is/expression";
import type { Resource } from "@webstudio-is/sdk";
import { BindableExpressionControl } from "~/builder/shared/bindable-expression";
import { validatePrimitiveValue } from "@webstudio-is/project-build/runtime";
import { $variableValuesByInstanceSelector } from "~/shared/nano-states";
import { $dataSources } from "~/shared/sync/data-stores";
@@ -278,56 +275,54 @@ export const ResourceControl = ({
};
const id = useId();
let variant: BindingVariant = "bound";
let readOnly = true;
if (isLiteralExpression(urlExpression)) {
variant = "default";
readOnly = false;
}
const bound = isLiteralExpression(urlExpression) === false;
const localValue = useDraftValue(
String(computeExpression(resource.url, variableValues) ?? ""),
(value) => updateResource({ ...resource, url: JSON.stringify(value) })
);
return (
<VerticalLayout
label={<PropertyLabel name={propName} readOnly={readOnly} />}
>
<BindingControl>
<InputField
id={id}
disabled={readOnly}
value={localValue.value}
onChange={(event) => localValue.set(event.target.value)}
onBlur={localValue.save}
onSubmit={localValue.save}
suffix={
isFeatureEnabled("resourceProp") && (
<ResourceControlPanel
resource={resource}
propName={propName}
onChange={updateResource}
/>
)
}
/>
<BindingPopover
scope={scope}
aliases={aliases}
validate={(value) => validatePrimitiveValue(value, "URL")}
variant={variant}
value={urlExpression}
onChange={(newExpression) =>
updateResource({ ...resource, url: newExpression })
}
onRemove={(evaluatedValue) =>
updateResource({
...resource,
url: JSON.stringify(String(evaluatedValue)),
})
}
/>
</BindingControl>
<VerticalLayout label={<PropertyLabel name={propName} readOnly={bound} />}>
<BindableExpressionControl
expression={urlExpression}
value={localValue.value}
bound={bound}
allowBindingOverwrite={false}
scope={scope}
aliases={aliases}
validate={(value) => validatePrimitiveValue(value, "URL")}
onChangeValue={(value) =>
updateResource({ ...resource, url: JSON.stringify(value) })
}
onChangeExpression={(value) =>
updateResource({ ...resource, url: value })
}
onRemove={(value) =>
updateResource({
...resource,
url: JSON.stringify(String(value)),
})
}
renderControl={({ readOnly }) => (
<InputField
id={id}
disabled={readOnly}
value={localValue.value}
onChange={(event) => localValue.set(event.target.value)}
onBlur={localValue.save}
onSubmit={localValue.save}
suffix={
isFeatureEnabled("resourceProp") && (
<ResourceControlPanel
resource={resource}
propName={propName}
onChange={updateResource}
/>
)
}
/>
)}
/>
</VerticalLayout>
);
};
@@ -1,19 +1,9 @@
import { useId } from "react";
import { useStore } from "@nanostores/react";
import { Select } from "@webstudio-is/design-system";
import {
BindingControl,
BindingPopover,
} from "~/builder/shared/binding-popover";
import {
type ControlProps,
VerticalLayout,
$selectedInstanceScope,
updateExpressionValue,
useBindingState,
humanizeAttribute,
} from "../shared";
import { BindableExpressionControl } from "~/builder/shared/bindable-expression";
import { type ControlProps, VerticalLayout } from "../shared";
import { PropertyLabel } from "../property-label";
import { useStringOptions } from "./use-string-options";
export const SelectControl = ({
meta,
@@ -24,20 +14,14 @@ export const SelectControl = ({
}: ControlProps<"select">) => {
const id = useId();
const value = computedValue === undefined ? undefined : String(computedValue);
// making sure that the current value is in the list of options
const options =
value === undefined || value.length === 0 || meta.options.includes(value)
? meta.options
: [value, ...meta.options];
const label = humanizeAttribute(meta.label || propName);
const { scope, aliases } = useStore($selectedInstanceScope);
const expression =
prop?.type === "expression" ? prop.value : JSON.stringify(computedValue);
const { overwritable, variant } = useBindingState(
prop?.type === "expression" ? prop.value : undefined
);
const { options, overwritable, binding } = useStringOptions({
meta,
prop,
propName,
computedValue,
onChange,
allowEmptyValue: true,
});
return (
<VerticalLayout
@@ -45,46 +29,19 @@ export const SelectControl = ({
<PropertyLabel name={propName} readOnly={overwritable === false} />
}
>
<BindingControl>
<Select
fullWidth
id={id}
disabled={overwritable === false}
value={value}
options={options}
onChange={(value) => {
if (prop?.type === "expression") {
updateExpressionValue(prop.value, value);
} else {
onChange({ type: "string", value });
}
}}
/>
<BindingPopover
scope={scope}
aliases={aliases}
validate={(value) => {
if (
value !== undefined &&
meta.options.includes(String(value)) === false
) {
const formatter = new Intl.ListFormat(undefined, {
type: "disjunction",
});
const options = formatter.format(meta.options);
return `${label} expects one of ${options}`;
}
}}
variant={variant}
value={expression}
onChange={(newExpression) =>
onChange({ type: "expression", value: newExpression })
}
onRemove={(evaluatedValue) =>
onChange({ type: "string", value: String(evaluatedValue) })
}
/>
</BindingControl>
<BindableExpressionControl
{...binding}
renderControl={({ value, readOnly, onChangeValue }) => (
<Select
fullWidth
id={id}
disabled={readOnly}
value={value}
options={options}
onChange={onChangeValue}
/>
)}
/>
</VerticalLayout>
);
};
@@ -13,22 +13,17 @@ import {
import type { Instance } from "@webstudio-is/sdk";
import { AlertIcon } from "@webstudio-is/icons";
import { $instances } from "~/shared/sync/data-stores";
import {
BindingControl,
BindingPopover,
} from "~/builder/shared/binding-popover";
import { validatePrimitiveValue } from "@webstudio-is/project-build/runtime";
import { useDraftValue } from "~/builder/shared/use-draft-value";
import {
BindableExpressionControl,
updateBindableValue,
} from "~/builder/shared/bindable-expression";
import { executeRuntimeMutation } from "~/shared/instance-utils/data";
import { CodeEditor } from "~/shared/code-editor";
import {
type ControlProps,
VerticalLayout,
$selectedInstanceScope,
updateExpressionValue,
useBindingState,
} from "../shared";
import { type ControlProps, VerticalLayout } from "../shared";
import { FieldLabel, useIsBindingResetForbidden } from "../property-label";
import { useBindableControl } from "./use-bindable-control";
const useInstance = (instanceId: Instance["id"]) => {
const $store = useMemo(() => {
@@ -62,14 +57,13 @@ export const TextContent = ({
// text content control is rendered only when empty or single child are present
const child = instance?.children?.[0] ?? { type: "text", value: "" };
const localValue = useDraftValue(String(computedValue ?? ""), (value) => {
if (child.type === "expression") {
updateExpressionValue(child.value, value);
} else {
updateChildren(instanceId, "text", value);
}
updateBindableValue({
expression: child.type === "expression" ? child.value : undefined,
value,
onChangeValue: (value) => updateChildren(instanceId, "text", value),
});
});
const { scope, aliases } = useStore($selectedInstanceScope);
let expression: undefined | string;
if (child.type === "text") {
expression = JSON.stringify(child.value);
@@ -78,9 +72,10 @@ export const TextContent = ({
expression = child.value;
}
const { overwritable, variant } = useBindingState(
child.type === "expression" ? child.value : undefined
);
const binding = useBindableControl({
boundExpression: child.type === "expression" ? expression : undefined,
fallbackExpression: expression ?? "",
});
const isBindingResetForbidden = useIsBindingResetForbidden();
const isResetDisabled =
child.type === "expression" && isBindingResetForbidden;
@@ -93,7 +88,7 @@ export const TextContent = ({
<>
Plain text content that can be bound to either a variable or a
resource value.
{overwritable === false && (
{binding.bindingState.overwritable === false && (
<Flex gap="1">
<AlertIcon
color={rawTheme.colors.backgroundAlertMain}
@@ -123,43 +118,39 @@ export const TextContent = ({
</FieldLabel>
}
>
<BindingControl>
<CodeEditor
title={
<DialogTitle
maximizable
suffix={
<DialogTitleActions>
<DialogMaximize />
<DialogClose />
</DialogTitleActions>
}
>
<Text variant="labels">Text content</Text>
</DialogTitle>
}
size="small"
readOnly={overwritable === false}
value={localValue.value}
onChange={localValue.set}
onChangeComplete={localValue.save}
/>
{expression !== undefined && (
<BindingPopover
scope={scope}
aliases={aliases}
validate={(value) => validatePrimitiveValue(value, "Text Content")}
variant={variant}
value={expression}
onChange={(newExpression) => {
updateChildren(instanceId, "expression", newExpression);
}}
onRemove={(evaluatedValue) =>
updateChildren(instanceId, "text", String(evaluatedValue))
<BindableExpressionControl
{...binding}
value={localValue.value}
showBinding={expression !== undefined}
validate={(value) => validatePrimitiveValue(value, "Text Content")}
onChangeValue={(value) => updateChildren(instanceId, "text", value)}
onChangeExpression={(value) =>
updateChildren(instanceId, "expression", value)
}
onRemove={(value) => updateChildren(instanceId, "text", String(value))}
renderControl={({ readOnly }) => (
<CodeEditor
title={
<DialogTitle
maximizable
suffix={
<DialogTitleActions>
<DialogMaximize />
<DialogClose />
</DialogTitleActions>
}
>
<Text variant="labels">Text content</Text>
</DialogTitle>
}
size="small"
readOnly={readOnly}
value={localValue.value}
onChange={localValue.set}
onChangeComplete={localValue.save}
/>
)}
</BindingControl>
/>
</VerticalLayout>
);
};
@@ -1,21 +1,18 @@
import { useId } from "react";
import { useStore } from "@nanostores/react";
import { TextArea } from "@webstudio-is/design-system";
import {
BindingControl,
BindingPopover,
} from "~/builder/shared/binding-popover";
import { validatePrimitiveValue } from "@webstudio-is/project-build/runtime";
import { useDraftValue } from "~/builder/shared/use-draft-value";
import {
BindableExpressionControl,
updateBindableValue,
} from "~/builder/shared/bindable-expression";
import {
type ControlProps,
ResponsiveLayout,
updateExpressionValue,
$selectedInstanceScope,
useBindingState,
humanizeAttribute,
} from "../shared";
import { PropertyLabel } from "../property-label";
import { useBindableControl } from "./use-bindable-control";
export const TextControl = ({
meta,
@@ -25,56 +22,51 @@ export const TextControl = ({
onChange,
}: ControlProps<"text">) => {
const localValue = useDraftValue(String(computedValue ?? ""), (value) => {
if (prop?.type === "expression") {
updateExpressionValue(prop.value, value);
} else {
onChange({ type: "string", value });
}
updateBindableValue({
expression: prop?.type === "expression" ? prop.value : undefined,
value,
onChangeValue: (value) => onChange({ type: "string", value }),
});
});
const id = useId();
const label = humanizeAttribute(meta.label || propName);
const { scope, aliases } = useStore($selectedInstanceScope);
const expression =
prop?.type === "expression" ? prop.value : JSON.stringify(computedValue);
const { overwritable, variant } = useBindingState(
prop?.type === "expression" ? prop.value : undefined
);
const binding = useBindableControl({
boundExpression: prop?.type === "expression" ? prop.value : undefined,
fallbackExpression: JSON.stringify(computedValue),
});
const input = (
<BindingControl>
<TextArea
id={id}
disabled={overwritable === false}
autoGrow
value={localValue.value}
rows={meta.rows ?? 1}
// Set maxRows to 3 when meta.rows is undefined or equal to 1, otherwise set it to rows * 2
maxRows={Math.max(2 * (meta.rows ?? 1), 3)}
onChange={localValue.set}
onBlur={localValue.save}
onSubmit={localValue.save}
/>
<BindingPopover
scope={scope}
aliases={aliases}
validate={(value) => validatePrimitiveValue(value, label)}
variant={variant}
value={expression}
onChange={(newExpression) =>
onChange({ type: "expression", value: newExpression })
}
onRemove={(evaluatedValue) =>
onChange({ type: "string", value: String(evaluatedValue) })
}
/>
</BindingControl>
<BindableExpressionControl
{...binding}
value={localValue.value}
validate={(value) => validatePrimitiveValue(value, label)}
onChangeValue={(value) => onChange({ type: "string", value })}
onChangeExpression={(value) => onChange({ type: "expression", value })}
onRemove={(value) => onChange({ type: "string", value: String(value) })}
renderControl={({ readOnly }) => (
<TextArea
id={id}
disabled={readOnly}
autoGrow
value={localValue.value}
rows={meta.rows ?? 1}
// Set maxRows to 3 when meta.rows is undefined or equal to 1, otherwise set it to rows * 2
maxRows={Math.max(2 * (meta.rows ?? 1), 3)}
onChange={localValue.set}
onBlur={localValue.save}
onSubmit={localValue.save}
/>
)}
/>
);
return (
<ResponsiveLayout
label={
<PropertyLabel name={propName} readOnly={overwritable === false} />
<PropertyLabel
name={propName}
readOnly={binding.bindingState.overwritable === false}
/>
}
>
{input}
@@ -2,22 +2,20 @@ import { useId } from "react";
import { useStore } from "@nanostores/react";
import { matchSorter } from "match-sorter";
import { Box, Combobox, Text, theme } from "@webstudio-is/design-system";
import {
BindingControl,
BindingPopover,
} from "~/builder/shared/binding-popover";
import { validatePrimitiveValue } from "@webstudio-is/project-build/runtime";
import { useDraftValue } from "~/builder/shared/use-draft-value";
import {
BindableExpressionControl,
updateBindableValue,
} from "~/builder/shared/bindable-expression";
import { $props } from "~/shared/sync/data-stores";
import {
type ControlProps,
ResponsiveLayout,
updateExpressionValue,
$selectedInstanceScope,
useBindingState,
humanizeAttribute,
} from "../shared";
import { PropertyLabel } from "../property-label";
import { useBindableControl } from "./use-bindable-control";
type TimeZoneItem = {
value: string;
@@ -198,22 +196,20 @@ export const TimeZoneControl = ({
computedValue ?? meta.defaultValue ?? defaultTimeZone
);
const localValue = useDraftValue(savedValue, (value) => {
if (prop?.type === "expression") {
updateExpressionValue(prop.value, value);
} else {
onChange({ type: "string", value });
}
updateBindableValue({
expression: prop?.type === "expression" ? prop.value : undefined,
value,
onChangeValue: (value) => onChange({ type: "string", value }),
});
});
const timeZoneItems = getTimeZoneItems(meta.options);
const selectedItem = getTimeZoneItem(savedValue);
const currentItem = getTimeZoneItem(localValue.value);
const label = humanizeAttribute(meta.label || propName);
const { scope, aliases } = useStore($selectedInstanceScope);
const expression =
prop?.type === "expression" ? prop.value : JSON.stringify(computedValue);
const { overwritable, variant } = useBindingState(
prop?.type === "expression" ? prop.value : undefined
);
const binding = useBindableControl({
boundExpression: prop?.type === "expression" ? prop.value : undefined,
fallbackExpression: JSON.stringify(computedValue),
});
const datetime = getStringProp(
props,
@@ -247,57 +243,55 @@ export const TimeZoneControl = ({
return (
<ResponsiveLayout
label={
<PropertyLabel name={propName} readOnly={overwritable === false} />
<PropertyLabel
name={propName}
readOnly={binding.bindingState.overwritable === false}
/>
}
>
<BindingControl>
<Combobox<TimeZoneItem>
name={id}
disabled={overwritable === false}
getItems={() => timeZoneItems}
itemToString={itemToString}
value={currentItem}
selectedItem={selectedItem}
match={matchOrSuggestTimeZone}
onChange={(value) => {
if (value !== undefined) {
localValue.set(value.trim());
}
}}
onItemSelect={(item) => {
localValue.set(item.value.trim());
localValue.flush();
}}
getDescription={(item) => {
const preview = getTimeZonePreview({
item,
date,
locale,
dateStyle,
timeStyle,
format,
});
return (
<Box css={{ width: theme.spacing[28] }}>
<Text>{preview ?? "Select or type an IANA timezone."}</Text>
</Box>
);
}}
/>
<BindingPopover
scope={scope}
aliases={aliases}
validate={(value) => validatePrimitiveValue(value, label)}
variant={variant}
value={expression}
onChange={(newExpression) =>
onChange({ type: "expression", value: newExpression })
}
onRemove={(evaluatedValue) =>
onChange({ type: "string", value: String(evaluatedValue) })
}
/>
</BindingControl>
<BindableExpressionControl
{...binding}
value={localValue.value}
validate={(value) => validatePrimitiveValue(value, label)}
onChangeValue={(value) => onChange({ type: "string", value })}
onChangeExpression={(value) => onChange({ type: "expression", value })}
onRemove={(value) => onChange({ type: "string", value: String(value) })}
renderControl={({ readOnly }) => (
<Combobox<TimeZoneItem>
name={id}
disabled={readOnly}
getItems={() => timeZoneItems}
itemToString={itemToString}
value={currentItem}
selectedItem={selectedItem}
match={matchOrSuggestTimeZone}
onChange={(value) => {
if (value !== undefined) {
localValue.set(value.trim());
}
}}
onItemSelect={(item) => {
localValue.set(item.value.trim());
localValue.flush();
}}
getDescription={(item) => {
const preview = getTimeZonePreview({
item,
date,
locale,
dateStyle,
timeStyle,
format,
});
return (
<Box css={{ width: theme.spacing[28] }}>
<Text>{preview ?? "Select or type an IANA timezone."}</Text>
</Box>
);
}}
/>
)}
/>
</ResponsiveLayout>
);
};
@@ -29,26 +29,24 @@ import {
type Page,
} from "@webstudio-is/sdk";
import { $instances, $pages, $props } from "~/shared/sync/data-stores";
import {
BindingControl,
BindingPopover,
} from "~/builder/shared/binding-popover";
import { validatePrimitiveValue } from "@webstudio-is/project-build/runtime";
import { useDraftValue } from "~/builder/shared/use-draft-value";
import {
BindableExpressionControl,
updateBindableValue,
} from "~/builder/shared/bindable-expression";
import { getPageDisplayName } from "~/builder/features/pages/page-utils";
import {
type ControlProps,
VerticalLayout,
Label,
updateExpressionValue,
$selectedInstanceScope,
useBindingState,
humanizeAttribute,
type PropValue,
} from "../shared";
import { SelectAsset } from "./select-asset";
import { createRootFolder } from "@webstudio-is/project-build";
import { PropertyLabel } from "../property-label";
import { useBindableControl } from "./use-bindable-control";
type UrlControlProps = ControlProps<"url">;
@@ -110,11 +108,11 @@ const addHttpsIfMissing = (url: string) => {
const BaseUrl = ({ readOnly, prop, value, onChange, id }: BaseControlProps) => {
const localValue = useDraftValue(value, (value) => {
if (prop?.type === "expression") {
updateExpressionValue(prop.value, value);
} else {
onChange({ type: "string", value });
}
updateBindableValue({
expression: prop?.type === "expression" ? prop.value : undefined,
value,
onChangeValue: (value) => onChange({ type: "string", value }),
});
});
useEffect(() => {
@@ -156,11 +154,12 @@ const BasePhone = ({
const localValue = useDraftValue(
value.startsWith("tel:") ? value.slice(4) : "",
(value) => {
if (prop?.type === "expression") {
updateExpressionValue(prop.value, `tel:${value}`);
} else {
onChange({ type: "string", value: `tel:${value}` });
}
const nextValue = `tel:${value}`;
updateBindableValue({
expression: prop?.type === "expression" ? prop.value : undefined,
value: nextValue,
onChangeValue: (value) => onChange({ type: "string", value }),
});
}
);
@@ -234,11 +233,11 @@ const BaseEmail = ({
}: BaseControlProps) => {
const localValue = useDraftValue(propToEmail(value), ({ email, subject }) => {
const value = emailToProp({ email, subject });
if (prop?.type === "expression") {
updateExpressionValue(prop.value, value);
} else {
onChange({ type: "string", value });
}
updateBindableValue({
expression: prop?.type === "expression" ? prop.value : undefined,
value,
onChangeValue: (value) => onChange({ type: "string", value }),
});
});
return (
@@ -483,14 +482,14 @@ export const UrlInput = ({
value,
readOnly = false,
onChange,
suffix,
renderControl,
}: {
instanceId: string;
prop: UrlInputProp;
value: string;
readOnly?: boolean;
onChange: (value: UrlInputValue) => void;
suffix?: ReactNode;
renderControl?: (control: ReactNode) => ReactNode;
}) => {
const { value: mode, set: setMode } = useDraftValue<Mode>(
propToMode(prop, value),
@@ -537,14 +536,7 @@ export const UrlInput = ({
))}
</ToggleGroup>
</Flex>
{suffix === undefined ? (
control
) : (
<BindingControl>
{control}
{suffix}
</BindingControl>
)}
{renderControl?.(control) ?? control}
</>
);
};
@@ -559,12 +551,10 @@ export const UrlControl = ({
}: UrlControlProps) => {
const value = String(computedValue ?? "");
const label = humanizeAttribute(meta.label || propName);
const { scope, aliases } = useStore($selectedInstanceScope);
const expression =
prop?.type === "expression" ? prop.value : JSON.stringify(computedValue);
const { overwritable, variant } = useBindingState(
prop?.type === "expression" ? prop.value : undefined
);
const binding = useBindableControl({
boundExpression: prop?.type === "expression" ? prop.value : undefined,
fallbackExpression: JSON.stringify(computedValue),
});
return (
<VerticalLayout label={<PropertyLabel name={propName} />}>
@@ -579,23 +569,23 @@ export const UrlControl = ({
: undefined
}
value={value}
readOnly={overwritable === false}
readOnly={binding.bindingState.overwritable === false}
onChange={onChange}
suffix={
<BindingPopover
scope={scope}
aliases={aliases}
renderControl={(control) => (
<BindableExpressionControl
{...binding}
value={value}
validate={(value) => validatePrimitiveValue(value, label)}
variant={variant}
value={expression}
onChange={(newExpression) =>
onChange({ type: "expression", value: newExpression })
onChangeValue={(value) => onChange({ type: "string", value })}
onChangeExpression={(value) =>
onChange({ type: "expression", value })
}
onRemove={(evaluatedValue) =>
onChange({ type: "string", value: String(evaluatedValue) })
onRemove={(value) =>
onChange({ type: "string", value: String(value) })
}
renderControl={() => control}
/>
}
)}
/>
</VerticalLayout>
);
@@ -0,0 +1,17 @@
import { useStore } from "@nanostores/react";
import { useBindingState } from "~/builder/shared/bindable-expression";
import { $selectedInstanceScope } from "../shared";
export const useBindableControl = ({
boundExpression,
fallbackExpression,
}: {
boundExpression?: string;
fallbackExpression: string;
}) => {
const { scope, aliases } = useStore($selectedInstanceScope);
const bindingState = useBindingState(boundExpression);
const bound = boundExpression !== undefined;
const expression = boundExpression ?? fallbackExpression;
return { expression, bound, scope, aliases, bindingState };
};
@@ -0,0 +1,53 @@
import { humanizeAttribute, type ControlProps } from "../shared";
import { useBindableControl } from "./use-bindable-control";
export const useStringOptions = ({
meta,
prop,
propName,
computedValue,
onChange,
allowEmptyValue = false,
}: Omit<ControlProps<"radio" | "inline-radio" | "select">, "instanceId"> & {
allowEmptyValue?: boolean;
}) => {
const value = computedValue === undefined ? undefined : String(computedValue);
const options =
value === undefined ||
(allowEmptyValue && value.length === 0) ||
meta.options.includes(value)
? meta.options
: [value, ...meta.options];
const label = humanizeAttribute(meta.label || propName);
const binding = useBindableControl({
boundExpression: prop?.type === "expression" ? prop.value : undefined,
fallbackExpression: JSON.stringify(computedValue),
});
return {
value,
options,
overwritable: binding.bindingState.overwritable,
binding: {
...binding,
value,
validate: (value: unknown) => {
if (
value !== undefined &&
meta.options.includes(String(value)) === false
) {
const formatter = new Intl.ListFormat(undefined, {
type: "disjunction",
});
return `${label} expects one of ${formatter.format(meta.options)}`;
}
},
onChangeValue: (value: string | undefined) =>
onChange({ type: "string", value: value ?? "" }),
onChangeExpression: (value: string) =>
onChange({ type: "expression", value }),
onRemove: (value: unknown) =>
onChange({ type: "string", value: String(value) }),
},
};
};
@@ -0,0 +1,39 @@
import { describe, expect, test } from "vitest";
import { getRequestErrorDiagnostics } from "./request-error-diagnostics";
describe("request error diagnostics", () => {
test("extracts HTTP and structured API error information", () => {
expect(
getRequestErrorDiagnostics({
ok: false,
status: 400,
statusText: "",
data: {
ok: false,
error: {
code: "CONTENT_NOT_TEXT",
message: "Selected binary asset cannot be embedded as text",
retryable: false,
details: { assetId: "asset", mimeType: "image/png" },
},
},
})
).toEqual({
status: 400,
statusText: undefined,
code: "CONTENT_NOT_TEXT",
message: "Selected binary asset cannot be embedded as text",
retryable: false,
details: { assetId: "asset", mimeType: "image/png" },
});
});
test("describes transport failures without a structured error", () => {
expect(
getRequestErrorDiagnostics({ ok: false, status: 502, data: "failure" })
).toMatchObject({ status: 502, message: "Request failed with status 502" });
expect(
getRequestErrorDiagnostics({ ok: true, status: 200, data: {} })
).toBeUndefined();
});
});
@@ -0,0 +1,104 @@
import { PanelBanner, Text } from "@webstudio-is/design-system";
import {
RequestDiagnosticsContent,
RequestDiagnosticsRow,
RequestDiagnosticsTable,
} from "./request-inspector";
type RequestErrorDiagnosticsValue = {
status?: number;
statusText?: string;
code?: string;
message: string;
retryable?: boolean;
details?: Record<string, unknown>;
};
const asRecord = (value: unknown): Record<string, unknown> | undefined =>
typeof value === "object" && value !== null && Array.isArray(value) === false
? (value as Record<string, unknown>)
: undefined;
export const getRequestErrorDiagnostics = (
value: unknown
): RequestErrorDiagnosticsValue | undefined => {
const response = asRecord(value);
if (response === undefined) {
return;
}
const status =
typeof response.status === "number" ? response.status : undefined;
const statusText =
typeof response.statusText === "string" && response.statusText !== ""
? response.statusText
: undefined;
const payload = asRecord(response.data);
const error = asRecord(payload?.error);
const failed =
response.ok === false || (status !== undefined && status >= 400);
if (error === undefined && failed === false) {
return;
}
const code = typeof error?.code === "string" ? error.code : undefined;
const details = asRecord(error?.details);
const errorMessage =
typeof error?.message === "string" && error.message !== ""
? error.message
: undefined;
const message =
errorMessage ??
statusText ??
(status === undefined
? "Request failed"
: `Request failed with status ${status}`);
return {
status,
statusText,
code,
message,
...(typeof error?.retryable === "boolean"
? { retryable: error.retryable }
: {}),
...(details === undefined ? {} : { details }),
};
};
const formatDetail = (value: unknown) =>
typeof value === "string" ? value : JSON.stringify(value);
export const RequestErrorDiagnostics = ({
value,
}: {
value: RequestErrorDiagnosticsValue;
}) => (
<RequestDiagnosticsContent>
<PanelBanner variant="error">
<Text>{value.message}</Text>
</PanelBanner>
<RequestDiagnosticsTable>
{value.status !== undefined && (
<RequestDiagnosticsRow
label="HTTP status"
value={`${value.status}${value.statusText === undefined ? "" : ` ${value.statusText}`}`}
/>
)}
{value.code !== undefined && (
<RequestDiagnosticsRow label="Error code" value={value.code} />
)}
<RequestDiagnosticsRow label="Message" value={value.message} />
{value.retryable !== undefined && (
<RequestDiagnosticsRow
label="Retryable"
value={value.retryable ? "Yes" : "No"}
/>
)}
{Object.entries(value.details ?? {}).map(([key, detail]) => (
<RequestDiagnosticsRow
key={key}
label={`Details · ${key}`}
value={formatDetail(detail)}
/>
))}
</RequestDiagnosticsTable>
</RequestDiagnosticsContent>
);
@@ -0,0 +1,58 @@
/**
* @vitest-environment jsdom
*/
import { act } from "react-dom/test-utils";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, expect, test } from "vitest";
import { RequestInspector } from "./request-inspector";
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
let root: Root | undefined;
afterEach(() => {
act(() => root?.unmount());
root = undefined;
document.body.innerHTML = "";
});
const renderInspector = (query: boolean) => {
const container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
<RequestInspector
queryContainerRef={query ? () => {} : undefined}
preview={<div>Preview content</div>}
diagnostics={<div>Diagnostics content</div>}
/>
);
});
return container;
};
test("places an available query editor in the first selected tab", () => {
const container = renderInspector(true);
const tabs = Array.from(container.querySelectorAll('[role="tab"]'));
expect(tabs.map(({ textContent }) => textContent)).toEqual([
"Query",
"Preview",
"Diagnostics",
]);
expect(tabs[0].getAttribute("data-state")).toBe("active");
});
test("keeps preview first when no query editor is available", () => {
const container = renderInspector(false);
const tabs = Array.from(container.querySelectorAll('[role="tab"]'));
expect(tabs.map(({ textContent }) => textContent)).toEqual([
"Preview",
"Diagnostics",
]);
expect(tabs[0].getAttribute("data-state")).toBe("active");
});
@@ -0,0 +1,134 @@
import type { ComponentProps, ReactNode, Ref } from "react";
import {
Flex,
Grid,
PanelTabs,
PanelTabsContent,
PanelTabsList,
PanelTabsTrigger,
ScrollAreaNative,
Text,
Tooltip,
theme,
} from "@webstudio-is/design-system";
import { InfoCircleIcon } from "@webstudio-is/icons";
export const RequestDiagnosticsContent = ({
children,
}: {
children: ReactNode;
}) => (
<ScrollAreaNative css={{ height: "100%", overflow: "auto" }}>
<Flex direction="column" gap={3} css={{ padding: theme.panel.padding }}>
{children}
</Flex>
</ScrollAreaNative>
);
export const RequestDiagnosticsTable = ({
children,
}: {
children: ReactNode;
}) => (
<Grid
css={{
border: `1px solid ${theme.colors.borderMain}`,
borderRadius: theme.borderRadius[4],
overflow: "hidden",
}}
>
{children}
</Grid>
);
export const RequestDiagnosticsRow = ({
label,
value,
description,
valueColor,
}: {
label: string;
value: ReactNode;
description?: ReactNode;
valueColor?: ComponentProps<typeof Text>["color"];
}) => (
<Grid
columns={2}
gap={2}
css={{
padding: theme.spacing[5],
borderBottom: `1px solid ${theme.colors.borderMain}`,
"&:last-child": { borderBottom: 0 },
}}
>
<Flex align="center" gap={1}>
<Text color="moreSubtle">{label}</Text>
{description !== undefined && (
<Tooltip variant="wrapped" content={description}>
<InfoCircleIcon tabIndex={0} />
</Tooltip>
)}
</Flex>
<Text
color={valueColor}
userSelect="text"
css={{ fontVariantNumeric: "tabular-nums" }}
>
{value}
</Text>
</Grid>
);
export const RequestInspector = ({
queryContainerRef,
preview,
diagnostics,
}: {
queryContainerRef?: Ref<HTMLDivElement>;
preview: ReactNode;
diagnostics?: ReactNode;
}) => (
<PanelTabs
key={queryContainerRef === undefined ? "preview" : "query"}
defaultValue={queryContainerRef === undefined ? "preview" : "query"}
css={{ height: "100%", minWidth: 0, overflow: "hidden" }}
>
<PanelTabsList
aria-label="Request details"
css={{
flexShrink: 0,
borderBottom: `1px solid ${theme.colors.borderMain}`,
}}
>
{queryContainerRef !== undefined && (
<PanelTabsTrigger value="query">Query</PanelTabsTrigger>
)}
<PanelTabsTrigger value="preview">Preview</PanelTabsTrigger>
<PanelTabsTrigger value="diagnostics">Diagnostics</PanelTabsTrigger>
</PanelTabsList>
{queryContainerRef !== undefined && (
<PanelTabsContent
value="query"
css={{ flex: 1, position: "relative", overflow: "hidden" }}
>
<div ref={queryContainerRef} style={{ height: "100%" }} />
</PanelTabsContent>
)}
<PanelTabsContent
value="preview"
css={{ flex: 1, position: "relative", overflow: "hidden" }}
>
{preview}
</PanelTabsContent>
<PanelTabsContent
value="diagnostics"
css={{ flex: 1, position: "relative", overflow: "hidden" }}
>
{diagnostics ?? (
<Flex align="center" justify="center" css={{ height: "100%" }}>
<Text color="moreSubtle">No diagnostics available</Text>
</Flex>
)}
</PanelTabsContent>
</PanelTabs>
);
File diff suppressed because it is too large. Load diff
@@ -1,11 +1,9 @@
import { atom, computed, type ReadableAtom } from "nanostores";
import { useStore } from "@nanostores/react";
import { computed } from "nanostores";
import {
type ComponentPropsWithoutRef,
type ReactNode,
useRef,
useState,
useMemo,
type ComponentProps,
} from "react";
import {
@@ -20,7 +18,6 @@ import {
} from "@webstudio-is/react-sdk";
import { showAttributeMeta } from "@webstudio-is/project-build/runtime";
import {
decodeDataSourceVariable,
encodeDataSourceVariable,
SYSTEM_VARIABLE_ID,
systemParameter,
@@ -40,12 +37,10 @@ import {
rawTheme,
} from "@webstudio-is/design-system";
import {
$dataSourceVariables,
$registeredComponentMetas,
$variableValuesByInstanceSelector,
} from "~/shared/nano-states";
import { $dataSources } from "~/shared/sync/data-stores";
import type { BindingVariant } from "~/builder/shared/binding-popover";
import { humanizeString } from "~/shared/string-utils";
import {
$selectedInstance,
@@ -243,6 +238,26 @@ export const Row = ({
</Flex>
);
export const CenteredPanelMessage = ({
children,
color = "subtle",
}: {
children: ReactNode;
color?: ComponentProps<typeof Text>["color"];
}) => (
<Row
css={{
alignItems: "center",
justifyContent: "center",
flexGrow: 1,
minHeight: 100,
textAlign: "center",
}}
>
<Text color={color}>{children}</Text>
</Row>
);
export const $selectedInstanceScope = computed(
[
$selectedInstanceKeyWithRoot,
@@ -274,61 +289,6 @@ export const $selectedInstanceScope = computed(
}
);
export const updateExpressionValue = (expression: string, value: unknown) => {
const dataSources = $dataSources.get();
// when expression contains only reference to variable update that variable
// extract id without parsing expression
const potentialVariableId = decodeDataSourceVariable(expression);
if (
potentialVariableId !== undefined &&
dataSources.has(potentialVariableId)
) {
const dataSourceId = potentialVariableId;
const dataSourceVariables = new Map($dataSourceVariables.get());
dataSourceVariables.set(dataSourceId, value);
$dataSourceVariables.set(dataSourceVariables);
}
};
type BindingState = {
overwritable: boolean;
variant: BindingVariant;
};
export const useBindingState = (expression: undefined | string) => {
const $bindingState = useMemo((): ReadableAtom<BindingState> => {
if (expression === undefined) {
// value is not bound to expression and can be updated
return atom({ overwritable: true, variant: "default" });
}
// try to extract variable id from expression
const potentialVariableId = decodeDataSourceVariable(expression);
if (potentialVariableId === undefined) {
// expression is complex and cannot be updated
return atom({ overwritable: false, variant: "bound" });
}
return computed(
[$dataSources, $dataSourceVariables],
(dataSources, dataSourceVariables): BindingState => {
const dataSource = dataSources.get(potentialVariableId);
// resources and parameters cannot be updated
if (dataSource?.type !== "variable") {
return { overwritable: false, variant: "bound" };
}
const variableId = potentialVariableId;
return {
overwritable: true,
variant:
dataSourceVariables.get(variableId) === undefined
? "bound"
: "overwritten",
};
}
);
}, [expression]);
return useStore($bindingState);
};
export const humanizeAttribute = (string: string) => {
if (string.includes("-")) {
return string;
@@ -35,6 +35,7 @@ import {
ProChip,
ScrollArea,
Select,
SplitView,
Switch,
Text,
TextArea,
@@ -72,11 +73,10 @@ import {
createDataVariableValueFromInput,
createResourceValueFromFormData,
findUnsetVariableNames,
parseDataVariableJsonExpression,
validateDataVariableJsonValue,
validateDataVariableNumberValue,
validateDataVariableStringArrayValue,
} from "@webstudio-is/project-build/runtime";
import { parseJsonExpression } from "@webstudio-is/expression";
import { validateDataVariableName } from "~/builder/shared/data-variable-utils";
import {
GraphqlResourceForm,
@@ -87,11 +87,20 @@ import {
import { generateCurl } from "./curl";
import {
$hasPendingResources,
$resourceDiagnosticsCache,
$resourcesCache,
computeResourceRequest,
getResourceKey,
invalidateResource,
} from "~/shared/resources";
import { Row } from "./shared";
import type { AssetQueryPreviewDiagnostics } from "@webstudio-is/content-engine";
import { RequestInspector } from "./request-inspector";
import { ContentDatabaseDiagnostics } from "./content-database-diagnostics";
import {
getRequestErrorDiagnostics,
RequestErrorDiagnostics,
} from "./request-error-diagnostics";
const NameField = ({
variable,
@@ -179,7 +188,6 @@ type VariableType =
| "string"
| "number"
| "boolean"
| "string[]"
| "json"
| "resource"
| "graphql-resource"
@@ -214,11 +222,6 @@ const TypeField = ({
label: "Boolean",
description: "A boolean is a true/false switch.",
},
{
value: "string[]",
label: "String Array",
description: "A list of text values.",
},
{
value: "json",
label: "JSON",
@@ -250,11 +253,11 @@ const TypeField = ({
value: "system-resource",
label: (
<Flex direction="row" gap="2" align="center">
System Resource
System resource
{allowDynamicData === false && <ProChip>Pro</ProChip>}
</Flex>
),
description: "A System Resource is a configuration for Webstudio data.",
description: "A system resource is a configuration for Webstudio data.",
},
];
const options = new Map(optionsList.map((option) => [option.value, option]));
@@ -282,7 +285,7 @@ const TypeField = ({
};
type PanelApi = {
save: (formData: FormData) => void;
save: (formData: FormData) => void | false;
};
const ParameterForm = forwardRef<
@@ -312,7 +315,7 @@ ParameterForm.displayName = "ParameterForm";
type ValueVariableType = Extract<
VariableType,
"string" | "number" | "boolean" | "string[]" | "json"
"string" | "number" | "boolean" | "json"
>;
const saveVariable = (
@@ -528,51 +531,6 @@ const JsonForm = forwardRef<
});
JsonForm.displayName = "JsonForm";
const StringArrayForm = forwardRef<
undefined | PanelApi,
{
variable?: DataSource;
value: unknown;
onChange: (value: unknown) => void;
}
>(({ variable, value: unknownValue, onChange }, ref) => {
const value = typeof unknownValue === "string" ? unknownValue : "[]";
const [valueError, setValueError] = useState("");
const valueRef = useRef<HTMLInputElement>(null);
useEffect(() => {
valueRef.current?.setCustomValidity(
validateDataVariableStringArrayValue(value)
);
setValueError("");
}, [value]);
useValuePanelRef({ ref, variable, type: "string[]" });
return (
<>
<input
ref={valueRef}
style={{ display: "none" }}
name="value"
data-color={valueError ? "error" : undefined}
value={value}
onChange={() => {}}
onInvalid={(event) =>
setValueError(event.currentTarget.validationMessage)
}
/>
<Flex direction="column" css={{ gap: theme.spacing[3] }}>
<Label>Value</Label>
<ExpressionEditor
color={valueError ? "error" : undefined}
value={value}
onChange={onChange}
onChangeComplete={() => valueRef.current?.checkValidity()}
/>
</Flex>
</>
);
});
StringArrayForm.displayName = "StringArrayForm";
const VariablePanelForm = forwardRef<
undefined | PanelApi,
{
@@ -581,10 +539,20 @@ const VariablePanelForm = forwardRef<
onVariableTypeChange: (variableType: VariableType) => void;
value: unknown;
onValueChange: (value: unknown) => void;
querySourceContainer: Element | null;
onQueryActiveChange: (active: boolean) => void;
}
>(
(
{ variable, variableType, onVariableTypeChange, value, onValueChange },
{
variable,
variableType,
onVariableTypeChange,
value,
onValueChange,
querySourceContainer,
onQueryActiveChange,
},
ref
) => {
const { allowDynamicData } = useStore($permissions);
@@ -615,56 +583,63 @@ const VariablePanelForm = forwardRef<
direction="column"
css={{
overflow: "hidden",
padding: theme.panel.padding,
paddingBlock: theme.panel.paddingBlock,
gap: theme.spacing[7],
}}
>
<NameField variable={variable} defaultValue={variable?.name ?? ""} />
<Row>
<NameField
variable={variable}
defaultValue={variable?.name ?? ""}
/>
</Row>
{variableType !== "parameter" && (
<TypeField value={variableType} onChange={onVariableTypeChange} />
<Row>
<TypeField value={variableType} onChange={onVariableTypeChange} />
</Row>
)}
{variableType === "parameter" && (
<ParameterForm ref={ref} variable={variable} />
)}
{variableType === "string" && (
<StringForm
ref={ref}
variable={variable}
value={value}
onChange={onValueChange}
/>
<Row>
<StringForm
ref={ref}
variable={variable}
value={value}
onChange={onValueChange}
/>
</Row>
)}
{variableType === "number" && (
<NumberForm
ref={ref}
variable={variable}
value={value}
onChange={onValueChange}
/>
<Row>
<NumberForm
ref={ref}
variable={variable}
value={value}
onChange={onValueChange}
/>
</Row>
)}
{variableType === "boolean" && (
<BooleanForm
ref={ref}
variable={variable}
value={value}
onChange={onValueChange}
/>
<Row>
<BooleanForm
ref={ref}
variable={variable}
value={value}
onChange={onValueChange}
/>
</Row>
)}
{variableType === "json" && (
<JsonForm
ref={ref}
variable={variable}
value={value}
onChange={onValueChange}
/>
)}
{variableType === "string[]" && (
<StringArrayForm
ref={ref}
variable={variable}
value={value}
onChange={onValueChange}
/>
<Row>
<JsonForm
ref={ref}
variable={variable}
value={value}
onChange={onValueChange}
/>
</Row>
)}
{variableType === "resource" && (
<ResourceForm ref={ref} variable={variable} />
@@ -673,7 +648,12 @@ const VariablePanelForm = forwardRef<
<GraphqlResourceForm ref={ref} variable={variable} />
)}
{variableType === "system-resource" && (
<SystemResourceForm ref={ref} variable={variable} />
<SystemResourceForm
ref={ref}
variable={variable}
querySourceContainer={querySourceContainer}
onQueryActiveChange={onQueryActiveChange}
/>
)}
</Flex>
</>
@@ -694,11 +674,15 @@ const VariablePreview = ({
variableType,
variableValue,
onLoadData,
queryActive,
queryContainerRef,
}: {
variable?: DataSource;
variableType: VariableType;
variableValue: unknown;
onLoadData: () => void;
queryActive: boolean;
queryContainerRef: (element: HTMLDivElement | null) => void;
}) => {
const isResource =
variableType === "resource" ||
@@ -708,12 +692,14 @@ const VariablePreview = ({
const resources = useStore($resources);
const variableValues = useStore($instanceVariableValues);
const resourcesCache = useStore($resourcesCache);
const resourceDiagnosticsCache = useStore($resourceDiagnosticsCache);
const resourceScope = useResourceScope({ variable });
let computedValue: unknown;
let resourceDiagnostics: AssetQueryPreviewDiagnostics | undefined;
if (variableType === "string" || variableType === "boolean") {
computedValue = variableValue;
} else if (variableType === "json") {
computedValue = parseDataVariableJsonExpression(String(variableValue));
computedValue = parseJsonExpression(String(variableValue));
} else if (variableType === "number") {
computedValue = Number(variableValue);
if (Number.isNaN(computedValue)) {
@@ -734,12 +720,15 @@ const VariablePreview = ({
}
}
if (parsedResourceRequest) {
computedValue = resourcesCache.get(getResourceKey(parsedResourceRequest));
const resourceKey = getResourceKey(parsedResourceRequest);
computedValue = resourcesCache.get(resourceKey);
resourceDiagnostics = resourceDiagnosticsCache.get(resourceKey);
}
}
const extensions = useMemo(() => [javascript({}), foldGutterExtension], []);
const editorProps = {
readOnly: true,
chromeless: true,
extensions,
// compute value as json lazily only when dialog is open
// by spliting into separate component which is invoked
@@ -748,7 +737,7 @@ const VariablePreview = ({
onChange: () => {},
onChangeComplete: () => {},
};
return (
const preview = (
<Grid
align="stretch"
css={{
@@ -756,6 +745,7 @@ const VariablePreview = ({
overflow: "hidden",
boxSizing: "content-box",
position: "relative",
gridTemplateRows: "minmax(0, 1fr)",
}}
>
<EditorContent {...editorProps} />
@@ -777,6 +767,23 @@ const VariablePreview = ({
)}
</Grid>
);
if (isResource === false) {
return preview;
}
const requestErrorDiagnostics = getRequestErrorDiagnostics(computedValue);
return (
<RequestInspector
queryContainerRef={queryActive ? queryContainerRef : undefined}
preview={preview}
diagnostics={
requestErrorDiagnostics !== undefined ? (
<RequestErrorDiagnostics value={requestErrorDiagnostics} />
) : resourceDiagnostics !== undefined ? (
<ContentDatabaseDiagnostics value={resourceDiagnostics} />
) : undefined
}
/>
);
};
const VariablePopoverContent = ({
@@ -790,13 +797,17 @@ const VariablePopoverContent = ({
}) => {
const hasPendingResources = useStore($hasPendingResources);
const panelRef = useRef<undefined | PanelApi>(undefined);
const [queryActive, setQueryActive] = useState(false);
const [querySourceContainer, setQuerySourceContainer] =
useState<HTMLDivElement | null>(null);
const queryContainerRef = useCallback(
(element: HTMLDivElement | null) => setQuerySourceContainer(element),
[]
);
const isSystemVariable = variable?.id === SYSTEM_VARIABLE_ID;
const [value, setValue] = useState<unknown>(() => {
if (variable?.type === "variable") {
if (
variable.value.type === "json" ||
variable.value.type === "string[]"
) {
if (variable.value.type === "json") {
return formatValue(variable.value.value);
}
return variable.value.value;
@@ -820,12 +831,7 @@ const VariablePopoverContent = ({
}
if (variable?.type === "variable") {
const type = variable.value.type;
if (
type === "string" ||
type === "number" ||
type === "boolean" ||
type === "string[]"
) {
if (type === "string" || type === "number" || type === "boolean") {
return type;
}
return "json";
@@ -849,9 +855,6 @@ const VariablePopoverContent = ({
// empty string gives an error
return prev || "{}";
}
if (variableType === "string[]") {
return "[]";
}
return prev;
});
};
@@ -887,69 +890,76 @@ const VariablePopoverContent = ({
return (
<>
<Grid
css={{
height: "100%",
gridTemplateColumns: "320px 1fr",
}}
>
<ScrollArea
// flex fixes content overflowing artificial scroll area
css={{ display: "flex", flexDirection: "column" }}
>
<form
ref={formRef}
noValidate={true}
// exclude from the flow
style={{ display: "contents" }}
onSubmit={(event) => {
event.preventDefault();
if (isSystemVariable) {
return;
}
const nameElement =
event.currentTarget.elements.namedItem("name");
// make sure only name is valid and allow to save everything else
// to avoid loosing complex configuration when closed accidentally
if (
nameElement instanceof HTMLInputElement &&
nameElement.checkValidity()
) {
const formData = new FormData(event.currentTarget);
panelRef.current?.save(formData);
// close popover whenever new variable is created
// to prevent creating duplicated variable
if (variable === undefined) {
onClose();
}
}
}}
<SplitView
defaultSize={{ value: 320, unit: "px" }}
minimumStartSize={240}
minimumEndSize={240}
separatorLabel="Resize variable configuration"
start={
<ScrollArea
// flex fixes content overflowing artificial scroll area
css={{ display: "flex", flexDirection: "column" }}
>
{/* submit is not triggered when press enter on input without submit button */}
<button hidden></button>
<fieldset
<form
ref={formRef}
noValidate={true}
// exclude from the flow
style={{ display: "contents" }}
// forbid editing system variable
disabled={isSystemVariable}
onSubmit={(event) => {
event.preventDefault();
if (isSystemVariable) {
return;
}
const nameElement =
event.currentTarget.elements.namedItem("name");
// make sure only name is valid and allow to save everything else
// to avoid loosing complex configuration when closed accidentally
if (
nameElement instanceof HTMLInputElement &&
nameElement.checkValidity()
) {
const formData = new FormData(event.currentTarget);
const saved = panelRef.current?.save(formData);
// close popover whenever new variable is created
// to prevent creating duplicated variable
if (variable === undefined && saved !== false) {
onClose();
}
}
}}
>
<VariablePanelForm
ref={panelRef}
variable={variable}
variableType={variableType}
onVariableTypeChange={updateVariableType}
value={value}
onValueChange={setValue}
/>
</fieldset>
</form>
</ScrollArea>
<VariablePreview
variable={variable}
variableType={variableType}
variableValue={value}
onLoadData={reloadData}
/>
</Grid>
{/* submit is not triggered when press enter on input without submit button */}
<button hidden></button>
<fieldset
style={{ display: "contents" }}
// forbid editing system variable
disabled={isSystemVariable}
>
<VariablePanelForm
ref={panelRef}
variable={variable}
variableType={variableType}
onVariableTypeChange={updateVariableType}
value={value}
onValueChange={setValue}
querySourceContainer={querySourceContainer}
onQueryActiveChange={setQueryActive}
/>
</fieldset>
</form>
</ScrollArea>
}
end={
<VariablePreview
variable={variable}
variableType={variableType}
variableValue={value}
onLoadData={reloadData}
queryActive={queryActive}
queryContainerRef={queryContainerRef}
/>
}
/>
<DialogTitle
maximizable
@@ -1028,6 +1038,7 @@ export const VariablePopoverTrigger = ({
return (
<FloatingPanel
maximizable
resize="both"
placement="center"
width={740}
height={480}
@@ -1,9 +1,8 @@
import { expect, test } from "vitest";
import type { Asset } from "@webstudio-is/sdk";
import { __testing__ } from "./markdown-preview";
import { renderMarkdown } from "./text-file-utils";
const { resolveAssetReferences } = __testing__;
const { renderMarkdownPreview } = __testing__;
const image: Asset = {
id: "image-id",
@@ -16,11 +15,23 @@ const image: Asset = {
createdAt: "2026-01-01T00:00:00.000Z",
};
test("resolves asset IDs in Markdown images and links", () => {
const html = resolveAssetReferences({
html: renderMarkdown(
"![Local image](image-id)\n\n[Download image](image-id)\n\n![Remote image](https://example.com/image.png)"
),
const markdown: Asset = {
id: "markdown-id",
projectId: "project-id",
type: "file",
name: "post.md",
format: "md",
size: 1,
meta: {},
createdAt: "2026-01-01T00:00:00.000Z",
};
test("resolves asset IDs and relative paths in Markdown images and links", () => {
const html = renderMarkdownPreview({
markdown:
"![Local image](image-id)\n\n[Relative image](./image.png)\n\n[Download image](image-id)\n\n![Remote image](https://example.com/image.png)",
sourceAsset: markdown,
folders: new Map(),
assetContainers: [{ status: "uploaded", asset: image }],
origin: "https://builder.example",
});
@@ -35,8 +46,10 @@ test("resolves asset IDs in Markdown images and links", () => {
});
test("uses an object URL while an inserted image is uploading", () => {
const html = resolveAssetReferences({
html: renderMarkdown("![Uploading](image-id)"),
const html = renderMarkdownPreview({
markdown: "![Uploading](image-id)",
sourceAsset: markdown,
folders: new Map(),
assetContainers: [
{
status: "uploading",
@@ -1,15 +1,16 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useMemo, type ReactNode } from "react";
import { css, SplitView, theme } from "@webstudio-is/design-system";
import { renderMarkdownHtml } from "@webstudio-is/sdk-components-react/markdown";
import { discoverNamedMarkdownAssetReferenceRanges } from "@webstudio-is/content-engine/markdown-assets";
import { rewriteMarkdownAssetReferenceRanges } from "@webstudio-is/content-engine/markdown-references";
import {
Box,
css,
numericScrubControl,
theme,
} from "@webstudio-is/design-system";
createAssetFolderHierarchy,
formatAssetName,
getAssetUrl,
type Asset,
type AssetFolders,
} from "@webstudio-is/sdk";
import type { AssetContainer } from "~/builder/shared/assets";
import { getAssetUrl } from "~/builder/shared/assets/asset-utils";
import { renderMarkdown } from "./text-file-utils";
const minimumPaneWidth = 160;
const previewStyle = css({
minWidth: 0,
@@ -68,45 +69,31 @@ const previewStyle = css({
"& a": { color: theme.colors.foregroundPrimary },
});
const splitHandleStyle = css({
position: "relative",
width: 5,
height: "100%",
cursor: "col-resize",
touchAction: "none",
outline: "none",
"&::before": {
position: "absolute",
content: '""',
top: 0,
bottom: 0,
left: 2,
width: 1,
background: theme.colors.backgroundPrimaryLight,
},
"&:hover::before, &:focus-visible::before": {
left: 1,
width: 3,
},
});
const clampRatio = (ratio: number) => Math.min(0.8, Math.max(0.2, ratio));
const resolveAssetReferences = ({
html,
const renderMarkdownPreview = ({
markdown,
sourceAsset,
folders,
assetContainers,
origin,
}: {
html: string;
markdown: string;
sourceAsset: Asset;
folders: AssetFolders;
assetContainers: AssetContainer[];
origin: string;
}) => {
if (typeof DOMParser === "undefined") {
return html;
}
const document = new DOMParser().parseFromString(html, "text/html");
const urls = new Map(
const hierarchy = createAssetFolderHierarchy(folders);
const getNamedAsset = (asset: AssetContainer["asset"]) => ({
id: asset.id,
name: formatAssetName(asset),
folderNames: hierarchy.getPath(asset.folderId).map(({ name }) => name),
});
const references = discoverNamedMarkdownAssetReferenceRanges({
markdown,
source: getNamedAsset(sourceAsset),
assets: assetContainers.map(({ asset }) => getNamedAsset(asset)),
});
const assetUrls = Object.fromEntries(
assetContainers.map((container) => [
container.asset.id,
container.status === "uploading"
@@ -114,128 +101,57 @@ const resolveAssetReferences = ({
: getAssetUrl(container.asset, origin).href,
])
);
for (const image of document.querySelectorAll("img[src]")) {
const url = urls.get(image.getAttribute("src") ?? "");
if (url !== undefined) {
image.setAttribute("src", url);
}
}
for (const link of document.querySelectorAll("a[href]")) {
const url = urls.get(link.getAttribute("href") ?? "");
if (url !== undefined) {
link.setAttribute("href", url);
}
}
return document.body.innerHTML;
return renderMarkdownHtml(
rewriteMarkdownAssetReferenceRanges({ markdown, references, assetUrls }),
{ allowBlobImages: true }
);
};
export const __testing__ = { resolveAssetReferences };
export const __testing__ = { renderMarkdownPreview };
export const MarkdownSplitView = ({
open,
source,
sourceAsset,
folders,
assetContainers,
children,
}: {
open: boolean;
source: string;
sourceAsset: Asset;
folders: AssetFolders;
assetContainers: AssetContainer[];
children: ReactNode;
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const handleRef = useRef<HTMLDivElement>(null);
const ratioRef = useRef(0.5);
const [ratio, setRatio] = useState(ratioRef.current);
const html = useMemo(() => {
if (open === false) {
return "";
}
return resolveAssetReferences({
html: renderMarkdown(source),
return renderMarkdownPreview({
markdown: source,
sourceAsset,
folders,
assetContainers,
origin: window.location.origin,
});
}, [assetContainers, open, source]);
const updateRatio = (nextRatio: number) => {
ratioRef.current = clampRatio(nextRatio);
setRatio(ratioRef.current);
};
useEffect(() => {
const handle = handleRef.current;
if (handle === null) {
return;
}
return numericScrubControl(handle, {
getInitialValue: () => {
const width = containerRef.current?.getBoundingClientRect().width ?? 0;
return width * ratioRef.current;
},
getValue: (state, movement) => {
const width = containerRef.current?.getBoundingClientRect().width ?? 0;
const paneWidth = Math.min(minimumPaneWidth, width / 2);
return Math.min(
width - paneWidth,
Math.max(paneWidth, state.value + movement)
);
},
onValueInput: (event) => {
const width = containerRef.current?.getBoundingClientRect().width ?? 0;
if (width > 0) {
updateRatio(event.value / width);
}
},
});
}, []);
}, [assetContainers, folders, open, source, sourceAsset]);
return (
<Box
ref={containerRef}
css={{
display: "grid",
height: "100%",
minHeight: 0,
}}
style={{
gridTemplateColumns: open
? `minmax(0, ${ratio}fr) 5px minmax(0, ${1 - ratio}fr)`
: "minmax(0, 1fr)",
}}
>
<Box css={{ minWidth: 0, minHeight: 0, overflow: "hidden" }}>
{children}
</Box>
<div
ref={handleRef}
className={splitHandleStyle()}
hidden={open === false}
role="separator"
aria-label="Resize Markdown preview"
aria-orientation="vertical"
aria-valuemin={20}
aria-valuemax={80}
aria-valuenow={Math.round(ratio * 100)}
tabIndex={0}
onKeyDown={(event) => {
if (event.key === "ArrowLeft") {
event.preventDefault();
updateRatio(ratioRef.current - 0.05);
}
if (event.key === "ArrowRight") {
event.preventDefault();
updateRatio(ratioRef.current + 0.05);
}
}}
/>
<div
className={previewStyle()}
hidden={open === false}
role="region"
aria-label="Markdown preview"
dangerouslySetInnerHTML={{ __html: html }}
/>
</Box>
<SplitView
defaultSize={{ value: 50, unit: "%" }}
start={children}
end={
open ? (
<div
className={previewStyle()}
role="region"
aria-label="Markdown preview"
dangerouslySetInnerHTML={{ __html: html }}
/>
) : undefined
}
separatorLabel="Resize Markdown preview"
/>
);
};
@@ -39,13 +39,17 @@ import {
TextStrikethroughIcon,
} from "@webstudio-is/icons";
import { formatAssetName } from "@webstudio-is/project-build/runtime";
import { getPagePath, type Asset } from "@webstudio-is/sdk";
import { getAssetUrl, getPagePath, type Asset } from "@webstudio-is/sdk";
import { CodeEditor } from "~/shared/code-editor";
import { EditorDialog, type EditorApi } from "~/shared/code-editor-base";
import { $assets, $pages, $props } from "~/shared/sync/data-stores";
import {
$assetFolders,
$assets,
$pages,
$props,
} from "~/shared/sync/data-stores";
import { $authPermit } from "~/shared/nano-states";
import { AssetManager } from "~/builder/shared/asset-manager";
import { getAssetUrl } from "~/builder/shared/assets/asset-utils";
import {
AssetUpload,
updateAssetContent,
@@ -423,6 +427,7 @@ export const TextFileEditor = ({
onOpenChange: (open: boolean) => void;
}) => {
const assets = useStore($assets);
const assetFolders = useStore($assetFolders);
const asset = assets.get(assetId);
const { assetContainers } = useAssets();
const canEdit = useStore($authPermit) !== "view";
@@ -517,7 +522,7 @@ export const TextFileEditor = ({
languageExtensions={getTextFileEditorExtensions(asset)}
size="full"
expandable={false}
showBorder={false}
chromeless
readOnly={canEdit === false}
onChange={(content) => {
setState({ status: "loaded", content });
@@ -575,6 +580,8 @@ export const TextFileEditor = ({
<MarkdownSplitView
open={previewOpen}
source={state.content}
sourceAsset={asset}
folders={assetFolders}
assetContainers={assetContainers}
>
{editor}
@@ -7,7 +7,6 @@ import {
import {
getTextFileEditorExtensions,
isMarkdownAsset,
renderMarkdown,
} from "./text-file-utils";
describe("text file assets", () => {
@@ -46,14 +45,4 @@ describe("text file assets", () => {
expect(isMarkdownAsset({ format: "MD" })).toBe(true);
expect(isMarkdownAsset({ format: "txt" })).toBe(false);
});
test("renders a safe GFM preview", () => {
const html = renderMarkdown(
"~~done~~\n\n| A | B |\n| - | - |\n| 1 | 2 |\n\n<script>alert(1)</script>\n\n[unsafe](javascript:alert(1))"
);
expect(html).toContain("<del>done</del>");
expect(html).toContain("<table>");
expect(html).toContain("&lt;script&gt;alert(1)&lt;/script&gt;");
expect(html).not.toContain('href="javascript:');
});
});
@@ -3,8 +3,6 @@ import { css } from "@codemirror/lang-css";
import { html } from "@codemirror/lang-html";
import { javascript } from "@codemirror/lang-javascript";
import { markdown } from "@codemirror/lang-markdown";
import { micromark } from "micromark";
import { gfm, gfmHtml } from "micromark-extension-gfm";
import {
getAssetTextEditorLanguage,
type Asset,
@@ -33,9 +31,3 @@ export const getTextFileEditorExtensions = (
export const isMarkdownAsset = (asset: Pick<Asset, "format">) =>
getAssetTextEditorLanguage(asset) === "markdown";
export const renderMarkdown = (source: string) =>
micromark(source, {
extensions: [gfm()],
htmlExtensions: [gfmHtml()],
});
@@ -0,0 +1,96 @@
import { act } from "react-dom/test-utils";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { TooltipProvider } from "@webstudio-is/design-system";
import { createAssetFolderHierarchy } from "@webstudio-is/sdk";
import {
createAssetFolderFixture,
createAssetFoldersFixture,
} from "@webstudio-is/sdk/testing";
import { AssetFolderBreadcrumbs } from "./asset-folder-breadcrumbs";
import { createAssetManagerTestRenderer } from "./test-utils";
const renderer = createAssetManagerTestRenderer();
const writeText = vi.fn();
beforeEach(() => {
vi.stubGlobal(
"ResizeObserver",
class {
observe() {}
unobserve() {}
disconnect() {}
}
);
Object.defineProperty(navigator, "clipboard", {
configurable: true,
value: { writeText },
});
Object.defineProperty(HTMLElement.prototype, "scrollIntoView", {
configurable: true,
value: vi.fn(),
});
});
afterEach(() => {
renderer.cleanup();
writeText.mockReset();
vi.unstubAllGlobals();
});
const parent = createAssetFolderFixture({ id: "parent", name: "Documents" });
const child = createAssetFolderFixture({
id: "child",
parentId: parent.id,
name: "Articles",
});
const hierarchy = createAssetFolderHierarchy(
createAssetFoldersFixture(parent, child)
);
const renderBreadcrumbs = (
selectedItem:
| { type: "folder"; id: string }
| { type: "asset"; folderId: string; name: string }
) =>
renderer.render(
<TooltipProvider>
<AssetFolderBreadcrumbs
hierarchy={hierarchy}
folderId={parent.id}
selectedItem={selectedItem}
onChange={vi.fn()}
/>
</TooltipProvider>
);
const copyPath = () => {
act(() => {
document
.querySelector<HTMLButtonElement>('[aria-label="Copy path"]')
?.click();
});
};
test("shows and copies the selected folder path", () => {
renderBreadcrumbs({ type: "folder", id: child.id });
expect(document.body.textContent).toContain("RootDocumentsArticles");
copyPath();
expect(writeText).toHaveBeenCalledWith("Root / Documents / Articles");
});
test("shows and copies the selected asset path", () => {
renderBreadcrumbs({
type: "asset",
folderId: child.id,
name: "welcome.md",
});
expect(document.body.textContent).toContain(
"RootDocumentsArticleswelcome.md"
);
copyPath();
expect(writeText).toHaveBeenCalledWith(
"Root / Documents / Articles / welcome.md"
);
});
@@ -5,13 +5,24 @@ import {
Flex,
ScrollAreaNative,
Separator,
SmallIconButton,
Text,
theme,
} from "@webstudio-is/design-system";
import type { AssetFolderHierarchy } from "@webstudio-is/sdk";
import { ChevronRightIcon } from "@webstudio-is/icons";
import { ChevronRightIcon, CopyIcon } from "@webstudio-is/icons";
import { useEffect, useRef, type ReactElement } from "react";
import { CopyToClipboard } from "~/shared/copy-to-clipboard";
import { AssetManagerItemContextMenuContent } from "./asset-manager-item-menu";
import { formatAssetFolderPath } from "./asset-folder-utils";
type SelectedItem =
| { type: "folder"; id: string }
| {
type: "asset";
folderId: string | undefined;
name: string;
};
const PasteTarget = ({
children,
@@ -40,86 +51,157 @@ const PasteTarget = ({
export const AssetFolderBreadcrumbs = ({
hierarchy,
folderId,
selectedItem,
onChange,
canPaste,
onPaste,
}: {
hierarchy: AssetFolderHierarchy;
folderId: string | undefined;
selectedItem?: SelectedItem;
onChange: (folderId: string | undefined) => void;
canPaste?: (folderId: string | undefined) => boolean;
onPaste?: (folderId: string | undefined) => void;
}) => {
const path = hierarchy.getPath(folderId);
const currentFolderRef = useRef<HTMLButtonElement>(null);
const pathFolderId =
selectedItem?.type === "folder"
? selectedItem.id
: (selectedItem?.folderId ?? folderId);
const path = hierarchy.getPath(pathFolderId);
const folderPath = formatAssetFolderPath(hierarchy, pathFolderId);
const copyPath =
selectedItem?.type === "asset"
? `${folderPath} / ${selectedItem.name}`
: folderPath;
const currentPathRef = useRef<HTMLElement | null>(null);
useEffect(() => {
currentFolderRef.current?.scrollIntoView({
currentPathRef.current?.scrollIntoView({
block: "nearest",
inline: "nearest",
});
}, [folderId]);
}, [folderId, selectedItem]);
return (
<>
<Separator />
<ScrollAreaNative
<Flex
align="center"
data-asset-folder-breadcrumbs=""
css={{
width: "100%",
overflowX: "auto",
overflowY: "hidden",
scrollbarWidth: "none",
"&::-webkit-scrollbar": { display: "none" },
flexShrink: 0,
marginBottom: `calc(-1 * ${theme.panel.paddingBlock})`,
"& [data-copy-asset-path]": { visibility: "hidden" },
"&:hover [data-copy-asset-path], &:focus-within [data-copy-asset-path]":
{ visibility: "visible" },
}}
aria-label="Asset folder path"
>
<Flex
align="center"
gap={1}
<ScrollAreaNative
css={{
minWidth: "max-content",
minHeight: theme.spacing[12],
paddingInline: theme.spacing[2],
paddingBlock: theme.spacing[2],
minWidth: 0,
flexGrow: 1,
overflowX: "auto",
overflowY: "hidden",
scrollbarWidth: "none",
"&::-webkit-scrollbar": { display: "none" },
}}
aria-label="Asset path"
>
<PasteTarget
folderId={undefined}
canPaste={canPaste}
onPaste={onPaste}
<Flex
align="center"
gap={1}
css={{
minWidth: "max-content",
minHeight: theme.spacing[12],
paddingInline: theme.spacing[2],
paddingBlock: theme.spacing[2],
}}
>
<Button
ref={folderId === undefined ? currentFolderRef : undefined}
color="ghost"
aria-current={folderId === undefined ? "location" : undefined}
onClick={() => onChange(undefined)}
<PasteTarget
folderId={undefined}
canPaste={canPaste}
onPaste={onPaste}
>
<Text css={{ minWidth: "3ch" }}>Root</Text>
</Button>
</PasteTarget>
{path.map((folder) => (
<Flex key={folder.id} align="center" shrink={false}>
<ChevronRightIcon size={12} />
<PasteTarget
folderId={folder.id}
canPaste={canPaste}
onPaste={onPaste}
<Button
ref={(element) => {
if (pathFolderId === undefined) {
currentPathRef.current = element;
}
}}
color="ghost"
aria-current={
pathFolderId === undefined && selectedItem === undefined
? "location"
: undefined
}
onClick={() => onChange(undefined)}
>
<Button
ref={folder.id === folderId ? currentFolderRef : undefined}
color="ghost"
aria-current={folder.id === folderId ? "location" : undefined}
onClick={() => onChange(folder.id)}
<Text css={{ minWidth: "3ch" }}>Root</Text>
</Button>
</PasteTarget>
{path.map((folder) => (
<Flex key={folder.id} align="center" shrink={false}>
<ChevronRightIcon size={12} />
<PasteTarget
folderId={folder.id}
canPaste={canPaste}
onPaste={onPaste}
>
<Text truncate css={{ minWidth: "3ch", maxWidth: 160 }}>
{folder.name}
</Text>
</Button>
</PasteTarget>
</Flex>
))}
<Button
ref={(element) => {
if (
folder.id === pathFolderId &&
selectedItem?.type !== "asset"
) {
currentPathRef.current = element;
}
}}
color="ghost"
aria-current={
folder.id === pathFolderId &&
selectedItem?.type !== "asset"
? "location"
: undefined
}
onClick={() => onChange(folder.id)}
>
<Text truncate css={{ minWidth: "3ch", maxWidth: 160 }}>
{folder.name}
</Text>
</Button>
</PasteTarget>
</Flex>
))}
{selectedItem?.type === "asset" && (
<Flex align="center" shrink={false}>
<ChevronRightIcon size={12} />
<Text
ref={(element) => {
currentPathRef.current = element;
}}
aria-current="location"
truncate
css={{
minWidth: "3ch",
maxWidth: 160,
paddingInline: theme.spacing[3],
}}
>
{selectedItem.name}
</Text>
</Flex>
)}
</Flex>
</ScrollAreaNative>
<Flex
data-copy-asset-path=""
shrink={false}
css={{ paddingInline: theme.spacing[2] }}
>
<CopyToClipboard text={copyPath} copyText="Copy path">
<SmallIconButton aria-label="Copy path" icon={<CopyIcon />} />
</CopyToClipboard>
</Flex>
</ScrollAreaNative>
</Flex>
</>
);
};
@@ -1,5 +1,7 @@
import type { ReactNode } from "react";
import { act } from "react-dom/test-utils";
import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { TooltipProvider } from "@webstudio-is/design-system";
import { $assetFolders } from "~/shared/sync/data-stores";
import {
AssetFolderSettingsDialog,
@@ -13,6 +15,8 @@ import {
import { createAssetManagerTestRenderer } from "./test-utils";
const renderer = createAssetManagerTestRenderer();
const render = (children: ReactNode) =>
renderer.render(<TooltipProvider>{children}</TooltipProvider>);
beforeEach(() => {
vi.stubGlobal(
@@ -44,7 +48,7 @@ test("Escape closes folder settings without closing the assets panel", () => {
const onPanelClose = vi.fn();
const onSettingsOpenChange = vi.fn();
renderer.render(
render(
<div
onKeyDown={(event) => {
if (event.key === "Escape") {
@@ -82,7 +86,7 @@ test.each([
});
$assetFolders.set(createAssetFoldersFixture(folder));
renderer.render(
render(
<AssetFolderSettingsDialog
folder={folder}
open
@@ -95,8 +99,32 @@ test.each([
}
);
test("shows the folder name and ID in settings", () => {
const folder = createAssetFolderFixture({
id: "folder-id",
name: "Documents",
});
$assetFolders.set(createAssetFoldersFixture(folder));
render(
<AssetFolderSettingsDialog folder={folder} open onOpenChange={vi.fn()} />
);
expect(
document.querySelector('label[for="asset-folder-name-folder-id"]')
?.textContent
).toBe("Name");
expect(
document.querySelector<HTMLInputElement>("#asset-folder-id-folder-id")
?.value
).toBe(folder.id);
expect(
document.querySelector('[aria-label="Copy folder ID"]')
).toBeInstanceOf(HTMLButtonElement);
});
test("focuses the folder name while the rightmost Create action is disabled", () => {
renderer.render(
render(
<CreateAssetFolderDialog
open
onOpenChange={vi.fn()}
@@ -123,7 +151,7 @@ test("moves items to the selected folder from the folder-only dialog", () => {
const onMove = vi.fn();
const onClose = vi.fn();
renderer.render(
render(
<MoveAssetManagerItemsDialog
initialFolderId={destination.id}
canMove={() => true}
@@ -16,6 +16,7 @@ import {
Grid,
InputField,
Label,
SmallIconButton,
Text,
theme,
} from "@webstudio-is/design-system";
@@ -23,9 +24,10 @@ import {
createAssetFolderHierarchy,
type AssetFolder,
} from "@webstudio-is/sdk";
import { TrashIcon } from "@webstudio-is/icons";
import { CopyIcon, TrashIcon } from "@webstudio-is/icons";
import { $assetFolders } from "~/shared/sync/data-stores";
import { executeRuntimeMutation } from "~/shared/instance-utils/data";
import { CopyToClipboard } from "~/shared/copy-to-clipboard";
import { AssetFolderSelector } from "./asset-folder-selector";
type AssetFolderFormValues = {
@@ -54,6 +56,7 @@ const AssetFolderForm = ({
initialName,
initialParentId,
excludedFolderId,
folderId,
autoFocusSubmit = false,
submitLabel,
secondaryAction,
@@ -64,6 +67,7 @@ const AssetFolderForm = ({
initialName: string;
initialParentId: string | undefined;
excludedFolderId?: string;
folderId?: string;
autoFocusSubmit?: boolean;
submitLabel: string;
secondaryAction?: ReactNode;
@@ -106,7 +110,7 @@ const AssetFolderForm = ({
return (
<Grid gap={3} css={{ padding: theme.panel.padding }}>
<Grid gap={1}>
<Label htmlFor={id}>Folder</Label>
<Label htmlFor={id}>Name</Label>
<InputField
id={id}
autoFocus={autoFocusSubmit === false}
@@ -131,6 +135,26 @@ const AssetFolderForm = ({
excludedFolderIds={excludedFolderIds}
rootLabel="Parent folder"
/>
{folderId !== undefined && (
<Grid gap={1}>
<Label htmlFor={`asset-folder-id-${folderId}`}>ID</Label>
<InputField
id={`asset-folder-id-${folderId}`}
readOnly
value={folderId}
suffix={
<Flex justify="center" css={{ paddingInline: theme.spacing[2] }}>
<CopyToClipboard text={folderId}>
<SmallIconButton
aria-label="Copy folder ID"
icon={<CopyIcon />}
/>
</CopyToClipboard>
</Flex>
}
/>
</Grid>
)}
<Flex justify="end" gap={2}>
{secondaryAction}
<Button
@@ -260,6 +284,7 @@ export const AssetFolderSettingsDialog = ({
initialName={folder.name}
initialParentId={folder.parentId}
excludedFolderId={folder.id}
folderId={folder.id}
autoFocusSubmit
submitLabel="Save"
onSubmit={save}
@@ -217,6 +217,26 @@ const createAsset = (id: string): Asset => ({
});
describe("Asset Manager multiselect interactions", () => {
test("keeps the selected asset in the path while focusing the copy action", () => {
const asset = createAsset("asset");
act(() => $assets.set(new Map([[asset.id, asset]])));
const container = renderManager();
const assetButton = getOptions(container).at(-1)!.button;
const path = container.querySelector<HTMLElement>(
'[aria-label="Asset path"]'
)!;
act(() => assetButton.focus());
expect(path.textContent).toContain("Rootasset.png");
act(() =>
container
.querySelector<HTMLButtonElement>('[aria-label="Copy path"]')
?.focus()
);
expect(path.textContent).toContain("Rootasset.png");
});
test("marquee-selects every thumbnail intersecting the pointer rectangle", () => {
const container = renderManager();
const options = getOptions(container);
@@ -32,6 +32,7 @@ import {
acceptToMimePatterns,
createAssetFolderHierarchy,
doesAssetMatchMimePatterns,
formatAssetName,
type Asset,
type AllowedFileExtension,
} from "@webstudio-is/sdk";
@@ -422,11 +423,6 @@ export const AssetManager = ({
const handleFocusChange = (item: AssetManagerSelection, focused: boolean) => {
if (focused) {
setSelection(item);
} else if (
forcedSelection === undefined &&
isSameAssetManagerSelection(selection, item)
) {
setSelection(undefined);
}
};
@@ -559,6 +555,32 @@ export const AssetManager = ({
[assetContainers]
);
const selectedBreadcrumbItem = useMemo(() => {
if (selection === undefined || forcedSelection !== undefined) {
return;
}
if (selection.type === "folder") {
return folders.has(selection.id) ? selection : undefined;
}
const asset = compatibleContainers.find(
({ asset }) => asset.id === selection.id
)?.asset;
if (asset === undefined) {
return;
}
return {
type: "asset" as const,
folderId: folderHierarchy.resolveFolderId(asset.folderId),
name: formatAssetName(asset),
};
}, [
compatibleContainers,
folderHierarchy,
folders,
forcedSelection,
selection,
]);
const normalizeItems = useCallback(
(items: readonly AssetManagerSelection[]) =>
normalizeAssetManagerItems({
@@ -953,6 +975,7 @@ export const AssetManager = ({
<AssetFolderBreadcrumbs
hierarchy={folderHierarchy}
folderId={currentFolderId}
selectedItem={selectedBreadcrumbItem}
onChange={setCurrentFolderId}
canPaste={
canManageFolders ? canPasteAssetManagerClipboard : undefined
@@ -92,6 +92,9 @@ test("uses an auto-growing textarea for the asset description", () => {
expect(document.body.textContent).toContain("Last modified");
expect(document.body.textContent).toContain("Jan 15, 2026");
expect(document.body.textContent).toContain("Feb 15, 2026");
expect(
Array.from(document.querySelectorAll("label"), (label) => label.textContent)
).toEqual(["Name", "Folder", "Description", "ID"]);
});
test("closes asset settings before replacing an asset", () => {
@@ -4,7 +4,7 @@ import { useDebouncedCallback } from "use-debounce";
import prettyBytes from "pretty-bytes";
import { computed } from "nanostores";
import { useStore } from "@nanostores/react";
import { getMimeTypeByExtension } from "@webstudio-is/sdk";
import { getAssetUrl, getMimeTypeByExtension } from "@webstudio-is/sdk";
import type { Asset, Instance } from "@webstudio-is/sdk";
import {
Box,
@@ -74,7 +74,6 @@ import {
} from "@webstudio-is/project-build/runtime";
import { AssetFolderSelector } from "./asset-folder-selector";
import { moveAssetManagerItems } from "./asset-manager-operations";
import { getAssetUrl } from "~/builder/shared/assets/asset-utils";
import { getFormattedAspectRatio } from "./utils";
import { CopyToClipboard } from "~/shared/copy-to-clipboard";
import {
@@ -478,6 +477,16 @@ const AssetSettingsContent = ({
</InputErrorsTooltip>
</Grid>
<Grid css={{ padding: theme.panel.padding, gap: 4 }}>
<AssetFolderSelector
value={asset.folderId}
onChange={moveToFolder}
rootLabel="Folder"
disabled={authPermit === "view"}
deferChangesUntilBlur
/>
</Grid>
<Grid css={{ padding: theme.panel.padding, gap: 4 }}>
<Label
htmlFor="asset-manager-description"
@@ -503,16 +512,6 @@ const AssetSettingsContent = ({
/>
</Grid>
<Grid css={{ padding: theme.panel.padding, gap: 4 }}>
<AssetFolderSelector
value={asset.folderId}
onChange={moveToFolder}
rootLabel="Folder"
disabled={authPermit === "view"}
deferChangesUntilBlur
/>
</Grid>
<Grid css={{ padding: theme.panel.padding, gap: 4 }}>
<Label htmlFor="asset-manager-id">ID</Label>
<InputField
@@ -18,6 +18,7 @@ import {
FILE_EXTENSIONS_BY_CATEGORY,
IMAGE_MIME_TYPES,
detectAssetType,
getAssetUrl,
isResizableImageFileName,
} from "@webstudio-is/sdk";
import type { MimeCategory } from "@webstudio-is/sdk";
@@ -25,7 +26,6 @@ import { draggable } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { $authPermit, $permissions } from "~/shared/nano-states";
import { replaceAsset } from "~/builder/shared/assets";
import { validateFiles } from "~/builder/shared/assets/asset-upload";
import { getAssetUrl } from "~/builder/shared/assets/asset-utils";
import { createAssetManagerClipboardActions } from "./asset-manager-clipboard";
import { setAssetManagerDragPreview } from "./asset-manager-drag-preview";
import { type AssetManagerItemActions } from "./asset-manager-item-menu";
@@ -10,12 +10,9 @@ import {
getFileExtension,
IMAGE_EXTENSIONS,
detectAssetType,
getAssetUrl,
getAssetContentHash,
} from "@webstudio-is/sdk";
import type { UploadingFileData } from "~/shared/nano-states";
import { getContentHash } from "@webstudio-is/asset-uploader";
export { getAssetUrl };
export const getImageNameAndType = (fileName: string) => {
const extractedExt = getFileExtension(fileName)?.toLowerCase();
@@ -58,7 +55,7 @@ const extractImageNameAndMimeTypeFromUrl = (url: URL) => {
export const getSha256Hash = async (data: string) => {
const encoder = new TextEncoder();
return getContentHash(encoder.encode(data));
return getAssetContentHash(encoder.encode(data));
};
const readFileAsArrayBuffer = (file: File): Promise<ArrayBuffer> =>
@@ -71,7 +68,7 @@ const readFileAsArrayBuffer = (file: File): Promise<ArrayBuffer> =>
export const getSha256HashOfFile = async (file: File) => {
const arrayBuffer = await readFileAsArrayBuffer(file);
return getContentHash(arrayBuffer);
return getAssetContentHash(arrayBuffer);
};
export const getFileUploadFingerprint = async (
@@ -20,10 +20,3 @@ export type UploadingAssetContainer = {
* Assets that can be shown in the UI
*/
export type AssetContainer = UploadedAssetContainer | UploadingAssetContainer;
export type AssetActionResponse = {
uploadedAssets?: Array<Asset>;
deduplicated?: boolean;
deletedAssets?: Array<Asset>;
errors?: string;
};
@@ -1,13 +1,14 @@
import warnOnce from "warn-once";
import invariant from "tiny-invariant";
import { getFileNameParts, type Asset } from "@webstudio-is/sdk";
import { assetsUploadsApiUrl } from "@webstudio-is/sdk/runtime";
import type { AssetUploadResult } from "@webstudio-is/protocol/asset-resource-api";
import type { AssetType, UploadTicket } from "@webstudio-is/asset-uploader";
import { Box, toast, css, theme } from "@webstudio-is/design-system";
import { sanitizeS3Key } from "@webstudio-is/asset-uploader";
import { Image, wsImageLoader } from "@webstudio-is/image";
import { restAssetsUploadPath, restAssetsPath } from "~/shared/router-utils";
import { restAssetsUploadPath } from "~/shared/router-utils";
import { fetch } from "~/shared/fetch.client";
import type { AssetActionResponse } from "~/builder/shared/assets";
import {
$authToken,
$uploadingFilesDataStore,
@@ -27,6 +28,8 @@ import {
getSha256HashOfFile,
} from "./asset-utils";
type AssetActionResponse = AssetUploadResult | { errors: string };
const safeDeleteAssets = (assetIds: Asset["id"][], projectId: string) => {
const currentProjectId = $project.get()?.id;
@@ -235,7 +238,7 @@ const submitAssetUpload = async ({
authToken: undefined | string;
uploadName: string;
fileOrUrl: File | URL;
onCompleted: (data: AssetActionResponse) => void;
onCompleted: (data: AssetUploadResult) => void;
onError: (error: string) => void;
request?: typeof fetch;
}) => {
@@ -321,7 +324,7 @@ const createUploadTicket = async ({
const authHeaders = createAssetUploadHeaders(authToken);
const metaResponse = await request(restAssetsPath(), {
const metaResponse = await request(assetsUploadsApiUrl, {
method: "POST",
body: metaFormData,
headers: authHeaders,
@@ -338,16 +341,16 @@ const createUploadTicket = async ({
const handleAfterSubmit = (
assetId: string,
data: AssetActionResponse,
data: AssetUploadResult,
projectId: string,
folderId?: string
) => {
warnOnce(
data.uploadedAssets?.length !== 1,
data.uploadedAssets.length !== 1,
"Expected exactly 1 uploaded asset"
);
const uploadedAsset = data.uploadedAssets?.[0];
const uploadedAsset = data.uploadedAssets[0];
if (uploadedAsset === undefined) {
warnOnce(true, "An uploaded asset is undefined");
@@ -0,0 +1,159 @@
/**
* @vitest-environment jsdom
*/
import { act } from "react-dom/test-utils";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, expect, test, vi } from "vitest";
import { encodeDataSourceVariable, type DataSource } from "@webstudio-is/sdk";
import { $dataSourceVariables } from "~/shared/nano-states";
import { $dataSources } from "~/shared/sync/data-stores";
import { BindableExpressionControl } from "./bindable-expression";
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
let root: Root | undefined;
afterEach(() => {
act(() => root?.unmount());
root = undefined;
document.body.innerHTML = "";
$dataSources.set(new Map());
$dataSourceVariables.set(new Map());
});
const renderControl = ({
expression,
bound,
allowBindingOverwrite,
parseValue,
onChangeValue,
}: {
expression: string;
bound: boolean;
allowBindingOverwrite?: boolean;
parseValue?: (value: string) => unknown;
onChangeValue: (value: string) => void;
}) => {
const container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
<BindableExpressionControl
expression={expression}
value="displayed"
bound={bound}
showBinding={false}
allowBindingOverwrite={allowBindingOverwrite}
parseValue={parseValue}
scope={{}}
aliases={new Map()}
onChangeValue={onChangeValue}
onChangeExpression={() => {}}
onRemove={() => {}}
renderControl={({ value, readOnly, onChangeValue }) => (
<button
type="button"
disabled={readOnly}
onClick={() => onChangeValue("changed")}
>
{value}
</button>
)}
/>
);
});
return container.querySelector("button") as HTMLButtonElement;
};
test("updates a directly bound variable without replacing its expression", () => {
const variable: DataSource = {
type: "variable",
id: "variable-id",
name: "Variable",
value: { type: "string", value: "initial" },
};
$dataSources.set(new Map([[variable.id, variable]]));
const onChangeValue = vi.fn();
const button = renderControl({
expression: encodeDataSourceVariable(variable.id),
bound: true,
onChangeValue,
});
act(() => button.click());
expect($dataSourceVariables.get().get(variable.id)).toBe("changed");
expect(onChangeValue).not.toHaveBeenCalled();
});
test("passes literal input changes to its consumer", () => {
const onChangeValue = vi.fn();
const button = renderControl({
expression: '"displayed"',
bound: false,
onChangeValue,
});
act(() => button.click());
expect(onChangeValue).toHaveBeenCalledExactlyOnceWith("changed");
});
test("parses bound values before updating the variable", () => {
const variable: DataSource = {
type: "variable",
id: "variable-id",
name: "Variable",
value: { type: "string", value: "initial" },
};
$dataSources.set(new Map([[variable.id, variable]]));
const parseValue = vi.fn((value: string) => ({ value }));
const button = renderControl({
expression: encodeDataSourceVariable(variable.id),
bound: true,
parseValue,
onChangeValue: () => {},
});
act(() => button.click());
expect(parseValue).toHaveBeenCalledExactlyOnceWith("changed");
expect($dataSourceVariables.get().get(variable.id)).toEqual({
value: "changed",
});
});
test("does not parse literal input", () => {
const parseValue = vi.fn((value: string) => ({ value }));
const onChangeValue = vi.fn();
const literalButton = renderControl({
expression: '"displayed"',
bound: false,
parseValue,
onChangeValue,
});
act(() => literalButton.click());
expect(parseValue).not.toHaveBeenCalled();
expect(onChangeValue).toHaveBeenCalledExactlyOnceWith("changed");
});
test("can keep directly bound values read-only", () => {
const variable: DataSource = {
type: "variable",
id: "variable-id",
name: "Variable",
value: { type: "string", value: "initial" },
};
$dataSources.set(new Map([[variable.id, variable]]));
const button = renderControl({
expression: encodeDataSourceVariable(variable.id),
bound: true,
allowBindingOverwrite: false,
onChangeValue: () => {},
});
expect(button.disabled).toBe(true);
});
@@ -0,0 +1,175 @@
import { useMemo, type ReactNode } from "react";
import { useStore } from "@nanostores/react";
import { atom, computed, type ReadableAtom } from "nanostores";
import { decodeDataSourceVariable } from "@webstudio-is/sdk";
import { $dataSourceVariables } from "~/shared/nano-states";
import { $dataSources } from "~/shared/sync/data-stores";
import {
BindingControl,
BindingPopover,
type BindingVariant,
} from "./binding-popover";
export type BindingState = {
overwritable: boolean;
variant: BindingVariant;
};
const updateExpressionValue = (expression: string, value: unknown) => {
const potentialVariableId = decodeDataSourceVariable(expression);
if (
potentialVariableId === undefined ||
$dataSources.get().has(potentialVariableId) === false
) {
return;
}
const dataSourceVariables = new Map($dataSourceVariables.get());
dataSourceVariables.set(potentialVariableId, value);
$dataSourceVariables.set(dataSourceVariables);
};
export const updateBindableValue = <Value,>({
expression,
value,
expressionValue = value,
onChangeValue,
}: {
expression: string | undefined;
value: Value;
expressionValue?: unknown;
onChangeValue: (value: Value) => void;
}) => {
if (expression !== undefined) {
updateExpressionValue(expression, expressionValue);
return;
}
onChangeValue(value);
};
export const useBindingState = (expression: string | undefined) => {
const $bindingState = useMemo((): ReadableAtom<BindingState> => {
if (expression === undefined) {
return atom({ overwritable: true, variant: "default" });
}
const potentialVariableId = decodeDataSourceVariable(expression);
if (potentialVariableId === undefined) {
return atom({ overwritable: false, variant: "bound" });
}
return computed(
[$dataSources, $dataSourceVariables],
(dataSources, dataSourceVariables): BindingState => {
const dataSource = dataSources.get(potentialVariableId);
if (dataSource?.type !== "variable") {
return { overwritable: false, variant: "bound" };
}
return {
overwritable: true,
variant:
dataSourceVariables.get(potentialVariableId) === undefined
? "bound"
: "overwritten",
};
}
);
}, [expression]);
return useStore($bindingState);
};
export const BindableExpressionControl = <Value,>({
expression,
value,
bound,
scope,
aliases,
validate,
showBinding = true,
allowBindingOverwrite = true,
renderControl,
parseValue = (value) => value,
onChangeValue,
onChangeExpression,
onRemove,
bindingState,
}: {
expression: string;
value: Value;
bound: boolean;
scope: Record<string, unknown>;
aliases: Map<string, string>;
validate?: (value: unknown) => string | undefined;
showBinding?: boolean;
allowBindingOverwrite?: boolean;
renderControl: (props: {
value: Value;
readOnly: boolean;
onChangeValue: (value: Value) => void;
}) => ReactNode;
parseValue?: (value: Value) => unknown;
onChangeValue: (value: Value) => void;
onChangeExpression: (expression: string) => void;
onRemove: (evaluatedValue: unknown) => void;
bindingState?: BindingState;
}) => {
if (bindingState === undefined) {
return (
<BindableExpressionControlWithState
expression={expression}
value={value}
bound={bound}
scope={scope}
aliases={aliases}
validate={validate}
showBinding={showBinding}
allowBindingOverwrite={allowBindingOverwrite}
renderControl={renderControl}
parseValue={parseValue}
onChangeValue={onChangeValue}
onChangeExpression={onChangeExpression}
onRemove={onRemove}
/>
);
}
const { overwritable, variant } = bindingState;
const readOnly =
overwritable === false || (bound && allowBindingOverwrite === false);
return (
<BindingControl>
{renderControl({
value,
readOnly,
onChangeValue: (nextValue) => {
updateBindableValue({
expression: bound ? expression : undefined,
value: nextValue,
expressionValue: bound ? parseValue(nextValue) : nextValue,
onChangeValue,
});
},
})}
{showBinding && (
<BindingPopover
scope={scope}
aliases={aliases}
validate={validate}
variant={variant}
value={expression}
onChange={onChangeExpression}
onRemove={onRemove}
/>
)}
</BindingControl>
);
};
const BindableExpressionControlWithState = <Value,>(
props: Omit<
Parameters<typeof BindableExpressionControl<Value>>[0],
"bindingState"
>
) => {
const bindingState = useBindingState(
props.bound ? props.expression : undefined
);
return <BindableExpressionControl {...props} bindingState={bindingState} />;
};
@@ -32,10 +32,8 @@ import {
Tooltip,
theme,
} from "@webstudio-is/design-system";
import {
decodeDataSourceVariable,
getExpressionIdentifiers,
} from "@webstudio-is/sdk";
import { getExpressionIdentifiers } from "@webstudio-is/expression";
import { decodeDataSourceVariable } from "@webstudio-is/sdk";
import { getExpressionErrorMessages } from "@webstudio-is/project-build/runtime";
import { $dataSourceVariables, $isDesignMode } from "~/shared/nano-states";
import {
@@ -358,6 +356,7 @@ export const BindingPopover = ({
return (
<FloatingPanel
placement="left-start"
anchor="trigger"
open={isOpen}
onOpenChange={(newOpen) => {
// handle special case for popover close
@@ -2,7 +2,10 @@ import { describe, test, expect } from "vitest";
import { CompletionContext } from "@codemirror/autocomplete";
import { EditorState } from "@codemirror/state";
import { javascript } from "@codemirror/lang-javascript";
import { allowedArrayMethods, allowedStringMethods } from "@webstudio-is/sdk";
import {
allowedArrayMethods,
allowedStringMethods,
} from "@webstudio-is/expression";
import {
completionPath,
formatValue,
@@ -28,18 +28,19 @@ import {
import { javascript } from "@codemirror/lang-javascript";
import { textVariants, css, rawTheme } from "@webstudio-is/design-system";
import {
decodeDataVariableId,
lintExpression,
allowedStringMethods,
allowedArrayMethods,
getExpressionValueKind,
} from "@webstudio-is/sdk";
} from "@webstudio-is/expression";
import { decodeDataVariableId } from "@webstudio-is/sdk";
import {
EditorContent,
EditorDialog,
EditorDialogButton,
EditorDialogControl,
type EditorApi,
getCodeEditorCssVars,
normalizeEditorValue,
} from "~/shared/code-editor-base";
import {
@@ -519,9 +520,20 @@ const emptyScope: Scope = {};
const emptyAliases: Aliases = new Map();
const wrapperStyle = css({
// 1 line is 16px
// set and max 20 lines
"--ws-code-editor-max-height": "320px",
variants: {
size: {
default: {
// 1 line is 16px; show at most 20 lines.
"--ws-code-editor-max-height": "320px",
},
full: {
...getCodeEditorCssVars({ minHeight: "100%", maxHeight: "100%" }),
height: "100%",
"& > div, & .cm-editor, & .cm-scroller": { height: "100%" },
},
},
},
defaultVariants: { size: "default" },
});
const linterTooltipTheme = EditorView.theme({
@@ -556,16 +568,20 @@ const expressionLinter = linter((view) => {
});
export const ExpressionEditor = ({
"aria-label": ariaLabel,
editorApiRef,
scope = emptyScope,
aliases = emptyAliases,
color,
size,
chromeless = false,
autoFocus = false,
readOnly = false,
value,
onChange,
onChangeComplete,
}: {
"aria-label"?: string;
editorApiRef?: RefObject<undefined | EditorApi>;
/**
* object with variables and their data to autocomplete
@@ -576,6 +592,8 @@ export const ExpressionEditor = ({
*/
aliases?: Aliases;
color?: "error";
size?: "default" | "full";
chromeless?: boolean;
autoFocus?: boolean;
readOnly?: boolean;
value?: string;
@@ -663,9 +681,11 @@ export const ExpressionEditor = ({
const content = (
<EditorContent
aria-label={ariaLabel}
editorApiRef={editorApiRef}
extensions={extensions}
invalid={color === "error"}
chromeless={chromeless}
readOnly={readOnly}
autoFocus={autoFocus}
value={expressionWithUnsetVariables}
@@ -686,8 +706,12 @@ export const ExpressionEditor = ({
/>
);
if (chromeless) {
return <div className={wrapperStyle({ size })}>{content}</div>;
}
return (
<div className={wrapperStyle.toString()}>
<div className={wrapperStyle({ size })}>
<EditorDialogControl>
{content}
<EditorDialog title="Expression Editor" content={content}>
@@ -0,0 +1,141 @@
/**
* @vitest-environment jsdom
*/
import { act } from "react-dom/test-utils";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, expect, test } from "vitest";
import type { QueryDefinition } from "@webstudio-is/query-builder";
import { BindableQueryBuilder } from "./query-builder";
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
const scope = {
$ws$system: {
pathname: "/blog/article",
page: 20,
},
};
const aliases = new Map([["$ws$system", "system"]]);
let root: Root | undefined;
afterEach(() => {
act(() => root?.unmount());
root = undefined;
document.body.innerHTML = "";
});
const renderQueryBuilder = <Query extends Record<string, unknown>>({
value,
capabilities,
}: {
value: Query;
capabilities: QueryDefinition<string, string>;
}) => {
const container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
<BindableQueryBuilder
value={value}
capabilities={capabilities}
scope={scope}
aliases={aliases}
onChange={() => {}}
/>
);
});
return container;
};
test("shows public variable names without changing the stored expression", () => {
const value = {
where: {
all: [
{
field: ["path"],
operator: "eq",
value: "$ws$system.pathname",
},
],
},
};
const capabilities = {
version: 1,
fields: [{ path: ["path"], label: "Path", types: ["string"] }],
operators: [
{
value: "eq",
label: "Equals",
types: ["string"],
input: { control: "expression", defaultValue: '""' },
},
],
source: {
fieldPathSchema: {
type: "array",
items: { type: "string" },
minItems: 1,
},
controls: [
{
type: "filter",
key: "where",
label: "Filters",
defaultValue: { all: [] },
combinators: ["all"],
limits: { conditions: 8, depth: 3 },
defaultCondition: { field: ["path"], operator: "eq" },
},
],
},
} as const satisfies QueryDefinition<string, string>;
const container = renderQueryBuilder({ value, capabilities });
const editor = container.querySelector(".cm-content");
const source = Array.from(container.querySelectorAll(".cm-content")).at(-1);
expect(editor?.textContent).toBe('"/blog/article"');
expect(source?.textContent).toContain("system.pathname");
expect(source?.textContent).not.toContain("$ws$system.pathname");
expect(value.where.all[0].value).toBe("$ws$system.pathname");
});
test("shows an evaluated value for a bound number input", () => {
const capabilities = {
version: 1,
fields: [],
operators: [],
source: {
fieldPathSchema: {
type: "array",
items: { type: "string" },
minItems: 1,
},
controls: [
{
type: "expression",
key: "limit",
label: "Limit",
defaultValue: "20",
input: "number",
integer: true,
},
],
},
} as const satisfies QueryDefinition<string, string>;
const container = renderQueryBuilder({
value: { limit: "$ws$system.page" },
capabilities,
});
const input = container.querySelector<HTMLInputElement>(
'input[aria-label="Limit"]'
);
expect(input?.value).toBe("20");
expect(input?.step).toBe("1");
expect(input?.disabled).toBe(true);
});
@@ -0,0 +1,180 @@
import { isLiteralExpression } from "@webstudio-is/expression";
import { Grid, InputField } from "@webstudio-is/design-system";
import { createPortal } from "react-dom";
import {
StructuredQueryBuilder,
type QueryValueEditorProps,
} from "@webstudio-is/query-builder-react";
import type { QueryDefinition } from "@webstudio-is/query-builder";
import { evaluateExpressionWithinScope } from "./binding-popover";
import { BindableExpressionControl } from "./bindable-expression";
import { ExpressionEditor, formatValue } from "./expression-editor";
const BoundExpression = ({
"aria-label": label,
value,
scope,
aliases,
onChange,
input,
min,
max,
exclusiveMin,
exclusiveMax,
integer,
}: QueryValueEditorProps & {
scope: Record<string, unknown>;
aliases: Map<string, string>;
}) => {
const bound = isLiteralExpression(value) === false;
const evaluatedValue = evaluateExpressionWithinScope(value, scope);
if (input === "number") {
const number = Number(evaluatedValue);
const displayedValue = Number.isNaN(number) ? "" : String(number);
return (
<BindableExpressionControl
expression={value}
value={displayedValue}
bound={bound}
scope={scope}
aliases={aliases}
validate={(value) => {
if (value !== undefined && typeof value !== "number") {
return `${label} expects a number value`;
}
if (typeof value !== "number") {
return;
}
if (integer && Number.isInteger(value) === false) {
return `${label} expects an integer value`;
}
if (
min !== undefined &&
(exclusiveMin ? value <= min : value < min)
) {
return `${label} expects a value ${exclusiveMin ? "greater than" : "greater than or equal to"} ${min}`;
}
if (
max !== undefined &&
(exclusiveMax ? value >= max : value > max)
) {
return `${label} expects a value ${exclusiveMax ? "less than" : "less than or equal to"} ${max}`;
}
}}
parseValue={Number}
onChangeValue={onChange}
onChangeExpression={onChange}
onRemove={(value) => onChange(String(Number(value) || 0))}
renderControl={({ value, readOnly, onChangeValue }) => (
<InputField
aria-label={label}
type="number"
min={min}
max={max}
step={integer === true ? 1 : "any"}
disabled={readOnly}
value={value}
onChange={(event) => onChangeValue(event.target.value)}
/>
)}
/>
);
}
const displayedValue = bound ? formatValue(evaluatedValue) : value;
return (
<BindableExpressionControl
expression={value}
value={displayedValue}
bound={bound}
scope={scope}
aliases={aliases}
parseValue={(value) => evaluateExpressionWithinScope(value, {})}
onChangeValue={onChange}
onChangeExpression={onChange}
onRemove={(value) => onChange(JSON.stringify(value) ?? "undefined")}
renderControl={({ value, readOnly, onChangeValue }) => {
const updateValue = (nextValue: string) => {
if (bound && isLiteralExpression(nextValue) === false) {
return;
}
onChangeValue(nextValue);
};
return (
<div>
<ExpressionEditor
aria-label={label}
readOnly={readOnly}
value={value}
onChange={updateValue}
onChangeComplete={updateValue}
/>
</div>
);
}}
/>
);
};
export const BindableQueryBuilder = <
FieldType extends string,
Operator extends string,
Query extends Record<string, unknown>,
>({
value,
capabilities,
scope,
aliases,
sourceContainer,
sectionPaddingInline,
onChange,
}: {
value: Query;
capabilities: QueryDefinition<FieldType, Operator>;
scope: Record<string, unknown>;
aliases: Map<string, string>;
sourceContainer?: Element | null;
sectionPaddingInline?: string;
onChange: (value: Query) => void;
}) => (
<StructuredQueryBuilder<FieldType, Operator, Query>
value={value}
capabilities={capabilities}
sectionPaddingInline={sectionPaddingInline}
editors={{
expression: (props) => (
<BoundExpression {...props} scope={scope} aliases={aliases} />
),
source: (props) => (
<ExpressionEditor
{...props}
scope={scope}
aliases={aliases}
size={sourceContainer === undefined ? "default" : "full"}
chromeless={sourceContainer !== undefined}
/>
),
}}
renderSource={
sourceContainer === undefined
? undefined
: (source) =>
sourceContainer === null
? null
: createPortal(
<Grid
css={{
height: "100%",
minHeight: 0,
gridTemplateRows: "minmax(0, 1fr) auto",
}}
>
{source}
</Grid>,
sourceContainer
)
}
onChange={onChange}
/>
);
@@ -1,66 +1,54 @@
import { json, type ActionFunctionArgs } from "@remix-run/server-runtime";
import {
AssetRevisionConflictError,
updateAssetContent,
} from "@webstudio-is/asset-uploader/index.server";
import type { Asset } from "@webstudio-is/sdk";
import { createAssetClient } from "~/shared/asset-client";
import { createContext } from "~/shared/context.server";
import { assetResourceApiOperations } from "@webstudio-is/protocol/asset-resource-api";
import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
import { checkCsrf } from "~/services/csrf-session.server";
import { parseError } from "~/shared/error/error-parse";
import { privateNoStoreResponseHeaders } from "~/services/cache-control.server";
import { createAssetContentLoader } from "~/services/asset-rest-route-handlers.server";
import { ensureApiCsrf } from "~/services/api-auth.server";
import {
assetRestErrorResponse,
assetRestMethodNotAllowed,
createAssetRestRepository,
parseAssetRestFilename,
parseAssetRestIdentifier,
requireAssetRestBody,
} from "~/services/asset-rest.server";
export type AssetContentActionResponse = { asset: Asset } | { errors: string };
export const loader = createAssetContentLoader();
export const action = async ({ request, params }: ActionFunctionArgs) => {
preventCrossOriginCookie(request);
if (request.headers.has("x-auth-token") === false) {
await checkCsrf(request);
if (
request.method.toLowerCase() !==
assetResourceApiOperations.replaceAssetContent.method
) {
return assetRestMethodNotAllowed([
assetResourceApiOperations.replaceAssetContent,
]);
}
await ensureApiCsrf(request);
try {
if (request.method !== "PUT" || request.body === null) {
return json(
{ errors: "Method not allowed" } satisfies AssetContentActionResponse,
{ status: 405, headers: privateNoStoreResponseHeaders }
);
}
if (params.assetId === undefined) {
throw new Error("Asset id is required");
}
const assetId = parseAssetRestIdentifier(params.assetId);
const url = new URL(request.url);
const projectId = url.searchParams.get("projectId");
const expectedName = url.searchParams.get("expectedName");
if (projectId === null || expectedName === null) {
throw new Error("Project id and expected asset name are required");
}
const context = await createContext(request);
const asset = await updateAssetContent(
{
assetId: params.assetId,
projectId,
expectedName,
data: request.body,
},
createAssetClient(),
context
const expectedName = parseAssetRestFilename(
url.searchParams.get("expectedName")
);
return json({ asset } satisfies AssetContentActionResponse, {
headers: privateNoStoreResponseHeaders,
const asset = await (
await createAssetRestRepository(request, "edit")
).updateContent({
assetId,
expectedName,
data: requireAssetRestBody(request),
});
} catch (error) {
console.error(error);
return json(
{ asset },
{
errors: parseError(error).message,
} satisfies AssetContentActionResponse,
{
status: error instanceof AssetRevisionConflictError ? 409 : 400,
headers: privateNoStoreResponseHeaders,
}
);
} catch (error) {
return assetRestErrorResponse(error);
}
};
@@ -0,0 +1,29 @@
import { json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { assetResourceApiOperations } from "@webstudio-is/protocol/asset-resource-api";
import { privateNoStoreResponseHeaders } from "~/services/cache-control.server";
import { createAssetAction } from "~/services/asset-rest-route-handlers.server";
import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
import {
assetRestErrorResponse,
assetRestMethodNotAllowed,
createAssetRestRepository,
parseAssetRestIdentifier,
} from "~/services/asset-rest.server";
export const action = createAssetAction();
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
preventCrossOriginCookie(request);
if (
request.method.toLowerCase() !== assetResourceApiOperations.getAsset.method
) {
return assetRestMethodNotAllowed([assetResourceApiOperations.getAsset]);
}
try {
const assetId = parseAssetRestIdentifier(params.assetId);
const asset = await (await createAssetRestRepository(request)).get(assetId);
return json({ asset }, { headers: privateNoStoreResponseHeaders });
} catch (error) {
return assetRestErrorResponse(error);
}
};
@@ -0,0 +1,5 @@
import { createAssetDescriptionLoader } from "~/shared/$resources/assets-description.server";
export const loader = createAssetDescriptionLoader({
createValue: (catalog) => catalog,
});
@@ -0,0 +1,73 @@
import {
json,
type ActionFunctionArgs,
type LoaderFunctionArgs,
} from "@remix-run/server-runtime";
import {
assetFolderUpdateRequest,
assetResourceApiOperations,
} from "@webstudio-is/protocol/asset-resource-api";
import { ensureApiCsrf } from "~/services/api-auth.server";
import {
assetRestErrorResponse,
assetRestMethodNotAllowed,
createAssetRestRepository,
parseAssetRestIdentifier,
readAssetRestJson,
} from "~/services/asset-rest.server";
import { privateNoStoreResponseHeaders } from "~/services/cache-control.server";
import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
preventCrossOriginCookie(request);
if (
request.method.toLowerCase() !==
assetResourceApiOperations.getAssetFolder.method
) {
return assetRestMethodNotAllowed([
assetResourceApiOperations.getAssetFolder,
]);
}
try {
const folderId = parseAssetRestIdentifier(params.folderId);
const folder = await (
await createAssetRestRepository(request, "view")
).getFolder(folderId);
return json({ folder }, { headers: privateNoStoreResponseHeaders });
} catch (error) {
return assetRestErrorResponse(error);
}
};
export const action = async ({ request, params }: ActionFunctionArgs) => {
preventCrossOriginCookie(request);
const method = request.method.toLowerCase();
if (
method !== assetResourceApiOperations.updateAssetFolder.method &&
method !== assetResourceApiOperations.deleteAssetFolder.method
) {
return assetRestMethodNotAllowed([
assetResourceApiOperations.updateAssetFolder,
assetResourceApiOperations.deleteAssetFolder,
]);
}
await ensureApiCsrf(request);
try {
const folderId = parseAssetRestIdentifier(params.folderId);
const repository = await createAssetRestRepository(request, "edit");
if (method === assetResourceApiOperations.updateAssetFolder.method) {
const folder = await repository.updateFolder(
folderId,
assetFolderUpdateRequest.parse(await readAssetRestJson(request))
);
return json({ folder }, { headers: privateNoStoreResponseHeaders });
}
await repository.deleteFolder(folderId);
return new Response(null, {
status: 204,
headers: privateNoStoreResponseHeaders,
});
} catch (error) {
return assetRestErrorResponse(error);
}
};
@@ -0,0 +1,64 @@
import {
json,
type ActionFunctionArgs,
type LoaderFunctionArgs,
} from "@remix-run/server-runtime";
import {
assetFolderCreateRequest,
assetResourceApiOperations,
} from "@webstudio-is/protocol/asset-resource-api";
import { ensureApiCsrf } from "~/services/api-auth.server";
import {
assetRestErrorResponse,
assetRestMethodNotAllowed,
createAssetRestRepository,
readAssetRestJson,
} from "~/services/asset-rest.server";
import { privateNoStoreResponseHeaders } from "~/services/cache-control.server";
import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
export const loader = async ({ request }: LoaderFunctionArgs) => {
preventCrossOriginCookie(request);
if (
request.method.toLowerCase() !==
assetResourceApiOperations.listAssetFolders.method
) {
return assetRestMethodNotAllowed([
assetResourceApiOperations.listAssetFolders,
]);
}
try {
const folders = await (
await createAssetRestRepository(request, "view")
).listFolders();
return json({ folders }, { headers: privateNoStoreResponseHeaders });
} catch (error) {
return assetRestErrorResponse(error);
}
};
export const action = async ({ request }: ActionFunctionArgs) => {
preventCrossOriginCookie(request);
if (
request.method.toLowerCase() !==
assetResourceApiOperations.createAssetFolder.method
) {
return assetRestMethodNotAllowed([
assetResourceApiOperations.createAssetFolder,
]);
}
await ensureApiCsrf(request);
try {
const folder = await (
await createAssetRestRepository(request, "edit")
).createFolder(
assetFolderCreateRequest.parse(await readAssetRestJson(request))
);
return json(
{ folder },
{ status: 201, headers: privateNoStoreResponseHeaders }
);
} catch (error) {
return assetRestErrorResponse(error);
}
};
@@ -0,0 +1,3 @@
import { createAssetIndexRefreshAction } from "~/services/asset-rest-route-handlers.server";
export const action = createAssetIndexRefreshAction();
@@ -0,0 +1,13 @@
import { createAssetResourceOpenApi } from "@webstudio-is/protocol/asset-resource-api";
import { createStaticAssetDescriptionLoader } from "~/shared/$resources/assets-description.server";
import { builderSessionCookieName } from "~/services/builder-session.server";
import { assetsQuerySchemaApiUrl } from "@webstudio-is/sdk/runtime";
export const loader = createStaticAssetDescriptionLoader({
createValue: ({ projectId }) =>
createAssetResourceOpenApi({
builderSessionCookieName,
querySchemaReference: `${assetsQuerySchemaApiUrl}?projectId=${encodeURIComponent(projectId)}`,
}),
contentType: "application/vnd.oai.openapi+json;version=3.1",
});
@@ -0,0 +1,7 @@
import { createAssetQueryJsonSchema } from "@webstudio-is/protocol/asset-resource-api";
import { createAssetDescriptionLoader } from "~/shared/$resources/assets-description.server";
export const loader = createAssetDescriptionLoader({
createValue: (catalog) => createAssetQueryJsonSchema({ catalog }),
contentType: "application/schema+json",
});
@@ -0,0 +1,14 @@
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { assetResourceApiOperations } from "@webstudio-is/protocol/asset-resource-api";
import { executeAssetQuery } from "../shared/$resources/assets-query.server";
import { assetRestMethodNotAllowed } from "~/services/asset-rest.server";
export const action = async ({ request }: ActionFunctionArgs) => {
if (
request.method.toLowerCase() !==
assetResourceApiOperations.queryAssets.method
) {
return assetRestMethodNotAllowed([assetResourceApiOperations.queryAssets]);
}
return await executeAssetQuery({ request, resourceRequest: request });
};
+21 -66
View File
@@ -1,72 +1,27 @@
import { json, type ActionFunctionArgs } from "@remix-run/server-runtime";
import {
createUploadTicket,
isContentHash,
} from "@webstudio-is/asset-uploader/index.server";
import isValidFilename from "valid-filename";
import { createContext } from "~/shared/context.server";
import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
import { checkCsrf } from "~/services/csrf-session.server";
import { parseError } from "~/shared/error/error-parse";
import { json, type LoaderFunctionArgs } from "@remix-run/server-runtime";
import { assetResourceApiOperations } from "@webstudio-is/protocol/asset-resource-api";
import { privateNoStoreResponseHeaders } from "~/services/cache-control.server";
import {
assetRestErrorResponse,
assetRestMethodNotAllowed,
createAssetRestRepository,
} from "~/services/asset-rest.server";
import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
export const loader = async () => {
return json(
{ errors: "Method not allowed" },
{ status: 405, headers: privateNoStoreResponseHeaders }
);
};
export const action = async (props: ActionFunctionArgs) => {
export const loader = async ({ request }: LoaderFunctionArgs) => {
preventCrossOriginCookie(request);
if (
request.method.toLowerCase() !==
assetResourceApiOperations.listAssets.method
) {
return assetRestMethodNotAllowed([assetResourceApiOperations.listAssets]);
}
try {
preventCrossOriginCookie(props.request);
await checkCsrf(props.request);
const { request } = props;
const context = await createContext(request);
if (request.method === "POST") {
const formData = await request.formData();
const projectId = formData.get("projectId");
const type = formData.get("type");
const filename = formData.get("filename");
const displayFilename = formData.get("displayFilename");
const contentHash = formData.get("contentHash");
if (
typeof projectId !== "string" ||
typeof type !== "string" ||
typeof filename !== "string" ||
(contentHash !== null && isContentHash(contentHash) === false) ||
(displayFilename !== null &&
(typeof displayFilename !== "string" ||
isValidFilename(displayFilename) === false))
) {
throw Error("Project id, type or filename are invalid");
}
const ticket = await createUploadTicket(
{
projectId,
type,
filename,
displayFilename: displayFilename ?? undefined,
contentHash: contentHash ?? undefined,
},
context
);
return json(ticket, { headers: privateNoStoreResponseHeaders });
}
return json(
{ errors: "Method not allowed" },
{ status: 405, headers: privateNoStoreResponseHeaders }
);
const assets = await (
await createAssetRestRepository(request, "view")
).list();
return json({ assets }, { headers: privateNoStoreResponseHeaders });
} catch (error) {
console.error(error);
return json(
{ errors: parseError(error).message },
{ headers: privateNoStoreResponseHeaders }
);
return assetRestErrorResponse(error);
}
};
@@ -0,0 +1,71 @@
import { json, type ActionFunctionArgs } from "@remix-run/server-runtime";
import isValidFilename from "valid-filename";
import {
assetResourceApiOperations,
assetUploadReservationRequest,
} from "@webstudio-is/protocol/asset-resource-api";
import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
import { privateNoStoreResponseHeaders } from "~/services/cache-control.server";
import { ensureApiCsrf } from "~/services/api-auth.server";
import {
AssetRestRequestError,
assetRestErrorResponse,
assetRestMethodNotAllowed,
createAssetRestRepositoryForProject,
readAssetRestFormData,
readAssetRestJson,
} from "~/services/asset-rest.server";
// Keep upload commands under an explicit collection so read-only Assets API
// operations can use stable names without colliding with uploaded filenames.
export const loader = async () => {
return assetRestMethodNotAllowed([
assetResourceApiOperations.reserveAssetUpload,
]);
};
export const action = async (props: ActionFunctionArgs) => {
const { request } = props;
preventCrossOriginCookie(request);
if (
request.method.toLowerCase() !==
assetResourceApiOperations.reserveAssetUpload.method
) {
return assetRestMethodNotAllowed([
assetResourceApiOperations.reserveAssetUpload,
]);
}
await ensureApiCsrf(request);
try {
const contentType = request.headers.get("content-type") ?? "";
const rawInput = contentType.includes("application/json")
? await readAssetRestJson(request)
: Object.fromEntries(await readAssetRestFormData(request));
const input = assetUploadReservationRequest.parse(rawInput);
if (
input.displayFilename !== undefined &&
isValidFilename(input.displayFilename) === false
) {
throw new AssetRestRequestError(
"Project id, type or filename are invalid"
);
}
const repository = await createAssetRestRepositoryForProject(
request,
input.projectId,
"edit"
);
const ticket = await repository.createUploadTicket({
type: input.type,
filename: input.filename,
displayFilename: input.displayFilename,
description: input.description,
folderId: input.folderId,
contentHash: input.contentHash,
});
return json(ticket, { headers: privateNoStoreResponseHeaders });
} catch (error) {
return assetRestErrorResponse(error);
}
};
@@ -0,0 +1,186 @@
import { json, type ActionFunctionArgs } from "@remix-run/server-runtime";
import { arrayBuffer } from "node:stream/consumers";
import {
createSizeLimiter,
assetDataOverride,
PostgresAssetRepository,
} from "@webstudio-is/asset-uploader/server";
import { isAssetFileName } from "@webstudio-is/protocol";
import {
assetResourceApiOperations,
type AssetUploadResult,
} from "@webstudio-is/protocol/asset-resource-api";
import { getAssetContentHash, type Asset } from "@webstudio-is/sdk";
import {
createAssetClient,
getMaxAssetUploadSize,
} from "~/shared/asset-client";
import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
import { privateNoStoreResponseHeaders } from "~/services/cache-control.server";
import { ensureApiCsrf } from "~/services/api-auth.server";
import { createContext } from "~/shared/context.server";
import {
AssetRestRequestError,
assetRestErrorResponse,
assetRestMethodNotAllowed,
createAssetRestRepositoryForProject,
parseAssetRestDescription,
parseAssetRestFilename,
parseAssetRestIdentifier,
parseAssetRestMetadataHeader,
requireAssetRestBody,
} from "~/services/asset-rest.server";
import {
getAssetInfoFallback,
getBrowserAssetFormat,
parseAssetType,
} from "@webstudio-is/project-build/runtime";
import { getBrowserUploadBody } from "~/services/asset-upload.server";
// The explicit uploads segment prevents asset names such as `query` from
// shadowing the read-only Assets API.
const assetUploadResponse = ({
asset,
deduplicated,
}: {
asset: Asset;
deduplicated: boolean;
}) => {
return json(
{
uploadedAssets: [asset],
deduplicated,
} satisfies AssetUploadResult,
{
headers: privateNoStoreResponseHeaders,
}
);
};
const createRequestBody = (data: Uint8Array) =>
new Blob([data as Uint8Array<ArrayBuffer>]).stream();
const readRequestBody = async (
body: ReadableStream<Uint8Array>,
name: string
) =>
new Uint8Array(
await arrayBuffer(
createSizeLimiter(
getMaxAssetUploadSize(),
name
)(body as unknown as AsyncIterable<Uint8Array>)
)
);
export const action = async (props: ActionFunctionArgs) => {
preventCrossOriginCookie(props.request);
const { request, params } = props;
if (
request.method.toLowerCase() !==
assetResourceApiOperations.uploadAssetContent.method
) {
return assetRestMethodNotAllowed([
assetResourceApiOperations.uploadAssetContent,
]);
}
const url = new URL(request.url);
const projectId = url.searchParams.get("projectId");
const folderIdValue = url.searchParams.get("folderId");
const rawAssetMeta = request.headers.get("x-webstudio-asset-meta");
const rawAssetType = url.searchParams.get("type");
const isApiUpload = projectId !== null || rawAssetType !== null;
await ensureApiCsrf(request);
try {
if (params.name === undefined) {
throw new AssetRestRequestError("Asset name is required");
}
const requestBody = requireAssetRestBody(request);
const assetType = parseAssetType(rawAssetType);
if (isApiUpload) {
const filename = parseAssetRestFilename(params.name);
if (isAssetFileName(filename) === false) {
throw new AssetRestRequestError("Asset name is invalid");
}
const parsedProjectId = parseAssetRestIdentifier(projectId);
const folderId =
folderIdValue === null
? undefined
: parseAssetRestIdentifier(folderIdValue);
const description = parseAssetRestDescription(
request.headers.get("x-webstudio-asset-description")
);
if (assetType === undefined) {
throw new AssetRestRequestError("Asset type is invalid");
}
const assetInfoFallback = getAssetInfoFallback({
format:
assetType === "image"
? (url.searchParams.get("format") ?? undefined)
: undefined,
searchParams: url.searchParams,
});
const assetInfoOverride = assetDataOverride.parse({
format: url.searchParams.get("format") ?? undefined,
meta: parseAssetRestMetadataHeader(rawAssetMeta),
});
const repository = await createAssetRestRepositoryForProject(
request,
parsedProjectId,
"edit"
);
const data = await readRequestBody(requestBody, params.name);
const force = url.searchParams.get("force") === "true";
const ticket = await repository.createUploadTicket({
type: assetType,
filename,
description,
folderId,
contentHash: force ? undefined : await getAssetContentHash(data),
});
if (ticket.deduplicated) {
return assetUploadResponse({ asset: ticket.asset, deduplicated: true });
}
const asset = await repository.completeUpload({
name: ticket.name,
data: createRequestBody(data),
assetInfoFallback,
assetDataOverride: assetInfoOverride,
assetId: ticket.assetId,
});
return assetUploadResponse({ asset, deduplicated: false });
}
const contentType = request.headers.get("Content-Type");
const body = await getBrowserUploadBody(request, contentType);
const format = getBrowserAssetFormat({ contentType, name: params.name });
const assetInfoFallback = getAssetInfoFallback({
format,
searchParams: url.searchParams,
});
const context = await createContext(request);
const assetClient = createAssetClient();
const repository = await PostgresAssetRepository.forUpload({
name: params.name,
context,
assetStore: assetClient,
});
const asset = await repository.completeUpload({
name: params.name,
data: body,
assetInfoFallback,
});
return assetUploadResponse({ asset, deduplicated: false });
} catch (error) {
return assetRestErrorResponse(error);
}
};
@@ -1,222 +0,0 @@
import { json, type ActionFunctionArgs } from "@remix-run/server-runtime";
import { arrayBuffer } from "node:stream/consumers";
import {
createUploadTicket,
createSizeLimiter,
assetDataOverride,
getContentHash,
uploadFile,
type UploadErrorCleanup,
} from "@webstudio-is/asset-uploader/index.server";
import { isAssetFileName } from "@webstudio-is/protocol";
import type { Asset } from "@webstudio-is/sdk";
import type { AssetActionResponse } from "~/builder/shared/assets";
import {
createAssetClient,
getMaxAssetUploadSize,
} from "~/shared/asset-client";
import { createContext } from "~/shared/context.server";
import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
import { checkCsrf } from "~/services/csrf-session.server";
import { parseError } from "~/shared/error/error-parse";
import { privateNoStoreResponseHeaders } from "~/services/cache-control.server";
import { assertApiProjectPermit } from "~/services/api-permits.server";
import {
getAssetInfoFallback,
getBrowserAssetFormat,
parseAssetType,
type AssetInfoFallback,
} from "@webstudio-is/project-build/runtime";
import { getBrowserUploadBody } from "~/services/asset-upload.server";
const createAssetUploadResponse = async ({
body,
context,
name,
assetInfoFallback,
assetInfoOverride,
onUploadError,
}: {
body: ReadableStream<Uint8Array>;
context: Awaited<ReturnType<typeof createContext>>;
name: string;
assetInfoFallback: AssetInfoFallback;
assetInfoOverride?: {
format?: string;
meta?: Record<string, unknown>;
};
onUploadError?: UploadErrorCleanup;
}) => {
const asset = await uploadFile(
name,
body,
createAssetClient(),
context,
assetInfoFallback,
assetInfoOverride,
onUploadError
);
return json(
{
uploadedAssets: [asset],
deduplicated: false,
} satisfies AssetActionResponse,
{
headers: privateNoStoreResponseHeaders,
}
);
};
const createDeduplicatedAssetResponse = (asset: Asset) => {
return json(
{
uploadedAssets: [asset],
deduplicated: true,
} satisfies AssetActionResponse,
{
headers: privateNoStoreResponseHeaders,
}
);
};
const createRequestBody = (data: Uint8Array) =>
new Blob([data as Uint8Array<ArrayBuffer>]).stream();
const readRequestBody = async (
body: ReadableStream<Uint8Array>,
name: string
) =>
new Uint8Array(
await arrayBuffer(
createSizeLimiter(
getMaxAssetUploadSize(),
name
)(body as unknown as AsyncIterable<Uint8Array>)
)
);
const createApiUploadErrorCleanup =
(assetId: string, projectId: string): UploadErrorCleanup =>
async (name, context) => {
const deleteAsset = await context.postgrest.client
.from("Asset")
.delete()
.eq("id", assetId)
.eq("projectId", projectId);
if (deleteAsset.error) {
throw deleteAsset.error;
}
const deleteFile = await context.postgrest.client
.from("File")
.delete()
.eq("name", name);
if (deleteFile.error) {
throw deleteFile.error;
}
};
export const action = async (props: ActionFunctionArgs) => {
preventCrossOriginCookie(props.request);
const { request, params } = props;
if (params.name === undefined) {
throw new Error("Name is undefined");
}
const url = new URL(request.url);
const projectId = url.searchParams.get("projectId");
const folderId = url.searchParams.get("folderId") ?? undefined;
const description =
request.headers.get("x-webstudio-asset-description") ?? undefined;
const rawAssetMeta = request.headers.get("x-webstudio-asset-meta");
const rawAssetType = url.searchParams.get("type");
const isApiUpload = projectId !== null || rawAssetType !== null;
if (isApiUpload === false) {
await checkCsrf(request);
}
try {
if (request.method !== "POST" || request.body === null) {
return json(
{ errors: "Method not allowed" } satisfies AssetActionResponse,
{ status: 405, headers: privateNoStoreResponseHeaders }
);
}
const assetType = parseAssetType(rawAssetType);
if (isApiUpload) {
if (isAssetFileName(params.name) === false) {
throw new Error("Asset name is invalid");
}
if (projectId === null) {
throw new Error("Project id is required");
}
if (assetType === undefined) {
throw new Error("Asset type is invalid");
}
const assetInfoFallback = getAssetInfoFallback({
format:
assetType === "image"
? (url.searchParams.get("format") ?? undefined)
: undefined,
searchParams: url.searchParams,
});
const assetInfoOverride = assetDataOverride.parse({
format: url.searchParams.get("format") ?? undefined,
meta: rawAssetMeta === null ? undefined : JSON.parse(rawAssetMeta),
});
const context = await createContext(request);
await assertApiProjectPermit(context, projectId, "build");
const data = await readRequestBody(request.body, params.name);
const force = url.searchParams.get("force") === "true";
const ticket = await createUploadTicket(
{
projectId,
type: assetType,
filename: params.name,
description,
folderId,
contentHash: force ? undefined : await getContentHash(data),
},
context
);
if (ticket.deduplicated) {
return createDeduplicatedAssetResponse(ticket.asset);
}
return await createAssetUploadResponse({
body: createRequestBody(data),
context,
name: ticket.name,
assetInfoFallback,
assetInfoOverride,
onUploadError: createApiUploadErrorCleanup(ticket.assetId, projectId),
});
}
const contentType = request.headers.get("Content-Type");
const body = await getBrowserUploadBody(request, contentType);
const format = getBrowserAssetFormat({ contentType, name: params.name });
const assetInfoFallback = getAssetInfoFallback({
format,
searchParams: url.searchParams,
});
const context = await createContext(request);
return await createAssetUploadResponse({
body,
context,
name: params.name,
assetInfoFallback,
});
} catch (error) {
console.error(error);
return json(
{ errors: parseError(error).message } satisfies AssetActionResponse,
{ headers: privateNoStoreResponseHeaders }
);
}
};
@@ -2,9 +2,12 @@ import { z } from "zod";
import { type ActionFunctionArgs, data, json } from "@remix-run/server-runtime";
import { type ResourceRequest, resourceRequest } from "@webstudio-is/sdk";
import { isLocalResource, loadResource } from "@webstudio-is/sdk/runtime";
import { parseBuilderUrl } from "@webstudio-is/protocol";
import { loader as siteMapLoader } from "../shared/$resources/sitemap.xml.server";
import { loader as currentDateLoader } from "../shared/$resources/current-date.server";
import { loader as assetsLoader } from "../shared/$resources/assets.server";
import { loader as assetsFieldCatalogLoader } from "./rest.assets.field-catalog";
import { executeAssetQuery } from "../shared/$resources/assets-query.server";
import { loader as assetsOpenApiLoader } from "./rest.assets.openapi[.]json";
import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
import { checkCsrf } from "~/services/csrf-session.server";
import { getResourceKey } from "~/shared/resources";
@@ -29,13 +32,23 @@ export const action = async ({ request }: ActionFunctionArgs) => {
}
if (isLocalResource(input, "assets")) {
return assetsLoader({ request });
const resourceRequest = new Request(new URL(input, request.url), init);
return executeAssetQuery({ request, resourceRequest });
}
if (isLocalResource(input, "assets/field-catalog")) {
return assetsFieldCatalogLoader({ request });
}
if (isLocalResource(input, "assets/openapi.json")) {
return assetsOpenApiLoader({ request });
}
return fetch(input, init);
};
const requestJson = await request.json();
const { sourceOrigin } = parseBuilderUrl(request.url);
const requestList = z.array(z.unknown()).safeParse(requestJson);
if (requestList.success === false) {
@@ -62,7 +75,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
}
return [
getResourceKey(resource.data),
await loadResource(customFetch, resource.data),
await loadResource(customFetch, resource.data, sourceOrigin),
];
})
);
+3 -13
View File
@@ -3,27 +3,17 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime";
import { createContext, isServiceAuthorization } from "~/shared/context.server";
import { appRouter } from "~/services/trcp-router.server";
import { preventCrossOriginCookie } from "~/services/no-cross-origin-cookie";
import { checkCsrf } from "~/services/csrf-session.server";
import { getTrpcResponseMeta } from "~/services/trpc-response-meta.server";
import { isCliApiRequest } from "~/services/trpc-request.server";
import { ensureApiCsrf } from "~/services/api-auth.server";
const isServiceRequest = (request: Request) => {
return isServiceAuthorization(request.headers.get("Authorization"));
};
const isAuthTokenRequest = (request: Request) => {
return request.headers.has("x-auth-token");
};
export const action = async ({ request }: ActionFunctionArgs) => {
if (
isServiceRequest(request) === false &&
isCliApiRequest(request) === false
) {
if (isServiceRequest(request) === false) {
preventCrossOriginCookie(request);
if (isAuthTokenRequest(request) === false) {
await checkCsrf(request);
}
await ensureApiCsrf(request);
}
// https://trpc.io/docs/server/adapters/fetch
@@ -0,0 +1,195 @@
import { expect, test, vi } from "vitest";
import { TRPCError } from "@trpc/server";
import { AuthorizationError } from "@webstudio-is/trpc-interface/index.server";
import {
authorizeApiProject,
ensureApiCsrf,
getApiAuthorizationFailure,
requiresApiCsrf,
} from "./api-auth.server";
test.each([
[
new TRPCError({ code: "UNAUTHORIZED", message: "login required" }),
{ status: 401, code: "UNAUTHORIZED", message: "login required" },
],
[
new TRPCError({ code: "FORBIDDEN", message: "denied" }),
{ status: 403, code: "FORBIDDEN", message: "denied" },
],
[
new AuthorizationError("project denied"),
{ status: 403, code: "FORBIDDEN", message: "project denied" },
],
[
new Response("CSRF failed", { status: 403 }),
{ status: 403, code: "FORBIDDEN", message: "Forbidden" },
],
])("classifies API authorization failures", (error, expected) => {
expect(getApiAuthorizationFailure(error)).toEqual(expected);
});
test("does not classify unrelated failures as authorization failures", () => {
expect(
getApiAuthorizationFailure(new Error("database failed"))
).toBeUndefined();
});
test("requires CSRF for cookie-authenticated API requests", () => {
expect(requiresApiCsrf(new Request("https://example.com/rest/assets"))).toBe(
true
);
});
test("uses explicit token authentication without cookie CSRF", () => {
expect(
requiresApiCsrf(
new Request("https://example.com/rest/assets", {
headers: { "x-auth-token": "share-token" },
})
)
).toBe(false);
});
test("requires CSRF for Builder share-token requests", () => {
expect(
requiresApiCsrf(
new Request("https://example.com/rest/assets", {
headers: {
"x-auth-token": "share-token",
"x-webstudio-client": "browser",
},
})
)
).toBe(true);
});
test("requires API enablement for explicit API tokens", async () => {
const context = { authorization: { type: "token" } } as never;
const dependencies = {
createContext: vi.fn().mockResolvedValue(context),
assertApiProjectPermit: vi.fn().mockResolvedValue(undefined),
hasProjectPermit: vi.fn().mockResolvedValue(true),
ensureApiCsrf: vi.fn(),
};
await expect(
authorizeApiProject(
new Request("https://example.com/rest/assets"),
"project-1",
"view",
dependencies
)
).resolves.toBe(context);
expect(dependencies.assertApiProjectPermit).toHaveBeenCalledWith(
context,
"project-1",
"view"
);
expect(dependencies.hasProjectPermit).not.toHaveBeenCalled();
});
test("checks project permits for cookie-authenticated requests", async () => {
const context = { authorization: { type: "user" } } as never;
const dependencies = {
createContext: vi.fn().mockResolvedValue(context),
assertApiProjectPermit: vi.fn(),
hasProjectPermit: vi.fn().mockResolvedValue(true),
ensureApiCsrf: vi.fn(),
};
await authorizeApiProject(
new Request("https://example.com/rest/assets"),
"project-1",
"build",
dependencies
);
expect(dependencies.assertApiProjectPermit).toHaveBeenCalledWith(
context,
"project-1",
"build"
);
expect(dependencies.hasProjectPermit).not.toHaveBeenCalled();
});
test("keeps Builder share-token requests on project authorization", async () => {
const context = { authorization: { type: "token" } } as never;
const dependencies = {
createContext: vi.fn().mockResolvedValue(context),
assertApiProjectPermit: vi.fn(),
hasProjectPermit: vi.fn().mockResolvedValue(true),
ensureApiCsrf: vi.fn().mockResolvedValue(undefined),
};
await authorizeApiProject(
new Request("https://example.com/rest/assets", {
headers: { "x-webstudio-client": "browser", "x-auth-token": "token" },
}),
"project-1",
"build",
dependencies
);
expect(dependencies.assertApiProjectPermit).not.toHaveBeenCalled();
expect(dependencies.hasProjectPermit).toHaveBeenCalledWith(
{ projectId: "project-1", permit: "build" },
context
);
expect(dependencies.ensureApiCsrf).toHaveBeenCalledOnce();
});
test("rejects Builder share tokens for a different project", async () => {
const context = { authorization: { type: "token" } } as never;
const dependencies = {
createContext: vi.fn().mockResolvedValue(context),
assertApiProjectPermit: vi.fn(),
hasProjectPermit: vi.fn().mockResolvedValue(false),
ensureApiCsrf: vi.fn().mockResolvedValue(undefined),
};
await expect(
authorizeApiProject(
new Request("https://example.com/rest/assets", {
headers: { "x-webstudio-client": "browser", "x-auth-token": "token" },
}),
"other-project",
"view",
dependencies
)
).rejects.toThrow("You don't have access to this project");
expect(dependencies.assertApiProjectPermit).not.toHaveBeenCalled();
});
test("does not trust the Builder client marker without valid CSRF", async () => {
const context = { authorization: { type: "token" } } as never;
const csrfError = new Response("Forbidden", { status: 403 });
const dependencies = {
createContext: vi.fn().mockResolvedValue(context),
assertApiProjectPermit: vi.fn(),
hasProjectPermit: vi.fn().mockResolvedValue(true),
ensureApiCsrf: vi.fn().mockRejectedValue(csrfError),
};
await expect(
authorizeApiProject(
new Request("https://example.com/rest/assets", {
headers: { "x-webstudio-client": "browser", "x-auth-token": "token" },
}),
"project-1",
"view",
dependencies
)
).rejects.toBe(csrfError);
expect(dependencies.assertApiProjectPermit).not.toHaveBeenCalled();
});
test("validates CSRF only once for one request", async () => {
const request = new Request("https://example.com/rest/assets", {
headers: { "x-webstudio-client": "browser" },
});
const validate = vi.fn().mockResolvedValue(undefined);
await ensureApiCsrf(request, validate);
await ensureApiCsrf(request, validate);
expect(validate).toHaveBeenCalledOnce();
});
@@ -0,0 +1,110 @@
import { TRPCError } from "@trpc/server";
import {
AuthorizationError,
authorizeProject,
type ProjectPermit,
} from "@webstudio-is/trpc-interface/index.server";
import { apiClientHeader } from "@webstudio-is/trpc-interface/api-compatibility";
import { createContext } from "~/shared/context.server";
import { assertApiProjectPermit } from "./api-permits.server";
import { checkCsrf } from "./csrf-session.server";
/**
* Authorize one project-scoped API request. Explicit API tokens are subject to
* public API enablement and token permits. Browser sessions and Builder share
* links retain the repository's normal project authorization.
*/
export const authorizeApiProject = async (
request: Request,
projectId: string,
permit: ProjectPermit,
dependencies = {
createContext,
assertApiProjectPermit,
hasProjectPermit: authorizeProject.hasProjectPermit,
ensureApiCsrf,
}
) => {
const context = await dependencies.createContext(request);
if (
context.authorization.type === "token" &&
request.headers.get(apiClientHeader) === "browser"
) {
await dependencies.ensureApiCsrf(request);
if (
(await dependencies.hasProjectPermit({ projectId, permit }, context)) ===
false
) {
throw new AuthorizationError("You don't have access to this project");
}
return context;
}
if (
context.authorization.type === "token" ||
context.authorization.type === "user"
) {
await dependencies.assertApiProjectPermit(context, projectId, permit);
}
return context;
};
/**
* Requests without an explicit API token require CSRF validation at API
* transports that can mutate. Builder share-token requests identify themselves
* as browser traffic and remain CSRF-protected.
*/
export const requiresApiCsrf = (request: Request) =>
request.headers.get(apiClientHeader) === "browser" ||
request.headers.has("x-auth-token") === false;
const csrfValidatedRequests = new WeakSet<Request>();
/** Validate one request at most once across its transport and authorization. */
export const ensureApiCsrf = async (
request: Request,
validate: typeof checkCsrf = checkCsrf
) => {
if (
requiresApiCsrf(request) === false ||
csrfValidatedRequests.has(request)
) {
return;
}
await validate(request);
csrfValidatedRequests.add(request);
};
export const getApiAuthorizationFailure = (error: unknown) => {
if (
error instanceof Response &&
(error.status === 401 || error.status === 403)
) {
return {
status: error.status,
code:
error.status === 401
? ("UNAUTHORIZED" as const)
: ("FORBIDDEN" as const),
message:
error.statusText ||
(error.status === 401 ? "Authentication required" : "Forbidden"),
};
}
if (error instanceof TRPCError && error.code === "UNAUTHORIZED") {
return {
status: 401 as const,
code: "UNAUTHORIZED" as const,
message: error.message,
};
}
if (
(error instanceof TRPCError && error.code === "FORBIDDEN") ||
error instanceof AuthorizationError
) {
return {
status: 403 as const,
code: "FORBIDDEN" as const,
message: error.message,
};
}
};
+39 -19
View File
@@ -46,36 +46,56 @@ export const assertApiTokenPermit = async (ctx: AppContext) => {
return { token, permits };
};
export const assertApiProjectPermit = async (
const assertProjectPermit = async (
ctx: AppContext,
projectId: string,
permit: ProjectApiPermit
) => {
const { token, permits } = await assertApiTokenPermit(ctx);
if (token.projectId !== projectId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Authorization token is not valid for project",
});
}
if (permits.includes(permit) === false) {
throw new TRPCError({
code: "FORBIDDEN",
message: `Authorization token does not have ${permit} permission`,
});
}
const canUseProject = await authorizeProject.hasProjectPermit(
const allowed = await authorizeProject.hasProjectPermit(
{ projectId, permit },
ctx
);
if (canUseProject === false) {
if (allowed === false) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have access to this project",
});
}
};
return { token, permits };
export const assertApiProjectPermit = async (
ctx: AppContext,
projectId: string,
permit: ProjectApiPermit
) => {
if (ctx.authorization.type === "user") {
await assertProjectPermit(ctx, projectId, permit);
const permits: ProjectApiPermit[] = [permit];
if (
permit === "edit" &&
(await authorizeProject.hasProjectPermit(
{ projectId, permit: "build" },
ctx
))
) {
permits.push("build");
}
return { type: "user" as const, permits };
}
const tokenAuth = await assertApiTokenPermit(ctx);
if (tokenAuth.token.projectId !== projectId) {
throw new TRPCError({
code: "FORBIDDEN",
message: "Authorization token is not valid for project",
});
}
if (tokenAuth.permits.includes(permit) === false) {
throw new TRPCError({
code: "FORBIDDEN",
message: `Authorization token does not have ${permit} permission`,
});
}
await assertProjectPermit(ctx, projectId, permit);
return { type: "token" as const, ...tokenAuth };
};
@@ -18,7 +18,8 @@ import {
} from "@webstudio-is/trpc-interface/index.server";
import { db as authDb } from "@webstudio-is/authorization-token/index.server";
import { blockComponent } from "@webstudio-is/sdk";
import * as assetUploader from "@webstudio-is/asset-uploader/index.server";
import { AssetIndexRevisionError } from "@webstudio-is/content-engine";
import * as assetUploader from "@webstudio-is/asset-uploader/server";
import { apiRouter, __testing__ } from "./api-router.server";
import {
getApiRouterProcedures,
@@ -74,6 +75,115 @@ const createCaller = (context: AppContext) =>
apiRouter.createCaller(context) as ApiRouterCaller & RuntimeApiCaller;
describe("api router build operation adapters", () => {
test("exposes query validation and persisted asset query reads to API clients", async () => {
vi.spyOn(authDb, "getTokenInfo").mockResolvedValue(createToken());
vi.spyOn(authorizeProject, "hasProjectPermit").mockResolvedValue(true);
vi.spyOn(assetUploader, "previewAssetResourceQuery").mockResolvedValue({
data: {
items: [{ id: "post" }],
totalCount: 1,
hasMore: false,
},
__diagnostics__: {
scope: "query-preview",
usedBytes: 100,
maxBytes: 512_000,
unboundedBytes: 100,
includedDocumentCount: 1,
omittedDocumentCount: 0,
truncated: false,
},
} as never);
vi.spyOn(assetUploader, "loadBuilderAssetFieldCatalog").mockResolvedValue({
format: "webstudio-builder-asset-field-catalog",
version: 1,
canonicalRevision: `sha256:${"a".repeat(64)}`,
documentCount: 1,
fields: {
"properties.slug": {
types: ["string"],
occurrences: 1,
queryPath: ["properties", "slug"],
},
},
});
const caller = createCaller(createContext(true));
await expect(
caller.assetQueries.validate({
projectId: "project-1",
query: {
where: {
all: [
{
field: ["properties", "slug"],
operator: "eq",
value: "post",
},
],
},
limit: 1,
},
})
).resolves.toMatchObject({
valid: true,
referencedFieldPaths: expect.arrayContaining([["properties", "slug"]]),
filterCount: 1,
});
await expect(
caller.assetQueries.validate({
projectId: "project-1",
query: {
where: {
all: [
{
field: ["properties", "missing"],
operator: "exists",
value: true,
},
],
},
},
})
).resolves.toMatchObject({
valid: true,
warnings: ["Asset field properties.missing is not currently observed"],
});
await expect(
caller.assetQueries.preview({
projectId: "project-1",
query: { limit: 10 },
})
).resolves.toMatchObject({
data: {
items: [{ id: "post" }],
totalCount: 1,
},
__diagnostics__: { scope: "query-preview", truncated: false },
});
expect(assetUploader.previewAssetResourceQuery).toHaveBeenCalledWith(
expect.objectContaining({ contentDatabaseMaxBytes: 512_000 })
);
vi.mocked(assetUploader.previewAssetResourceQuery).mockRejectedValueOnce(
new AssetIndexRevisionError()
);
await expect(
caller.assetQueries.preview({
projectId: "project-1",
query: { limit: 10 },
indexRevision: `sha256:${"0".repeat(64)}`,
})
).rejects.toMatchObject({
code: "CONFLICT",
cause: { webstudioCode: "STALE_INDEX" },
});
await expect(
caller.assetQueries.fieldCatalog({ projectId: "project-1" })
).resolves.toMatchObject({
fields: { "properties.slug": { types: ["string"] } },
});
});
test("allows CLI clients to refresh pages without project settings", async () => {
const build = {
id: "build-1",
@@ -258,7 +368,9 @@ describe("api router permits", () => {
).toEqual(["view", "edit", "build", "admin", "api"]);
});
test("requires token auth for project-scoped api procedures", async () => {
test("accepts cookie auth for project-scoped api procedures", async () => {
vi.spyOn(authorizeProject, "hasProjectPermit").mockResolvedValue(true);
await expect(
assertApiProjectPermit(
createContext(true, {
@@ -270,12 +382,50 @@ describe("api router permits", () => {
"project-1",
"view"
)
).rejects.toMatchObject({
code: "FORBIDDEN",
message: "Builder API requires an API token",
).resolves.toEqual({
type: "user",
permits: ["view"],
});
});
test("rejects cookie auth without the requested project permit", async () => {
vi.spyOn(authorizeProject, "hasProjectPermit").mockResolvedValue(false);
await expect(
assertApiProjectPermit(
createContext(true, {
type: "user",
userId: "user-1",
sessionCreatedAt: 0,
isLoggedInToBuilder: async () => true,
}),
"project-1",
"edit"
)
).rejects.toThrow("You don't have access to this project");
});
test("preserves content-only permissions for cookie-authenticated editors", async () => {
const hasProjectPermit = vi
.spyOn(authorizeProject, "hasProjectPermit")
.mockImplementation(async ({ permit }) => permit === "edit");
const context = createContext(true, {
type: "user",
userId: "user-1",
sessionCreatedAt: 0,
isLoggedInToBuilder: async () => true,
});
await expect(
assertApiProjectPermit(context, "project-1", "edit")
).resolves.toEqual({ type: "user", permits: ["edit"] });
hasProjectPermit.mockResolvedValue(true);
await expect(
assertApiProjectPermit(context, "project-1", "edit")
).resolves.toEqual({ type: "user", permits: ["edit", "build"] });
});
test("rejects tokens from another project", async () => {
vi.spyOn(authDb, "getTokenInfo").mockResolvedValue(
createToken({ projectId: "project-2" })
@@ -436,6 +586,7 @@ describe("api router permits", () => {
await expect(
assertApiProjectPermit(createContext(true), "project-1", "edit")
).resolves.toEqual({
type: "token",
token,
permits: ["view", "edit", "api"],
});
@@ -444,9 +595,18 @@ describe("api router permits", () => {
test("requires publish permission for api publish domains", () => {
const project = { domain: "project.wstd.dev" };
expect(() =>
assertApiPublishDomains({
auth: { type: "user", permits: ["edit"] },
domains: ["custom.example.com"],
project,
})
).not.toThrow();
expect(() =>
assertApiPublishDomains({
auth: {
type: "token",
token: createToken({ relation: "editors", canPublish: false }),
permits: ["view", "edit", "api"],
},
@@ -458,6 +618,7 @@ describe("api router permits", () => {
expect(() =>
assertApiPublishDomains({
auth: {
type: "token",
token: createToken({ relation: "editors", canPublish: true }),
permits: ["view", "edit", "api"],
},
@@ -469,6 +630,7 @@ describe("api router permits", () => {
expect(() =>
assertApiPublishDomains({
auth: {
type: "token",
token: createToken({ relation: "builders", canPublish: false }),
permits: ["view", "edit", "build", "api"],
},
@@ -480,6 +642,7 @@ describe("api router permits", () => {
expect(() =>
assertApiPublishDomains({
auth: {
type: "token",
token: createToken({ relation: "builders", canPublish: false }),
permits: ["view", "edit", "build", "api"],
},
@@ -491,6 +654,7 @@ describe("api router permits", () => {
expect(() =>
assertApiPublishDomains({
auth: {
type: "token",
token: createToken({
relation: "administrators",
canPublish: true,
@@ -536,10 +700,12 @@ describe("api router permits", () => {
marketplaceProduct: {},
} as unknown as CompactBuild;
const editorAuth = {
type: "token",
token: createToken({ relation: "editors" }),
permits: ["view", "edit", "api"],
} satisfies Awaited<ReturnType<typeof assertApiProjectPermit>>;
const builderAuth = {
type: "token",
token: createToken({ relation: "builders" }),
permits: ["view", "edit", "build", "api"],
} satisfies Awaited<ReturnType<typeof assertApiProjectPermit>>;
@@ -660,7 +826,9 @@ describe("api router permits", () => {
projectId: "project-1",
clientVersion: 3,
}),
expect.anything()
expect.objectContaining({
authorization: expect.objectContaining({ type: "token" }),
})
);
});
+102 -1
View File
@@ -28,9 +28,17 @@ import {
paginatedOutputInputSchema,
} from "@webstudio-is/project-build/runtime";
import {
getAssetResourceQueryError,
assetQueryRequest,
getAssetQueryWhereMetrics,
validateAssetQueryAgainstCatalog,
} from "@webstudio-is/content-engine";
import {
loadBuilderAssetFieldCatalog,
loadAssetDataByProject,
loadAssetFoldersByProject,
} from "@webstudio-is/asset-uploader/index.server";
previewAssetResourceQuery,
} from "@webstudio-is/asset-uploader/server";
import { buildPatchTransaction } from "@webstudio-is/protocol/schema";
import {
publicApiContractVersion,
@@ -72,6 +80,8 @@ import {
executeApiRuntimeMutation,
executeApiRuntimeOperation,
} from "./api-runtime.server";
import { createAssetClient } from "../shared/asset-client";
import { getContentDatabaseMaxBytes } from "./content-database.server";
const assertApiPublishDomains = ({
auth,
@@ -82,6 +92,9 @@ const assertApiPublishDomains = ({
domains: string[];
project: { domain: string };
}) => {
if (auth.type === "user") {
return;
}
const { token } = auth;
if (token.canPublish === true) {
return;
@@ -100,6 +113,24 @@ const assertApiPublishDomains = ({
const projectIdInput = z.object({ projectId: z.string() });
const assetQueryInput = projectIdInput.extend(assetQueryRequest.shape);
const assetQueryValidationInput = projectIdInput.extend({
query: assetQueryRequest.shape.query,
});
const throwAssetQueryApiError = (error: unknown): never => {
const queryError = getAssetResourceQueryError(error);
if (queryError !== undefined) {
return throwApiError(
queryError.status === 409 ? "CONFLICT" : "BAD_REQUEST",
queryError.message,
{
webstudioCode: queryError.code,
}
);
}
throw error;
};
const paginatedProjectInput = projectIdInput.extend(
paginatedOutputInputSchema.shape
);
@@ -691,6 +722,76 @@ export const apiRouter = router({
...runtimeOperationRouters,
assetQueries: router({
validate: projectQuery(
assetQueryValidationInput,
"view",
async ({ ctx, input }) => {
try {
const catalog = await loadBuilderAssetFieldCatalog({
projectId: input.projectId,
context: ctx,
assetClient: createAssetClient(),
});
const validated = validateAssetQueryAgainstCatalog({
query: input.query,
catalog,
});
return {
valid: true as const,
referencedFieldPaths: validated.referencedFieldPaths,
filterCount: getAssetQueryWhereMetrics(validated.query.where)
.filters,
sortCount: validated.query.sort.length,
warnings: validated.warnings,
};
} catch (error) {
return throwAssetQueryApiError(error);
}
},
{
command: "validate-asset-query",
client: "validateAssetQuery",
}
),
preview: projectQuery(
assetQueryInput,
"view",
async ({ ctx, input }) => {
try {
const { projectId, ...request } = input;
return await previewAssetResourceQuery({
projectId,
request,
context: ctx,
assetClient: createAssetClient(),
contentDatabaseMaxBytes: getContentDatabaseMaxBytes(),
});
} catch (error) {
return throwAssetQueryApiError(error);
}
},
{
command: "preview-asset-query",
client: "previewAssetQuery",
}
),
fieldCatalog: projectQuery(
projectIdInput,
"view",
async ({ ctx, input }) =>
await loadBuilderAssetFieldCatalog({
projectId: input.projectId,
context: ctx,
assetClient: createAssetClient(),
}),
{
command: "get-asset-field-catalog",
client: "getAssetFieldCatalog",
}
),
}),
publish: router({
list: projectQuery(
paginatedProjectInput,
@@ -0,0 +1,69 @@
import { describe, expect, test, vi } from "vitest";
import type { Asset } from "@webstudio-is/sdk";
import {
createAssetContentLoader,
parseRequestRange,
} from "./asset-rest-route-handlers.server";
const asset: Asset = {
id: "asset-1",
projectId: "project-1",
name: "post.md",
type: "file",
format: "md",
size: 10,
description: null,
createdAt: "2026-07-25T00:00:00.000Z",
meta: {},
};
describe("Assets REST content download", () => {
test("parses one standard byte range", () => {
expect(parseRequestRange("bytes=2-5", 10)).toEqual({
offset: 2,
length: 4,
});
expect(() => parseRequestRange("bytes=0-1,4-5", 10)).toThrow(
"single valid byte range"
);
expect(() => parseRequestRange("items=0-1", 10)).toThrow(
"single valid byte range"
);
});
test("streams an authorized partial response", async () => {
const readContent = vi.fn(async () => ({
asset,
data: {
async *[Symbol.asyncIterator]() {
yield new TextEncoder().encode("post");
},
},
contentLength: 4,
}));
const dependencies = {
preventCrossOriginCookie: vi.fn(),
createRepository: vi.fn(async () => ({
get: vi.fn(async () => asset),
readContent,
})) as never,
};
const response = await createAssetContentLoader(dependencies)({
request: new Request(
"https://webstudio.is/rest/assets/asset-1/content?projectId=project-1",
{ headers: { range: "bytes=2-5", "x-auth-token": "token" } }
),
params: { assetId: "asset-1" },
} as never);
expect(response.status).toBe(206);
expect(response.headers.get("content-range")).toBe("bytes 2-5/10");
expect(response.headers.get("content-length")).toBe("4");
expect(await response.text()).toBe("post");
expect(readContent).toHaveBeenCalledWith({
assetId: "asset-1",
range: { offset: 2, length: 4 },
asset,
});
});
});
@@ -0,0 +1,66 @@
import { describe, expect, test, vi } from "vitest";
import { createAssetIndexRefreshAction } from "./asset-rest-route-handlers.server";
const call = (
action: ReturnType<typeof createAssetIndexRefreshAction>,
headers?: HeadersInit
) =>
action({
request: new Request(
"https://webstudio.is/rest/assets/index/refresh?projectId=project-1",
{ method: "POST", headers }
),
params: {},
} as never);
const createDependencies = (issues: Array<Record<string, string>> = []) => ({
preventCrossOriginCookie: vi.fn(),
ensureApiCsrf: vi.fn(),
createRepository: vi.fn(async () => ({
synchronize: vi.fn(async () => ({
scanned: 1,
indexed: issues.length === 0 ? 1 : 0,
metadataUpdated: 0,
unchanged: 0,
removed: 0,
skipped: issues.length,
inconsistent: 0,
issues,
})),
})) as never,
});
describe("Assets index repair", () => {
test("repairs metadata and protects cookie mutations with CSRF", async () => {
const dependencies = createDependencies();
const response = await call(createAssetIndexRefreshAction(dependencies));
expect(response.status).toBe(200);
expect(dependencies.ensureApiCsrf).toHaveBeenCalledOnce();
expect(dependencies.createRepository).toHaveBeenCalledWith(
expect.any(Request),
"edit"
);
expect(await response.json()).toMatchObject({ indexed: 1 });
});
test("reports incomplete repair and accepts project-token authentication", async () => {
const dependencies = createDependencies([
{
assetId: "asset-1",
storageName: "post.md",
revision: "revision-1",
message: "Object is unavailable",
},
]);
const response = await call(createAssetIndexRefreshAction(dependencies), {
"x-auth-token": "share-token",
});
expect(response.status).toBe(503);
expect(dependencies.ensureApiCsrf).toHaveBeenCalledOnce();
expect(await response.json()).toMatchObject({
issues: [expect.objectContaining({ assetId: "asset-1" })],
});
});
});
@@ -0,0 +1,220 @@
import { describe, expect, test, vi } from "vitest";
import type { Asset } from "@webstudio-is/sdk";
import { assetResourceLimits } from "@webstudio-is/sdk/asset-resource-limits";
import { AuthorizationError } from "@webstudio-is/trpc-interface/index.server";
import { AssetRepositoryNotFoundError } from "@webstudio-is/asset-uploader/server";
import { createAssetAction } from "./asset-rest-route-handlers.server";
const asset: Asset = {
id: "asset-1",
projectId: "project-1",
name: "post.md",
filename: "Post",
description: null,
type: "file",
format: "md",
size: 10,
createdAt: "2026-07-25T00:00:00.000Z",
meta: {},
};
const createDependencies = () => {
const updateMetadata = vi.fn().mockResolvedValue(asset);
const deleteAsset = vi.fn().mockResolvedValue(undefined);
const createRepository = vi.fn(async () => ({
updateMetadata,
delete: deleteAsset,
}));
return {
updateMetadata,
deleteAsset,
createRepository,
dependencies: {
preventCrossOriginCookie: vi.fn(),
ensureApiCsrf: vi.fn(),
createRepository: createRepository as never,
} satisfies Parameters<typeof createAssetAction>[0],
};
};
const callAction = (
action: ReturnType<typeof createAssetAction>,
request: Request
) => action({ request, params: { assetId: "asset-1" } } as never);
describe("mutable Assets REST route", () => {
test("rejects unsupported methods before authorization", async () => {
const { dependencies, createRepository } = createDependencies();
const response = await callAction(
createAssetAction(dependencies),
new Request(
"https://webstudio.is/rest/assets/asset-1?projectId=project-1",
{ method: "POST" }
)
);
expect(response.status).toBe(405);
expect(response.headers.get("allow")).toBe("PATCH, DELETE");
expect(dependencies.ensureApiCsrf).not.toHaveBeenCalled();
expect(createRepository).not.toHaveBeenCalled();
});
test("updates metadata through the repository with browser CSRF protection", async () => {
const { dependencies, updateMetadata } = createDependencies();
const action = createAssetAction(dependencies);
const response = await callAction(
action,
new Request(
"https://webstudio.is/rest/assets/asset-1?projectId=project-1",
{
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ filename: "Post", folderId: null }),
}
)
);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ asset });
expect(dependencies.ensureApiCsrf).toHaveBeenCalledOnce();
expect(updateMetadata).toHaveBeenCalledWith("asset-1", {
filename: "Post",
folderId: null,
});
});
test("returns the same metadata contract for project-token authentication", async () => {
const { dependencies, createRepository, updateMetadata } =
createDependencies();
const response = await callAction(
createAssetAction(dependencies),
new Request(
"https://webstudio.is/rest/assets/asset-1?projectId=project-1",
{
method: "PATCH",
headers: {
"content-type": "application/json",
"x-auth-token": "token",
},
body: JSON.stringify({ filename: "Post", folderId: null }),
}
)
);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ asset });
expect(dependencies.ensureApiCsrf).toHaveBeenCalledOnce();
expect(createRepository).toHaveBeenCalledWith(expect.any(Request), "edit");
expect(updateMetadata).toHaveBeenCalledWith("asset-1", {
filename: "Post",
folderId: null,
});
});
test("deletes through the repository and accepts token-authenticated requests", async () => {
const { dependencies, deleteAsset } = createDependencies();
const action = createAssetAction(dependencies);
const response = await callAction(
action,
new Request(
"https://webstudio.is/rest/assets/asset-1?projectId=project-1",
{
method: "DELETE",
headers: { "x-auth-token": "token" },
}
)
);
expect(response.status).toBe(204);
expect(dependencies.ensureApiCsrf).toHaveBeenCalledOnce();
expect(deleteAsset).toHaveBeenCalledWith(["asset-1"]);
});
test("returns a forbidden response for repository authorization failures", async () => {
const { dependencies, createRepository } = createDependencies();
createRepository.mockRejectedValue(
new AuthorizationError("You don't have access") as never
);
const report = vi.spyOn(console, "error").mockImplementation(() => {});
const response = await callAction(
createAssetAction(dependencies),
new Request(
"https://webstudio.is/rest/assets/asset-1?projectId=project-1",
{
method: "PATCH",
headers: {
"content-type": "application/json",
"x-auth-token": "token",
},
body: JSON.stringify({ description: "Updated" }),
}
)
);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({
errors: "You don't have access",
});
report.mockRestore();
});
test.each([
["PATCH", "updateMetadata"],
["DELETE", "deleteAsset"],
] as const)(
"returns not found when %s targets a missing asset",
async (method, operation) => {
const dependencies = createDependencies();
dependencies[operation].mockRejectedValue(
new AssetRepositoryNotFoundError("Asset not found")
);
const requestInit: RequestInit = {
method,
headers: { "x-auth-token": "token" },
};
if (method === "PATCH") {
requestInit.headers = {
...requestInit.headers,
"content-type": "application/json",
};
requestInit.body = JSON.stringify({ description: "Missing" });
}
const response = await callAction(
createAssetAction(dependencies.dependencies),
new Request(
"https://webstudio.is/rest/assets/missing?projectId=project-1",
requestInit
)
);
expect(response.status).toBe(404);
expect(await response.json()).toEqual({ errors: "Asset not found" });
}
);
test("rejects oversized JSON before calling the repository", async () => {
const { dependencies, updateMetadata } = createDependencies();
const response = await callAction(
createAssetAction(dependencies),
new Request(
"https://webstudio.is/rest/assets/asset-1?projectId=project-1",
{
method: "PATCH",
headers: {
"content-type": "application/json",
"x-auth-token": "token",
},
body: JSON.stringify({
description: "x".repeat(
assetResourceLimits.restMutationRequestBytes
),
}),
}
)
);
expect(response.status).toBe(413);
expect(updateMetadata).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,175 @@
import {
json,
type ActionFunctionArgs,
type LoaderFunctionArgs,
} from "@remix-run/server-runtime";
import { Readable } from "node:stream";
import parseRange from "range-parser";
import { getAssetMime } from "@webstudio-is/sdk";
import {
assetMetadataUpdate,
assetResourceApiOperations,
} from "@webstudio-is/protocol/asset-resource-api";
import { privateNoStoreResponseHeaders } from "./cache-control.server";
import { ensureApiCsrf } from "./api-auth.server";
import {
AssetRestRangeError,
assetRestErrorResponse,
assetRestMethodNotAllowed,
createAssetRestRepository,
parseAssetRestIdentifier,
readAssetRestJson,
} from "./asset-rest.server";
import { preventCrossOriginCookie } from "./no-cross-origin-cookie";
export const parseRequestRange = (value: string | null, size: number) => {
if (value === null) {
return;
}
const parsed = parseRange(size, value, { combine: true });
if (
typeof parsed === "number" ||
parsed.type !== "bytes" ||
parsed.length !== 1
) {
throw new AssetRestRangeError("A single valid byte range is required");
}
const [{ start, end }] = parsed;
return { offset: start, length: end - start + 1 };
};
type AssetContentLoaderDependencies = {
preventCrossOriginCookie: typeof preventCrossOriginCookie;
createRepository: typeof createAssetRestRepository;
};
const defaultAssetContentLoaderDependencies: AssetContentLoaderDependencies = {
preventCrossOriginCookie,
createRepository: createAssetRestRepository,
};
export const createAssetContentLoader =
(
dependencies: AssetContentLoaderDependencies = defaultAssetContentLoaderDependencies
) =>
async ({ request, params }: LoaderFunctionArgs) => {
dependencies.preventCrossOriginCookie(request);
if (
request.method.toLowerCase() !==
assetResourceApiOperations.downloadAssetContent.method
) {
return assetRestMethodNotAllowed([
assetResourceApiOperations.downloadAssetContent,
]);
}
try {
const assetId = parseAssetRestIdentifier(params.assetId);
const repository = await dependencies.createRepository(request, "view");
const asset = await repository.get(assetId);
const range = parseRequestRange(request.headers.get("range"), asset.size);
const content = await repository.readContent({ assetId, range, asset });
const headers = new Headers(privateNoStoreResponseHeaders);
headers.set(
"content-type",
getAssetMime(asset) ?? "application/octet-stream"
);
headers.set("accept-ranges", "bytes");
headers.set(
"content-length",
String(content.contentLength ?? range?.length ?? asset.size)
);
if (range !== undefined) {
headers.set(
"content-range",
`bytes ${range.offset}-${range.offset + range.length - 1}/${asset.size}`
);
}
return new Response(
Readable.toWeb(Readable.from(content.data)) as ReadableStream,
{ status: range === undefined ? 200 : 206, headers }
);
} catch (error) {
return assetRestErrorResponse(error);
}
};
type AssetActionDependencies = {
preventCrossOriginCookie: typeof preventCrossOriginCookie;
ensureApiCsrf: typeof ensureApiCsrf;
createRepository: typeof createAssetRestRepository;
};
const defaultAssetActionDependencies: AssetActionDependencies = {
preventCrossOriginCookie,
ensureApiCsrf,
createRepository: createAssetRestRepository,
};
export const createAssetAction =
(dependencies: AssetActionDependencies = defaultAssetActionDependencies) =>
async ({ request, params }: ActionFunctionArgs) => {
dependencies.preventCrossOriginCookie(request);
const method = request.method.toLowerCase();
if (
method !== assetResourceApiOperations.updateAsset.method &&
method !== assetResourceApiOperations.deleteAsset.method
) {
return assetRestMethodNotAllowed([
assetResourceApiOperations.updateAsset,
assetResourceApiOperations.deleteAsset,
]);
}
await dependencies.ensureApiCsrf(request);
try {
const assetId = parseAssetRestIdentifier(params.assetId);
const repository = await dependencies.createRepository(request, "edit");
if (method === assetResourceApiOperations.updateAsset.method) {
const asset = await repository.updateMetadata(
assetId,
assetMetadataUpdate.parse(await readAssetRestJson(request))
);
return json({ asset }, { headers: privateNoStoreResponseHeaders });
}
await repository.delete([assetId]);
return new Response(null, {
status: 204,
headers: privateNoStoreResponseHeaders,
});
} catch (error) {
return assetRestErrorResponse(error);
}
};
const defaultAssetIndexRefreshDependencies = {
preventCrossOriginCookie,
ensureApiCsrf,
createRepository: createAssetRestRepository,
};
export const createAssetIndexRefreshAction =
(dependencies = defaultAssetIndexRefreshDependencies) =>
async ({ request }: ActionFunctionArgs) => {
dependencies.preventCrossOriginCookie(request);
if (
request.method.toLowerCase() !==
assetResourceApiOperations.refreshAssetIndex.method
) {
return assetRestMethodNotAllowed([
assetResourceApiOperations.refreshAssetIndex,
]);
}
await dependencies.ensureApiCsrf(request);
try {
const result = await (
await dependencies.createRepository(request, "edit")
).synchronize();
return json(result, {
status: result.issues.length === 0 ? 200 : 503,
headers: privateNoStoreResponseHeaders,
});
} catch (error) {
return assetRestErrorResponse(error);
}
};
@@ -0,0 +1,203 @@
import { describe, expect, test, vi } from "vitest";
import { TRPCError } from "@trpc/server";
import {
AssetRepositoryConflictError,
AssetRepositoryNotFoundError,
} from "@webstudio-is/asset-uploader/server";
import { assetFolders } from "@webstudio-is/sdk";
import { assetResourceLimits } from "@webstudio-is/sdk/asset-resource-limits";
import { assetResourceApiOperations } from "@webstudio-is/protocol/asset-resource-api";
import { AuthorizationError } from "@webstudio-is/trpc-interface/index.server";
import {
AssetRestRangeError,
AssetRestPayloadTooLargeError,
AssetRestRequestError,
assetRestErrorResponse,
assetRestMethodNotAllowed,
parseAssetRestDescription,
parseAssetRestMetadataHeader,
readAssetRestFormData,
readAssetRestJson,
requireAssetRestBody,
} from "./asset-rest.server";
import { AssetUploadSizeLimitError } from "@webstudio-is/asset-uploader/server";
describe("Assets REST responses", () => {
test.each([
[new AssetRestRequestError("bad request"), 400],
[new AuthorizationError("forbidden"), 403],
[new TRPCError({ code: "UNAUTHORIZED" }), 401],
[new TRPCError({ code: "FORBIDDEN" }), 403],
[new AssetRepositoryNotFoundError("missing"), 404],
[new AssetRepositoryConflictError("conflict"), 409],
[new AssetRestRangeError("bad range"), 416],
[new AssetRestPayloadTooLargeError("too large"), 413],
[new AssetUploadSizeLimitError("post.md", 10), 413],
])("maps domain errors to HTTP status", async (error, status) => {
const response = assetRestErrorResponse(error);
expect(response.status).toBe(status);
expect(await response.json()).toEqual({ errors: error.message });
});
test("maps duplicate sibling folders to conflict", () => {
const error = assetFolders.safeParse(
new Map([
[
"one",
{
id: "one",
projectId: "project-1",
name: "Blog",
createdAt: "2026-07-25T00:00:00.000Z",
},
],
[
"two",
{
id: "two",
projectId: "project-1",
name: "Blog",
createdAt: "2026-07-25T00:00:00.000Z",
},
],
])
).error;
expect(error).toBeDefined();
expect(assetRestErrorResponse(error).status).toBe(409);
});
test("does not swallow CSRF responses and advertises allowed methods", () => {
const csrf = new Response("Forbidden", { status: 403 });
expect(assetRestErrorResponse(csrf)).toBe(csrf);
const response = assetRestMethodNotAllowed([
assetResourceApiOperations.getAsset,
assetResourceApiOperations.updateAsset,
]);
expect(response.status).toBe(405);
expect(response.headers.get("allow")).toBe("GET, PATCH");
});
test("does not expose unexpected internal errors", async () => {
const report = vi.spyOn(console, "error").mockImplementation(() => {});
const response = assetRestErrorResponse(new Error("database password"));
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
errors: "Internal Assets API error",
});
report.mockRestore();
});
test("bounds mutation bodies before parsing JSON", async () => {
await expect(
readAssetRestJson(
new Request("https://example.com/rest/assets", {
method: "PATCH",
headers: {
"content-type": "application/json",
"content-length": String(
assetResourceLimits.restMutationRequestBytes + 1
),
},
body: "{}",
})
)
).rejects.toBeInstanceOf(AssetRestPayloadTooLargeError);
await expect(
readAssetRestJson(
new Request("https://example.com/rest/assets", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({
description: "x".repeat(
assetResourceLimits.restMutationRequestBytes
),
}),
})
)
).rejects.toBeInstanceOf(AssetRestPayloadTooLargeError);
});
test("rejects malformed UTF-8 in JSON bodies", async () => {
const prefix = new TextEncoder().encode('{"description":"');
const suffix = new TextEncoder().encode('"}');
const body = new Uint8Array(prefix.byteLength + 1 + suffix.byteLength);
body.set(prefix);
body[prefix.byteLength] = 0xff;
body.set(suffix, prefix.byteLength + 1);
await expect(
readAssetRestJson(
new Request("https://example.com/rest/assets", {
method: "PATCH",
body,
})
)
).rejects.toBeInstanceOf(AssetRestRequestError);
});
test("distinguishes a missing content body from an unsupported method", () => {
expect(() =>
requireAssetRestBody(
new Request("https://example.com/rest/assets/asset/content", {
method: "POST",
})
)
).toThrow(AssetRestRequestError);
expect(
requireAssetRestBody(
new Request("https://example.com/rest/assets/asset/content", {
method: "POST",
body: "content",
})
)
).toBeInstanceOf(ReadableStream);
});
test("bounds upload metadata carried in headers", () => {
expect(() =>
parseAssetRestDescription(
"x".repeat(assetResourceLimits.assetDescriptionCharacters + 1)
)
).toThrow(AssetRestRequestError);
expect(() =>
parseAssetRestMetadataHeader(
"x".repeat(assetResourceLimits.restMutationRequestBytes + 1)
)
).toThrow(AssetRestPayloadTooLargeError);
expect(() => parseAssetRestMetadataHeader("{")).toThrow(
AssetRestRequestError
);
});
test("parses bounded multipart forms and rejects oversized forms", async () => {
const form = new FormData();
form.set("projectId", "project-1");
form.set("filename", "post.md");
const parsed = await readAssetRestFormData(
new Request("https://example.com/rest/assets/uploads", {
method: "POST",
body: form,
})
);
expect(Object.fromEntries(parsed)).toEqual({
projectId: "project-1",
filename: "post.md",
});
await expect(
readAssetRestFormData(
new Request("https://example.com/rest/assets/uploads", {
method: "POST",
headers: {
"content-type": "multipart/form-data; boundary=test",
"content-length": String(
assetResourceLimits.restMutationRequestBytes + 1
),
},
body: "--test--",
})
)
).rejects.toBeInstanceOf(AssetRestPayloadTooLargeError);
});
});
@@ -0,0 +1,245 @@
import { json } from "@remix-run/server-runtime";
import {
readBoundedRequestBytes,
RequestByteLimitError,
} from "@webstudio-is/content-engine";
import { decodeUtf8 } from "@webstudio-is/content-engine/compiler";
import {
AssetRepositoryConflictError,
AssetRepositoryNotFoundError,
AssetRevisionConflictError,
AssetUploadSizeLimitError,
PostgresAssetRepository,
} from "@webstudio-is/asset-uploader/server";
import type { ProjectPermit } from "@webstudio-is/trpc-interface/index.server";
import { assetFolderIssue } from "@webstudio-is/sdk";
import { assetResourceLimits } from "@webstudio-is/sdk/asset-resource-limits";
import { parseBuilderUrl } from "@webstudio-is/protocol";
import { ZodError } from "zod";
import { createAssetClient } from "~/shared/asset-client";
import { parseError } from "~/shared/error/error-parse";
import { privateNoStoreResponseHeaders } from "./cache-control.server";
import {
authorizeApiProject,
getApiAuthorizationFailure,
} from "./api-auth.server";
export class AssetRestRequestError extends Error {}
export class AssetRestRangeError extends Error {}
export class AssetRestPayloadTooLargeError extends Error {}
export const requireAssetRestBody = (request: Request) => {
if (request.body === null) {
throw new AssetRestRequestError("Asset content is required");
}
return request.body;
};
const readAssetRestBody = async (request: Request) => {
try {
return await readBoundedRequestBytes(
request,
assetResourceLimits.restMutationRequestBytes
);
} catch (error) {
if (error instanceof RequestByteLimitError) {
throw new AssetRestPayloadTooLargeError(
"Assets API request exceeds the byte limit",
{ cause: error }
);
}
throw error;
}
};
export const readAssetRestJson = async (request: Request) => {
const bytes = await readAssetRestBody(request);
try {
return JSON.parse(decodeUtf8(bytes)) as unknown;
} catch (error) {
throw new AssetRestRequestError("Assets API request JSON is invalid", {
cause: error,
});
}
};
export const readAssetRestFormData = async (request: Request) => {
const bytes = await readAssetRestBody(request);
try {
return await new Request(request.url, {
method: request.method,
headers: request.headers,
body: bytes,
}).formData();
} catch (error) {
throw new AssetRestRequestError("Assets API form data is invalid", {
cause: error,
});
}
};
const parseRequiredAssetString = ({
value,
maximumCharacters,
name,
}: {
value: string | undefined | null;
maximumCharacters: number;
name: string;
}) => {
if (
value === undefined ||
value === null ||
value.length === 0 ||
value.length > maximumCharacters
) {
throw new AssetRestRequestError(`Assets API ${name} is invalid`);
}
return value;
};
export const parseAssetRestIdentifier = (value: string | undefined | null) =>
parseRequiredAssetString({
value,
maximumCharacters: assetResourceLimits.assetIdentifierCharacters,
name: "identifier",
});
export const parseAssetRestFilename = (value: string | undefined | null) =>
parseRequiredAssetString({
value,
maximumCharacters: assetResourceLimits.assetFilenameCharacters,
name: "filename",
});
export const parseAssetRestDescription = (value: string | undefined | null) => {
if (
value !== undefined &&
value !== null &&
value.length > assetResourceLimits.assetDescriptionCharacters
) {
throw new AssetRestRequestError("Assets API description is invalid");
}
return value ?? undefined;
};
export const parseAssetRestMetadataHeader = (
value: string | undefined | null
) => {
if (value === undefined || value === null) {
return;
}
if (
new TextEncoder().encode(value).byteLength >
assetResourceLimits.restMutationRequestBytes
) {
throw new AssetRestPayloadTooLargeError(
"Assets API metadata header exceeds the byte limit"
);
}
try {
return JSON.parse(value) as unknown;
} catch (error) {
throw new AssetRestRequestError("Assets API metadata header is invalid", {
cause: error,
});
}
};
export const getAssetRestProjectId = (request: Request) => {
const projectId =
parseBuilderUrl(request.url).projectId ??
new URL(request.url).searchParams.get("projectId");
return parseAssetRestIdentifier(projectId);
};
export const createAssetRestRepositoryForProject = async (
request: Request,
projectId: string,
permit: ProjectPermit = "view"
) =>
new PostgresAssetRepository({
projectId,
context: await authorizeApiProject(request, projectId, permit),
assetStore: createAssetClient(),
});
export const createAssetRestRepository = async (
request: Request,
permit: ProjectPermit = "view"
) =>
createAssetRestRepositoryForProject(
request,
getAssetRestProjectId(request),
permit
);
const getAssetRestErrorStatus = (error: unknown) => {
const authorizationFailure = getApiAuthorizationFailure(error);
if (authorizationFailure !== undefined) {
return authorizationFailure.status;
}
if (error instanceof AssetRepositoryNotFoundError) {
return 404;
}
if (
error instanceof AssetRepositoryConflictError ||
error instanceof AssetRevisionConflictError
) {
return 409;
}
if (
error instanceof ZodError &&
error.issues.some(
({ message }) => message === assetFolderIssue.duplicateName
)
) {
return 409;
}
if (error instanceof AssetRestRangeError) {
return 416;
}
if (
error instanceof AssetRestPayloadTooLargeError ||
error instanceof AssetUploadSizeLimitError
) {
return 413;
}
if (error instanceof AssetRestRequestError || error instanceof ZodError) {
return 400;
}
return 500;
};
export const assetRestErrorResponse = (error: unknown) => {
if (error instanceof Response) {
return error;
}
const status = getAssetRestErrorStatus(error);
if (status === 500) {
console.error(error);
}
return json(
{
errors:
status === 500
? "Internal Assets API error"
: parseError(error).message,
},
{ status, headers: privateNoStoreResponseHeaders }
);
};
export const assetRestMethodNotAllowed = (
allowed: readonly { method: string }[]
) =>
json(
{ errors: "Method not allowed" },
{
status: 405,
headers: {
...privateNoStoreResponseHeaders,
Allow: allowed.map(({ method }) => method.toUpperCase()).join(", "),
},
}
);
@@ -9,10 +9,13 @@ afterEach(() => {
});
test("keeps raw browser upload request bodies unchanged", async () => {
const request = new Request("https://webstudio.is/rest/assets/image.png", {
method: "POST",
body: "raw file",
});
const request = new Request(
"https://webstudio.is/rest/assets/uploads/image.png",
{
method: "POST",
body: "raw file",
}
);
await expect(
new Response(
@@ -22,11 +25,14 @@ test("keeps raw browser upload request bodies unchanged", async () => {
});
test("keeps browser JSON file uploads as JSON file content", async () => {
const request = new Request("https://webstudio.is/rest/assets/data.json", {
method: "POST",
body: JSON.stringify({ value: 1 }),
headers: { "Content-Type": "application/json" },
});
const request = new Request(
"https://webstudio.is/rest/assets/uploads/data.json",
{
method: "POST",
body: JSON.stringify({ value: 1 }),
headers: { "Content-Type": "application/json" },
}
);
await expect(
new Response(await getBrowserUploadBody(request, "application/json")).text()
@@ -36,11 +42,14 @@ test("keeps browser JSON file uploads as JSON file content", async () => {
test("keeps browser URL image uploads by fetching the remote image body", async () => {
const fetch = vi.fn(async () => new Response("remote image"));
vi.stubGlobal("fetch", fetch);
const request = new Request("https://webstudio.is/rest/assets/image.png", {
method: "POST",
body: JSON.stringify({ url: "https://example.com/image.png" }),
headers: { "Content-Type": "application/json" },
});
const request = new Request(
"https://webstudio.is/rest/assets/uploads/image.png",
{
method: "POST",
body: JSON.stringify({ url: "https://example.com/image.png" }),
headers: { "Content-Type": "application/json" },
}
);
await expect(
new Response(await getBrowserUploadBody(request, "application/json")).text()
@@ -56,11 +65,14 @@ test("reports browser URL image fetch failures", async () => {
"fetch",
vi.fn(async () => new Response("not found", { status: 404 }))
);
const request = new Request("https://webstudio.is/rest/assets/image.png", {
method: "POST",
body: JSON.stringify({ url: "https://example.com/image.png" }),
headers: { "Content-Type": "application/json" },
});
const request = new Request(
"https://webstudio.is/rest/assets/uploads/image.png",
{
method: "POST",
body: JSON.stringify({ url: "https://example.com/image.png" }),
headers: { "Content-Type": "application/json" },
}
);
await expect(
getBrowserUploadBody(request, "application/json")
@@ -4,11 +4,17 @@ import { createPublishedProjectBundleFixture } from "@webstudio-is/protocol/fixt
import { buildRouter, __testing__ } from "./build-router.server";
import { authorizeProject } from "@webstudio-is/trpc-interface/index.server";
import { getApiCompatibilityPayload } from "@webstudio-is/trpc-interface/api-compatibility";
import { createStructuredAssetQueryResourceBody } from "@webstudio-is/sdk";
import {
createAssetIndex,
createCanonicalAssetFileEntry,
} from "@webstudio-is/content-engine/compiler";
const {
assertCliBundleVersion,
createImportProjectBundleHandler,
prepareProjectBundleForClient,
getContentDatabasePublishDiagnostics,
} = __testing__;
afterEach(() => {
@@ -75,6 +81,91 @@ describe("build router project bundle compatibility", () => {
});
});
describe("content database publish diagnostics", () => {
test("reports bounded database stats and conservatively affected resources", async () => {
const resourceId = "blog-posts";
const createEntry = (id: string, bytes: number) =>
createCanonicalAssetFileEntry({
projectId: "source-project",
document: {
_id: id,
_type: "asset.file",
name: `${id}.md`,
path: `blog/${id}.md`,
key: id,
extension: "md",
mimeType: "text/markdown",
size: bytes,
revision: `revision-${id}`,
contentRef: `files/${id}.md`,
properties: { value: "x".repeat(bytes) },
},
});
const assetIndex = await createAssetIndex({
projectId: "source-project",
entries: [createEntry("small", 100), createEntry("large", 10_000)],
maxBytes: 5_000,
});
const bundle = createPublishedProjectBundleFixture({
build: {
props: [
[
"posts-prop",
{
id: "posts-prop",
instanceId: "collection",
name: "data",
type: "resource",
value: resourceId,
},
],
],
resources: [
[
resourceId,
{
id: resourceId,
name: "Blog posts",
control: "system",
method: "post",
url: '"/$resources/assets"',
headers: [],
body: createStructuredAssetQueryResourceBody({
where: { all: [] },
sort: [],
limit: "pageSize",
offset: "0",
output: { mode: "base", includeMetadata: true },
content: { mode: "none" },
}),
},
],
],
},
assetIndex,
});
expect(getContentDatabasePublishDiagnostics(bundle)).toMatchObject({
stats: {
maxBytes: 5_000,
includedDocumentCount: 1,
omittedDocumentCount: 1,
truncated: true,
},
potentiallyAffectedResources: [{ id: resourceId, name: "Blog posts" }],
hasDynamicValues: true,
});
});
test("does nothing when the build has no content query", () => {
expect(
getContentDatabasePublishDiagnostics(
createPublishedProjectBundleFixture()
)
).toBeUndefined();
});
});
describe("build router jsx fragment conversion", () => {
test("converts Webstudio JSX through the shared runtime converter", async () => {
vi.spyOn(authorizeProject, "hasProjectPermit").mockResolvedValue(true);
@@ -22,7 +22,12 @@ import type { BuildPatchTransaction } from "@webstudio-is/project/index.server";
import { loadDevBuildByProjectId } from "@webstudio-is/project-build/server";
import { parseWebstudioJsxFragment } from "@webstudio-is/project-build/transfer/server";
import { serializePages } from "@webstudio-is/project-migrations/pages";
import { loadAssetDataByProject } from "@webstudio-is/asset-uploader/index.server";
import { loadAssetDataByProject } from "@webstudio-is/asset-uploader/server";
import {
createContentDatabase,
hasDynamicContentCompilationValues,
} from "@webstudio-is/content-engine";
import { createReachableAssetContentCompilationPlan } from "@webstudio-is/sdk";
import {
checkProjectBuildPermissionInput,
importProjectBundleInput,
@@ -33,6 +38,7 @@ import { removeAgentInstructionsFromProjectSettings } from "@webstudio-is/projec
import { hydrateRestorePointTransaction } from "@webstudio-is/project-build/project-session";
import {
loadProjectBundleByBuildId,
loadProjectBundleByProjectId,
loadPublishedProjectBundleByProjectId,
} from "~/shared/db";
import {
@@ -60,6 +66,36 @@ const buildBundleInput = z.object({
bundleVersion: z.union([z.string(), z.number()]).optional(),
});
const getContentDatabasePublishDiagnostics = (
bundle: z.infer<typeof publishedProjectBundle>
) => {
if (bundle.assetIndex === undefined) {
return;
}
const plan = createReachableAssetContentCompilationPlan({
props: bundle.build.props.map(([, prop]) => prop),
dataSources: bundle.build.dataSources.map(([, dataSource]) => dataSource),
resources: bundle.build.resources.map(([, resource]) => resource),
});
if (plan === undefined) {
return;
}
const resourceNameById = new Map(
bundle.build.resources.map(([, resource]) => [resource.id, resource.name])
);
const stats = createContentDatabase({
artifact: bundle.assetIndex,
}).getStats();
return {
stats,
potentiallyAffectedResources: plan.queries.map(({ id }) => ({
id,
name: resourceNameById.get(id) ?? id,
})),
hasDynamicValues: hasDynamicContentCompilationValues(plan),
};
};
const assertCliBundleVersion = (
ctx: AppContext,
clientBundleVersion: string | number | undefined
@@ -284,6 +320,15 @@ export const buildRouter = router({
);
}),
contentDatabasePublishDiagnostics: procedure
.input(z.object({ projectId: z.string() }))
.query(async ({ ctx, input }) => {
await assertProjectBuildPermit({ ctx, projectId: input.projectId });
return getContentDatabasePublishDiagnostics(
await loadProjectBundleByProjectId(input.projectId, ctx)
);
}),
checkProjectBuildPermission: procedure
.input(checkProjectBuildPermissionInput)
.query(async ({ ctx, input }) => {
@@ -412,4 +457,5 @@ export const __testing__ = {
createImportProjectBundleHandler,
assertCliBundleVersion,
prepareProjectBundleForClient,
getContentDatabasePublishDiagnostics,
};
@@ -2,12 +2,14 @@ import { createCookieSessionStorage } from "@remix-run/node";
import env from "~/env/env.server";
import { getSessionCookieNameVersion } from "./auth.server.utils";
export const builderSessionCookieName = `__Host-_session_builder_session_${getSessionCookieNameVersion()}`;
export const builderSessionStorage = createCookieSessionStorage({
cookie: {
// Using the __Host- prefix to prevent a malicious user from setting another person's session cookie
// on all subdomains of apps.webstudio.is, e.g., setting Domain=.apps.webstudio.is.
// For more information, see: https://developer.mozilla.org/en-US/docs/Web/Security/Practical_implementation_guides/Cookies#name
name: `__Host-_session_builder_session_${getSessionCookieNameVersion()}`,
name: builderSessionCookieName,
sameSite: "lax",
path: "/",
httpOnly: true,
@@ -0,0 +1,4 @@
import { parseContentDatabaseMaxBytes } from "@webstudio-is/content-engine";
export const getContentDatabaseMaxBytes = () =>
parseContentDatabaseMaxBytes(process.env.CONTENT_DATABASE_MAX_BYTES);
Loaded 100 of 582 files, more files were not shown because too many files have changed in this diff. Show more