mikser-io 8.3.0 → 8.3.1

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
@@ -208,8 +208,8 @@ For a working starter — config with a real plugin set, sample `documents/`, ex
208
208
  The shape mikser fits cleanly:
209
209
 
210
210
  - **Marketing sites with editorial teams** — content authors work in files (via their editor, a Git client, or `mikser-io-decap`), engineers ship features without negotiating with a CMS schema, the site stays portable.
211
- - **Multilingual publishing platforms** — the `useHref()` / `useAlternates()` pattern in `sdk-vue` decouples logical references from per-locale URLs. One source tree, many language deployments.
212
- - **Content-heavy product catalogues** — `documents` + `mikser-io-schemas` + `data` plugin + a Vue frontend = typed product listings with live updates, semantic search via `vector`, and static-CDN-friendly JSON snapshots all at once.
211
+ - **Multilingual publishing platforms** — the `useHref()` / `useAlternates()` pattern decouples logical references from per-locale URLs. One source tree, many language deployments.
212
+ - **Content-heavy product catalogues** — `documents` + `mikser-io-schemas` + `data` plugin + a Frontend Framework = typed product listings with live updates, semantic search via `vector`, and static-CDN-friendly JSON snapshots all at once.
213
213
  - **AI-augmented media pipelines** — `assets` plugin presets call out to Replicate / OpenAI / local models to upscale images, transcribe audio, transcode video. The pipeline is JS code, so anything Node can do is in scope.
214
214
  - **Mixed-output publishing** — the same source document renders to HTML, PDF (via `post-pdf`), MJML email (via `post-mjml`), and JSON snapshots. One catalog, many output formats, all concurrent.
215
215
  - **Headless backends for static frontends** — pair the `api` plugin with `sdk-api` for SSE-driven live frontends; pair the `data` plugin output with any static host for pre-rendered consumption.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mikser-io",
3
- "version": "8.3.0",
3
+ "version": "8.3.1",
4
4
  "description": "<p align=\"center\"> <img src=\"mikser-lockup-stacked.svg\" alt=\"mikser\" width=\"198\" /> </p>",
5
5
  "main": "index.js",
6
6
  "scripts": {
package/src/engine.js CHANGED
@@ -9,7 +9,7 @@ import { onInitialize, onInitialized, onRender, onCancel, onCancelled, onFinaliz
9
9
  import { useJournal, updateEntry } from './journal.js'
10
10
  import { globby } from 'globby'
11
11
  import { OPERATION, TASKS } from './constants.js'
12
- import { changeExtension, formatErrorContext, projectMeta } from './utils.js'
12
+ import { changeExtension, formatErrorContext, projectMeta, lookupKeys } from './utils.js'
13
13
  import render from './render.js'
14
14
  import postprocess, { loadPlugin as loadPostPlugin } from './postprocess.js'
15
15
  import map from 'p-map'
@@ -221,8 +221,16 @@ export async function setup(options) {
221
221
  // cycle's CREATE/UPDATE/DELETE journal entries — RENDER entries
222
222
  // are this cycle's work, not the trigger.
223
223
  //
224
- // - `mutatedRefs` is a Set of ids (and hrefs) — fast membership
225
- // check for layout/partial/$-ref edges.
224
+ // - `mutatedRefs` is a Map<key, Set<lang|null>>: the keys are
225
+ // the ids / hrefs / id-minus-extension forms a refClosure
226
+ // entry might target; the values are the set of languages
227
+ // that touched that key this cycle (null for entities with
228
+ // no meta.lang). The Map shape preserves the fast `.has(key)`
229
+ // membership check the existing skip logic relied on AND
230
+ // adds language information so multilingual sites don't
231
+ // over-invalidate. When a French author entity changes,
232
+ // English posts that reference the same /authors/<name>
233
+ // href don't re-render — the lang sets disagree.
226
234
  // - `currentHashes` carries the current input hash for each
227
235
  // mutated entity. Cold-start file discovery emits CREATE for
228
236
  // every file even when content didn't change; without the
@@ -231,26 +239,40 @@ export async function setup(options) {
231
239
  // - `mutatedEntities` carries the entity payloads themselves so
232
240
  // the query-match check can call `sift(filter)` against each
233
241
  // mutation to decide whether a stored query dep is hit.
234
- const mutatedRefs = new Set()
242
+ const mutatedRefs = new Map()
235
243
  const currentHashes = new Map()
236
244
  const mutatedEntities = new Map()
237
245
  for await (let { entity, operation } of useJournal('Manifest mutations', [OPERATION.CREATE, OPERATION.UPDATE, OPERATION.DELETE])) {
238
246
  if (!entity?.id) continue
239
- mutatedRefs.add(entity.id)
240
- if (entity.meta?.href) mutatedRefs.add(entity.meta.href)
241
247
  mutatedEntities.set(entity.id, entity)
248
+ const hash = operation === OPERATION.DELETE ? null : inputHashOf(entity)
249
+ const lang = entity.meta?.lang ?? null
250
+ // Expand the mutated entity into every form a refClosure
251
+ // entry might target — id, meta.href, AND id-minus-extension
252
+ // — via lookupKeys. Without the stripped form, a refClosure
253
+ // recorded against the natural author/blog-post pattern
254
+ // (`$author: /documents/authors/dick`) would never match
255
+ // the mutated `/documents/authors/dick.yml` here and
256
+ // manifest.shouldSkip would silently return true, pinning
257
+ // the post's output to bytes that reference stale author
258
+ // data. refs.inverseClosureOf and catalog.findEntity both
259
+ // use the same extension-tolerant resolution; the manifest
260
+ // layer has to match.
261
+ //
262
+ // Each key carries the language tag of the mutation so
263
+ // shouldSkip can constrain by language compatibility.
264
+ //
242
265
  // For DELETE we set `null` as the current hash so manifest.
243
266
  // shouldSkip can distinguish "target was deleted from the
244
267
  // catalog" from "target wasn't in this cycle's mutations
245
268
  // at all." Without this distinction, a consumer whose
246
269
  // refClosure points at a deleted partial/layout would
247
- // silently skip re-rendering — leaving the disk output
248
- // pinned to bytes that reference something no longer in
249
- // the catalog.
250
- currentHashes.set(
251
- entity.id,
252
- operation === OPERATION.DELETE ? null : inputHashOf(entity),
253
- )
270
+ // silently skip re-rendering.
271
+ for (const key of lookupKeys(entity)) {
272
+ if (!mutatedRefs.has(key)) mutatedRefs.set(key, new Set())
273
+ mutatedRefs.get(key).add(lang)
274
+ currentHashes.set(key, hash)
275
+ }
254
276
  }
255
277
  let skipped = 0
256
278
 
package/src/manifest.js CHANGED
@@ -253,6 +253,7 @@ export function createManifest(db) {
253
253
  if (!snapshot?.inputHash) return false
254
254
  if (inputHashOf(entity) !== snapshot.inputHash) return false
255
255
  if (!snapshot.refClosure?.length) return true
256
+ const sourceLang = entity?.meta?.lang ?? null
256
257
  for (const entry of snapshot.refClosure) {
257
258
  if (entry.kind === 'query') {
258
259
  if (!entry.filter) return false
@@ -264,6 +265,20 @@ export function createManifest(db) {
264
265
  continue
265
266
  }
266
267
  if (!mutatedRefs?.has(entry.target)) continue
268
+ // Language scope: if the source has a meta.lang AND the
269
+ // mutation came from a different meta.lang, the ref
270
+ // doesn't actually depend on what changed. A `null` lang
271
+ // in the mutation set means a shared (un-localized)
272
+ // entity touched the same key — that does invalidate
273
+ // language-specific sources because the shared entity
274
+ // is the only variant. Sources without a meta.lang are
275
+ // language-agnostic and accept any mutation lang.
276
+ if (sourceLang) {
277
+ const mutatedLangs = mutatedRefs.get(entry.target)
278
+ if (mutatedLangs && !mutatedLangs.has(sourceLang) && !mutatedLangs.has(null)) {
279
+ continue
280
+ }
281
+ }
267
282
  if (!entry.hash) return false
268
283
  const currentHash = currentHashes?.get(entry.target)
269
284
  if (currentHash === undefined) continue