mechanica 2.2.1 → 2.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -156,6 +156,8 @@ Other `defineBlock` options: `icon`, `description` and `order` for the palette;
156
156
 
157
157
  Give every block meaningful `previewData`: it drives the palette's hover preview, the standalone preview route and `mechanica shot`. A `$slots` key fills slots with child blocks, e.g. `$slots: { default: [{ blockId: 'card', data: { title: 'One' } }] }`.
158
158
 
159
+ The exported site is prerendered, and the browser **hydrates** that HTML rather than rendering it again, so a block must render the same on the server and in the browser: read `window`, `document` or `matchMedia` only in `onMounted` (or behind a client-only flag set there), never in `setup` or the template. The dev server mounts into an empty page and never hydrates, so a mismatch shows only in the built site. To check, build with `MECHANICA_HYDRATION_DEBUG=1 mechanica build` and watch the browser console on the exported pages: each mismatch is logged with the element involved.
160
+
159
161
  ## Pages
160
162
 
161
163
  Each page is a `.page.md` file under `.mech/pages/`, and its path is the URL: `about.page.md` → `/about`, `blog/index.page.md` → `/blog`, `index.page.md` → `/`.
@@ -254,7 +256,7 @@ Editors can set a focal point and crop from the image field's **Position / Crop*
254
256
 
255
257
  ## Links and navigation
256
258
 
257
- `<Link :to="…">` takes a path string or a `smartLink` value. Internal links navigate client-side without a full reload, get an `is-active` class on the current page, and add the language prefix on multi-language sites. `useRouter().push(path)` navigates from code, and `useRoute().path` is the current path.
259
+ `<Link :to="…">` takes a path string or a `smartLink` value. Internal links navigate client-side without a full reload, get an `is-active` class on the current page, and add the language prefix on multi-language sites. `useRouter().push(path)` navigates from code, and `useRoute().path` is the current path. A target the client-side navigation cannot take over (an error response, or a page without Mechanica state) is opened as a normal full-page navigation instead.
258
260
 
259
261
  ## Listing pages and pagination
260
262
 
@@ -339,6 +341,8 @@ Every export also warns about missing titles and descriptions, pages without exa
339
341
 
340
342
  By default all blocks share one `blocks` chunk, cached across pages. Mark heavy, rarely used blocks with `chunk: 'charts'` in `defineBlock` to split them into their own chunk, or set the plugin's `blockChunks: 'per-block'`. The export adds per-page `<link rel="modulepreload">` and stylesheet tags, so each page loads exactly the block code it uses.
341
343
 
344
+ Code outside blocks follows Vite's defaults. To group it yourself, add your own groups under `build.rollupOptions.output.codeSplitting.groups` in `vite.config.ts`: they are kept next to Mechanica's blocks group and listed before it, so at equal `priority` yours win. The plugin sets `includeDependenciesRecursively: false`, so a group holds exactly the modules its `test` matches.
345
+
342
346
  ## Checking your work visually
343
347
 
344
348
  ```bash
@@ -1,5 +1,5 @@
1
1
  import { computed, defineComponent, h, inject, nextTick, shallowReactive, shallowRef } from "vue";
2
- import { localePath, resolveComposedTemplate, templateBlockIds, walkTree } from "mechanica-shared";
2
+ import { localePath, paginationVariantPath, resolveComposedTemplate, templateBlockIds, walkTree } from "mechanica-shared";
3
3
  //#region src/core/state.ts
4
4
  /** Vue injection key for the {@link MechanicaContext}. */
5
5
  var mechanicaKey = Symbol("mechanica");
@@ -36,7 +36,7 @@ function createRouter(content, data, options) {
36
36
  if (!baseUrl) return path;
37
37
  return path.startsWith(baseUrl) ? path.slice(baseUrl.length) : path;
38
38
  };
39
- const initialPath = isBrowser ? normalizePath(stripBase(window.location.pathname)) : "/";
39
+ const initialPath = isBrowser ? normalizePath(stripBase(window.location.pathname)) : normalizePath(options.initialPath ?? "/");
40
40
  const currentRoute = shallowReactive({ path: initialPath });
41
41
  const cache = /* @__PURE__ */ new Map();
42
42
  const inflight = /* @__PURE__ */ new Map();
@@ -45,12 +45,14 @@ function createRouter(content, data, options) {
45
45
  const existing = inflight.get(path);
46
46
  if (existing) return existing;
47
47
  const run = async () => {
48
- const html = await fetch(path).then((res) => res.text());
48
+ const res = await fetch(path);
49
+ const html = res.ok ? await res.text() : "";
49
50
  const match = html.match(/window\.state\s*=\s*(\{.+?\})<\/script>/s)?.[1];
50
- const state = match ? JSON.parse(match) : {
51
- content: [],
52
- data: {}
53
- };
51
+ if (!match) {
52
+ if (isBrowser) window.location.assign(path);
53
+ return;
54
+ }
55
+ const state = JSON.parse(match);
54
56
  state.page = {
55
57
  ...state.page,
56
58
  title: html.match(/<title>(.*?)<\/title>/)?.[1]
@@ -869,6 +871,25 @@ function runtimeLayoutNames() {
869
871
  return window[RUNTIME_KEY]?.layoutNames ?? [];
870
872
  }
871
873
  //#endregion
874
+ //#region src/core/page-path.ts
875
+ /**
876
+ * The address a page's state is served at: its logical `path` under the
877
+ * locale prefix (`/about` -> `/ru/about`) plus the pagination variant suffix
878
+ * (`/blog` -> `/blog/2`). This is what the browser shows in the address bar for
879
+ * that state, so the server-side router starts there and `<Link>` resolves the
880
+ * same `is-active` on both sides of hydration.
881
+ *
882
+ * The export already serves a variant under its own path (`page.path` is
883
+ * `/blog/2` there, while dev keeps the base `/blog`); a suffix that is
884
+ * already present is kept, as `usePagination().pathFor` does.
885
+ */
886
+ function servedPagePath(page, locales) {
887
+ const localized = localePath(page?.path || "/", page?.locale, locales);
888
+ const variant = page?.pagination?.page ?? 1;
889
+ if (variant > 1 && localized.endsWith(`/${variant}`)) return localized;
890
+ return paginationVariantPath(localized, variant);
891
+ }
892
+ //#endregion
872
893
  //#region src/core/create-mechanica.ts
873
894
  /**
874
895
  * Vue plugin that wires the Mechanica runtime: page content, shared data, the
@@ -903,6 +924,7 @@ function createMechanica(options = {}) {
903
924
  router: createRouter(content, data, {
904
925
  mode,
905
926
  baseUrl: initial?.baseUrl,
927
+ initialPath: servedPagePath(initial?.page, initial?.locales),
906
928
  ensureBlocks: loaders ? async (next) => {
907
929
  await loadBlocks(loaders, next, blocks, composedMap);
908
930
  } : void 0,
@@ -927,7 +949,7 @@ function createMechanica(options = {}) {
927
949
  mergeData: (incoming) => mergeData(context.data, incoming),
928
950
  setPage: (next) => {
929
951
  applyPage(next);
930
- if (next.path) context.router.currentRoute.path = context.router.normalizePath(next.path);
952
+ if (next.path) context.router.currentRoute.path = context.router.normalizePath(servedPagePath(next, initial?.locales));
931
953
  }
932
954
  });
933
955
  } };
@@ -1,4 +1,4 @@
1
- import { a as renderBlocks, t as createMechanica } from "./create-mechanica-CYaKXuQd.js";
1
+ import { a as renderBlocks, t as createMechanica } from "./create-mechanica-CMcmeOtG.js";
2
2
  import { createApp, defineComponent, h } from "vue";
3
3
  import { areFieldSchemasRegistered, buildPreviewData, registerFieldSchemas } from "mechanica-shared";
4
4
  import { unfoldSchema } from "compact-json-schema";
package/dist/composer.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import './editor.css';
2
- import { a as renderBlocks, l as ANCHOR_H, t as createMechanica, u as ANCHOR_V } from "./chunks/create-mechanica-CYaKXuQd.js";
2
+ import { a as renderBlocks, l as ANCHOR_H, t as createMechanica, u as ANCHOR_V } from "./chunks/create-mechanica-CMcmeOtG.js";
3
3
  import { A as SmartLinkField_default, C as removeBlock, I as editorBackend, L as createSaveQueue, R as VSelect_default, S as relocateBlock, T as registerBuiltinFieldEditors, _ as findBlock, a as FieldControl_default, b as moveBlock, f as toBlockMeta, g as ensureSlotList, h as duplicateBlock, i as SchemaField_default, m as cloneBlock, n as normalizeTemplate, o as shortcutChar, p as humanize, v as findParentList, w as uid, x as placeBlock, y as findParentSlot, z as VPopover_default } from "./chunks/editor-DoYHG0mS.js";
4
4
  import { d as _plugin_vue_export_helper_default, l as VIcon_default, u as contents } from "./chunks/ImagePickerDialog-DJGhtW3r.js";
5
5
  import { Fragment, Teleport, computed, createApp, createBlock, createCommentVNode, createElementBlock, createElementVNode, createTextVNode, createVNode, defineComponent, inject, nextTick, normalizeClass, normalizeStyle, onBeforeUnmount, onMounted, onScopeDispose, openBlock, provide, reactive, ref, renderList, renderSlot, resolveComponent, toDisplayString, unref, vModelText, watch, withCtx, withDirectives, withKeys, withModifiers } from "vue";
package/dist/editor.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import './editor.css';
2
- import { _ as mechanicaKey, a as renderBlocks, c as registerElements, i as createComposedComponent, m as createRouter, n as pushStateUpdate, r as runtimeLayoutNames } from "./chunks/create-mechanica-CYaKXuQd.js";
3
- import { i as getDataEntries, t as buildPreviewContent } from "./chunks/preview-CbUxh2gL.js";
2
+ import { _ as mechanicaKey, a as renderBlocks, c as registerElements, i as createComposedComponent, m as createRouter, n as pushStateUpdate, r as runtimeLayoutNames } from "./chunks/create-mechanica-CMcmeOtG.js";
3
+ import { i as getDataEntries, t as buildPreviewContent } from "./chunks/preview-BrtQ0KpO.js";
4
4
  import { C as removeBlock, D as contextMenuKey, E as registerRichTextWidgets, F as pathFromName, I as editorBackend, L as createSaveQueue, M as filterPages, N as groupPagesByFolder, O as createContextMenu, P as pageThumbUrl, R as VSelect_default, S as relocateBlock, T as registerBuiltinFieldEditors, _ as findBlock, b as moveBlock, c as blockAvailableIn, d as isUnderFolder, f as toBlockMeta, h as duplicateBlock, j as fetchPages, k as VSegmented_default, l as compareBlocks, m as cloneBlock, o as shortcutChar, p as humanize, r as SchemaForm_default, s as blockAvailableForLayout, t as createRootFrame, u as createContentBlock, w as uid, x as placeBlock, y as findParentSlot, z as VPopover_default } from "./chunks/editor-DoYHG0mS.js";
5
5
  import { c as useDialog, d as _plugin_vue_export_helper_default, l as VIcon_default, n as VDialog_default, o as createDialogStore, s as dialogKey } from "./chunks/ImagePickerDialog-DJGhtW3r.js";
6
6
  import { Fragment, Teleport, Transition, TransitionGroup, computed, createApp, createBlock, createCommentVNode, createElementBlock, createElementVNode, createTextVNode, createVNode, defineComponent, inject, markRaw, mergeProps, nextTick, normalizeClass, normalizeStyle, onBeforeUnmount, onErrorCaptured, onMounted, onScopeDispose, openBlock, provide, reactive, ref, renderList, resolveComponent, resolveDynamicComponent, shallowRef, toDisplayString, unref, useTemplateRef, vModelCheckbox, vModelText, watch, withCtx, withDirectives, withModifiers } from "vue";
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { _ as mechanicaKey, a as renderBlocks, c as registerElements, d as Link, f as loadBlocks, g as useRouter, h as useRoute, i as createComposedComponent, m as createRouter, o as elements, p as usedBlockIds, s as isElementBlock, t as createMechanica } from "./chunks/create-mechanica-CYaKXuQd.js";
2
- import { a as registerDataEntry, i as getDataEntries, n as mountPreviewApp, r as clearDataEntries, t as buildPreviewContent } from "./chunks/preview-CbUxh2gL.js";
1
+ import { _ as mechanicaKey, a as renderBlocks, c as registerElements, d as Link, f as loadBlocks, g as useRouter, h as useRoute, i as createComposedComponent, m as createRouter, o as elements, p as usedBlockIds, s as isElementBlock, t as createMechanica } from "./chunks/create-mechanica-CMcmeOtG.js";
2
+ import { a as registerDataEntry, i as getDataEntries, n as mountPreviewApp, r as clearDataEntries, t as buildPreviewContent } from "./chunks/preview-BrtQ0KpO.js";
3
3
  import { computed, createApp, createSSRApp, defineComponent, h, inject, isReactive, onMounted, onServerPrefetch, reactive, ref, shallowReactive } from "vue";
4
4
  import { localePath, normalizeClassManifest, passDefaultValue } from "mechanica-shared";
5
5
  import { unfoldSchema } from "compact-json-schema";
@@ -205,13 +205,13 @@ var Image = defineComponent({
205
205
  const cropped = !!image.croppedSrc;
206
206
  return h("img", {
207
207
  ref: el,
208
+ loading: props.eager ? "eager" : "lazy",
209
+ fetchpriority: props.eager ? "high" : void 0,
210
+ decoding: "async",
208
211
  src: cropped ? image.croppedSrc : image.src,
209
212
  alt: image.alt ?? "",
210
213
  width: (cropped ? image.croppedWidth : image.width) ?? void 0,
211
214
  height: (cropped ? image.croppedHeight : image.height) ?? void 0,
212
- loading: props.eager ? "eager" : "lazy",
213
- fetchpriority: props.eager ? "high" : void 0,
214
- decoding: "async",
215
215
  style: lqipStyle.value,
216
216
  onLoad: () => {
217
217
  loaded.value = true;
package/dist/plugin.js CHANGED
@@ -660,6 +660,7 @@ function generateComponentsModule(manifestSpecifier) {
660
660
  * `createApp().mount()` with an explicit, generated entry.
661
661
  */
662
662
  function generateClientEntry(options) {
663
+ const hydrate = options.hydrate ? "ssr: true, " : "";
663
664
  if (options.lazy) return [
664
665
  `import 'virtual:mechanica/elements.css'`,
665
666
  `import definition from ${JSON.stringify(options.userEntry)}`,
@@ -672,7 +673,7 @@ function generateClientEntry(options) {
672
673
  `const composedMap = new Map(composedList.map((def) => [def.id, def]))`,
673
674
  `loadBlocks(blockLoaders, state.content ?? [], undefined, composedMap).then((blocks) => {`,
674
675
  ` registerComponents(blocks)`,
675
- ` createMechanicaApp(definition, { mode: ${JSON.stringify(options.mode)}, state, blocks, blockLoaders, composed: composedList })`,
676
+ ` createMechanicaApp(definition, { ${hydrate}mode: ${JSON.stringify(options.mode)}, state, blocks, blockLoaders, composed: composedList })`,
676
677
  ` .mount(${JSON.stringify(options.mount)})`,
677
678
  `})`,
678
679
  ``
@@ -697,7 +698,7 @@ function generateClientEntry(options) {
697
698
  `const state = window.state ?? { content: [], data: {} }`,
698
699
  `registerComponents(blocksMap)`,
699
700
  ...resolveQuery,
700
- `createMechanicaApp(definition, { mode: ${JSON.stringify(options.mode)}, state, blocks: blocksMap, composed: composedList${options.mode === "dev" ? ", resolveQuery" : ""} })`,
701
+ `createMechanicaApp(definition, { ${hydrate}mode: ${JSON.stringify(options.mode)}, state, blocks: blocksMap, composed: composedList${options.mode === "dev" ? ", resolveQuery" : ""} })`,
701
702
  ` .mount(${JSON.stringify(options.mount)})`,
702
703
  ``
703
704
  ].join("\n");
@@ -1118,18 +1119,22 @@ function mechanica(options = {}) {
1118
1119
  config(_config, env) {
1119
1120
  if (env.command !== "build" || env.isSsrBuild) return;
1120
1121
  if (process.env.MECHANICA_EDITOR_BUILD) return;
1121
- return { build: { rollupOptions: { output: { codeSplitting: {
1122
- includeDependenciesRecursively: false,
1123
- groups: [{
1124
- ...supportsGroupDebugName(this.meta?.rolldownVersion) ? { debugName: "mechanica:blocks" } : {},
1125
- name: (id, ctx) => {
1126
- const own = blockChunkNames.get(slash(id.split("?")[0]));
1127
- if (own !== void 0) return groupName(own);
1128
- const g = sharedGroupOf(id, ctx, /* @__PURE__ */ new Set());
1129
- return g === "skip" ? null : g;
1130
- }
1131
- }]
1132
- } } } } };
1122
+ const label = supportsGroupDebugName(this.meta?.rolldownVersion) ? { debugName: "mechanica:blocks" } : {};
1123
+ return {
1124
+ ...process.env.MECHANICA_HYDRATION_DEBUG ? { define: { __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: "true" } } : {},
1125
+ build: { rollupOptions: { output: { codeSplitting: {
1126
+ includeDependenciesRecursively: false,
1127
+ groups: [{
1128
+ ...label,
1129
+ name: (id, ctx) => {
1130
+ const own = blockChunkNames.get(slash(id.split("?")[0]));
1131
+ if (own !== void 0) return groupName(own);
1132
+ const g = sharedGroupOf(id, ctx, /* @__PURE__ */ new Set());
1133
+ return g === "skip" ? null : g;
1134
+ }
1135
+ }]
1136
+ } } } }
1137
+ };
1133
1138
  },
1134
1139
  configResolved(config) {
1135
1140
  blocksDir = join(config.root, options.blocksDir ?? "src/blocks");
@@ -1196,7 +1201,8 @@ function mechanica(options = {}) {
1196
1201
  userEntry,
1197
1202
  mount,
1198
1203
  mode: isDev || isEditorBuild ? "dev" : "client",
1199
- lazy: isClientBuild
1204
+ lazy: isClientBuild,
1205
+ hydrate: isClientBuild
1200
1206
  });
1201
1207
  if (id === RESOLVED_SSR_ID) return generateSsrEntry({
1202
1208
  userEntry,
@@ -0,0 +1,13 @@
1
+ import { type LocalesConfig, type PageMeta } from 'mechanica-shared';
2
+ /**
3
+ * The address a page's state is served at: its logical `path` under the
4
+ * locale prefix (`/about` -> `/ru/about`) plus the pagination variant suffix
5
+ * (`/blog` -> `/blog/2`). This is what the browser shows in the address bar for
6
+ * that state, so the server-side router starts there and `<Link>` resolves the
7
+ * same `is-active` on both sides of hydration.
8
+ *
9
+ * The export already serves a variant under its own path (`page.path` is
10
+ * `/blog/2` there, while dev keeps the base `/blog`); a suffix that is
11
+ * already present is kept, as `usePagination().pathFor` does.
12
+ */
13
+ export declare function servedPagePath(page: PageMeta | undefined, locales?: LocalesConfig | null): string;
@@ -27,6 +27,13 @@ export interface CreateRouterOptions {
27
27
  * runtime context.
28
28
  */
29
29
  applyState?: (state: State) => void;
30
+ /**
31
+ * The address the rendered page is served at (locale prefix and pagination
32
+ * variant included). The server has no address bar, so this seeds its
33
+ * current route: `<Link>` then computes the same `is-active` the client
34
+ * will on hydration. The client reads `window.location` instead.
35
+ */
36
+ initialPath?: string;
30
37
  }
31
38
  /**
32
39
  * Create the client-side router. On the server it is inert (no listeners, no
@@ -12,6 +12,15 @@ export interface ClientEntryOptions {
12
12
  * components while unused blocks stay in their own chunks, never downloaded.
13
13
  */
14
14
  lazy?: boolean;
15
+ /**
16
+ * Hydrate the server-rendered HTML (`createSSRApp`) instead of mounting
17
+ * into an empty container. The production client build: the export
18
+ * prerenders every page, and re-rendering would throw that DOM away and
19
+ * refetch every image at once (elements the client creates start their
20
+ * fetch before `loading="lazy"` applies). Dev and the editor build mount
21
+ * into an empty `#app`, so they keep `createApp`.
22
+ */
23
+ hydrate?: boolean;
15
24
  }
16
25
  /**
17
26
  * Generate the client entry that mounts the user's app from their
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mechanica",
3
- "version": "2.2.1",
3
+ "version": "2.2.2",
4
4
  "description": "Build Vue 3 websites with a visual block editor",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -70,7 +70,7 @@
70
70
  "prepublishOnly": "bun ../../scripts/ensure-bun-publish.js && bun run build"
71
71
  },
72
72
  "dependencies": {
73
- "mechanica-shared": "2.2.1",
73
+ "mechanica-shared": "2.2.2",
74
74
  "@vue/compiler-sfc": "^3.5.38",
75
75
  "compact-json-schema": "^0.1.5",
76
76
  "estree-walker": "^3.0.3",