diff --git a/apps/dev/cms.tsx b/apps/dev/cms.tsx
index 73cf6ea01..58a5a02d9 100644
--- a/apps/dev/cms.tsx
+++ b/apps/dev/cms.tsx
@@ -1,5 +1,5 @@
-import alinea from 'alinea'
-import {Entry, createCMS} from 'alinea/core'
+import alinea, {Query} from 'alinea'
+import {createCMS} from 'alinea/core'
import {IcRoundTranslate} from 'alinea/ui/icons/IcRoundTranslate'
import {IcRoundUploadFile} from 'alinea/ui/icons/IcRoundUploadFile'
import {position} from './src/PositionField'
@@ -96,7 +96,7 @@ const Fields = alinea.document('Fields', {
entry: alinea.entry('Internal link'),
entryWithCondition: alinea.entry('With condition', {
help: `Show only entries of type Fields`,
- condition: Entry.type.is('Fields')
+ condition: Query.whereType('Fields')
}),
linkMultiple: alinea.link.multiple('Mixed links, multiple'),
image: alinea.image('Image link'),
diff --git a/apps/web/content/pages/docs/content/editing-content.json b/apps/web/content/pages/docs/content/editing-content.json
new file mode 100644
index 000000000..d5070afd0
--- /dev/null
+++ b/apps/web/content/pages/docs/content/editing-content.json
@@ -0,0 +1,198 @@
+{
+ "title": "Editing content",
+ "navigationTitle": "",
+ "body": [
+ {
+ "type": "paragraph",
+ "textAlign": "left",
+ "content": [
+ {
+ "type": "text",
+ "text": "Your CMS instance has methods to create and update content. During development these will update the file system directly, while in production the changes will result in a new git commit."
+ }
+ ]
+ },
+ {
+ "type": "heading",
+ "textAlign": "left",
+ "level": 2,
+ "content": [
+ {
+ "type": "text",
+ "text": "Creating Entries"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "textAlign": "left",
+ "content": [
+ {
+ "type": "text",
+ "text": "New Entries can be created using the `create` function."
+ }
+ ]
+ },
+ {
+ "id": "2bww0XuSHzm0f72kxNM5cQlVLvI",
+ "type": "CodeBlock",
+ "code": "import {Edit} from 'alinea'\n\n// Start a transaction to create a new entry of type BlogPost\nconst post = Edit.create(BlogPost).set({\n title: 'A new blog post',\n body: 'Hello world'\n})\n\n// The new entry ID can be read before comitting\nconsole.log(`Creating post with id: ${post.id}`)\n\n// Save the changes\nawait cms.commit(post)",
+ "fileName": "",
+ "language": ""
+ },
+ {
+ "type": "heading",
+ "textAlign": "left",
+ "level": 3,
+ "content": [
+ {
+ "type": "text",
+ "text": "Creating child Entries"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "textAlign": "left",
+ "content": [
+ {
+ "type": "text",
+ "text": "To nest new Entries correctly it's possible to set the parent id, or construct directly from a parent update."
+ }
+ ]
+ },
+ {
+ "id": "2c4tj6ItJDix4tpGojE9LGn0fTo",
+ "type": "CodeBlock",
+ "code": "import {Edit} from 'alinea'\n\nconst blog = Edit.create(Blog).set({title: 'Blog'})\nconst posts = postData.map(data =>\n blog.createChild(BlogPost).set({title: data.title})\n)\n// Or, if you're adding to an existing parent\n// blog.create(BlogPost).setParent(blogId).set({title: data.title})\nawait cms.commit(blog, ...posts)",
+ "fileName": "",
+ "language": ""
+ },
+ {
+ "type": "heading",
+ "textAlign": "left",
+ "level": 2,
+ "content": [
+ {
+ "type": "text",
+ "text": "Update Fields"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "textAlign": "left",
+ "content": [
+ {
+ "type": "text",
+ "text": "Entry fields can be edited using the `edit` function. Optionally pass the entry Type as a second argument so the field values are typed correctly."
+ }
+ ]
+ },
+ {
+ "id": "2bx3AJ8D93WgmthX8tiEqZura9X",
+ "type": "CodeBlock",
+ "code": "import {Edit, Query} from 'alinea'\n\n// Select the first blog post\nconst blogPostId = await cms.get(Query(BlogPost).select(Query.id))\n\n// Edit a field and save\nconst update = Edit(blogPostId, BlogPost).set({\n body: 'New body text'\n})\n\nawait cms.commit(update)",
+ "fileName": "",
+ "language": ""
+ },
+ {
+ "type": "heading",
+ "textAlign": "left",
+ "level": 3,
+ "content": [
+ {
+ "type": "text",
+ "text": "Constructing field values"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "textAlign": "left",
+ "content": [
+ {
+ "type": "text",
+ "text": "Some fields contain values that are more complex than a string. The Edit namespace contains helper functions to construct these. In this example we construct the value of a List Field."
+ }
+ ]
+ },
+ {
+ "id": "2c4t1IBcmwpjaS3YqNaJQxRpWss",
+ "type": "CodeBlock",
+ "code": "const richTextField = richText('Item body text');\nconst listField = list('My list field', {\n schema: {\n Text: type('Text', {\n title: text('Item title'),\n text: richText,\n })\n }\n})\nconst rowText = Edit.richText(richTextField)\n .addHtml(`\n
Main heading
\n A rich text value parsed from HTML.
\n `)\n .value()\nconst listValue = Edit.list(listField)\n .add('Text', {\n title: 'The row title',\n text: rowText,\n })\n .value()\nconst update = Edit(entryId, TypeWithList).set({\n list: listValue\n})",
+ "fileName": "",
+ "language": ""
+ },
+ {
+ "type": "heading",
+ "textAlign": "left",
+ "level": 2,
+ "content": [
+ {
+ "type": "text",
+ "text": "File uploads"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "textAlign": "left",
+ "content": [
+ {
+ "type": "text",
+ "text": "Files can be uploaded using the upload function."
+ }
+ ]
+ },
+ {
+ "id": "2bx3SNjDfXQsRSkJYEUX5GY3qg9",
+ "type": "CodeBlock",
+ "code": "import {Edit} from 'alinea'\n\nconst file = new File(['content'], 'test.txt')\nconst upload = Edit.upload(file)\n\n// The new entry ID can be read before comitting\nconsole.log(`Creating post with id: ${upload.id}`)\n\n// Upload file and save file metadata\nawait cms.commit(upload)",
+ "fileName": "",
+ "language": ""
+ },
+ {
+ "type": "heading",
+ "textAlign": "left",
+ "level": 3,
+ "content": [
+ {
+ "type": "text",
+ "text": "Creating image previews"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "textAlign": "left",
+ "content": [
+ {
+ "type": "text",
+ "text": "Alinea can create all the metadata for images (such as previews) by passing a `createPreview` function. On the server this will use the `sharp` package to read image data. The package will need to be installed separately."
+ }
+ ]
+ },
+ {
+ "id": "2bxKAtZiQcruhSmRPO9PldQZ3mR",
+ "type": "CodeBlock",
+ "code": "import {Edit} from 'alinea'\nimport {createPreview} from 'alinea/core/media/CreatePreview'\nimport fs from 'node:fs'\n\nconst file = new File([\n fs.readFileSync('./test.png')\n], 'test.png')\nconst upload = Edit.upload(file, {createPreview})\nawait cms.commit(upload)",
+ "fileName": "",
+ "language": ""
+ }
+ ],
+ "metadata": {
+ "title": "",
+ "description": "",
+ "openGraph": {
+ "title": "",
+ "image": {},
+ "description": ""
+ }
+ },
+ "@alinea": {
+ "entryId": "2bwoBrTOuERj2E8r7yuNglC0H6G",
+ "type": "Doc",
+ "index": "a1G"
+ }
+}
\ No newline at end of file
diff --git a/apps/web/content/pages/docs/content/query.json b/apps/web/content/pages/docs/content/query.json
index 24b034823..5849e51f1 100644
--- a/apps/web/content/pages/docs/content/query.json
+++ b/apps/web/content/pages/docs/content/query.json
@@ -8,7 +8,7 @@
"content": [
{
"type": "text",
- "text": "The CMS instance that is exported in your config file can be used to query the stored data."
+ "text": "Once content has been saved in the CMS you'll want a way to retrieve it. Your CMS instance has methods to fetch specific Entries or search through all content."
}
]
},
@@ -29,7 +29,7 @@
{
"id": "2YlCpSWtMLknsvHhIwtZlTf1MDI",
"type": "CodeBlock",
- "code": "import {cms} from '@/cms'\n\nexport default async function HomePage() {\n const homePage = await cms.get(HomePage())\n return {homePage.title}
\n}",
+ "code": "import {cms} from '@/cms'\nimport {Query} from 'alinea'\n\nexport default async function HomePage() {\n const homePage = await cms.get(Query(HomePage))\n return {homePage.title}
\n}",
"fileName": "",
"language": "",
"compact": false
@@ -53,7 +53,7 @@
"content": [
{
"type": "text",
- "text": "Retrieving a page"
+ "text": "Getting a specific Entry"
}
]
},
@@ -63,14 +63,14 @@
"content": [
{
"type": "text",
- "text": "A single page can be fetched using the `get` method. Criteria can be passed to filter entries."
+ "text": "A single Entry can be fetched using the `get` method."
}
]
},
{
"id": "28hy8mAMZJiFhtaajix2fKGBKBE",
"type": "CodeBlock",
- "code": "// Fetch the first page where field equals the string 'value'\nconst page = await cms.get(Page({field: 'value'}))",
+ "code": "import {Query} from 'alinea'\n\nconst query = Query(HomePage).whereId(homePageId)\nconst entry = await cms.get(query)",
"fileName": "",
"language": "",
"compact": false
@@ -82,7 +82,7 @@
"content": [
{
"type": "text",
- "text": "Retrieving multiple pages"
+ "text": "Querying multiple Entries"
}
]
},
@@ -92,14 +92,14 @@
"content": [
{
"type": "text",
- "text": "Multiple pages can be fetched using the `find` method."
+ "text": "Multiple Entries can be fetched using the `find` method."
}
]
},
{
"id": "28hyQUttn286uRfAArTKdMWNJVb",
"type": "CodeBlock",
- "code": "// Fetch all pages where field equals the string 'value'\nconst pages = await cms.find(Page({field: 'value'}))",
+ "code": "import {Query} from 'alinea'\n\nconst query = Query(BlogPost).whereParent(blogId)\nconst blogPosts = await cms.find(query)",
"fileName": "",
"language": "",
"compact": false
@@ -128,7 +128,7 @@
{
"id": "28hyZtRcyC7fXKRHUiqhjA5O7iA",
"type": "CodeBlock",
- "code": "// Skip the first 10 pages and return a maximum of 10\nconst limited = await cms.find(Page().skip(10).take(10))",
+ "code": "// Skip the first 10 entries and return a maximum of 10\nconst query = Query(BlogPost).skip(10).take(10)",
"fileName": "",
"language": "",
"compact": false
@@ -140,7 +140,7 @@
"content": [
{
"type": "text",
- "text": "Full text search"
+ "text": "Order results"
}
]
},
@@ -150,16 +150,17 @@
"content": [
{
"type": "text",
- "text": "Pages can be queried with search terms. Any (rich) text field with the `searchable` option set to `true` is indexed."
+ "text": "A result set can be ordered by passing one or multiple fields. "
}
]
},
{
- "id": "2b05iLFLHZ4Vxcyao5sTiFKsFOi",
+ "id": "28hzjFJi5kuByP0j3ZX79ATIyyS",
"type": "CodeBlock",
- "code": "const results = await cms.find(\n Page().search('query', 'content')\n)",
+ "code": "const ordered = Query(NewsItem).orderBy(NewsItem.publishDate.desc())",
"fileName": "",
- "language": ""
+ "language": "",
+ "compact": false
},
{
"type": "heading",
@@ -168,7 +169,7 @@
"content": [
{
"type": "text",
- "text": "Querying specific pages"
+ "text": "Group by"
}
]
},
@@ -178,43 +179,71 @@
"content": [
{
"type": "text",
- "text": "To filter pages on specific fields first narrow the search to a type, then use the `where` method to specify conditions."
+ "text": "Results can be grouped by one or more fields."
}
]
},
{
- "id": "IEmT75lZgxznL9v7Zq9mW",
+ "id": "28i0B7nRFUqmGWdCmchhzy21bkt",
"type": "CodeBlock",
- "code": "const old = await cms.find(\n Animal().where(Animal.age.isGreater(10))\n)\nconst teenager = await cms.find(Human().where(\n Human.age.isGreater(10).or(\n Human.age.isLess(20)\n )\n)\nconst applesOrOranges = await cms.find(\n Fruit().where(Fruit.title.isIn(['apple', 'orange']))\n)",
+ "code": "const grouped = Query(NewsItem).groupBy(NewsItem.category)",
"fileName": "",
"language": "",
"compact": false
},
+ {
+ "type": "heading",
+ "textAlign": "left",
+ "level": 2,
+ "content": [
+ {
+ "type": "text",
+ "text": "Creating queries"
+ }
+ ]
+ },
{
"type": "paragraph",
"textAlign": "left",
"content": [
{
"type": "text",
- "text": "Conditions can be created by accessing the fields of the table instances and using the operators of Expr:"
+ "text": "Results can be filtered using the `where` function, or tailored functions such as `whereId` or `whereLocale`. To find Entries of any Type, use the Query functions directly."
}
]
},
{
- "id": "2aiMtwZlHXcQkToqIEv04u3V6VY",
+ "id": "2c2tpFYfBG25n2Mfk8eIBtgSTH2",
"type": "CodeBlock",
- "code": "BlogPost.title // This is an Expr which has an `is` method to compare\nBlogPost.title.is(\"Test\") // Compare with a value, results in an Expr",
+ "code": "// Any entry that matches your conditions\nconst searchAllEntries = Query.where(...)",
+ "fileName": "",
+ "language": ""
+ },
+ {
+ "type": "paragraph",
+ "textAlign": "left",
+ "content": [
+ {
+ "type": "text",
+ "text": "If you're looking for Entries of a specific Type, pass it to the Query function to create a query of that Type. This will correctly infer the result TypeScript type."
+ }
+ ]
+ },
+ {
+ "id": "2c2trI1Z51608LisXXDO46kek10",
+ "type": "CodeBlock",
+ "code": "// Only entries of type BlogPost will be found\nconst narrowByType = Query(BlogPost).where(...)",
"fileName": "",
"language": ""
},
{
"type": "heading",
- "level": 2,
"textAlign": "left",
+ "level": 3,
"content": [
{
"type": "text",
- "text": "Ordering results"
+ "text": "Filtering by Field values"
}
]
},
@@ -224,26 +253,26 @@
"content": [
{
"type": "text",
- "text": "A result set can be ordered by passing one or multiple fields."
+ "text": "To search Entries by specific Fields use the `where` function. `Fields` can be compared to values using its conditional methods."
}
]
},
{
- "id": "28hzjFJi5kuByP0j3ZX79ATIyyS",
+ "id": "IEmT75lZgxznL9v7Zq9mW",
"type": "CodeBlock",
- "code": "const ordered = await cms.find(NewsItem().orderBy(NewsItem.publishDate.desc())",
+ "code": "// If filtered by Type first it's possible to match fields\n// on equality directly by passing an object. This does not\n// work for any other comparison operator.\nconst withPath = Query(BlogPost).where({path: 'why-you-should-get-a-cat'})\n\n// Comparisons can be made by using the conditional methods\n// of the field you're comparing to.\nconst recent = Query(BlogPost).where(\n BlogPost.publishedDate.isGreaterOrEqual(`2024-01-01`)\n)\n\n// Multiple conditions result in matching on both (AND).\nconst previousYear = Query(BlogPost).where(\n BlogPost.publishedDate.isGreaterOrEqual(`2023-01-01`),\n BlogPost.publishedDate.isLess(`2024-01-01`)\n)\n\n// To match any condition use Query.or (OR).\nconst isPetPost = Query(BlogPost).where(\n Query.or(\n BlogPost.tags.includes('cats'),\n BlogPost.tags.includes('dogs')\n )\n)",
"fileName": "",
"language": "",
"compact": false
},
{
"type": "heading",
- "level": 2,
"textAlign": "left",
+ "level": 3,
"content": [
{
"type": "text",
- "text": "Group by"
+ "text": "Filtering by Entry values"
}
]
},
@@ -253,26 +282,53 @@
"content": [
{
"type": "text",
- "text": "Results can be grouped by one or more fields."
+ "text": "Entries contain values managed by the CMS such as an id, parent, the assigned workspace, root or locale. Query has shortcuts to query these directly."
}
]
},
{
- "id": "28i0B7nRFUqmGWdCmchhzy21bkt",
+ "id": "2c2w35gHrX76eswIr8ZNleu39zn",
"type": "CodeBlock",
- "code": "const grouped = await cms.find(\n NewsItem().groupBy(NewsItem.category)\n)",
+ "code": "const german = Query.whereLocale('de')\nconst blogPosts = Query(BlogPost).whereParent(blog.id)\n\n// Multiple conditions can be chained\nconst secretPages = Query(Secret).whereWorkspace('secret').whereRoot('pages')",
"fileName": "",
- "language": "",
- "compact": false
+ "language": ""
},
{
"type": "heading",
- "level": 2,
"textAlign": "left",
+ "level": 3,
+ "content": [
+ {
+ "type": "text",
+ "text": "Full text search"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "textAlign": "left",
+ "content": [
+ {
+ "type": "text",
+ "text": "Entries can be queried with search terms. Any (Rich) Text Field with the `searchable` option set to `true` is indexed."
+ }
+ ]
+ },
+ {
+ "id": "2b05iLFLHZ4Vxcyao5sTiFKsFOi",
+ "type": "CodeBlock",
+ "code": "// Search can be used in combination with conditions\nconst containsDogs = Query(BlogPost).where(...).search('dog')\n\n// Multiple search terms can be used\nconst containsBothDogsAndCats = Query(BlogPost).search('cat', 'dog')",
+ "fileName": "",
+ "language": ""
+ },
+ {
+ "type": "heading",
+ "textAlign": "left",
+ "level": 2,
"content": [
{
"type": "text",
- "text": "Selecting specific fields"
+ "text": "Selecting Fields"
}
]
},
@@ -289,13 +345,40 @@
{
"id": "28hywuwvVMmRT7zhyZEumjM19tI",
"type": "CodeBlock",
- "code": "// Return only titles\nconst rows = await cms.find(\n Page().select({title: Page.title})\n)",
+ "code": "// Returns a select set of fields \nconst rows = Query(BlogPost).select({\n // Entry fields are available on Query\n id: Query.id,\n url: Query.url,\n title: BlogPost.title,\n description: BlogPost.shortDescription\n})\n\n// You can include all available Entry fields at once\nconst rows = Query(BlogPost).select({\n ...Query.entry,\n title: BlogPost.title,\n description: BlogPost.shortDescription\n})",
"fileName": "",
"language": "",
"compact": false
+ },
+ {
+ "type": "heading",
+ "textAlign": "left",
+ "level": 3,
+ "content": [
+ {
+ "type": "text",
+ "text": "Selecting data from related Entries"
+ }
+ ]
+ },
+ {
+ "type": "paragraph",
+ "textAlign": "left",
+ "content": [
+ {
+ "type": "text",
+ "text": "Entries in Alinea are part of a content tree. This means they'll often have a parent Entry or contain children Entries. To query content from the parent(s) or children you can request it within the selection."
+ }
+ ]
+ },
+ {
+ "id": "2c2xXRab38WZmsa3GLCLx5Z6LGA",
+ "type": "CodeBlock",
+ "code": "// Select a few fields from the parent Entries to render\n// a breadcrumb navigation.\nconst breadcrumbs = Query.parents().select({\n url: Query.url,\n title: Query.title\n})\n\n// Use it directly in another select\nconst blogPosts = Query(BlogPost).select({\n // Select the fields you want from this blog post\n title: BlogPost.title,\n body: BlogPost.body,\n // ... and include the data of the parents\n breadcrumbs\n})\n\n// You can use the spread operator to make the above more readable\nconst blogPosts = Query(BlogPost).select({\n // Select all fields of the BlogPost type\n ...BlogPost,\n breadcrumbs\n})\n\n// Similarly you can fetch parent and children in one query\nconst blog = Query(Blog).select({\n title: Blog.title,\n posts: Query.children(BlogPost)\n})",
+ "fileName": "",
+ "language": ""
}
],
- "extraField": [],
"metadata": {
"title": "",
"description": "",
diff --git a/apps/web/src/app/api/search/route.ts b/apps/web/src/app/api/search/route.ts
index a6516c03e..3b7e7f99e 100644
--- a/apps/web/src/app/api/search/route.ts
+++ b/apps/web/src/app/api/search/route.ts
@@ -1,6 +1,5 @@
import {cms} from '@/cms'
-import alinea from 'alinea'
-import {Entry} from 'alinea/core'
+import alinea, {Query} from 'alinea'
export async function GET(request: Request) {
const searchTerm = new URL(request.url).searchParams.get('query')
@@ -9,18 +8,15 @@ export async function GET(request: Request) {
.in(cms.workspaces.main.pages)
.disableSync()
.find(
- Entry()
- .select({
- title: Entry.title,
- url: Entry.url,
- snippet: alinea.snippet('[[mark]]', '[[/mark]]', '…', 25),
- parents({parents}) {
- return parents().select({
- id: Entry.entryId,
- title: Entry.title
- })
- }
+ Query.select({
+ title: Query.title,
+ url: Query.url,
+ snippet: alinea.snippet('[[mark]]', '[[/mark]]', '…', 25),
+ parents: Query.parents().select({
+ id: Query.id,
+ title: Query.title
})
+ })
.search(...searchTerm.split(' '))
.take(25)
)
diff --git a/apps/web/src/layout/Header.tsx b/apps/web/src/layout/Header.tsx
index 5642bfcc8..da1ccd142 100644
--- a/apps/web/src/layout/Header.tsx
+++ b/apps/web/src/layout/Header.tsx
@@ -1,6 +1,5 @@
import {cms} from '@/cms'
-import {EntryReference, UrlReference} from 'alinea'
-import {Entry} from 'alinea/core'
+import {EntryReference, Query, UrlReference} from 'alinea'
import {HStack, Stack, Styler, fromModule} from 'alinea/ui'
import {IcRoundClose} from 'alinea/ui/icons/IcRoundClose'
import {IcRoundHamburger} from 'alinea/ui/icons/IcRoundHamburger'
@@ -56,15 +55,9 @@ export async function Header() {
}
async function MobileNav() {
- const docs = await cms.in(cms.workspaces.main.pages.docs).find(
- Entry().select({
- id: Entry.entryId,
- type: Entry.type,
- url: Entry.url,
- title: Entry.title,
- parent: Entry.parent
- })
- )
+ const docs = await cms
+ .in(cms.workspaces.main.pages.docs)
+ .find(Query.select(Query.entry))
const tree = [
{id: 'home', url: '/', title: 'Home'},
{id: 'roadmap', url: '/roadmap', title: 'Roadmap'},
diff --git a/apps/web/src/page/BlogPage.tsx b/apps/web/src/page/BlogPage.tsx
index ef6a9389a..097e9eb4f 100644
--- a/apps/web/src/page/BlogPage.tsx
+++ b/apps/web/src/page/BlogPage.tsx
@@ -4,7 +4,7 @@ import {WebTypo} from '@/layout/WebTypo'
import {Link} from '@/layout/nav/Link'
import {BlogOverview} from '@/schema/BlogOverview'
import {BlogPost} from '@/schema/BlogPost'
-import {Entry} from 'alinea/core'
+import {Query} from 'alinea'
import {VStack, fromModule} from 'alinea/ui'
import css from './BlogPage.module.scss'
import {BlogPostMeta} from './blog/BlogPostMeta'
@@ -13,18 +13,14 @@ const styles = fromModule(css)
export default async function BlogPage() {
const overview = await cms.get(
- BlogOverview().select({
- title: BlogOverview.title,
- posts({children}) {
- return children().select({
- id: Entry.entryId,
- url: Entry.url,
- title: BlogPost.title,
- introduction: BlogPost.introduction,
- author: BlogPost.author,
- publishDate: BlogPost.publishDate
- })
- }
+ Query(BlogOverview).select({
+ title: Query.title,
+ posts: Query.children(BlogPost).select({
+ ...Query.entry,
+ introduction: BlogPost.introduction,
+ author: BlogPost.author,
+ publishDate: BlogPost.publishDate
+ })
})
)
return (
diff --git a/apps/web/src/page/BlogPostPage.tsx b/apps/web/src/page/BlogPostPage.tsx
index 7275edc33..77d116773 100644
--- a/apps/web/src/page/BlogPostPage.tsx
+++ b/apps/web/src/page/BlogPostPage.tsx
@@ -1,13 +1,10 @@
-/* eslint-disable @next/next/no-img-element */
-
import {cms} from '@/cms'
import {PageContainer, PageContent} from '@/layout/Page'
import {WebTypo} from '@/layout/WebTypo'
import {TextView} from '@/page/blocks/TextBlockView'
import {BlogPost} from '@/schema/BlogPost'
-import {Entry} from 'alinea/core'
+import {Query} from 'alinea'
import {fromModule} from 'alinea/ui'
-import {notFound} from 'next/navigation'
import {Breadcrumbs} from '../layout/Breadcrumbs'
import css from './BlogPostPage.module.scss'
import {BlogPostMeta} from './blog/BlogPostMeta'
@@ -20,23 +17,17 @@ export interface BlogPostPageProps {
export const dynamicParams = false
export async function generateStaticParams() {
- const slugs = await cms.find(BlogPost().select(Entry.path))
+ const slugs = await cms.find(Query(BlogPost).select(Query.path))
return slugs.map(slug => ({slug}))
}
export async function generateMetadata({params}: BlogPostPageProps) {
- const page = await cms.maybeGet(
- BlogPost().where(Entry.url.is(`/blog/${params.slug}`))
- )
- if (!page) return notFound()
+ const page = await cms.get(Query(BlogPost).whereUrl(`/blog/${params.slug}`))
return {title: page.metadata?.title || page.title}
}
export default async function BlogPostPage({params}: BlogPostPageProps) {
- const page = await cms.maybeGet(
- BlogPost().where(Entry.url.is(`/blog/${params.slug}`))
- )
- if (!page) return notFound()
+ const page = await cms.get(Query(BlogPost).whereUrl(`/blog/${params.slug}`))
return (
diff --git a/apps/web/src/page/DocPage.tsx b/apps/web/src/page/DocPage.tsx
index 3532b4afc..95c18a4de 100644
--- a/apps/web/src/page/DocPage.tsx
+++ b/apps/web/src/page/DocPage.tsx
@@ -8,7 +8,7 @@ import {NavTree} from '@/layout/nav/NavTree'
import {NavItem, nestNav} from '@/layout/nav/NestNav'
import {BodyView} from '@/page/blocks/BodyFieldView'
import {Doc} from '@/schema/Doc'
-import {Entry} from 'alinea/core'
+import {Query} from 'alinea'
import {HStack, VStack, fromModule} from 'alinea/ui'
import {Metadata} from 'next'
import {notFound} from 'next/navigation'
@@ -27,9 +27,9 @@ interface DocPageProps {
}
const summary = {
- id: Entry.entryId,
- title: Entry.title,
- url: Entry.url
+ id: Query.id,
+ title: Query.title,
+ url: Query.url
}
async function getPage(params: DocPageParams) {
@@ -43,17 +43,13 @@ async function getPage(params: DocPageParams) {
const url = pathname ? `/docs/${pathname}` : '/docs'
return {
framework,
- doc: await cms.maybeGet(
- Entry()
- .where(Entry.url.is(url))
- .select({
- ...Doc,
- id: Entry.entryId,
- level: Entry.level,
- parents({parents}) {
- return parents().select(summary)
- }
- })
+ doc: await cms.get(
+ Query.whereUrl(url).select({
+ ...Doc,
+ id: Query.id,
+ level: Query.level,
+ parents: Query.parents().select(summary)
+ })
)
}
}
@@ -62,7 +58,7 @@ export const dynamicParams = false
export async function generateStaticParams() {
const urls = await cms
.in(cms.workspaces.main.pages.docs)
- .find(Entry().select(Entry.url))
+ .find(Query.select(Query.url))
return urls
.flatMap(url => {
return supportedFrameworks
@@ -99,17 +95,17 @@ export default async function DocPage({params}: DocPageProps) {
const {doc, framework} = await getPage(params)
if (!doc) return notFound()
const select = {
- id: Entry.entryId,
- type: Entry.type,
- url: Entry.url,
- title: Entry.title,
+ id: Query.id,
+ type: Query.type,
+ url: Query.url,
+ title: Query.title,
navigationTitle: Doc.navigationTitle,
- parent: Entry.parent
+ parent: Query.parent
}
- const root = await cms.get(Entry({url: '/docs'}).select(select))
+ const root = await cms.get(Query.whereUrl('/docs').select(select))
const nav = await cms
.in(cms.workspaces.main.pages.docs)
- .find(Entry().select(select))
+ .find(Query.select(select))
const entries = [
root,
...nav.map(item => ({
diff --git a/apps/web/src/page/GenericPage.tsx b/apps/web/src/page/GenericPage.tsx
index 95db4403e..5a70d705a 100644
--- a/apps/web/src/page/GenericPage.tsx
+++ b/apps/web/src/page/GenericPage.tsx
@@ -1,7 +1,7 @@
import {cms} from '@/cms'
import {PageContainer, PageContent} from '@/layout/Page'
import {Page} from '@/schema/Page'
-import {Entry} from 'alinea/core'
+import {Query} from 'alinea'
import {notFound} from 'next/navigation'
import {TextView} from './blocks/TextBlockView'
@@ -13,18 +13,22 @@ export interface GenericPageProps {
export const dynamicParams = false
export async function generateStaticParams() {
- const slugs = await cms.find(Page().select(Entry.path))
+ const slugs = await cms.find(Query(Page).select(Query.path))
return slugs.map(slug => ({slug}))
}
export async function generateMetadata({params}: GenericPageProps) {
- const page = await cms.maybeGet(Page().where(Entry.url.is(`/${params.slug}`)))
+ const page = await cms.maybeGet(
+ Query(Page).where(Query.url.is(`/${params.slug}`))
+ )
if (!page) return notFound()
return {title: page.metadata?.title || page.title}
}
export default async function GenericPage({params}: GenericPageProps) {
- const page = await cms.maybeGet(Page().where(Entry.url.is(`/${params.slug}`)))
+ const page = await cms.maybeGet(
+ Query(Page).where(Query.url.is(`/${params.slug}`))
+ )
if (!page) return notFound()
return (
diff --git a/apps/web/src/page/blocks/BodyFieldView.tsx b/apps/web/src/page/blocks/BodyFieldView.tsx
index 500bdd941..fcc0f096f 100644
--- a/apps/web/src/page/blocks/BodyFieldView.tsx
+++ b/apps/web/src/page/blocks/BodyFieldView.tsx
@@ -1,6 +1,6 @@
import {WebText} from '@/layout/WebText'
import {BodyBlock} from '@/schema/blocks/BodyField'
-import {Infer} from 'alinea'
+import alinea from 'alinea'
import {fromModule} from 'alinea/ui'
import {ComponentType, Fragment} from 'react'
import {ChapterLinkView} from './ChapterLinkView'
@@ -14,7 +14,7 @@ import css from './TextBlockView.module.scss'
const styles = fromModule(css)
-export interface BodyViewProps extends Infer {
+export interface BodyViewProps extends alinea.infer {
container?: ComponentType
}
diff --git a/apps/web/src/page/blocks/ChapterLinkView.tsx b/apps/web/src/page/blocks/ChapterLinkView.tsx
index b979a3057..b28eddf7b 100644
--- a/apps/web/src/page/blocks/ChapterLinkView.tsx
+++ b/apps/web/src/page/blocks/ChapterLinkView.tsx
@@ -1,13 +1,13 @@
import {Link} from '@/layout/nav/Link'
import {ChapterLinkBlock} from '@/schema/blocks/ChapterLinkBlock'
-import {Infer} from 'alinea'
+import alinea from 'alinea'
import {fromModule, HStack, Stack} from 'alinea/ui'
import {IcRoundArrowForward} from 'alinea/ui/icons/IcRoundArrowForward'
import css from './ChapterLinkView.module.scss'
const styles = fromModule(css)
-export function ChapterLinkView({link}: Infer) {
+export function ChapterLinkView({link}: alinea.infer) {
if (!link || !link.url) return null
return (
) {
+}: alinea.infer) {
const {codeToHtml} = await codeHighlighter
if (!code) return null
const html = codeToHtml(code, {
diff --git a/apps/web/src/page/blocks/CodeVariantsView.tsx b/apps/web/src/page/blocks/CodeVariantsView.tsx
index e9a9cb037..93aa6f9bc 100644
--- a/apps/web/src/page/blocks/CodeVariantsView.tsx
+++ b/apps/web/src/page/blocks/CodeVariantsView.tsx
@@ -1,5 +1,5 @@
import {CodeVariantsBlock} from '@/schema/blocks/CodeVariantsBlock'
-import {Infer} from 'alinea'
+import alinea from 'alinea'
import {fromModule} from 'alinea/ui'
import {CodeVariantTabs} from './CodeVariantsView.client'
import css from './CodeVariantsView.module.scss'
@@ -8,7 +8,7 @@ import {codeHighlighter} from './code/CodeHighlighter'
const styles = fromModule(css)
export interface CodeVariantsViewProps
- extends Infer {}
+ extends alinea.infer {}
export async function CodeVariantsView({variants}: CodeVariantsViewProps) {
const {codeToHtml} = await codeHighlighter
diff --git a/apps/web/src/page/blocks/ExampleBlockView.tsx b/apps/web/src/page/blocks/ExampleBlockView.tsx
index 21ac54182..eb56d375d 100644
--- a/apps/web/src/page/blocks/ExampleBlockView.tsx
+++ b/apps/web/src/page/blocks/ExampleBlockView.tsx
@@ -1,12 +1,14 @@
import {ExampleBlock} from '@/schema/blocks/ExampleBlock'
-import {Infer} from 'alinea'
+import alinea from 'alinea'
import {fromModule} from 'alinea/ui'
import lzstring from 'lz-string'
import css from './ExampleBlockView.module.scss'
const styles = fromModule(css)
-export async function ExampleBlockView({code}: Infer) {
+export async function ExampleBlockView({
+ code
+}: alinea.infer) {
const hash = lzstring.compressToEncodedURIComponent(code)
return (
diff --git a/apps/web/src/page/blocks/FrameworkBlockView.tsx b/apps/web/src/page/blocks/FrameworkBlockView.tsx
index eca264be3..c9bfbbe6e 100644
--- a/apps/web/src/page/blocks/FrameworkBlockView.tsx
+++ b/apps/web/src/page/blocks/FrameworkBlockView.tsx
@@ -1,6 +1,6 @@
import {supportedFrameworks} from '@/layout/nav/Frameworks'
import {FrameworkBlock} from '@/schema/blocks/FrameworkBlock'
-import {Infer} from 'alinea'
+import alinea from 'alinea'
import {fromModule} from 'alinea/ui'
import {RenderSelectedFramework} from './FrameworkBlockView.client'
import css from './FrameworkBlockView.module.scss'
@@ -8,7 +8,9 @@ import {TextView} from './TextBlockView'
const styles = fromModule(css)
-export function FrameworkBlockView(blocks: Infer
) {
+export function FrameworkBlockView(
+ blocks: alinea.infer
+) {
return (
) {
+export function ImageBlockView({image}: alinea.infer) {
const blurUrl = imageBlurUrl(image)
if (!image.src) return null
return (
diff --git a/apps/web/src/page/blocks/NoticeView.tsx b/apps/web/src/page/blocks/NoticeView.tsx
index 1b8879afa..b35ff7fb5 100644
--- a/apps/web/src/page/blocks/NoticeView.tsx
+++ b/apps/web/src/page/blocks/NoticeView.tsx
@@ -1,13 +1,13 @@
import {IcOutlineInfo} from '@/icons'
import {WebText} from '@/layout/WebText'
import {NoticeBlock} from '@/schema/blocks/NoticeBlock'
-import {Infer} from 'alinea'
+import alinea from 'alinea'
import {fromModule, HStack} from 'alinea/ui'
import css from './NoticeView.module.scss'
const styles = fromModule(css)
-export function NoticeView({level, body}: Infer) {
+export function NoticeView({level, body}: alinea.infer) {
return (
diff --git a/apps/web/src/page/blocks/TextBlockView.tsx b/apps/web/src/page/blocks/TextBlockView.tsx
index fba9e6280..7cc6fbd9b 100644
--- a/apps/web/src/page/blocks/TextBlockView.tsx
+++ b/apps/web/src/page/blocks/TextBlockView.tsx
@@ -1,6 +1,6 @@
import {WebText} from '@/layout/WebText'
import {TextBlock} from '@/schema/blocks/TextBlock'
-import {Infer} from 'alinea'
+import alinea from 'alinea'
import {fromModule} from 'alinea/ui'
import {ComponentType, Fragment} from 'react'
import {ChapterLinkView} from './ChapterLinkView'
@@ -13,7 +13,7 @@ import css from './TextBlockView.module.scss'
const styles = fromModule(css)
-export interface TextBlockViewProps extends Infer {
+export interface TextBlockViewProps extends alinea.infer {
container?: ComponentType
}
diff --git a/build.js b/build.js
index feaf29aad..1f01d8843 100644
--- a/build.js
+++ b/build.js
@@ -37,6 +37,7 @@ const external = builtinModules
'fs-extra',
'@alinea',
'next',
+ 'sharp',
'@remix-run',
'react',
'react-dom',
diff --git a/package.json b/package.json
index a2dc99101..eab3b448b 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,7 @@
"release:types": "tsc",
"release:build": "node build.js",
"type": "tsc --diagnostics",
- "test": "node build.js --test",
+ "test": "NODE_OPTIONS=--enable-source-maps node build.js --test",
"tag": "esbx tag",
"dev:check": "tsc -w",
"dev:run": "node build.js --watch -- node dev.js --dir apps/dev --config cms",
@@ -50,7 +50,7 @@
"src/input/richtext"
],
"dependencies": {
- "@alinea/iso": "^0.3.1",
+ "@alinea/iso": "^0.3.2",
"@alinea/sqlite-wasm": "^0.1.14",
"esbuild": "^0.19.12"
},
@@ -81,6 +81,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"sass": "^1.63.4",
+ "sharp": "0.32.0",
"speedscope": "^1.14.0",
"typescript": "^5.3.3",
"typescript-plugin-css-modules": "^5.0.2",
@@ -103,6 +104,11 @@
"browser": "./dist/cloud/view/CloudAuth.browser.js",
"default": "./dist/cloud/view/CloudAuth.js"
},
+ "./core/media/CreatePreview": {
+ "worker": "./dist/core/media/CreatePreview.js",
+ "browser": "./dist/core/media/CreatePreview.browser.js",
+ "default": "./dist/core/media/CreatePreview.js"
+ },
"./core/media/MediaRoot": {
"worker": "./dist/core/media/MediaRoot.js",
"browser": "./dist/core/media/MediaRoot.browser.js",
diff --git a/src/backend/Database.test.ts b/src/backend/Database.test.ts
index 0f5892aa0..2694db1d5 100644
--- a/src/backend/Database.test.ts
+++ b/src/backend/Database.test.ts
@@ -1,195 +1,138 @@
-import {
- CMS,
- Entry,
- EntryPhase,
- EntryRow,
- Schema,
- Type,
- createId,
- slugify
-} from 'alinea/core'
-import {entryChildrenDir, entryFilepath} from 'alinea/core/EntryFilenames'
-import {Mutation, MutationType} from 'alinea/core/Mutation'
-import {createEntryRow} from 'alinea/core/util/EntryRows'
+import {Edit, EntryPhase, Query} from 'alinea/core'
+import {createPreview} from 'alinea/core/media/CreatePreview'
+import {readFileSync} from 'fs'
import {test} from 'uvu'
import * as assert from 'uvu/assert'
import {createExample} from './test/Example.js'
-async function entry(
- cms: CMS,
- type: Type,
- data: Partial = {title: 'Entry'},
- parent?: EntryRow
-): Promise {
- const typeNames = Schema.typeNames(cms.config.schema)
- const title = data.title ?? 'Entry'
- const details = {
- entryId: createId(),
- phase: EntryPhase.Published,
- type: typeNames.get(type)!,
- title,
- path: data.path ?? slugify(title),
- seeded: null,
- workspace: 'main',
- root: 'pages',
- level: 0,
- parent: parent?.entryId ?? null,
- locale: null,
- index: 'a0',
- i18nId: createId(),
- modifiedAt: 0,
- active: true,
- main: true,
- data: data.data ?? {},
- searchableText: ''
- }
- const parentPaths = parent?.childrenDir.split('/').filter(Boolean) ?? []
- const filePath = entryFilepath(cms.config, details, parentPaths)
- const childrenDir = entryChildrenDir(cms.config, details, parentPaths)
- const row = {
- ...details,
- filePath,
- childrenDir,
- parentDir: childrenDir.split('/').slice(0, -1).join('/'),
- url: childrenDir
- }
- return createEntryRow(cms.config, row)
-}
-
-function create(entry: EntryRow): Mutation {
- return {
- type: MutationType.Create,
- entry: entry,
- entryId: entry.entryId,
- file: entry.filePath
- }
-}
-
-function remove(entry: EntryRow): Mutation {
- return {
- type: MutationType.Remove,
- entryId: entry.entryId,
- file: entry.filePath
- }
-}
-
-function edit(entry: EntryRow): Mutation {
- return {
- type: MutationType.Edit,
- entryId: entry.entryId,
- file: entry.filePath,
- entry: entry
- }
-}
-
-function archive(entry: EntryRow): Mutation {
- return {
- type: MutationType.Archive,
- entryId: entry.entryId,
- file: entry.filePath
- }
-}
-
-function publish(entry: EntryRow): Mutation {
- return {
- type: MutationType.Publish,
- entryId: entry.entryId,
- file: entry.filePath,
- phase: entry.phase
- }
-}
-
test('create', async () => {
const example = createExample()
- const db = await example.db
- const entry1 = await entry(example, example.schema.Page, {
- title: 'Test title'
- })
- await db.applyMutations([create(entry1)])
- const result = await example.get(Entry({entryId: entry1.entryId}))
- assert.is(result.entryId, entry1.entryId)
- assert.is(result.title, 'Test title')
+ const {Page} = example.schema
+ const parent = Edit.create(Page).set({title: 'New parent'})
+ await example.commit(parent)
+ const result = await example.get(Query.whereId(parent.entryId))
+ assert.is(result.entryId, parent.entryId)
+ assert.is(result.title, 'New parent')
})
test('remove child entries', async () => {
const example = createExample()
- const db = await example.db
- const parent = await entry(example, example.schema.Container)
- const sub = await entry(example, example.schema.Container, {}, parent)
- const subSub = await entry(example, example.schema.Page, {}, sub)
-
- await db.applyMutations([create(parent), create(sub), create(subSub)])
-
- const res1 = await example.get(Entry({entryId: subSub.entryId}))
+ const {Page, Container} = example.schema
+ const parent = Edit.create(Container)
+ const sub = parent.createChild(Container)
+ const entry = sub.createChild(Page)
+ await example.commit(parent, sub, entry)
+ const res1 = await example.get(Query.whereId(entry.entryId))
assert.ok(res1)
assert.is(res1.parent, sub.entryId)
-
- await db.applyMutations([remove(parent)])
-
- const res2 = await example.get(Entry({entryId: subSub.entryId}))
+ await example.commit(Edit.remove(parent.entryId))
+ const res2 = await example.get(Query.whereId(entry.entryId))
assert.not.ok(res2)
})
test('change draft path', async () => {
const example = createExample()
- const db = await example.db
- const parent = await entry(example, example.schema.Container, {
- path: 'parent'
- })
- const sub = await entry(
- example,
- example.schema.Container,
- {path: 'sub'},
- parent
- )
- await db.applyMutations([create(parent), create(sub)])
- const resParent0 = await example.get(Entry({entryId: parent.entryId}))
+ const {Container} = example.schema
+ const parent = Edit.create(Container).set({path: 'parent'})
+ const sub = parent.createChild(Container).set({path: 'sub'})
+ await example.commit(parent, sub)
+ const resParent0 = await example.get(Query.whereId(parent.entryId))
assert.is(resParent0.url, '/parent')
-
- const draft = {
- ...parent,
- phase: EntryPhase.Draft,
- data: {path: 'new-path'}
- }
-
// Changing entry paths in draft should not have an influence on
// computed properties such as url, filePath etc. until we publish.
- await db.applyMutations([edit(draft)])
+ await example.commit(
+ Edit(parent.entryId, Container).set({path: 'new-path'}).draft()
+ )
const resParent1 = await example.graph.drafts.get(
- Entry({entryId: parent.entryId})
+ Query.whereId(parent.entryId)
)
assert.is(resParent1.url, '/parent')
- const res1 = await example.get(Entry({entryId: sub.entryId}))
+ const res1 = await example.get(Query.whereId(sub.entryId))
assert.is(res1.url, '/parent/sub')
// Once we publish, the computed properties should be updated.
- await db.applyMutations([publish(draft)])
- const resParent2 = await example.get(Entry({entryId: parent.entryId}))
+ await example.commit(Edit.publish(parent.entryId))
+ const resParent2 = await example.get(Query.whereId(parent.entryId))
assert.is(resParent2.url, '/new-path')
- const res2 = await example.get(Entry({entryId: sub.entryId}))
+ const res2 = await example.get(Query.whereId(sub.entryId))
assert.is(res2.url, '/new-path/sub')
})
test('change published path for entry with language', async () => {
const example = createExample()
- const db = await example.db
- const multi = example.in(example.workspaces.main.multiLanguage)
- console.log(await multi.find(Entry()))
- const localised3 = await multi.get(Entry({path: 'localised3'}))
+ const multi = example.locale('en').in(example.workspaces.main.multiLanguage)
+ const localised3 = await multi.get(Query.wherePath('localised3'))
assert.is(localised3.url, '/en/localised2/localised3')
// Archive localised3
- await db.applyMutations([archive(localised3)])
+ await example.commit(Edit.archive(localised3.entryId))
const localised3Archived = await example.graph.archived
.in(example.workspaces.main.multiLanguage)
- .get(Entry({path: 'localised3'}))
+ .get(Query.wherePath('localised3'))
assert.is(localised3Archived.phase, EntryPhase.Archived)
// And publish again
- await db.applyMutations([publish(localised3Archived)])
- const localised3Publish = await multi.get(Entry({path: 'localised3'}))
+ await example.commit(Edit.publish(localised3.entryId))
+ const localised3Publish = await multi.get(Query.wherePath('localised3'))
assert.is(localised3Publish.url, '/en/localised2/localised3')
})
+test('file upload', async () => {
+ const example = createExample()
+ const upload = Edit.upload([
+ 'test.txt',
+ new TextEncoder().encode('Hello, World!')
+ ])
+ await example.commit(upload)
+ const result = await example.get(Query.whereId(upload.entryId))
+ assert.is(result.title, 'test')
+ assert.is(result.root, 'media')
+})
+
+test('image upload', async () => {
+ const example = createExample()
+ const imageData = readFileSync(
+ 'apps/web/public/screenshot-2022-09-19-at-12-21-23.2U9fkc81kcSh2InU931HrUJstwD.png'
+ )
+ const upload = Edit.upload(['test.png', imageData], {createPreview})
+ await example.commit(upload)
+ const result = await example.get(Query.whereId(upload.entryId))
+ assert.is(result.title, 'test')
+ assert.is(result.root, 'media')
+ assert.is(result.data.width, 2880)
+ assert.is(result.data.height, 1422)
+ assert.is(result.data.averageColor, '#4b4f59')
+})
+
+test('field creators', async () => {
+ const example = createExample()
+ const {Fields} = example.schema
+ const entry = Edit.create(Fields)
+ const list = Edit.list(Fields.list)
+ .add('Text', {
+ title: '',
+ text: Edit.richText(Fields.richText)
+ .addHtml(
+ `
+ Test
+ This will be quite useful.
+ `
+ )
+ .value()
+ })
+ .value()
+ entry.set({title: 'Fields', list})
+ await example.commit(entry)
+ const res = (await example.get(
+ Query.whereId(entry.entryId).select(Fields.list)
+ ))![0]
+ if (res?.type !== 'Text') throw new Error('Expected Text')
+ assert.equal(res.text[0], {
+ type: 'heading',
+ level: 1,
+ content: [{type: 'text', text: 'Test'}]
+ })
+})
+
test.run()
diff --git a/src/backend/Database.ts b/src/backend/Database.ts
index ae8006d1f..913df8c8c 100644
--- a/src/backend/Database.ts
+++ b/src/backend/Database.ts
@@ -16,6 +16,7 @@ import {
import {entryInfo, entryUrl} from 'alinea/core/EntryFilenames'
import {EntryRecord, META_KEY, createRecord} from 'alinea/core/EntryRecord'
import {Mutation, MutationType} from 'alinea/core/Mutation'
+import {createFileHash, createRowHash} from 'alinea/core/util/ContentHash'
import {createEntryRow, publishEntryRow} from 'alinea/core/util/EntryRows'
import {Logger} from 'alinea/core/util/Logger'
import {entries} from 'alinea/core/util/Objects'
@@ -30,7 +31,6 @@ import {Target} from './Target.js'
import {ChangeSetCreator} from './data/ChangeSet.js'
import {AlineaMeta} from './db/AlineaMeta.js'
import {createEntrySearch} from './db/CreateEntrySearch.js'
-import {createFileHash, createRowHash} from './util/ContentHash.js'
interface Seed {
type: string
diff --git a/src/backend/Media.ts b/src/backend/Media.ts
index 2c53beb3f..546fc2620 100644
--- a/src/backend/Media.ts
+++ b/src/backend/Media.ts
@@ -29,21 +29,4 @@ export namespace Media {
export type File = EntryRow>
export type Image = EntryRow
-
- export const imageExtensions = [
- '.jpg',
- '.jpeg',
- '.png',
- '.gif',
- '.bmp',
- '.webp',
- '.avif',
- '.heic',
- '.svg'
- ]
-
- export function isImage(pathOrExtension: string) {
- const extension = pathOrExtension.toLowerCase().split('.').pop()
- return extension && imageExtensions.includes(`.${extension}`)
- }
}
diff --git a/src/backend/test/Example.ts b/src/backend/test/Example.ts
index 09764d1a0..4bc051bfc 100644
--- a/src/backend/test/Example.ts
+++ b/src/backend/test/Example.ts
@@ -1,8 +1,25 @@
-import {page, root, type, workspace} from 'alinea/core'
+import {Entry, document, page, root, schema, type, workspace} from 'alinea/core'
import {createTestCMS} from 'alinea/core/driver/TestDriver'
import {createMediaRoot} from 'alinea/core/media/MediaRoot'
import {MediaFile, MediaLibrary} from 'alinea/core/media/MediaSchema'
-import {path, tab, tabs, text} from 'alinea/input'
+import {
+ check,
+ code,
+ date,
+ entry,
+ image,
+ link,
+ list,
+ number,
+ object,
+ path,
+ richText,
+ select,
+ tab,
+ tabs,
+ text,
+ url
+} from 'alinea/input'
export function createExample() {
const Page = type('Type', {
@@ -31,6 +48,101 @@ export function createExample() {
}
})
+ const Fields = document('Fields', {
+ text: text('Text field'),
+ hello: text('Validated text field', {
+ help: 'This field only accepts "hello"',
+ validate: value => {
+ if (value !== 'hello') {
+ return 'Only "hello" is allowed'
+ }
+ }
+ }),
+ richText: richText('Rich text field'),
+ select: select('Select field', {
+ a: 'Option a',
+ b: 'Option b'
+ }),
+ number: number('Number field', {
+ minValue: 0,
+ maxValue: 10
+ }),
+ check: check('Check field', {description: 'Check me please'}),
+ date: date('Date field'),
+ code: code('Code field'),
+ externalLink: url('External link'),
+ entry: entry('Internal link'),
+ entryWithCondition: entry('With condition', {
+ help: `Show only entries of type Fields`,
+ condition: Entry.type.is('Fields')
+ }),
+ linkMultiple: link.multiple('Mixed links, multiple'),
+ image: image('Image link'),
+ images: image.multiple('Image link (multiple)'),
+ file: entry('File link'),
+ withFields: link('With extra fields', {
+ fields: type({
+ fieldA: text('Field A', {width: 0.5}),
+ fieldB: text('Field B', {width: 0.5})
+ })
+ }),
+ multipleWithFields: link.multiple('Multiple With extra fields', {
+ fields: type({
+ fieldA: text('Field A', {width: 0.5}),
+ fieldB: text('Field B', {width: 0.5, required: true})
+ })
+ }),
+ list: list('My list field', {
+ schema: schema({
+ Text: type('Text', {
+ title: text('Item title'),
+ text: richText('Item body text')
+ }),
+ Image: type('Image', {
+ image: image('Image')
+ })
+ })
+ }),
+ withInitial: richText('With initial value', {
+ required: true,
+ initialValue: [
+ {
+ type: 'paragraph',
+ content: [
+ {type: 'text', text: 'This is a paragraph with initial value'}
+ ]
+ }
+ ]
+ }),
+ nested: richText('With nested blocks', {
+ schema: {
+ Inner: type('Inner', {
+ checkbox1: check('Checkbox 1'),
+ checkbox2: check('Checkbox 2'),
+ title: text('Title'),
+ content: richText('Inner rich text')
+ }),
+
+ NestLayout: type('Nested layout fields', {
+ object: object('Object field', {
+ fields: type('Fields', {
+ fieldA: text('Field A', {width: 0.5}),
+ fieldB: text('Field B', {width: 0.5})
+ })
+ }),
+ ...tabs(
+ tab('Tab A', {
+ tabA: text('Tab A')
+ }),
+ tab('Tab B', {
+ tabB: text('Tab B')
+ })
+ )
+ })
+ }
+ })
+ })
+
const main = workspace('Main', {
pages: root('Pages', {
entry1: page(Page({title: 'Test title'})),
@@ -76,7 +188,7 @@ export function createExample() {
})
return createTestCMS({
- schema: {Page, Container},
+ schema: {Fields, Page, Container},
workspaces: {main}
})
}
diff --git a/src/cli/Init.test.ts b/src/cli/Init.test.ts
index 826ed0f76..06a00cfd3 100644
--- a/src/cli/Init.test.ts
+++ b/src/cli/Init.test.ts
@@ -1,6 +1,6 @@
import {init} from 'alinea/cli/Init'
import fs from 'fs-extra'
-import path from 'path'
+import path from 'node:path'
import {test} from 'uvu'
const testPms = false
diff --git a/src/cli/Init.ts b/src/cli/Init.ts
index 700f6c301..414e75f99 100644
--- a/src/cli/Init.ts
+++ b/src/cli/Init.ts
@@ -1,7 +1,6 @@
import {createId, outcome} from 'alinea/core'
import fs from 'node:fs/promises'
import path from 'node:path'
-import {generate} from './Generate.js'
import {dirname} from './util/Dirname.js'
import {findConfigFile} from './util/FindConfigFile.js'
@@ -75,8 +74,6 @@ export async function init(options: InitOptions) {
)
await fs.writeFile(configFileLocation, configFileContents)
const pm = await detectPm()
- for await (const _ of generate({cwd: path.resolve(cwd), quiet})) {
- }
if (options.next) {
let [pkg] = await outcome(
fs.readFile(path.join(cwd, 'package.json'), 'utf-8')
diff --git a/src/cli/generate/CompileConfig.ts b/src/cli/generate/CompileConfig.ts
index 113b80728..9ea9bd50e 100644
--- a/src/cli/generate/CompileConfig.ts
+++ b/src/cli/generate/CompileConfig.ts
@@ -51,6 +51,7 @@ export function compileConfig({
},
platform: 'neutral',
jsx: 'automatic',
+ sourcemap: true,
define,
loader: {
'.module.css': 'local-css',
diff --git a/src/core.ts b/src/core.ts
index 8e081acff..88e4918ac 100644
--- a/src/core.ts
+++ b/src/core.ts
@@ -1,9 +1,12 @@
+export {createCMS} from 'alinea/core/driver/DefaultDriver'
export * from './core/Auth.js'
export * from './core/CMS.js'
export * from './core/Config.js'
export * from './core/Connection.js'
export * from './core/Doc.js'
+export * from './core/Document.js'
export * from './core/Draft.js'
+export * from './core/Edit.js'
export * from './core/Entry.js'
export * from './core/EntryRow.js'
export * from './core/Field.js'
@@ -17,6 +20,7 @@ export * from './core/Meta.js'
export * from './core/Outcome.js'
export * from './core/Page.js'
export * from './core/Picker.js'
+export * from './core/Query.js'
export * from './core/Reference.js'
export * from './core/Resolver.js'
export * from './core/Root.js'
@@ -31,7 +35,6 @@ export * from './core/Type.js'
export * from './core/User.js'
export * from './core/View.js'
export * from './core/Workspace.js'
-export {createCMS} from './core/driver/DefaultDriver.js'
export * from './core/field/ListField.js'
export * from './core/field/RecordField.js'
export * from './core/field/RichTextField.js'
diff --git a/src/core/CMS.ts b/src/core/CMS.ts
index f9e43a87f..183ee4252 100644
--- a/src/core/CMS.ts
+++ b/src/core/CMS.ts
@@ -1,8 +1,9 @@
-import {Store} from 'alinea/backend/Store'
import {Config, createConfig} from './Config.js'
+import {Connection} from './Connection.js'
import {Graph, GraphRealm} from './Graph.js'
import {Resolver} from './Resolver.js'
import {Root} from './Root.js'
+import {Operation, Transaction} from './Transaction.js'
import {Workspace} from './Workspace.js'
import {entries} from './util/Objects.js'
@@ -28,7 +29,14 @@ export abstract class CMS extends GraphRealm {
}
abstract resolver(): Promise
- abstract readStore(): Promise
+ abstract connection(): Promise
+
+ commit(...operations: Array) {
+ return Transaction.commit(
+ this,
+ operations.map(op => op[Operation.Data]).flat()
+ )
+ }
#attach(config: Config) {
for (const [name, workspace] of entries(config.workspaces)) {
diff --git a/src/core/Edit.ts b/src/core/Edit.ts
new file mode 100644
index 000000000..c160be7b7
--- /dev/null
+++ b/src/core/Edit.ts
@@ -0,0 +1,61 @@
+import {File} from '@alinea/iso'
+import {
+ FieldOptions,
+ ListEditor,
+ ListField,
+ ListRow,
+ RichTextEditor,
+ RichTextField,
+ TextDoc
+} from 'alinea/core'
+import {
+ CreateOperation,
+ DeleteOp,
+ EditOperation,
+ UploadOperation,
+ UploadOptions
+} from './Transaction.js'
+import {Type} from './Type.js'
+
+export function Edit(entryId: string, type?: Type) {
+ return new EditOperation(entryId)
+}
+
+export namespace Edit {
+ export function create(type: Type) {
+ return new CreateOperation(type)
+ }
+
+ export function remove(entryId: string) {
+ return new DeleteOp(entryId)
+ }
+
+ export function upload(
+ file: File | [string, Uint8Array],
+ options?: UploadOptions
+ ) {
+ return new UploadOperation(file, options)
+ }
+
+ export function archive(entryId: string) {
+ return Edit(entryId).archive()
+ }
+
+ export function publish(entryId: string) {
+ return Edit(entryId).publish()
+ }
+
+ export function list<
+ Row extends ListRow,
+ Options extends FieldOptions>
+ >(field: ListField, current?: Array) {
+ return new ListEditor(current)
+ }
+
+ export function richText(
+ field: RichTextField,
+ current?: TextDoc
+ ) {
+ return new RichTextEditor(current)
+ }
+}
diff --git a/src/core/Edits.test.ts b/src/core/Edits.test.ts
deleted file mode 100644
index e69de29bb..000000000
diff --git a/src/core/Field.ts b/src/core/Field.ts
index 8ab3b9d47..18be58eb3 100644
--- a/src/core/Field.ts
+++ b/src/core/Field.ts
@@ -70,6 +70,7 @@ export class Field<
export namespace Field {
export const Data = Symbol.for('@alinea/Field.Data')
+ export const Value = Symbol.for('@alinea/Field.Value')
export const Ref = Symbol.for('@alinea/Field.Self')
export function provideView<
diff --git a/src/core/Page.ts b/src/core/Page.ts
index e5e7f8476..5ee6df362 100644
--- a/src/core/Page.ts
+++ b/src/core/Page.ts
@@ -29,7 +29,7 @@ export function page<
Definition,
Children extends Record>
>(
- type: Type | Cursor.Partial,
+ type: Type | Cursor.Typed,
children?: Children
): PageSeed {
children = children ?? ({} as Children)
diff --git a/src/core/Picker.ts b/src/core/Picker.ts
index 2f5c848cb..23b77c4de 100644
--- a/src/core/Picker.ts
+++ b/src/core/Picker.ts
@@ -35,22 +35,20 @@ export interface Picker {
postProcess?: PostProcess
}
-export namespace Picker {
- export function withView<
- R extends Reference,
- T extends {},
- C extends (...args: Array) => Picker
- >(
- create: C,
- views: {
- view: ComponentType>
- viewRow: ComponentType<{reference: R}>
- }
- ): C {
- const factory = (...args: Array) => {
- const field: any = create(...args)
- return {...field, ...views}
- }
- return factory as any
+export function pickerWithView<
+ R extends Reference,
+ T extends {},
+ C extends (...args: Array) => Picker
+>(
+ create: C,
+ views: {
+ view: ComponentType>
+ viewRow: ComponentType<{reference: R}>
}
+): C {
+ const factory = (...args: Array) => {
+ const field: any = create(...args)
+ return {...field, ...views}
+ }
+ return factory as any
}
diff --git a/src/core/Query.ts b/src/core/Query.ts
new file mode 100644
index 000000000..d257f214a
--- /dev/null
+++ b/src/core/Query.ts
@@ -0,0 +1,165 @@
+import {Entry} from './Entry.js'
+import {Type} from './Type.js'
+import {Cursor, SourceType} from './pages/Cursor.js'
+import {EV, Expr} from './pages/Expr.js'
+import {Projection} from './pages/Projection.js'
+
+/*
+// Todo: support multiple types
+export function Query>(
+ ...types: Types
+): Cursor.Find> {
+ return undefined!
+}*/
+
+export function Query(
+ type: Type
+): Cursor.Typed {
+ return new Cursor.Typed(type)
+}
+
+export namespace Query {
+ export const and = Expr.and
+ export const or = Expr.or
+ export const not = (a: EV) => Expr.create(a).not()
+ export const is = (a: EV, b: EV) => Expr.create(a).is(b)
+ export const isNot = (a: EV, b: EV) => Expr.create(a).isNot(b)
+ export const isNull = (a: EV) => Expr.create(a).isNull()
+ export const isNotNull = (a: EV) => Expr.create(a).isNotNull()
+ export const isIn = (a: EV, b: EV> | Cursor.Find) =>
+ Expr.create(a).isIn(b)
+ export const isNotIn = (
+ a: EV,
+ b: EV> | Cursor.Find
+ ) => Expr.create(a).isNotIn(b)
+ export const isGreater = (a: EV, b: EV) =>
+ Expr.create(a).isGreater(b)
+ export const isGreaterOrEqual = (a: EV, b: EV) =>
+ Expr.create(a).isGreaterOrEqual(b)
+ export const isLess = (a: EV, b: EV) => Expr.create(a).isLess(b)
+ export const isLessOrEqual = (a: EV, b: EV) =>
+ Expr.create(a).isLessOrEqual(b)
+ export const add = (
+ a: EV,
+ b: EV,
+ ...rest: Array>
+ ) => Expr.create(a).add(b, ...rest)
+ export const subtract = (
+ a: EV,
+ b: EV,
+ ...rest: Array>
+ ) => Expr.create(a).subtract(b, ...rest)
+ export const multiply = (
+ a: EV,
+ b: EV,
+ ...rest: Array>
+ ) => Expr.create(a).multiply(b, ...rest)
+ export const divide = (
+ a: EV,
+ b: EV,
+ ...rest: Array>
+ ) => Expr.create(a).divide(b, ...rest)
+ export const remainder = (a: EV, b: EV) =>
+ Expr.create(a).remainder(b)
+ export const concat = (a: EV, b: EV) =>
+ Expr.create(a).concat(b)
+ export const like = (a: EV, b: EV) => Expr.create(a).like(b)
+ export const at = (a: EV>, index: number) =>
+ Expr.create(a).at(index)
+ export const includes = (a: EV>, value: EV) =>
+ Expr.create(a).includes(value)
+
+ export const url = Entry.url
+ export const path = Entry.path
+ export const title = Entry.title
+ export const id = Entry.entryId
+ export const parent = Entry.parent
+ export const type = Entry.type
+ export const workspace = Entry.workspace
+ export const root = Entry.root
+ export const locale = Entry.locale
+ export const level = Entry.level
+
+ export const entry = {
+ id,
+ type,
+ workspace,
+ root,
+ locale,
+ path,
+ title,
+ url,
+ parent,
+ level
+ }
+
+ export const where = (...where: Array>) => Entry().where(...where)
+ export const whereId = (id: string) => Entry().where(Entry.entryId.is(id))
+ export const whereUrl = (url: string) => Entry().where(Entry.url.is(url))
+ export const wherePath = (path: string) => Entry().where(Entry.path.is(path))
+ export const whereParent = (parentId: string) =>
+ Entry().where(Entry.parent.is(parentId))
+ export const whereLocale = (locale: string) =>
+ Entry().where(Entry.locale.is(locale))
+ export const whereWorkspace = (workspace: string) =>
+ Entry().where(Entry.workspace.is(workspace))
+ export const whereRoot = (root: string) => Entry().where(Entry.root.is(root))
+ export const whereType = (type: string) => Entry().where(Entry.type.is(type))
+
+ export const select = (select: S) =>
+ Entry().select(select)
+ export const search = (...searchTerms: Array) =>
+ Entry().search(...searchTerms)
+
+ export function children(
+ type?: Type,
+ depth = 1
+ ): Cursor.Find> {
+ return new Cursor.Find({
+ target: {type},
+ source: {type: SourceType.Children, depth}
+ })
+ }
+
+ export function parents(): Cursor.Find> {
+ return new Cursor.Find({
+ source: {type: SourceType.Parents}
+ })
+ }
+
+ export function next(
+ type?: Type
+ ): Cursor.Get> {
+ return new Cursor.Get({
+ target: {type},
+ source: {type: SourceType.Next}
+ })
+ }
+
+ export function previous(
+ type?: Type
+ ): Cursor.Get> {
+ return new Cursor.Get({
+ target: {type},
+ source: {type: SourceType.Previous}
+ })
+ }
+
+ export function siblings(
+ type?: Type
+ ): Cursor.Find> {
+ return new Cursor.Find({
+ target: {type},
+ source: {type: SourceType.Siblings}
+ })
+ }
+
+ export function translations(
+ type?: Type
+ ): Cursor.Find> {
+ return new Cursor.Find({
+ target: {type},
+ source: {type: SourceType.Translations}
+ })
+ }
+}
diff --git a/src/core/Transaction.ts b/src/core/Transaction.ts
new file mode 100644
index 000000000..d6519f248
--- /dev/null
+++ b/src/core/Transaction.ts
@@ -0,0 +1,434 @@
+import {Blob, File} from '@alinea/iso'
+import {ImagePreviewDetails} from 'alinea/core/media/CreatePreview'
+import {CMS} from './CMS.js'
+import {Config} from './Config.js'
+import {Entry} from './Entry.js'
+import {
+ entryChildrenDir,
+ entryFileName,
+ entryFilepath,
+ entryUrl,
+ workspaceMediaDir
+} from './EntryFilenames.js'
+import {EntryPhase, EntryRow} from './EntryRow.js'
+import {GraphRealm} from './Graph.js'
+import {HttpError} from './HttpError.js'
+import {createId} from './Id.js'
+import {Mutation, MutationType} from './Mutation.js'
+import {Root} from './Root.js'
+import {Schema} from './Schema.js'
+import {EntryUrlMeta, Type, TypeI} from './Type.js'
+import {Workspace} from './Workspace.js'
+import {isImage} from './media/IsImage.js'
+import {createFileHash} from './util/ContentHash.js'
+import {createEntryRow, entryParentPaths} from './util/EntryRows.js'
+import {basename, extname, join, normalize} from './util/Paths.js'
+import {slugify} from './util/Slugs.js'
+
+export interface Transaction {
+ (cms: CMS): Promise>
+}
+export namespace Transaction {
+ export async function commit(cms: CMS, tx: Array) {
+ const mutations = await Promise.all(tx.map(task => task(cms)))
+ const cnx = await cms.connection()
+ return cnx.mutate(mutations.flat())
+ }
+}
+
+export interface Operation {
+ [Operation.Data]: Transaction
+}
+
+export class Operation {
+ static readonly Data = Symbol.for('@alinea/Operation.Data')
+
+ constructor(tx: Transaction) {
+ this[Operation.Data] = tx
+ }
+
+ protected typeName(config: Config, type: TypeI) {
+ const typeNames = Schema.typeNames(config.schema)
+ const typeName = typeNames.get(type)!
+ if (!typeName) throw new Error(`Type not found: ${type}`)
+ return typeName
+ }
+}
+
+export interface UploadOptions {
+ createPreview?(blob: Blob): Promise
+}
+
+export class UploadOperation extends Operation {
+ entryId = createId()
+ private parentId?: string
+ private workspace?: string
+ private root?: string
+
+ constructor(file: File | [string, Uint8Array], options: UploadOptions = {}) {
+ super(async ({config, graph, connection}): Promise> => {
+ const fileName = Array.isArray(file) ? file[0] : file.name
+ const body = Array.isArray(file) ? file[1] : await file.arrayBuffer()
+ const cnx = await connection()
+ const workspace = this.workspace ?? Object.keys(config.workspaces)[0]
+ const root =
+ this.root ?? Workspace.defaultMediaRoot(config.workspaces[workspace])
+ const extension = extname(fileName)
+ const path = slugify(basename(fileName, extension))
+ const directory = workspaceMediaDir(config, workspace)
+ const uploadLocation = join(directory, path + extension)
+ const info = await cnx.prepareUpload(uploadLocation)
+ const previewData = isImage(fileName)
+ ? await options.createPreview?.(
+ file instanceof Blob ? file : new Blob([body])
+ )
+ : undefined
+ await fetch(info.upload.url, {
+ method: info.upload.method ?? 'POST',
+ body
+ }).then(async result => {
+ if (!result.ok)
+ throw new HttpError(
+ result.status,
+ `Could not reach server for upload`
+ )
+ })
+ const parent = this.parentId
+ ? await graph.preferPublished.get(Entry({entryId: this.parentId}))
+ : undefined
+ const title = basename(fileName, extension)
+ const hash = await createFileHash(new Uint8Array(body))
+ const {mediaDir} = Workspace.data(config.workspaces[workspace])
+ const prefix = mediaDir && normalize(mediaDir)
+ const fileLocation =
+ prefix && info.location.startsWith(prefix)
+ ? info.location.slice(prefix.length)
+ : info.location
+ const entryData = {
+ title,
+ location: fileLocation,
+ extension,
+ size: body.byteLength,
+ hash,
+ ...previewData
+ }
+ const entry = await createEntry(
+ config,
+ 'MediaFile',
+ {
+ path,
+ entryId: this.entryId,
+ workspace,
+ root,
+ data: entryData
+ },
+ parent
+ )
+ const parentPaths = entryParentPaths(config, entry)
+ const entryFile = entryFileName(config, entry, parentPaths)
+ return [
+ {
+ type: MutationType.Upload,
+ entryId: this.entryId,
+ url: info.previewUrl,
+ file: info.location
+ },
+ {
+ type: MutationType.Create,
+ entryId: entry.entryId,
+ file: entryFile,
+ entry
+ }
+ ]
+ })
+ }
+
+ setParent(parentId: string) {
+ this.parentId = parentId
+ return this
+ }
+
+ setWorkspace(workspace: string) {
+ this.workspace = workspace
+ return this
+ }
+
+ setRoot(root: string) {
+ this.root = root
+ return this
+ }
+}
+
+export class DeleteOp extends Operation {
+ constructor(protected entryId: string) {
+ super(async ({config, graph}) => {
+ const entry = await graph.preferPublished.get(
+ Entry({entryId: this.entryId})
+ )
+ const parentPaths = entryParentPaths(config, entry)
+ const file = entryFileName(config, entry, parentPaths)
+ return [
+ {
+ type: MutationType.Remove,
+ entryId: this.entryId,
+ file
+ }
+ ]
+ })
+ }
+}
+
+export class EditOperation extends Operation {
+ private entryData?: Partial>
+ private changePhase?: EntryPhase
+
+ constructor(protected entryId: string) {
+ super(async ({config, graph}) => {
+ let realm: GraphRealm
+ if (this.changePhase === EntryPhase.Draft) realm = graph.preferDraft
+ else if (this.changePhase === EntryPhase.Archived)
+ realm = graph.preferPublished
+ else if (this.changePhase === EntryPhase.Published)
+ realm = graph.preferDraft
+ else realm = graph.preferPublished
+ const entry = await realm.get(Entry({entryId: this.entryId}))
+ const parent = entry.parent
+ ? await graph.preferPublished.get(Entry({entryId: entry.parent}))
+ : undefined
+ const parentPaths = entryParentPaths(config, entry)
+
+ const file = entryFileName(
+ config,
+ {...entry, phase: entry.phase},
+ parentPaths
+ )
+ const type = config.schema[entry.type]
+ const mutations: Array = []
+ const createDraft = this.changePhase === EntryPhase.Draft
+ if (createDraft)
+ mutations.push({
+ type: MutationType.Edit,
+ entryId: this.entryId,
+ file,
+ entry: await createEntry(
+ config,
+ this.typeName(config, type),
+ {
+ ...entry,
+ phase: EntryPhase.Draft,
+ data: {...entry.data, ...this.entryData}
+ },
+ parent
+ )
+ })
+ else if (this.entryData)
+ mutations.push({
+ type: MutationType.Patch,
+ entryId: this.entryId,
+ file,
+ patch: this.entryData
+ })
+ switch (this.changePhase) {
+ case EntryPhase.Published:
+ mutations.push({
+ type: MutationType.Publish,
+ phase: entry.phase,
+ entryId: this.entryId,
+ file
+ })
+ break
+ case EntryPhase.Archived:
+ mutations.push({
+ type: MutationType.Archive,
+ entryId: this.entryId,
+ file
+ })
+ break
+ }
+ return mutations
+ })
+ }
+
+ set(entryData: Partial>) {
+ this.entryData = {...this.entryData, ...entryData}
+ return this
+ }
+
+ /*moveTo(workspace: string, root: string, parentId?: string) {
+ throw new Error(`Not implemented`)
+ return this
+ }
+
+ createAfter(type: Type) {
+ throw new Error(`Not implemented`)
+ return new CreateOp(type)
+ }
+
+ createBefore(type: Type) {
+ throw new Error(`Not implemented`)
+ return new CreateOp(type)
+ }
+
+ createChild(type: Type) {
+ throw new Error(`Not implemented`)
+ return new CreateOp(type)
+ }*/
+
+ draft() {
+ this.changePhase = EntryPhase.Draft
+ return this
+ }
+
+ archive() {
+ this.changePhase = EntryPhase.Archived
+ return this
+ }
+
+ publish() {
+ this.changePhase = EntryPhase.Published
+ return this
+ }
+}
+
+export class CreateOperation extends Operation {
+ entryId = createId()
+ private workspace?: string
+ private root?: string
+ private locale?: string | null
+ private entryData: Partial> = {}
+ private entryRow = async (cms: CMS) => {
+ return createEntry(
+ cms.config,
+ this.typeName(cms.config, this.type),
+ {entryId: this.entryId, data: this.entryData ?? {}},
+ await this.parentRow?.(cms)
+ )
+ }
+
+ constructor(
+ protected type: Type,
+ protected parentRow?: (cms: CMS) => Promise
+ ) {
+ super(async (cms): Promise> => {
+ const parent = await this.parentRow?.(cms)
+ const {config} = cms
+ const entry = await createEntry(
+ config,
+ this.typeName(config, type),
+ {
+ entryId: this.entryId,
+ workspace: this.workspace,
+ root: this.root,
+ locale: this.locale,
+ data: this.entryData ?? {}
+ },
+ parent
+ )
+ const parentPaths = entryParentPaths(config, entry)
+ const file = entryFileName(config, entry, parentPaths)
+ return [
+ {
+ type: MutationType.Create,
+ entryId: this.entryId,
+ file,
+ entry: entry
+ }
+ ]
+ })
+ }
+
+ setParent(parentId: string) {
+ this.parentRow = async (cms: CMS) => {
+ return cms.graph.preferPublished.get(Entry({entryId: parentId}))
+ }
+ return this
+ }
+
+ setWorkspace(workspace: string) {
+ this.workspace = workspace
+ return this
+ }
+
+ setRoot(root: string) {
+ this.root = root
+ return this
+ }
+
+ setLocale(locale: string | null) {
+ this.locale = locale
+ return this
+ }
+
+ set(entryData: Partial>) {
+ this.entryData = {...this.entryData, ...entryData}
+ return this
+ }
+
+ createChild(type: Type) {
+ return new CreateOperation(type, this.entryRow)
+ }
+}
+
+async function createEntry(
+ config: Config,
+ typeName: string,
+ partial: Partial = {title: 'Entry'},
+ parent?: EntryRow
+): Promise {
+ const type = config.schema[typeName]
+ const workspace =
+ parent?.workspace ?? partial.workspace ?? Object.keys(config.workspaces)[0]
+ const root =
+ parent?.root ?? partial.root ?? Object.keys(config.workspaces[workspace])[0]
+ const locale =
+ parent?.locale ??
+ partial.locale ??
+ Root.defaultLocale(config.workspaces[workspace][root]) ??
+ null
+ const title = partial.data.title ?? partial.title ?? 'Entry'
+ const phase = partial.phase ?? EntryPhase.Published
+ const path = slugify(
+ (phase === EntryPhase.Published && partial.data.path) ||
+ (partial.path ?? title)
+ )
+ const entryData = {title, path, ...partial.data}
+ const entryId = partial.entryId ?? createId()
+ const i18nId = partial.i18nId ?? createId()
+ const details = {
+ entryId,
+ phase,
+ type: typeName,
+ title,
+ path,
+ seeded: null,
+ workspace: workspace,
+ root: root,
+ level: parent ? parent.level + 1 : 0,
+ parent: parent?.entryId ?? null,
+ locale,
+ index: 'a0',
+ i18nId,
+ modifiedAt: 0,
+ active: true,
+ main: true,
+ data: entryData,
+ searchableText: Type.searchableText(type, entryData)
+ }
+ const parentPaths = parent?.childrenDir.split('/').filter(Boolean) ?? []
+ const filePath = entryFilepath(config, details, parentPaths)
+ const childrenDir = entryChildrenDir(config, details, parentPaths)
+ const urlMeta: EntryUrlMeta = {
+ locale,
+ path,
+ phase,
+ parentPaths
+ }
+ const url = entryUrl(type, urlMeta)
+ return createEntryRow(config, {
+ ...details,
+ filePath,
+ childrenDir,
+ parentDir: childrenDir.split('/').slice(0, -1).join('/'),
+ url
+ })
+}
diff --git a/src/core/Type.ts b/src/core/Type.ts
index 12dc57eae..c09fd3512 100644
--- a/src/core/Type.ts
+++ b/src/core/Type.ts
@@ -1,6 +1,6 @@
import {EntryPhase, Expand} from 'alinea/core'
import {Cursor} from 'alinea/core/pages/Cursor'
-import {Expr, and} from 'alinea/core/pages/Expr'
+import {Expr} from 'alinea/core/pages/Expr'
import {EntryEditProps} from 'alinea/dashboard/view/EntryEdit'
import {Callable} from 'rado/util/Callable'
import type {ComponentType} from 'react'
@@ -70,19 +70,19 @@ export declare class TypeI {
export interface TypeI extends Callable {
(): Cursor.Find>
- (partial: Partial>): Cursor.Partial
+ (partial: Partial>): Cursor.Typed
}
export type Type = Definition & TypeI
-export type TypeRow = Expand<{
+type TypeRow = Expand<{
[K in keyof Definition as Definition[K] extends Expr
? K
: never]: Definition[K] extends Expr ? T : never
}>
-
export namespace Type {
export type Infer = TypeRow
+
export const Data = Symbol.for('@alinea/Type.Data')
export function label(type: Type): Label {
@@ -220,7 +220,7 @@ class TypeInstance implements TypeData {
const conditions = isConditionalRecord
? entries(input[0]).map(([key, value]) => {
const field = Expr(ExprData.Field({type: this.target}, key))
- return Expr(
+ return Expr(
ExprData.BinOp(
field[Expr.Data],
BinaryOp.Equals,
@@ -228,13 +228,13 @@ class TypeInstance implements TypeData {
)
)
})
- : input.map(ev => Expr(createExprData(ev)))
- return and(...conditions)[Expr.Data]
+ : input.map(ev => Expr(createExprData(ev)))
+ return Expr.and(...conditions)[Expr.Data]
}
call(...input: Array) {
const isConditionalRecord = input.length === 1 && !Expr.isExpr(input[0])
- if (isConditionalRecord) return new Cursor.Partial(this.target, input[0])
+ if (isConditionalRecord) return new Cursor.Typed(this.target, input[0])
else
return new Cursor.Find({
target: {type: this.target},
diff --git a/src/core/Workspace.ts b/src/core/Workspace.ts
index a4b9ee84d..3e33d5ff6 100644
--- a/src/core/Workspace.ts
+++ b/src/core/Workspace.ts
@@ -3,7 +3,9 @@ import {CMS} from './CMS.js'
import {Label} from './Label.js'
import {Meta, StripMeta} from './Meta.js'
import {Root} from './Root.js'
+import {isMediaRoot} from './media/MediaRoot.js'
import {getRandomColor} from './util/GetRandomColor.js'
+import {entries} from './util/Objects.js'
export interface WorkspaceMeta {
/** A directory which contains the json entry files */
@@ -52,6 +54,13 @@ export namespace Workspace {
export function isWorkspace(value: any): value is Workspace {
return Boolean(value && value[Workspace.Data])
}
+
+ export function defaultMediaRoot(workspace: Workspace): string {
+ const {roots} = workspace[Workspace.Data]
+ for (const [name, root] of entries(roots))
+ if (isMediaRoot(root)) return name
+ throw new Error(`Workspace ${workspace.name} has no media root`)
+ }
}
/** Create a workspace */
diff --git a/src/core/driver/DefaultDriver.server.tsx b/src/core/driver/DefaultDriver.server.tsx
index 7128f3910..2dad1b1d0 100644
--- a/src/core/driver/DefaultDriver.server.tsx
+++ b/src/core/driver/DefaultDriver.server.tsx
@@ -1,22 +1,30 @@
import {Database} from 'alinea/backend'
-import {Store, createStore} from 'alinea/backend/Store'
+import {createStore} from 'alinea/backend/Store'
import {EntryResolver} from 'alinea/backend/resolver/EntryResolver'
+import {createCloudHandler} from 'alinea/cloud/server/CloudHandler'
import {base64} from 'alinea/core/util/Encoding'
import PLazy from 'p-lazy'
import {CMS} from '../CMS.js'
import {Client} from '../Client.js'
import {Config} from '../Config.js'
+import {Connection} from '../Connection.js'
import {Resolver} from '../Resolver.js'
import {Realm} from '../pages/Realm.js'
+import {Logger} from '../util/Logger.js'
-export class DefaultDriver extends CMS {
- db = PLazy.from(this.createDb.bind(this))
+const store = PLazy.from(async () => {
+ // @ts-ignore
+ const {storeData} = await import('@alinea/generated/store.js')
+ return createStore(new Uint8Array(base64.parse(storeData)))
+})
- async readStore(): Promise {
- // @ts-ignore
- const {storeData} = await import('@alinea/generated/store.js')
- return createStore(new Uint8Array(base64.parse(storeData)))
- }
+export class DefaultDriver extends CMS {
+ apiKey = process.env.ALINEA_API_KEY
+ db = PLazy.from(async () => new Database(this.config, await store))
+ cloudHandler = PLazy.from(async () => {
+ const db = await this.db
+ return createCloudHandler(this.config, db, this.apiKey)
+ })
async resolver(): Promise {
const devUrl = process.env.ALINEA_DEV_SERVER
@@ -32,8 +40,12 @@ export class DefaultDriver extends CMS {
return new EntryResolver(await this.db, this.config.schema)
}
- private async createDb() {
- return new Database(this.config, await this.readStore())
+ async connection(): Promise {
+ const devUrl = process.env.ALINEA_DEV_SERVER
+ if (devUrl) return new Client({config: this.config, url: devUrl})
+ return (await this.cloudHandler).connect({
+ logger: new Logger('driver')
+ })
}
}
diff --git a/src/core/driver/DefaultDriver.tsx b/src/core/driver/DefaultDriver.tsx
index b60c9e777..6d4206b48 100644
--- a/src/core/driver/DefaultDriver.tsx
+++ b/src/core/driver/DefaultDriver.tsx
@@ -1,6 +1,7 @@
import {Store} from 'alinea/backend/Store'
import {CMS} from '../CMS.js'
import {Config} from '../Config.js'
+import {Connection} from '../Connection.js'
import {Resolver} from '../Resolver.js'
export class DefaultDriver extends CMS {
@@ -11,6 +12,10 @@ export class DefaultDriver extends CMS {
async resolver(): Promise {
throw new Error('Not implemented')
}
+
+ async connection(): Promise {
+ throw new Error('Not implemented')
+ }
}
export function createCMS(
diff --git a/src/core/driver/NextDriver.server.tsx b/src/core/driver/NextDriver.server.tsx
index 0126db9c0..99906d3bf 100644
--- a/src/core/driver/NextDriver.server.tsx
+++ b/src/core/driver/NextDriver.server.tsx
@@ -1,7 +1,6 @@
'use server'
import {JWTPreviews} from 'alinea/backend'
-import {createCloudHandler} from 'alinea/cloud/server/CloudHandler'
import {parseChunkedCookies} from 'alinea/preview/ChunkCookieValue'
import {
PREVIEW_ENTRYID_NAME,
@@ -10,7 +9,6 @@ import {
} from 'alinea/preview/PreviewConstants'
import {enums, object, string} from 'cito'
import dynamic from 'next/dynamic.js'
-import PLazy from 'p-lazy'
import {Suspense} from 'react'
import {Client} from '../Client.js'
import {Config} from '../Config.js'
@@ -99,11 +97,6 @@ class NextDriver extends DefaultDriver implements NextApi {
return response ?? new Response('Not found', {status: 404})
}
- cloudHandler = PLazy.from(async () => {
- const db = await this.db
- return createCloudHandler(this.config, db, this.apiKey)
- })
-
previewHandler = async (request: Request) => {
const {draftMode, cookies} = await import('next/headers.js')
const {searchParams} = new URL(request.url)
diff --git a/src/core/driver/TestDriver.ts b/src/core/driver/TestDriver.ts
index 085fa2eaf..87dfc459c 100644
--- a/src/core/driver/TestDriver.ts
+++ b/src/core/driver/TestDriver.ts
@@ -1,20 +1,21 @@
import sqlite from '@alinea/sqlite-wasm'
import {Database, Handler, JWTPreviews} from 'alinea/backend'
import {Store} from 'alinea/backend/Store'
+import type {AddressInfo} from 'node:net'
import {connect} from 'rado/driver/sql.js'
import {CMS} from '../CMS.js'
import {Config} from '../Config.js'
import {Connection} from '../Connection.js'
+import {createId} from '../Id.js'
import {Resolver} from '../Resolver.js'
import {Logger} from '../util/Logger.js'
-import {DefaultDriver} from './DefaultDriver.js'
export interface TestApi extends CMS {
db: Promise
connection(): Promise
}
-class TestDriver extends DefaultDriver implements TestApi {
+class TestDriver extends CMS implements TestApi {
store: Promise = sqlite().then(({Database}) =>
connect(new Database()).toAsync()
)
@@ -28,22 +29,32 @@ class TestDriver extends DefaultDriver implements TestApi {
config: this.config,
db,
previews: new JWTPreviews('test'),
- previewAuthToken: 'test'
+ previewAuthToken: 'test',
+ target: {
+ mutate: async ({mutations}) => {
+ return {commitHash: createId()}
+ }
+ },
+ media: {
+ async prepareUpload(file: string) {
+ const id = createId()
+ const serve = await listenForUpload()
+ return {
+ entryId: createId(),
+ location: `media/${file}_${id}`,
+ previewUrl: `media/${file}_${id}`,
+ upload: {
+ url: serve.url
+ }
+ }
+ }
+ }
})
return handler.connect({logger: new Logger('test')})
})
- async exportStore(outDir: string, data: Uint8Array) {}
- async readStore(): Promise {
- return this.store
- }
- async connection(): Promise {
- return this.handler
- }
-
- async resolver(): Promise {
- return this.handler
- }
+ connection = (): Promise => this.handler
+ resolver = (): Promise => this.handler
}
export function createTestCMS(
@@ -51,3 +62,18 @@ export function createTestCMS(
): Definition & TestApi & CMS {
return new TestDriver(config) as any
}
+
+async function listenForUpload(): Promise<{url: string}> {
+ const {createServer} = await import('http')
+ const server = createServer((req, res) => {
+ res.end()
+ server.close()
+ })
+ return new Promise(resolve => {
+ server.listen(0, () => {
+ resolve({
+ url: `http://localhost:${(server.address() as AddressInfo).port}`
+ })
+ })
+ })
+}
diff --git a/src/core/field/ListField.ts b/src/core/field/ListField.ts
index 8e02e6202..ab5a423d2 100644
--- a/src/core/field/ListField.ts
+++ b/src/core/field/ListField.ts
@@ -1,6 +1,8 @@
import {Field, FieldMeta, FieldOptions} from '../Field.js'
+import {createId} from '../Id.js'
import {ListMutator, ListRow, ListShape} from '../shape/ListShape.js'
import {RecordShape} from '../shape/RecordShape.js'
+import {generateKeyBetween} from '../util/FractionalIndexing.js'
export class ListField<
Row extends ListRow,
@@ -21,3 +23,44 @@ export class ListField<
})
}
}
+
+export type ListRowInput = Omit<
+ Extract,
+ 'type' | 'id' | 'index'
+>
+
+export class ListEditor {
+ constructor(private rows: Array = []) {}
+
+ insertAt(
+ insertAt: number,
+ type: Key,
+ row: ListRowInput
+ ) {
+ const id = createId()
+ const before = insertAt - 1
+ const after = before + 1
+ const keyA = this.rows[before]?.index || null
+ const keyB = this.rows[after]?.index || null
+ this.rows.push({
+ id,
+ index: generateKeyBetween(keyA, keyB),
+ type,
+ ...row
+ } as any)
+ return this
+ }
+
+ add(type: Key, row: ListRowInput) {
+ return this.insertAt(this.rows.length, type, row)
+ }
+
+ removeAt(index: number) {
+ this.rows.splice(index, 1)
+ return this
+ }
+
+ value() {
+ return this.rows
+ }
+}
diff --git a/src/core/field/RichTextField.ts b/src/core/field/RichTextField.ts
index b02708863..31e6fd74b 100644
--- a/src/core/field/RichTextField.ts
+++ b/src/core/field/RichTextField.ts
@@ -1,6 +1,7 @@
import {RichTextMutator, RichTextShape} from 'alinea/core'
+import {Parser} from 'htmlparser2'
import {Field, FieldMeta, FieldOptions} from '../Field.js'
-import {TextDoc} from '../TextDoc.js'
+import {TextDoc, TextNode} from '../TextDoc.js'
import {RecordShape} from '../shape/RecordShape.js'
export class RichTextField<
@@ -22,3 +23,82 @@ export class RichTextField<
})
}
}
+
+export class RichTextEditor {
+ constructor(private doc: TextDoc = []) {}
+
+ addHtml(html: string) {
+ this.doc.push(...parseHTML(html.trim()))
+ return this
+ }
+
+ value() {
+ return this.doc
+ }
+}
+
+function mapNode(
+ name: string,
+ attributes: Record
+): TextNode.Element | undefined {
+ switch (name) {
+ case 'h1':
+ case 'h2':
+ case 'h3':
+ case 'h4':
+ case 'h5':
+ case 'h6':
+ const type = 'heading'
+ const level = Number(name.slice(1))
+ return {type, level, content: []}
+ case 'p':
+ return {type: 'paragraph', content: []}
+ case 'b':
+ case 'strong':
+ return {type: 'bold', content: []}
+ case 'i':
+ case 'em':
+ return {type: 'italic', content: []}
+ case 'ul':
+ return {type: 'unorderedList', content: []}
+ case 'ol':
+ return {type: 'orderedList', content: []}
+ case 'li':
+ return {type: 'listItem', content: []}
+ case 'blockquote':
+ return {type: 'blockquote', content: []}
+ case 'hr':
+ return {type: 'horizontalRule'}
+ case 'br':
+ return {type: 'hardBreak'}
+ case 'small':
+ return {type: 'small', content: []}
+ case 'a':
+ // Todo: pick what we need
+ return {type: 'link', ...attributes, content: []}
+ }
+}
+
+export function parseHTML(html: string): TextDoc {
+ const doc: TextDoc = []
+ if (typeof html !== 'string') return doc
+ let parents: Array | undefined> = [doc]
+ const parser = new Parser({
+ onopentag(name, attributes) {
+ const node = mapNode(name, attributes)
+ const parent = parents[parents.length - 1]
+ if (node) parent?.push(node)
+ parents.push(node?.content)
+ },
+ ontext(text) {
+ const parent = parents[parents.length - 1]
+ parent?.push({type: 'text', text})
+ },
+ onclosetag() {
+ parents.pop()
+ }
+ })
+ parser.write(html)
+ parser.end()
+ return doc
+}
diff --git a/src/core/media/CreatePreview.browser.ts b/src/core/media/CreatePreview.browser.ts
new file mode 100644
index 000000000..74f1bc635
--- /dev/null
+++ b/src/core/media/CreatePreview.browser.ts
@@ -0,0 +1,68 @@
+import {base64} from 'alinea/core/util/Encoding'
+import {rgba, toHex} from 'color2k'
+import smartcrop from 'smartcrop'
+import {rgbaToThumbHash, thumbHashToAverageRGBA} from 'thumbhash'
+import type {ImagePreviewDetails} from './CreatePreview.js'
+
+export {ImagePreviewDetails}
+
+export async function createPreview(blob: Blob): Promise {
+ const url = URL.createObjectURL(blob)
+
+ // Load the image
+ const image = await new Promise((resolve, reject) => {
+ const image = new Image()
+ image.onload = () => resolve(image)
+ image.onerror = err => reject(err)
+ image.src = url
+ }).finally(() => URL.revokeObjectURL(url))
+
+ const size = Math.max(image.width, image.height)
+
+ // Scale the image to 100x100 maximum size
+ const thumbW = Math.round((100 * image.width) / size)
+ const thumbH = Math.round((100 * image.height) / size)
+ const thumbCanvas = document.createElement('canvas')
+ const thumbContext = thumbCanvas.getContext('2d')!
+ thumbCanvas.width = thumbW
+ thumbCanvas.height = thumbH
+ thumbContext.drawImage(image, 0, 0, thumbW, thumbH)
+
+ // Calculate thumbhash
+ const pixels = thumbContext.getImageData(0, 0, thumbW, thumbH)
+ const thumbHash = rgbaToThumbHash(thumbW, thumbH, pixels.data)
+
+ // Get the average color via thumbhash
+ const {r, g, b, a} = thumbHashToAverageRGBA(thumbHash)
+ const averageColor = toHex(rgba(r * 255, g * 255, b * 255, a))
+
+ // Create webp preview image
+ const previewW = Math.min(Math.round((160 * image.width) / size), image.width)
+ const previewH = Math.min(
+ Math.round((160 * image.height) / size),
+ image.height
+ )
+ const previewCanvas = document.createElement('canvas')
+ const previewContext = previewCanvas.getContext('2d')!
+ previewContext.imageSmoothingEnabled = true
+ previewContext.imageSmoothingQuality = 'high'
+ previewCanvas.width = previewW
+ previewCanvas.height = previewH
+ previewContext.drawImage(image, 0, 0, previewW, previewH)
+ const preview = previewCanvas.toDataURL('image/webp')
+
+ const crop = await smartcrop.crop(image, {width: 100, height: 100})
+ const focus = {
+ x: (crop.topCrop.x + crop.topCrop.width / 2) / image.width,
+ y: (crop.topCrop.y + crop.topCrop.height / 2) / image.height
+ }
+
+ return {
+ preview,
+ averageColor,
+ focus,
+ thumbHash: base64.stringify(thumbHash),
+ width: image.naturalWidth,
+ height: image.naturalHeight
+ }
+}
diff --git a/src/core/media/CreatePreview.ts b/src/core/media/CreatePreview.ts
new file mode 100644
index 000000000..4954eef1a
--- /dev/null
+++ b/src/core/media/CreatePreview.ts
@@ -0,0 +1,54 @@
+import {base64} from 'alinea/core/util/Encoding'
+import {rgba, toHex} from 'color2k'
+import {rgbaToThumbHash, thumbHashToAverageRGBA} from 'thumbhash'
+
+export interface ImagePreviewDetails {
+ width: number
+ height: number
+ averageColor: string
+ focus: {x: number; y: number}
+ thumbHash: string
+ preview: string
+}
+
+export async function createPreview(blob: Blob): Promise {
+ const {default: sharp} = await import('sharp' + '').catch(() => {
+ throw new Error(
+ `To create image previews server side you need to install the 'sharp' package`
+ )
+ })
+ const image = sharp(await blob.arrayBuffer())
+ const metadata = await image.metadata()
+ const width = metadata.width ?? 0
+ const height = metadata.height ?? 0
+
+ // Scale the image to 100x100 maximum size
+ const scaledImage = image.resize(100, 100, {
+ fit: 'inside'
+ })
+ const {data, info} = await scaledImage
+ .ensureAlpha()
+ .raw()
+ .toBuffer({resolveWithObject: true})
+ const thumbHash = rgbaToThumbHash(info.width, info.height, data)
+
+ // Get the average color via thumbhash
+ const {r, g, b, a} = thumbHashToAverageRGBA(thumbHash)
+ const averageColor = toHex(rgba(r * 255, g * 255, b * 255, a))
+
+ // Create webp preview image
+ const previewImage = image.resize(160, 160, {
+ fit: 'inside'
+ })
+ const previewBuffer = await previewImage.webp().toBuffer()
+ const preview = `data:image/webp;base64,${previewBuffer.toString('base64')}`
+
+ return {
+ width,
+ height,
+ thumbHash: base64.stringify(thumbHash),
+ averageColor,
+ preview,
+ focus: {x: 0.5, y: 0.5}
+ }
+}
diff --git a/src/core/media/IsImage.ts b/src/core/media/IsImage.ts
new file mode 100644
index 000000000..b9946fc03
--- /dev/null
+++ b/src/core/media/IsImage.ts
@@ -0,0 +1,16 @@
+export const imageExtensions = [
+ '.jpg',
+ '.jpeg',
+ '.png',
+ '.gif',
+ '.bmp',
+ '.webp',
+ '.avif',
+ '.heic',
+ '.svg'
+]
+
+export function isImage(pathOrExtension: string) {
+ const extension = pathOrExtension.toLowerCase().split('.').pop()
+ return extension && imageExtensions.includes(`.${extension}`)
+}
diff --git a/src/core/package.json b/src/core/package.json
index 90a89676f..bb8d8a885 100644
--- a/src/core/package.json
+++ b/src/core/package.json
@@ -3,6 +3,7 @@
"type": "module",
"devDependencies": {
"cito": "^0.2.0",
+ "htmlparser2": "^9.1.0",
"p-lazy": "^4.0.0",
"pretty-ms": "^8.0.0",
"yjs": "^13.6.11"
diff --git a/src/core/pages/Condition.ts b/src/core/pages/Condition.ts
new file mode 100644
index 000000000..cadd05f07
--- /dev/null
+++ b/src/core/pages/Condition.ts
@@ -0,0 +1,3 @@
+import {Expr, HasExpr} from './Expr.js'
+
+export type Condition = Expr | HasExpr
diff --git a/src/core/pages/Cursor.ts b/src/core/pages/Cursor.ts
index 776ef013b..e31186dba 100644
--- a/src/core/pages/Cursor.ts
+++ b/src/core/pages/Cursor.ts
@@ -1,9 +1,10 @@
import {array, boolean, enums, number, object, string} from 'cito'
import {Type} from '../Type.js'
import {entries} from '../util/Objects.js'
+import {Condition} from './Condition.js'
import {createExprData} from './CreateExprData.js'
import {createSelection} from './CreateSelection.js'
-import {EV, Expr, and} from './Expr.js'
+import {EV, Expr, HasExpr} from './Expr.js'
import {BinaryOp, ExprData} from './ExprData.js'
import {Projection} from './Projection.js'
import {Selection} from './Selection.js'
@@ -62,13 +63,19 @@ export interface Cursor {
}
declare const brand: unique symbol
-export class Cursor {
+export class Cursor implements HasExpr {
declare [brand]: T
constructor(data: CursorData) {
this[Cursor.Data] = data
}
+ [Expr.ToExpr]() {
+ // Todo: this has to take target into account
+ const {where} = this[Cursor.Data]
+ return Expr(where ?? ExprData.Value(true))
+ }
+
protected with(data: Partial): CursorData {
return {...this[Cursor.Data], ...data}
}
@@ -86,11 +93,59 @@ export namespace Cursor {
export const Data = Symbol.for('@alinea/Cursor.Data')
export class Find extends Cursor> {
- where(...where: Array>): Find {
+ where(...where: Array): Find {
const current = this[Cursor.Data].where
return new Find(
this.with({
- where: and(current ? Expr(current) : true, ...where)[Expr.Data]
+ where: Expr.and(current ? Expr(current) : true, ...where)[Expr.Data]
+ })
+ )
+ }
+
+ whereUrl(url: string): Find {
+ return new Find(
+ this.with({
+ where: Expr(ExprData.Field({}, 'url')).is(url)[Expr.Data]
+ })
+ )
+ }
+
+ wherePath(path: string): Find {
+ return new Find(
+ this.with({
+ where: Expr(ExprData.Field({}, 'path')).is(path)[Expr.Data]
+ })
+ )
+ }
+
+ whereParent(parentId: string): Find {
+ return new Find(
+ this.with({
+ where: Expr(ExprData.Field({}, 'parent')).is(parentId)[Expr.Data]
+ })
+ )
+ }
+
+ whereLocale(locale: string): Find {
+ return new Find(
+ this.with({
+ where: Expr(ExprData.Field({}, 'locale')).is(locale)[Expr.Data]
+ })
+ )
+ }
+
+ whereRoot(root: string): Find {
+ return new Find(
+ this.with({
+ where: Expr(ExprData.Field({}, 'root')).is(root)[Expr.Data]
+ })
+ )
+ }
+
+ whereWorkspace(workspace: string): Find {
+ return new Find(
+ this.with({
+ where: Expr(ExprData.Field({}, 'workspace')).is(workspace)[Expr.Data]
})
)
}
@@ -143,17 +198,35 @@ export namespace Cursor {
}
}
- export class Partial extends Find> {
+ export class Typed extends Find> {
constructor(
public type: Type,
- public partial: Partial>
+ public partial: Partial> = {}
) {
super({
target: {type},
- where: Partial.condition(type, partial)
+ where: Typed.condition(type, partial)
})
}
+ where(partial: Partial