@jxsuite/compiler 0.0.1 → 0.5.0

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.
@@ -29,11 +29,15 @@ import {
29
29
  buildInitialScope,
30
30
  isTemplateString,
31
31
  evaluateStaticTemplate,
32
+ preRenderComponentHtml,
33
+ isComponentFullyStatic,
34
+ buildComponentCSS,
32
35
  DEFAULT_REACTIVITY_SRC,
33
36
  DEFAULT_LIT_HTML_SRC,
34
37
  } from "../shared.js";
35
38
  import { loadCollections, loadContentConfig, resolveCollectionRefs } from "./content-loader.js";
36
39
  import { resolvePrototypes } from "./prototype-resolver.js";
40
+ import { compileMarkdown } from "../targets/compile-markdown.js";
37
41
 
38
42
  /**
39
43
  * Build an entire Jx site from a project directory.
@@ -96,10 +100,16 @@ export async function buildSite(projectRoot, options = {}) {
96
100
  const componentsDir = resolve(projectRoot, "components");
97
101
  /** @type {string[]} */
98
102
  const compiledComponentTags = [];
103
+ /** @type {Map<string, string>} */
104
+ const preRendered = new Map(); // tagName → innerHTML (default state, fallback)
105
+ /** @type {Map<string, string>} */
106
+ const componentCSS = new Map(); // tagName → CSS text
107
+ /** @type {Map<string, any>} */
108
+ const componentDefs = new Map(); // tagName → parsed component definition
99
109
  if (existsSync(componentsDir)) {
100
110
  log("Compiling components...");
101
- const componentFiles = readdirSync(componentsDir).filter((/** @type {string} */ f) =>
102
- f.endsWith(".json"),
111
+ const componentFiles = readdirSync(componentsDir).filter(
112
+ (/** @type {string} */ f) => f.endsWith(".json") || f.endsWith(".md"),
103
113
  );
104
114
  const componentOutDir = resolve(outDir, "components");
105
115
  mkdirSync(componentOutDir, { recursive: true });
@@ -116,6 +126,25 @@ export async function buildSite(projectRoot, options = {}) {
116
126
  if (f.tagName) compiledComponentTags.push(f.tagName);
117
127
  fileCount++;
118
128
  }
129
+
130
+ // Pre-render component HTML scaffold and CSS sidecar
131
+ const doc = componentPath.endsWith(".md")
132
+ ? (await import("@jxsuite/parser/transpile")).transpileJxMarkdown(
133
+ readFileSync(componentPath, "utf8"),
134
+ )
135
+ : JSON.parse(readFileSync(componentPath, "utf8"));
136
+ if (doc.tagName) {
137
+ componentDefs.set(doc.tagName, doc);
138
+ const innerHTML = preRenderComponentHtml(doc);
139
+ if (innerHTML) preRendered.set(doc.tagName, innerHTML);
140
+
141
+ const css = buildComponentCSS(doc.tagName, doc.style);
142
+ if (css) {
143
+ componentCSS.set(doc.tagName, css);
144
+ writeFileSync(resolve(componentOutDir, `${doc.tagName}.css`), css, "utf8");
145
+ fileCount++;
146
+ }
147
+ }
119
148
  } catch (e) {
120
149
  const err = /** @type {any} */ (e);
121
150
  errors.push(`Error compiling component ${file}: ${err.message}`);
@@ -134,9 +163,24 @@ export async function buildSite(projectRoot, options = {}) {
134
163
  log(` Compiling ${route.urlPattern} ...`);
135
164
  const result = await compilePage(route, projectConfig, projectRoot, collections);
136
165
 
137
- // Inject component scripts if the page references any compiled components
166
+ // Inject pre-rendered component HTML scaffolding (instance-aware)
167
+ // Must happen before script injection so we know which tags are fully static
168
+ /** @type {Set<string>} */
169
+ let staticTags = new Set();
170
+ if (componentDefs.size > 0) {
171
+ const preResult = injectPreRenderedComponents(result.html, preRendered, componentDefs);
172
+ result.html = preResult.html;
173
+ staticTags = preResult.staticTags;
174
+ }
175
+
176
+ // Inject component CSS and JS scripts
138
177
  if (compiledComponentTags.length > 0) {
139
- result.html = injectComponentScripts(result.html, compiledComponentTags);
178
+ result.html = injectComponentScripts(
179
+ result.html,
180
+ compiledComponentTags,
181
+ componentCSS,
182
+ staticTags,
183
+ );
140
184
  }
141
185
 
142
186
  // Determine output path
@@ -145,6 +189,19 @@ export async function buildSite(projectRoot, options = {}) {
145
189
  writeFileSync(outPath, result.html, "utf8");
146
190
  fileCount++;
147
191
 
192
+ // Write markdown export alongside HTML
193
+ try {
194
+ const md = compileMarkdown(result.doc, componentDefs);
195
+ if (md.content) {
196
+ const mdPath = outPath.replace(/\.html$/, ".md");
197
+ writeFileSync(mdPath, md.content, "utf8");
198
+ fileCount++;
199
+ }
200
+ } catch (e) {
201
+ const err = /** @type {any} */ (e);
202
+ errors.push(`Error exporting markdown for ${route.urlPattern}: ${err.message}`);
203
+ }
204
+
148
205
  // Write any additional files (island modules, etc.)
149
206
  for (const file of result.files) {
150
207
  const filePath = resolve(dirname(outPath), file.path);
@@ -209,11 +266,17 @@ export async function buildSite(projectRoot, options = {}) {
209
266
  * @param {any} projectConfig
210
267
  * @param {string} projectRoot
211
268
  * @param {Map<string, any[]>} [collections]
212
- * @returns {Promise<{ html: string; files: any[]; serverHandler: string | null }>}
269
+ * @returns {Promise<{ html: string; files: any[]; serverHandler: string | null; doc: any }>}
213
270
  */
214
271
  async function compilePage(route, projectConfig, projectRoot, collections = new Map()) {
215
272
  // Load the raw page document
216
- let pageDoc = JSON.parse(readFileSync(route.sourcePath, "utf8"));
273
+ let pageDoc;
274
+ if (route.sourcePath.endsWith(".md")) {
275
+ const { transpileJxMarkdown } = await import("@jxsuite/parser/transpile");
276
+ pageDoc = transpileJxMarkdown(readFileSync(route.sourcePath, "utf8"));
277
+ } else {
278
+ pageDoc = JSON.parse(readFileSync(route.sourcePath, "utf8"));
279
+ }
217
280
 
218
281
  // Resolve layout (wraps page in layout with slot distribution)
219
282
  const layoutDoc = resolveLayout(pageDoc, projectConfig, projectRoot);
@@ -278,10 +341,16 @@ async function compilePage(route, projectConfig, projectRoot, collections = new
278
341
  pageUrl: route.urlPattern,
279
342
  });
280
343
 
344
+ // Merge project-level $media into the layout document so responsive queries are available
345
+ if (projectConfig.$media) {
346
+ layoutDoc.$media = { ...projectConfig.$media, ...layoutDoc.$media };
347
+ }
348
+
281
349
  // Compile the document using the existing compiler
282
350
  const result = await compile(layoutDoc, {
283
351
  title,
284
352
  lang: projectConfig.defaults?.lang ?? "en",
353
+ projectStyle: projectConfig.style ?? null,
285
354
  });
286
355
 
287
356
  // Post-process: inject merged <head> content into the compiled HTML
@@ -307,7 +376,7 @@ async function compilePage(route, projectConfig, projectRoot, collections = new
307
376
  // No server entries — that's fine
308
377
  }
309
378
 
310
- return { html: result.html, files: result.files, serverHandler };
379
+ return { html: result.html, files: result.files, serverHandler, doc: layoutDoc };
311
380
  }
312
381
 
313
382
  /**
@@ -386,7 +455,13 @@ function resolveDocTemplates(node, scope) {
386
455
  if (!node || typeof node !== "object") return;
387
456
 
388
457
  if (typeof node.innerHTML === "string" && isTemplateString(node.innerHTML)) {
389
- node.innerHTML = evaluateStaticTemplate(node.innerHTML, scope) ?? node.innerHTML;
458
+ const resolved = evaluateStaticTemplate(node.innerHTML, scope);
459
+ if (resolved != null) {
460
+ // Encode any remaining `${` as HTML entities so the compile phase won't
461
+ // re-interpret them as template expressions. After resolution, any `${` in the
462
+ // result is literal content (e.g., code examples), not an intentional template.
463
+ node.innerHTML = resolved.replaceAll("${", "&#36;{");
464
+ }
390
465
  }
391
466
  if (typeof node.textContent === "string" && isTemplateString(node.textContent)) {
392
467
  node.textContent = evaluateStaticTemplate(node.textContent, scope) ?? node.textContent;
@@ -413,20 +488,40 @@ function resolveDocTemplates(node, scope) {
413
488
  }
414
489
 
415
490
  /**
416
- * Inject component script tags into compiled HTML for any referenced custom elements. Adds an
417
- * import map and module scripts before </body>.
491
+ * Inject component script and CSS link tags into compiled HTML for any referenced custom elements.
492
+ * Adds an import map and module scripts before </body>, and CSS links in <head>.
418
493
  *
419
494
  * @param {string} html
420
495
  * @param {string[]} allComponentTags - All compiled component tag names
496
+ * @param {Map<string, string>} [cssMap] - TagName → CSS text (for link injection)
497
+ * @param {Set<string>} [staticTags] - Tags where ALL instances are fully static (skip JS)
421
498
  * @returns {string}
422
499
  */
423
- function injectComponentScripts(html, allComponentTags) {
500
+ function injectComponentScripts(
501
+ html,
502
+ allComponentTags,
503
+ cssMap = new Map(),
504
+ staticTags = new Set(),
505
+ ) {
424
506
  // Find which components are actually referenced in this page
425
507
  const usedTags = allComponentTags.filter(
426
508
  (/** @type {string} */ tag) => html.includes(`<${tag}`), // matches <tag> and <tag ...>
427
509
  );
428
510
  if (usedTags.length === 0) return html;
429
511
 
512
+ // Inject CSS links in <head> for ALL components that have CSS sidecars
513
+ const cssLinks = usedTags
514
+ .filter((/** @type {string} */ tag) => cssMap.has(tag))
515
+ .map((/** @type {string} */ tag) => `<link rel="stylesheet" href="./components/${tag}.css">`)
516
+ .join("\n ");
517
+ if (cssLinks) {
518
+ html = html.replace("</head>", ` ${cssLinks}\n</head>`);
519
+ }
520
+
521
+ // Only inject JS for components that have non-static instances
522
+ const jsTags = usedTags.filter((/** @type {string} */ tag) => !staticTags.has(tag));
523
+ if (jsTags.length === 0) return html;
524
+
430
525
  // Build import map (needed for @vue/reactivity and lit-html)
431
526
  const importMap = `<script type="importmap">
432
527
  {
@@ -437,9 +532,9 @@ function injectComponentScripts(html, allComponentTags) {
437
532
  }
438
533
  </script>`;
439
534
 
440
- const moduleScripts = usedTags
535
+ const moduleScripts = jsTags
441
536
  .map(
442
- (/** @type {string} */ tag) => `<script type="module" src="/components/${tag}.js"></script>`,
537
+ (/** @type {string} */ tag) => `<script type="module" src="./components/${tag}.js"></script>`,
443
538
  )
444
539
  .join("\n ");
445
540
 
@@ -450,6 +545,74 @@ function injectComponentScripts(html, allComponentTags) {
450
545
  return html.replace("</body>", ` ${injection}\n</body>`);
451
546
  }
452
547
 
548
+ /**
549
+ * Inject pre-rendered HTML scaffolding into component tags, using instance-specific props.
550
+ *
551
+ * @param {string} html
552
+ * @param {Map<string, string>} preRendered - TagName → default innerHTML (fallback)
553
+ * @param {Map<string, any>} componentDefs - TagName → parsed component definition
554
+ * @returns {{ html: string; staticTags: Set<string> }}
555
+ */
556
+ function injectPreRenderedComponents(html, preRendered, componentDefs) {
557
+ /** @type {Set<string>} */
558
+ const staticTags = new Set();
559
+ /** @type {Map<string, boolean>} */
560
+ const tagHasNonStaticInstance = new Map();
561
+
562
+ for (const [tag] of componentDefs) {
563
+ // Match both empty tags and tags with inner content (for slotted components)
564
+ const pattern = new RegExp(`<${tag}(\\s[^>]*?)?>([\\s\\S]*?)</${tag}>`, "g");
565
+ html = html.replace(pattern, (match, attrsStr, existingInner) => {
566
+ const attrs = attrsStr ?? "";
567
+ const doc = componentDefs.get(tag);
568
+ if (!doc) {
569
+ // No definition, use default pre-rendered content
570
+ const fallback = preRendered.get(tag) ?? "";
571
+ return `<${tag}${attrs}>${fallback}</${tag}>`;
572
+ }
573
+
574
+ // Extract data-jx-props from the attribute string
575
+ /** @type {Record<string, any> | null} */
576
+ let props = null;
577
+ const propsMatch = attrs.match(/\sdata-jx-props="([^"]*)"/);
578
+ if (propsMatch) {
579
+ try {
580
+ props = JSON.parse(
581
+ propsMatch[1]
582
+ .replace(/&quot;/g, '"')
583
+ .replace(/&amp;/g, "&")
584
+ .replace(/&lt;/g, "<")
585
+ .replace(/&gt;/g, ">")
586
+ .replace(/&#39;/g, "'"),
587
+ );
588
+ } catch {}
589
+ }
590
+
591
+ // Pre-render with instance-specific props
592
+ const slotContent = existingInner.trim() || null;
593
+ const innerHTML = preRenderComponentHtml(doc, props, slotContent);
594
+
595
+ const isStatic = isComponentFullyStatic(doc);
596
+ if (!isStatic) tagHasNonStaticInstance.set(tag, true);
597
+
598
+ if (isStatic) {
599
+ // Strip data-jx-props from attrs for fully static instances
600
+ let cleanAttrs = attrs.replace(/\s*data-jx-props="[^"]*"/, "");
601
+ return `<${tag}${cleanAttrs} data-jx-static>${innerHTML}</${tag}>`;
602
+ } else {
603
+ return `<${tag}${attrs} data-jx-prerendered>${innerHTML}</${tag}>`;
604
+ }
605
+ });
606
+
607
+ // Track whether ALL instances of this tag are static
608
+ if (componentDefs.has(tag) && !tagHasNonStaticInstance.has(tag)) {
609
+ staticTags.add(tag);
610
+ }
611
+ }
612
+
613
+ return { html, staticTags };
614
+ }
615
+
453
616
  /**
454
617
  * Inject <script type="module"> tags for npm package $elements (cherry-picked component imports).
455
618
  * Bare specifiers are resolved to /node_modules/ paths.
@@ -462,7 +625,8 @@ function injectComponentScripts(html, allComponentTags) {
462
625
  function injectNpmElementScripts(html, npmElements) {
463
626
  const scripts = npmElements
464
627
  .map(
465
- (/** @type {string} */ spec) => `<script type="module" src="/node_modules/${spec}"></script>`,
628
+ (/** @type {string} */ spec) =>
629
+ `<script type="module" src="./node_modules/${spec}"></script>`,
466
630
  )
467
631
  .join("\n ");
468
632
 
@@ -41,7 +41,7 @@ export function compileClient(raw, opts) {
41
41
  } = opts;
42
42
 
43
43
  const context = createCompileContext(raw, null, raw.state ?? {}, raw.$media ?? {});
44
- const styleBlock = compileStyles(raw, raw.$media ?? {});
44
+ const styleBlock = compileStyles(raw, raw.$media ?? {}, opts.projectStyle ?? null);
45
45
 
46
46
  // Collectors for bindings and handlers
47
47
  const counter = { t: 0, s: 0, h: 0, m: 0, sw: 0, l: 0, needsLit: false };
@@ -164,11 +164,11 @@ export function compileClient(raw, opts) {
164
164
  reactivitySrc,
165
165
  );
166
166
 
167
- // Build importmap entries
168
- const importmapEntries = [` "@vue/reactivity": "${reactivitySrc}"`];
169
- if (counter.needsLit) {
170
- importmapEntries.push(` "lit-html": "${litHtmlSrc}"`);
171
- }
167
+ // Build importmap entries — always include lit-html since compiled custom elements need it
168
+ const importmapEntries = [
169
+ ` "@vue/reactivity": "${reactivitySrc}"`,
170
+ ` "lit-html": "${litHtmlSrc}"`,
171
+ ];
172
172
 
173
173
  const html = `<!DOCTYPE html>
174
174
  <html lang="en">
@@ -349,7 +349,9 @@ function buildClientNode(def, raw, context, bindings, handlers, counter) {
349
349
  inner = "";
350
350
  }
351
351
  } else if (source.innerHTML) {
352
- inner = resolveStaticValue(source.innerHTML, nextContext.scope) ?? "";
352
+ // resolveStaticValue may return null if innerHTML contains `${` from rendered content
353
+ // (e.g., code examples) that isn't an actual template expression. Fall back to raw value.
354
+ inner = resolveStaticValue(source.innerHTML, nextContext.scope) ?? source.innerHTML;
353
355
  } else if (
354
356
  source.children &&
355
357
  typeof source.children === "object" &&
@@ -37,7 +37,12 @@ export async function compileElement(sourcePath, opts = {}) {
37
37
  filePath = parentDir ? resolve(parentDir, srcPath) : resolve(srcPath);
38
38
  if (visited.has(filePath)) return;
39
39
  visited.add(filePath);
40
- doc = JSON.parse(readFileSync(filePath, "utf8"));
40
+ if (filePath.endsWith(".md")) {
41
+ const { transpileJxMarkdown } = await import("@jxsuite/parser/transpile");
42
+ doc = transpileJxMarkdown(readFileSync(filePath, "utf8"));
43
+ } else {
44
+ doc = JSON.parse(readFileSync(filePath, "utf8"));
45
+ }
41
46
  } else {
42
47
  doc = srcPath;
43
48
  filePath = null;
@@ -288,8 +293,21 @@ export function emitElementModule(doc, className, elementImports) {
288
293
 
289
294
  // connectedCallback
290
295
  lines.push(" connectedCallback() {");
296
+ // Read $props from data-jx-props attribute (set by compiler for pre-rendered instances)
297
+ lines.push(" const _pa = this.getAttribute('data-jx-props');");
298
+ lines.push(" if (_pa) {");
299
+ lines.push(" try {");
300
+ lines.push(" const _p = JSON.parse(_pa);");
301
+ lines.push(" for (const [k, v] of Object.entries(_p)) {");
302
+ lines.push(" if (k in this.state) this.state[k] = v;");
303
+ lines.push(" }");
304
+ lines.push(" } catch {}");
305
+ lines.push(" this.removeAttribute('data-jx-props');");
306
+ lines.push(" }");
307
+ // Merge JS properties set before connection (by parent runtime).
308
+ // Only check own properties to avoid inherited DOM properties like `title`.
291
309
  lines.push(" for (const key of Object.keys(this.state)) {");
292
- lines.push(" if (key in this && this[key] !== undefined) {");
310
+ lines.push(" if (this.hasOwnProperty(key) && this[key] !== undefined) {");
293
311
  lines.push(" this.state[key] = this[key];");
294
312
  lines.push(" }");
295
313
  lines.push(" }");
@@ -333,7 +351,28 @@ export function emitElementModule(doc, className, elementImports) {
333
351
  lines.push(" });");
334
352
  }
335
353
  }
354
+ const hasSlot = treeHasSlot(doc.children);
355
+ if (hasSlot) {
356
+ // Save light DOM children (slotted content) before clearing
357
+ lines.push(
358
+ " const _slotted = Array.from(this.childNodes).filter(n => n.nodeType === 1 || (n.nodeType === 3 && n.textContent.trim()));",
359
+ );
360
+ }
361
+ // Skip clearing innerHTML if content was pre-rendered with correct props
362
+ lines.push(" if (this.hasAttribute('data-jx-prerendered')) {");
363
+ lines.push(" this.removeAttribute('data-jx-prerendered');");
364
+ lines.push(" } else {");
365
+ lines.push(" this.innerHTML = '';");
366
+ lines.push(" }");
336
367
  lines.push(" this.#dispose = effect(() => render(this.template(), this));");
368
+ if (hasSlot) {
369
+ // Replace <slot> placeholder with saved slotted content
370
+ lines.push(" const _slot = this.querySelector('slot');");
371
+ lines.push(" if (_slot && _slotted.length > 0) {");
372
+ lines.push(" for (const n of _slotted) _slot.before(n);");
373
+ lines.push(" _slot.remove();");
374
+ lines.push(" }");
375
+ }
337
376
  lines.push(" }");
338
377
  lines.push("");
339
378
 
@@ -617,3 +656,19 @@ function emitStyleString(styleDef) {
617
656
 
618
657
  return parts.join("; ");
619
658
  }
659
+
660
+ /**
661
+ * Check if a children tree contains a `<slot>` element.
662
+ *
663
+ * @param {any} children
664
+ * @returns {boolean}
665
+ */
666
+ function treeHasSlot(children) {
667
+ if (!Array.isArray(children)) return false;
668
+ for (const child of children) {
669
+ if (!child || typeof child !== "object") continue;
670
+ if (child.tagName === "slot") return true;
671
+ if (treeHasSlot(child.children)) return true;
672
+ }
673
+ return false;
674
+ }