lecodes-sdk 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +114 -0
- package/dist/global.d.ts +503 -0
- package/dist/host.d.ts +61 -0
- package/dist/inject.js +4730 -0
- package/dist/types/animate/animate.d.ts +20 -0
- package/dist/types/animate/bezier.d.ts +2 -0
- package/dist/types/animate/easings.d.ts +21 -0
- package/dist/types/canvas/Canvas.d.ts +168 -0
- package/dist/types/core/Aspect.d.ts +171 -0
- package/dist/types/core/InspectorUI.d.ts +88 -0
- package/dist/types/core/StateMachine.d.ts +81 -0
- package/dist/types/core/color.d.ts +13 -0
- package/dist/types/core/compWrite.d.ts +11 -0
- package/dist/types/core/events.d.ts +11 -0
- package/dist/types/core/fields.d.ts +58 -0
- package/dist/types/core/registry.d.ts +7 -0
- package/dist/types/core/signals.d.ts +29 -0
- package/dist/types/core/time.d.ts +31 -0
- package/dist/types/g2/Camera2D.d.ts +19 -0
- package/dist/types/g2/CharacterController2D.d.ts +134 -0
- package/dist/types/g2/Node2D.d.ts +91 -0
- package/dist/types/g2/OneWay2D.d.ts +24 -0
- package/dist/types/g2/Physics2D.d.ts +147 -0
- package/dist/types/g2/Scene2D.d.ts +72 -0
- package/dist/types/g2/Shape2D.d.ts +58 -0
- package/dist/types/g2/Sprite.d.ts +51 -0
- package/dist/types/g2/SpriteAnimation.d.ts +50 -0
- package/dist/types/g2/SpriteSheet.d.ts +69 -0
- package/dist/types/g2/Texture2D.d.ts +16 -0
- package/dist/types/g2/Tilemap.d.ts +20 -0
- package/dist/types/g2/Tileset.d.ts +32 -0
- package/dist/types/g2/Trigger2D.d.ts +20 -0
- package/dist/types/g2/autotile.d.ts +133 -0
- package/dist/types/g2/cells.d.ts +14 -0
- package/dist/types/g2/defineScene2d.d.ts +141 -0
- package/dist/types/g2/groups2d.d.ts +16 -0
- package/dist/types/g2/loop.d.ts +4 -0
- package/dist/types/g2/scenarios2d.d.ts +30 -0
- package/dist/types/g2/touch.d.ts +2 -0
- package/dist/types/gl/Camera.d.ts +79 -0
- package/dist/types/gl/CameraPlace.d.ts +26 -0
- package/dist/types/gl/CharacterController.d.ts +98 -0
- package/dist/types/gl/Gearbox.d.ts +86 -0
- package/dist/types/gl/Geometry.d.ts +39 -0
- package/dist/types/gl/IK.d.ts +53 -0
- package/dist/types/gl/InstancedMesh.d.ts +46 -0
- package/dist/types/gl/Light.d.ts +73 -0
- package/dist/types/gl/Lightmap.d.ts +85 -0
- package/dist/types/gl/Locomotion.d.ts +279 -0
- package/dist/types/gl/Material.d.ts +100 -0
- package/dist/types/gl/Mesh.d.ts +33 -0
- package/dist/types/gl/Model.d.ts +53 -0
- package/dist/types/gl/NavAgent.d.ts +99 -0
- package/dist/types/gl/NavMesh.d.ts +169 -0
- package/dist/types/gl/Node.d.ts +99 -0
- package/dist/types/gl/Noise.d.ts +20 -0
- package/dist/types/gl/Particles.d.ts +277 -0
- package/dist/types/gl/Physics.d.ts +91 -0
- package/dist/types/gl/Plane.d.ts +19 -0
- package/dist/types/gl/Ragdoll.d.ts +86 -0
- package/dist/types/gl/Ray.d.ts +8 -0
- package/dist/types/gl/Scene.d.ts +221 -0
- package/dist/types/gl/Shape.d.ts +122 -0
- package/dist/types/gl/Terrain.d.ts +280 -0
- package/dist/types/gl/Texture.d.ts +27 -0
- package/dist/types/gl/Trigger.d.ts +10 -0
- package/dist/types/gl/Vehicle.d.ts +191 -0
- package/dist/types/gl/Wheel.d.ts +95 -0
- package/dist/types/gl/animation/AnimationClip.d.ts +60 -0
- package/dist/types/gl/animation/Animator.d.ts +219 -0
- package/dist/types/gl/animation/Layer.d.ts +31 -0
- package/dist/types/gl/animation/Loop.d.ts +17 -0
- package/dist/types/gl/animation/Playback.d.ts +36 -0
- package/dist/types/gl/animation/core.d.ts +367 -0
- package/dist/types/gl/controls.d.ts +19 -0
- package/dist/types/gl/physicsEvents.d.ts +1 -0
- package/dist/types/gl/scenarios.d.ts +98 -0
- package/dist/types/gl/state.d.ts +5 -0
- package/dist/types/gl/terrainMesh.d.ts +46 -0
- package/dist/types/gl/touch.d.ts +2 -0
- package/dist/types/inject.d.ts +123 -0
- package/dist/types/math/Mathf.d.ts +39 -0
- package/dist/types/math/mat4.d.ts +66 -0
- package/dist/types/math/quat.d.ts +53 -0
- package/dist/types/math/vec.d.ts +129 -0
- package/dist/types/net/codec.d.ts +25 -0
- package/dist/types/net/core.d.ts +106 -0
- package/dist/types/net/index.d.ts +80 -0
- package/dist/types/net/replication.d.ts +101 -0
- package/dist/types/plugins/camera.d.ts +25 -0
- package/dist/types/plugins/geolocation.d.ts +39 -0
- package/dist/types/plugins/oauth.d.ts +25 -0
- package/dist/types/plugins/permission.d.ts +1 -0
- package/dist/types/plugins/push.d.ts +46 -0
- package/dist/types/plugins/qr.d.ts +21 -0
- package/dist/types/plugins/service.d.ts +33 -0
- package/dist/types/runtime/app.d.ts +67 -0
- package/dist/types/runtime/appEvents.d.ts +7 -0
- package/dist/types/runtime/channel.d.ts +1 -0
- package/dist/types/runtime/clipboard.d.ts +7 -0
- package/dist/types/runtime/datetime.d.ts +80 -0
- package/dist/types/runtime/device.d.ts +126 -0
- package/dist/types/runtime/fetch.d.ts +31 -0
- package/dist/types/runtime/files.d.ts +33 -0
- package/dist/types/runtime/input.d.ts +121 -0
- package/dist/types/runtime/media.d.ts +46 -0
- package/dist/types/runtime/misc.d.ts +11 -0
- package/dist/types/runtime/net.d.ts +15 -0
- package/dist/types/runtime/rpc.d.ts +44 -0
- package/dist/types/runtime/service.d.ts +1 -0
- package/dist/types/runtime/share.d.ts +2 -0
- package/dist/types/runtime/storage.d.ts +5 -0
- package/dist/types/runtime/touch.d.ts +56 -0
- package/dist/types/scene/defineScene.d.ts +296 -0
- package/dist/types/scene/editorPlugins.d.ts +82 -0
- package/dist/types/scene/gizmos.d.ts +56 -0
- package/dist/types/scene/grammar.d.ts +56 -0
- package/dist/types/scene/level.d.ts +98 -0
- package/dist/types/scene/material.d.ts +76 -0
- package/dist/types/server/auth/appConfig.d.ts +16 -0
- package/dist/types/server/auth/global.d.ts +56 -0
- package/dist/types/server/auth/models.d.ts +164 -0
- package/dist/types/server/auth/types.d.ts +56 -0
- package/dist/types/server/channel.d.ts +28 -0
- package/dist/types/server/context.d.ts +27 -0
- package/dist/types/server/db/defineDb.d.ts +36 -0
- package/dist/types/server/db/fields.d.ts +146 -0
- package/dist/types/server/db/index.d.ts +6 -0
- package/dist/types/server/db/marci/query.d.ts +326 -0
- package/dist/types/server/db/types.d.ts +163 -0
- package/dist/types/server/errors.d.ts +8 -0
- package/dist/types/server/inject.d.ts +5 -0
- package/dist/types/ui/NativeView.d.ts +38 -0
- package/dist/types/ui/UI.d.ts +27 -0
- package/dist/types/ui/UIBottomSheet.d.ts +71 -0
- package/dist/types/ui/UIButton.d.ts +30 -0
- package/dist/types/ui/UIContainer.d.ts +33 -0
- package/dist/types/ui/UIImage.d.ts +35 -0
- package/dist/types/ui/UIInput.d.ts +110 -0
- package/dist/types/ui/UIModal.d.ts +70 -0
- package/dist/types/ui/UINode.d.ts +363 -0
- package/dist/types/ui/UIPager.d.ts +123 -0
- package/dist/types/ui/UIPopover.d.ts +39 -0
- package/dist/types/ui/UIScreen.d.ts +57 -0
- package/dist/types/ui/UIScrollable.d.ts +40 -0
- package/dist/types/ui/UISpacer.d.ts +9 -0
- package/dist/types/ui/UITabs.d.ts +96 -0
- package/dist/types/ui/UIText.d.ts +19 -0
- package/dist/types/ui/UIVideo.d.ts +28 -0
- package/dist/types/ui/UIVirtualizedList.d.ts +97 -0
- package/dist/types/ui/UIWidget.d.ts +65 -0
- package/dist/types/ui/fonts.d.ts +9 -0
- package/dist/types/ui/presentable.d.ts +68 -0
- package/dist/types/ui/router.d.ts +40 -0
- package/dist/types/ui/theme.d.ts +26 -0
- package/dist/types.json +1 -0
- package/package.json +46 -0
- package/prompts/2d.md +395 -0
- package/prompts/3d-scene-files.md +113 -0
- package/prompts/3d-scene.md +119 -0
- package/prompts/3d.md +303 -0
- package/prompts/README.md +142 -0
- package/prompts/ar.md +109 -0
- package/prompts/canvas.md +27 -0
- package/prompts/compose.ts +46 -0
- package/prompts/concept.md +49 -0
- package/prompts/core-design.md +75 -0
- package/prompts/core.md +264 -0
- package/prompts/design.md +218 -0
- package/prompts/dist/2d-game.md +1530 -0
- package/prompts/dist/3d-app.md +1672 -0
- package/prompts/dist/ar-app.md +1548 -0
- package/prompts/dist/concept.md +49 -0
- package/prompts/dist/design.md +498 -0
- package/prompts/dist/ui-app.md +1134 -0
- package/prompts/index.md +37 -0
- package/prompts/intake-prompt.md +33 -0
- package/prompts/namer-prompt.md +12 -0
- package/prompts/router-prompt.md +50 -0
- package/prompts/select.ts +111 -0
- package/prompts/ui-design.md +203 -0
- package/prompts/ui.md +803 -0
- package/src/animate/animate.ts +238 -0
- package/src/animate/bezier.ts +138 -0
- package/src/animate/easings.ts +126 -0
- package/src/bridges.d.ts +1345 -0
- package/src/canvas/Canvas.ts +305 -0
- package/src/compile/__tests__/assetIconMacro.test.ts +219 -0
- package/src/compile/__tests__/assetMacro.test.ts +74 -0
- package/src/compile/__tests__/assetName.test.ts +55 -0
- package/src/compile/__tests__/compile.test.ts +287 -0
- package/src/compile/__tests__/detectEntry.test.ts +132 -0
- package/src/compile/__tests__/fontMacro.test.ts +199 -0
- package/src/compile/aspectMacro.ts +86 -0
- package/src/compile/assetIconMacro.ts +384 -0
- package/src/compile/assetMacro.ts +146 -0
- package/src/compile/assetName.ts +50 -0
- package/src/compile/bundler.ts +287 -0
- package/src/compile/compileProject.ts +127 -0
- package/src/compile/detectEntry.ts +128 -0
- package/src/compile/fontMacro.ts +459 -0
- package/src/compile/fontRegistry.ts +78 -0
- package/src/compile/header.ts +67 -0
- package/src/compile/index.ts +100 -0
- package/src/compile/libraryImports.ts +52 -0
- package/src/compile/liteMaterial.ts +247 -0
- package/src/compile/sceneEditor.ts +88 -0
- package/src/compile/serverSplit.ts +233 -0
- package/src/compile/serverTypes.ts +227 -0
- package/src/compile/sfnt.ts +98 -0
- package/src/compile/shaderSchema.ts +202 -0
- package/src/compile/shaderTargets.ts +77 -0
- package/src/compile/sourcemap.ts +25 -0
- package/src/core/Aspect.ts +568 -0
- package/src/core/InspectorUI.ts +212 -0
- package/src/core/StateMachine.ts +308 -0
- package/src/core/__tests__/stateMachine.test.ts +132 -0
- package/src/core/color.ts +66 -0
- package/src/core/compWrite.ts +42 -0
- package/src/core/events.ts +38 -0
- package/src/core/fields.ts +120 -0
- package/src/core/registry.ts +23 -0
- package/src/core/signals.ts +277 -0
- package/src/core/time.ts +84 -0
- package/src/g2/Camera2D.ts +40 -0
- package/src/g2/CharacterController2D.ts +276 -0
- package/src/g2/Node2D.ts +267 -0
- package/src/g2/OneWay2D.ts +66 -0
- package/src/g2/Physics2D.ts +346 -0
- package/src/g2/Scene2D.ts +209 -0
- package/src/g2/Shape2D.ts +259 -0
- package/src/g2/Sprite.ts +89 -0
- package/src/g2/SpriteAnimation.ts +171 -0
- package/src/g2/SpriteSheet.ts +166 -0
- package/src/g2/Texture2D.ts +47 -0
- package/src/g2/Tilemap.ts +41 -0
- package/src/g2/Tileset.ts +71 -0
- package/src/g2/Trigger2D.ts +77 -0
- package/src/g2/autotile.ts +433 -0
- package/src/g2/cells.ts +91 -0
- package/src/g2/defineScene2d.ts +381 -0
- package/src/g2/groups2d.ts +106 -0
- package/src/g2/loop.ts +50 -0
- package/src/g2/scenarios2d.ts +69 -0
- package/src/g2/touch.ts +83 -0
- package/src/gl/Camera.ts +160 -0
- package/src/gl/CameraPlace.ts +52 -0
- package/src/gl/CharacterController.ts +238 -0
- package/src/gl/Gearbox.ts +212 -0
- package/src/gl/Geometry.ts +279 -0
- package/src/gl/IK.ts +193 -0
- package/src/gl/InstancedMesh.ts +132 -0
- package/src/gl/Light.ts +135 -0
- package/src/gl/Lightmap.ts +402 -0
- package/src/gl/Locomotion.ts +498 -0
- package/src/gl/Material.ts +279 -0
- package/src/gl/Mesh.ts +83 -0
- package/src/gl/Model.ts +124 -0
- package/src/gl/NavAgent.ts +337 -0
- package/src/gl/NavMesh.ts +397 -0
- package/src/gl/Node.ts +350 -0
- package/src/gl/Noise.ts +30 -0
- package/src/gl/Particles.ts +676 -0
- package/src/gl/Physics.ts +222 -0
- package/src/gl/Plane.ts +53 -0
- package/src/gl/Ragdoll.ts +270 -0
- package/src/gl/Ray.ts +16 -0
- package/src/gl/Scene.ts +505 -0
- package/src/gl/Shape.ts +409 -0
- package/src/gl/Terrain.ts +1092 -0
- package/src/gl/Texture.ts +63 -0
- package/src/gl/Trigger.ts +45 -0
- package/src/gl/Vehicle.ts +473 -0
- package/src/gl/Wheel.ts +240 -0
- package/src/gl/animation/AnimationClip.ts +204 -0
- package/src/gl/animation/Animator.ts +329 -0
- package/src/gl/animation/Layer.ts +39 -0
- package/src/gl/animation/Loop.ts +31 -0
- package/src/gl/animation/Playback.ts +52 -0
- package/src/gl/animation/core.ts +670 -0
- package/src/gl/controls.ts +95 -0
- package/src/gl/physicsEvents.ts +20 -0
- package/src/gl/scenarios.ts +291 -0
- package/src/gl/state.ts +6 -0
- package/src/gl/terrainMesh.ts +219 -0
- package/src/gl/touch.ts +68 -0
- package/src/host.d.ts +61 -0
- package/src/inject.ts +203 -0
- package/src/math/Mathf.ts +118 -0
- package/src/math/mat4.ts +278 -0
- package/src/math/quat.ts +232 -0
- package/src/math/vec.ts +255 -0
- package/src/net/codec.ts +119 -0
- package/src/net/core.ts +384 -0
- package/src/net/index.ts +181 -0
- package/src/net/replication.ts +622 -0
- package/src/plugins/camera.ts +81 -0
- package/src/plugins/geolocation.ts +123 -0
- package/src/plugins/oauth.ts +61 -0
- package/src/plugins/permission.ts +7 -0
- package/src/plugins/push.ts +132 -0
- package/src/plugins/qr.ts +73 -0
- package/src/plugins/service.ts +47 -0
- package/src/runtime/app.ts +101 -0
- package/src/runtime/appEvents.ts +54 -0
- package/src/runtime/channel.ts +61 -0
- package/src/runtime/clipboard.ts +20 -0
- package/src/runtime/datetime.ts +329 -0
- package/src/runtime/device.ts +293 -0
- package/src/runtime/fetch.ts +77 -0
- package/src/runtime/files.ts +108 -0
- package/src/runtime/input.ts +175 -0
- package/src/runtime/media.ts +111 -0
- package/src/runtime/misc.ts +16 -0
- package/src/runtime/net.ts +36 -0
- package/src/runtime/rpc.ts +218 -0
- package/src/runtime/service.ts +83 -0
- package/src/runtime/share.ts +9 -0
- package/src/runtime/storage.ts +13 -0
- package/src/runtime/touch.ts +76 -0
- package/src/scene/defineScene.ts +1396 -0
- package/src/scene/editorPlugins.ts +110 -0
- package/src/scene/gizmos.ts +148 -0
- package/src/scene/grammar.ts +120 -0
- package/src/scene/level.ts +296 -0
- package/src/scene/material.ts +188 -0
- package/src/server/auth/appConfig.ts +12 -0
- package/src/server/auth/global.ts +80 -0
- package/src/server/auth/host.ts +318 -0
- package/src/server/auth/models.ts +83 -0
- package/src/server/auth/types.ts +50 -0
- package/src/server/channel.ts +56 -0
- package/src/server/context.ts +36 -0
- package/src/server/db/defineDb.ts +237 -0
- package/src/server/db/fields.ts +132 -0
- package/src/server/db/httpTransport.ts +93 -0
- package/src/server/db/index.ts +7 -0
- package/src/server/db/marci/query.ts +412 -0
- package/src/server/db/types.ts +202 -0
- package/src/server/errors.ts +12 -0
- package/src/server/host.ts +74 -0
- package/src/server/inject.ts +13 -0
- package/src/server/runtime.ts +133 -0
- package/src/server/validate.ts +87 -0
- package/src/ui/NativeView.ts +142 -0
- package/src/ui/UI.ts +39 -0
- package/src/ui/UIBottomSheet.ts +139 -0
- package/src/ui/UIButton.ts +101 -0
- package/src/ui/UIContainer.ts +60 -0
- package/src/ui/UIImage.ts +83 -0
- package/src/ui/UIInput.ts +185 -0
- package/src/ui/UIModal.ts +139 -0
- package/src/ui/UINode.ts +830 -0
- package/src/ui/UIPager.ts +362 -0
- package/src/ui/UIPopover.ts +100 -0
- package/src/ui/UIScreen.ts +123 -0
- package/src/ui/UIScrollable.ts +87 -0
- package/src/ui/UISpacer.ts +14 -0
- package/src/ui/UITabs.ts +236 -0
- package/src/ui/UIText.ts +51 -0
- package/src/ui/UIVideo.ts +88 -0
- package/src/ui/UIVirtualizedList.ts +241 -0
- package/src/ui/UIWidget.ts +127 -0
- package/src/ui/fonts.ts +13 -0
- package/src/ui/presentable.ts +117 -0
- package/src/ui/router.ts +132 -0
- package/src/ui/theme.ts +84 -0
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
// A core 2D drawing surface that bakes to a texture — see docs/canvas-contract.md. It is NOT part of
|
|
2
|
+
// creator-2d/creator-gl: the texture it produces is consumed by 2D sprites, 3D materials and UI
|
|
3
|
+
// images alike ("a canvas is just a texture"), so world/label text and arbitrary 2D graphics are
|
|
4
|
+
// drawn once, per platform, on that platform's native 2D stack (web OffscreenCanvas, iOS CoreGraphics,
|
|
5
|
+
// Android Canvas).
|
|
6
|
+
//
|
|
7
|
+
// The API looks immediate but RECORDS commands into a compact buffer and flushes them to the backend
|
|
8
|
+
// ONCE per bake (one bridge crossing, not one per lineTo). Colors and fonts are CSS strings, resolved
|
|
9
|
+
// by the platform. Do all drawing AFTER `await registerFont(...)` — an unloaded font mis-measures.
|
|
10
|
+
//
|
|
11
|
+
// State (fillStyle, font, textAlign, …) is get/set like the browser; drawing calls are methods (they
|
|
12
|
+
// return `this`, so drawing still chains). `pixelRatio` is the device-pixel multiplier (browser-style
|
|
13
|
+
// `scale(sx, sy)` is a transform).
|
|
14
|
+
//
|
|
15
|
+
// const c = new Canvas(160, 40, { pixelRatio: 2 })
|
|
16
|
+
// c.font = 'bold 24px Inter'
|
|
17
|
+
// const w = c.measureText('Alice').width + 24
|
|
18
|
+
// c.resize(w, 40)
|
|
19
|
+
// c.fillStyle = '#000a'; c.roundRect(0, 0, w, 40, 12).fill()
|
|
20
|
+
// c.fillStyle = '#fff'; c.textAlign = 'center'; c.fillText('Alice', w / 2, 27)
|
|
21
|
+
// new Sprite({ texture: c }) // Canvas is a valid texture source everywhere
|
|
22
|
+
|
|
23
|
+
import { Texture2D } from "../g2/Texture2D"
|
|
24
|
+
import { File, type FetchResponse } from "../runtime/fetch"
|
|
25
|
+
import type { SvgSourceValue } from "../runtime/misc"
|
|
26
|
+
|
|
27
|
+
// Opcodes — MUST stay in sync with docs/canvas-contract.md.
|
|
28
|
+
const OP = {
|
|
29
|
+
SAVE: 0, RESTORE: 1, TRANSLATE: 2, SCALE: 3, ROTATE: 4, GLOBAL_ALPHA: 5,
|
|
30
|
+
FILL_STYLE: 6, STROKE_STYLE: 7, LINE_WIDTH: 8, LINE_JOIN: 9, LINE_CAP: 10,
|
|
31
|
+
FONT: 11, TEXT_ALIGN: 12, TEXT_BASELINE: 13,
|
|
32
|
+
BEGIN_PATH: 14, CLOSE_PATH: 15, MOVE_TO: 16, LINE_TO: 17, QUADRATIC_TO: 18, BEZIER_TO: 19,
|
|
33
|
+
ARC: 20, RECT: 21, ROUND_RECT: 22, FILL: 23, STROKE: 24,
|
|
34
|
+
FILL_RECT: 25, STROKE_RECT: 26, CLEAR_RECT: 27, FILL_TEXT: 28, STROKE_TEXT: 29,
|
|
35
|
+
DRAW_IMAGE: 30,
|
|
36
|
+
} as const
|
|
37
|
+
|
|
38
|
+
const ALIGN = { left: 0, center: 1, right: 2, start: 3, end: 4 } as const
|
|
39
|
+
const BASELINE = { alphabetic: 0, top: 1, middle: 2, bottom: 3, hanging: 4, ideographic: 5 } as const
|
|
40
|
+
const JOIN = { miter: 0, round: 1, bevel: 2 } as const
|
|
41
|
+
const CAP = { butt: 0, round: 1, square: 2 } as const
|
|
42
|
+
|
|
43
|
+
export type TextAlign = keyof typeof ALIGN
|
|
44
|
+
export type TextBaseline = keyof typeof BASELINE
|
|
45
|
+
export type LineJoin = keyof typeof JOIN
|
|
46
|
+
export type LineCap = keyof typeof CAP
|
|
47
|
+
export interface CanvasTextMetrics { width: number, ascent: number, descent: number }
|
|
48
|
+
|
|
49
|
+
export class Canvas {
|
|
50
|
+
/** Brand so `Sprite`/`UIImage`/`Material` can accept a Canvas anywhere a texture source is expected. */
|
|
51
|
+
readonly __canvas = true as const
|
|
52
|
+
|
|
53
|
+
width: number
|
|
54
|
+
height: number
|
|
55
|
+
/** Device-pixel multiplier (draw at 2 for crisp retina text). Applied by the backend. */
|
|
56
|
+
pixelRatio: number
|
|
57
|
+
|
|
58
|
+
private _cmd: number[] = []
|
|
59
|
+
private _refs: string[] = []
|
|
60
|
+
// Shadowed drawing state so the get/set accessors read back the current value (like the browser).
|
|
61
|
+
private _globalAlpha = 1
|
|
62
|
+
private _fillStyle = "#000000"
|
|
63
|
+
private _strokeStyle = "#000000"
|
|
64
|
+
private _lineWidth = 1
|
|
65
|
+
private _lineJoin: LineJoin = "miter"
|
|
66
|
+
private _lineCap: LineCap = "butt"
|
|
67
|
+
private _font = "10px sans-serif"
|
|
68
|
+
private _textAlign: TextAlign = "start"
|
|
69
|
+
private _textBaseline: TextBaseline = "alphabetic"
|
|
70
|
+
private _surfaceId = 0
|
|
71
|
+
private _tex2d?: Texture2D // cached 2D texture, re-uploaded on update()
|
|
72
|
+
/** @internal — cached 3D (creator-gl) texture id, set by gl/Texture.fromCanvas; re-uploaded on
|
|
73
|
+
* update(). Kept as a bare id (not a Texture import) so a 2D-only canvas never pulls the 3D engine. */
|
|
74
|
+
_tex3dId = 0
|
|
75
|
+
// Set once this canvas is baked as a UIImage source (see _bakeSurfaceForUI). When true, update()
|
|
76
|
+
// also refreshes the on-screen image node(s) via _creatorUI — the UI consumer the 2D/3D texture
|
|
77
|
+
// hooks below don't cover. Stays a bare flag so a non-UI canvas never references the UI bridge.
|
|
78
|
+
private _uiBound = false
|
|
79
|
+
|
|
80
|
+
constructor(width: number, height: number, opts: { pixelRatio?: number } = {}) {
|
|
81
|
+
this.width = width
|
|
82
|
+
this.height = height
|
|
83
|
+
this.pixelRatio = opts.pixelRatio ?? 1
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private _ref(s: string): number {
|
|
87
|
+
const i = this._refs.indexOf(s)
|
|
88
|
+
if (i >= 0) return i
|
|
89
|
+
this._refs.push(s)
|
|
90
|
+
return this._refs.length - 1
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Discard the recorded drawing to start a new one (for dynamic canvases). */
|
|
94
|
+
reset(): this { this._cmd.length = 0; this._refs.length = 0; return this }
|
|
95
|
+
/** Change the surface size; takes effect on the next bake. */
|
|
96
|
+
resize(width: number, height: number): this { this.width = width; this.height = height; return this }
|
|
97
|
+
|
|
98
|
+
// --- transforms & state stack (actions; chainable) ---
|
|
99
|
+
save(): this { this._cmd.push(OP.SAVE); return this }
|
|
100
|
+
restore(): this { this._cmd.push(OP.RESTORE); return this }
|
|
101
|
+
translate(x: number, y: number): this { this._cmd.push(OP.TRANSLATE, x, y); return this }
|
|
102
|
+
scale(sx: number, sy: number): this { this._cmd.push(OP.SCALE, sx, sy); return this }
|
|
103
|
+
rotate(rad: number): this { this._cmd.push(OP.ROTATE, rad); return this }
|
|
104
|
+
|
|
105
|
+
// --- drawing state (get/set like the browser; colors/fonts are CSS strings resolved by the platform).
|
|
106
|
+
// The setter records the state op AND shadows the value so the getter reads it back.
|
|
107
|
+
get globalAlpha(): number { return this._globalAlpha }
|
|
108
|
+
set globalAlpha(a: number) { this._globalAlpha = a; this._cmd.push(OP.GLOBAL_ALPHA, a) }
|
|
109
|
+
get fillStyle(): string { return this._fillStyle }
|
|
110
|
+
set fillStyle(color: string) { this._fillStyle = color; this._cmd.push(OP.FILL_STYLE, this._ref(color)) }
|
|
111
|
+
get strokeStyle(): string { return this._strokeStyle }
|
|
112
|
+
set strokeStyle(color: string) { this._strokeStyle = color; this._cmd.push(OP.STROKE_STYLE, this._ref(color)) }
|
|
113
|
+
get lineWidth(): number { return this._lineWidth }
|
|
114
|
+
set lineWidth(w: number) { this._lineWidth = w; this._cmd.push(OP.LINE_WIDTH, w) }
|
|
115
|
+
get lineJoin(): LineJoin { return this._lineJoin }
|
|
116
|
+
set lineJoin(j: LineJoin) { this._lineJoin = j; this._cmd.push(OP.LINE_JOIN, JOIN[j]) }
|
|
117
|
+
get lineCap(): LineCap { return this._lineCap }
|
|
118
|
+
set lineCap(c: LineCap) { this._lineCap = c; this._cmd.push(OP.LINE_CAP, CAP[c]) }
|
|
119
|
+
get font(): string { return this._font }
|
|
120
|
+
set font(f: string) { this._font = f; this._cmd.push(OP.FONT, this._ref(f)) }
|
|
121
|
+
get textAlign(): TextAlign { return this._textAlign }
|
|
122
|
+
set textAlign(a: TextAlign) { this._textAlign = a; this._cmd.push(OP.TEXT_ALIGN, ALIGN[a]) }
|
|
123
|
+
get textBaseline(): TextBaseline { return this._textBaseline }
|
|
124
|
+
set textBaseline(b: TextBaseline) { this._textBaseline = b; this._cmd.push(OP.TEXT_BASELINE, BASELINE[b]) }
|
|
125
|
+
|
|
126
|
+
// --- paths ---
|
|
127
|
+
beginPath(): this { this._cmd.push(OP.BEGIN_PATH); return this }
|
|
128
|
+
closePath(): this { this._cmd.push(OP.CLOSE_PATH); return this }
|
|
129
|
+
moveTo(x: number, y: number): this { this._cmd.push(OP.MOVE_TO, x, y); return this }
|
|
130
|
+
lineTo(x: number, y: number): this { this._cmd.push(OP.LINE_TO, x, y); return this }
|
|
131
|
+
quadraticCurveTo(cx: number, cy: number, x: number, y: number): this { this._cmd.push(OP.QUADRATIC_TO, cx, cy, x, y); return this }
|
|
132
|
+
bezierCurveTo(c1x: number, c1y: number, c2x: number, c2y: number, x: number, y: number): this {
|
|
133
|
+
this._cmd.push(OP.BEZIER_TO, c1x, c1y, c2x, c2y, x, y); return this
|
|
134
|
+
}
|
|
135
|
+
arc(x: number, y: number, r: number, a0: number, a1: number, ccw = false): this {
|
|
136
|
+
this._cmd.push(OP.ARC, x, y, r, a0, a1, ccw ? 1 : 0); return this
|
|
137
|
+
}
|
|
138
|
+
rect(x: number, y: number, w: number, h: number): this { this._cmd.push(OP.RECT, x, y, w, h); return this }
|
|
139
|
+
roundRect(x: number, y: number, w: number, h: number, r: number): this { this._cmd.push(OP.ROUND_RECT, x, y, w, h, r); return this }
|
|
140
|
+
fill(evenOdd = false): this { this._cmd.push(OP.FILL, evenOdd ? 1 : 0); return this }
|
|
141
|
+
stroke(): this { this._cmd.push(OP.STROKE); return this }
|
|
142
|
+
|
|
143
|
+
// --- shape/text sugar ---
|
|
144
|
+
fillRect(x: number, y: number, w: number, h: number): this { this._cmd.push(OP.FILL_RECT, x, y, w, h); return this }
|
|
145
|
+
strokeRect(x: number, y: number, w: number, h: number): this { this._cmd.push(OP.STROKE_RECT, x, y, w, h); return this }
|
|
146
|
+
clearRect(x: number, y: number, w: number, h: number): this { this._cmd.push(OP.CLEAR_RECT, x, y, w, h); return this }
|
|
147
|
+
fillText(text: string, x: number, y: number, maxWidth = 0): this { this._cmd.push(OP.FILL_TEXT, this._ref(text), x, y, maxWidth); return this }
|
|
148
|
+
strokeText(text: string, x: number, y: number, maxWidth = 0): this { this._cmd.push(OP.STROKE_TEXT, this._ref(text), x, y, maxWidth); return this }
|
|
149
|
+
|
|
150
|
+
// --- images ---
|
|
151
|
+
/** Blit a `Bitmap` (a snapshot from `toBitmap()`) onto this canvas. All coordinates are LOGICAL —
|
|
152
|
+
* including the source sub-rect in the 9-arg form (it's scaled by the bitmap's pixelRatio here).
|
|
153
|
+
* Records a DRAW_IMAGE op, so the blit runs at bake time: keep the Bitmap alive until the next
|
|
154
|
+
* `update()`/`toBitmap()`/`texture()`. Three browser-style forms:
|
|
155
|
+
* drawImage(bmp, dx, dy) — whole bitmap at its natural size
|
|
156
|
+
* drawImage(bmp, dx, dy, dw, dh) — whole bitmap, scaled into dw×dh
|
|
157
|
+
* drawImage(bmp, sx, sy, sw, sh, dx, dy, dw, dh) — a sub-rect of the bitmap, scaled into dw×dh */
|
|
158
|
+
drawImage(img: Bitmap, dx: number, dy: number): this
|
|
159
|
+
drawImage(img: Bitmap, dx: number, dy: number, dw: number, dh: number): this
|
|
160
|
+
drawImage(img: Bitmap, sx: number, sy: number, sw: number, sh: number, dx: number, dy: number, dw: number, dh: number): this
|
|
161
|
+
drawImage(img: Bitmap, a: number, b: number, c?: number, d?: number, e?: number, f?: number, g?: number, h?: number): this {
|
|
162
|
+
const pr = img.pixelRatio
|
|
163
|
+
let sx: number, sy: number, sw: number, sh: number, dx: number, dy: number, dw: number, dh: number
|
|
164
|
+
if (e === undefined) { // (dx, dy) or (dx, dy, dw, dh): the whole bitmap
|
|
165
|
+
sx = 0; sy = 0; sw = img.pixelWidth; sh = img.pixelHeight
|
|
166
|
+
dx = a; dy = b; dw = c ?? img.width; dh = d ?? img.height
|
|
167
|
+
} else { // (sx, sy, sw, sh, dx, dy, dw, dh): a sub-rect (logical → device px)
|
|
168
|
+
sx = a * pr; sy = b * pr; sw = c! * pr; sh = d! * pr
|
|
169
|
+
dx = e; dy = f!; dw = g!; dh = h!
|
|
170
|
+
}
|
|
171
|
+
this._cmd.push(OP.DRAW_IMAGE, img._surfaceId, sx, sy, sw, sh, dx, dy, dw, dh)
|
|
172
|
+
return this
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Decode an image source into a standalone `Bitmap` you can blit with `drawImage()` — either an
|
|
176
|
+
* `SvgSource(...)` (rasterized) or a `FetchResponse` holding already-fetched image bytes (decoded).
|
|
177
|
+
* This is async because turning SVG markup / encoded bytes into pixels needs a decode step (on the
|
|
178
|
+
* web, `createImageBitmap` / an `<img>` load); `await` it ONCE, then `drawImage(bitmap, …)` is
|
|
179
|
+
* synchronous and the Bitmap is reusable. The returned Bitmap owns a native surface at the image's
|
|
180
|
+
* natural pixel size (`pixelRatio` 1) — `destroy()` it when done. Typical use:
|
|
181
|
+
* const logo = await canvas.loadImage(SvgSource(svg)) // or: await fetch(url) → the response
|
|
182
|
+
* canvas.drawImage(logo, 0, 0) // sync, blit as often as you like */
|
|
183
|
+
loadImage(source: SvgSourceValue | FetchResponse): Promise<Bitmap> {
|
|
184
|
+
// Discriminate without touching FetchResponse internals: only an SvgSource carries `.svg`.
|
|
185
|
+
const isSvg = typeof (source as SvgSourceValue).svg === "string"
|
|
186
|
+
const svg = isSvg ? (source as SvgSourceValue).svg : ""
|
|
187
|
+
const bufferId = isSvg ? -1 : (source as any)._id // <0 = the SVG path; else the host buffer id
|
|
188
|
+
return new Promise<Bitmap>((res, rej) => {
|
|
189
|
+
_creatorCanvas.loadImage(svg, bufferId, (surfaceId, w, h) => res(new Bitmap(surfaceId, w, h, 1)), rej)
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Measure `text` in the current font on THIS platform. The one synchronous round-trip. */
|
|
194
|
+
measureText(text: string): CanvasTextMetrics {
|
|
195
|
+
const o = _creatorCanvas.measureText(text, this._font)
|
|
196
|
+
return { width: o[0], ascent: o[1], descent: o[2] }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** @internal — rasterize the recorded commands into the surface; returns the surface id. */
|
|
200
|
+
_bakeSurface(): number {
|
|
201
|
+
const buf = new Float32Array(this._cmd)
|
|
202
|
+
this._surfaceId = _creatorCanvas.rasterize(this._surfaceId, this.width, this.height, this.pixelRatio, buf, buf.length, this._refs)
|
|
203
|
+
return this._surfaceId
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** @internal — bake for a `UIImage(canvas)` source: same as `_bakeSurface()` but also flags this
|
|
207
|
+
* canvas UI-bound, so a later `update()` re-reads the re-baked pixels into the on-screen image
|
|
208
|
+
* node(s) — not just the 2D/3D engine textures. Called by UIImage when a Canvas is assigned as src. */
|
|
209
|
+
_bakeSurfaceForUI(): number {
|
|
210
|
+
this._uiBound = true
|
|
211
|
+
return this._bakeSurface()
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Bake and return the 2D texture (created once, then cached). */
|
|
215
|
+
texture(): Texture2D {
|
|
216
|
+
if (!this._tex2d) this._tex2d = Texture2D.fromCanvas(this)
|
|
217
|
+
return this._tex2d
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Re-rasterize and re-upload to every texture this canvas produced (call after redrawing) — the
|
|
221
|
+
* cached 2D and/or 3D texture, whichever engines this canvas was baked into. */
|
|
222
|
+
update(): this {
|
|
223
|
+
const surfaceId = this._bakeSurface()
|
|
224
|
+
if (this._tex2d) _creator2d.updateTextureFromCanvas(this._tex2d.id, surfaceId)
|
|
225
|
+
if (this._tex3dId) _creator.updateTextureFromCanvas(this._tex3dId, surfaceId)
|
|
226
|
+
// UIImage(canvas): repaint any live image node showing this surface with the re-baked pixels —
|
|
227
|
+
// draw-only, no relayout (the surface keeps its id and size). Feature-detected so an older host
|
|
228
|
+
// without the hook just no-ops (fall back to re-assigning UIImage.src). See docs/canvas-contract.md.
|
|
229
|
+
if (this._uiBound) _creatorUI.refreshCanvasSurface?.(surfaceId)
|
|
230
|
+
return this
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Copy the current pixels into a standalone, immutable `Bitmap` — independent of this canvas, so
|
|
234
|
+
* later drawing/`reset()` never touches it. Draw it back with `drawImage()`, export it with
|
|
235
|
+
* `toDataURL()`, or use it to "flatten" an ever-growing drawing so re-bakes stay O(1):
|
|
236
|
+
* const snap = canvas.toBitmap()
|
|
237
|
+
* canvas.reset(); canvas.drawImage(snap, 0, 0) // one op replaces the whole history
|
|
238
|
+
* // …keep drawing on top; snap.destroy() once it's been drawn in. */
|
|
239
|
+
toBitmap(): Bitmap {
|
|
240
|
+
return new Bitmap(_creatorCanvas.snapshot(this._bakeSurface()), this.width, this.height, this.pixelRatio)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Encode the current pixels to an image `File` — 'image/png' (default) or 'image/jpeg'. The File
|
|
244
|
+
* is an opaque host buffer handle (`name`/`size` only): append it to a FormData to upload, load it
|
|
245
|
+
* as a texture (`Texture2D.load(file)`), or hand it to a share/save sheet. */
|
|
246
|
+
toFile(name = "canvas.png", type = "image/png"): Promise<File> {
|
|
247
|
+
const surfaceId = this._bakeSurface()
|
|
248
|
+
return new Promise<File>((res, rej) => {
|
|
249
|
+
_creatorCanvas.toFile(surfaceId, type, (bufferId, size) => res(new File(bufferId, name, size)), rej)
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
destroy(): void {
|
|
254
|
+
if (this._surfaceId) _creatorCanvas.destroySurface(this._surfaceId)
|
|
255
|
+
this._tex2d?.destroy()
|
|
256
|
+
// The 3D texture (if any) is owned by creator-gl's engine and freed at its teardown — no per-texture
|
|
257
|
+
// destroy in the 3D API — so we just drop our cached id here.
|
|
258
|
+
this._surfaceId = 0
|
|
259
|
+
this._tex2d = undefined
|
|
260
|
+
this._tex3dId = 0
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Duck-type guard so consumers can accept a Canvas anywhere a texture source is expected. */
|
|
265
|
+
export const isCanvas = (v: unknown): v is Canvas =>
|
|
266
|
+
typeof v === "object" && v !== null && (v as { __canvas?: unknown }).__canvas === true
|
|
267
|
+
|
|
268
|
+
/** An immutable RGBA8 snapshot of a canvas, produced by `Canvas.toBitmap()`. It owns its own baked
|
|
269
|
+
* surface (a copy), so the source canvas can keep drawing or `reset()` without affecting it. Use it
|
|
270
|
+
* to stamp/blit back (`canvas.drawImage(bitmap, …)`), to export (`bitmap.toDataURL()`), or as a
|
|
271
|
+
* cheap "layer" you composite. Call `destroy()` when done — it holds a native surface. */
|
|
272
|
+
export class Bitmap {
|
|
273
|
+
readonly __bitmap = true as const
|
|
274
|
+
/** @internal — a standalone `_creatorCanvas` surface (RGBA8), independent of any Canvas. */
|
|
275
|
+
readonly _surfaceId: number
|
|
276
|
+
/** Logical width, matching the Canvas it was captured from (pixelRatio is crispness, not size). */
|
|
277
|
+
readonly width: number
|
|
278
|
+
readonly height: number
|
|
279
|
+
readonly pixelRatio: number
|
|
280
|
+
|
|
281
|
+
constructor(surfaceId: number, width: number, height: number, pixelRatio: number) {
|
|
282
|
+
this._surfaceId = surfaceId
|
|
283
|
+
this.width = width
|
|
284
|
+
this.height = height
|
|
285
|
+
this.pixelRatio = pixelRatio
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Device-pixel dimensions of the stored bitmap (`width * pixelRatio`, rounded). */
|
|
289
|
+
get pixelWidth(): number { return Math.round(this.width * this.pixelRatio) }
|
|
290
|
+
get pixelHeight(): number { return Math.round(this.height * this.pixelRatio) }
|
|
291
|
+
|
|
292
|
+
/** Encode to an image `File` — 'image/png' (default) or 'image/jpeg'. See `Canvas.toFile`. */
|
|
293
|
+
toFile(name = "canvas.png", type = "image/png"): Promise<File> {
|
|
294
|
+
return new Promise<File>((res, rej) => {
|
|
295
|
+
_creatorCanvas.toFile(this._surfaceId, type, (bufferId, size) => res(new File(bufferId, name, size)), rej)
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Free the native surface. The bitmap must not be drawn or encoded afterwards. */
|
|
300
|
+
destroy(): void { _creatorCanvas.destroySurface(this._surfaceId) }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Duck-type guard for a Bitmap (see `isCanvas`). */
|
|
304
|
+
export const isBitmap = (v: unknown): v is Bitmap =>
|
|
305
|
+
typeof v === "object" && v !== null && (v as { __bitmap?: unknown }).__bitmap === true
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test"
|
|
2
|
+
import { applyAssetIconMacro, buildIconsHeaderValue, iconNotFoundError, rewriteAssetIcons, scanAssetIconCalls, suggestIconNames } from "../assetIconMacro"
|
|
3
|
+
|
|
4
|
+
const svg = (stroke: string) => `<svg viewBox="0 0 24 24"><path stroke="${stroke}" d="M1 1"/></svg>`
|
|
5
|
+
|
|
6
|
+
describe("scanAssetIconCalls", () => {
|
|
7
|
+
test("a bare call → id + pack + name, no color, correct span", () => {
|
|
8
|
+
const src = `UIImage(assetIcon("lucide:bell"))`
|
|
9
|
+
const [c] = scanAssetIconCalls(src)
|
|
10
|
+
expect(c.id).toBe("lucide:bell")
|
|
11
|
+
expect(c.pack).toBe("lucide")
|
|
12
|
+
expect(c.name).toBe("bell")
|
|
13
|
+
expect(c.bake).toBeUndefined()
|
|
14
|
+
expect(c.tint).toBeUndefined()
|
|
15
|
+
expect(c.error).toBeUndefined()
|
|
16
|
+
expect(src.slice(c.start, c.end)).toBe(`assetIcon("lucide:bell")`)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test("a literal color option → captured as a compile-time bake", () => {
|
|
20
|
+
const [c] = scanAssetIconCalls(`assetIcon('lucide:check', { color: "#5b8cff" })`)
|
|
21
|
+
expect(c.id).toBe("lucide:check")
|
|
22
|
+
expect(c.bake).toBe("#5b8cff")
|
|
23
|
+
expect(c.tint).toBeUndefined()
|
|
24
|
+
expect(c.error).toBeUndefined()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test("a token / expression color → captured verbatim as a runtime tint (not an error)", () => {
|
|
28
|
+
const [c] = scanAssetIconCalls(`assetIcon("lucide:bell", { color: tokens.accent })`)
|
|
29
|
+
expect(c.tint).toBe("tokens.accent")
|
|
30
|
+
expect(c.bake).toBeUndefined()
|
|
31
|
+
expect(c.error).toBeUndefined()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test("dashed pack/name ids are valid", () => {
|
|
35
|
+
const [c] = scanAssetIconCalls(`assetIcon("simple-icons:arrow-right")`)
|
|
36
|
+
expect(c.pack).toBe("simple-icons")
|
|
37
|
+
expect(c.name).toBe("arrow-right")
|
|
38
|
+
expect(c.error).toBeUndefined()
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test("a non-literal id → error", () => {
|
|
42
|
+
const [c] = scanAssetIconCalls(`assetIcon(iconVar)`)
|
|
43
|
+
expect(c.error).toContain("string-literal id")
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test("a malformed id → error naming pack:name", () => {
|
|
47
|
+
const [c] = scanAssetIconCalls(`assetIcon("BellIcon")`)
|
|
48
|
+
expect(c.error).toContain("pack:name")
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test("a non-object opts → error", () => {
|
|
52
|
+
const [c] = scanAssetIconCalls(`assetIcon("lucide:bell", "#fff")`)
|
|
53
|
+
expect(c.error).toContain("object literal")
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test("a method named assetIcon is not matched", () => {
|
|
57
|
+
expect(scanAssetIconCalls(`icons.assetIcon("lucide:bell")`)).toEqual([])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test("assetIcon inside a string or comment is ignored", () => {
|
|
61
|
+
expect(scanAssetIconCalls(`const s = "assetIcon('lucide:bell')" // assetIcon('lucide:x')`)).toEqual([])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test("surrounding template literals don't confuse the scan", () => {
|
|
65
|
+
const src = "UIBox([]).style({ border: `2px solid ${c.muted}` })\nUIImage(assetIcon('lucide:bell'))"
|
|
66
|
+
const calls = scanAssetIconCalls(src)
|
|
67
|
+
expect(calls).toHaveLength(1)
|
|
68
|
+
expect(calls[0].id).toBe("lucide:bell")
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test("multiple calls are all found in order", () => {
|
|
72
|
+
const calls = scanAssetIconCalls(`assetIcon("lucide:bell"); assetIcon("lucide:check")`)
|
|
73
|
+
expect(calls.map((c) => c.id)).toEqual(["lucide:bell", "lucide:check"])
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe("rewriteAssetIcons", () => {
|
|
78
|
+
test("rewrites a call to an inline SvgSource(...)", () => {
|
|
79
|
+
const src = `UIImage(assetIcon("lucide:bell"))`
|
|
80
|
+
const out = rewriteAssetIcons(src, scanAssetIconCalls(src), new Map([["lucide:bell", svg("currentColor")]]))
|
|
81
|
+
expect(out).toBe("UIImage(SvgSource(`" + svg("currentColor") + "`))")
|
|
82
|
+
expect(out).not.toContain("assetIcon")
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
test("bakes a literal color (currentColor → color)", () => {
|
|
86
|
+
const src = `assetIcon("lucide:bell", { color: "#f00" })`
|
|
87
|
+
const out = rewriteAssetIcons(src, scanAssetIconCalls(src), new Map([["lucide:bell", svg("currentColor")]]))
|
|
88
|
+
expect(out).toContain(`stroke="#f00"`)
|
|
89
|
+
expect(out).not.toContain("currentColor")
|
|
90
|
+
expect(out).not.toContain("tintColor")
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
test("a token color → source-level tint, SVG left as currentColor", () => {
|
|
94
|
+
const src = `assetIcon("lucide:bell", { color: colors.accent })`
|
|
95
|
+
const out = rewriteAssetIcons(src, scanAssetIconCalls(src), new Map([["lucide:bell", svg("currentColor")]]))
|
|
96
|
+
expect(out).toContain("Object.assign(SvgSource(`")
|
|
97
|
+
expect(out).toContain("{ tintColor: (colors.accent) }")
|
|
98
|
+
expect(out).toContain("currentColor")
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
test("escapes backticks and ${ in the SVG body", () => {
|
|
102
|
+
const nasty = "<svg>`${danger}`</svg>"
|
|
103
|
+
const src = `assetIcon("lucide:bell")`
|
|
104
|
+
const out = rewriteAssetIcons(src, scanAssetIconCalls(src), new Map([["lucide:bell", nasty]]))
|
|
105
|
+
expect(out).toContain("\\`")
|
|
106
|
+
expect(out).toContain("\\${")
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test("two calls in one file are both rewritten (indices stay valid)", () => {
|
|
110
|
+
const src = `[assetIcon("lucide:bell"), assetIcon("lucide:check")]`
|
|
111
|
+
const map = new Map([["lucide:bell", svg("a")], ["lucide:check", svg("b")]])
|
|
112
|
+
const out = rewriteAssetIcons(src, scanAssetIconCalls(src), map)
|
|
113
|
+
expect(out).not.toContain("assetIcon")
|
|
114
|
+
expect(out).toContain(svg("a"))
|
|
115
|
+
expect(out).toContain(svg("b"))
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe("applyAssetIconMacro", () => {
|
|
120
|
+
test("rewrites every text entry via the resolver and returns the resolved ids", async () => {
|
|
121
|
+
const entries = [
|
|
122
|
+
{ path: "/screens/home.ts", type: "text", text: `export default UIImage(assetIcon("lucide:bell"))` },
|
|
123
|
+
{ path: "/shared/icon.ts", type: "text", text: `export const c = () => UIImage(assetIcon("lucide:check", { color: "#0f0" }))` },
|
|
124
|
+
{ path: "/logo.png", type: "resource", text: undefined },
|
|
125
|
+
]
|
|
126
|
+
let asked: string[] = []
|
|
127
|
+
const ids = await applyAssetIconMacro(entries, (want) => {
|
|
128
|
+
asked = want
|
|
129
|
+
return new Map(want.map((id) => [id, svg("currentColor")]))
|
|
130
|
+
})
|
|
131
|
+
expect(ids.sort()).toEqual(["lucide:bell", "lucide:check"])
|
|
132
|
+
expect(asked.sort()).toEqual(["lucide:bell", "lucide:check"])
|
|
133
|
+
expect(entries[0].text).toContain("SvgSource(`")
|
|
134
|
+
expect(entries[0].text).not.toContain("assetIcon")
|
|
135
|
+
expect(entries[1].text).toContain(`stroke="#0f0"`)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
test("a no-op when nothing uses assetIcon — the resolver is never called", async () => {
|
|
139
|
+
const entries = [{ path: "/x.ts", type: "text", text: `export default UIScreen([])` }]
|
|
140
|
+
const ids = await applyAssetIconMacro(entries, () => { throw new Error("resolver must not run") })
|
|
141
|
+
expect(ids).toEqual([])
|
|
142
|
+
expect(entries[0].text).toBe(`export default UIScreen([])`)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test("a malformed call aborts before the resolver runs", async () => {
|
|
146
|
+
const entries = [{ path: "/x.ts", type: "text", text: `UIImage(assetIcon(runtimeVar))` }]
|
|
147
|
+
await expect(applyAssetIconMacro(entries, () => new Map())).rejects.toThrow(/string-literal id/)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
test("an id the resolver silently omits fails the pass", async () => {
|
|
151
|
+
const entries = [{ path: "/x.ts", type: "text", text: `UIImage(assetIcon("lucide:ghost"))` }]
|
|
152
|
+
await expect(applyAssetIconMacro(entries, () => new Map())).rejects.toThrow(/could not resolve icon/)
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe("buildIconsHeaderValue", () => {
|
|
157
|
+
test("distinct packs, sorted", () => {
|
|
158
|
+
expect(buildIconsHeaderValue(["noto:star", "lucide:bell", "lucide:check"])).toBe("lucide;noto")
|
|
159
|
+
expect(buildIconsHeaderValue([])).toBe("")
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
describe("suggestIconNames", () => {
|
|
164
|
+
const icons = {
|
|
165
|
+
"bell": { kw: ["bell"] },
|
|
166
|
+
"bell-ring": { kw: ["bell", "ring"] },
|
|
167
|
+
"bell-off": { kw: ["bell", "off"] },
|
|
168
|
+
"check": { kw: ["check"] },
|
|
169
|
+
"arrow-right": { kw: ["arrow", "right"] },
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
test("a typo resolves by edit distance, exact-ish first", () => {
|
|
173
|
+
const s = suggestIconNames("bel", icons)
|
|
174
|
+
expect(s[0]).toBe("bell")
|
|
175
|
+
expect(s).toContain("bell-off") // substring family follows
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
test("a sub-word matches by substring", () => {
|
|
179
|
+
expect(suggestIconNames("ring", icons)).toContain("bell-ring")
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test("swapped word order matches via keywords", () => {
|
|
183
|
+
expect(suggestIconNames("right-arrow", icons)).toContain("arrow-right")
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test("nothing similar → empty", () => {
|
|
187
|
+
expect(suggestIconNames("zzz-qqq", icons)).toEqual([])
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
describe("iconNotFoundError", () => {
|
|
192
|
+
const registry: Record<string, string> = {
|
|
193
|
+
"lucide/meta.json": JSON.stringify({ icons: { bell: { kw: ["bell"] }, "bell-ring": { kw: ["bell", "ring"] } } }),
|
|
194
|
+
"licenses.json": JSON.stringify({ lucide: {}, tabler: {} }),
|
|
195
|
+
}
|
|
196
|
+
const read = async (rel: string) => registry[rel] ?? null
|
|
197
|
+
|
|
198
|
+
test("a known pack + missing name → did-you-mean from the pack's meta", async () => {
|
|
199
|
+
const err = await iconNotFoundError("lucide", "bel", read)
|
|
200
|
+
expect(err.message).toContain(`"lucide:bel" not found`)
|
|
201
|
+
expect(err.message).toContain(`"lucide:bell"`)
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test("a known pack, nothing similar → says so", async () => {
|
|
205
|
+
const err = await iconNotFoundError("lucide", "zzz-qqq", read)
|
|
206
|
+
expect(err.message).toContain("No similar name")
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
test("an unknown pack → the available-pack list", async () => {
|
|
210
|
+
const err = await iconNotFoundError("nope", "bell", read)
|
|
211
|
+
expect(err.message).toContain(`unknown icon pack "nope"`)
|
|
212
|
+
expect(err.message).toContain("lucide, tabler")
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
test("nothing reachable → a connectivity error naming the registry", async () => {
|
|
216
|
+
const err = await iconNotFoundError("lucide", "bell", async () => null, "https://reg.example/v1")
|
|
217
|
+
expect(err.message).toContain("can't reach the icon registry (https://reg.example/v1)")
|
|
218
|
+
})
|
|
219
|
+
})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { test, expect } from "bun:test"
|
|
2
|
+
import { collectAssetMacros, desugarAssetMacros } from "../assetMacro"
|
|
3
|
+
import { compileProject } from "../compileProject"
|
|
4
|
+
|
|
5
|
+
// A missing asset is a COMPILE ERROR — never a fallback. The old behaviour compiled
|
|
6
|
+
// `asset('../x.bake')` whose file didn't exist into the source path verbatim: a string nothing
|
|
7
|
+
// resolves at runtime, handed to the app as a broken reference with no diagnostic at all (and,
|
|
8
|
+
// since the macro appends its generated imports, a const used before its own declaration — a TDZ
|
|
9
|
+
// ReferenceError at boot that read like a bundler crash).
|
|
10
|
+
|
|
11
|
+
const paths = (src: string) => collectAssetMacros(src, "/main.ts").map(r => r.path)
|
|
12
|
+
|
|
13
|
+
test("a missing asset fails the compile, naming the file and the call site", async () => {
|
|
14
|
+
const main = "const scene = new Scene()\nconst bake = asset('./assets/lightmap.bake')\nconsole.log(scene, bake)\n"
|
|
15
|
+
const compile = compileProject({
|
|
16
|
+
name: "Missing",
|
|
17
|
+
publicUrl: "https://le.codes",
|
|
18
|
+
entries: [ { path: "/main.ts", type: "text", text: main } ],
|
|
19
|
+
})
|
|
20
|
+
await expect(compile).rejects.toThrow("asset not found: ./assets/lightmap.bake (main.ts:2)")
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test("the same asset present in the entries compiles to its URL", async () => {
|
|
24
|
+
const main = "const bake = asset('./assets/lightmap.bake')\nconsole.log(bake)\n"
|
|
25
|
+
const js = await compileProject({
|
|
26
|
+
name: "Present",
|
|
27
|
+
publicUrl: "https://le.codes",
|
|
28
|
+
entries: [
|
|
29
|
+
{ path: "/main.ts", type: "text", text: main },
|
|
30
|
+
{ path: "/assets/lightmap.bake", type: "resource", fileSrc: "/u/lightmap.bake" },
|
|
31
|
+
],
|
|
32
|
+
})
|
|
33
|
+
expect(js).toContain("https://le.codes/u/lightmap.bake")
|
|
34
|
+
expect(js).not.toContain("./assets/lightmap.bake")
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test("a commented-out asset() is not an asset at all", () => {
|
|
38
|
+
const src = [
|
|
39
|
+
"/** The atlas image — write `asset('./TileSet.png')`. */",
|
|
40
|
+
"// const old = asset('./deleted.png')",
|
|
41
|
+
"const live = asset('./hero.png')",
|
|
42
|
+
"/* asset('./block-commented.png') */",
|
|
43
|
+
].join("\n")
|
|
44
|
+
expect(paths(src)).toEqual([ "./hero.png" ])
|
|
45
|
+
const out = desugarAssetMacros(src, "/main.ts")
|
|
46
|
+
expect(out).toContain(`import __asset_0 from "./hero.png";`)
|
|
47
|
+
expect(out).not.toContain("deleted.png\";")
|
|
48
|
+
expect(out).toContain("asset('./deleted.png')") // the comment itself is untouched
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test("an asset() inside a string literal is text, one inside `${…}` is a call", () => {
|
|
52
|
+
expect(paths(`const doc = "call asset('./x.png') to load"`)).toEqual([])
|
|
53
|
+
expect(paths("const url = `bg:${asset('./bg.png')}`")).toEqual([ "./bg.png" ])
|
|
54
|
+
expect(paths("const url = `not ${1} asset('./no.png')`")).toEqual([])
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
test("method calls named asset are still untouched, dynamic arguments still throw", () => {
|
|
58
|
+
expect(paths(`state.model = ui.asset("model")`)).toEqual([])
|
|
59
|
+
expect(() => paths("const p = './x.png'\nconst a = asset(p)")).toThrow("expects a string literal")
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
test("repeated paths share one import and the line numbers stay exact", () => {
|
|
63
|
+
const src = "const a = asset('./x.png')\n\nconst b = asset('./x.png')\nconst c = asset('./y.png')\n"
|
|
64
|
+
const refs = collectAssetMacros(src, "/main.ts")
|
|
65
|
+
expect(refs.map(r => r.line)).toEqual([ 1, 3, 4 ])
|
|
66
|
+
const out = desugarAssetMacros(src, "/main.ts")
|
|
67
|
+
// The generated imports are APPENDED, so no original line moves (source maps stay honest).
|
|
68
|
+
const lines = out.split("\n")
|
|
69
|
+
expect(lines.slice(0, 4)).toEqual([ "const a = __asset_0", "", "const b = __asset_0", "const c = __asset_1" ])
|
|
70
|
+
expect(lines.slice(4).join("\n")).toContain("import __asset_0")
|
|
71
|
+
expect(out).toContain("const a = __asset_0")
|
|
72
|
+
expect(out).toContain("const b = __asset_0")
|
|
73
|
+
expect(out).toContain("const c = __asset_1")
|
|
74
|
+
})
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { test, expect } from "bun:test"
|
|
2
|
+
import { isCanonicalAssetName, sanitizeAssetName } from "../assetName"
|
|
3
|
+
|
|
4
|
+
// The invariant: two names that LOOK the same on screen must be stored the same way, or the import
|
|
5
|
+
// written from what the user sees resolves to nothing. Codepoints are spelled out via fromCharCode
|
|
6
|
+
// so the cases stay readable — the characters themselves are invisible in an editor.
|
|
7
|
+
|
|
8
|
+
const NBSP = String.fromCharCode(0x00a0)
|
|
9
|
+
const NARROW_NBSP = String.fromCharCode(0x202f)
|
|
10
|
+
const ZERO_WIDTH = String.fromCharCode(0x200b)
|
|
11
|
+
const RTL_MARK = String.fromCharCode(0x200f)
|
|
12
|
+
const COMBINING_BREVE = String.fromCharCode(0x0306)
|
|
13
|
+
|
|
14
|
+
test("a non-breaking space becomes the plain space the user types in an import", () => {
|
|
15
|
+
expect(sanitizeAssetName(`итальянский${NBSP}сквер1.jpg`)).toBe("итальянский сквер1.jpg")
|
|
16
|
+
expect(sanitizeAssetName(`narrow${NARROW_NBSP}space.png`)).toBe("narrow space.png")
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test("a decomposed name (what macOS hands out) composes to the same codepoints as typed text", () => {
|
|
20
|
+
// "й" as и + combining breve — indistinguishable on screen from the single U+0439 codepoint.
|
|
21
|
+
const decomposed = `и${COMBINING_BREVE}ога.glb`
|
|
22
|
+
expect(decomposed).not.toBe("йога.glb")
|
|
23
|
+
expect(sanitizeAssetName(decomposed)).toBe("йога.glb")
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test("zero-width and bidi characters are dropped", () => {
|
|
27
|
+
expect(sanitizeAssetName(`logo${ZERO_WIDTH}.png`)).toBe("logo.png")
|
|
28
|
+
expect(sanitizeAssetName(`${RTL_MARK}logo.png`)).toBe("logo.png")
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test("path separators and Windows-illegal characters can't enter a name", () => {
|
|
32
|
+
expect(sanitizeAssetName("a/b.png")).toBe("a_b.png")
|
|
33
|
+
expect(sanitizeAssetName("a\\b.png")).toBe("a_b.png")
|
|
34
|
+
expect(sanitizeAssetName('q?:*"<>|.png')).toBe("q_______.png")
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test("surrounding whitespace and trailing dots go; a leading dot stays", () => {
|
|
38
|
+
expect(sanitizeAssetName(" main.ts ")).toBe("main.ts")
|
|
39
|
+
expect(sanitizeAssetName("main.ts...")).toBe("main.ts")
|
|
40
|
+
expect(sanitizeAssetName(".gitignore")).toBe(".gitignore")
|
|
41
|
+
expect(sanitizeAssetName("two spaces.png")).toBe("two spaces.png")
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test("an ordinary name is returned untouched and sanitizing is idempotent", () => {
|
|
45
|
+
for (const name of [ "main.ts", "итальянский сквер1.jpg", "My Scene.scene", "ui-kit.mat" ]) {
|
|
46
|
+
expect(sanitizeAssetName(name)).toBe(name)
|
|
47
|
+
expect(isCanonicalAssetName(name)).toBe(true)
|
|
48
|
+
expect(sanitizeAssetName(sanitizeAssetName(name))).toBe(sanitizeAssetName(name))
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test("a name with nothing printable left comes back empty (callers reject it)", () => {
|
|
53
|
+
expect(sanitizeAssetName(` ${ZERO_WIDTH}${NBSP} `)).toBe("")
|
|
54
|
+
expect(isCanonicalAssetName(`x${ZERO_WIDTH}.png`)).toBe(false)
|
|
55
|
+
})
|