@42wp/dev-env 1.0.1 → 1.0.4

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
@@ -25,6 +25,7 @@ This installs the `42wp` command globally.
25
25
  ## Usage
26
26
 
27
27
  ```bash
28
+ 42wp init [project] # scaffold a .42wp-dev-env with this project's options
28
29
  42wp global start # start Traefik + MySQL + phpMyAdmin + Mailpit
29
30
  42wp start <project> # build & start a project (run from your theme/plugin repo)
30
31
  42wp update <project> # update an existing project to a newer WordPress image
@@ -37,6 +38,44 @@ This installs the `42wp` command globally.
37
38
 
38
39
  Run `42wp` with no arguments for the full command list.
39
40
 
41
+ ### Project config (`.42wp-dev-env`)
42
+
43
+ Drop a `.42wp-dev-env` file in your project repo to save its `42wp start` options,
44
+ so you (and your team) can just run `42wp start` with no flags. Scaffold one with
45
+ `42wp init`:
46
+
47
+ ```bash
48
+ 42wp init mysite --subdomains --vip --locale pt_BR --theme my-theme --demo-content
49
+ ```
50
+
51
+ That writes:
52
+
53
+ ```ini
54
+ project_slug=mysite
55
+ multisite=subdomain # false | subdir | subdomain
56
+ username=admin
57
+ password=password
58
+ locale=pt_BR
59
+ default_theme=my-theme
60
+ wpvip=true
61
+ initial_content=200
62
+ ```
63
+
64
+ `42wp start`, run in that directory, reads the file and applies it — and **CLI
65
+ flags override** any value for a one-off run. With `project_slug` set you can run
66
+ `42wp start` with no name. `42wp init` won't overwrite an existing file unless you
67
+ pass `--force`.
68
+
69
+ | Key | Effect |
70
+ | --- | --- |
71
+ | `project_slug` | project name (so `42wp start` needs no argument) |
72
+ | `multisite` | `false`, `subdir` or `subdomain` |
73
+ | `username` / `password` | admin credentials (plaintext — local dev only) |
74
+ | `locale` | WordPress site language, e.g. `pt_BR` (`pt-br` is normalized) |
75
+ | `default_theme` | theme to `wp theme activate` after install |
76
+ | `wpvip` | clone + mount the VIP mu-plugins |
77
+ | `initial_content` | `true` (200 posts) or a number |
78
+
40
79
  ### WordPress version
41
80
 
42
81
  By default projects are built on **`wordpress:php8.4-apache`** (newest WordPress,
@@ -93,6 +132,21 @@ posts (it doesn't skip when posts already exist), and a failure never aborts
93
132
  registers itself (e.g. the `42-framework` mu-plugin) — the seeder does not register
94
133
  any taxonomy. If `42wp_author` isn't available, the author links are simply skipped.
95
134
 
135
+ When the project ships the **42-framework** (detected by `client-mu-plugins/42-framework`
136
+ or a `composer.json` that requires `42wp/wp-mu-plugins`), the seeder additionally
137
+ configures — **once, idempotently** — a fuller site:
138
+
139
+ - the `/%category%/%postname%/` permalink structure
140
+ - a `42wp_homepage` post set as the static front page (falls back to a regular page
141
+ if the type isn't registered)
142
+ - 4 category-driven menus — Header Primário / Secundário and Footer Primário / Social
143
+ (with a nested footer and YouTube/X/Instagram links) — assigned to the theme's
144
+ registered menu locations
145
+
146
+ Re-running (or `42wp seed`) reuses the existing homepage and menus instead of
147
+ duplicating them; only posts keep appending. On a plain WordPress site these extra
148
+ steps are skipped and only the content above is seeded.
149
+
96
150
  To add demo content to an **already-running** project without recreating it, use
97
151
  `seed`:
98
152
 
@@ -111,6 +165,21 @@ project on `start`:
111
165
  42wp start my-theme --user joao --pass s3cr3t
112
166
  ```
113
167
 
168
+ ### Site language
169
+
170
+ New sites install in English (`en_US`) by default. Pick a WordPress locale with
171
+ `--locale`; the language pack is downloaded and activated automatically:
172
+
173
+ ```bash
174
+ 42wp start my-site --locale pt_BR # Brazilian Portuguese
175
+ 42wp start my-site --locale es_ES # Spanish (Spain)
176
+ ```
177
+
178
+ Set a default for every project with the `FORTYTWO_WP_LOCALE` env var. This is the
179
+ **site** language — distinct from `--lang`, which controls the `42wp` CLI's own
180
+ messages (`en`/`pt`). The pack is downloaded into `wp-content/languages`; if the
181
+ download fails (e.g. offline), the site stays in English and `start` continues.
182
+
114
183
  ### Multisite
115
184
 
116
185
  Start a site as a WordPress network with `--multisite` (subdirectory) or add
@@ -177,7 +246,7 @@ State lives under `~/.42wp` (override with `FORTYTWO_HOME`):
177
246
  ~/.42wp/
178
247
  docker-compose.global.yml # the global layer (created on first run)
179
248
  mysql-data/ # persisted MySQL data
180
- projects/<project>/ # generated wp-config.php, Dockerfile, docker-compose.yml
249
+ projects/<project>/ # generated wp-config.php, Dockerfile, docker-compose.yml, meta.json
181
250
  ```
182
251
 
183
252
  `42wp start` generates an ephemeral `wp-config.php` (fetching real salts from
@@ -186,6 +255,15 @@ api.wordpress.org, with a local fallback when offline), a `Dockerfile`, and a
186
255
  `wp core install`. The DB name is the project name with hyphens turned into
187
256
  underscores; a trailing `.suffix` (e.g. `.localhost`) is stripped.
188
257
 
258
+ **Where a project is bound.** `42wp start` mounts the directory you run it from as
259
+ `wp-content`, so it first checks that directory looks like one — it must contain
260
+ `plugins/` and `themes/`, otherwise `start` refuses (rather than building a broken
261
+ site). Pass `--force` to override (e.g. bootstrapping a brand-new, empty project).
262
+ The chosen directory is recorded in the project's `meta.json`, and later `start`s
263
+ reuse it — so re-running from a different folder can't silently re-point the
264
+ container. Run `42wp start <name> --rebind` from a new directory to move the binding
265
+ there on purpose.
266
+
189
267
  The generated `wp-config.php` always defines `WP_ENVIRONMENT_TYPE` as `local`, so
190
268
  `wp_get_environment_type()` reports `local` (not the WordPress default of
191
269
  `production`) — code and plugins that branch on the environment behave as they
@@ -199,7 +277,8 @@ should in dev. The constant is set before mu-plugins load, so it holds even unde
199
277
  | --------------- | -------------- | --------------------------------------------- |
200
278
  | `FORTYTWO_HOME` | `~/.42wp` | Where the global compose, DB data and projects live |
201
279
  | `FORTYTWO_WP_TAG` | `php8.4-apache` | Default WordPress image tag for new projects (overridden by `--wp`) |
202
- | `FORTYTWO_LANG` | auto (`LANG`) | UI language: `en` or `pt` |
280
+ | `FORTYTWO_WP_LOCALE` | unset (`en_US`) | Default WordPress **site** language for new projects (overridden by `--locale`) |
281
+ | `FORTYTWO_LANG` | auto (`LANG`) | CLI UI language: `en` or `pt` |
203
282
  | `NO_COLOR` | unset | Disable colored output |
204
283
 
205
284
  You can also pass `--lang en` / `--lang pt` before the command.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@42wp/dev-env",
3
- "version": "1.0.1",
3
+ "version": "1.0.4",
4
4
  "description": "42WP — a CLI to spin up disposable WordPress dev environments with Docker (Traefik + MySQL + per-project containers).",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -10,6 +10,7 @@ import { wp } from './commands/wp.js';
10
10
  import { update } from './commands/update.js';
11
11
  import { rm } from './commands/rm.js';
12
12
  import { seed } from './commands/seed.js';
13
+ import { init } from './commands/init.js';
13
14
  import { startGlobal, stopGlobal } from './commands/global.js';
14
15
 
15
16
  function extractLang(argv) {
@@ -24,7 +25,7 @@ function extractLang(argv) {
24
25
 
25
26
  // Flags that take a value (--wp tag, --user bob, --pass secret). Anything else
26
27
  // (e.g. --vip) is a boolean. The `--key=value` form works for all of them.
27
- const VALUE_FLAGS = new Set(['wp', 'user', 'pass']);
28
+ const VALUE_FLAGS = new Set(['wp', 'user', 'pass', 'locale', 'theme']);
28
29
 
29
30
  // Pull options out of an arg list, leaving positionals behind. Used by `start`
30
31
  // and `update`; NOT by `wp`, whose args pass through verbatim to WP-CLI.
@@ -53,6 +54,7 @@ function printUsage() {
53
54
  plain(t('usage.line'));
54
55
  plain('');
55
56
  plain(t('usage.commands'));
57
+ plain(t('usage.init'));
56
58
  plain(t('usage.start'));
57
59
  plain(t('usage.update'));
58
60
  plain(t('usage.stop'));
@@ -99,6 +101,12 @@ export async function run(rawArgs) {
99
101
  break;
100
102
  }
101
103
 
104
+ case 'init': {
105
+ const { opts, positionals } = parseOpts(argv.slice(1));
106
+ await init(positionals[0], opts);
107
+ break;
108
+ }
109
+
102
110
  case 'wp':
103
111
  await wp(argv[1], argv.slice(2));
104
112
  break;
@@ -0,0 +1,66 @@
1
+ // `42wp init [project] [flags]` — scaffold a .42wp-dev-env file in the current
2
+ // directory from the given flags (or sensible defaults), so `42wp start` can read
3
+ // the project's options next time. Refuses to overwrite unless --force / --yes.
4
+
5
+ import fs from 'node:fs/promises';
6
+ import path from 'node:path';
7
+
8
+ import { normalizeName } from '../lib/naming.js';
9
+ import { PROJECT_CONFIG_FILE } from '../lib/projectConfig.js';
10
+ import {
11
+ resolveLocale,
12
+ resolveDemoCount,
13
+ DEFAULT_ADMIN_USER,
14
+ DEFAULT_ADMIN_PASS,
15
+ } from '../lib/config.js';
16
+ import { success, error, plain } from '../lib/log.js';
17
+ import { t } from '../lib/i18n.js';
18
+
19
+ export async function init(rawName, opts = {}) {
20
+ const dir = process.cwd();
21
+ const file = path.join(dir, PROJECT_CONFIG_FILE);
22
+
23
+ // Don't clobber an existing file unless explicitly forced.
24
+ try {
25
+ await fs.access(file);
26
+ if (!opts.force && !opts.yes) {
27
+ error(t('init.exists', { file: PROJECT_CONFIG_FILE }));
28
+ process.exitCode = 1;
29
+ return;
30
+ }
31
+ } catch {
32
+ // no existing file — good to write
33
+ }
34
+
35
+ const slug = normalizeName(rawName || opts.project_slug || path.basename(dir));
36
+ const subdomains = !!opts.subdomains;
37
+ const multisiteMode = subdomains ? 'subdomain' : opts.multisite ? 'subdir' : 'false';
38
+ const user = opts.user || DEFAULT_ADMIN_USER;
39
+ const pass = opts.pass || DEFAULT_ADMIN_PASS;
40
+ const locale = resolveLocale(opts.locale) || 'en_US';
41
+ const theme = opts.theme || '';
42
+ const vip = !!opts.vip;
43
+ const demo = resolveDemoCount(opts['demo-content']); // null | number
44
+ const initialContent = demo ? String(demo) : 'false';
45
+
46
+ const content =
47
+ [
48
+ '# 42wp dev environment — project config (.42wp-dev-env)',
49
+ '# Read by `42wp start` in this directory. CLI flags override these values.',
50
+ '# multisite: false | subdir | subdomain · booleans: true | false',
51
+ '# locale uses the WordPress format, e.g. pt_BR, es_ES',
52
+ '',
53
+ `project_slug=${slug}`,
54
+ `multisite=${multisiteMode}`,
55
+ `username=${user}`,
56
+ `password=${pass}`,
57
+ `locale=${locale}`,
58
+ `default_theme=${theme}`,
59
+ `wpvip=${vip}`,
60
+ `initial_content=${initialContent}`,
61
+ ].join('\n') + '\n';
62
+
63
+ await fs.writeFile(file, content, 'utf8');
64
+ success(t('init.created', { file: PROJECT_CONFIG_FILE }));
65
+ plain(t('init.next'));
66
+ }
@@ -14,6 +14,15 @@ import {
14
14
  routerRule,
15
15
  } from '../lib/naming.js';
16
16
  import { projectDir } from '../lib/paths.js';
17
+ import { readProjectConfig } from '../lib/projectConfig.js';
18
+ import {
19
+ missingWpDirs,
20
+ pathExists,
21
+ readProjectMeta,
22
+ writeProjectMeta,
23
+ readComposeRepoDir,
24
+ REQUIRED_WP_DIRS,
25
+ } from '../lib/projectState.js';
17
26
  import { renderTemplate, render, readTemplate } from '../lib/render.js';
18
27
  import { fetchSalts } from '../lib/salts.js';
19
28
  import { run, compose, dockerExec, dockerExecCapture } from '../lib/docker.js';
@@ -26,6 +35,7 @@ import {
26
35
  wpImage,
27
36
  multisiteConfig,
28
37
  resolveDemoCount,
38
+ resolveLocale,
29
39
  DEFAULT_ADMIN_USER,
30
40
  DEFAULT_ADMIN_PASS,
31
41
  VIP_MU_PLUGINS_REPO,
@@ -50,34 +60,87 @@ async function ensureVipMuPlugins(envDir) {
50
60
  }
51
61
 
52
62
  export async function start(rawName, opts = {}) {
53
- if (!rawName) {
63
+ // The directory the user is sitting in may carry a .42wp-dev-env with saved
64
+ // options. CLI flags still take precedence over it.
65
+ const cwd = process.cwd();
66
+ const fileOpts = readProjectConfig(cwd);
67
+ if (Object.keys(fileOpts).length > 0) {
68
+ opts = { ...fileOpts, ...opts };
69
+ step(t('start.usingConfig'));
70
+ }
71
+
72
+ const projectName = rawName || opts.project_slug;
73
+ if (!projectName) {
54
74
  error(t('start.needName'));
55
75
  process.exitCode = 1;
56
76
  return;
57
77
  }
58
78
 
59
- // The repo the user is sitting in becomes the container's wp-content.
60
- const repoDir = process.cwd();
61
-
62
- const name = normalizeName(rawName);
79
+ const name = normalizeName(projectName);
63
80
  validateName(name);
64
81
 
65
82
  const dom = domain(name);
66
83
  const db = dbName(name);
67
84
  const container = containerName(db);
68
85
  const envDir = projectDir(name);
86
+
87
+ // Resolve which host directory is mounted as wp-content. The FIRST start binds
88
+ // to the current directory and records it in meta.json; later starts reuse that
89
+ // path, so re-running from another folder can't silently re-point the mount.
90
+ // --rebind moves the binding to the current directory.
91
+ const meta = await readProjectMeta(envDir);
92
+ // Recorded binding, or (for projects created before meta.json) recover it from
93
+ // the existing compose so an upgrade doesn't lose the original directory.
94
+ const boundDir = meta.repoDir || (await readComposeRepoDir(envDir));
95
+ let repoDir = cwd;
96
+ if (boundDir && !opts.rebind) {
97
+ if (await pathExists(boundDir)) {
98
+ repoDir = boundDir;
99
+ if (path.resolve(repoDir) !== path.resolve(cwd)) {
100
+ step(t('start.boundTo', { dir: repoDir }));
101
+ }
102
+ } else {
103
+ // The recorded directory is gone — fall back to the current one.
104
+ step(t('start.boundMissing', { dir: boundDir }));
105
+ repoDir = cwd;
106
+ }
107
+ } else if (opts.rebind && boundDir && path.resolve(boundDir) !== path.resolve(cwd)) {
108
+ step(t('start.rebound', { dir: cwd }));
109
+ }
110
+
111
+ // Guard: refuse to mount a directory that isn't a wp-content root (missing the
112
+ // minimal plugins/ + themes/), which would produce a broken WordPress install.
113
+ // --force overrides (e.g. bootstrapping a brand-new, empty project).
114
+ if (!opts.force) {
115
+ const missing = await missingWpDirs(repoDir);
116
+ if (missing.length) {
117
+ error(
118
+ t('start.notWpContent', {
119
+ dir: repoDir,
120
+ missing: missing.map((d) => `${d}/`).join(', '),
121
+ required: REQUIRED_WP_DIRS.map((d) => `${d}/`).join(', '),
122
+ }),
123
+ );
124
+ process.exitCode = 1;
125
+ return;
126
+ }
127
+ }
69
128
  const adminUser = opts.user || DEFAULT_ADMIN_USER;
70
129
  const adminPass = opts.pass || DEFAULT_ADMIN_PASS;
71
130
  // --subdomains implies multisite; without it, --multisite is subdirectory mode.
72
131
  const subdomains = !!opts.subdomains;
73
132
  const multisite = !!opts.multisite || subdomains;
74
133
  const demoCount = resolveDemoCount(opts['demo-content']); // null when not requested
134
+ const locale = resolveLocale(opts.locale); // '' = WordPress default (en_US)
135
+ const theme = opts.theme || ''; // activate this theme after install (if present)
75
136
 
76
137
  if (!(await ensureDockerRunning())) return;
77
138
  await checkGlobalLayer();
78
139
 
79
140
  step(t('start.preparing', { domain: dom }));
80
141
  await fs.mkdir(envDir, { recursive: true });
142
+ // Record (or update, on --rebind) the directory bound as wp-content.
143
+ await writeProjectMeta(envDir, { repoDir });
81
144
 
82
145
  // 1. Database on the global MySQL.
83
146
  step(t('start.creatingDb', { db }));
@@ -175,12 +238,30 @@ export async function start(rawName, opts = {}) {
175
238
  `--admin_user=${adminUser}`,
176
239
  `--admin_password=${adminPass}`,
177
240
  `--admin_email=${ADMIN_EMAIL}`,
241
+ ...(locale ? [`--locale=${locale}`] : []),
178
242
  '--skip-email',
179
243
  '--skip-plugins',
180
244
  '--skip-themes',
181
245
  '--allow-root',
182
246
  ]);
183
247
 
248
+ // 7a. Install and activate the requested site language (downloads the .mo pack
249
+ // into wp-content/languages). Non-fatal — a failure (e.g. offline) leaves
250
+ // the site in English rather than aborting the whole start.
251
+ if (locale && locale !== 'en_US') {
252
+ step(t('start.installingLang', { locale }));
253
+ const lang = await dockerExecCapture(container, [
254
+ 'wp',
255
+ 'language',
256
+ 'core',
257
+ 'install',
258
+ locale,
259
+ '--activate',
260
+ '--allow-root',
261
+ ]);
262
+ if (lang.code !== 0) step(t('start.langFailed', { locale }));
263
+ }
264
+
184
265
  // 7b. Multisite: create the network tables while WP is still single-site
185
266
  // (--skip-config: we manage wp-config ourselves), then enable the MULTISITE
186
267
  // constants. Order matters — the constants must come AFTER the tables exist.
@@ -202,6 +283,15 @@ export async function start(rawName, opts = {}) {
202
283
  await writeWpConfig(multisiteConfig(dom, { subdomains }));
203
284
  }
204
285
 
286
+ // 7c. Activate the project theme (from .42wp-dev-env `default_theme=` or --theme)
287
+ // BEFORE seeding demo content, so its menu locations/features are registered
288
+ // when the seeder runs. Non-fatal — a missing theme just logs a warning.
289
+ if (theme) {
290
+ step(t('start.activatingTheme', { theme }));
291
+ const act = await dockerExecCapture(container, ['wp', 'theme', 'activate', theme, '--allow-root']);
292
+ if (act.code !== 0) step(t('start.themeFailed', { theme }));
293
+ }
294
+
205
295
  // 8. Pretty permalinks.
206
296
  step(t('start.permalinks'));
207
297
  await dockerExec(container, [
@@ -229,6 +319,8 @@ export async function start(rawName, opts = {}) {
229
319
  plain(colors.green(t('start.admin', { url })));
230
320
  plain(colors.green(t('start.user', { user: adminUser })));
231
321
  plain(colors.green(t('start.pass', { pass: adminPass })));
322
+ if (locale) plain(colors.green(t('start.locale', { locale })));
323
+ if (theme) plain(colors.green(t('start.theme', { theme })));
232
324
  if (multisite) {
233
325
  const mode = subdomains ? 'subdomain' : 'subdirectory';
234
326
  plain(colors.green(t('start.multisite', { mode, url })));
package/src/lib/config.js CHANGED
@@ -34,6 +34,26 @@ export function resolveWpTag(override) {
34
34
  return override || process.env.FORTYTWO_WP_TAG || DEFAULT_WP_TAG;
35
35
  }
36
36
 
37
+ // WordPress site language (locale). Empty means the WordPress default (en_US) — no
38
+ // language pack is downloaded. Set per project with --locale (e.g. pt_BR, es_ES),
39
+ // or globally with FORTYTWO_WP_LOCALE. Distinct from --lang, which is the CLI's own
40
+ // UI language.
41
+ export const DEFAULT_WP_LOCALE = '';
42
+
43
+ // Normalize a locale to the WordPress format: `pt-br` / `pt_br` -> `pt_BR`.
44
+ // Values that don't fit the simple ll[_-]RR shape (e.g. `en_US`, `es_419`,
45
+ // `de_DE_formal`) pass through unchanged.
46
+ export function normalizeLocale(loc) {
47
+ if (!loc) return '';
48
+ const s = String(loc).trim();
49
+ const m = /^([a-z]{2,3})[-_]([a-z]{2})$/i.exec(s);
50
+ return m ? `${m[1].toLowerCase()}_${m[2].toUpperCase()}` : s;
51
+ }
52
+
53
+ export function resolveLocale(override) {
54
+ return normalizeLocale(override || process.env.FORTYTWO_WP_LOCALE || DEFAULT_WP_LOCALE);
55
+ }
56
+
37
57
  // Full image reference for display/logging.
38
58
  export function wpImage(tag) {
39
59
  return `wordpress:${tag}`;
package/src/lib/docker.js CHANGED
@@ -4,6 +4,25 @@
4
4
 
5
5
  import { spawn } from 'node:child_process';
6
6
 
7
+ // Format a command + args for human-readable error/log lines. Args that contain
8
+ // spaces or shell metacharacters are POSIX-single-quoted so the line is
9
+ // unambiguous and copy-pasteable. This is DISPLAY ONLY — real execution always
10
+ // passes args as an array to spawn() (no shell), so nothing here affects how
11
+ // commands actually run.
12
+ export function formatCommand(cmd, args = []) {
13
+ return [cmd, ...args].map(quoteForDisplay).join(' ');
14
+ }
15
+
16
+ function quoteForDisplay(arg) {
17
+ const s = String(arg);
18
+ if (s === '') return "''";
19
+ // Bare word when it's only safe characters (letters, digits and common URL/flag
20
+ // punctuation) — e.g. --url=http://base.localhost stays unquoted.
21
+ if (/^[A-Za-z0-9_@%+=:,.\/-]+$/.test(s)) return s;
22
+ // Otherwise single-quote, escaping any embedded single quotes.
23
+ return `'${s.replace(/'/g, `'\\''`)}'`;
24
+ }
25
+
7
26
  // Run a command, streaming its stdio to the user. Resolves on exit 0, rejects otherwise.
8
27
  export function run(cmd, args, opts = {}) {
9
28
  return new Promise((resolve, reject) => {
@@ -11,7 +30,7 @@ export function run(cmd, args, opts = {}) {
11
30
  child.on('error', reject);
12
31
  child.on('close', (code) => {
13
32
  if (code === 0) resolve();
14
- else reject(new Error(`${cmd} ${args.join(' ')} exited with code ${code}`));
33
+ else reject(new Error(`${formatCommand(cmd, args)} exited with code ${code}`));
15
34
  });
16
35
  });
17
36
  }
@@ -24,7 +43,7 @@ export function runWithInput(cmd, args, input, opts = {}) {
24
43
  child.on('error', reject);
25
44
  child.on('close', (code) => {
26
45
  if (code === 0) resolve();
27
- else reject(new Error(`${cmd} ${args.join(' ')} exited with code ${code}`));
46
+ else reject(new Error(`${formatCommand(cmd, args)} exited with code ${code}`));
28
47
  });
29
48
  child.stdin.write(input);
30
49
  child.stdin.end();
package/src/lib/naming.js CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { t } from './i18n.js';
8
8
 
9
- // Remove the shortest trailing ".something", e.g. "jovempan.localhost" -> "jovempan".
9
+ // Remove the shortest trailing ".something", e.g. "mysite.localhost" -> "mysite".
10
10
  export function normalizeName(raw) {
11
11
  return String(raw ?? '').replace(/\.[^.]*$/, '');
12
12
  }
@@ -0,0 +1,91 @@
1
+ // Reads an optional per-project config file (.42wp-dev-env) from the directory a
2
+ // command is run in, so a repo can carry its own `42wp start` defaults. Values are
3
+ // returned shaped like the parsed CLI opts, so a command can merge them as
4
+ // defaults UNDER the real flags (a CLI flag always wins over the file).
5
+ //
6
+ // Format: `key=value`, one per line. `#` comments and blank lines are ignored;
7
+ // surrounding quotes on a value are stripped.
8
+
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+
12
+ export const PROJECT_CONFIG_FILE = '.42wp-dev-env';
13
+
14
+ // File key -> opts key. Both the documented names and the CLI-flag names are
15
+ // accepted so the file stays forgiving. `project_slug` becomes the project name.
16
+ // `multisite` is handled separately (its value picks subdir/subdomain).
17
+ const KEY_MAP = {
18
+ project_slug: 'project_slug',
19
+ slug: 'project_slug',
20
+ username: 'user',
21
+ user: 'user',
22
+ password: 'pass',
23
+ pass: 'pass',
24
+ locale: 'locale',
25
+ default_theme: 'theme',
26
+ theme: 'theme',
27
+ wpvip: 'vip',
28
+ vip: 'vip',
29
+ wp: 'wp',
30
+ initial_content: 'demo-content',
31
+ 'demo-content': 'demo-content',
32
+ };
33
+
34
+ const BOOL_KEYS = new Set(['vip']);
35
+
36
+ const isTrue = (v) => /^(1|true|yes|on)$/i.test(v);
37
+
38
+ // Parse .42wp-dev-env from `dir`. Returns {} when the file is absent or unreadable.
39
+ export function readProjectConfig(dir) {
40
+ let text;
41
+ try {
42
+ text = fs.readFileSync(path.join(dir, PROJECT_CONFIG_FILE), 'utf8');
43
+ } catch {
44
+ return {};
45
+ }
46
+
47
+ const out = {};
48
+ for (const rawLine of text.split(/\r?\n/)) {
49
+ const line = rawLine.trim();
50
+ if (!line || line.startsWith('#')) continue;
51
+ const eq = line.indexOf('=');
52
+ if (eq === -1) continue;
53
+
54
+ const rawKey = line.slice(0, eq).trim().toLowerCase();
55
+ let value = line.slice(eq + 1).trim();
56
+ // Strip an inline comment (" # ..."), unless the value is quoted. A '#' with no
57
+ // preceding whitespace is kept, so values like a password can contain it.
58
+ if (value[0] !== '"' && value[0] !== "'") {
59
+ value = value.replace(/\s+#.*$/, '').trim();
60
+ }
61
+ // Strip surrounding matching quotes.
62
+ value = value.replace(/^(['"])(.*)\1$/, '$2');
63
+
64
+ // multisite=subdir|subdomain (true == subdir; false/absent == single site).
65
+ if (rawKey === 'multisite') {
66
+ const v = value.toLowerCase();
67
+ if (/^(subdomain|subdomains)$/.test(v)) {
68
+ out.multisite = true;
69
+ out.subdomains = true;
70
+ } else if (/^(subdir|subdirectory|true|yes|1|on)$/.test(v)) {
71
+ out.multisite = true;
72
+ out.subdomains = false;
73
+ }
74
+ continue;
75
+ }
76
+
77
+ const key = KEY_MAP[rawKey];
78
+ if (!key) continue; // ignore unknown keys
79
+
80
+ if (BOOL_KEYS.has(key)) {
81
+ out[key] = isTrue(value);
82
+ } else if (key === 'demo-content') {
83
+ // true -> bare flag (default count); a number -> that count; false -> omit.
84
+ if (isTrue(value)) out[key] = true;
85
+ else if (/^\d+$/.test(value)) out[key] = value;
86
+ } else if (value !== '') {
87
+ out[key] = value;
88
+ }
89
+ }
90
+ return out;
91
+ }
@@ -0,0 +1,66 @@
1
+ // Per-project runtime state kept in the generated project dir (~/.42wp/projects/<name>/):
2
+ // - meta.json remembers which host directory is bound as wp-content, so
3
+ // re-running `start` from another folder can't silently re-point the mount.
4
+ // - a guard that the directory about to be mounted really is a wp-content
5
+ // (has the minimal plugins/ and themes/ subdirectories).
6
+
7
+ import fs from 'node:fs/promises';
8
+ import path from 'node:path';
9
+
10
+ const META_FILE = 'meta.json';
11
+
12
+ // Sub-directories that must exist for a folder to count as a wp-content root.
13
+ export const REQUIRED_WP_DIRS = ['plugins', 'themes'];
14
+
15
+ // Returns the required wp-content subdirs that are missing from `dir` (empty means
16
+ // the directory looks like a valid wp-content).
17
+ export async function missingWpDirs(dir) {
18
+ const missing = [];
19
+ for (const sub of REQUIRED_WP_DIRS) {
20
+ try {
21
+ const st = await fs.stat(path.join(dir, sub));
22
+ if (!st.isDirectory()) missing.push(sub);
23
+ } catch {
24
+ missing.push(sub);
25
+ }
26
+ }
27
+ return missing;
28
+ }
29
+
30
+ export async function pathExists(p) {
31
+ try {
32
+ await fs.stat(p);
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ // Read the project's meta.json (returns {} when absent/unreadable).
40
+ export async function readProjectMeta(envDir) {
41
+ try {
42
+ return JSON.parse(await fs.readFile(path.join(envDir, META_FILE), 'utf8'));
43
+ } catch {
44
+ return {};
45
+ }
46
+ }
47
+
48
+ // Recover the wp-content bind-mount source from an existing docker-compose.yml,
49
+ // for projects created before meta.json existed. Returns '' when not found.
50
+ export async function readComposeRepoDir(envDir) {
51
+ try {
52
+ const yml = await fs.readFile(path.join(envDir, 'docker-compose.yml'), 'utf8');
53
+ const m = yml.match(/^\s*-\s*(.+):\/var\/www\/html\/wp-content\s*$/m);
54
+ return m ? m[1].trim() : '';
55
+ } catch {
56
+ return '';
57
+ }
58
+ }
59
+
60
+ // Persist the project's meta.json (merging over any existing fields).
61
+ export async function writeProjectMeta(envDir, data) {
62
+ const current = await readProjectMeta(envDir);
63
+ const next = { ...current, ...data };
64
+ await fs.writeFile(path.join(envDir, META_FILE), `${JSON.stringify(next, null, 2)}\n`, 'utf8');
65
+ return next;
66
+ }
package/src/locales/en.js CHANGED
@@ -13,7 +13,13 @@ export default {
13
13
  'global.invalid': "Invalid global command. Use 'start' or 'stop'.",
14
14
 
15
15
  // start
16
- 'start.needName': 'You must provide a project name. e.g.: 42wp start jovempan',
16
+ 'start.needName': 'You must provide a project name (or set project_slug in .42wp-dev-env). e.g.: 42wp start mysite',
17
+ 'start.usingConfig': 'Using options from .42wp-dev-env (CLI flags override).',
18
+ 'start.notWpContent':
19
+ '${dir} is not a wp-content directory (missing ${missing}). Run 42wp start from your project root (it needs ${required}), or pass --force to override.',
20
+ 'start.boundTo': 'Project is bound to ${dir}; keeping it. Use --rebind to point it at the current directory.',
21
+ 'start.boundMissing': 'Bound directory ${dir} no longer exists — binding to the current directory.',
22
+ 'start.rebound': 'Rebinding project to ${dir}.',
17
23
  'start.preparing': 'Preparing environment for ${domain}...',
18
24
  'start.creatingDb': 'Creating database: ${db}',
19
25
  'start.genConfig': 'Generating ephemeral wp-config.php...',
@@ -25,6 +31,10 @@ export default {
25
31
  'start.upping': 'Starting project containers...',
26
32
  'start.waitingWp': 'Waiting for the WordPress container to respond...',
27
33
  'start.installing': 'Running silent WordPress install...',
34
+ 'start.installingLang': 'Installing WordPress language pack (${locale})...',
35
+ 'start.langFailed': 'Could not install the ${locale} language pack (continuing in English).',
36
+ 'start.activatingTheme': 'Activating theme (${theme})...',
37
+ 'start.themeFailed': 'Could not activate the "${theme}" theme (is it in the repo?).',
28
38
  'start.enablingMultisite': 'Enabling multisite (network tables + wp-config constants)...',
29
39
  'start.permalinks': 'Configuring permalinks...',
30
40
  'start.demoGenerating': 'Seeding ${count} demo posts (authors, tags, categories, images)...',
@@ -34,17 +44,24 @@ export default {
34
44
  'start.admin': 'Admin: ${url}/wp-admin',
35
45
  'start.user': 'User: ${user}',
36
46
  'start.pass': 'Password: ${pass}',
47
+ 'start.locale': 'Language: ${locale}',
48
+ 'start.theme': 'Theme: ${theme}',
37
49
  'start.multisite': 'Multisite: ${mode}. Network admin: ${url}/wp-admin/network/',
38
50
 
51
+ // init
52
+ 'init.exists': '${file} already exists here. Re-run with --force to overwrite.',
53
+ 'init.created': 'Created ${file}. Edit it, then run 42wp start.',
54
+ 'init.next': 'Tip: with project_slug set, you can run 42wp start with no name.',
55
+
39
56
  // update
40
- 'update.needName': 'Tell me which project to update. e.g.: 42wp update jovempan',
57
+ 'update.needName': 'Tell me which project to update. e.g.: 42wp update mysite',
41
58
  'update.notFound': "Project '${name}' not found at ${dir}. Run 42wp start ${name} first.",
42
59
  'update.rebuilding': 'Rebuilding ${name} on ${image}...',
43
60
  'update.updatingDb': 'Updating database schema (wp core update-db)...',
44
61
  'update.done': 'Updated! ${name} is now on WordPress ${version}.',
45
62
 
46
63
  // rm
47
- 'rm.needName': 'Tell me which project to remove. e.g.: 42wp rm jovempan',
64
+ 'rm.needName': 'Tell me which project to remove. e.g.: 42wp rm mysite',
48
65
  'rm.notFound': "Project '${name}' not found at ${dir}.",
49
66
  'rm.confirm':
50
67
  "Remove '${name}'? This deletes its container, image and database. Your repository is kept.",
@@ -57,17 +74,17 @@ export default {
57
74
  'rm.done': "Removed '${name}'. Your repository was left untouched.",
58
75
 
59
76
  // seed
60
- 'seed.needName': 'Tell me which project to seed. e.g.: 42wp seed jovempan',
77
+ 'seed.needName': 'Tell me which project to seed. e.g.: 42wp seed mysite',
61
78
  'seed.notRunning': "Project '${name}' is not running. Start it first with 42wp start ${name}.",
62
79
  'seed.failed': 'Demo content generation failed.',
63
80
 
64
81
  // stop
65
- 'stop.needName': 'Tell me which project to stop. e.g.: 42wp stop jovempan',
82
+ 'stop.needName': 'Tell me which project to stop. e.g.: 42wp stop mysite',
66
83
  'stop.stopping': 'Stopping the ${name} environment...',
67
84
  'stop.notFound': 'Environment for ${name} not found at ${dir}.',
68
85
 
69
86
  // wp proxy
70
- 'wp.needArgs': 'Provide the project and command. e.g.: 42wp wp jovempan plugin list',
87
+ 'wp.needArgs': 'Provide the project and command. e.g.: 42wp wp mysite plugin list',
71
88
  'wp.notRunning': 'Container ${container} is not running.',
72
89
 
73
90
  // validation
@@ -77,6 +94,7 @@ export default {
77
94
  // usage
78
95
  'usage.line': 'Usage: 42wp <command> [project] [arguments]',
79
96
  'usage.commands': 'Commands:',
97
+ 'usage.init': ' init [project] Scaffold a .42wp-dev-env in the current directory.',
80
98
  'usage.start': ' start <project> Start the environment using \'.localhost\'.',
81
99
  'usage.update': ' update <project> Update an existing project to a newer WordPress image.',
82
100
  'usage.stop': ' stop <project> Stop the project containers.',
package/src/locales/pt.js CHANGED
@@ -13,7 +13,13 @@ export default {
13
13
  'global.invalid': "Comando global inválido. Use 'start' ou 'stop'.",
14
14
 
15
15
  // start
16
- 'start.needName': 'Você precisa informar o nome do projeto. Ex: 42wp start jovempan',
16
+ 'start.needName': 'Você precisa informar o nome do projeto (ou definir project_slug no .42wp-dev-env). Ex: 42wp start mysite',
17
+ 'start.usingConfig': 'Usando opções do .42wp-dev-env (flags da CLI têm prioridade).',
18
+ 'start.notWpContent':
19
+ '${dir} não é um diretório wp-content (falta ${missing}). Rode 42wp start na raiz do projeto (precisa de ${required}), ou use --force para ignorar.',
20
+ 'start.boundTo': 'O projeto está vinculado a ${dir}; mantendo. Use --rebind para apontar para o diretório atual.',
21
+ 'start.boundMissing': 'O diretório vinculado ${dir} não existe mais — vinculando ao diretório atual.',
22
+ 'start.rebound': 'Revinculando o projeto para ${dir}.',
17
23
  'start.preparing': 'Preparando ambiente para ${domain}...',
18
24
  'start.creatingDb': 'Criando banco de dados: ${db}',
19
25
  'start.genConfig': 'Gerando wp-config.php efêmero...',
@@ -25,6 +31,10 @@ export default {
25
31
  'start.upping': 'Subindo containers do projeto...',
26
32
  'start.waitingWp': 'Aguardando o container WP responder...',
27
33
  'start.installing': 'Executando instalação silenciosa do WordPress...',
34
+ 'start.installingLang': 'Instalando o pacote de idioma do WordPress (${locale})...',
35
+ 'start.langFailed': 'Não foi possível instalar o pacote de idioma ${locale} (continuando em inglês).',
36
+ 'start.activatingTheme': 'Ativando o tema (${theme})...',
37
+ 'start.themeFailed': 'Não foi possível ativar o tema "${theme}" (ele está no repositório?).',
28
38
  'start.enablingMultisite': 'Habilitando multisite (tabelas de rede + constantes no wp-config)...',
29
39
  'start.permalinks': 'Configurando Permalinks...',
30
40
  'start.demoGenerating': 'Gerando ${count} posts de demonstração (autores, tags, categorias, imagens)...',
@@ -34,17 +44,24 @@ export default {
34
44
  'start.admin': 'Admin: ${url}/wp-admin',
35
45
  'start.user': 'Usuário: ${user}',
36
46
  'start.pass': 'Senha: ${pass}',
47
+ 'start.locale': 'Idioma: ${locale}',
48
+ 'start.theme': 'Tema: ${theme}',
37
49
  'start.multisite': 'Multisite: ${mode}. Admin da rede: ${url}/wp-admin/network/',
38
50
 
51
+ // init
52
+ 'init.exists': '${file} já existe aqui. Rode novamente com --force para sobrescrever.',
53
+ 'init.created': 'Criado ${file}. Edite-o e rode 42wp start.',
54
+ 'init.next': 'Dica: com project_slug definido, você pode rodar 42wp start sem o nome.',
55
+
39
56
  // update
40
- 'update.needName': 'Informe o projeto para atualizar. Ex: 42wp update jovempan',
57
+ 'update.needName': 'Informe o projeto para atualizar. Ex: 42wp update mysite',
41
58
  'update.notFound': "Projeto '${name}' não encontrado em ${dir}. Rode 42wp start ${name} primeiro.",
42
59
  'update.rebuilding': 'Reconstruindo ${name} com ${image}...',
43
60
  'update.updatingDb': 'Atualizando o schema do banco (wp core update-db)...',
44
61
  'update.done': 'Atualizado! ${name} agora está no WordPress ${version}.',
45
62
 
46
63
  // rm
47
- 'rm.needName': 'Informe o projeto para remover. Ex: 42wp rm jovempan',
64
+ 'rm.needName': 'Informe o projeto para remover. Ex: 42wp rm mysite',
48
65
  'rm.notFound': "Projeto '${name}' não encontrado em ${dir}.",
49
66
  'rm.confirm':
50
67
  "Remover '${name}'? Isso apaga o container, a imagem e o banco de dados. Seu repositório é mantido.",
@@ -57,17 +74,17 @@ export default {
57
74
  'rm.done': "Removido '${name}'. Seu repositório não foi tocado.",
58
75
 
59
76
  // seed
60
- 'seed.needName': 'Informe o projeto para popular. Ex: 42wp seed jovempan',
77
+ 'seed.needName': 'Informe o projeto para popular. Ex: 42wp seed mysite',
61
78
  'seed.notRunning': "O projeto '${name}' não está rodando. Inicie com 42wp start ${name} primeiro.",
62
79
  'seed.failed': 'Falha ao gerar o conteúdo de demonstração.',
63
80
 
64
81
  // stop
65
- 'stop.needName': 'Informe o projeto para parar. Ex: 42wp stop jovempan',
82
+ 'stop.needName': 'Informe o projeto para parar. Ex: 42wp stop mysite',
66
83
  'stop.stopping': 'Parando o ambiente ${name}...',
67
84
  'stop.notFound': 'Ambiente para ${name} não encontrado em ${dir}.',
68
85
 
69
86
  // proxy wp
70
- 'wp.needArgs': 'Informe o projeto e o comando. Ex: 42wp wp jovempan plugin list',
87
+ 'wp.needArgs': 'Informe o projeto e o comando. Ex: 42wp wp mysite plugin list',
71
88
  'wp.notRunning': 'Container ${container} não está rodando.',
72
89
 
73
90
  // validação
@@ -77,6 +94,7 @@ export default {
77
94
  // uso
78
95
  'usage.line': 'Uso: 42wp <comando> [projeto] [argumentos]',
79
96
  'usage.commands': 'Comandos:',
97
+ 'usage.init': ' init [projeto] Cria um .42wp-dev-env no diretório atual.',
80
98
  'usage.start': " start <projeto> Inicia o ambiente usando '.localhost'.",
81
99
  'usage.update': ' update <projeto> Atualiza um projeto existente para uma imagem mais recente do WordPress.',
82
100
  'usage.stop': ' stop <projeto> Para os containers do projeto.',
@@ -9,6 +9,15 @@
9
9
  * - <count> posts: rich HTML content + plain-text excerpt + featured image,
10
10
  * each linked to 1 category, 2-5 tags and 1-3 authors, with varied past dates.
11
11
  *
12
+ * When the 42-framework is part of the project (its files live under
13
+ * client-mu-plugins, or composer.json requires it) it also configures, once
14
+ * (idempotent):
15
+ * - the `/%category%/%postname%/` permalink structure
16
+ * - a `42wp_homepage` post set as the static front page
17
+ * - 4 category-driven menus (header primary/secondary, footer primary/social)
18
+ * assigned to the theme's registered locations
19
+ * On a plain WordPress site those steps are skipped and only content is seeded.
20
+ *
12
21
  * This file is managed by the dev environment; edits will be overwritten.
13
22
  */
14
23
 
@@ -199,4 +208,153 @@ for ( $i = 0; $i < $count; $i++ ) {
199
208
  }
200
209
  }
201
210
 
202
- WP_CLI::success( sprintf( 'Created %d demo posts.', $created ) );
211
+ WP_CLI::log( sprintf( 'Created %d demo posts.', $created ) );
212
+
213
+ /* -------------------------------------------------------------------------- *
214
+ * 42-framework extras — permalinks, front page, menus
215
+ *
216
+ * Only applied when the 42-framework is part of the project: its files under
217
+ * client-mu-plugins/42-framework, or a composer.json that requires it
218
+ * (42wp/wp-mu-plugins). On a plain WordPress site these steps are skipped and
219
+ * only the content above is seeded.
220
+ * -------------------------------------------------------------------------- */
221
+
222
+ $framework_active = is_dir( WP_CONTENT_DIR . '/client-mu-plugins/42-framework' );
223
+ if ( ! $framework_active && is_readable( WP_CONTENT_DIR . '/composer.json' ) ) {
224
+ $composer = (string) file_get_contents( WP_CONTENT_DIR . '/composer.json' );
225
+ $framework_active = ( false !== strpos( $composer, '42-framework' ) || false !== strpos( $composer, '42wp/wp-mu-plugins' ) );
226
+ }
227
+
228
+ if ( ! $framework_active ) {
229
+ WP_CLI::log( '42-framework not detected — skipped permalinks, front page and menus.' );
230
+ WP_CLI::success( sprintf( 'Demo content ready (%d posts).', $created ) );
231
+ return;
232
+ }
233
+
234
+ /* Permalinks: /%category%/%postname%/. Soft flush so we never rewrite the
235
+ * mounted .htaccess. Idempotent — re-setting the same structure is a no-op. */
236
+ if ( '/%category%/%postname%/' !== get_option( 'permalink_structure' ) ) {
237
+ global $wp_rewrite;
238
+ $wp_rewrite->set_permalink_structure( '/%category%/%postname%/' );
239
+ $wp_rewrite->flush_rules( false );
240
+ WP_CLI::log( 'Permalinks set to /%category%/%postname%/.' );
241
+ }
242
+
243
+ /* Front page: a `42wp_homepage` post (falls back to a page if the type isn't
244
+ * registered), reused when it already exists. */
245
+ $home_type = post_type_exists( '42wp_homepage' ) ? '42wp_homepage' : 'page';
246
+ $home_ids = get_posts( [ 'post_type' => $home_type, 'name' => 'homepage', 'post_status' => 'any', 'numberposts' => 1, 'fields' => 'ids' ] );
247
+ $home_id = $home_ids ? (int) $home_ids[0] : 0;
248
+ if ( ! $home_id ) {
249
+ $home_id = wp_insert_post( [
250
+ 'post_title' => 'Homepage',
251
+ 'post_name' => 'homepage',
252
+ 'post_status' => 'publish',
253
+ 'post_type' => $home_type,
254
+ 'post_author' => 1,
255
+ ], true );
256
+ }
257
+ if ( ! is_wp_error( $home_id ) && $home_id ) {
258
+ update_post_meta( $home_id, '42wp_seo_metadata_noindex', '1' );
259
+ update_option( 'show_on_front', 'page' );
260
+ update_option( 'page_on_front', (int) $home_id );
261
+ WP_CLI::log( sprintf( 'Front page set to %s #%d.', $home_type, $home_id ) );
262
+ }
263
+
264
+ /* Menus: 4 category-driven menus, populated once and assigned to the theme's
265
+ * registered locations. The category items reuse the terms created above. */
266
+ $cat_by_name = [];
267
+ foreach ( $cat_names as $cn ) {
268
+ $term = get_term_by( 'name', $cn, 'category' );
269
+ if ( $term ) {
270
+ $cat_by_name[ $cn ] = (int) $term->term_id;
271
+ }
272
+ }
273
+
274
+ $menu_specs = [
275
+ '42wp_menu_header_primary' => [
276
+ 'name' => 'Header Primário',
277
+ 'items' => [ [ 'cat', 'Política' ], [ 'cat', 'Mundo' ], [ 'cat', 'Cultura' ], [ 'cat', 'Entretenimento' ] ],
278
+ ],
279
+ '42wp_menu_header_secondary' => [
280
+ 'name' => 'Header Secundário',
281
+ 'items' => [ [ 'cat', 'Saúde' ], [ 'cat', 'Brasil' ], [ 'cat', 'Tecnologia' ] ],
282
+ ],
283
+ '42wp_menu_footer_primary' => [
284
+ 'name' => 'Footer Primário',
285
+ 'items' => [
286
+ [ 'cat', 'Esportes', [ [ 'cat', 'Entretenimento' ], [ 'cat', 'Brasil' ], [ 'cat', 'Tecnologia' ] ] ],
287
+ [ 'cat', 'Economia', [ [ 'cat', 'Política' ], [ 'cat', 'Saúde' ], [ 'cat', 'Esportes' ] ] ],
288
+ [ 'cat', 'Ciência', [ [ 'cat', 'Mundo' ], [ 'cat', 'Cultura' ] ] ],
289
+ [ 'cat', 'Tecnologia', [ [ 'cat', 'Saúde' ], [ 'cat', 'Brasil' ], [ 'cat', 'Economia' ] ] ],
290
+ [ 'cat', 'Política', [ [ 'cat', 'Mundo' ], [ 'cat', 'Cultura' ], [ 'cat', 'Entretenimento' ], [ 'cat', 'Ciência' ] ] ],
291
+ ],
292
+ ],
293
+ '42wp_menu_footer_social' => [
294
+ 'name' => 'Footer Social',
295
+ 'items' => [ [ 'link', 'YouTube', 'https://youtube.com' ], [ 'link', 'X', 'https://x.com' ], [ 'link', 'Instagram', 'https://instagram.com' ] ],
296
+ ],
297
+ ];
298
+
299
+ // Recursively add menu items (category archives or custom links), honoring nesting.
300
+ $add_items = static function ( $menu_id, array $items, $parent = 0 ) use ( &$add_items, $cat_by_name ) {
301
+ foreach ( $items as $item ) {
302
+ if ( 'cat' === $item[0] ) {
303
+ if ( empty( $cat_by_name[ $item[1] ] ) ) {
304
+ continue;
305
+ }
306
+ $new_item = wp_update_nav_menu_item( $menu_id, 0, [
307
+ 'menu-item-title' => $item[1],
308
+ 'menu-item-type' => 'taxonomy',
309
+ 'menu-item-object' => 'category',
310
+ 'menu-item-object-id' => $cat_by_name[ $item[1] ],
311
+ 'menu-item-parent-id' => $parent,
312
+ 'menu-item-status' => 'publish',
313
+ ] );
314
+ if ( ! is_wp_error( $new_item ) && ! empty( $item[2] ) ) {
315
+ $add_items( $menu_id, $item[2], (int) $new_item );
316
+ }
317
+ } elseif ( 'link' === $item[0] ) {
318
+ wp_update_nav_menu_item( $menu_id, 0, [
319
+ 'menu-item-title' => $item[1],
320
+ 'menu-item-url' => $item[2],
321
+ 'menu-item-type' => 'custom',
322
+ 'menu-item-parent-id' => $parent,
323
+ 'menu-item-status' => 'publish',
324
+ ] );
325
+ }
326
+ }
327
+ };
328
+
329
+ $registered = get_registered_nav_menus();
330
+ $locations = get_theme_mod( 'nav_menu_locations', [] );
331
+ if ( ! is_array( $locations ) ) {
332
+ $locations = [];
333
+ }
334
+
335
+ $menus_done = 0;
336
+ foreach ( $menu_specs as $location => $spec ) {
337
+ $menu = wp_get_nav_menu_object( $spec['name'] );
338
+ if ( $menu ) {
339
+ $menu_id = (int) $menu->term_id;
340
+ } else {
341
+ $menu_id = wp_create_nav_menu( $spec['name'] );
342
+ if ( is_wp_error( $menu_id ) ) {
343
+ continue;
344
+ }
345
+ }
346
+
347
+ // Populate only when empty, so re-runs don't duplicate items.
348
+ if ( empty( wp_get_nav_menu_items( $menu_id ) ) ) {
349
+ $add_items( $menu_id, $spec['items'] );
350
+ }
351
+
352
+ if ( isset( $registered[ $location ] ) ) {
353
+ $locations[ $location ] = (int) $menu_id;
354
+ }
355
+ $menus_done++;
356
+ }
357
+ set_theme_mod( 'nav_menu_locations', $locations );
358
+ WP_CLI::log( sprintf( 'Menus ready: %d created/assigned.', $menus_done ) );
359
+
360
+ WP_CLI::success( sprintf( 'Demo content ready: %d posts, front page and %d menus.', $created, $menus_done ) );
@@ -11,6 +11,16 @@ RUN echo "xdebug.mode=debug" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.
11
11
  && echo "xdebug.client_host=host.docker.internal" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \
12
12
  && echo "xdebug.start_with_request=yes" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
13
13
 
14
+ # Raise the upload limit to 2G (WP caps at min(upload_max_filesize, post_max_size);
15
+ # the PHP default post_max_size is only 8M, so both must be raised together).
16
+ RUN { \
17
+ echo "upload_max_filesize = 2048M"; \
18
+ echo "post_max_size = 2048M"; \
19
+ echo "memory_limit = 2048M"; \
20
+ echo "max_execution_time = 300"; \
21
+ echo "max_input_time = 300"; \
22
+ } > /usr/local/etc/php/conf.d/uploads.ini
23
+
14
24
  RUN curl -O https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar \
15
25
  && chmod +x wp-cli.phar \
16
26
  && mv wp-cli.phar /usr/local/bin/wp