nodality 1.0.166 → 1.0.167
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/bin/nodality.js +250 -0
- package/layout/animator.js +1 -1
- package/layout/audio.js +1 -1
- package/layout/audionew.js +1 -1
- package/layout/base-2.js +1 -1
- package/layout/base.js +1 -1
- package/layout/beta-desktop-bar.js +1 -1
- package/layout/beta-mobile-bar.js +1 -1
- package/layout/box.js +1 -1
- package/layout/button.js +1 -1
- package/layout/cards.js +1 -1
- package/layout/center.js +1 -1
- package/layout/checkbox.js +1 -1
- package/layout/circle.js +1 -1
- package/layout/clean-row.js +1 -1
- package/layout/code.js +1 -1
- package/layout/container.js +1 -1
- package/layout/custom.js +1 -1
- package/layout/div-image.js +1 -1
- package/layout/dropdown-2025.js +1 -1
- package/layout/dropdown.js +1 -1
- package/layout/empty-element.js +1 -1
- package/layout/external-stylesheet.js +1 -1
- package/layout/flex-card.js +1 -1
- package/layout/flex-grid.js +1 -1
- package/layout/flex-row.js +1 -1
- package/layout/footer.js +1 -1
- package/layout/form-components/custom.js +1 -1
- package/layout/form-components/data-list.js +1 -1
- package/layout/form-components/floating-input.js +1 -1
- package/layout/form-components/form-all.js +1 -1
- package/layout/form-components/form.js +1 -1
- package/layout/form-components/image-picker.js +1 -1
- package/layout/form-components/picker.js +1 -1
- package/layout/form-components/radio.js +1 -1
- package/layout/form-components/radiogroup.js +1 -1
- package/layout/form-components/range.js +1 -1
- package/layout/free.js +1 -1
- package/layout/grid-new.js +1 -1
- package/layout/grid-switcher.js +1 -1
- package/layout/grid.js +1 -1
- package/layout/group.js +1 -1
- package/layout/header.js +1 -1
- package/layout/horizontal-scroller.js +1 -1
- package/layout/image-old.js +1 -1
- package/layout/image.js +1 -1
- package/layout/index.js +1 -1
- package/layout/label.js +1 -1
- package/layout/link.js +1 -1
- package/layout/list-OLD.js +1 -1
- package/layout/list.js +1 -1
- package/layout/meta-adder.js +1 -1
- package/layout/modal-2025.js +1 -1
- package/layout/modernwrap.js +1 -1
- package/layout/multiswitcher.js +1 -1
- package/layout/multiswitcherBeta.js +1 -1
- package/layout/nav-bar.js +1 -1
- package/layout/nav-factor/custom-div.js +1 -1
- package/layout/navBar-OLD.js +1 -1
- package/layout/new-flat-adder.js +1 -1
- package/layout/new-nav-bar.js +1 -1
- package/layout/offset-container.js +1 -1
- package/layout/polygon.js +1 -1
- package/layout/prerender-site.js +1 -1
- package/layout/prerender.js +1 -1
- package/layout/progress.js +1 -1
- package/layout/row.js +1 -1
- package/layout/saved-new-nav-bar.js +1 -1
- package/layout/scroll-video.js +1 -1
- package/layout/side-bar.js +1 -1
- package/layout/side-nav-bar.js +1 -1
- package/layout/simple-bar.js +1 -1
- package/layout/slider-2025.js +1 -1
- package/layout/spacer.js +1 -1
- package/layout/stack.js +1 -1
- package/layout/styler.js +1 -1
- package/layout/svg.js +1 -1
- package/layout/switcher.js +1 -1
- package/layout/table.js +1 -1
- package/layout/text-field.js +1 -1
- package/layout/text.js +1 -1
- package/layout/ulist.js +1 -1
- package/layout/video.js +1 -1
- package/layout/without-new.js +1 -1
- package/layout/wrap.js +1 -1
- package/layout/zoom-card.js +1 -1
- package/lib/card-getter.js +1 -1
- package/lib/designer.js +1 -1
- package/lib/element-mapper.js +1 -1
- package/lib/keyframe-animation.js +1 -1
- package/lib/link-getter.js +1 -1
- package/lib/scroll-video.js +1 -1
- package/lib/stacker.js +1 -1
- package/lib/theme.js +1 -1
- package/lib/transform-anim.js +1 -1
- package/package.json +1 -1
package/bin/nodality.js
CHANGED
|
@@ -40,6 +40,9 @@ function showUsage() {
|
|
|
40
40
|
console.log(`Usage:
|
|
41
41
|
nodality prerender [flags] # SSG: render upload/*.html in place
|
|
42
42
|
nodality compile [src/<file>.js] [flags] # Emit Designer output as a companion file
|
|
43
|
+
nodality fanout # Generate per-item pages from JSON (reads
|
|
44
|
+
# the \`fanout\` block in nodality.config.json;
|
|
45
|
+
# runs automatically before \`prerender\`)
|
|
43
46
|
nodality help
|
|
44
47
|
|
|
45
48
|
Compile flags:
|
|
@@ -162,6 +165,218 @@ function loadConfigFile(cwd) {
|
|
|
162
165
|
}
|
|
163
166
|
}
|
|
164
167
|
|
|
168
|
+
// ─── Fanout (data-driven page expansion) ────────────────────────
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Generate one HTML + entry-script pair per item in a JSON dataset.
|
|
172
|
+
* The classic use case is a "detail" template that takes a query
|
|
173
|
+
* parameter at runtime (`/detail.html?id=helix`) and the SEO problem
|
|
174
|
+
* that crawlers see an empty mount for every product. Fanout reads
|
|
175
|
+
* the data once at build time, walks an array within it, and writes
|
|
176
|
+
* a per-item static page that the prerender step then turns into
|
|
177
|
+
* fully populated HTML.
|
|
178
|
+
*
|
|
179
|
+
* Configured in `nodality.config.json`:
|
|
180
|
+
*
|
|
181
|
+
* "fanout": [
|
|
182
|
+
* {
|
|
183
|
+
* "template": "detail.html",
|
|
184
|
+
* "data": "products.json",
|
|
185
|
+
* "items": "categories[].products", // dot path with [] for flatMap
|
|
186
|
+
* "id": "id", // field on each item
|
|
187
|
+
* "title": "SLS3 — {name}", // {name} = item.name, {id} etc.
|
|
188
|
+
* "entry": "pages/detail.js", // exported renderDetailPage(id)
|
|
189
|
+
* "bodyAttr": "data-product-id" // attr on <body> for the id
|
|
190
|
+
* }
|
|
191
|
+
* ]
|
|
192
|
+
*
|
|
193
|
+
* Output:
|
|
194
|
+
* • `upload/<basename>-<id>.html` — clone of the template, with
|
|
195
|
+
* `<title>` substituted and `<body data-...="<id>">`.
|
|
196
|
+
* • `upload/pages/<basename>-<id>.js` — thin wrapper that imports
|
|
197
|
+
* the entry's exported renderer and invokes it with this id.
|
|
198
|
+
*
|
|
199
|
+
* Stale outputs from a previous run (orphaned products etc.) are
|
|
200
|
+
* cleaned up before regenerating. The expansion runs once at the
|
|
201
|
+
* start of `nodality prerender` whenever the config has a `fanout`
|
|
202
|
+
* block; you can also invoke it standalone via `nodality fanout`.
|
|
203
|
+
*/
|
|
204
|
+
function runFanout(cwd, uploadDir, fanoutConfig) {
|
|
205
|
+
if (!Array.isArray(fanoutConfig) || fanoutConfig.length === 0) return 0;
|
|
206
|
+
|
|
207
|
+
let totalWrote = 0;
|
|
208
|
+
for (const spec of fanoutConfig) {
|
|
209
|
+
const { template, data, items, id = "id", title, entry, bodyAttr = "data-product-id" } = spec;
|
|
210
|
+
if (!template || !data) {
|
|
211
|
+
console.warn(`[nodality] fanout: skipping spec without template/data`);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const tplPath = path.join(uploadDir, template);
|
|
216
|
+
const dataPath = path.join(uploadDir, data);
|
|
217
|
+
if (!fs.existsSync(tplPath)) {
|
|
218
|
+
console.error(`[nodality] fanout: template missing: ${template}`);
|
|
219
|
+
process.exit(1);
|
|
220
|
+
}
|
|
221
|
+
if (!fs.existsSync(dataPath)) {
|
|
222
|
+
console.error(`[nodality] fanout: data missing: ${data}`);
|
|
223
|
+
process.exit(1);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const json = JSON.parse(fs.readFileSync(dataPath, "utf8"));
|
|
227
|
+
const list = resolveItemsPath(json, items ?? "");
|
|
228
|
+
if (!Array.isArray(list) || list.length === 0) {
|
|
229
|
+
console.warn(`[nodality] fanout: ${data} produced no items at path "${items}"`);
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const templateHtml = fs.readFileSync(tplPath, "utf8");
|
|
234
|
+
const base = path.basename(template, ".html");
|
|
235
|
+
const pagesDir = path.join(uploadDir, "pages");
|
|
236
|
+
fs.mkdirSync(pagesDir, { recursive: true });
|
|
237
|
+
|
|
238
|
+
// Clean stale outputs from a previous run so removing an item
|
|
239
|
+
// also removes its page. Match by prefix; original template
|
|
240
|
+
// (e.g. `detail.html` itself) is never touched.
|
|
241
|
+
for (const f of fs.readdirSync(uploadDir)) {
|
|
242
|
+
if (new RegExp(`^${escapeRegex(base)}-.+\\.html$`).test(f)) {
|
|
243
|
+
fs.unlinkSync(path.join(uploadDir, f));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
for (const f of fs.readdirSync(pagesDir)) {
|
|
247
|
+
if (new RegExp(`^${escapeRegex(base)}-.+\\.js$`).test(f)) {
|
|
248
|
+
fs.unlinkSync(path.join(pagesDir, f));
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Discover the entry file to mirror per-item if not given.
|
|
253
|
+
const entryRel = entry ?? `pages/${base}.js`;
|
|
254
|
+
const entryFull = path.join(uploadDir, entryRel);
|
|
255
|
+
if (!fs.existsSync(entryFull)) {
|
|
256
|
+
console.error(`[nodality] fanout: entry script missing: ${entryRel}`);
|
|
257
|
+
process.exit(1);
|
|
258
|
+
}
|
|
259
|
+
const entryBase = path.basename(entryRel, ".js");
|
|
260
|
+
const entryDir = path.dirname(entryRel);
|
|
261
|
+
|
|
262
|
+
let wrote = 0;
|
|
263
|
+
for (const item of list) {
|
|
264
|
+
const itemId = item?.[id];
|
|
265
|
+
if (!itemId || !/^[A-Za-z0-9][A-Za-z0-9-_]*$/.test(String(itemId))) {
|
|
266
|
+
console.warn(`[nodality] fanout: skipping item with bad ${id}: ${JSON.stringify(itemId)}`);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// HTML: rewrite <title>, inject body data-attr, point script src
|
|
271
|
+
// at the per-item entry.
|
|
272
|
+
const resolvedTitle = title
|
|
273
|
+
? interpolate(title, item)
|
|
274
|
+
: String(item?.name ?? itemId);
|
|
275
|
+
const escTitle = escapeHtml(resolvedTitle);
|
|
276
|
+
|
|
277
|
+
let html = templateHtml
|
|
278
|
+
.replace(/<title>[^<]*<\/title>/i, `<title>${escTitle}</title>`)
|
|
279
|
+
.replace(/<body(\s[^>]*)?>/i, (_, attrs = "") => {
|
|
280
|
+
const cleaned = (attrs ?? "").replace(
|
|
281
|
+
new RegExp(`\\s+${escapeRegex(bodyAttr)}="[^"]*"`),
|
|
282
|
+
"",
|
|
283
|
+
);
|
|
284
|
+
return `<body${cleaned} ${bodyAttr}="${itemId}">`;
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// Rewrite the original `<script src=".../pages/<entryBase>.js">`
|
|
288
|
+
// to point at the per-item wrapper.
|
|
289
|
+
const srcRx = new RegExp(
|
|
290
|
+
`(<script[^>]*src=")([^"]*\\/?)${escapeRegex(entryBase)}\\.js("[^>]*>)`,
|
|
291
|
+
"i",
|
|
292
|
+
);
|
|
293
|
+
html = html.replace(srcRx, `$1$2${entryBase}-${itemId}.js$3`);
|
|
294
|
+
|
|
295
|
+
fs.writeFileSync(path.join(uploadDir, `${base}-${itemId}.html`), html);
|
|
296
|
+
|
|
297
|
+
// JS wrapper. The user's entry must export an async function
|
|
298
|
+
// named `renderDetailPage` (or, generically, the camelCase
|
|
299
|
+
// form of the basename + "Page"). We call it with the id.
|
|
300
|
+
const fnName = entryBase
|
|
301
|
+
.split(/[-_]/)
|
|
302
|
+
.map((p, i) => (i === 0 ? p : p[0].toUpperCase() + p.slice(1)))
|
|
303
|
+
.join("") + "Page"; // detail → renderDetailPage? No — detailPage
|
|
304
|
+
// To minimise convention surprises, default to the underscored
|
|
305
|
+
// `render<Pascal>Page` form too. Try both: the wrapper imports
|
|
306
|
+
// whichever the entry exports.
|
|
307
|
+
const pascal = entryBase
|
|
308
|
+
.split(/[-_]/)
|
|
309
|
+
.map((p) => p[0].toUpperCase() + p.slice(1))
|
|
310
|
+
.join("");
|
|
311
|
+
const wrapperBody = `// Auto-generated by \`nodality fanout\`.
|
|
312
|
+
// Per-item entry — invokes the renderer in ${entryRel}
|
|
313
|
+
// with this item's id baked in.
|
|
314
|
+
import * as mod from "./${path.basename(entryRel)}";
|
|
315
|
+
|
|
316
|
+
const fn = mod.render${pascal}Page ?? mod.${fnName} ?? mod.default;
|
|
317
|
+
if (typeof fn !== "function") {
|
|
318
|
+
throw new Error(
|
|
319
|
+
"[fanout] ${entryRel} must export render${pascal}Page(id) " +
|
|
320
|
+
"(or a default async function). nodality fanout could not find one.",
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
await fn(${JSON.stringify(String(itemId))});
|
|
324
|
+
`;
|
|
325
|
+
fs.writeFileSync(
|
|
326
|
+
path.join(pagesDir, `${entryBase}-${itemId}.js`),
|
|
327
|
+
wrapperBody,
|
|
328
|
+
);
|
|
329
|
+
wrote++;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
console.log(`[nodality] fanout: ${template} × ${data} → ${wrote} page(s)`);
|
|
333
|
+
totalWrote += wrote;
|
|
334
|
+
}
|
|
335
|
+
return totalWrote;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Resolve a dot-path with `[]` segments to an array of items. */
|
|
339
|
+
function resolveItemsPath(data, path) {
|
|
340
|
+
if (!path) return Array.isArray(data) ? data : [];
|
|
341
|
+
const tokens = path.split(/\.|(\[\])/).filter(Boolean);
|
|
342
|
+
let acc = data;
|
|
343
|
+
for (const tok of tokens) {
|
|
344
|
+
if (tok === "[]") {
|
|
345
|
+
if (!Array.isArray(acc)) return [];
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
if (Array.isArray(acc)) {
|
|
349
|
+
acc = acc.flatMap((x) => (x != null ? [x[tok]] : [])).filter((x) => x != null);
|
|
350
|
+
// After accessing a field through an array, the result is an
|
|
351
|
+
// array of values (possibly nested). Don't auto-flatten — the
|
|
352
|
+
// user can put another `[]` after if they want.
|
|
353
|
+
} else if (acc != null) {
|
|
354
|
+
acc = acc[tok];
|
|
355
|
+
} else {
|
|
356
|
+
return [];
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return Array.isArray(acc) ? acc.flat(Infinity).filter((x) => x != null) : [];
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Replace `{field}` in a template with item[field]. */
|
|
363
|
+
function interpolate(template, item) {
|
|
364
|
+
return template.replace(/\{([^}]+)\}/g, (_, key) => {
|
|
365
|
+
const v = key.split(".").reduce((acc, k) => (acc == null ? acc : acc[k]), item);
|
|
366
|
+
return v == null ? "" : String(v);
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function escapeRegex(s) {
|
|
371
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function escapeHtml(s) {
|
|
375
|
+
return String(s).replace(/[<>&"']/g, (c) =>
|
|
376
|
+
c === "<" ? "<" : c === ">" ? ">" : c === "&" ? "&" : c === '"' ? """ : "'",
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
165
380
|
// ─── Page auto-discovery ────────────────────────────────────────
|
|
166
381
|
|
|
167
382
|
/**
|
|
@@ -258,6 +473,16 @@ async function runPrerender(rawArgs) {
|
|
|
258
473
|
);
|
|
259
474
|
}
|
|
260
475
|
|
|
476
|
+
// Data-driven page fanout. When the config declares a `fanout`
|
|
477
|
+
// block (per-product detail pages, per-article blog entries, etc.),
|
|
478
|
+
// expand it BEFORE auto-discovery so the generated HTMLs are picked
|
|
479
|
+
// up alongside the hand-written ones. Subprocess child renders skip
|
|
480
|
+
// fanout — the parent already wrote the files and re-running inside
|
|
481
|
+
// each locale would just thrash them.
|
|
482
|
+
if (!process.env.NODALITY_SSG_LOCALE && Array.isArray(fileConfig.fanout)) {
|
|
483
|
+
runFanout(cwd, uploadDir, fileConfig.fanout);
|
|
484
|
+
}
|
|
485
|
+
|
|
261
486
|
// Explicit `pages` from config wins over auto-discovery. The
|
|
262
487
|
// discovery rules (pages/<base>.js, <base>.js) can't infer
|
|
263
488
|
// irregular pairs like h7-nodality's `index.html → app.js`; for
|
|
@@ -441,6 +666,29 @@ ${body}
|
|
|
441
666
|
}
|
|
442
667
|
}
|
|
443
668
|
|
|
669
|
+
// ─── fanout subcommand (standalone) ────────────────────────────
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Run the fanout expansion standalone (without prerender) — useful
|
|
673
|
+
* for debugging the generated files before kicking off a full SSG.
|
|
674
|
+
* Reads the `fanout` block from nodality.config.json.
|
|
675
|
+
*/
|
|
676
|
+
async function runFanoutStandalone(rawArgs) {
|
|
677
|
+
const cwd = process.cwd();
|
|
678
|
+
const flags = parseFlags(rawArgs);
|
|
679
|
+
const fileConfig = loadConfigFile(cwd);
|
|
680
|
+
const uploadDir = path.resolve(cwd, flags.upload || fileConfig.uploadDir || "upload");
|
|
681
|
+
|
|
682
|
+
if (!Array.isArray(fileConfig.fanout) || fileConfig.fanout.length === 0) {
|
|
683
|
+
console.error(`[nodality] fanout: no \`fanout\` block in nodality.config.json`);
|
|
684
|
+
process.exit(1);
|
|
685
|
+
}
|
|
686
|
+
const wrote = runFanout(cwd, uploadDir, fileConfig.fanout);
|
|
687
|
+
if (wrote === 0) {
|
|
688
|
+
console.warn(`[nodality] fanout: 0 page(s) generated — check config & data`);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
444
692
|
// ─── Dispatch ──────────────────────────────────────────────────
|
|
445
693
|
|
|
446
694
|
async function main() {
|
|
@@ -462,6 +710,8 @@ async function main() {
|
|
|
462
710
|
await runPrerender(rest);
|
|
463
711
|
} else if (command === "compile") {
|
|
464
712
|
await runCompile(rest);
|
|
713
|
+
} else if (command === "fanout") {
|
|
714
|
+
await runFanoutStandalone(rest);
|
|
465
715
|
} else {
|
|
466
716
|
console.error(`[nodality] Unknown command: ${command}`);
|
|
467
717
|
showUsage();
|
package/layout/animator.js
CHANGED
package/layout/audio.js
CHANGED
package/layout/audionew.js
CHANGED
package/layout/base-2.js
CHANGED
package/layout/base.js
CHANGED
package/layout/box.js
CHANGED
package/layout/button.js
CHANGED
package/layout/cards.js
CHANGED
package/layout/center.js
CHANGED
package/layout/checkbox.js
CHANGED
package/layout/circle.js
CHANGED
package/layout/clean-row.js
CHANGED
package/layout/code.js
CHANGED
package/layout/container.js
CHANGED
package/layout/custom.js
CHANGED
package/layout/div-image.js
CHANGED
package/layout/dropdown-2025.js
CHANGED
package/layout/dropdown.js
CHANGED
package/layout/empty-element.js
CHANGED
package/layout/flex-card.js
CHANGED
package/layout/flex-grid.js
CHANGED
package/layout/flex-row.js
CHANGED
package/layout/footer.js
CHANGED
package/layout/free.js
CHANGED
package/layout/grid-new.js
CHANGED
package/layout/grid-switcher.js
CHANGED
package/layout/grid.js
CHANGED
package/layout/group.js
CHANGED
package/layout/header.js
CHANGED
package/layout/image-old.js
CHANGED
package/layout/image.js
CHANGED
package/layout/index.js
CHANGED
package/layout/label.js
CHANGED
package/layout/link.js
CHANGED
package/layout/list-OLD.js
CHANGED
package/layout/list.js
CHANGED
package/layout/meta-adder.js
CHANGED
package/layout/modal-2025.js
CHANGED
package/layout/modernwrap.js
CHANGED
package/layout/multiswitcher.js
CHANGED
package/layout/nav-bar.js
CHANGED
package/layout/navBar-OLD.js
CHANGED
package/layout/new-flat-adder.js
CHANGED
package/layout/new-nav-bar.js
CHANGED
package/layout/polygon.js
CHANGED
package/layout/prerender-site.js
CHANGED
package/layout/prerender.js
CHANGED
package/layout/progress.js
CHANGED
package/layout/row.js
CHANGED
package/layout/scroll-video.js
CHANGED
package/layout/side-bar.js
CHANGED
package/layout/side-nav-bar.js
CHANGED
package/layout/simple-bar.js
CHANGED
package/layout/slider-2025.js
CHANGED
package/layout/spacer.js
CHANGED
package/layout/stack.js
CHANGED
package/layout/styler.js
CHANGED
package/layout/svg.js
CHANGED
package/layout/switcher.js
CHANGED
package/layout/table.js
CHANGED
package/layout/text-field.js
CHANGED
package/layout/text.js
CHANGED
package/layout/ulist.js
CHANGED
package/layout/video.js
CHANGED
package/layout/without-new.js
CHANGED
package/layout/wrap.js
CHANGED
package/layout/zoom-card.js
CHANGED
package/lib/card-getter.js
CHANGED
package/lib/designer.js
CHANGED
package/lib/element-mapper.js
CHANGED
package/lib/link-getter.js
CHANGED
package/lib/scroll-video.js
CHANGED
package/lib/stacker.js
CHANGED
package/lib/theme.js
CHANGED
package/lib/transform-anim.js
CHANGED