Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions BROWSER_FILE_CAPABILITIES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Browser file capability contract

Assessment date: 2026-09-07

Glendix exposes generic browser file operations through `glendix/js/file`.
Document parsing, persistence, filename normalization, and application-specific
extension rules remain application responsibilities.

## Capability matrix

| Responsibility | Implementation | Residual Glendix FFI |
| --- | --- | --- |
| Build a MIME-typed Blob from `BitArray` | `gossamer/blob.from_bytes` | None |
| Create and revoke an object URL | `gossamer/blob.to_object_url` and `revoke_object_url` | None |
| Render a download link | Lustre or Redraw `href` and `download` attributes | None; no hidden anchor or programmatic click |
| Detect the modern picker | `glendix/js/file.picker_capability` | One predicate in `file_ffi.mjs`, because Plinth 0.11.0 has no capability query |
| Open the modern picker | `plinth/browser/file_system.show_open_file_picker` | None |
| Open the selected handle | `plinth/browser/file_system.get_file` | None |
| Read name, MIME type, size, and bytes | `plinth/browser/file` | None |
| Convert a rejected byte read to a domain error | `gleam/javascript/promise.rescue` plus typed dynamic decoding | None |
| Legacy fallback picker | Visible Lustre/Redraw `<input type="file">` owned by the application | No imperative hidden-input adapter |

The capability predicate is the only production JavaScript added for this
feature. It does not perform selection or file access. Its retention rationale
and contract belong in the retained-FFI inventory tracked by issue #21.

## Download contract

`file.download`:

- rejects a filename that is empty after trimming;
- rejects an empty or malformed concrete MIME type;
- preserves every non-empty filename exactly, without sanitizing it;
- creates the Blob and object URL through Gossamer;
- exposes URL, filename, and MIME accessors for a declarative anchor.

Call `file.release` when the anchor is replaced or its component is disposed.
Repeated release is safe because Gossamer follows `URL.revokeObjectURL`
semantics.

```gleam
import gleam/bit_array
import gleam/result
import glendix/js/file
import lustre/attribute
import lustre/element
import lustre/element/html

pub fn download_link() -> Result(element.Element(message), file.DownloadError) {
use resource <- result.try(file.download(
from: bit_array.from_string("workbook bytes"),
named: "workbook.ic",
with_mime_type: "application/octet-stream",
))

Ok(html.a(
[
attribute.href(file.download_url(resource)),
attribute.download(file.download_filename(resource)),
],
[html.text("Download")],
))
}
```

The component that stores `resource` must call `file.release(resource)` in its
disposal path. Redraw uses its equivalent `href` and `download` attributes.

## Picker contract

`file.picker` requires a positive maximum byte size. Accepted values can be:

- exact MIME types such as `application/json`;
- MIME wildcards such as `image/*`;
- dot-prefixed extensions such as `.ic`;
- an empty list, meaning any type.

Duplicates are removed in first-seen order. The stable list is available
through `file.accepted_types` for a visible fallback input's `accept`
attribute.

`file.pick` uses one file. Plinth 0.11.0 does not expose picker options, and the
browser call is single-select by default; if a browser returns several handles,
Glendix uses the first handle in browser order.

Validation occurs before reading bytes:

1. a zero-byte file returns `SelectedFileWasEmpty`;
2. a file larger than the maximum returns `SelectedFileWasTooLarge`;
3. a non-matching MIME type/extension returns
`SelectedFileTypeWasNotAccepted`;
4. only a file that passes metadata checks is read.

A file exactly equal to the maximum is accepted. Picker cancellation and an
empty handle list return `SelectionCancelled`. Unsupported browsers return
`PickerUnsupported` without attempting the Plinth picker call. Other picker,
handle-open, and byte-read errors preserve their operation and reason.

## Fallback policy

Glendix does not claim a package-only cross-browser imperative picker.
Applications that support browsers without `showOpenFilePicker` must choose
one of these explicit policies:

1. require the modern picker and display an unsupported-capability message; or
2. render a visible Lustre/Redraw file input and process its event in the
application UI layer.

Glendix does not create, click, or remove a hidden input, because Plinth 0.11.0
does not fully type `input.files`, programmatic click, one-shot listeners, and
cancellation as one portable operation.
14 changes: 14 additions & 0 deletions README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,20 @@ unsafe/dynamic な境界です。`__proto__` を通常のデータとして保
JavaScript setter の意味を維持するため、信頼できないプロパティ名を
`reflect.set` に渡してはいけません。

## ブラウザーファイルのダウンロードと選択

`glendix/js/file` は `gossamer/blob` で宣言的なダウンロードリソースを
作成し、`plinth/browser/file_system` と `plinth/browser/file` でモダンな
ブラウザーファイル選択を読み取ります。通常の Lustre/Redraw アンカーとして
描画し、置換または破棄時に `file.release` を呼び出します。

`showOpenFilePicker` がない場合は `PickerUnsupported` を返します。より広い
ブラウザー対応が必要なアプリケーションは、安定した重複除去済みの
`file.accepted_types` を使って可視のファイル入力を描画できます。Glendix は
隠し入力や隠しアンカーを生成してクリックする FFI を追加しません。API、
エラー、フォールバック方針、ecosystem 対応表は
[browser file capability contract](BROWSER_FILE_CAPABILITIES.md) を参照してください。

## WebAssembly 依存関係

Glendix は、ブラウザ toolchain が使用する次の標準的な静的 URL 形式の
Expand Down
14 changes: 14 additions & 0 deletions README.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,20 @@ pub fn themed_component(
`object.from_entries`에만 적용된다. Reflection 대입은 일반 JavaScript setter 의미를
유지하므로 신뢰할 수 없는 속성 이름을 `reflect.set`에 전달하면 안 된다.

## 브라우저 파일 다운로드와 선택

`glendix/js/file`은 `gossamer/blob`으로 선언형 다운로드 리소스를 만들고
`plinth/browser/file_system` 및 `plinth/browser/file`로 최신 브라우저 파일
선택을 읽는다. 다운로드는 일반 Lustre/Redraw 앵커로 렌더링하고 컴포넌트가
교체되거나 해제될 때 `file.release`를 호출한다.

`showOpenFilePicker`가 없으면 `PickerUnsupported`를 반환한다. 더 넓은 브라우저
지원이 필요하면 안정적으로 중복 제거된 `file.accepted_types`를 사용해 보이는
파일 입력을 렌더링할 수 있다. Glendix는 숨겨진 입력이나 앵커를 생성해서
클릭하는 FFI를 추가하지 않는다. 전체 API, 오류, 폴백 정책 및 생태계 사용
현황은 [브라우저 파일 capability 계약](BROWSER_FILE_CAPABILITIES.md)을
참고한다.

## WebAssembly 의존성

Glendix는 브라우저 도구가 사용하는 다음 표준 정적 URL 형식의 WebAssembly
Expand Down
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,27 @@ property or method exists. The `__proto__`-as-data guarantee applies to
`object.from_entries`; reflective assignment retains ordinary JavaScript setter
semantics, so never pass untrusted property names to `reflect.set`.

## Browser file downloads and selection

`glendix/js/file` creates declarative download resources through
`gossamer/blob` and reads a modern browser file selection through
`plinth/browser/file_system` and `plinth/browser/file`.

Render downloads as normal Lustre or Redraw anchors with the resource's URL and
filename, then call `file.release` when the anchor is replaced or disposed.
Glendix does not create or click a hidden download anchor.

The picker reports `PickerUnsupported` when `showOpenFilePicker` is unavailable.
Applications that need broader browser support can render a visible file input
using the stable, de-duplicated `file.accepted_types` list; Glendix does not add
an imperative hidden-input fallback. Empty files, maximum-size overflow, type
mismatch, cancellation, handle failures, and read failures have distinct typed
errors. Application parsing and filename policy remain outside Glendix.

See [the browser file capability contract](BROWSER_FILE_CAPABILITIES.md) for
the API examples, validation order, fallback policy, and ecosystem/residual-FFI
matrix.

## WebAssembly dependencies

Glendix automatically packages browser WebAssembly modules referenced with the
Expand Down
1 change: 1 addition & 0 deletions gleam.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ plinth = ">= 0.11.0 and < 1.0.0"
simplifile = ">= 2.6.0 and < 3.0.0"
xmlm = ">= 1.0.1 and < 2.0.0"
tom = ">= 2.1.0 and < 3.0.0"
gossamer = ">= 10.0.0 and < 11.0.0"

[dev_dependencies]
gleeunit = ">= 1.11.0 and < 2.0.0"
11 changes: 11 additions & 0 deletions glendix_guide.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,17 @@ pub fn themed_component(
`object.from_entries` は通常の文字列キーの順序を保持し、重複キーには最後の値を
採用し、特殊なキーもデータとして安全に格納します。

## ブラウザーファイル capability

`glendix/js/file` は Gossamer ベースの宣言的ダウンロードリソースと、
Plinth ベースのモダンなファイル選択を提供します。対応状況、キャンセル、
メタデータ検証、ハンドルを開く処理、バイト読み取りの失敗をそれぞれ型で
返します。隠し入力のフォールバック、アプリケーション固有の解析やファイル名
方針は意図的に含みません。

API、検証順序、フォールバック方針、ecosystem/残存 FFI の対応表は
[browser file capability contract](BROWSER_FILE_CAPABILITIES.md) を参照してください。

## Marketplace ウィジェットとの組み合わせ

```toml
Expand Down
10 changes: 10 additions & 0 deletions glendix_guide.ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,16 @@ pub fn themed_component(
`object.from_entries`는 일반 문자열 키 순서를 보존하고, 중복 키에는 마지막 값을
적용하며, 특수 키도 데이터로 안전하게 저장한다.

## 브라우저 파일 capability

`glendix/js/file`은 Gossamer 기반 선언형 다운로드 리소스와 Plinth 기반 최신
파일 선택을 제공한다. 지원 여부, 취소, 메타데이터 검증, 핸들 열기, 바이트 읽기
실패를 각각 타입으로 반환한다. 숨겨진 입력 폴백과 애플리케이션별 파싱 및 파일명
정책은 의도적으로 포함하지 않는다.

API, 검증 순서, 폴백 정책 및 생태계/잔여 FFI 현황은
[브라우저 파일 capability 계약](BROWSER_FILE_CAPABILITIES.md)을 참고한다.

## Marketplace 위젯과 조합

```toml
Expand Down
11 changes: 11 additions & 0 deletions glendix_guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,17 @@ pub fn themed_component(
`object.from_entries` preserves ordinary string-key order, keeps the last value
for duplicate keys, and safely stores special keys as data.

## Browser file capabilities

Use `glendix/js/file` for Gossamer-backed declarative download resources and
Plinth-backed modern file selection. It returns typed capability, cancellation,
metadata-validation, handle-open, and byte-read errors. Glendix deliberately
does not add a hidden-input fallback or application-specific parsing and
filename policy.

See [the browser file capability contract](BROWSER_FILE_CAPABILITIES.md) for
the API, validation order, fallback policy, and ecosystem/residual-FFI matrix.

## Installed Marketplace widgets

Package acquisition is a separate step owned by mxpak:
Expand Down
4 changes: 4 additions & 0 deletions manifest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,12 @@ packages = [
{ name = "gleam_javascript", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_javascript", source = "hex", outer_checksum = "D542C4B4F40E942F5D3372D524419FA521A7BB92D621AF696CC286E89D882D55" },
{ name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" },
{ name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" },
{ name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" },
{ name = "gleam_stdlib", version = "1.0.3", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "1F543AFBA5D33DA493E6087F4E4C4F20D899411343512686C98A8ABB2963CF22" },
{ name = "gleam_time", version = "1.10.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56539216E4C4B1748714652AB38F0BD16B9101F61DB62769FDC7CD42A8E5E833" },
{ name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" },
{ name = "gleeunit", version = "1.11.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "EC31ABA74256AEA531EDF8169931D775BBB384FED0A8A1BDC4DD9354E3E21826" },
{ name = "gossamer", version = "10.0.0", build_tools = ["gleam"], requirements = ["gleam_fetch", "gleam_http", "gleam_javascript", "gleam_regexp", "gleam_stdlib", "gleam_time", "gleam_yielder"], otp_app = "gossamer", source = "hex", outer_checksum = "BFE6A981CA1BF31AC2925C926241BD9918D809C2CBCA07909024BC765A25013B" },
{ name = "houdini", version = "1.2.1", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "6F8AC2F12974567FB744BEA66AC93CEB76AAEA19AD28564623F76CDA9BC26A85" },
{ name = "lustre", version = "5.7.1", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "663A2D1A3458914CA537A42AC07E601DD3FD85415EBDAF4717F2DFF6E86F90F9" },
{ name = "mendraw", version = "2.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "redraw", "redraw_dom"], otp_app = "mendraw", source = "hex", outer_checksum = "BB913054A5FEDC872AAB114FFADDFB791DACFE605B3DCEEAD3620F11BB59D090" },
Expand All @@ -36,6 +39,7 @@ gleam_javascript = { version = ">= 1.0.1 and < 2.0.0" }
gleam_json = { version = ">= 3.1.0 and < 4.0.0" }
gleam_stdlib = { version = ">= 1.0.3 and < 2.0.0" }
gleeunit = { version = ">= 1.11.0 and < 2.0.0" }
gossamer = { version = ">= 10.0.0 and < 11.0.0" }
lustre = { version = ">= 5.7.1 and < 6.0.0" }
mendraw = { version = ">= 2.0.0 and < 3.0.0" }
plinth = { version = ">= 0.11.0 and < 1.0.0" }
Expand Down
Loading
Loading