From e9ea023b11d25832c9dbf84c45ae76092f58ec95 Mon Sep 17 00:00:00 2001 From: Michael Aufreiter Date: Sun, 19 Jul 2026 23:22:48 +0200 Subject: [PATCH 1/5] Convert codebase to TypeScript --- ARCHITECTURE.md | 18 +- CLAUDE.md | 4 +- IMPLEMENTATION_PLAN.md | 11 + README.md | 36 +- eslint.config.js | 52 +- package-lock.json | 245 ++++++++ package.json | 6 +- scripts/create-migration.js | 2 +- src/app.d.ts | 9 +- src/{hooks.server.js => hooks.server.ts} | 17 +- src/lib/{api.remote.js => api.remote.ts} | 541 ++++++++---------- ...{asset_processor.js => asset_processor.ts} | 48 +- .../{asset_upload.js => asset_upload.ts} | 203 +++---- ...edia_dimensions.js => media_dimensions.ts} | 24 +- .../{process_asset.js => process_asset.ts} | 43 +- .../{process_video.js => process_video.ts} | 38 +- .../client/{touch_drag.js => touch_drag.ts} | 61 +- ...{video_processor.js => video_processor.ts} | 57 +- ...comments.test.js => code_comments.test.ts} | 6 +- .../{code_comments.js => code_comments.ts} | 21 +- src/lib/{config.js => config.ts} | 16 +- .../{content_config.js => content_config.ts} | 2 +- src/lib/{demo_doc.js => demo_doc.ts} | 0 .../{document_graph.js => document_graph.ts} | 30 +- ...{document_schema.js => document_schema.ts} | 8 +- src/lib/{index.js => index.ts} | 0 src/lib/{new_page.js => new_page.ts} | 12 +- .../{page_metadata.js => page_metadata.ts} | 139 ++--- .../{asset_storage.js => asset_storage.ts} | 106 ++-- src/lib/server/{auth.js => auth.ts} | 82 +-- src/lib/server/{db.js => db.ts} | 6 +- .../server/{db_snapshot.js => db_snapshot.ts} | 11 +- ...n.test.js => layout_ids_migration.test.ts} | 0 .../markdown/{compose.js => compose.ts} | 15 +- .../{config.test.js => config.test.ts} | 0 .../server/markdown/{config.js => config.ts} | 28 +- .../{convert.test.js => convert.test.ts} | 24 +- .../markdown/{convert.js => convert.ts} | 207 +++---- .../{registry.test.js => registry.test.ts} | 0 .../markdown/{registry.js => registry.ts} | 16 +- src/lib/server/markdown/{toc.js => toc.ts} | 25 +- src/lib/server/{migrate.js => migrate.ts} | 2 +- ...on_plan.test.js => migration_plan.test.ts} | 2 +- .../{migration_plan.js => migration_plan.ts} | 18 +- .../server/{migrations.js => migrations.ts} | 18 +- ...712T125641366Z_editable_initial_schema.ts} | 0 ...379Z_editable_add_page_metadata_fields.ts} | 0 ...5641382Z_editable_add_page_image_nodes.ts} | 0 ...1383Z_editable_add_document_timestamps.ts} | 0 ...715T120000000Z_editable_use_layout_ids.ts} | 2 +- src/lib/server/{s3.js => s3.ts} | 40 +- ...documents.js => seed_initial_documents.ts} | 6 +- .../{server_config.js => server_config.ts} | 0 .../{+layout.server.js => +layout.server.ts} | 6 +- src/routes/+layout.svelte | 2 +- .../{+page.server.js => +page.server.ts} | 6 +- src/routes/+page.svelte | 14 +- .../{+page.server.js => +page.server.ts} | 11 +- src/routes/[page_id]/+page.svelte | 2 +- .../api/assets/{+server.js => +server.ts} | 10 +- .../[asset_id]/{+server.js => +server.ts} | 6 +- .../variants/{+server.js => +server.ts} | 11 +- src/routes/app_context.ts | 26 + .../{app_utils.test.js => app_utils.test.ts} | 107 ++-- src/routes/{app_utils.js => app_utils.ts} | 94 ++- .../[...path]/{+server.js => +server.ts} | 34 +- ...{commands.svelte.js => commands.svelte.ts} | 50 +- src/routes/components/Accordion.svelte | 9 +- src/routes/components/AccordionItem.svelte | 9 +- src/routes/components/App.svelte | 58 +- src/routes/components/AuthDialog.svelte | 19 +- src/routes/components/Button.svelte | 12 +- src/routes/components/ButtonGroup.svelte | 11 +- src/routes/components/CaptionedFigure.svelte | 9 +- src/routes/components/Code.svelte | 15 +- src/routes/components/CreateLink.svelte | 13 +- .../components/DescriptiveGallery.svelte | 9 +- .../components/DescriptiveGalleryItem.svelte | 12 +- .../components/DescriptiveListing.svelte | 9 +- .../components/DescriptiveListingItem.svelte | 12 +- src/routes/components/Display.svelte | 2 +- src/routes/components/Drawer.svelte | 6 +- src/routes/components/EditLink.svelte | 16 +- src/routes/components/EditMedia.svelte | 13 +- src/routes/components/Emphasis.svelte | 12 +- src/routes/components/Feature.svelte | 9 +- src/routes/components/Figure.svelte | 9 +- src/routes/components/Footer.svelte | 12 +- src/routes/components/FooterLink.svelte | 12 +- .../components/FooterLinkCategory.svelte | 5 +- src/routes/components/FooterLinkColumn.svelte | 5 +- src/routes/components/Gallery.svelte | 9 +- src/routes/components/GalleryItem.svelte | 12 +- src/routes/components/Heading1.svelte | 12 +- src/routes/components/Heading1XL.svelte | 12 +- src/routes/components/Heading2.svelte | 12 +- src/routes/components/Heading3.svelte | 12 +- src/routes/components/Heading4.svelte | 12 +- src/routes/components/Highlight.svelte | 17 +- src/routes/components/Image.svelte | 22 +- src/routes/components/Link.svelte | 14 +- src/routes/components/LinkPreview.svelte | 15 +- src/routes/components/List.svelte | 5 +- src/routes/components/ListItem.svelte | 9 +- src/routes/components/Media.svelte | 11 +- src/routes/components/MediaControls.svelte | 13 +- src/routes/components/MediaProperty.svelte | 22 +- src/routes/components/Nav.svelte | 12 +- src/routes/components/NavButton.svelte | 12 +- src/routes/components/NavLink.svelte | 12 +- src/routes/components/NavMedia.svelte | 20 +- src/routes/components/NodeNavigator.svelte | 2 +- src/routes/components/Overlays.svelte | 15 +- src/routes/components/Page.svelte | 15 +- src/routes/components/PagesDrawer.svelte | 11 +- src/routes/components/Paragraph.svelte | 12 +- src/routes/components/ParagraphLG.svelte | 12 +- src/routes/components/ParagraphSM.svelte | 12 +- src/routes/components/ParagraphXL.svelte | 12 +- src/routes/components/Preformatted.svelte | 9 +- src/routes/components/Prose.svelte | 16 +- src/routes/components/ProseGrid.svelte | 9 +- src/routes/components/ProseGridItem.svelte | 15 +- .../components/SaveProgressModal.svelte | 15 +- src/routes/components/Section.svelte | 12 +- src/routes/components/SizableViewbox.svelte | 35 +- .../components/SizableViewboxControls.svelte | 136 ++--- src/routes/components/Strong.svelte | 12 +- src/routes/components/SupportingMedia.svelte | 13 +- src/routes/components/Toolbar.svelte | 6 +- src/routes/components/Video.svelte | 32 +- ...elte.js => page_browser_context.svelte.ts} | 34 +- .../{create_session.js => create_session.ts} | 87 +-- src/routes/nanoid.js | 11 - src/routes/nanoid.ts | 8 + .../new/{+page.server.js => +page.server.ts} | 8 +- src/routes/new/+page.svelte | 2 +- src/routes/{reveal.js => reveal.ts} | 9 +- src/routes/svedit_context.ts | 19 + .../{tailwind_theme.js => tailwind_theme.ts} | 0 jsconfig.json => tsconfig.json | 4 +- vite.config.js => vite.config.ts | 4 +- 142 files changed, 1932 insertions(+), 1854 deletions(-) rename src/{hooks.server.js => hooks.server.ts} (80%) rename src/lib/{api.remote.js => api.remote.ts} (74%) rename src/lib/client/{asset_processor.js => asset_processor.ts} (78%) rename src/lib/client/{asset_upload.js => asset_upload.ts} (71%) rename src/lib/client/{media_dimensions.js => media_dimensions.ts} (81%) rename src/lib/client/{process_asset.js => process_asset.ts} (60%) rename src/lib/client/{process_video.js => process_video.ts} (73%) rename src/lib/client/{touch_drag.js => touch_drag.ts} (75%) rename src/lib/client/{video_processor.js => video_processor.ts} (85%) rename src/lib/{code_comments.test.js => code_comments.test.ts} (94%) rename src/lib/{code_comments.js => code_comments.ts} (88%) rename src/lib/{config.js => config.ts} (91%) rename src/lib/{content_config.js => content_config.ts} (99%) rename src/lib/{demo_doc.js => demo_doc.ts} (100%) rename src/lib/{document_graph.js => document_graph.ts} (65%) rename src/lib/{document_schema.js => document_schema.ts} (97%) rename src/lib/{index.js => index.ts} (100%) rename src/lib/{new_page.js => new_page.ts} (87%) rename src/lib/{page_metadata.js => page_metadata.ts} (69%) rename src/lib/server/{asset_storage.js => asset_storage.ts} (66%) rename src/lib/server/{auth.js => auth.ts} (62%) rename src/lib/server/{db.js => db.ts} (86%) rename src/lib/server/{db_snapshot.js => db_snapshot.ts} (91%) rename src/lib/server/{layout_ids_migration.test.js => layout_ids_migration.test.ts} (100%) rename src/lib/server/markdown/{compose.js => compose.ts} (75%) rename src/lib/server/markdown/{config.test.js => config.test.ts} (100%) rename src/lib/server/markdown/{config.js => config.ts} (81%) rename src/lib/server/markdown/{convert.test.js => convert.test.ts} (96%) rename src/lib/server/markdown/{convert.js => convert.ts} (80%) rename src/lib/server/markdown/{registry.test.js => registry.test.ts} (100%) rename src/lib/server/markdown/{registry.js => registry.ts} (63%) rename src/lib/server/markdown/{toc.js => toc.ts} (57%) rename src/lib/server/{migrate.js => migrate.ts} (96%) rename src/lib/server/{migration_plan.test.js => migration_plan.test.ts} (95%) rename src/lib/server/{migration_plan.js => migration_plan.ts} (84%) rename src/lib/server/{migrations.js => migrations.ts} (69%) rename src/lib/server/migrations/{20260712T125641366Z_editable_initial_schema.js => 20260712T125641366Z_editable_initial_schema.ts} (100%) rename src/lib/server/migrations/{20260712T125641379Z_editable_add_page_metadata_fields.js => 20260712T125641379Z_editable_add_page_metadata_fields.ts} (100%) rename src/lib/server/migrations/{20260712T125641382Z_editable_add_page_image_nodes.js => 20260712T125641382Z_editable_add_page_image_nodes.ts} (100%) rename src/lib/server/migrations/{20260712T125641383Z_editable_add_document_timestamps.js => 20260712T125641383Z_editable_add_document_timestamps.ts} (100%) rename src/lib/server/migrations/{20260715T120000000Z_editable_use_layout_ids.js => 20260715T120000000Z_editable_use_layout_ids.ts} (96%) rename src/lib/server/{s3.js => s3.ts} (79%) rename src/lib/server/{seed_initial_documents.js => seed_initial_documents.ts} (82%) rename src/lib/{server_config.js => server_config.ts} (100%) rename src/routes/{+layout.server.js => +layout.server.ts} (83%) rename src/routes/{+page.server.js => +page.server.ts} (83%) rename src/routes/[page_id]/{+page.server.js => +page.server.ts} (91%) rename src/routes/api/assets/{+server.js => +server.ts} (92%) rename src/routes/api/assets/[asset_id]/{+server.js => +server.ts} (80%) rename src/routes/api/assets/[asset_id]/variants/{+server.js => +server.ts} (90%) create mode 100644 src/routes/app_context.ts rename src/routes/{app_utils.test.js => app_utils.test.ts} (54%) rename src/routes/{app_utils.js => app_utils.ts} (79%) rename src/routes/assets/[...path]/{+server.js => +server.ts} (85%) rename src/routes/{commands.svelte.js => commands.svelte.ts} (91%) rename src/routes/components/{page_browser_context.svelte.js => page_browser_context.svelte.ts} (55%) rename src/routes/{create_session.js => create_session.ts} (93%) delete mode 100644 src/routes/nanoid.js create mode 100644 src/routes/nanoid.ts rename src/routes/new/{+page.server.js => +page.server.ts} (78%) rename src/routes/{reveal.js => reveal.ts} (86%) create mode 100644 src/routes/svedit_context.ts rename src/routes/{tailwind_theme.js => tailwind_theme.ts} (100%) rename jsconfig.json => tsconfig.json (77%) rename vite.config.js => vite.config.ts (89%) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7742aaa9..2851a5f9 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -63,6 +63,18 @@ In full runtime mode, Editable also supports a simple owner-only admin authentic 2. **Edit-for-fun mode** — unauthenticated, can temporarily edit the currently open page in the browser UI, but cannot save changes or access private site-management features 3. **Public browsing mode** — normal site visitor mode with no editing UI active +## Language: TypeScript + +The codebase is written in TypeScript throughout — modules use `.ts` (or `.svelte.ts` when they use runes), and Svelte components use ` ``` +The `Nodes['hero']` annotation types the node against the schema, so `hero.` autocompletes its properties and a misspelled property fails `npm run check`. + `session.get` follows node references for you, so the last expression returns the media node rather than its stored id. Components do not need to know whether they are on a page, inside another block, or nested several arrays deep. ### Node: establish the editing boundary @@ -298,7 +304,7 @@ The first four come from `svedit`; the last two live in `src/routes/components`. A small, typed vocabulary for Editable's pages and shared site content. -Editable's content model defines the nodes and properties available to pages and shared site content. Its schema lives in `src/lib/document_schema.js`; this section is the reference. +Editable's content model defines the nodes and properties available to pages and shared site content. Its schema lives in `src/lib/document_schema.ts`; this section is the reference. Documents are graphs of nodes stored by id. Each node has an `id`, a `type`, and type-specific properties. A few naming conventions hold throughout: `content` is the string payload of text properties, `body` holds authored nested content, `items` holds repeated structured children, and `label`/`title`/`description`/`meta` are text properties with semantic meaning. @@ -502,7 +508,7 @@ Each dropped video goes through this decision tree: 2. **Already good** — if the video is already H.264, within the resolution cap and within the size goal (with 25% tolerance, since re-encoding a marginally-over file costs quality and saves little), nothing is re-encoded: an MP4 is uploaded untouched, and other containers (e.g. an H.264 `.mov`) are losslessly repackaged into an MP4 container. 3. **Everything else** is transcoded to fit the size goal: the bitrate is derived from the video's duration, and the resolution is chosen as the largest that still looks good at that bitrate — starting from the resolution cap (1080 means landscape 1920×1080 *and* portrait 1080×1920; videos are never upscaled) and stepping down (720, 540, …) for long videos where the size budget would otherwise spread too thin. Rotation is preserved. -Two knobs in `src/lib/config.js`: +Two knobs in `src/lib/config.ts`: ```js export const MAX_VIDEO_RESOLUTION = 1080; // cap on the short side: 720, 1080, … @@ -526,7 +532,7 @@ Editable's built-in types are just a starting set. This walkthrough adds a `hero ### 1. Define the type in the schema -In `src/lib/document_schema.js`, add the node type definition. A hero is a `block` with a `layout` variant, two text properties, and a media reference: +In `src/lib/document_schema.ts`, add the node type definition. A hero is a `block` with a `layout` variant, two text properties, and a media reference: ```js hero: { @@ -578,15 +584,17 @@ That's the whole data model. Documents containing heroes now validate, and every Create `src/routes/components/Hero.svelte`. It reads the node at `path`, renders each property through an editable primitive (`TextProperty` for text, `MediaProperty` for the image), and picks a snippet per layout: ```svelte - @@ -630,7 +638,7 @@ There is no read-only twin to keep in sync: this one component is the live page ### 3. Register it in the session -In `src/routes/create_session.js`, import the component and add it to `node_components`, so Svedit knows what to render: +In `src/routes/create_session.ts`, import the component and add it to `node_components`, so Svedit knows what to render: ```js import Hero from './components/Hero.svelte'; @@ -912,7 +920,7 @@ A deployment can expose selected repository markdown files as read-only pages re ### Configuration -Any markdown file in the repository can be mapped to a URL in `src/lib/content_config.js` (server/build-only — never import it from client code). Reference the file with a `?raw` import, so Vite inlines exactly the mapped files and a missing file fails the build: +Any markdown file in the repository can be mapped to a URL in `src/lib/content_config.ts` (server/build-only — never import it from client code). Reference the file with a `?raw` import, so Vite inlines exactly the mapped files and a missing file fails the build: ```js import manual_md from '../../README.md?raw'; diff --git a/eslint.config.js b/eslint.config.js index 3da30749..b470ffae 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -20,13 +20,52 @@ export default [ { languageOptions: { globals: { ...globals.browser, ...globals.node } + }, + rules: { + 'no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }] + } + }, + { + files: ['**/*.svelte', '**/*.svelte.js', '**/*.svelte.ts'], + languageOptions: { parserOptions: { svelteConfig, parser: typescriptParser } }, + plugins: { + '@typescript-eslint': typescript + }, + rules: { + 'svelte/no-navigation-without-resolve': 'off', + // The base rule reports parameters in type annotations; use the + // TypeScript-aware variant. + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' } + ] } }, + // TypeScript configuration for regular .ts modules (.svelte.ts files are + // handled by the svelte plugin configs above so runes stay defined) { - files: ['**/*.svelte', '**/*.svelte.js'], - languageOptions: { parserOptions: { svelteConfig } }, + files: ['**/*.ts'], + ignores: ['**/*.d.ts', '**/*.svelte.ts'], + languageOptions: { + parser: typescriptParser, + parserOptions: { + ecmaVersion: 2022, + sourceType: 'module' + } + }, + plugins: { + '@typescript-eslint': typescript + }, rules: { - 'svelte/no-navigation-without-resolve': 'off' + // TypeScript itself checks undefined names and unused variables of + // type-only constructs; use the TS-aware variants. + 'no-undef': 'off', + 'no-unused-vars': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' } + ] } }, // TypeScript configuration for .d.ts files @@ -35,7 +74,7 @@ export default [ languageOptions: { parser: typescriptParser, parserOptions: { - project: './jsconfig.json', + project: './tsconfig.json', ecmaVersion: 2022, sourceType: 'module' } @@ -45,15 +84,14 @@ export default [ }, rules: { // Enable TypeScript-specific rules for .d.ts files + 'no-undef': 'off', '@typescript-eslint/no-unused-vars': 'error', '@typescript-eslint/consistent-type-definitions': ['error', 'type'], '@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/prefer-namespace-keyword': 'error', '@typescript-eslint/triple-slash-reference': 'error', - '@typescript-eslint/no-var-requires': 'off', // Often needed in .d.ts files - '@typescript-eslint/ban-types': 'error', '@typescript-eslint/no-duplicate-enum-values': 'error', - '@typescript-eslint/no-empty-interface': 'error', + '@typescript-eslint/no-empty-object-type': 'error', '@typescript-eslint/no-inferrable-types': 'off', '@typescript-eslint/no-misused-new': 'error', '@typescript-eslint/no-namespace': 'error', diff --git a/package-lock.json b/package-lock.json index 06200c5d..0460ae67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,8 @@ "@tailwindcss/forms": "^0.5.11", "@tailwindcss/vite": "^4.2.2", "@types/node": "^25", + "@typescript-eslint/eslint-plugin": "^8.64.0", + "@typescript-eslint/parser": "^8.64.0", "eslint": "^10.0.3", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.15.1", @@ -1595,6 +1597,236 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz", + "integrity": "sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.64.0", + "@typescript-eslint/type-utils": "8.64.0", + "@typescript-eslint/utils": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.64.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.64.0.tgz", + "integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.64.0.tgz", + "integrity": "sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.64.0", + "@typescript-eslint/types": "^8.64.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.64.0.tgz", + "integrity": "sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.64.0.tgz", + "integrity": "sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.64.0.tgz", + "integrity": "sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0", + "@typescript-eslint/utils": "8.64.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.64.0.tgz", + "integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.64.0.tgz", + "integrity": "sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.64.0", + "@typescript-eslint/tsconfig-utils": "8.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/visitor-keys": "8.64.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.64.0.tgz", + "integrity": "sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.64.0", + "@typescript-eslint/types": "8.64.0", + "@typescript-eslint/typescript-estree": "8.64.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.64.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.64.0.tgz", + "integrity": "sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.64.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, "node_modules/@vitest/expect": { "version": "4.1.10", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", @@ -4311,6 +4543,19 @@ "node": ">=6" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", diff --git a/package.json b/package.json index 024f80af..7338c733 100644 --- a/package.json +++ b/package.json @@ -11,8 +11,8 @@ "build": "vite build", "preview": "vite preview", "prepare": "svelte-kit sync || echo ''", - "check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "test": "vitest run", "test:unit": "vitest", "migration:create": "node scripts/create-migration.js", @@ -40,6 +40,8 @@ "@tailwindcss/forms": "^0.5.11", "@tailwindcss/vite": "^4.2.2", "@types/node": "^25", + "@typescript-eslint/eslint-plugin": "^8.64.0", + "@typescript-eslint/parser": "^8.64.0", "eslint": "^10.0.3", "eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "^3.15.1", diff --git a/scripts/create-migration.js b/scripts/create-migration.js index 0e78e5f5..e9f808d8 100644 --- a/scripts/create-migration.js +++ b/scripts/create-migration.js @@ -37,7 +37,7 @@ if (!slug) fail('Migration name must contain a letter or number.'); const timestamp = new Date().toISOString().replace(/[-:.]/g, ''); const id = `${timestamp}_${namespace}_${slug}`; const migration_dir = join(process.cwd(), 'src/lib/server/migrations'); -const path = join(migration_dir, `${id}.js`); +const path = join(migration_dir, `${id}.ts`); const before_property = before.length > 0 ? `\n\tbefore: [${before.map((target) => `'${target}'`).join(', ')}],` : ''; const source = `export default {${before_property}\n\tup({ db }) {\n\t\t// Transform the database here.\n\t}\n};\n`; diff --git a/src/app.d.ts b/src/app.d.ts index 136b47dd..48a76bd6 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -3,10 +3,13 @@ declare global { namespace App { // interface Error {} + // SvelteKit's App types rely on declaration merging, so this must stay + // an interface. + // eslint-disable-next-line @typescript-eslint/consistent-type-definitions interface Locals { - db: DatabaseSync, - user: string, - is_admin: boolean, + db: import('node:sqlite').DatabaseSync; + user: string; + is_admin: boolean; } // interface PageData {} // interface PageState {} diff --git a/src/hooks.server.js b/src/hooks.server.ts similarity index 80% rename from src/hooks.server.js rename to src/hooks.server.ts index c5d62b30..84c8ff36 100644 --- a/src/hooks.server.js +++ b/src/hooks.server.ts @@ -1,4 +1,5 @@ import { env } from '$env/dynamic/private'; +import type { Handle, ServerInit } from '@sveltejs/kit'; import { admin_session_cookie_name, clear_admin_session_cookie, @@ -8,8 +9,7 @@ import { set_admin_session_cookie } from '$lib/server/auth.js'; -/** @type {import('@sveltejs/kit').ServerInit} */ -export async function init() { +export const init: ServerInit = async () => { if (!env.VERCEL && !env.ADMIN_PASSWORD) { throw new Error('ADMIN_PASSWORD must be set'); } @@ -18,10 +18,9 @@ export async function init() { const { default: migrate } = await import('$lib/server/migrate.js'); migrate(); } -} +}; -/** @type {import('@sveltejs/kit').Handle} */ -export const handle = async ({ event, resolve }) => { +export const handle: Handle = async ({ event, resolve }) => { event.locals.is_admin = false; if (!env.VERCEL) { @@ -29,9 +28,9 @@ export const handle = async ({ event, resolve }) => { if (session_id) { const { default: db } = await import('$lib/server/db.js'); - const row = /** @type {{ expires: number } | undefined } */ ( - db.prepare('SELECT expires FROM sessions WHERE session_id = ?').get(session_id) - ); + const row = db + .prepare('SELECT expires FROM sessions WHERE session_id = ?') + .get(session_id) as unknown as { expires: number } | undefined; const now = Math.floor(Date.now() / 1000); @@ -57,4 +56,4 @@ export const handle = async ({ event, resolve }) => { const response = await resolve(event); return response; -}; \ No newline at end of file +}; diff --git a/src/lib/api.remote.js b/src/lib/api.remote.ts similarity index 74% rename from src/lib/api.remote.js rename to src/lib/api.remote.ts index b78373d2..5ac9a6d9 100644 --- a/src/lib/api.remote.js +++ b/src/lib/api.remote.ts @@ -14,6 +14,9 @@ import { extract_site_metadata, collect_page_body_node_ids } from '$lib/page_metadata.js'; +import type { PreviewMediaNode } from '$lib/page_metadata.js'; +import type { Attachment, DocumentNode, NodeSchema, PropertyDefinition } from 'svedit'; +import type { StatementSync } from 'node:sqlite'; import { admin_session_cookie_name, get_required_admin_password, @@ -32,7 +35,7 @@ const admin_login_input_schema = v.object({ password: v.string() }); -function create_page_url_error_result(code, message) { +function create_page_url_error_result(code: string, message: string) { return { ok: false, code, @@ -40,7 +43,7 @@ function create_page_url_error_result(code, message) { }; } -function create_auth_error_result(code, message) { +function create_auth_error_result(code: string, message: string) { return { ok: false, code, @@ -48,84 +51,60 @@ function create_auth_error_result(code, message) { }; } -/** - * @param {number} seconds - * @returns {string} - */ -function format_lockout_duration(seconds) { +function format_lockout_duration(seconds: number): string { if (seconds < 60) return `${seconds} seconds`; const minutes = Math.ceil(seconds / 60); return minutes === 1 ? '1 minute' : `${minutes} minutes`; } -/** - * @typedef {Object} DocumentRow - * @property {string} document_id - * @property {string} type - * @property {string} data - * @property {string | null | undefined} created_at - * @property {string | null | undefined} updated_at - */ - -/** - * @typedef {Object} DocumentData - * @property {string} document_id - * @property {Record} nodes - */ - -/** - * @typedef {Object} PageDocumentRecord - * @property {string} document_id - * @property {Record} nodes - * @property {string | null} created_at - * @property {string | null} updated_at - */ - -/** - * @typedef {Object} PreviewMediaNode - * @property {string} type - * @property {string} src - * @property {number} width - * @property {number} height - * @property {string} alt - * @property {number} scale - * @property {number} focal_point_x - * @property {number} focal_point_y - * @property {string} object_fit - * @property {string | undefined} mime_type - */ - -/** - * @typedef {Object} PageSummary - * @property {string} document_id - * @property {string} title - * @property {string | null} description - * @property {PreviewMediaNode | null} preview_media_node - * @property {string} page_href - * @property {string} slug - * @property {string | null} created_at - * @property {string | null} updated_at - */ - -/** - * @typedef {Object} InternalLinkPreview - * @property {string} document_id - * @property {string} title - * @property {string | null} description - * @property {PreviewMediaNode | null} preview_media_node - */ - -/** - * @typedef {Object} PageTreeNode - * @property {string} document_id - * @property {string} title - * @property {PreviewMediaNode | null} preview_media_node - * @property {string} page_href - * @property {string} slug - * @property {string | null} created_at - * @property {string | null} updated_at - * @property {PageTreeNode[]} children - */ +type DocumentRow = { + document_id: string; + type: string; + data: string; + created_at: string | null | undefined; + updated_at: string | null | undefined; +}; + +type DocumentData = { + document_id: string; + nodes: Record; +}; + +type PageDocumentRecord = { + document_id: string; + nodes: Record; + created_at: string | null; + updated_at: string | null; +}; + +export type PageSummary = { + document_id: string; + title: string; + description: string | null; + preview_media_node: PreviewMediaNode | null; + page_href: string; + slug: string; + created_at: string | null; + updated_at: string | null; +}; + +export type InternalLinkPreview = { + document_id: string; + title: string; + description: string | null; + preview_media_node: PreviewMediaNode | null; +}; + +export type PageTreeNode = { + document_id: string; + title: string; + preview_media_node: PreviewMediaNode | null; + page_href: string; + slug: string; + created_at: string | null; + updated_at: string | null; + children: PageTreeNode[]; +}; const save_document_input_schema = v.object({ document_id: v.string(), @@ -142,22 +121,23 @@ const delete_page_input_schema = v.object({ document_id: v.string() }); -function get_attached_ranges(value) { +function get_attached_ranges( + value: { marks?: Attachment[]; annotations?: Attachment[] } | null | undefined +): Attachment[] { return [...(value?.marks ?? []), ...(value?.annotations ?? [])]; } /** * Collect all node ids reachable from a root node by walking node/node_array * properties and mark/annotation references. - * - * @param {string} root_id - * @param {Record} nodes - * @param {Set} [exclude_roots] - * @returns {Set} */ -function collect_node_ids(root_id, nodes, exclude_roots) { - const collected = new Set(); - const stack = [root_id]; +function collect_node_ids( + root_id: string, + nodes: Record, + exclude_roots?: Set +): Set { + const collected = new Set(); + const stack: string[] = [root_id]; while (stack.length > 0) { const id = stack.pop(); @@ -169,10 +149,12 @@ function collect_node_ids(root_id, nodes, exclude_roots) { const node = nodes[id]; if (!node) continue; - const type_schema = document_schema[node.type]; + const type_schema: NodeSchema | undefined = document_schema[node.type]; if (!type_schema) continue; - for (const [prop_name, prop_def] of Object.entries(type_schema.properties)) { + for (const [prop_name, prop_def] of Object.entries( + type_schema.properties + )) { const value = node[prop_name]; if (value == null) continue; @@ -200,13 +182,10 @@ function collect_node_ids(root_id, nodes, exclude_roots) { return collected; } -/** - * @returns {Set} - */ -function get_referenced_asset_ids() { - const rows = /** @type {Array<{ asset_id: string }>} */ ( - db.prepare('SELECT DISTINCT asset_id FROM asset_refs').all() - ); +function get_referenced_asset_ids(): Set { + const rows = db.prepare('SELECT DISTINCT asset_id FROM asset_refs').all() as unknown as Array<{ + asset_id: string; + }>; return new Set(rows.map((row) => row.asset_id)); } @@ -217,10 +196,9 @@ function get_referenced_asset_ids() { * Assets that lost their last reference in the write (refs_before minus * refs_after) get their orphan clock started via touch_asset, so the grace * period runs from dereferencing — not from upload. - * - * @param {Set} refs_before - referenced asset ids captured before the write + * refs_before holds the referenced asset ids captured before the write. */ -async function cleanup_orphaned_assets(refs_before) { +async function cleanup_orphaned_assets(refs_before: Set) { try { const refs_after = get_referenced_asset_ids(); @@ -236,14 +214,12 @@ async function cleanup_orphaned_assets(refs_before) { } } -/** - * @param {string} document_id - * @param {Set} node_ids - * @param {Record} all_nodes - * @returns {DocumentData} - */ -function extract_document(document_id, node_ids, all_nodes) { - const nodes = {}; +function extract_document( + document_id: string, + node_ids: Set, + all_nodes: Record +): DocumentData { + const nodes: Record = {}; for (const id of node_ids) { if (all_nodes[id]) { nodes[id] = all_nodes[id]; @@ -252,14 +228,10 @@ function extract_document(document_id, node_ids, all_nodes) { return { document_id, nodes }; } -/** - * @param {string} document_id - * @returns {DocumentData} - */ -function get_doc_from_db(document_id) { - const doc_row = /** @type {DocumentRow | undefined} */ ( - db.prepare('SELECT * FROM documents WHERE document_id = ?').get(document_id) - ); +function get_doc_from_db(document_id: string): DocumentData { + const doc_row = db + .prepare('SELECT * FROM documents WHERE document_id = ?') + .get(document_id) as unknown as DocumentRow | undefined; if (!doc_row) { throw new Error(`Document not found: ${document_id}`); @@ -268,60 +240,41 @@ function get_doc_from_db(document_id) { return JSON.parse(doc_row.data); } -/** - * @param {string} document_id - * @returns {DocumentData | null} - */ -function get_optional_doc_from_db(document_id) { - const doc_row = /** @type {DocumentRow | undefined} */ ( - db.prepare('SELECT * FROM documents WHERE document_id = ?').get(document_id) - ); +function get_optional_doc_from_db(document_id: string): DocumentData | null { + const doc_row = db + .prepare('SELECT * FROM documents WHERE document_id = ?') + .get(document_id) as unknown as DocumentRow | undefined; if (!doc_row) return null; return JSON.parse(doc_row.data); } -/** - * @returns {string | null} - */ -function get_home_page_id_from_db() { - const row = /** @type {{ value: string } | undefined } */ ( - db.prepare('SELECT value FROM site_settings WHERE key = ?').get('home_page_id') - ); +function get_home_page_id_from_db(): string | null { + const row = db.prepare('SELECT value FROM site_settings WHERE key = ?').get('home_page_id') as + | { value: string } + | undefined; return row?.value ?? null; } -/** - * @param {string} document_id - * @returns {boolean} - */ -function is_home_page_document_id(document_id) { +function is_home_page_document_id(document_id: string): boolean { return get_home_page_id_from_db() === document_id; } -/** - * @param {string} document_id - * @returns {string | null} - */ -function get_active_slug_for_document_id(document_id) { - const row = /** @type {{ slug: string } | undefined } */ ( - db - .prepare('SELECT slug FROM document_slugs WHERE document_id = ? AND is_active = 1') - .get(document_id) - ); +function get_active_slug_for_document_id(document_id: string): string | null { + const row = db + .prepare('SELECT slug FROM document_slugs WHERE document_id = ? AND is_active = 1') + .get(document_id) as unknown as { slug: string } | undefined; return row?.slug ?? null; } -/** - * @param {string} slug - * @returns {{ document_id: string, is_active: boolean, active_slug: string } | null} - */ -function resolve_slug(slug) { - const row = /** @type {{ document_id: string, is_active: number } | undefined } */ ( - db.prepare('SELECT document_id, is_active FROM document_slugs WHERE slug = ?').get(slug) - ); +function resolve_slug( + slug: string +): { document_id: string; is_active: boolean; active_slug: string } | null { + const row = db + .prepare('SELECT document_id, is_active FROM document_slugs WHERE slug = ?') + .get(slug) as unknown as { document_id: string; is_active: number } | undefined; if (!row) return null; @@ -337,16 +290,13 @@ function resolve_slug(slug) { }; } -/** - * @returns {PageDocumentRecord[]} - */ -function list_page_documents() { - const rows = /** @type {DocumentRow[]} */ ( - db.prepare('SELECT * FROM documents WHERE type = ? ORDER BY document_id').all('page') - ); +function list_page_documents(): PageDocumentRecord[] { + const rows = db + .prepare('SELECT * FROM documents WHERE type = ? ORDER BY document_id') + .all('page') as unknown as DocumentRow[]; return rows.map((row) => { - const doc = /** @type {DocumentData} */ (JSON.parse(row.data)); + const doc = JSON.parse(row.data) as DocumentData; return { document_id: doc.document_id, nodes: doc.nodes, @@ -356,39 +306,26 @@ function list_page_documents() { }); } -/** - * @param {string} title - * @param {string} document_id - * @returns {string} - */ -function create_slug_candidate(title, document_id) { +function create_slug_candidate(title: string, document_id: string): string { const slug = slugify(title, { lower: true, strict: true, trim: true }); return slug || document_id; } -/** - * @param {string} base_slug - * @returns {string} - */ -function create_unique_slug(base_slug) { +function create_unique_slug(base_slug: string): string { const slug_exists_stmt = db.prepare('SELECT document_id FROM document_slugs WHERE slug = ?'); let slug = base_slug; let suffix = 2; while (true) { - const row = /** @type {{ document_id: string } | undefined } */ (slug_exists_stmt.get(slug)); + const row = slug_exists_stmt.get(slug) as unknown as { document_id: string } | undefined; if (!row) return slug; slug = `${base_slug}-${suffix}`; suffix += 1; } } -/** - * @param {string} href - * @returns {{ slug: string, fragment: string }} | null - */ -function parse_internal_page_href(href) { +function parse_internal_page_href(href: string): { slug: string; fragment: string } | null { if (!href) return null; if (/^[a-z][a-z0-9+.-]*:/i.test(href)) return null; if (href.startsWith('//')) return null; @@ -409,12 +346,10 @@ function parse_internal_page_href(href) { }; } -/** - * @param {string} href - * @param {string | undefined} source_document_id - * @returns {string | null} - */ -function normalize_internal_page_href(href, source_document_id) { +function normalize_internal_page_href( + href: string, + source_document_id: string | undefined +): string | null { const parsed = parse_internal_page_href(href); if (!parsed) return null; @@ -425,15 +360,13 @@ function normalize_internal_page_href(href, source_document_id) { return resolved.document_id; } -/** - * @param {Record} nodes - * @param {Iterable} node_ids - * @param {string} source_document_id - * @returns {string[]} - */ -function collect_document_refs(nodes, node_ids, source_document_id) { - const refs = []; - const seen_refs = new Set(); +function collect_document_refs( + nodes: Record, + node_ids: Iterable, + source_document_id: string +): string[] { + const refs: string[] = []; + const seen_refs = new Set(); for (const node_id of node_ids) { const node = nodes[node_id]; @@ -447,10 +380,12 @@ function collect_document_refs(nodes, node_ids, source_document_id) { } } - const type_schema = document_schema[node.type]; + const type_schema: NodeSchema | undefined = document_schema[node.type]; if (!type_schema) continue; - for (const [prop_name, prop_def] of Object.entries(type_schema.properties)) { + for (const [prop_name, prop_def] of Object.entries( + type_schema.properties + )) { if (prop_def.type !== 'text') continue; const value = node[prop_name]; @@ -476,15 +411,14 @@ function collect_document_refs(nodes, node_ids, source_document_id) { return refs; } -/** - * @param {string} document_id - * @param {Iterable} node_ids - * @param {Record} all_nodes - * @param {import('node:sqlite').StatementSync} delete_stmt - * @param {import('node:sqlite').StatementSync} insert_stmt - */ -function update_asset_refs(document_id, node_ids, all_nodes, delete_stmt, insert_stmt) { - const asset_ids = new Set(); +function update_asset_refs( + document_id: string, + node_ids: Iterable, + all_nodes: Record, + delete_stmt: StatementSync, + insert_stmt: StatementSync +) { + const asset_ids = new Set(); for (const node_id of node_ids) { const node = all_nodes[node_id]; @@ -505,24 +439,22 @@ function update_asset_refs(document_id, node_ids, all_nodes, delete_stmt, insert } } -/** - * @param {string} source_document_id - * @param {string[]} target_document_ids - * @param {import('node:sqlite').StatementSync} delete_stmt - * @param {import('node:sqlite').StatementSync} insert_stmt - */ -function update_document_refs(source_document_id, target_document_ids, delete_stmt, insert_stmt) { +function update_document_refs( + source_document_id: string, + target_document_ids: string[], + delete_stmt: StatementSync, + insert_stmt: StatementSync +) { delete_stmt.run(source_document_id); for (const [ref_order, target_document_id] of target_document_ids.entries()) { insert_stmt.run(target_document_id, source_document_id, ref_order); } } -/** - * @param {DocumentData} page_doc - * @returns {{ nav_root_id: string | null, footer_root_id: string | null }} - */ -function get_shared_root_ids(page_doc) { +function get_shared_root_ids(page_doc: DocumentData): { + nav_root_id: string | null; + footer_root_id: string | null; +} { const page_node = page_doc.nodes[page_doc.document_id]; return { @@ -531,11 +463,7 @@ function get_shared_root_ids(page_doc) { }; } -/** - * @param {string} document_id - * @returns {DocumentData} - */ -function get_combined_document(document_id) { +function get_combined_document(document_id: string): DocumentData { const page_doc = get_doc_from_db(document_id); const page_node = page_doc.nodes[page_doc.document_id]; const merged_nodes = { ...page_doc.nodes }; @@ -556,11 +484,7 @@ function get_combined_document(document_id) { }; } -/** - * @param {PageDocumentRecord} page_doc - * @returns {PageSummary} - */ -function summarize_page_document(page_doc) { +function summarize_page_document(page_doc: PageDocumentRecord): PageSummary { const metadata = extract_page_metadata({ document_id: page_doc.document_id, nodes: page_doc.nodes @@ -581,31 +505,23 @@ function summarize_page_document(page_doc) { }; } -/** - * @param {string} source_document_id - * @returns {string[]} - */ -function get_outgoing_refs(source_document_id) { - const rows = /** @type {Array<{ target_document_id: string }>} */ ( - db - .prepare( - 'SELECT target_document_id FROM document_refs WHERE source_document_id = ? ORDER BY ref_order, rowid' - ) - .all(source_document_id) - ); +function get_outgoing_refs(source_document_id: string): string[] { + const rows = db + .prepare( + 'SELECT target_document_id FROM document_refs WHERE source_document_id = ? ORDER BY ref_order, rowid' + ) + .all(source_document_id) as unknown as Array<{ target_document_id: string }>; return rows.map((row) => row.target_document_id); } -/** - * @param {string[]} refs - * @param {Set} assigned_page_ids - * @param {Map} summaries_by_id - * @param {Map} body_refs_by_page_id - * @returns {PageTreeNode[]} - */ -function build_tree_children(refs, assigned_page_ids, summaries_by_id, body_refs_by_page_id) { - const children = []; +function build_tree_children( + refs: string[], + assigned_page_ids: Set, + summaries_by_id: Map, + body_refs_by_page_id: Map +): PageTreeNode[] { + const children: PageTreeNode[] = []; for (const target_document_id of refs) { if (assigned_page_ids.has(target_document_id)) continue; @@ -635,21 +551,13 @@ function build_tree_children(refs, assigned_page_ids, summaries_by_id, body_refs return children; } -/** - * @param {string} root_document_id - * @param {Set} assigned_page_ids - * @param {Map} summaries_by_id - * @param {Map} body_refs_by_page_id - * @param {string[] | null} root_refs - * @returns {PageTreeNode | null} - */ function build_page_tree_node( - root_document_id, - assigned_page_ids, - summaries_by_id, - body_refs_by_page_id, - root_refs = null -) { + root_document_id: string, + assigned_page_ids: Set, + summaries_by_id: Map, + body_refs_by_page_id: Map, + root_refs: string[] | null = null +): PageTreeNode | null { const summary = summaries_by_id.get(root_document_id); if (!summary) return null; if (assigned_page_ids.has(root_document_id)) return null; @@ -673,14 +581,11 @@ function build_page_tree_node( }; } -/** - * @returns {{ - * home_page_id: string | null, - * current_document_id: string | null, - * page_forest: PageTreeNode[] - * }} - */ -function build_page_browser_data() { +function build_page_browser_data(): { + home_page_id: string | null; + current_document_id: string | null; + page_forest: PageTreeNode[]; +} { const request_event = getRequestEvent(); const pathname = request_event.url.pathname; const home_page_id = get_home_page_id_from_db(); @@ -696,7 +601,7 @@ function build_page_browser_data() { ? get_shared_root_ids(home_page_doc) : { nav_root_id: null, footer_root_id: null }; - const body_refs_by_page_id = new Map(); + const body_refs_by_page_id = new Map(); for (const page_doc of page_docs) { const body_node_ids = collect_page_body_node_ids(page_doc); body_refs_by_page_id.set( @@ -705,9 +610,9 @@ function build_page_browser_data() { ); } - const page_forest = []; - const assigned_page_ids = new Set(); - const incoming_page_ref_counts = new Map(); + const page_forest: PageTreeNode[] = []; + const assigned_page_ids = new Set(); + const incoming_page_ref_counts = new Map(); for (const page_doc of page_docs) { incoming_page_ref_counts.set(page_doc.document_id, 0); @@ -723,7 +628,7 @@ function build_page_browser_data() { } } - let home_linked_page_ids = new Set(); + let home_linked_page_ids = new Set(); if (home_page_id && summaries_by_id.has(home_page_id)) { const nav_refs = nav_root_id ? get_outgoing_refs(nav_root_id) : []; @@ -1000,28 +905,29 @@ export const get_internal_link_preview = query(v.string(), async (href) => { return null; } - const doc_row = /** @type {DocumentRow | undefined} */ ( - db.prepare('SELECT type, data FROM documents WHERE document_id = ?').get(resolved.document_id) - ); + const doc_row = db + .prepare('SELECT type, data FROM documents WHERE document_id = ?') + .get(resolved.document_id) as unknown as DocumentRow | undefined; if (!doc_row || doc_row.type !== 'page') { return null; } - const page_doc = /** @type {DocumentData} */ (JSON.parse(doc_row.data)); + const page_doc = JSON.parse(doc_row.data) as DocumentData; const metadata = extract_page_metadata(page_doc); - return /** @type {InternalLinkPreview} */ ({ + const preview: InternalLinkPreview = { document_id: resolved.document_id, title: metadata.title || 'Untitled page', description: metadata.description, preview_media_node: metadata.preview_media_node - }); + }; + return preview; }); /** * Save a document to the database, splitting shared documents (nav, footer) back out. */ -function rewrite_internal_page_href(href, target_document_id, new_slug) { +function rewrite_internal_page_href(href: string, target_document_id: string, new_slug: string) { const parsed = parse_internal_page_href(href); if (!parsed) return href; @@ -1031,7 +937,11 @@ function rewrite_internal_page_href(href, target_document_id, new_slug) { return `/${new_slug}${parsed.fragment}`; } -function rewrite_internal_page_hrefs(nodes, target_document_id, new_slug) { +function rewrite_internal_page_hrefs( + nodes: Record, + target_document_id: string, + new_slug: string +) { for (const node of Object.values(nodes)) { if (!node || typeof node !== 'object') continue; @@ -1039,10 +949,12 @@ function rewrite_internal_page_hrefs(nodes, target_document_id, new_slug) { node.href = rewrite_internal_page_href(node.href, target_document_id, new_slug); } - const type_schema = document_schema[node.type]; + const type_schema: NodeSchema | undefined = document_schema[node.type]; if (!type_schema) continue; - for (const [prop_name, prop_def] of Object.entries(type_schema.properties)) { + for (const [prop_name, prop_def] of Object.entries( + type_schema.properties + )) { if (prop_def.type !== 'text') continue; const value = node[prop_name]; @@ -1058,16 +970,21 @@ function rewrite_internal_page_hrefs(nodes, target_document_id, new_slug) { } } -function insert_active_slug(document_id, slug, insert_slug_stmt, deactivate_slug_stmt) { +function insert_active_slug( + document_id: string, + slug: string, + insert_slug_stmt: StatementSync, + deactivate_slug_stmt: StatementSync +) { deactivate_slug_stmt.run(document_id); insert_slug_stmt.run(slug, document_id, 1, new Date().toISOString()); } function move_active_slug_to_history( - document_id, - insert_slug_stmt, - deactivate_slug_stmt, - delete_slug_stmt + document_id: string, + insert_slug_stmt: StatementSync, + deactivate_slug_stmt: StatementSync, + delete_slug_stmt: StatementSync ) { const current_slug = get_active_slug_for_document_id(document_id); if (!current_slug) return null; @@ -1079,11 +996,11 @@ function move_active_slug_to_history( } function assign_active_slug( - document_id, - slug, - insert_slug_stmt, - deactivate_slug_stmt, - delete_slug_stmt + document_id: string, + slug: string, + insert_slug_stmt: StatementSync, + deactivate_slug_stmt: StatementSync, + delete_slug_stmt: StatementSync ) { delete_slug_stmt.run(slug); insert_active_slug(document_id, slug, insert_slug_stmt, deactivate_slug_stmt); @@ -1119,12 +1036,12 @@ export const save_document = command(save_document_input_schema, async (combined const nav_node_ids = nav_root_id ? new Set(collect_node_ids_in_order(nav_root_id, all_nodes)) - : new Set(); + : new Set(); const footer_node_ids = footer_root_id ? new Set(collect_node_ids_in_order(footer_root_id, all_nodes)) - : new Set(); + : new Set(); - const exclude_roots = new Set(); + const exclude_roots = new Set(); if (nav_root_id) exclude_roots.add(nav_root_id); if (footer_root_id) exclude_roots.add(footer_root_id); @@ -1156,11 +1073,9 @@ export const save_document = command(save_document_input_schema, async (combined const refs_before = get_referenced_asset_ids(); with_transaction(() => { - const existing_page_row = /** @type {DocumentRow | undefined} */ ( - db - .prepare('SELECT created_at FROM documents WHERE document_id = ?') - .get(combined_doc.document_id) - ); + const existing_page_row = db + .prepare('SELECT created_at FROM documents WHERE document_id = ?') + .get(combined_doc.document_id) as unknown as DocumentRow | undefined; const now_iso = new Date().toISOString(); const created_at = existing_page_row?.created_at ?? now_iso; @@ -1181,9 +1096,9 @@ export const save_document = command(save_document_input_schema, async (combined if (nav_root_id && nav_node_ids.size > 0) { const nav_doc = extract_document(nav_root_id, nav_node_ids, all_nodes); - const existing_nav_row = /** @type {DocumentRow | undefined} */ ( - db.prepare('SELECT created_at FROM documents WHERE document_id = ?').get(nav_root_id) - ); + const existing_nav_row = db + .prepare('SELECT created_at FROM documents WHERE document_id = ?') + .get(nav_root_id) as unknown as DocumentRow | undefined; const nav_created_at = existing_nav_row?.created_at ?? now_iso; upsert.run(nav_root_id, 'nav', JSON.stringify(nav_doc), nav_created_at, now_iso); update_asset_refs(nav_root_id, nav_node_ids, all_nodes, delete_asset_refs, insert_asset_ref); @@ -1197,9 +1112,9 @@ export const save_document = command(save_document_input_schema, async (combined if (footer_root_id && footer_node_ids.size > 0) { const footer_doc = extract_document(footer_root_id, footer_node_ids, all_nodes); - const existing_footer_row = /** @type {DocumentRow | undefined} */ ( - db.prepare('SELECT created_at FROM documents WHERE document_id = ?').get(footer_root_id) - ); + const existing_footer_row = db + .prepare('SELECT created_at FROM documents WHERE document_id = ?') + .get(footer_root_id) as unknown as DocumentRow | undefined; const footer_created_at = existing_footer_row?.created_at ?? now_iso; upsert.run(footer_root_id, 'footer', JSON.stringify(footer_doc), footer_created_at, now_iso); update_asset_refs( @@ -1300,11 +1215,9 @@ export const update_page_slug = command(update_page_slug_input_schema, async (in }; } - const existing_slug = /** @type {{ document_id: string, is_active: number } | undefined} */ ( - db - .prepare('SELECT document_id, is_active FROM document_slugs WHERE slug = ?') - .get(normalized_slug) - ); + const existing_slug = db + .prepare('SELECT document_id, is_active FROM document_slugs WHERE slug = ?') + .get(normalized_slug) as unknown as { document_id: string; is_active: number } | undefined; if ( existing_slug && @@ -1345,11 +1258,9 @@ export const update_page_slug = command(update_page_slug_input_schema, async (in throw new Error('Failed to assign new active slug'); } - const page_rows = /** @type {DocumentRow[]} */ ( - db - .prepare('SELECT * FROM documents WHERE type IN (?, ?, ?) ORDER BY document_id') - .all('page', 'nav', 'footer') - ); + const page_rows = db + .prepare('SELECT * FROM documents WHERE type IN (?, ?, ?) ORDER BY document_id') + .all('page', 'nav', 'footer') as unknown as DocumentRow[]; const upsert = db.prepare( 'INSERT INTO documents (document_id, type, data, created_at, updated_at) VALUES(?, ?, ?, ?, ?) ON CONFLICT(document_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at' diff --git a/src/lib/client/asset_processor.js b/src/lib/client/asset_processor.ts similarity index 78% rename from src/lib/client/asset_processor.js rename to src/lib/client/asset_processor.ts index ee4e633d..22ab0576 100644 --- a/src/lib/client/asset_processor.js +++ b/src/lib/client/asset_processor.ts @@ -5,11 +5,8 @@ const WEBP_QUALITY = 80; /** * Decode an image file to ImageData using the browser's built-in decoding. * Works for JPEG, PNG, WebP, GIF, BMP, etc. - * - * @param {Blob} blob - * @returns {Promise} */ -async function decode_to_image_data(blob) { +async function decode_to_image_data(blob: Blob): Promise { const bitmap = await createImageBitmap(blob); const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); const ctx = canvas.getContext('2d'); @@ -21,11 +18,8 @@ async function decode_to_image_data(blob) { /** * Convert ImageData to a PNG blob using canvas. - * - * @param {ImageData} image_data - * @returns {Promise} */ -async function image_data_to_png_blob(image_data) { +async function image_data_to_png_blob(image_data: ImageData): Promise { const canvas = new OffscreenCanvas(image_data.width, image_data.height); const ctx = canvas.getContext('2d'); if (!ctx) throw new Error('Could not get OffscreenCanvas 2d context'); @@ -35,12 +29,8 @@ async function image_data_to_png_blob(image_data) { /** * Resize an image blob to a specific width using canvas, maintaining aspect ratio. - * - * @param {Blob} blob - * @param {number} target_width - * @returns {Promise} */ -async function resize_blob_to_width(blob, target_width) { +async function resize_blob_to_width(blob: Blob, target_width: number): Promise { const bitmap = await createImageBitmap(blob); const scale = target_width / bitmap.width; const target_height = Math.round(bitmap.height * scale); @@ -58,12 +48,8 @@ async function resize_blob_to_width(blob, target_width) { /** * Resize ImageData to fit within max_width, preserving aspect ratio. * Returns the original ImageData if it already fits. - * - * @param {ImageData} image_data - * @param {number} max_width - * @returns {Promise} */ -async function resize_to_fit(image_data, max_width) { +async function resize_to_fit(image_data: ImageData, max_width: number): Promise { const { width } = image_data; if (width <= max_width) return image_data; @@ -73,40 +59,27 @@ async function resize_to_fit(image_data, max_width) { /** * Resize ImageData to a specific width, maintaining aspect ratio. - * - * @param {ImageData} image_data - * @param {number} target_width - * @returns {Promise} */ -async function resize_to_width(image_data, target_width) { +async function resize_to_width(image_data: ImageData, target_width: number): Promise { const png_blob = await image_data_to_png_blob(image_data); return await resize_blob_to_width(png_blob, target_width); } /** * Encode ImageData to WebP ArrayBuffer. - * - * @param {ImageData} image_data - * @param {number} [quality] - * @returns {Promise} */ -async function encode_to_webp(image_data, quality = WEBP_QUALITY) { +async function encode_to_webp(image_data: ImageData, quality = WEBP_QUALITY): Promise { return await encodeWebP(image_data, { quality }); } -/** - * @param {string} status - */ -function post_status(status) { +function post_status(status: string) { self.postMessage({ type: 'status', status }); } /** * Handle a process request from the main thread. - * - * @param {{ file: File, max_width: number, variant_widths: number[] }} data */ -async function handle_process(data) { +async function handle_process(data: { file: File; max_width: number; variant_widths: number[] }) { const { file, max_width, variant_widths } = data; try { @@ -129,8 +102,7 @@ async function handle_process(data) { // 4. Generate size variants (only for widths smaller than the original) const applicable_widths = variant_widths.filter((w) => w < original_width); - /** @type {Array<{ width: number, buffer: ArrayBuffer }>} */ - const variants = []; + const variants: Array<{ width: number; buffer: ArrayBuffer }> = []; for (let i = 0; i < applicable_widths.length; i++) { const target_width = applicable_widths[i]; @@ -166,4 +138,4 @@ self.addEventListener('message', (e) => { if (e.data?.type === 'process') { handle_process(e.data); } -}); \ No newline at end of file +}); diff --git a/src/lib/client/asset_upload.js b/src/lib/client/asset_upload.ts similarity index 71% rename from src/lib/client/asset_upload.js rename to src/lib/client/asset_upload.ts index 5cb12ce3..0e2c3c17 100644 --- a/src/lib/client/asset_upload.js +++ b/src/lib/client/asset_upload.ts @@ -1,5 +1,6 @@ import { process_asset } from './process_asset.js'; import { process_video } from './process_video.js'; +import type { ProcessVideoOptions, ProcessedVideo } from './process_video.js'; import { EXT_TO_MIME, MAX_IMAGE_WIDTH, @@ -7,34 +8,31 @@ import { OPTIMIZED_VIDEO_REGEX } from '$lib/config.js'; import { get_video_dimensions, get_media_dimensions } from './media_dimensions.js'; +import type { DocumentNode } from 'svedit'; -/** - * @typedef {{ - * hash: string, - * asset_id: string, - * original: { blob: Blob, width: number, height: number }, - * variants: Array<{ width: number, blob: Blob }>, - * status: 'processing' | 'ready' | 'error', - * progress: number, - * error: string | null - * }} PendingAsset - */ +export type PendingAsset = { + hash: string; + asset_id: string; + original: { blob: Blob; width: number; height: number }; + variants: Array<{ width: number; blob: Blob }>; + status: 'processing' | 'ready' | 'error'; + progress: number; + error: string | null; +}; + +/** The result of a successful asset upload. */ +export type UploadedAsset = { asset_id: string; width: number; height: number }; /** * Map of blob URL → PendingAsset. Populated when images are pasted/dropped, * consulted during the save flow to upload and replace blob URLs. - * - * @type {Map} */ -const pending_assets = new Map(); +const pending_assets = new Map(); /** * Compute SHA-256 hex hash of a Blob. - * - * @param {Blob} blob - * @returns {Promise} */ -async function hash_blob(blob) { +async function hash_blob(blob: Blob): Promise { const buffer = await blob.arrayBuffer(); const hash_buffer = await crypto.subtle.digest('SHA-256', buffer); const hash_array = Array.from(new Uint8Array(hash_buffer)); @@ -43,11 +41,8 @@ async function hash_blob(blob) { /** * Detect whether a GIF file is animated by counting Graphic Control Extension blocks. - * - * @param {File} file - * @returns {Promise} */ -async function is_animated_gif(file) { +async function is_animated_gif(file: File): Promise { if (file.type !== 'image/gif') return false; const buffer = await file.arrayBuffer(); const bytes = new Uint8Array(buffer); @@ -63,12 +58,8 @@ async function is_animated_gif(file) { /** * Determine the stored file extension for a given file. - * - * @param {File} file - * @param {boolean} animated - * @returns {string} */ -function get_stored_extension(file, animated) { +function get_stored_extension(file: File, animated: boolean): string { if (file.type === 'image/svg+xml') return 'svg'; if (file.type === 'image/gif' && animated) return 'gif'; // All other raster images get converted to WebP @@ -77,39 +68,29 @@ function get_stored_extension(file, animated) { /** * Check if a file is a video based on MIME type. - * - * @param {File} file - * @returns {boolean} */ -function is_video(file) { +function is_video(file: File): boolean { return file.type.startsWith('video/'); } /** * Check if a video file is marked as already web-optimized via the filename * convention (e.g. my_video_optimized.mp4). Such files are uploaded as-is. - * - * @param {File} file - * @returns {boolean} */ -function is_preoptimized_video(file) { +function is_preoptimized_video(file: File): boolean { return file.type === 'video/mp4' && OPTIMIZED_VIDEO_REGEX.test(file.name); } /** * Serialize video transcode jobs: parallel transcodes would compete for * memory and hardware encoders, so run them one at a time. - * - * @type {Promise} */ -let video_queue = Promise.resolve(); +let video_queue: Promise = Promise.resolve(); -/** - * @param {File} file - * @param {import('./process_video.js').ProcessVideoOptions} options - * @returns {Promise} - */ -function enqueue_video_processing(file, options) { +function enqueue_video_processing( + file: File, + options: ProcessVideoOptions +): Promise { const job = video_queue.then(() => process_video(file, options)); // Keep the queue chain alive even if this job fails video_queue = job.catch(() => {}); @@ -118,15 +99,12 @@ function enqueue_video_processing(file, options) { /** * Start background processing for a pasted/dropped media file. - * Call this from handle_media_paste. The blob_url is used as the key - * to look up the processing result during the save flow. - * - * @param {string} blob_url - The blob: URL set as the image node's src - * @param {File} file - The original source file + * Call this from handle_media_paste. The blob_url (the blob: URL set as the + * media node's src) is used as the key to look up the processing result + * during the save flow. */ -export async function start_processing(blob_url, file) { - /** @type {PendingAsset} */ - const entry = { +export async function start_processing(blob_url: string, file: File) { + const entry: PendingAsset = { hash: '', asset_id: '', original: { blob: file, width: 0, height: 0 }, @@ -142,10 +120,7 @@ export async function start_processing(blob_url, file) { if (is_preoptimized_video(file)) { // Escape hatch: filename marks the file as already optimized — // upload the raw bytes without transcoding. - const [hash, dims] = await Promise.all([ - hash_blob(file), - get_video_dimensions(file) - ]); + const [hash, dims] = await Promise.all([hash_blob(file), get_video_dimensions(file)]); entry.hash = hash; entry.asset_id = `${hash}.mp4`; entry.original = { blob: file, width: dims.width, height: dims.height }; @@ -210,31 +185,27 @@ export async function start_processing(blob_url, file) { /** * Check if there are any assets still being processed. - * - * @returns {boolean} */ -export function has_pending_processing() { +export function has_pending_processing(): boolean { for (const entry of pending_assets.values()) { if (entry.status === 'processing') return true; } return false; } -/** - * @callback ProcessingProgressCallback - * @param {{ done: number, total: number, progress: number }} progress - */ +export type ProcessingProgressCallback = (progress: { + done: number; + total: number; + progress: number; +}) => void; /** * Wait until all pending assets have finished processing. * The reported progress is the average completion across all entries (0–1); * video transcodes report incremental progress, other entries count as * 0 while processing and 1 when done. - * - * @param {ProcessingProgressCallback} [on_progress] - optional progress callback - * @returns {Promise} */ -export async function wait_for_processing(on_progress) { +export async function wait_for_processing(on_progress?: ProcessingProgressCallback): Promise { while (has_pending_processing()) { if (on_progress) { let done = 0; @@ -257,14 +228,13 @@ export async function wait_for_processing(on_progress) { /** * Upload a blob using XHR with progress tracking. - * - * @param {string} url - * @param {Blob} blob - * @param {Record} headers - * @param {(progress: number) => void} [on_progress] - * @returns {Promise} */ -function upload_blob(url, blob, headers, on_progress) { +function upload_blob( + url: string, + blob: Blob, + headers: Record, + on_progress?: (progress: number) => void +): Promise { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); if (on_progress) { @@ -282,7 +252,9 @@ function upload_blob(url, blob, headers, on_progress) { try { const body = JSON.parse(xhr.responseText); if (body.message) message = body.message; - } catch { /* ignore */ } + } catch { + /* ignore */ + } reject(new Error(message)); } }); @@ -299,11 +271,8 @@ function upload_blob(url, blob, headers, on_progress) { /** * Upload a single asset (original + variants) to the server. * Returns the asset_id on success. - * - * @param {PendingAsset} entry - * @returns {Promise<{ asset_id: string, width: number, height: number }>} */ -async function upload_asset(entry) { +async function upload_asset(entry: PendingAsset): Promise { const ext = entry.asset_id.slice(entry.asset_id.lastIndexOf('.') + 1); const content_type = EXT_TO_MIME[ext]; @@ -317,7 +286,11 @@ async function upload_asset(entry) { // If deduplicated, skip variant uploads if (result.deduplicated) { - return { asset_id: result.asset_id, width: entry.original.width, height: entry.original.height }; + return { + asset_id: result.asset_id, + width: entry.original.width, + height: entry.original.height + }; } // Upload variants sequentially @@ -332,31 +305,34 @@ async function upload_asset(entry) { // Clean up the partially uploaded asset try { await fetch(`/api/assets/${result.asset_id}`, { method: 'DELETE' }); - } catch { /* best effort cleanup */ } - throw new Error(`Variant upload failed (w${variant.width}): ${err instanceof Error ? err.message : err}`); + } catch { + /* best effort cleanup */ + } + throw new Error( + `Variant upload failed (w${variant.width}): ${err instanceof Error ? err.message : err}` + ); } } return { asset_id: result.asset_id, width: entry.original.width, height: entry.original.height }; } -/** - * @callback UploadProgressCallback - * @param {{ phase: 'uploading', index: number, total: number }} progress - */ +export type UploadProgressCallback = (progress: { + phase: 'uploading'; + index: number; + total: number; +}) => void; /** * Upload pending assets that are referenced in the document. * Only uploads entries whose blob URL appears in the provided list. * Throws on the first failure (after cleaning up the failed asset). - * - * @param {string[]} blob_urls - blob URLs currently in the document's image nodes - * @param {UploadProgressCallback} [on_progress] - optional progress callback - * @returns {Promise>} */ -export async function upload_pending(blob_urls, on_progress) { - /** @type {Map} */ - const mapping = new Map(); +export async function upload_pending( + blob_urls: string[], + on_progress?: UploadProgressCallback +): Promise> { + const mapping = new Map(); const total = blob_urls.length; for (let i = 0; i < blob_urls.length; i++) { @@ -387,13 +363,18 @@ export async function upload_pending(blob_urls, on_progress) { * Replace blob URLs in document nodes with asset ids using the upload mapping. * Also updates width, height and mime_type to the processed values (e.g. a * dropped .mov is stored as video/mp4 after transcoding). - * - * @param {Record} nodes - The document's nodes map (mutated in place) - * @param {Map} mapping + * The nodes map is mutated in place. */ -export function replace_blob_urls(nodes, mapping) { +export function replace_blob_urls( + nodes: Record, + mapping: Map +) { for (const node of Object.values(nodes)) { - if ((node.type === 'image' || node.type === 'video') && typeof node.src === 'string' && node.src.startsWith('blob:')) { + if ( + (node.type === 'image' || node.type === 'video') && + typeof node.src === 'string' && + node.src.startsWith('blob:') + ) { const entry = mapping.get(node.src); if (entry) { const ext = entry.asset_id.slice(entry.asset_id.lastIndexOf('.') + 1); @@ -413,11 +394,8 @@ export function replace_blob_urls(nodes, mapping) { * that's missing from the map (e.g. after undo brought back blob URLs * that were cleaned up after a previous save), re-fetch the blob and * restart processing. - * - * @param {string[]} blob_urls - blob URLs currently in the document's image nodes - * @returns {Promise} */ -export async function ensure_processing(blob_urls) { +export async function ensure_processing(blob_urls: string[]): Promise { for (const blob_url of blob_urls) { if (pending_assets.has(blob_url)) continue; @@ -446,15 +424,16 @@ export async function ensure_processing(blob_urls) { } /** - * Collect all blob URLs from image nodes in a document. - * - * @param {Record} nodes - * @returns {string[]} + * Collect all blob URLs from media nodes in a document. */ -export function collect_blob_urls(nodes) { - const blob_urls = []; +export function collect_blob_urls(nodes: Record): string[] { + const blob_urls: string[] = []; for (const node of Object.values(nodes)) { - if ((node.type === 'image' || node.type === 'video') && typeof node.src === 'string' && node.src.startsWith('blob:')) { + if ( + (node.type === 'image' || node.type === 'video') && + typeof node.src === 'string' && + node.src.startsWith('blob:') + ) { blob_urls.push(node.src); } } @@ -464,11 +443,9 @@ export function collect_blob_urls(nodes) { /** * Clean up completed entries from the pending map. * Call after a successful save. - * - * @param {Map} mapping */ -export function cleanup_pending(mapping) { +export function cleanup_pending(mapping: Map) { for (const blob_url of mapping.keys()) { pending_assets.delete(blob_url); } -} \ No newline at end of file +} diff --git a/src/lib/client/media_dimensions.js b/src/lib/client/media_dimensions.ts similarity index 81% rename from src/lib/client/media_dimensions.js rename to src/lib/client/media_dimensions.ts index 6ec3e5f0..03b0db5f 100644 --- a/src/lib/client/media_dimensions.js +++ b/src/lib/client/media_dimensions.ts @@ -1,10 +1,7 @@ /** * Extract image dimensions using an element. - * - * @param {Blob} blob - * @returns {Promise<{ width: number, height: number }>} */ -function get_image_dimensions(blob) { +function get_image_dimensions(blob: Blob): Promise<{ width: number; height: number }> { return new Promise((resolve, reject) => { const img = new Image(); const object_url = URL.createObjectURL(blob); @@ -26,11 +23,8 @@ function get_image_dimensions(blob) { /** * Extract dimensions from an SVG by parsing the viewBox attribute. * Falls back to get_image_dimensions if no viewBox is found. - * - * @param {Blob} blob - * @returns {Promise<{ width: number, height: number }>} */ -async function get_svg_dimensions(blob) { +async function get_svg_dimensions(blob: Blob): Promise<{ width: number; height: number }> { try { const text = await blob.text(); const match = text.match(/viewBox=["']([^"']+)["']/); @@ -52,16 +46,13 @@ async function get_svg_dimensions(blob) { /** * Extract video dimensions using a temporary