Skip to content

Commit 2faf5ff

Browse files
chore: bump .claude submodule pointer
1 parent 8e814f3 commit 2faf5ff

11 files changed

Lines changed: 632 additions & 114 deletions

.claude

astro.config.mjs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ import remarkCallouts from './src/lib/remark-callouts.mjs';
77
import rehypeTables from './src/lib/rehype-tables.mjs';
88
import rehypeLinkExternal from './src/lib/rehype-link-external.mjs';
99
import shikiCodeTitle from './src/lib/shiki-code-title.mjs';
10+
import { scheduledPostSlugs, postSlugFromUrl } from './src/lib/scheduled-slugs.mjs';
11+
12+
// Scheduled (future-dated) posts build a `noindex` teaser page for shareable pre-publish previews
13+
// (see getRenderablePosts in src/lib/content.ts) but must NOT appear in the sitemap until they
14+
// reveal — otherwise the deploy guard in .github/workflows/deploy.yml would treat the teaser URL as
15+
// already-live and never deploy the pubDate reveal. Frozen once at config load (= build start).
16+
const scheduled = scheduledPostSlugs();
1017

1118
// https://astro.build/config
1219
export default defineConfig({
@@ -19,7 +26,16 @@ export default defineConfig({
1926
'/post/getting-started-with-anypoint-code-builder':
2027
'/post/getting-started-with-anypoint-code-builder-in-vs-code-beginner-guide',
2128
},
22-
integrations: [mdx(), sitemap()],
29+
integrations: [
30+
mdx(),
31+
sitemap({
32+
// Drop scheduled posts' teaser URLs; keep everything else. See `scheduled` above.
33+
filter: (url) => {
34+
const slug = postSlugFromUrl(url);
35+
return !(slug && scheduled.has(slug));
36+
},
37+
}),
38+
],
2339
vite: {
2440
plugins: [tailwindcss()],
2541
},
4.2 MB
Loading
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
/**
3+
* "Coming soon" panel shown IN PLACE of the article body on a scheduled post's teaser page
4+
* (post/[slug].astro, when `scheduled`). The page itself returns 200 with the correct OG card so a
5+
* pre-publish LinkedIn/social share previews correctly; this panel is what a curious early click
6+
* sees instead of the (embargoed) body. Reuses the dashed-panel idiom + the `calendar` icon already
7+
* used elsewhere; semantic tokens only, so it works in both themes. See getRenderablePosts /
8+
* isPostScheduled in src/lib/content.ts.
9+
*/
10+
import Icon from '@/components/Icon.astro';
11+
import { formatDate } from '@/lib/content';
12+
13+
interface Props {
14+
/** The instant the article goes live (post.pubDate). Rendered in UTC to match the rest of the site. */
15+
pubDate: Date;
16+
}
17+
const { pubDate } = Astro.props;
18+
---
19+
<div class="my-10 rounded-xl border border-dashed border-default p-8 text-center">
20+
<Icon name="calendar" class="mx-auto mb-3 h-6 w-6 text-accent" />
21+
<p class="text-lg font-semibold">This article is scheduled</p>
22+
<p class="mt-2 text-muted">
23+
It goes live on <time datetime={pubDate.toISOString()}>{formatDate(pubDate)}</time>.
24+
In the meantime, see
25+
<!-- WCAG 1.4.3 + 1.4.1: this link sits OUTSIDE `.prose`, so it doesn't inherit the
26+
`.prose :where(a)` --accent-strong rule. Plain `--accent` (.text-accent) is only 3.46:1 on
27+
white → fails AA 4.5:1 for normal text in the LIGHT theme; and a color-only-at-rest link
28+
(hover-only underline) fails 1.4.1 since accent-vs-muted-text is ~1.6:1, well under G183's
29+
3:1. Fix: --accent-strong (5.03:1 light / 9.89:1 dark) + a PERSISTENT underline as the
30+
non-color affordance. -->
31+
<a href="/calendar" class="font-medium text-[var(--accent-strong)] underline underline-offset-2 hover:no-underline">what's coming next</a>.
32+
</p>
33+
</div>
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
---
2+
title: "No fx button on an ACB field? Set a dynamic value in the XML"
3+
description: >-
4+
A field in Anypoint Code Builder has no fx button? Set the dynamic value in the flow XML with a
5+
#[] expression, and read properties with Mule::p() instead of ${}.
6+
pubDate: 2026-07-24
7+
category: Guides
8+
tags: [MuleSoft, Anypoint Code Builder, DataWeave, Visual Studio Code]
9+
heroImage: ../../assets/blog/acb-field-no-fx-button-set-dynamic-value-with-xml.png
10+
faqs:
11+
- question: "Why does a field in Anypoint Code Builder have no fx button?"
12+
answer: "Two common reasons. Not every field in a connector is expression-enabled in the UI, so some are exposed only as plain text inputs and the visual editor never renders the fx toggle next to them, even though the attribute still accepts a runtime expression. It can also be the connector version, since which fields expose the fx button is defined by the connector itself, so an older version may be missing it on a field a newer release has fixed. It's worth checking Exchange or your `pom.xml` for a newer version first."
13+
- question: "How do I set a dynamic value on a field that has no fx button?"
14+
answer: "Open the flow's `.xml` file directly and set the attribute by hand with a DataWeave expression wrapped in `#[ ]`, for example `path=\"#[vars.apiVersion ++ '/orders/' ++ payload.orderId]\"`. Everything inside `#[ ]` is DataWeave evaluated at runtime, which is what makes the value dynamic. Replace `path` with the actual attribute name for your connector field."
15+
- question: "Is editing the flow XML directly safe, or does it bypass the runtime?"
16+
answer: "It's safe. Setting the attribute in the XML is equivalent to what the fx button would have produced, so you're not working around the runtime, just reaching the same setting a different way. After you save, the value shows up on the component in the visual view and the flow runs with your expression."
17+
- question: "How do I use a value from a properties file inside the expression?"
18+
answer: "Read it with the `Mule::p('property.name')` function inside the `#[ ]`. `Mule::p()` reads the value of a Mule application property from your `.yaml` or `.properties` files (and also system properties or environment variables), so you can treat it like any other DataWeave value, for example `#[Mule::p('orders.api.basePath') ++ payload.orderId]`."
19+
- question: "When should I use ${property} versus #[Mule::p('property')]?"
20+
answer: "Use `${property}` when a field takes a plain, standalone property value. Switch to `#[Mule::p('property')]` the moment you need to concatenate or transform it, because `${...}` is a static placeholder, not a DataWeave expression, so you can't join it with `++` or mix it with the payload. Both read the same property; only the second is a real DataWeave expression you can build on."
21+
draft: false
22+
---
23+
24+
There was a question in the MuleSoft Community Slack workspace about a wall people hit in **Anypoint
25+
Code Builder (ACB)**: you want to pass a *dynamic* value into a component field, but that field has
26+
no **fx** button. The fx button (sometimes described as the "f" button) is what flips a field into
27+
expression mode so you can type DataWeave. No button, no obvious way to make the value dynamic from
28+
the UI.
29+
30+
The good news: the restriction is only in the visual editor. The underlying Mule XML has no such
31+
limit, so you can set the value yourself.
32+
33+
## TL;DR
34+
35+
Open the flow's XML and set the attribute inline with a DataWeave expression wrapped in `#[ ]`:
36+
37+
```xml
38+
<http:request method="GET" path="#[vars.apiVersion ++ '/orders/' ++ payload.orderId]" />
39+
```
40+
41+
If the value comes from a **properties file** (`.yaml` or `.properties`), read it inside the
42+
expression with `Mule::p('...')` instead of the `${...}` placeholder syntax, and concatenate with
43+
`++`:
44+
45+
```xml
46+
<http:request method="GET" path="#[Mule::p('orders.api.basePath') ++ payload.orderId]" />
47+
```
48+
49+
Replace `path` with the real attribute name for your connector field. That's the whole trick. The
50+
rest of this post explains why it works and walks through each piece.
51+
52+
## Why the fx button is sometimes missing
53+
54+
Not every field in a connector is expression-enabled in the UI. Some are exposed only as plain text
55+
inputs, so the visual editor never renders the fx toggle next to them, even though the attribute
56+
behind that field will happily accept a runtime expression. It's a gap in the visual layer, not a
57+
limitation of the runtime.
58+
59+
It can also come down to the **connector version**. Which fields expose the fx button is defined by
60+
the connector itself, so an **older version** may be missing it on a field that a newer release has
61+
since fixed. Before anything else, it's worth checking Exchange (or your `pom.xml`) for a newer
62+
version of the connector and updating it. That alone can bring the button back.
63+
64+
Either way, the fix below works: stop fighting the UI and go one level down.
65+
66+
## Fix it in the XML with `#[ ]`
67+
68+
Every Mule flow is just XML. In ACB you can open the flow's `.xml` file directly and edit the
69+
attribute by hand. Anywhere Mule accepts an expression, you write it between `#[` and `]`:
70+
71+
```xml
72+
<http:request method="GET" path="#[vars.apiVersion ++ '/orders/' ++ payload.orderId]" />
73+
```
74+
75+
A few things to note:
76+
77+
- `path` here is a stand-in. Use whatever the **actual attribute name** is for your connector's
78+
field (`path`, `url`, `query`, `fileName`, and so on). Hover the field in the UI or check the
79+
connector docs if you're not sure what it maps to.
80+
- Everything inside `#[ ]` is DataWeave, evaluated **at runtime** for each event, which is exactly
81+
what "dynamic" means here. You get the full language: variables (`vars.*`), the incoming message
82+
(`payload`, `attributes`), functions, and `++` for concatenation.
83+
- Setting the attribute in the XML is equivalent to what the fx button would have produced. You're
84+
not working around the runtime, you're just reaching the same setting a different way.
85+
86+
Once you save, switch back to the visual view. The value shows up on the component, and the flow runs
87+
with your expression.
88+
89+
> [!TIP]
90+
> If you're not sure which attribute a UI field writes to, set a placeholder value in the visual
91+
> editor first, then open the XML and look for it. Now you know the exact attribute name to make
92+
> dynamic.
93+
94+
## When the value lives in a properties file: use `Mule::p()`
95+
96+
A very common case is that part of the dynamic value isn't computed from the payload but read from
97+
configuration, like a base URL that changes per environment. In the UI, connector fields can
98+
consume properties with the `${...}` placeholder syntax:
99+
100+
```xml
101+
<http:request method="GET" path="${orders.api.basePath}" />
102+
```
103+
104+
That works fine on its own. But the moment you need to **combine** that property with something else,
105+
`${...}` gets awkward, because it isn't a DataWeave expression, it's a static placeholder. You can't
106+
concatenate it with `++` or mix it with the payload.
107+
108+
The clean way is to move the property *inside* the expression with the `Mule::p()` function.
109+
`Mule::p('property.name')` reads the value of a Mule application property (from your `.yaml` /
110+
`.properties` files, and also system properties or environment variables), so you can treat it like
111+
any other DataWeave value:
112+
113+
```xml
114+
<http:request method="GET" path="#[Mule::p('orders.api.basePath') ++ payload.orderId]" />
115+
```
116+
117+
Now the base path comes from configuration and the order ID comes from the payload, joined at
118+
runtime. Same idea works with variables or literals:
119+
120+
```xml
121+
<http:request method="GET" path="#[Mule::p('orders.api.basePath') ++ '/' ++ vars.orderId]" />
122+
```
123+
124+
> [!NOTE]
125+
> Rule of thumb: use `${property}` when a field takes a **plain, standalone** property value, and
126+
> switch to `#[Mule::p('property')]` the moment you need to **concatenate or transform** it. Both
127+
> read the same property; only the second one is a real DataWeave expression you can build on.
128+
129+
## Quick recap
130+
131+
- No fx button on a field just means the visual editor won't let you type an expression there. The
132+
attribute still accepts one.
133+
- First check Exchange or your `pom.xml` for a newer connector version, since an older one can be
134+
the reason the button is missing.
135+
- Open the flow XML and set the attribute inline: `attr="#[ your expression ]"`.
136+
- For values from a properties file, read them with `Mule::p('name')` inside the `#[ ]` (instead of
137+
`${...}`) so you can concatenate and transform them.
138+
139+
A small trick, but it turns "this field won't let me" into "this field does exactly what I need."
140+
141+
I hope this was helpful.
142+
143+
💬 Prost! 🍻

src/lib/content.test.ts

Lines changed: 100 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,30 @@
1-
import { describe, it, expect, vi } from 'vitest';
1+
import { describe, it, expect, vi, afterEach } from 'vitest';
22

33
// Stub the two Astro-only imports that content.ts pulls in transitively, so this pure
44
// data helper can be unit-tested off the build:
5-
// - astro:content — content.ts calls getCollection('blog', …). Return [] posts so the
6-
// calendar's VIDEO branch is what we assert. (The article branch is exercised elsewhere.)
5+
// - astro:content — content.ts calls getCollection('blog', filter). The mock APPLIES the filter
6+
// callback to `blogFixtures` (default [] → the calendar tests below see no posts, unchanged),
7+
// so getPosts/getRenderablePosts can be exercised on real fixtures. Referenced lazily (only
8+
// when getCollection is invoked at test time, after `blogFixtures` is assigned) — no TDZ.
79
// - @/content.config — only CATEGORIES/TAGS are used, and only by OTHER helpers; stub it so
810
// the real config's zod schema + glob loader don't evaluate at import time.
11+
let blogFixtures: any[] = [];
912
vi.mock('astro:content', () => ({
10-
getCollection: vi.fn(async () => []),
13+
getCollection: vi.fn(async (_name: string, filter?: (e: any) => boolean) =>
14+
filter ? blogFixtures.filter(filter) : blogFixtures.slice()
15+
),
1116
}));
1217
vi.mock('@/content.config', () => ({ CATEGORIES: [], TAGS: [] }));
1318

14-
import { upcomingCalendarItems } from './content';
19+
import { upcomingCalendarItems, isPostScheduled } from './content';
1520
import { VIDEOS, isScheduled } from '@/data/videos';
1621

22+
/** Minimal blog entry shape the pure helpers read (id + data.pubDate + data.draft). */
23+
const makePost = (id: string, iso: string, draft = false): any => ({
24+
id,
25+
data: { pubDate: new Date(iso), draft },
26+
});
27+
1728
describe('upcomingCalendarItems — never drifts from the scheduling gate', () => {
1829
// The bug class this whole seam guards against: the calendar hand-copied the "is it in the
1930
// future?" comparison instead of sharing it. This pins the property that made drift possible —
@@ -44,3 +55,87 @@ describe('upcomingCalendarItems — never drifts from the scheduling gate', () =
4455
expect(times).toEqual([...times].sort((a, b) => a - b));
4556
});
4657
});
58+
59+
describe('isPostScheduled — the future-date fact (twin of video isScheduled)', () => {
60+
const now = new Date('2026-07-20T00:00:00Z');
61+
62+
it('is false for a past pubDate', () => {
63+
expect(isPostScheduled(makePost('p', '2026-07-19T00:00:00Z'), now)).toBe(false);
64+
});
65+
66+
it('is false at the exact boundary (pubDate == now) — published, not scheduled', () => {
67+
// Mirrors the getPosts gate `pubDate <= now`: at the instant it's due, it publishes.
68+
expect(isPostScheduled(makePost('p', '2026-07-20T00:00:00Z'), now)).toBe(false);
69+
});
70+
71+
it('is true for a strictly-future pubDate', () => {
72+
expect(isPostScheduled(makePost('p', '2026-07-21T00:00:00Z'), now)).toBe(true);
73+
});
74+
75+
it('reports purely on date — a future-dated DRAFT is still "scheduled" by this predicate', () => {
76+
// draft is a separate lever; getRenderablePosts excludes drafts, not this fact.
77+
expect(isPostScheduled(makePost('p', '2999-01-01T00:00:00Z', true), now)).toBe(true);
78+
});
79+
});
80+
81+
describe('getRenderablePosts — pages set = published + scheduled, minus drafts (prod)', () => {
82+
afterEach(() => {
83+
vi.unstubAllEnvs();
84+
vi.resetModules();
85+
});
86+
87+
// Fixtures use far-past / far-future dates so the wall-clock comparison inside getPosts is
88+
// deterministic regardless of when the suite runs. getPosts/getRenderablePosts read
89+
// import.meta.env.PROD at module load, so force it via stubEnv + resetModules + dynamic import.
90+
const fixtures = () => [
91+
makePost('past', '2000-01-01T00:00:00Z'),
92+
makePost('future', '2999-01-01T00:00:00Z'),
93+
makePost('future-draft', '2999-01-01T00:00:00Z', true),
94+
makePost('past-draft', '2000-01-01T00:00:00Z', true),
95+
];
96+
97+
it('in PROD includes past + future non-draft, excludes both drafts', async () => {
98+
vi.stubEnv('PROD', true);
99+
vi.resetModules();
100+
blogFixtures = fixtures();
101+
const { getRenderablePosts } = await import('./content');
102+
const ids = (await getRenderablePosts()).map((p) => p.id).sort();
103+
expect(ids).toEqual(['future', 'past']);
104+
});
105+
106+
it('in PROD differs from getPosts by exactly the scheduled (future non-draft) post', async () => {
107+
vi.stubEnv('PROD', true);
108+
vi.resetModules();
109+
blogFixtures = fixtures();
110+
const mod = await import('./content');
111+
const published = (await mod.getPosts()).map((p) => p.id).sort();
112+
const renderable = (await mod.getRenderablePosts()).map((p) => p.id).sort();
113+
expect(published).toEqual(['past']); // getPosts hides the future one
114+
expect(renderable).toEqual(['future', 'past']); // getRenderablePosts keeps it (as a teaser)
115+
});
116+
117+
it('returns newest-first', async () => {
118+
vi.stubEnv('PROD', true);
119+
vi.resetModules();
120+
blogFixtures = fixtures();
121+
const { getRenderablePosts } = await import('./content');
122+
const ids = (await getRenderablePosts()).map((p) => p.id);
123+
expect(ids).toEqual(['future', 'past']); // 2999 before 2000
124+
});
125+
126+
it('in DEV is ungated — everything (drafts + future) shows, the SAME set as getPosts', async () => {
127+
// Both gates are `isProd ? … : true`, so locally a scheduled OR draft post previews and the
128+
// two seams AGREE. Pins that the teaser work didn't accidentally start gating dev — the exact
129+
// regression that would make `npm run dev` hide a post you're still writing.
130+
vi.stubEnv('PROD', false);
131+
vi.resetModules();
132+
blogFixtures = fixtures();
133+
const mod = await import('./content');
134+
const renderable = (await mod.getRenderablePosts()).map((p) => p.id).sort();
135+
const published = (await mod.getPosts()).map((p) => p.id).sort();
136+
const all = ['future', 'future-draft', 'past', 'past-draft'];
137+
expect(renderable).toEqual(all); // nothing gated
138+
expect(published).toEqual(all); // getPosts is also ungated in dev
139+
expect(renderable).toEqual(published); // …so the two sets are identical locally
140+
});
141+
});

0 commit comments

Comments
 (0)