Author SHA1 Message Date
blog-dev b6fea27963 feat: surface Cyber Essentials certification on homepage hero and footer 2026-09-11 19:45:06 +00:00
blog-dev aa1f015f5a docs(blog): punchier LinkedIn draft for non-DC users - pain-first, no jargon
publish / Build-and-publish (push) Successful in 6m39s
2026-09-04 09:29:20 +00:00
blog-dev 79b62e4cfc docs(blog): add source LinkedIn post comment for the v7.13 article
publish / Build-and-publish (push) Successful in 6m33s
2026-09-04 08:06:46 +00:00
blog-dev 73d4796638 fix(blog): show sasdemo instead of root user in audit demo screenshot
publish / Build-and-publish (push) Successful in 6m36s
2026-09-04 07:51:32 +00:00
blog-dev 7267af58c4 ci: drop npm cache from setup-node - save/restore costs exceed the benefit at this repo size
publish / Build-and-publish (push) Successful in 7m0s
2026-09-04 07:23:58 +00:00
blog-dev e5dfcd84da feat(blog): add audit-column demo image showing the IF formula resolving live
publish / Build-and-publish (push) Successful in 13m17s
2026-09-04 01:28:42 +00:00
blog-dev 02295334b3 fix(blog): restore hardening and polishing phrasing in v7.13 post
publish / Build-and-publish (push) Successful in 13m11s
2026-09-04 01:09:48 +00:00
blog-dev e7a538ed9a feat(blog): link getdata and postedit hook to code.datacontroller.io
publish / Build-and-publish (push) Successful in 15m42s
2026-09-04 00:53:20 +00:00
blog-dev 60a19dda89 fix(blog): correct PK live-formula wording in v7.13 post
publish / Build-and-publish (push) Successful in 15m16s
2026-09-04 00:36:58 +00:00
blog-dev b415793eec fix(blog): trim intra-release fix churn from the also-in-release list
publish / Build-and-publish (push) Successful in 15m40s
2026-09-04 00:20:36 +00:00
blog-dev d352ea8fea fix(blog): re-encode v7.13 images to bust poisoned CDN edge cache entries
publish / Build-and-publish (push) Successful in 15m47s
The /static/* path sits behind a Cloudflare cache-everything rule with a
62-day edge TTL. An image fetched mid-deploy can pin a bad (truncated/404)
response at an edge POP. Re-encoding produces new content-hashed URLs so
every variant is fetched fresh from origin. Pixels are identical.
2026-09-04 00:19:32 +00:00
blog-dev 9e04cd4220 feat(blog): v7.13 release post - formulas & regex with live screenshots
publish / Build-and-publish (push) Successful in 15m47s
2026-09-03 23:56:29 +00:00
blog-dev 181c3ae834 fix(feed): update LinkedIn source copy - community edition, not 5-user free tier
publish / Build-and-publish (push) Successful in 13m5s
2026-08-27 17:04:13 +01:00
blog-dev 0ec0789ac4 feat(feed): five lines of defence post with cover image
publish / Build-and-publish (push) Successful in 13m1s
2026-08-27 16:43:07 +01:00
blog-dev 2e5fc4a368 feat(feed): legacy data ingestion post with cover image
publish / Build-and-publish (push) Successful in 12m56s
2026-08-24 16:37:11 +01:00
blog-dev 3d66f91820 fix(feed): correct docs link to /restore/
publish / Build-and-publish (push) Successful in 12m54s
2026-08-20 22:26:03 +01:00
blog-dev 5cdfadf75c feat(feed): use ctrl-z as cover, move trains meme into post body
publish / Build-and-publish (push) Successful in 13m0s
2026-08-20 22:20:56 +01:00
hermes fef2c3c12e chore: li update
publish / Build-and-publish (push) Successful in 12m58s
2026-08-20 22:14:47 +01:00
blog-dev b50efc01e2 feat(feed): expand rollback post LinkedIn copy, drop version refs
publish / Build-and-publish (push) Successful in 12m52s
2026-08-20 21:50:27 +01:00
blog-dev de23966721 feat(feed): add rollback data changes post with meme 2026-08-20 21:06:43 +01:00
4gl 9121bf900d fix: contact image
publish / Build-and-publish (push) Successful in 13m2s
2026-08-17 20:14:26 +01:00
4gl 56175353d6 feat: sas projects / excel
publish / Build-and-publish (push) Successful in 12m56s
2026-08-17 19:51:20 +01:00
4gl 26b4c55ecd feat: horror stories
publish / Build-and-publish (push) Successful in 13m19s
2026-08-15 09:47:06 +01:00
4gl 5be23393d6 fix: dc link
publish / Build-and-publish (push) Successful in 12m56s
2026-08-13 13:52:18 +01:00
4gl bf3ef8bab1 fix: links
publish / Build-and-publish (push) Successful in 12m51s
2026-08-13 13:46:43 +01:00
4gl 729437308a fix: feed post git data
publish / Build-and-publish (push) Successful in 13m19s
2026-08-13 13:28:30 +01:00
4gl 244829edf9 feat: post
publish / Build-and-publish (push) Successful in 12m58s
2026-08-12 10:32:04 +01:00
4gl b51c831b53 chore: ai docs
publish / Build-and-publish (push) Successful in 13m28s
2026-08-11 10:47:35 +01:00
4gl 8e0b28ed93 feat: libinfo feed post
publish / Build-and-publish (push) Successful in 13m33s
2026-08-11 09:13:04 +01:00
4gl 5b6575ae8e fix: small edits + ai notes
publish / Build-and-publish (push) Successful in 12m53s
2026-08-10 18:42:49 +01:00
4gl 9dcedfffb0 feat: business data capture feed post
publish / Build-and-publish (push) Successful in 12m55s
2026-08-10 18:28:16 +01:00
4gl 34846e5a4c fix: formatting
publish / Build-and-publish (push) Successful in 12m51s
2026-08-09 18:12:42 +01:00
4gl 26304f3c73 feat: fsedit feed post
publish / Build-and-publish (push) Successful in 13m9s
2026-08-09 18:00:11 +01:00
4gl 14b745e2e7 fix: agent tidy up 2026-08-09 17:39:33 +01:00
4gl bdddfac52a fix: image
publish / Build-and-publish (push) Successful in 12m45s
2026-08-07 15:15:09 +01:00
4gl 2cc3a13340 feat: new article
publish / Build-and-publish (push) Successful in 13m3s
2026-08-07 15:01:17 +01:00
4gl ebd77c9d76 feat: regex rules
publish / Build-and-publish (push) Successful in 12m48s
2026-07-28 14:28:28 +01:00
4gl 73fccc70a5 feat: first announcement
publish / Build-and-publish (push) Successful in 12m44s
2026-07-26 15:27:48 +01:00
Unknown 8a10d127c1 feat: feed feature, typescript, remove google, fix errs & warns
publish / Build-and-publish (push) Successful in 13m32s
2026-07-17 22:22:29 +01:00
Unknown 2c42ecae5f fix: glob expansion
publish / Build-and-publish (push) Successful in 13m6s
2026-07-13 22:29:57 +01:00
Unknown 2fee73cb9a fix: local surfer
publish / Build-and-publish (push) Successful in 13m11s
2026-07-13 22:12:36 +01:00
Unknown eae81f1f6d fix: force update of surfer
publish / Build-and-publish (push) Failing after 7m14s
2026-07-13 21:46:30 +01:00
Unknown efaa05549b fix: updating dependencies
publish / Build-and-publish (push) Failing after 7m15s
2026-07-13 20:17:16 +01:00
Unknown 8b2603f804 feat: agent files 2026-07-13 19:05:20 +01:00
allan b58387bee5 fix: prod -> production
publish / Build-and-publish (push) Successful in 12m42s
2026-06-16 11:35:30 +01:00
allan 53aa81c788 feat: env restriction
publish / Build-and-publish (push) Successful in 13m14s
2026-06-16 11:24:56 +01:00
allan d2c8e5e6bc feat: price reduction
publish / Build-and-publish (push) Successful in 12m46s
2026-03-18 14:22:17 +00:00
allan 50833b4fd8 chore: rename
publish / Build-and-publish (push) Successful in 12m39s
2026-03-14 13:57:11 +00:00
allan 1f4e159fe0 fix: wording
publish / Build-and-publish (push) Has been cancelled
2026-03-14 13:50:41 +00:00
allan ae55a82b19 feat: snowflake support
publish / Build-and-publish (push) Successful in 12m41s
2026-03-14 13:33:56 +00:00
_ bf3e48654c fix: footer
publish / Build-and-publish (push) Successful in 11m21s
2026-02-15 22:38:44 +00:00
_ 9c4c1e9d99 fix: footer
publish / Build-and-publish (push) Failing after 6m3s
2026-02-15 22:28:25 +00:00
_ b543b5ab96 fix: footer
publish / Build-and-publish (push) Failing after 6m7s
2026-02-15 21:56:45 +00:00
_ 19743c7dd5 fix: tidy up
publish / Build-and-publish (push) Successful in 11m21s
2026-02-15 21:37:46 +00:00
_ 39ab4be7f7 fix: footer
publish / Build-and-publish (push) Has been cancelled
2026-02-15 21:27:03 +00:00
_ 51761ca3bd fix: footer
publish / Build-and-publish (push) Successful in 11m22s
2026-02-15 21:07:50 +00:00
_ 72e01e4c02 fix: deps
publish / Build-and-publish (push) Successful in 11m29s
2026-02-15 20:07:05 +00:00
_ 1b7a29b2d6 chore: remove old image
publish / Build-and-publish (push) Failing after 1m16s
2026-02-15 19:59:13 +00:00
_ 2120a3892a fix: release table as preview 2026-02-15 19:59:00 +00:00
109 changed files with 5486 additions and 1727 deletions

No files matched your search

+36
View File
@@ -0,0 +1,36 @@
# Domain Docs
How to consume this repo's domain documentation when exploring the codebase.
## Before exploring, read these
- **`CONTEXT.md`** at the repo root - the domain glossary and orientation.
- **`.agents/adr/`** - read the Architecture Decision Records that touch the area you're about to work in.
If any of these files don't exist, proceed silently. Don't flag their absence or suggest creating them upfront; they are created lazily when terms or decisions actually get resolved.
## File structure
```
/
├── CONTEXT.md
├── .agents/
│ ├── adr/
│ │ ├── 0001-<decision-slug>.md
│ │ └── 0002-<decision-slug>.md
│ ├── docs/
│ └── skills/
└── src/
```
## Use the glossary's vocabulary
When your output names a domain concept (an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary avoids.
If the concept you need isn't in the glossary yet, that's a signal - either you're inventing language the project doesn't use (reconsider), or there's a real gap (note it).
## Flag ADR conflicts
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
> _Contradicts ADR-0001 (...) - but worth reopening because..._
+19
View File
@@ -0,0 +1,19 @@
# Issue tracker: Local Markdown
Issues and specs (you may know a spec as a PRD) for this repo live as markdown files in `.scratch/`.
## Conventions
- One feature per directory: `.scratch/<feature-slug>/`
- The spec is `.scratch/<feature-slug>/spec.md`
- Implementation issues are one file per ticket at `.scratch/<feature-slug>/issues/<NN>-<slug>.md`, numbered from `01` - never a single combined tickets file
- Triage state is recorded as a `Status:` line near the top of each issue file
- Comments and conversation history append to the bottom of the file under a `## Comments` heading
## When a skill says "publish to the issue tracker"
Create a new file under `.scratch/<feature-slug>/` (creating the directory if needed).
## When a skill says "fetch the relevant ticket"
Read the file at the referenced path. The user will normally pass the path or the issue number directly.
+63
View File
@@ -0,0 +1,63 @@
# LinkedIn Post Writing Guide
Guidance for drafting LinkedIn posts promoting Data Controller (datacontroller.io).
## Audience
Our readers are typically:
- **Technical managers and platform owners** in regulated industries (Banking, Insurance, Pharma) running enterprise SAS® estates.
- **Data governance / data quality leads** dealing with End User Computing (EUC) risk and audit requirements.
- **SAS developers and administrators** who maintain data pipelines and care about automation, security and auditability.
- Occasionally **C-level / procurement** - keep business value (OpEx, risk reduction, compliance) visible alongside technical detail.
Implications:
- Assume competence - no need to explain what ETL, SAS, or a data warehouse is.
- Speak to their pains: regulatory reporting deadlines, audit trails, hand-cranked ETL, EUC sprawl, OpEx, blame-games between business and IT.
- Professional but direct tone. Avoid hype, buzzword stuffing, and exclamation-mark overload.
## Structure
1. **Hook** - first 1-2 lines must stand alone; LinkedIn truncates the rest behind "…see more". State the pain or the claim, not the product.
2. **Problem** - short paragraphs describing the pain in the reader's context.
3. **Belief / shift** - what we believe the world should look like.
4. **Solution** - introduce Data Controller, one or two sentences on what it does (zero-code data capture, review/approve workflow, audit trail, loads into SAS / any database).
5. **Outcome** - the concrete result (eliminated ETL flows, reduced OpEx, full lineage).
6. **CTA** - single call to action, typically "book a Discovery Call - link in the comments below 👇". LinkedIn deprioritises posts with external links, so put URLs in the first comment, not the body.
## Formatting capabilities and tricks
LinkedIn posts are plain text - there is no rich-text editor, so **no markdown**. Bold, italics and other styling you see in posts are faked with Unicode characters:
- **Faux bold / italic / monospace via Unicode Mathematical Alphanumeric Symbols** - e.g. `𝐛𝐨𝐥𝐝` (U+1D401+), `𝑖𝑡𝑎𝑙𝑖𝑐` (U+1D456+), `𝚖𝚘𝚗𝚘𝚜𝚙𝚊𝚌𝚎` (U+1D670+). Tools like YayText / LingoJam generate these. **Use with extreme restraint:** they break screen readers and accessibility tools, are not searchable (LinkedIn search treats them as different characters, so keywords in faux-bold won't be found), and can render as boxes on some devices. Our default: avoid them; use CAPS sparingly for emphasis instead. If a hook or single key term really needs to pop, one short faux-bold phrase is the acceptable ceiling.
- **Bullets** - no native lists; use literal characters: `-`, `•`, `✓`, `→`, numbered `1.` etc. Each on its own line.
- **Whitespace** - blank lines survive and are the primary layout tool; use them aggressively (1-3 sentence paragraphs). LinkedIn collapses multiple blank lines beyond one, so a single blank line is the unit.
- **Emoji** - work natively and render fine; use sparingly and professionally (👇 for the CTA, occasional ✅). Avoid emoji walls.
- **Links** - URLs in the body get auto-linked but LinkedIn deprioritises posts containing external links; put the URL in the first comment and point to it ("link in the comments 👇"). Tag people/companies with `@mention` (native, drives reach) where relevant.
- **Truncation** - only the first ~3 lines (~210 characters on mobile) show before "…see more". The hook must earn the click; never waste the first line on a greeting or context-setting.
- **Special characters** - Unicode symbols like → ✅ ⏱ work fine and can structure a post; avoid exotic characters that some fonts render as tofu (□).
- **No nested structure** - no headings, tables, quotes or code blocks. Simulated section breaks (a line of `—` or just blank space) are the only separators.
## Formatting rules
- **Hashtags are written as `#tag`** (e.g. `#endusercomputing`, `#excel`, `#datagovernance`, `#sas`, `#masterdata`). Never write `hashtag#tag` - that is an artefact of copy-pasting from LinkedIn's editor UI and must be stripped/normalised to `#tag` before posting or republishing.
- 3-5 hashtags, all lowercase, placed at the end on their own lines.
- Use regular dashes (`-`), not em-dashes (`—`).
- Short paragraphs (1-3 sentences) with blank lines between them - LinkedIn has no markdown.
- No markdown syntax (no `**bold**`, no `[links](url)`) - LinkedIn renders plain text. Use CAPS sparingly for emphasis instead.
- ® symbol on first mention of SAS® is fine; don't repeat it every time.
- Keep total length under ~1,300 characters where possible; longer posts are fine when the story warrants it, but the hook still has to earn the "see more" click.
## Media
- Always attach an image - posts with images get substantially more reach. Use the same image as the feed post's `previewImg` where one exists.
- **Optimum image size: 1200 x 627 px (1.91:1 landscape)** - LinkedIn's recommended size for feed images and link previews, displays uncropped on desktop and mobile. Square (1080 x 1080) and portrait (1080 x 1350, 4:5) also work and take up more feed real estate on mobile; avoid narrow/tall images beyond 4:5 as they get cropped. Keep key content away from the edges.
- Product screenshots work well for feature announcements; flat branded illustrations for thought-leadership posts.
## Relationship to the site Feed
- Feed posts (`content/feed/`) and LinkedIn posts are siblings, not copies: the site version may contain markdown links and omits hashtags; the LinkedIn version uses plain text, `#tag` hashtags, and "link in the comments".
- When converting site → LinkedIn: strip markdown, convert links to a comment-based CTA, append hashtags.
- When converting LinkedIn → site: strip `hashtag#` artefacts and hashtags, add proper markdown links, add front matter per the `add-feed-post` skill.
- **Keep them in sync:** whenever a feed post is modified, update its `Source LinkedIn post:` comment in the same change (same points, same claims, same ordering of lists) - and vice versa. A stale LinkedIn comment means the next social post is drafted from outdated copy.
+102
View File
@@ -0,0 +1,102 @@
# Data Controller - Sales & Positioning Playbook
Reference for writing marketing, blog, feed, and sales copy. Covers pain points, target audience, value proposition, packaging, sales motion, and objection handling. Synthesised from the marketing site (`datacontroller.io`), product docs (`docs.datacontroller.io`), and product glossary (`dc/CONTEXT.md`).
## The product in one paragraph
Data Controller for SAS® is a SAS-Powered web application that lets business users safely add, modify and delete data in SAS datasets and databases. Every change goes through an edit-stage-approve workflow: edits are staged, validated at the point of entry, reviewed as a diff (new / changed / deleted rows), approved by a data owner, then loaded - with a full audit trail of who changed what, when, and why. It runs on SAS Viya, SAS 9 EBI and SASjs Server, entirely on-prem / offline (no external network calls). It can write to any database reachable via a SAS libname engine.
## The problem (pain points)
SAS users routinely need to load or fix data manually - steering tables, parameters, mappings, corrections, regulatory adjustments. The status-quo approaches all hurt:
- **Network drive + batch job** - drop a CSV/Excel on a share, wait for a nightly job. Every new input means a new job to build, test, document and deploy. File-format drift breaks batches at the worst times (month end).
- **Direct SAS code / Enterprise Guide edits** - requires granting end users MODIFY access to sensitive data locations. High risk of locking datasets, no audit trail, no approval.
- **Asking a DBA / IT to run a script** - change requests, queues, delays, blame games between business and IT.
- **Bespoke in-house capture apps** - years of development and testing, and a "black box" nobody wants to maintain.
- **EUC sprawl** - uncontrolled Excel/VBA applications on network shares: opaque access, no version control, no lineage, audit failure. Regulators care (SOX, BCBS, national DQ circulars). Fines are real: Morgan Stanley $5m (2020), Citibank $400m (2020), DTCC £350k (2021).
The five measurable cost areas (see blog post "ROI and Payback"):
1. **Development time** - reduced to zero (zero-code, config-driven; the grid is generated dynamically from table metadata).
2. **Deployment time** - reduced to zero (config change in the UI, not a code release).
3. **Batch incidents** - invalid data cannot be submitted (validated on arrival).
4. **Data quality issues** - schema-based automatic rules plus configurable frontend validations and backend hook scripts.
5. **Compliance costs** - single source of truth, full audit trail, Excel originals retained with each submission; no reperformed audits of unsecured EUC code.
## Target audience
**Verticals:** regulated industries with large SAS estates - Banking, Insurance, Pharma / Life Sciences. Typical use cases: regulatory reporting adjustments, actuarial / bitemporal reporting (Allianz), DWH steering-table management (AFA försäkring), data catalogs and lineage (Siemens Healthineers), BICC / BI competency centres (DER Touristik).
**Personas:**
- **Economic buyer:** Head of BI / Data Platform owner, CDO, Head of Regulatory Reporting. Cares about OpEx, risk reduction, audit outcomes, compliance.
- **Champion:** SAS platform owner / technical manager drowning in ad-hoc data-load requests.
- **End users:** business users (Editors) submitting data; data owners (Approvers) signing off; Auditors reviewing history. Roles: Viewer, Editor, Approver, Auditor, Administrator.
- **Gatekeepers:** IT security (satisfied by: on-prem, no external calls, uses existing SASLogon auth, row & column level security, end users never get direct write access to target tables).
**Audience tone (from the LinkedIn guide):** assume competence - don't explain ETL or data warehouses. Speak to their pains: reporting deadlines, audit trails, hand-cranked ETL, EUC risk, OpEx. Professional and direct; no hype.
## Value proposition / key messages
- **Zero code** - adding a new editable table is configuration, done in the tool itself. Column types, lengths, dropdowns are derived from the target table at runtime.
- **Review / approve workflow** - nothing is applied without 1+ approvals; approvers see only the diff.
- **Full audit history** - who, when, why, what; original uploaded files retained.
- **Secure** - existing SAS authentication; RLS and CLS enforced server-side; no end-user write access to targets.
- **Real-time** - intra-day concurrent updates via lock table and queuing; loads abort if the table changed since the diff.
- **Any database** - via SAS libname engines; supports retained keys, composite keys, SCD2 (TXTEMPORAL) and full BITEMPORAL history.
- **Validation at point of entry** - HARDREGEX/SOFTREGEX, dropdowns, min/max, NOT NULL, plus hook scripts to run arbitrary SAS programs before/after edit or approve.
- **Saves vs alternatives** - years of dev/test vs bespoke build; no black box.
## Packaging & pricing
Four packages, all with **unlimited users** (since v6), all flavours of SAS (Viya / EBI / SASjs Server):
| Package | Price | Shape |
| --- | --- | --- |
| Community | FREE | 35 tables/library, 15 view rows, 5 upload rows, 1 viewbox, best-endeavours support |
| Bronze | 12k EUR pa | 1 production env, 100 tables/library, 100 rows, 8hr SLA, SASjs support |
| Silver | 21k EUR pa | 2 production envs, unlimited tables/rows, 6 viewboxes, in-house SAS app support |
| Gold | Contact us | Unlimited envs, 4hr SLA, developer + consulting days, **perpetual licence option** |
- Add-on: Developer Days (new product features) at 600 EUR/day for all plans; consulting rates at sasapps.io/pricing.
- Pricing goals: Transparent, Simple, Fair. Full public price list on the site - a differentiator; use it.
- Support: tickets via support.4gl.io or support@datacontroller.io, severity levels 1-4, SLAs per package, escalation by video call between managers.
## Sales motion / execution strategy
1. **Land with the free tier.** The Community package and the evaluation SPK (single-file import, ~minutes to install on SAS 9) make trying the product nearly frictionless. Since v6.1 the source is also available for anyone to build and evaluate. Push evaluation hard - it is the top of funnel. The eval version is deliberately not production-grade (BASE-engine config tables, table-lock risk, unlicensed interface) - the natural upgrade path is built in.
2. **Discovery call as the primary CTA.** Marketing (LinkedIn, blog, feed) always ends with "book a Discovery Call". Content strategy: hook with the pain (EUC risk, ETL sprawl, audit pain), not the product.
3. **Expand from one use case.** Typical wedge: steering tables / mapping tables / parameter tables in a reporting team. Expansion levers: more tables, more environments, more business units, API-driven submissions (API Explorer since v6).
4. **Sell the business case, not features.** Use the ROI/Payback blog post (five cost areas + calculator) so the champion can build an internal business case. Quantify dev hours, batch incidents, audit effort.
5. **Partner channel.** Resellers exist (e.g. SaasNow in the Netherlands); partner-purchased deals may have partner first-line support.
6. **Roadmap as a sales tool.** The public roadmap is customer-driven - "features requested by customers get built" - and Gold includes Developer Days. Position the product as one that listens.
7. **Proof points.** Named customer stories (AFA, Allianz, Siemens Healthineers, DER Touristik), SAS 2021 Excellence in Innovation Award finalist.
## Getting it installed (technical ease-of-adoption)
- Deploys entirely inside the customer's existing SAS platform - no new infrastructure, no external assets or network calls at runtime (passes strict security reviews).
- SAS 9 EBI: SPK import via SAS Management Console / DI Studio; run the configurator stored process; done. Requirements documented at docs.datacontroller.io (`dci-requirements`, `dci-deploysas9`, `dci-stpinstance`).
- Viya and SASjs Server deployments also documented (`deploy-viya`).
- Instillation/deployment support is in-scope for all paid support plans.
## Licensing
- Annual subscription per package (Bronze/Silver/Gold); perpetual licence available at Gold.
- Community tier is free; the evaluation build is governed by an evaluation agreement (Bowe IO Ltd) - evaluation/non-production use only, no redistribution, no reverse engineering, "as is".
- Embedded Handsontable requires a licence key for commercial use - handled as part of paid deployments.
## Objection handling (from the FAQ)
- **"Can it change our data model?"** - No; it updates data, not structure. Positioned as a safety feature.
- **"Our preferred database?"** - Yes, any engine SAS can reach (incl. Snowflake, Redshift support announcements).
- **"Retained / surrogate keys?"** - Supported, along with composite primary keys, SCD2 and bitemporal loads.
- **"Security review will block it."** - On-prem, offline, existing SASLogon, RLS/CLS, no direct target access, no metadata modification after install.
- **"We could build this."** - Yes, at the cost of years of dev/test plus an unmaintainable black box; DC is proven, supported, documented, and source-available for evaluation.
## Copy rules when writing sales-adjacent content
- Regular dashes, not em-dashes. No hard-wrapped Markdown.
- Use product glossary terms exactly (submission, staging, target table, load type, etc. - see `dc/CONTEXT.md`).
- ® on first mention of SAS®.
- CTA conventions per channel: site content may link directly; LinkedIn puts links in the first comment (see `.agents/docs/linkedin.md`).
+88
View File
@@ -0,0 +1,88 @@
---
name: add-feed-post
description: Add a new Feed post (short announcement / social-style update) to the Data Controller marketing site. Use when the user wants to publish a Feed post, add a /feed/ entry, or create a short announcement that also goes out via RSS.
---
# Add a Feed post
The **Feed** section (`/feed/`) is for short announcements and social-media style updates - things that don't warrant a full `content/blog/` article but are still worth publishing and syndicating via RSS. Feed posts work exactly like blog posts (one markdown file per post, in its own directory) but live under `content/feed/` and publish under `/feed/`.
Both Blog and Feed share a single combined RSS feed at `/rss.xml` (via `gatsby-plugin-feed` in `gatsby-config.js`), so a new Feed post appears in RSS automatically on the next build - no extra config.
## Steps
### 1. Create the post directory
Create a folder under `content/feed/`, named after the post slug (this becomes the URL):
```
content/feed/my-new-announcement/index.md
```
Pick a short, hyphenated, lowercase slug.
### 2. Write the front matter + content
```md
---
title: 'My New Announcement'
description: A one or two sentence summary used for SEO and RSS.
date: '2024-06-01 09:00:00'
author: 'Your Name'
authorLink: https://www.linkedin.com/in/yourprofile/
tags:
- Announcements
---
Your announcement content goes here, written in regular Markdown.
```
Front matter fields:
| Field | Required | Notes |
| ------------- | -------- | --------------------------------------------------------------------- |
| `title` | Yes | Used on the page, in the sidebar and in the RSS item. |
| `description` | Yes | Short summary for SEO meta tags and the RSS item description. |
| `date` | Yes | Format `'YYYY-MM-DD HH:MM:SS'`. Controls sort order and the archive. |
| `author` | Yes | Displayed under the post title. |
| `authorLink` | No | If set, the author name links out (e.g. to a LinkedIn profile). |
| `tags` | Yes | One or more tags. Drives the sidebar category list and RSS category. |
| `previewImg` | No | Relative path to an image in the same folder (e.g. `'./cover.png'`). The template renders this image on the post page automatically - do NOT also embed the same image in the markdown body. |
Use regular dashes (`-`) in content, not em-dashes.
If the post is adapted from source copy the user provided (e.g. a LinkedIn post), paste the original text into an HTML comment (verbatim, except strip the literal `hashtag` prefix that LinkedIn sometimes adds before `#tags`, e.g. `hashtag#sas` -> `#sas`) (`<!-- ... -->`) at the bottom of the markdown file, labelled (e.g. `Source LinkedIn post:`). This preserves the original - including hashtags and checkmarks that get dropped/adapted in the site version - for future reuse.
**Important:** when editing an existing feed post that has a `Source LinkedIn post:` comment, ALWAYS update the LinkedIn version too, so the overall message stays consistent (same points, same claims, same ordering of any lists). Wording does not need to be identical, and formatting will differ: the article uses Markdown, while LinkedIn is plain text with `✓`-style bullets, hashtags, and a comment-based CTA (see `.agents/docs/linkedin.md`).
Every new feed post should include an image (a `previewImg` cover) - and its full image-generation prompt must be recorded in the same HTML comment block, labelled (e.g. `Image prompt:`), so it can be tweaked and regenerated later. If the user has not provided or asked for an image, propose one (theme, style and a draft prompt) and add the `Image prompt:` comment even if the image itself is not yet generated.
### 3. Add images (optional)
If you set `previewImg` or reference images in the body, place the image files in the same post directory and reference them with a relative path.
### 4. Build / preview
```shell
npm run develop
```
The post will be available at `/my-new-announcement/` and will appear on the `/feed/` listing page, in the site search index, and in `/rss.xml` alongside blog posts.
## How Feed differs from Blog
| Aspect | Blog | Feed |
| ----------------- | ----------------------------- | --------------------------------- |
| Content directory | `content/blog/` | `content/feed/` |
| Listing page | `/blog/` | `/feed/` |
| Year archive | `/{year}/` | `/feed/{year}/` |
| Category page | `/category/{tag}/` | `/feed/category/{tag}/` |
| Template | `src/templates/blog-post.tsx`, `src/templates/blog-list.tsx` | `src/templates/feed-post.tsx`, `src/templates/feed-list.tsx` |
| RSS | Included in `/rss.xml` | Included in `/rss.xml` |
Feed archive/category routes are prefixed with `/feed/` to avoid colliding with the equivalent Blog routes, since both are rendered by the shared `src/templates/sidebar.tsx` component (via its `basePath` prop).
## Notes
- To add a **blog** post instead, follow the same steps but use `content/blog/` and the `/blog/` routes.
- The canonical human-readable version of this guide is `docs/adding-feed-posts.md`.
+5 -9
View File
@@ -10,20 +10,16 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
node-version-file: '.nvmrc'
- name: Install dependencies
run: |
npm ci --legacy-peer-deps
npm -g install cloudron-surfer
run: npm ci --legacy-peer-deps
- name: Build
run: |
npm run build
run: npm run build
- name: Publish
run: |
surfer put --token ${{ secrets.SURFER_TOKEN }} --server datacontroller.io public/* /
run: npx surfer put --token ${{ secrets.SURFER_TOKEN }} --server datacontroller.io public/* /
+10 -11
View File
@@ -1,26 +1,25 @@
name: Gatsby Publish
# on:
# push:
# branches: main
on:
push:
branches: main
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [14.17.0]
steps:
- uses: actions/checkout@v1
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
- name: Use Node.js from .nvmrc
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
node-version-file: .nvmrc
cache: npm
- name: Install dependencies
run: npm ci --legacy-peer-deps
- name: Check prettier offences
run: npm run lint
+3
View File
@@ -1 +1,4 @@
legacy-peer-deps=true
ignore-scripts=true
save-exact=true
fund=false
+1 -1
View File
@@ -1 +1 @@
v20.19.6
v24.18.0
+34
View File
@@ -0,0 +1,34 @@
## Related repositories
This is the Data Controller **marketing site, blog and feed** (Gatsby). It has two sibling repos, usually checked out side by side:
- **`dc`** - the product source (Angular client + SAS backend). Behaviour of the product itself is defined there; see its `CONTEXT.md` for the domain glossary.
- **`docs.datacontroller.io`** - the user-facing product documentation (MkDocs), published at docs.datacontroller.io.
When writing marketing/blog/feed copy about a feature, treat `docs.datacontroller.io` and `dc` as the source of truth for how the product actually behaves.
## Content structure
- **Blog** (`content/blog/`) - long-form articles, published at `/blog/`.
- **Feed** (`content/feed/`) - short announcements, published at `/feed/`.
- Both are combined into a single RSS feed at `/rss.xml`.
- To add a feed post, use the `add-feed-post` skill (`.agents/skills/add-feed-post/SKILL.md`).
## Agent files
All agent-facing material lives under `.agents/`:
- `.agents/skills/` - pi-discovered skills (e.g. `add-feed-post`).
- `.agents/docs/` - reference docs consumed by skills and these instructions. Notably `sales.md` (product positioning, pain points, audience, pricing and sales motion) - consult it when writing marketing, blog, feed or sales copy.
### Issue tracker
Issues live as markdown files under `.scratch/`. See `.agents/docs/issue-tracker.md`.
### Domain docs
Single-context layout - `CONTEXT.md` at the repo root + ADRs in `.agents/adr/`. See `.agents/docs/domain.md`.
### Writing style
Use regular dashes (`-`) in content, not em-dashes (`—`).
+30
View File
@@ -0,0 +1,30 @@
# Context: datacontroller.io (marketing site)
The public marketing website for Data Controller for SAS®, built with **Gatsby** (React + TypeScript, styled-components). It hosts the product's marketing pages, blog and feed. It is separate from the product itself (`dc`) and the product documentation (`docs.datacontroller.io`).
## What the product is
Data Controller for SAS® is a web application that lets users safely add, modify and delete data in SAS datasets and databases, with an edit-stage-approve workflow and full change history. It runs on SAS Viya, SAS 9 EBI and SASjs Server. For the product's own domain vocabulary (roles, load types, MPE control tables, validations, security), see `dc/CONTEXT.md`; for user-facing feature docs, see the `docs.datacontroller.io` repo. Treat those as the source of truth when writing copy about how the product behaves.
## Layout
- `content/blog/` - long-form articles (published at `/blog/`).
- `content/feed/` - short announcements (published at `/feed/`). See the `add-feed-post` skill.
- `src/pages/` - top-level pages (`index`, `about`, `contact`, `faq`, plus the markdown-driven `{MarkdownRemark.frontmatter__slug}.tsx`).
- `src/markdown-pages/` - standalone markdown pages (e.g. `pricing.md`).
- `src/templates/` - list/post/sidebar templates for blog and feed.
- `src/components/` - shared React components (layout, navibar, footer, hero, seo).
- `static/` - static assets served as-is; `gatsby-*.js` - Gatsby config (feed, node APIs, etc.).
## Conventions
- Use regular dashes (`-`) in content, not em-dashes.
- Blog and feed share a single combined RSS feed at `/rss.xml` (via `gatsby-plugin-feed`); new posts appear automatically on the next build.
- Lint with `npm run lint` (prettier) and typecheck with `npm run typecheck` before considering front-end changes done.
- Preview locally with `npm run develop`.
## Agent files
All agent-facing material lives under `.agents/`: skills in `.agents/skills/` (e.g. `add-feed-post`), and reference docs in `.agents/docs/` (`domain.md`, `issue-tracker.md`).
See `AGENTS.md` for agent skills, writing rules, and where these files live.
+11
View File
@@ -30,3 +30,14 @@
- <img class="alignright" src="/wp-content/uploads/2021/04/2IrsV7v.png" alt="Title" width="352" height="442" />
- <img class="alignleft" src="/wp-content/uploads/2021/04/2IrsV7v.png" alt="Title" width="352" height="442" />
## 📰 Blog & Feed content
There are two markdown-driven content sections on this site:
- **Blog** (`content/blog/`) — long-form articles, product releases and case studies. Published at `/blog/`.
- **Feed** (`content/feed/`) — short announcements and social-media style updates. Published at `/feed/`.
Both sections are combined into a single RSS feed at [`/rss.xml`](https://datacontroller.io/rss.xml).
See [`docs/adding-feed-posts.md`](docs/adding-feed-posts.md) for a step-by-step guide on adding a new Feed post.
@@ -9,6 +9,7 @@ tags:
- Data Controller
- SAS
- Excel
- Recap
---
Today marks the last day of 2024 and another period of growth for Data Controller for SAS. We made **24** [releases](https://git.datacontroller.io/dc/dc/releases) over the last 12 months, moving from [v6.3.1](https://git.datacontroller.io/dc/dc/releases/tag/v6.3.1) to [v6.12.1](https://git.datacontroller.io/dc/dc/releases/tag/v6.12.1). We also on-boarded three new clients - two banks, and an insurance company. Every single one of our existing customers renewed. And some even invested in some new features. In this article we'll go through the major additions made during 2024.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 153 KiB

@@ -4,16 +4,16 @@ description: A recap of the features delivered in Data Controller for SAS over t
date: '2025-12-31 12:00:00'
author: 'Allan Bowe'
authorLink: https://www.linkedin.com/in/allanbowe/
previewImg: './dc2025.jpg'
previewImg: './releases2025.png'
tags:
- Data Controller
- SAS
- Excel
- Recap
---
In 2025, Data Controller’s theme was **making governed, auditable data change easier to roll out** — especially on SAS Viya — while continuing to invest in metadata/catalog capabilities and accessibility.
## What Data Controller is (quick refresher)
## What is Data Controller?
Data Controller is a SAS-powered web application for **adding, modifying, and deleting data** in target tables—while keeping control with data owners via a **review/approve workflow** and a **full audit trail**.
@@ -21,7 +21,7 @@ Under the hood, it’s designed for multi-user, production-friendly operation (l
![](./dc_dataflow.png)
## Data Controller 2025 at a glance
## 2025 at a glance
Across calendar year 2025, [releases](https://git.datacontroller.io/dc/dc/releases) moved from **v6.12.2 (27JAN25)** to **v7.2.5 (09DEC25)**.
@@ -31,9 +31,9 @@ Key themes:
- **Data Catalog expanded** to include SAS catalogs and associated objects / entries, with corresponding storage changes and migrations.
- **Accessibility work accelerated**, culminating in high Lighthouse scores, improved screen reader support, and CI checks to keep it that way.
## 1) SAS Viya: from "deployment project" to "quick start"
## 1) Viya: from "deployment project" to "quick start"
A major through-line of 2025 was reducing the friction of getting DC live on Viya:
A major through-line of 2025 was reducing the friction of getting DC live on SAS Viya:
- **Content Security Policy (CSP) hardening & UI refactor**
The UI was refactored to comply with Viya’s default (and strict) CSP, with deploy-flow improvements targeted at Viya.
@@ -44,7 +44,7 @@ A major through-line of 2025 was reducing the friction of getting DC live on Viy
- **Compute-context-aware installs & streaming deploy maturity**
Releases in early June focused on ensuring deployment actions happen with the selected shared compute context, and then delivering a **streaming Viya version** that can self-configure key deployment choices during setup.
## 2) Data Catalog got deeper: SAS catalogs + objects
## 2) Extending the Data Catalog: SAS catalogs + objects
On **June 11, 2025**, **v7.0.0** introduced a major upgrade to cataloging:
@@ -79,7 +79,7 @@ During 2025 we also [detailed the Data Controller use case with AFA Insurance (S
They call out the **approval workflow** and **history/audit** as standout features, and also mention value from dropdowns, hooks, and lineage.
## Selected 2025 release timeline (highlights)
## 2025 Release Highlights
![](./releases2025.png)
@@ -89,7 +89,9 @@ We would like to thank all of our customers for their continued support and feed
> Our mission is for Data Controller to be secure, simple, and fast - when loading ad-hoc data into SAS.
If you'd like to kick the tyres, [reach out](https://datacontroller.io/contact/), or just go ahead and perform an [installation](https://docs.datacontroller.io/dci-requirements/) - the base product is completely free to use, with [premium features](https://datacontroller.io/pricing/) / support plan available on request.
If you'd like to try it out, just go ahead and perform an [installation](https://docs.datacontroller.io/dci-requirements/) - the base product does not require a paid license.
We're also happy to provide complementary deployment support, just [reach out](https://datacontroller.io/contact/).
<!--
@@ -0,0 +1,45 @@
---
title: "Data Controller now supports Snowflake"
description: Data Controller now supports Snowflake as a data modification target, enabling governed editing, approval workflows, and full audit trails for Snowflake tables on both SAS 9 and SAS Viya.
date: '2026-03-14 09:00:00'
author: 'Allan Bowe'
authorLink: https://www.linkedin.com/in/allanbowe/
previewImg: './snowflake.png'
tags:
- Data Management
- Releases
- Snowflake
---
We're delighted to announce that Data Controller now supports **Snowflake** as a data modification target! This means you can use Data Controller's full suite of governed data editing, approval workflows, and audit trail capabilities directly against your Snowflake tables — on both SAS 9 and SAS Viya.
## What This Means for You
If your organisation stores data in [Snowflake](https://www.linkedin.com/company/snowflake-computing/), you can now:
- **Edit** Snowflake table data through the familiar Data Controller interface
- **Submit** changes for review and approval before they are applied
- **Audit** every change with a complete, tamper-evident history
Say goodbye to uncontrolled spreadsheet uploads and ad-hoc database edits. Every Snowflake modification now flows through the same robust approval process that governs the rest of your Data Controller tables.
## Implementation Details
On SAS Viya, the library should be assigned in the autoexec. On SAS 9, Snowflake library assignments can come from the autoexec, or from **SAS Metadata**. Simply register your Snowflake library in SMC or SAS DI Studio as you normally would, and Data Controller will pick it up automatically.
Both the `SNOW` (alias) and `SASIOSNF` engines are supported.
## Native Passthrough SQL
Under the hood, Data Controller uses **native passthrough SQL** to interact with Snowflake. This means:
- Modifications are pushed directly to Snowflake using its own SQL dialect
- The target table does **not** need to be indexed (but must unique on its primary key)
- Maximum performance and compatibility with Snowflake's architecture is assured
## Getting Started
Snowflake support is available out of the box — no additional configuration is required beyond your existing Snowflake library assignment in SAS. More details on Data Controller are available in the [documentation](https://docs.datacontroller.io).
Ready to bring governed data modification to your Snowflake environment? We'd love to [hear from you](/contact)!
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

@@ -0,0 +1,199 @@
---
title: "v7.13 Release: Formulas & Regex"
description: Data Controller 7.13 wires a spreadsheet-grade formula engine into the editor grid, completing the point-of-entry validation story that began with 7.12's regex rules. Both are pure MPE_VALIDATIONS configuration - no code, no deployment.
date: '2026-09-03 12:00:00'
author: 'Allan Bowe'
authorLink: https://www.linkedin.com/in/allanbowe/
previewImg: './v713_cover.png'
tags:
- Releases
- Data Controller
---
# v7.13: Formulas & Regex
We stopped writing release-specific blog posts after v6.1 - the automated, public release system made them redundant for routine version bumps. But every so often a release lands that changes what the product can actually _do_, and v7.13 is one of them. Together with 7.12 it completes a piece of the [roadmap](https://docs.datacontroller.io/roadmap/) we have been chipping away at for a while: **frontend formulae and regex rules**, both configurable purely in `MPE_VALIDATIONS`.
If you configure data entry in Data Controller, this post is a practical guide: how the rules work, how to set them up, and the process flow from config to grid. All the screenshots below are captured from a live editor session against the demo tables, so what you see is what shipped.
## Why this matters
Data Controller's job is to let business users change data safely. A big part of "safely" is catching problems at the point of entry - rather than after an approval, in a batch log, or (worst case) in a report. The [validations](https://docs.datacontroller.io/dcc-validations/) framework already covered length, type, nullability, primary keys, ranges, casing and dropdowns. Two things were missing:
* **Computed values.** Plenty of tables have columns that are derived from other columns - a revenue column that is price times volume, a stamp column that records who last touched a row. Until now the options were a backend hook script (real SAS code to write, test and deploy) or just letting users type anything.
* **Pattern enforcement.** A dropdown is overkill when all you need is "this value looks like an email address" or "this postcode is well-formed". You want the _shape_ of the value checked as typed - and sometimes you want a hard block, sometimes just a gentle warning.
7.12 delivered regex rules (`HARDREGEX` / `SOFTREGEX`). 7.13 delivers formulas (`HARDFORMULA` / `SOFTFORMULA`). Both are rows in a config table. That is the whole feature.
## The process flow: from MPE_VALIDATIONS to the grid
Every configurable rule in Data Controller follows the same pipeline, and the new rules plug straight into it:
1. **Configure.** `MPE_VALIDATIONS` is itself a Data Controller table - open it from the navigation tree like any other, and add a row with `BASE_LIB`, `BASE_DS` and `BASE_COL` pointing at the column you want to govern, `RULE_TYPE` set to the new rule, `RULE_VALUE` holding the formula or pattern, and `RULE_ACTIVE=1`. Submit, approve, done - it's a config change, not a code release. The [MPE_VALIDATIONS table guide](https://docs.datacontroller.io/tables/mpe_validations/) has the full column reference.
2. **Serve.** When a user opens the editor, the [`editors/getdata`](https://code.datacontroller.io/getdata_8sas.html) service extracts the active rules for that table - it filters `MPE_VALIDATIONS` on library, table and `RULE_ACTIVE=1` - and returns them in the `dqrules` object of the response, alongside the table data, schema, and the schema-derived `NOTNULL` constraints.
3. **Apply.** The frontend wires each rule into the Handsontable grid: formula rules are computed live by [HyperFormula](https://hyperformula.handsontable.com/) (the calculation engine behind Handsontable itself), regex rules are evaluated in the browser with the JavaScript regex engine.
4. **Block or warn.** On submit, the standard [cell validation](https://docs.datacontroller.io/dcc-validations/) cycle runs: `HARD` rules block the submission if violated, `SOFT` rules warn but allow.
Here are the new rule types as they appear in the config - one row per rule, `RULE_VALUE` holding the formula or the pattern:
![](./mpe_validations_rules.png)
For regex there is also a config-time guard, and it's a neat piece of dogfooding: because the rule itself lives in a table, saving an edit to `MPE_VALIDATIONS` through Data Controller runs the [post-edit hook](https://code.datacontroller.io/mpe__validations__postedit_8sas.html), which passes every `HARDREGEX` / `SOFTREGEX` `RULE_VALUE` through `PRXPARSE` and rejects the edit if the pattern is invalid - listing the offending columns. A typo in a pattern is caught the moment you save the rule, not the first time a user hits it.
## Regex rules (HARDREGEX / SOFTREGEX, v7.12)
Regex rules validate cell values against a SAS (Perl-style) regular expression. You provide the pattern in `RULE_VALUE`; whether it blocks or warns depends on the rule type:
* **HARDREGEX** - the value **must** match the pattern. If it doesn't, the cell is highlighted red and submission is blocked.
* **SOFTREGEX** - a non-matching value is highlighted yellow as a warning. The user can still submit - it's a nudge, not a block.
### Setting one up
Say `SOME_CHAR` in the demo table must contain either "the" or "data" (case-insensitive). That's one insert:
```sas
insert into &lib..MPE_VALIDATIONS set
tx_from=0
,base_lib="&lib"
,base_ds="MPE_X_TEST"
,base_col="SOME_CHAR"
,rule_type='HARDREGEX'
,rule_value='/the|data/i'
,rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
```
That's a real row from the demo data, by the way - the shipped `MPE_X_TEST` table carries sample `HARDREGEX` and `SOFTREGEX` rules so you can try both without touching your own config.
In the editor it looks like this - an invalid email blocked red by `HARDREGEX`, an invalid postcode warned yellow by `SOFTREGEX`, and a value that passes:
![](./regex_demo.png)
Note the third column in that screenshot, `REGEX_BOTH_COL`. It carries _both_ a `HARDREGEX` and a `SOFTREGEX` rule - and shows neither warning. That's the precedence rule in action: only one regex is ever applied per column, and if both exist the `SOFTREGEX` is ignored entirely, so the column behaves exactly as if it were `HARDREGEX`-only.
### What to know before writing patterns
The full list of gotchas is in the [regex rules documentation](https://docs.datacontroller.io/dcc-validations/#regex-rules); the ones that matter most in practice:
* **Use the PRX delimiter form.** Patterns are authored exactly as [PRXPARSE](https://documentation.sas.com/doc/en/pgmsascdc/9.4_3.5/lefunctionsref/p0s9ilagexmjl8n1u7e1t1jfnzlk.htm) accepts them: `/pattern/flags`, e.g. `/^\d+$/` for "integers only", `/the|data/i` for a case-insensitive contains. The config-time `PRXPARSE` check enforces this - a bare pattern without delimiters will be rejected when you save the rule. (The frontend tolerates bare patterns for backwards compatibility, but don't author new rules that way.)
* **Anchor your own patterns.** The pattern is used **as authored** - it is not auto-anchored. `/the|data/i` matches "the" _anywhere_ in the value. If you want the whole value to match, include `^` and `$` yourself.
* **Stick to the common subset.** The pattern is evaluated in the browser with the JavaScript regex engine, which shares SAS PRX's core syntax (character classes, quantifiers, groups, alternation, `^`/`$` anchors, `\d \w \s` and friends). Three unambiguous Perl-isms are translated automatically - a leading `(?i)` modifier, `\Q...\E` literal sequences, and `\A`/`\z` absolute anchors. But Perl-only constructs such as possessive quantifiers (`a++`) and atomic groups (`(?>...)`) pass the SAS-side check and then silently do nothing in the frontend - so just don't use them.
* **Blank is exempt.** Blank values skip pattern matching on any column type (use the `NOTNULL` rule if you also need populated values). On numeric columns the plain SAS missing (`.`) is also exempt - but special missings (`.A`-`.Z`, `._`) are not: they're deliberately-set values, so your pattern needs to accommodate them.
* **One regex per column.** As above - `HARDREGEX` wins when both are present, and the column-header info dropdown shows only the rule that is actually applied.
* **Length limit.** `RULE_VALUE` is 128 characters, which constrains very long patterns.
* **Deleted rows are exempt.** Cells in rows marked for deletion are not validated or warned (except primary key columns, which still are).
### Where regex beats a dropdown
For currency codes, country codes, account number formats, email shapes, or "must be an integer" - a regex is one row of config versus a 1000-value `HARDSELECT` dropdown. And unlike a dropdown, a regex catches _paste_ operations too, not just typed values.
## Formula rules (HARDFORMULA / SOFTFORMULA, v7.13)
Formula rules make a column compute itself from other columns in the same row - like a spreadsheet formula, but the formula lives in config and applies to every row. When a user opens the editor, the formula is evaluated live and the result is shown in each cell. Change an input, and every dependent cell recalculates on the spot.
### Writing a formula
Formulas use **column names, not cell references** - there is no need to know the grid layout. If you have `A_COL` and `B_COL`, a `FORMULA_HARD_COL` rule is simply:
```
=A_COL * B_COL
```
Each row calculates its own result: row 1's values, row 2's values, and so on. Under the hood each column name is translated to a row-relative cell reference (text inside quotes is left untouched) and handed to HyperFormula - so you get the full spreadsheet function library, `IF`, `SUM`, `ROUND`, `CONCAT` and friends, without writing any code.
The one syntax rule: **each column name must be surrounded by spaces**. `=MATCH( PRICE )` resolves the column reference; `=MATCH(PRICE)` does not - without the surrounding blanks the token isn't recognised as a column reference, and the formula errors rather than using the column's value. The spaces stop column names clashing with function names.
### HARD vs SOFT formulas
* **HARDFORMULA** - the column is read-only. The formula result is always shown and submitted; the user cannot change it. Think calculated amounts, or a `PROCESSED_BY` column.
* **SOFTFORMULA** - the cell shows the formula result, but the user can type a different value if the computed one is wrong. Their value is submitted instead. Useful for derived defaults where the business occasionally needs to override.
### Special values
Formulas can reference three runtime values, resolved when the formula is evaluated:
* `DC.ROW_STATUS` - the row's current state: `M` (Modified), `A` (Added), `D` (Deleted), or `U` (Unchanged). A newly-added row is `A` from the moment it is created - there is no transient state before that.
* `DC.USER_NAME` - the logged-in user id.
* `DC.ORIG_VALUE` - the original cell value before the current edit.
Our demo formula table puts them all to work. There's a row-status column (`=DC.ROW_STATUS`), a user column (`=DC.USER_NAME`), and a `CHANGE_SUMMARY_COL` whose rule reads like a proper audit sentence:
```
=IF( DC.ROW_STATUS ="U","unedited",
DC.USER_NAME &" changed from "& DC.ORIG_VALUE )
```
Here it is live in the editor. We edited `B_COL` on the second row from 10 to 25 - and the whole row reacted: `FORMULA_HARD_COL` recomputed to 50 (`A_COL * B_COL`), `FORMULA_SOFT_COL` recomputed to 27 (`A_COL + B_COL`), and the row status flipped from `U` to `M` - all instantly, all without touching a single line of SAS:
![](./formula_demo.png)
And here's the payoff of those `DC.*` references - the audit columns from the same table, same edit. `ROW_STATUS_COL` flipped to `M`, and the change summary resolved the `IF` formula above into a sentence - "sasdemo changed from orig-2":
![](./formula_audit_demo.png)
Because `DC.ROW_STATUS` is a live reference, it updates as the user works: edit a cell and the stamp flips to your user id; cancel the edit and it reverts.
### Editor behaviour worth knowing
* When you paste a formula into the grid, column names are automatically translated so the formula works in its new position.
* A cell that is overwritten by a formula is flagged (with the original value retained) so you can revert it.
* Formula-looking values pasted from Excel are treated as plain data (not evaluated), unless you explicitly choose "Apply as formula" - a deliberate safety measure so a spreadsheet's internal formulas don't leak into your data as live rules.
* When submitted, it's the formula's computed value that is sent to the backend, never the raw formula text (a primary key column always resolves its live formula immediately to the computed value so the submission keys are correct).
## How it works under the hood (briefly)
Two moving parts, and the boundary between them explains most of the gotchas above.
**Serving the rules.** [`getdata`](https://code.datacontroller.io/getdata_8sas.html) extracts the active `MPE_VALIDATIONS` rows for the target table and returns them in `dqrules`, along with the schema-derived `NOTNULL` constraints. Formulas and regex are frontend rules - they are evaluated in the browser, not in SAS - which is why they arrive as `dqrules` rather than as backend hook scripts.
**Evaluating them.** For formulas, the client translates each column name in `RULE_VALUE` to a row-relative cell reference and hands it to HyperFormula (wired into Handsontable's formulas plugin). For regex, the client parses the PRX `/pattern/flags` form, translates the three Perl-isms it can, and constructs a JavaScript `RegExp`. If a pattern still fails in the browser, the editor treats it as always-valid rather than breaking - a failed pattern never blocks a submission it shouldn't.
**Guarding the config.** Because `MPE_VALIDATIONS` is itself a Data Controller table, a [post-edit hook](https://code.datacontroller.io/mpe__validations__postedit_8sas.html) validates new rules: `PRXPARSE` checks every regex `RULE_VALUE`, and the edit is rejected with the offending columns listed. Invalid rules never reach users.
## Also in these releases
Beyond the headline features, the usual spread of hardening and polishing shipped along the way: row-header status cells are now colour-coded (with a `±` symbol for modified rows), CAS support landed for the `REPLACE` load type, Viya deploy diagnostics were improved, and a large tranche of dependency upgrades (Angular 20, Handsontable 18 pinned, sasjs core v5) keeps the audit trail clean. As ever, the full commit-by-commit detail is in the [release notes](https://git.datacontroller.io/dc/dc/releases).
## Upgrading
The frontend changes are included in the 7.13 release assets. The backend additions are **data-only, optional migrations**:
* The **v7.12 migration** adds `HARDREGEX` / `SOFTREGEX` to the `RULE_TYPE` dropdown in `MPE_VALIDATIONS` (and switches the `MPE_SECURITY.LIBREF` validation to a hook that lists all libraries).
* The **v7.13 migration** adds `HARDFORMULA` / `SOFTFORMULA` to the same dropdown.
Both scripts are in [`sas/sasjs/db/migrations/`](https://git.datacontroller.io/dc/dc/src/branch/main/sas/sasjs/db/migrations) in the source repo, and they're worth running even if you don't plan to use the rules immediately - they only add dropdown values to `MPE_SELECTBOX`.
## Try it yourself
The shipped demo data includes the regex rules on `MPE_X_TEST`, so you can see them working without configuring anything: open the demo library, edit `MPE_X_TEST`, and try entering a `SOME_SHORTNUM` between 1 and 5 (blocked red - `HARDREGEX`), a `PRIMARY_KEY_FIELD` with a decimal point (warned yellow - `SOFTREGEX`), or a `SOME_CHAR` without "the" or "data" in it (blocked - `HARDREGEX`).
For formulas, add a rule to one of your own tables - the `REVENUE = PRICE * VOLUME` example above is a two-minute configuration, and the `DC.*` special values make audit-style columns almost free. Full reference in the [validations docs](https://docs.datacontroller.io/dcc-validations/).
As ever - if you'd like to see additional validation types, [get in touch](https://datacontroller.io/pricing). The roadmap is customer-driven, and the validations list keeps growing.
<!-- Source LinkedIn post:
Every SAS team has a spreadsheet that "helps" load data into production.
You know the one. Someone built it years ago, nobody fully understands it, and it writes straight to a table it probably shouldn't.
What if that spreadsheet was wrong BEFORE anyone could hit send?
That's the idea behind Data Controller - a web app for SAS that lets business users change data safely. Every change is checked at the point of entry, goes for approval, and lands in a full audit trail. No new infrastructure - it runs on the SAS you already have.
The latest release adds two things that used to require a developer:
→ Formulas. Type a rule like = PRICE * VOLUME and the revenue column computes itself - spreadsheet-style, in the browser, live.
→ Pattern checks. "Must be a valid email." "Must be an integer." One row of config. Bad values are blocked or flagged before submission - not found in next month's report.
Both are configuration, not code. If you can fill in a table, you can set them up.
We wrote up how it works (with live screenshots of a bad email being caught red-handed):
Link in the comments 👇
#sas #dataquality #endusercomputing #excel #datagovernance
First comment: https://datacontroller.io/v7-13-formulas-and-regex/
-->
Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1,26 @@
---
title: 'Complex Excel Uploads with Excel Maps'
description: Extract scattered, dynamic data from any Excel workbook using configurable Excel Maps - no manual reformatting required.
date: '2026-07-26 09:00:00'
author: 'Data Controller'
authorLink: https://www.linkedin.com/showcase/data-controller-for-sas
tags:
- Announcements
previewImg: './dc-capture-review-approve.jpeg'
---
# Complex Excel Uploads with Excel Maps
Not all spreadsheets arrive in a tidy, tabular format - and now they don't have to. Data Controller's **Complex Excel Upload** feature lets you extract data from anywhere in a workbook using an **Excel Map**: a configurable set of rules for loading specific cells and ranges. Each rule defines a range ID, a start, and an end, and can use absolute or relative references - for example, matching a specific string in a cell, then moving down and across to capture the desired value. This makes it ideal for business reports and financial spreadsheets where the layout is dynamic and the data you need is scattered across multiple locations.
Once you've dragged in the spreadsheet and the rules have run, submitting the data stages it alongside a copy of the original Excel file - preserving a complete audit trail right back to the source. The extracted data is transposed into a single column in the [`MPE_XLMAP_DATA`](https://docs.datacontroller.io/tables/mpe_xlmap_data/) table, ready for you to build jobs that present it however your business requires. You can also adjust your map's attributes in the [`MPE_XLMAP_INFO`](https://docs.datacontroller.io/tables/mpe_xlmap_info/) table to load data into different target tables.
All of this flows through the standard Data Controller approval process and data quality checks - governance included, straight out of the box.
See it in action:
<div style="position: relative; padding-top: 56.25%; margin-bottom: 2rem;"><iframe title="Complex Excel Uploads" width="100%" height="100%" src="https://vid.4gl.io/videos/embed/7jRSxNs2ENJ3dBEZun4cPf?peertubeLink=0" style="border: 0px; position: absolute; inset: 0px;" allow="fullscreen" sandbox="allow-same-origin allow-scripts allow-popups allow-forms"></iframe></div>
![Example Excel Map configuration](xlmap_example.png)
Learn more in the [documentation](https://docs.datacontroller.io/excel/#complex-excel-uploads).
Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

@@ -0,0 +1,70 @@
---
title: 'Every SAS® Project Needs to Load Data from Excel'
description: After years of SAS consulting, one pattern emerged - every project needed Excel data loads, and every project built them from scratch. Here is how that journey led to Data Controller.
date: '2026-08-17 19:15:00'
author: 'Allan Bowe'
authorLink: https://www.linkedin.com/in/allanbowe/
tags:
- Announcements
previewImg: './sasprojects.jpeg'
---
After several years of SAS consulting I noticed EVERY SINGLE PROJECT had a need to load data from Excel.
And EVERY SINGLE PROJECT would require dedicated SAS code (or ETL jobs) to read that data, cleanse, append to target, and to run in a secure production environment under batch credentials. This meant a dev/test/accept(+signoff)/prod lifecycle.
This process could literally take weeks.
As projects went by, I crafted a generic data capture tool using VBA, Stored Processes, and a shared network drive to share CSVs with SAS. It was a ZERO CODE solution that worked really well, and allowed ANY table to be configured so that business users could make changes in Excel and upload directly to production (with approval step).
It was a godsend.
Still, it had major drawbacks - users had to self-pull the latest version, and we still had this insecure network drive situation.
By 2020 the codebase was completely re-written as a SASjs Powered Web Application. The shared network drive was gone. The browser always has the latest version. We had SAS Viya compatibility and a growing number of satisfied (paying) customers.
By 2022 we had advanced Data Quality features, support for all flavours of SAS (Base, Meta, Viya), special SAS missing numerics and the ability to update formats in catalogs. Big-name customers in Banking, Insurance, Government, Travel, Pharma and other sectors.
In 2024 we had support for complex excel uploads (extract cells / ranges from anywhere in a workbook), detailed data audit, and a data rollback feature.
In 2026 we have a streamlined and tightened Viya deploy and many more validations - such as regex, and soon, frontend formulae.
The need to load ad-hoc data into SAS isn't going away.
Our mission is to make that process simple, secure, and fast.
To see how easily Data Controller can be deployed in your organisation, book a [Discovery Call](https://datacontroller.io/contact/) - or start today with the free [Community version](https://datacontroller.io/pricing/).
<!--
Source LinkedIn post:
After several years #SAS consulting I noticed EVERY SINGLE PROJECT had a need to load data from Excel.
And EVERY SINGLE PROJECT would require dedicated SAS code (or ETL jobs) to read that data, cleanse, append to target, and to run in a secure production environment under batch credentials. This meant a dev/test/accept(+signoff)/prod lifecycle.
This process could literally take weeks.
As projects went by, I crafted a generic data capture tool using VBA, Stored Processes, and a shared network drive to share CSVs with SAS. It was a ZERO CODE solution that worked really well, and allowed ANY table to be configured so that business users could make changes in Excel and upload directly to production (with approval step).
It was a godsend.
Still, it had major drawbacks - users had to self-pull the latest version, and we still had this insecure network drive situation.
By 2020 the codebase was completely re-written as a SASjs Powered Web Application. The shared network drive was gone. The browser always has the latest version. We had #SASViya compatibility and a growing number of satisfied (paying) customers.
By 2022 we had advanced Data Quality features, support for all flavours of SAS (Base, Meta, Viya), special SAS missing numerics and the ability to update formats in catalogs. Big-name customers in Banking, Insurance, Government, Travel, Pharma and other sectors.
In 2024 we had support for complex excel uploads (extract cells / ranges from anywhere in a workbook), detailed data audit, and a data rollback feature.
In 2026 we have a streamlined and tightened Viya deploy and many more validations - such as regex, and soon, frontend formulae.
The need to load ad-hoc data into SAS isn't going away.
Our mission is to make that process simple, secure, and fast.
To see how easily Data Controller can be deployed in your organisation, book a Discovery Call (link in comments) - or start today with the free Community version.
#datamanagement
Image prompt: A flat-style illustration showing an Excel spreadsheet with an arrow flowing into a secure database server, with a subtle timeline (VBA era to modern web app) in the background, blue and green corporate palette, clean minimal design suitable for a LinkedIn/blog cover image.
-->
Binary file not shown.

After

Width:  |  Height:  |  Size: 582 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 KiB

@@ -0,0 +1,67 @@
---
title: 'The 5 Lines of Defence for Business Data Inputs'
description: How Data Controller for SAS® defends data quality for workflows deriving from business inputs - data model, permissions, validation checks, post edit hooks, and approvals.
date: '2026-08-27 09:00:00'
author: 'Allan Bowe'
authorLink: https://www.linkedin.com/in/allanbowe/
tags:
- Announcements
previewImg: './five-lines-of-defence.jpeg'
---
How does Data Controller for SAS® defend data quality for data workflows deriving from business inputs?
**⚔️ 1st Line of Defence - Data Model ⚔️**
The interface will only accept inputs conforming to this model (columns, types, lengths, indexes, constraints etc). The schema determines the behaviour - eg, a date format results in a date picker.
**⚔️ 2nd Line of Defence - Data Permissions ⚔️**
Changes are made using a SAS System Account (eg `sassrv`). This means you can safely DENY write-access to data for business users, preventing un-controlled data ingestion.
**⚔️ 3rd Line of Defence - Validation Checks ⚔️**
Additional validation checks (eg value ranges, specific patterns) can be configured to run at the point of data capture, prior to SAS upload. See the [validation documentation](https://docs.datacontroller.io/dcc-validations/) for details.
**⚔️ 4th Line of Defence - Post Edit Hook ⚔️**
Complex / customer specific validation can be deployed as SAS code, to run after every EDIT (prior to APPROVAL) using a [post edit hook](https://docs.datacontroller.io/dcc-tables/#post_edit_hook). A failure here results in immediate user feedback / change rejection.
**⚔️ 5th Line of Defence - Approval Step(s) ⚔️**
Changes are reviewed with [one or more approvals](https://docs.datacontroller.io/dcc-tables/) BEFORE being applied to the target database. A full audit trail is also maintained.
All functionality is ZERO-CODE, works on SAS Viya / EBI / SASjs Server, and applies to any database you have an ACCESS engine for. There is also a [community edition](https://datacontroller.io/pricing/) - free for unlimited users.
If you'd like to strengthen your own defences - [let's chat](https://datacontroller.io/contact/).
<!--
Source LinkedIn post:
How does Data Controller for SAS® defend #DataQuality for #DataWorkflows deriving from Business Inputs?
⚔️ 1st Line of Defence - Data Model ⚔️
The interface will only accept inputs conforming to this model (columns, types, lengths, indexes, constraints etc). The schema determines the behaviour - eg, a date format results in a date picker.
⚔️ 2nd Line of Defence - Data Permissions ⚔️
Changes are made using a SAS System Account (eg `sassrv`). This means you can safely DENY write-access to data for business users, preventing un-controlled #DataIngestion.
⚔️ 3rd Line of Defence - Validation checks ⚔️
Additional validation checks (eg value ranges, specific patterns) can be configured to run at the point of data capture, prior to #SAS upload. The docs for these are here: https://lnkd.in/djtaGPsr
⚔️ 4th Line of Defence - Post Edit Hook ⚔️
Complex / customer specific validation can be deployed as SAS code, to run after every EDIT (prior to APPROVAL). A failure here results in immediate user feedback / change rejection.
⚔️ 5th Line of Defence - Approval Step(s) ⚔️
Changes are reviewed with one or more approvals BEFORE being applied to the target database. A full audit trail is also maintained.
All functionality is ZERO-CODE, works on #sasViya / EBI / SASjs Server, and applies to any database you have an ACCESS engine for. There is also a Community edition - free for unlimited users.
#datagovernance #sasadmin #saspartners
-->
<!-- Image prompt:
A flat-design illustration in the Data Controller brand style (dark navy background, teal/green and orange accents), composed around a SINGLE CENTRAL focal point so it survives a square crop: a medieval-castle motif reimagined as a data fortress - five concentric defensive walls (rendered as clean glowing ring segments, each a slightly different teal/green shade, subtly numbered 1-5) surrounding a central database cylinder that glows safely at the core. An arrow or data-packet stream approaches from the top, passing checkpoints in each wall: a schema/grid icon (wall 1), a padlock (wall 2), a checklist with ticks (wall 3), a code angle-brackets icon (wall 4), and a stamp/approval tick (wall 5). One red/orange invalid packet is shown bouncing off an outer wall. All key elements within the central square of the frame; outer left and right thirds contain only background texture and glow, safe to crop. Minimal text, no logos. Professional but lightly playful, suitable for a B2B data product site. 16:9 landscape, 1200x627, suitable as a blog/feed cover image.
Generated with: local Routstr node (see blog-dev/skills routstr-image-generation), model gemini-3.1-flash-lite-image, output 1424x736 JPEG, ~31 sats.
-->
Binary file not shown.

After

Width:  |  Height:  |  Size: 115 KiB

+24
View File
@@ -0,0 +1,24 @@
---
title: 'FSEDIT-Style Paging in the Edit Record Form'
description: Page through your data one record at a time in the Edit Record dialog - a modern take on the classic SAS PROC FSEDIT full-screen editor.
date: '2026-08-09 09:00:00'
author: 'Data Controller'
authorLink: https://www.linkedin.com/showcase/data-controller-for-sas
tags:
- Announcements
previewImg: './example.png'
---
# FSEDIT-Style Paging in the Edit Record Form
Anyone who has worked with SAS for a while will remember `PROC FSEDIT`. Part of the SAS/FSP (Full-Screen Product) family alongside `PROC FSVIEW` and `PROC FSBROWSE`, it was for years the standard way to edit a dataset interactively: one observation per screen, typing directly over values, and commiting straight to the underlying dataset. That focused, one-record-at-a-time view was a simple way to amend data without getting lost in a wide grid - though edits landed immediately, with no staging or approval step in between.
Data Controller's **Edit Record** dialog brings that experience into the browser. Instead of scanning across dozens of columns in the grid, you open a single row as a form - every field labelled and stacked vertically, with dropdowns, validations and formatting applied just as they are in the main editor.
You can **page through your records** without leaving the form. Previous and Next arrows move you one record at a time, and a **Current row** indicator shows exactly where you are in the extract. It's the fastest way to work through a set of rows methodically - reviewing, correcting and moving on - especially for wide tables where horizontal scrolling in the grid slows you down.
<iframe title="Forwards_Backwards_in_Edit_Record_Modal" width="560" height="315" src="https://vid.4gl.io/videos/embed/o8yzHqRqTWhYo6cgNACRkg" style="border: 0px;" allow="fullscreen" sandbox="allow-same-origin allow-scripts allow-popups allow-forms"></iframe>
As always, everything flows through the standard Data Controller workflow: your changes are staged, validated and sent for approval before they reach the target table, with a full audit trail retained.
Administrators who prefer to keep the grid-only experience can disable the dialog with the [`DC_RESTRICT_EDITRECORD`](https://docs.datacontroller.io/dcc-options/#dc_restrict_editrecord) option.
Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

@@ -0,0 +1,68 @@
---
title: 'Some of You Will "Git" This'
description: Pull requests, diffs, hooks, tests and pipelines - for your production data. Data Controller brings the git workflow to master data management in SAS.
date: '2026-08-13 14:00:00'
author: 'Data Controller'
authorLink: https://www.linkedin.com/showcase/data-controller-for-sas
tags:
- Announcements
previewImg: './gitflow.png'
---
# Some of You Will "Git" This
If you could make a "pull request" for production data, how might that look?
Perhaps:
- ✅ A [before / after comparison](https://docs.datacontroller.io/dc-userguide/) of the values that had changed
- ✅ [Security](https://docs.datacontroller.io/dcc-security/) (who can submit / approve in each "repo")
- ✅ Only store the changed data (not full copies)
- ✅ A new "diff" when the base table changes
- ✅ Ability to choose [one or more approvers](https://docs.datacontroller.io/dcc-tables/)
- ✅ [HOOK scripts](https://docs.datacontroller.io/dcc-tables/) before / after a commit
- ✅ Tests ([DQ checks](https://docs.datacontroller.io/dcc-validations/)) prior to merge
- ✅ A [pipeline](https://docs.datacontroller.io/dcc-tables/) following a merge
- ✅ [Emails](https://docs.datacontroller.io/emails/) for "status" changes
- ✅ Comments by reviewers
- ✅ Full [history](https://docs.datacontroller.io/dc-userguide/) of changes
Sound familiar? This is exactly how developers have managed *code* for decades - and Data Controller brings the same discipline to *data*. Every edit to a table in SAS® goes through an edit - stage - approve workflow: changes are staged as a diff, checked by validation rules and hook scripts, reviewed and approved by the people you choose, then loaded. The audit trail captures who changed what, when and why - the `git blame` of master data.
If you're ready to "commit" to Data Control, we invite you to "clone" the latest version of [Data Controller for SAS®](https://datacontroller.io) - which has ALL these features - and "push" to your "main" environment for some "master" data management!
<!-- Source LinkedIn post:
Some of you will "git" this..
If you could make a "pull request" for production data, how might that look?
Perhaps:
✅ A before / after comparison of the values that had changed
✅ Security (who can submit / approve in each "repo")
✅ Only store the changed data (not full copies)
✅ A new "diff" when the base table changes
✅ Ability to choose one or more approvers
✅ HOOK scripts before / after a commit
✅ Tests (DQ checks) prior to merge
✅ A pipeline following a merge
✅ Emails for "status" changes
✅ Comments by reviewers
✅ Full history of changes
Sound familiar? This is exactly how developers have managed CODE for decades - and Data Controller brings the same discipline to DATA.
Every edit to a table in Data Controller for SAS® goes through an edit - stage - approve workflow: changes are staged as a diff, checked by validation rules and hook scripts, reviewed and approved by the people you choose, then loaded.
The audit trail captures who changed what, when and why - the "git blame" of master data.
If you're ready to "commit" to Data Control, we invite you to "clone" the latest version of Data Controller for SAS - which has ALL these features - and "push" to your "main" environment for some "master" data management!
Link in the comments below 👇
#dataops #mdm #masterdata #dataquality #sas #sasjs #datagovernance
-->
<!-- Image prompt:
A clean, flat-design illustration in the Data Controller brand style showing a database table flowing through a git-style pull-request workflow: rows of a data table on the left passing through a "stage" step shown as a highlighted before/after diff of changed cells, then an approval checkpoint with a tick and reviewer avatars, then merging into a production database cylinder on the right. Git-flavoured touches: a branch line diagram arcing from "staging" branch into "main", small commit dots along the line. Dark navy background, teal/green accents, orange highlights on the changed cells. Minimal text: "stage -> approve -> merge". 16:9 landscape, suitable as a blog/feed cover image.
-->
Binary file not shown.

After

Width:  |  Height:  |  Size: 512 KiB

@@ -0,0 +1,73 @@
---
title: 'Horror Stories of Business Data Ingestion'
description: Real-world tales of business data ingestion gone wrong - from broken ETL jobs to network-share "audit trails" and the Access database under the operations desk.
date: '2026-08-15 09:00:00'
author: 'Data Controller'
authorLink: https://www.linkedin.com/showcase/data-controller-for-sas
previewImg: './horror.jpeg'
tags:
- Master Data Management
---
# Horror Stories of Business Data Ingestion
> "We spent 6 weeks recruiting a Data Engineer, who built several complex ETL workflows, which promptly broke on his departure thanks to a minor change in the source file"
> "I wrote the SQL update script myself, which was working fine until my holiday cover adjusted the where clause, and wiped half the records. At least, that's what I think happened. We didn't see the discrepancy until the following month."
> "We often need to upload new mapping files and so all the Business Analysts in the Customer Insights team are being trained on the enterprise ETL tool"
> "Our DBA does the manual updates, but he only has 0.5 free days per month allocated to the task - and he will only do it AFTER month end"
> "We have a dedicated data engineering team and they can knock out an ETL job in a few minutes. The problem is that it takes 4-8 hours of documentation and meetings to set the requirements, and 4-8 weeks to deploy the job into production"
> "If you need to change the data values, just double click the table in our .NET application and type it in. Remember to close it afterwards though, else the overnight batch job will fail due to a physical lock. This happens quite a lot - it's safest to just close the application afterwards and re-open it."
> "We keep an audit trail of ALL manual updates. Whoever makes the change will just open the spreadsheet, fill in the necessary records, and save the file with a new version number on the department network share"
> "We don't have any business data ingestion, we only load source data, and it is never modified"
> "We export the data from the web report into Excel. We copy/paste the data into another Excel sheet where we execute some VBscript macros that were developed by a guy who left the company. After that, the updated data are saved as a csv file on a network drive. The file is then picked up by a batch job and loaded into the database. The job is scheduled to run every 15 minutes. The updated records can then be displayed in our reports." - [Xavier Bizoux](https://www.linkedin.com/in/xavierbizoux) ([source](https://www.linkedin.com/feed/update/urn:li:activity:7183406509450547200?commentUrn=urn%3Ali%3Acomment%3A%28activity%3A7183406509450547200%2C7184815374624772096%29&dashCommentUrn=urn%3Ali%3Afsd_comment%3A%287184815374624772096%2Curn%3Ali%3Aactivity%3A7183406509450547200%29))
Coupled with:
> "We don't load our data to the DataWarehouse, it's too difficult. We just stick it all in the team ACCESS database on the PC under the operations desk"
What are YOUR horror stories?
<!--
Source LinkedIn post:
Horror Stories of Business Data Ingestion
"We spent 6 weeks recruiting a Data Engineer, who built several complex ETL workflows, which promptly broke on his departure thanks to a minor change in the source file"
"I wrote the SQL update script myself, which was working fine until my holiday cover adjusted the where clause, and wiped half the records. At least, that's what I think happened. We didn't see the discrepancy until the following month."
"We often need to upload new mapping files and so all the Business Analysts in the Customer Insights team are being trained on the enterprise ETL tool"
"Our DBA does the manual updates, but he only has 0.5 free days per month allocated to the task - and he will only do it AFTER month end"
"We have a dedicated data engineering team and they can knock out an ETL job in a few minutes. The problem is that it takes 4-8 hours of documentation and meetings to set the requirements, and 4-8 weeks to deploy the job into production"
"If you need to change the data values, just double click the table in our .NET application and type it in. Remember to close it afterwards though, else the overnight batch job will fail due to a physical lock. This happens quite a lot - it's safest to just close the application afterwards and re-open it."
"We keep an audit trail of ALL manual updates. Whoever makes the change will just open the spreadsheet, fill in the necessary records, and save the file with a new version number on the department network share"
"We don't have any business data ingestion, we only load source data, and it is never modified"
"We export the data from the web report into Excel. We copy/paste the data into another Excel sheet where we execute some VBscript macros that were developed by a guy who left the company. After that, the updated data are saved as a csv file on a network drive. The file is then picked up by a batch job and loaded into the database. The job is scheduled to run every 15 minutes. The updated records can then be displayed in our reports." - Xavier Bizoux (https://www.linkedin.com/in/xavierbizoux)
Source comment: https://www.linkedin.com/feed/update/urn:li:activity:7183406509450547200?commentUrn=urn%3Ali%3Acomment%3A%28activity%3A7183406509450547200%2C7184815374624772096%29&dashCommentUrn=urn%3Ali%3Afsd_comment%3A%287184815374624772096%2Curn%3Ali%3Aactivity%3A7183406509450547200%29
Coupled with:
"We don't load our data to the DataWarehouse, it's too difficult. We just stick it all in the team ACCESS database on the PC under the operations desk"
What are YOUR horror stories?
#masterdatamanagement #dataops #datagovernance
-->
<!-- Image prompt:
A spooky-but-professional flat-design illustration in the Data Controller brand style for a "Horror Stories of Business Data Ingestion" post: a haunted-house style scene rendered in dark navy, with ghostly spreadsheets, a broken ETL pipeline (cracked pipe segments leaking data rows), an old Microsoft Access database icon lurking under a desk, and a graveyard of tombstones labelled with file versions like "mapping_v12_FINAL_v2.xlsx". A full moon and a few bats in the background. Teal/green and orange accents for the eerie glow on the data elements. Humorous, Halloween-flavoured but still suitable for a B2B data product site. Minimal text. 16:9 landscape, suitable as a blog/feed cover image.
-->
@@ -0,0 +1,72 @@
---
title: 'Struggling with Legacy Data Ingestion?'
description: Filename conventions, network drives, ancient formats and 5am batch failures - legacy spreadsheet ingestion hurts everyone. With Data Controller for SAS®, those issues disappear.
date: '2026-08-24 09:00:00'
author: 'Allan Bowe'
authorLink: https://www.linkedin.com/in/allanbowe/
tags:
- Announcements
previewImg: './legacy-data-ingestion.jpeg'
---
Do you struggle against legacy data ingestion processes?
Are your analysts saving spreadsheets with a particular filename, on a particular network drive, on a particular day, with a specific structure?
Perhaps your process necessitates an ancient format (.xls, Excel '95) or particular encoding.
Sucks, right?
It sucks also for the Platform Manager, who gets the email at 5am when the batch breaks due to something not-quite-right in said input file.
Or the Data Engineer, spending all day knocking out generic excel import routines. Or the sponsor, paying for a broken process, that should take seconds.
With Data Controller for SAS®, all these issues... disappear.
Thanks to our OEM licence of [SheetJS](https://sheetjs.com), analysts can self-serve data uploads via the browser - no (insecure) network drive needed, nor thick client install.
And the data really can be **anywhere** in the workbook - in any shape, spread across any number of disparate sheets, ranges and cells. A range of [import options](https://docs.datacontroller.io/excel/) handles everything from clean tabular sheets to sprawling financial reports.
Once automatic validations and DQ checks pass, and approval received, the target table is updated and any onward jobs are triggered. Needless to say, there is a full audit trail right back to the original excel file.
Not only that - you can load ANY database, SAS dataset, format, or CAS table. New data targets are configured by the admin - zero code, nothing to deploy or drag through to production.
There is also a [community edition](https://datacontroller.io/pricing/), free for unlimited users (restricted by rows).
If you'd like to excel by spending less time on Excel - [let's chat](https://datacontroller.io/contact/).
<!--
Source LinkedIn post:
Do you struggle against legacy data ingestion processes?
Are your analysts saving spreadsheets with a particular filename, on a particular network drive, on a particular day, with a specific structure?
Perhaps your process necessitates an ancient format (.xls, Excel '95) or particular encoding.
Sucks, right?
It sucks also for the Platform Manager, who gets the email at 5am when the batch breaks due to something not-quite-right in said input file.
Or the Data Engineer, spending all day knocking out generic excel import routines. Or the sponsor, paying for a broken process, that should take seconds.
With Data Controller for SAS®, all these issues... disappear.
Thanks to our OEM licence of SheetJS, analysts can self-serve data uploads via the browser - no (insecure) network drive needed, nor thick client install.
The data can be anywhere in the workbook - in any shape, across any number of disparate sheets, ranges and cells - thanks to a range of import options. Columns in any order. Additional columns (not in the target table) are simply ignored. Invalid columns cause immediate rejection, so the uploader can rectify.
Once automatic validations and DQ checks pass, and approval received, the target table is updated and any onward jobs are triggered. Needless to say, there is a full audit trail right back to the original excel file.
Not only that - you can load ANY database, #SAS dataset or CAS table. New data targets are configured by the admin - zero code, nothing to deploy or drag through to production.
There is also a community edition, free for unlimited users (restricted by rows).
If you'd like to excel by spending less time on Excel - let's chat.
#datamanagement #excel #datagovernance
-->
<!-- Image prompt:
A flat-design illustration in the Data Controller brand style (dark navy background, teal/green and orange accents), composed around a SINGLE CENTRAL focal point so it survives a square crop: in the centre, a large glowing browser window with a spreadsheet file being dropped into it and a green tick, feeding a short downward flow into a database cylinder directly beneath. Sinking into shadow at the very bottom of the frame, small and half-buried, the legacy relics being left behind: a floppy disk labelled .xls, a dusty network drive wrapped in chains, and an alarm clock showing 5am. Light radiates from the central browser window against the dark background. All key elements within the central square of the frame; outer left and right thirds contain only background texture and glow, safe to crop. Minimal text, no logos. Professional but lightly humorous, suitable for a B2B data product site. 16:9 landscape, 1200x627, suitable as a blog/feed cover image.
-->
Binary file not shown.

After

Width:  |  Height:  |  Size: 370 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

+72
View File
@@ -0,0 +1,72 @@
---
title: 'SAS-isms: Library Info'
description: Expanding a VIEW library in Data Controller surfaces engine, size, paths, table count and schema - with the same data queryable from the underlying tables.
date: '2026-08-11 09:00:00'
author: 'Data Controller'
authorLink: https://www.linkedin.com/showcase/data-controller-for-sas
tags:
- SAS-isms
previewImg: './library-info.jpeg'
---
# Library Info
One of many "SAS-isms" in Data Controller for SAS®.
When expanding a VIEW library, Data Catalog items are surfaced:
- Library Engine
- Library Size (Bytes, MB, GB)
- Paths (if BASE engine)
- Table Count
- Schema (if a database)
![Library engine and host information](./library-enginehost-info.jpeg)
And more. Much of this data can also be queried from the underlying tables:
- [MPE_DATACATALOG_LIBS](https://docs.datacontroller.io/tables/mpe_datacatalog_libs) - engine, paths, permissions, owners, schemas, metadata name / id
- [MPE_DATASTATUS_LIBS](https://docs.datacontroller.io/tables/mpe_datastatus_libs) - frequently changing attributes: library size, table count, catalog count
The split is deliberate - stable attributes live in the DATACATALOG tables, volatile ones (size, counts) in DATASTATUS. All of it is stored with SCD2, so you can track how your data landscape changes over time.
Refreshes are driven by the `mpe_refreshlibs` and `mpe_refreshtables` macros - any user can refresh an individual library from the VIEW menu, and admins can scan the entire estate from the System page.
Similar information is available for datasets - it's essentially an extract from `proc contents`, using the [mp_dsmeta](https://core.sasjs.io/mp__dsmeta_8sas.html) macro from the [SASjs Core](https://github.com/sasjs/core) library.
![Dataset attributes](./dataset-attributes.jpeg)
<!--
LinkedIn version (plain text - no markdown, links in comments):
One of many "SAS-isms" in Data Controller for SAS® - "Library Info"
When expanding a VIEW library, Data Catalog items are surfaced:
▪️ Library Engine
▪️ Library Size (Bytes, MB, GB)
▪️ Paths (if BASE engine)
▪️ Table Count
▪️ Schema (if a database)
And more. Much of this data can also be queried from the underlying tables (links in the comments below):
▪️ MPE_DATACATALOG_LIBS - engine, paths, permissions, owners, schemas, metadata name / id
▪️ MPE_DATASTATUS_LIBS - frequently changing attributes: library size, table count, catalog count
The split is deliberate - stable attributes live in the DATACATALOG tables, volatile ones (size, counts) in DATASTATUS. All of it is stored with SCD2, so you can track how your data landscape changes over time.
Refreshes are driven by the mpe_refreshlibs and mpe_refreshtables macros - any user can refresh an individual library from the VIEW menu, and admins can scan the entire estate from the System page.
Similar information is available for datasets - it's essentially an extract from proc contents, using the mp_dsmeta macro from the SASjs Core library (link in the comments).
#sas #datacatalog #datagovernance #sasjs #metadata
Suggested first comment:
MPE_DATACATALOG_LIBS: https://docs.datacontroller.io/tables/mpe_datacatalog_libs
MPE_DATASTATUS_LIBS: https://docs.datacontroller.io/tables/mpe_datastatus_libs
mp_dsmeta macro: https://core.sasjs.io/mp__dsmeta_8sas.html
Attach library-info.jpeg (and optionally library-enginehost-info.jpeg + dataset-attributes.jpeg) to the post.
-->
Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 754 KiB

@@ -0,0 +1,85 @@
---
title: 'Oops! Now You Can Roll Back Data Changes'
description: Despite all the checks in Data Controller for SAS®, sometimes the wrong updates get approved. You can now roll back to a previous version - with full audit history preserved.
date: '2026-08-20 09:00:00'
author: 'Data Controller'
authorLink: https://www.linkedin.com/showcase/data-controller-for-sas
tags:
- Announcements
previewImg: './ctrl-z.jpeg'
---
# Oops! Now You Can Roll Back Data Changes
Despite the MANY checks and guarantees in Data Controller for SAS®, sometimes it can happen that the wrong updates are approved and applied.
Thankfully, it is now possible to **roll back** data changes to a previous state.
![Two trains colliding - the wrong data changes applied](rollback-trains-meme.jpeg)
## How it works in practice
Behind the scenes, rollback is not a silent undo. It is a **first-class approval workflow** just like any other edit. When you choose to restore a previous version, the backend - via the [`%mp_stripdiffs`](https://core.sasjs.io/mp__stripdiffs_8sas.html) macro - reads the `MPE_AUDIT` table (or a custom `AUDIT_LIBDS` configured for the table) and computes every difference between the current state and the version you want to go back to.
It handles all three change types:
- **Deleted rows** are re-inserted with their original values.
- **Modified rows** are reverted to their previous values.
- **Added rows** are marked for deletion with `_____DELETE__THIS__RECORD_____="Yes"`.
The computed differences are written to a new staging package in the approvals directory, complete with a CSV and a `macvars.sas` snapshot of the session context. A new `LOAD_REF` is generated, and the package is submitted via the standard `%mpe_loader` service - so it goes through the same edit-stage-approve workflow as any manual change.
This means the rollback itself is **reviewable and approvable**. Nothing is applied silently, and the full audit trail is maintained: the reversion appears as a new load reference in `MPE_SUBMIT`, `MPE_REVIEW`, `MPE_DATALOADS`, and `MPE_AUDIT`, just like any other submission.
## Security and access
Not everyone can roll back everything. The `%mpe_checkrestore` macro enforces a strict access check before the restore service will run:
- The load must actually exist and have been loaded (no rollbacks of unapproved submissions).
- The table must be configured with an audit table.
- The user must have `EDIT` access to the target table.
- If the user is not an admin, Row Level Security or Column Level Security rules on the table will block the restore.
If access is denied, the service aborts with a clear reason - no opaque errors.
## What this means for compliance
Because the rollback creates a new, approved changeset rather than silently rewinding history, auditors can see exactly what was reverted, when, and by whom. The `MPE_AUDIT` table retains the record of every intermediate state, so nothing is ever truly lost. For tables where data integrity is critical - regulatory reporting, actuarial assumptions, steering parameters - this is the difference between "we have no idea what happened" and "here is the complete chain of custody."
This feature works for all temporal-aware load types (`UPDATE`, `TXTEMPORAL`, and `BITEMPORAL`) and respects SCD2 validity windows. And the whole process is built on the same open-source macro library that powers the rest of Data Controller - so you can inspect, test, and audit the code itself.
Full documentation is here: https://docs.datacontroller.io/restore/
<!--
Source LinkedIn post:
"Can we put it back the way it was?" should never be a hard question to answer.
Yet for many teams managing reference data, mappings, and regulatory adjustments in SAS®, a wrong approval means exactly that — a forensic exercise, an uncomfortable audit conversation, and a quiet hope that nobody upstream consumed the bad data.
Data Controller now lets you ROLL BACK a table to any previous state.
Not by silently rewinding history - the one thing your auditor definitely does not want. Instead, the reversion is packaged as a brand NEW change that goes through the same review and approval as any other edit:
- The reversion diff is calculated and staged
- An approver reviews it before anything is applied
- The rollback itself lands in the audit trail - who, what, when, why
Nothing disappears. The original mistake, its correction, and every state in between all remain fully traceable.
Access rules apply exactly as they do for edits - the same group permissions, the same restrictions. A rollback cannot be used as a shortcut around your controls.
For regulated reporting data - the kind where "oops" is a reportable event, not a shrug - this closes the loop between catching an error and evidencing its correction.
The underlying process is open source.
Link in the comments below 👇
#sas #sasviya #datagovernance #datamanagement
-->
<!-- Image prompt:
A dramatic flat-design meme illustration in the Data Controller brand style: two high-speed trains colliding head-on at the center of the frame, with data rows and spreadsheet cells flying out of the impact. A group of onlookers in business-casual attire stand in the foreground, heads bowed, looking on in shared sadness. Dark navy background, teal/green and muted orange accents. The collision represents conflicting data changes; the sad onlookers are the data stewards. Minimal text, no labels. Slightly stylised, not gory - suitable for a B2B data product audience. 16:9 landscape, suitable as a blog/feed cover image.
-->
Binary file not shown.

After

Width:  |  Height:  |  Size: 749 KiB

@@ -0,0 +1,90 @@
---
title: 'Data Controller for SAS® - Use Cases and Features'
description: A rundown of Data Controller use cases and features - from secure data capture and approval workflows to SCD2 load routines, validation rules, and data lineage.
date: '2026-08-12 09:00:00'
author: 'Data Controller'
authorLink: https://www.linkedin.com/showcase/data-controller-for-sas
tags:
- Announcements
previewImg: './usecases.jpeg'
---
# Data Controller for SAS® - Use Cases and Features
What can you actually *do* with Data Controller? Here's the rundown:
- Data viewer with search, filter, and URL links - without table locks
- Secure data (stop end-user write access to production tables)
- Configure inputs in seconds (vs days or weeks for ETL jobs)
- Preconfigured load routines including SCD2 and Bitemporal
- Avoid exposing sensitive data in unsecured staging areas
- Enable SAS workflows (trigger a SAS job on data change)
- Prevent spreadsheet changes from halting batch flows
- Email alerts when data is changed / about to change
- Audit manual overrides on regulatory reporting data
- Four-eyes (or more) approval for all data changes
- View and amend formats in SAS format catalogs
- Export DDL in SAS, TSQL or PGSQL format
- Explore SAS 9 metadata and data lineage
- Work with special SAS missing numerics
- Upload password-protected Excel files
- Upload directly from CSV or Excel
- Composite / retained key support
- Enforce data quality at source
- Column and Row Level Security
- Advanced validation rules
Our software is used and trusted (in production) by:
- Governments
- Insurers
- Pharma
- Banks
Plus various other industry sectors - references available.
Would you like to kick the tyres? [Get in touch](/contact) for details.
<!--
Source LinkedIn post:
Data Controller for SAS® Use Cases and Features:
✓ Data viewer with search, filter, and URL links - without table locks
✓ Secure data (stop end-user write access to production tables)
✓ Configure inputs in seconds (vs days or weeks for ETL jobs)
✓ Preconfigured load routines including SCD2 and Bitemporal
✓ Avoid exposing sensitive data in unsecured staging areas
✓ Enable SAS workflows (trigger a SAS job on data change)
✓ Prevent spreadsheet changes from halting batch flows
✓ Email alerts when data is changed / about to change
✓ Audit manual overrides on regulatory reporting data
✓ Four-eyes (or more) approval for all data changes
✓ View and amend formats in SAS format catalogs
✓ Export DDL in SAS, TSQL or PGSQL format
✓ Explore SAS 9 metadata and data lineage
✓ Work with special SAS missing numerics
✓ Upload password-protected Excel files
✓ Upload directly from CSV or Excel
✓ Composite / retained key support
✓ Enforce data quality at source
✓ Column and Row Level Security
✓ Advanced validation rules
Our software is used and trusted (in production) by:
- Governments
- Insurers
- Pharma
- Banks
Plus various other industry sectors - references available.
Would you like to kick the tires? DM for details.
#sas #sasviya #dataquality #datacapture
Image prompt:
Flat vector illustration, dark slate blue background (#314351), wide 1.91:1 banner. A clean modern data dashboard interface floating in the centre showing a data grid/table with a search bar and filter icons. Around it, a neat ring of small white and light-grey icons representing governance features: a padlock (security), a magnifying glass over rows (audit), two checkmarks side by side (4-eyes approval), an envelope (email alerts), an Excel/spreadsheet icon with an upward arrow (upload), a branching flow diagram (workflow/lineage), and a shield with a tick (data quality). Thin connecting lines link the icons to the central dashboard, suggesting control and flow. Minimal, professional, corporate SaaS style; subtle depth with soft shadows; accent colour a muted red-orange (#d3531a) used sparingly on one or two icons. No text, no words, no letters. High contrast, crisp edges, suitable as a LinkedIn post image.
-->
Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

@@ -0,0 +1,36 @@
---
title: 'v7.12.0: Regex Validation Rules'
description: Define regex patterns once and have every value checked automatically - with hard rules that block bad data and soft rules to flag it for a second look.
date: '2026-07-28 15:00:00'
author: 'Data Controller'
authorLink: https://www.linkedin.com/showcase/data-controller-for-sas
tags:
- Announcements
---
# v7.12.0: Regex Validation Rules
🚀 A new Data Controller release is out - now featuring **regex validation rules**.
Regex (short for "regular expression") is a way of describing a pattern that text should match - think "must look like an email address" or "must be a valid UK postcode". Instead of writing code to check every entry, you simply define the pattern once, and every value entered into that column is automatically checked against it. It's a technique trusted by developers and data teams for decades, now available to your rule authors in a simple, declarative way.
This release gives you two levels of enforcement:
- **Hard rules** (`HARDREGEX`) block submission entirely - an invalid email address simply can't be saved, and the cell turns red so the problem is obvious at a glance.
- **Soft rules** (`SOFTREGEX`) show a gentle yellow warning instead, letting the submission through while flagging that something might need a second look. Perfect for checks that are important but not deal-breakers.
Rules are checked the moment they're created. Every pattern is run through SAS's own `PRXPARSE` engine when it's saved, so a broken or invalid regex is rejected immediately - with a clear message showing exactly which column it affects. There's no risk of a typo'd pattern silently failing on your users later.
The value? Cleaner data at the point of entry, with no manual review needed. Mistakes are caught the moment they're typed - not weeks later in a report - and your team decides exactly which rules are strict and which are advisory.
Full details in the [validation rules documentation](https://docs.datacontroller.io/dcc-validations/).
## Also in this release
🔒 Incompatible security settings are now caught before they cause problems
🔧 Table filters no longer leak between views
🛡️ Dependencies fully updated - zero known vulnerabilities, fully offline/on-prem capable
See the [v7.12.0 release notes](https://git.datacontroller.io/dc/dc/releases/tag/v7.12.0) for the complete changelog.
+21
View File
@@ -0,0 +1,21 @@
---
title: "Welcome to the Data Controller Feed"
description: A new home for quick announcements, social media updates and other bite-sized news from the Data Controller team.
date: '2026-07-17 09:00:00'
author: 'Data Controller'
authorLink: https://www.linkedin.com/showcase/data-controller-for-sas
tags:
- Announcements
---
# Welcome to the Feed
We're launching a new **Feed** section on the Data Controller website! This is where you'll find:
- Quick announcements
- Social media style updates
- Smaller news items that don't warrant a full blog post
It sits alongside our [Blog](/blog/) and both are combined into a single [RSS feed](/rss.xml) so you can subscribe once and get everything in one place.
Stay tuned for more updates!
Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

@@ -0,0 +1,32 @@
---
title: 'Zen and the Art of Data Quality'
description: Pirsig's motorcycle classic meets the National Bank of Belgium's DQ guidance - and the three layers of data quality defence Data Controller for SAS provides.
date: '2026-08-07 09:00:00'
author: 'Data Controller'
authorLink: https://www.linkedin.com/showcase/data-controller-for-sas
previewImg: './dilbert.jpeg'
tags:
- Announcements
---
# Zen and the Art of Data Quality
The [linked article below](https://www.linkedin.com/pulse/zen-art-data-quality-allan-bowe) contrasts Pirsig's 1974 motorcycle masterpiece with the insightful DQ guidance of the National Bank of Belgium ([nbb_2017_27](/blog/data-quality-and-the-nbb_2017_27-circular/)).
![Dilbert on data quality](dilbert2.jpeg)
And it would be remiss not to explain how Data Controller for SAS® provides 3 layers of data quality defence (for those who Care):
1. **Frontend Defence** - select boxes, type checking, date pickers, validation rules, key protection. Prevents simple mistakes.
2. **Backend Defence** - run a SAS program following a submit to apply complex DQ rules, enabling immediate rejection with contextual reasoning. Prevents any mistake for which a rule can be written.
3. **Human Defence** - changes reviewed by one or more approvers before the target table is updated. The final line of defence against the stress of poor data quality.
To complete your data-zen experience - [Data Controller](https://git.datacontroller.io/dc/dc) can be downloaded, deployed AND configured in just a few minutes.
It runs on Viya, SAS 9 EBI, and vanilla Base SAS.
DM for details!
Read the full article: [Zen and the Art of Data Quality](https://www.linkedin.com/pulse/zen-art-data-quality-allan-bowe)
@@ -0,0 +1,26 @@
---
title: 'Zero-Code Business Data Capture for SAS®'
description: Loading business data to IT-controlled databases should be simple, secure, and FAST. Data Controller for SAS® eliminates hand-cranked ETL flows.
date: '2026-08-10 18:00:00'
author: 'Allan Bowe'
authorLink: https://www.linkedin.com/in/allanbowe/
tags:
- Announcements
previewImg: './zero-code-business-data-capture-for-sas.jpeg'
---
As a member of a Data & Analytics team in a regulated sector such as Banking or Insurance, you need to deliver outputs in a consistent and timely fashion to meet statutory and management reporting demands.
In order to do that, your team builds and maintains automated processes that capture, cleanse and integrate with centrally managed Data Warehouses.
The problem when capturing data created by business users (as opposed to IT-secured sources) is the cost to build, deploy and incident-manage ETL flows with constantly changing file formats and arrival frequencies.
This can result in 'blame-games' and delays in data delivery.
We strongly believe that loading business data to IT-controlled databases should be simple, secure, and fast.
That's why we created Data Controller for SAS® - a unique zero-code solution for capturing business data at source and moving it safely and securely into your IT-managed data store.
The result is the complete elimination of 'hand-cranked' ETL flows and other expensive home-grown solutions that eat up your OpEx budget and reduce your capacity to scale.
To see how easily Data Controller can be deployed in your organisation, book a [Discovery Call](https://datacontroller.io/contact/).
Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

+96
View File
@@ -0,0 +1,96 @@
# Adding a Feed Post
The **Feed** section (`/feed/`) is for short announcements and social-media
style updates — things that don't warrant a full [`content/blog/`](../content/blog)
article, but are still worth publishing and syndicating via RSS.
Feed posts work exactly like blog posts (one markdown file per post, in its
own directory), but live under `content/feed/` instead of `content/blog/`,
and are published under `/feed/` instead of `/blog/`.
Both sections share a single combined RSS feed at
[`/rss.xml`](https://datacontroller.io/rss.xml) (see `gatsby-plugin-feed` in
[`gatsby-config.js`](../gatsby-config.js)), so a new Feed post automatically
appears in the RSS feed the next time the site is built — no extra
configuration required.
A visible "RSS Feed" link (pointing to `/rss.xml`) is rendered in two places
so visitors can discover and subscribe to it:
- The site-wide [`Footer`](../src/components/footer/index.tsx), under
"Other Resources".
- The "Subscribe" section of the shared
[`SideBar`](../src/templates/sidebar.tsx), shown on Blog and Feed list/post
pages.
## 1. Create a new post directory
Create a new folder under `content/feed/`, named after the post slug (this
becomes the URL):
```
content/feed/my-new-announcement/index.md
```
## 2. Write the frontmatter + content
```md
---
title: 'My New Announcement'
description: A one or two sentence summary used for SEO and RSS.
date: '2024-06-01 09:00:00'
author: 'Your Name'
authorLink: https://www.linkedin.com/in/yourprofile/
tags:
- Announcements
---
Your announcement content goes here, written in regular Markdown.
```
Notes on frontmatter fields:
| Field | Required | Notes |
| ------------- | -------- | ---------------------------------------------------------------------- |
| `title` | Yes | Post title, used on the page, in the sidebar and in the RSS item. |
| `description` | Yes | Short summary, used for SEO meta tags and the RSS item description. |
| `date` | Yes | Format `'YYYY-MM-DD HH:MM:SS'`. Controls sort order and the archive. |
| `author` | Yes | Displayed under the post title. |
| `authorLink` | No | If set, the author name links out (e.g. to a LinkedIn profile). |
| `tags` | Yes | One or more tags. Used for the sidebar category list and RSS `<category>`. |
| `previewImg` | No | Optional relative path to an image in the same folder (e.g. `'./cover.png'`). Feed posts commonly omit this since they're short-form. |
## 3. Add any images (optional)
If you use `previewImg` or reference images in the body, place the image
files in the same post directory and reference them with a relative path,
the same as for blog posts.
## 4. Build / preview
```shell
npm run develop
```
Your new post will be available at `/my-new-announcement/`, and will appear:
- On the [`/feed/`](https://datacontroller.io/feed/) listing page (with
pagination, sidebar archives and tag categories under `/feed/...`).
- In the site search index.
- In the combined RSS feed at `/rss.xml`, alongside blog posts.
## How it differs from a Blog post
| Aspect | Blog | Feed |
| ------------------ | ------------------------------ | ----------------------------------- |
| Content directory | `content/blog/` | `content/feed/` |
| Listing page | `/blog/` | `/feed/` |
| Year archive | `/{year}/` | `/feed/{year}/` |
| Category page | `/category/{tag}/` | `/feed/category/{tag}/` |
| Template | `src/templates/blog-post.tsx`, `src/templates/blog-list.tsx` | `src/templates/feed-post.tsx`, `src/templates/feed-list.tsx` |
| RSS | Included in `/rss.xml` | Included in `/rss.xml` |
Feed archive/category routes are prefixed with `/feed/` specifically to avoid
colliding with the equivalent Blog routes, since both content types are
rendered by the shared [`src/templates/sidebar.tsx`](../src/templates/sidebar.tsx)
component (via its `basePath` prop).
+1
View File
@@ -1,4 +1,5 @@
import "bootstrap/dist/css/bootstrap.min.css";
import "./src/styles/montserrat.css";
// Import all js dependencies.
import "jquery/dist/jquery.min.js";
+15
View File
@@ -0,0 +1,15 @@
export interface SiteMetadata {
title: string
description: string
siteUrl: string
author: {
name: string
summary: string
}
social: {
linkedin: string
}
}
export const siteMetadata: SiteMetadata
export const pathPrefix: string
+67 -11
View File
@@ -8,14 +8,13 @@ module.exports = {
summary: ``
},
social: {
linkedin: `https://www.linkedin.com/showcase/data_controller/`
linkedin: `https://www.linkedin.com/showcase/data-controller-for-sas/`
}
},
pathPrefix: '',
plugins: [
'gatsby-plugin-styled-components',
'gatsby-plugin-image',
'gatsby-plugin-react-helmet',
'gatsby-plugin-sitemap',
{
resolve: 'gatsby-plugin-manifest',
@@ -23,7 +22,6 @@ module.exports = {
name: `Data Controller | Flexible and Secure SAS® Data Modification`,
short_name: `Data Controller`,
description: `Data Controller for SAS® is dedicated to helping users, admins and developers manage their data. A zero code approach with Data Lineage, Catalog, Dictionary, Validation, Workflow, Alerts and more.`,
homepage_url: 'https://datacontroller.io/',
start_url: '/',
background_color: '#fff',
theme_color: '#314351',
@@ -51,6 +49,14 @@ module.exports = {
},
__key: 'blog'
},
{
resolve: `gatsby-source-filesystem`,
options: {
path: `./content/feed`,
name: `feed`
},
__key: 'feed'
},
{
resolve: `gatsby-source-filesystem`,
options: {
@@ -103,13 +109,6 @@ module.exports = {
},
__key: 'pages'
},
{
resolve: `gatsby-plugin-google-fonts`,
options: {
fonts: [`Montserrat\:300,400,500`],
display: 'swap'
}
},
{
resolve: 'gatsby-plugin-local-search',
options: {
@@ -131,7 +130,7 @@ module.exports = {
// required.
query: `
{
remark: allMarkdownRemark (filter: {fileAbsolutePath: {regex: "/content/blog/"}}) {
remark: allMarkdownRemark (filter: {fileAbsolutePath: {regex: "/content\\/(blog|feed)\\//"}}) {
posts: edges {
post: node {
id
@@ -184,6 +183,63 @@ module.exports = {
html: data.post.html
}))
}
},
{
resolve: 'gatsby-plugin-feed',
options: {
query: `
{
site {
siteMetadata {
title
description
siteUrl
}
}
}
`,
feeds: [
{
serialize: ({ query: { site, allMarkdownRemark } }) =>
allMarkdownRemark.nodes.map((node) => ({
title: node.frontmatter.title,
description: node.frontmatter.description || '',
date: node.frontmatter.date,
url: site.siteMetadata.siteUrl + node.fields.slug,
guid: site.siteMetadata.siteUrl + node.fields.slug,
author: node.frontmatter.author,
categories: node.frontmatter.tags || [],
custom_elements: [{ 'content:encoded': node.html }]
})),
query: `
{
allMarkdownRemark(
sort: { frontmatter: { date: DESC } }
filter: {
fileAbsolutePath: { regex: "/content\\/(blog|feed)\\//" }
}
) {
nodes {
html
fields {
slug
}
frontmatter {
title
description
date
author
tags
}
}
}
}
`,
output: '/rss.xml',
title: 'Data Controller — Blog & Feed'
}
]
}
}
]
}
+174 -3
View File
@@ -6,6 +6,10 @@ const recentPosts = []
const archives = {}
const tagsFrequent = []
const feedRecentPosts = []
const feedArchives = {}
const feedTagsFrequent = []
exports.createPages = async ({ graphql, actions, reporter }) => {
const { createPage } = actions
@@ -14,12 +18,16 @@ exports.createPages = async ({ graphql, actions, reporter }) => {
const blogListTemplate = path.resolve(`./src/templates/blog-list.tsx`)
const blogSearchTemplate = path.resolve(`./src/templates/blog-search.tsx`)
// Define a template for feed post
const feedPostTemplate = path.resolve(`./src/templates/feed-post.tsx`)
const feedListTemplate = path.resolve(`./src/templates/feed-list.tsx`)
// Get all markdown blog posts sorted by date
const result = await graphql(
`
{
allMarkdownRemark(
sort: { fields: [frontmatter___date], order: DESC }
sort: { frontmatter: { date: DESC } }
limit: 1000
filter: { fileAbsolutePath: { regex: "/content/blog/" } }
) {
@@ -34,8 +42,11 @@ exports.createPages = async ({ graphql, actions, reporter }) => {
}
}
}
tagsGroup: allMarkdownRemark(limit: 1000) {
group(field: frontmatter___tags) {
tagsGroup: allMarkdownRemark(
limit: 1000
filter: { fileAbsolutePath: { regex: "/content/blog/" } }
) {
group(field: { frontmatter: { tags: SELECT } }) {
name: fieldValue
totalCount
}
@@ -176,6 +187,166 @@ exports.createPages = async ({ graphql, actions, reporter }) => {
tags: tagsFrequent
}
})
// ---------------------------------------------------------------------
// Feed section (announcements / social updates) - mirrors the blog logic
// above but is scoped to /content/feed/ and lives under the /feed/ path
// ---------------------------------------------------------------------
const feedResult = await graphql(
`
{
allMarkdownRemark(
sort: { frontmatter: { date: DESC } }
limit: 1000
filter: { fileAbsolutePath: { regex: "/content/feed/" } }
) {
nodes {
id
fields {
slug
}
frontmatter {
title
date(formatString: "YYYY")
}
}
}
tagsGroup: allMarkdownRemark(
limit: 1000
filter: { fileAbsolutePath: { regex: "/content/feed/" } }
) {
group(field: { frontmatter: { tags: SELECT } }) {
name: fieldValue
totalCount
}
}
}
`
)
if (feedResult.errors) {
reporter.panicOnBuild(
`There was an error loading your feed posts`,
feedResult.errors
)
return
}
const feedPosts = feedResult.data.allMarkdownRemark.nodes
feedRecentPosts.push(
...feedPosts.slice(0, 10).map((p) => ({
slug: p.fields.slug,
title: p.frontmatter.title
}))
)
const feedTags = feedResult.data.tagsGroup.group
feedTagsFrequent.push(
...feedTags.sort((a, b) => b.totalCount - a.totalCount).slice(0, 10)
)
feedPosts.forEach((d) => {
if (feedArchives[d.frontmatter.date] == null)
feedArchives[d.frontmatter.date] = 0
feedArchives[d.frontmatter.date]++
})
// Create individual feed post pages
if (feedPosts.length > 0) {
feedPosts.forEach((post, index) => {
const previousPostId = index === 0 ? null : feedPosts[index - 1].id
const nextPostId =
index === feedPosts.length - 1 ? null : feedPosts[index + 1].id
createPage({
path: post.fields.slug,
component: feedPostTemplate,
context: {
id: post.id,
archives: feedArchives,
recentPosts: feedRecentPosts,
tags: feedTagsFrequent,
previousPostId,
nextPostId
}
})
})
}
// Create feed-list pages
const feedNumPages = Math.ceil(feedPosts.length / postsPerPage)
Array.from({ length: feedNumPages }).forEach((_, i) => {
createPage({
path: i === 0 ? `/feed` : `/feed/page/${i + 1}`,
component: feedListTemplate,
context: {
page: 'index',
archives: feedArchives,
recentPosts: feedRecentPosts,
tags: feedTagsFrequent,
filter: { fileAbsolutePath: { regex: '/content/feed/' } },
limit: postsPerPage,
skip: i * postsPerPage,
numPages: feedNumPages,
currentPage: i + 1
}
})
})
for (year in feedArchives) {
const count = feedArchives[year]
const numPagesOfYear = Math.ceil(count / postsPerPage)
Array.from({ length: numPagesOfYear }).forEach((_, i) => {
createPage({
path:
i === 0 ? `/feed/${year}/` : `/feed/${year}/page/${i + 1}`,
component: feedListTemplate,
context: {
page: 'year',
archives: feedArchives,
recentPosts: feedRecentPosts,
tags: feedTagsFrequent,
filter: {
frontmatter: { date: { gte: year, lt: year + 1 } },
fileAbsolutePath: { regex: '/content/feed/' }
},
limit: postsPerPage,
skip: i * postsPerPage,
numPages: numPagesOfYear,
currentPage: i + 1,
year: year
}
})
})
}
feedTags.forEach((tag) => {
const count = tag.totalCount
const numPagesOfTag = Math.ceil(count / postsPerPage)
Array.from({ length: numPagesOfTag }).forEach((__, i) => {
const tagPath = `/feed/category/${_.kebabCase(tag.name)}/`
createPage({
path: i === 0 ? tagPath : `${tagPath}page/${i + 1}`,
component: feedListTemplate,
context: {
page: 'category',
archives: feedArchives,
recentPosts: feedRecentPosts,
tags: feedTagsFrequent,
filter: {
frontmatter: { tags: { in: [tag.name] } },
fileAbsolutePath: { regex: '/content/feed/' }
},
limit: postsPerPage,
skip: i * postsPerPage,
numPages: numPagesOfTag,
currentPage: i + 1,
tag: tag.name
}
})
})
})
}
exports.onCreatePage = ({ page, actions }) => {
+2753 -1121
View File
File diff suppressed because it is too large. Load diff
+23 -6
View File
@@ -14,21 +14,22 @@
"serve": "gatsby serve",
"clean": "gatsby clean",
"lint": "npx prettier --check \"src/**/*.+(ts|tsx|js|jsx|json|css|scss)\"",
"lint:fix": "npx prettier --write \"src/**/*.+(ts|tsx|js|jsx|json|css|scss)\" --ignore-path .gitignore"
"lint:fix": "npx prettier --write \"src/**/*.+(ts|tsx|js|jsx|json|css|scss)\" --ignore-path .gitignore",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@browniebroke/gatsby-image-gallery": "^8.2.0",
"@mdx-js/mdx": "^3.1.1",
"@mdx-js/react": "^3.1.1",
"babel-plugin-styled-components": "^2.1.4",
"baseline-browser-mapping": "^2.10.43",
"caniuse-lite": "^1.0.30001806",
"gatsby": "^5.15.0",
"gatsby-plugin-google-analytics": "^5.13.1",
"gatsby-plugin-google-fonts": "^1.0.1",
"gatsby-plugin-feed": "^5.16.0",
"gatsby-plugin-image": "^3.15.0",
"gatsby-plugin-local-search": "^2.0.1",
"gatsby-plugin-manifest": "^5.15.0",
"gatsby-plugin-matomo": "^0.17.0",
"gatsby-plugin-react-helmet": "^6.15.0",
"gatsby-plugin-sharp": "^5.15.0",
"gatsby-plugin-sitemap": "^6.15.0",
"gatsby-plugin-styled-components": "^6.15.0",
@@ -41,7 +42,6 @@
"nostr-tools": "^2.7.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-helmet": "^6.1.0",
"react-icons": "^5.2.1",
"react-share": "^5.1.0",
"react-use-flexsearch": "^0.1.1",
@@ -50,7 +50,24 @@
},
"devDependencies": {
"@popperjs/core": "^2.9.2",
"@types/lodash": "^4.17.24",
"@types/node": "^20.19.43",
"@types/react": "^18.3.31",
"@types/react-dom": "^18.3.7",
"bootstrap": "^5.0.0-beta3",
"jquery": "^3.6.0"
"cloudron-surfer": "^6.4.1",
"jquery": "^3.6.0",
"typescript": "^7.0.2"
},
"overrides": {
"sharp": "^0.33.5",
"msgpackr": "^1.12.1",
"lmdb": "^2.9.4",
"ordered-binary": "^1.6.1",
"whatwg-url": "^17.1.0"
},
"engines": {
"node": ">=24.0.0",
"npm": ">=10.0.0"
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"version": 1,
"skills": {
"grill-with-docs": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/engineering/grill-with-docs/SKILL.md",
"computedHash": "9c460cbd94fd3c63cdef967dbdb6e66ca687103cdc380cd37834e4d10b738f78"
},
"setup-matt-pocock-skills": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/engineering/setup-matt-pocock-skills/SKILL.md",
"computedHash": "74e894a3509e2676d4cdb771c8eace087092430635e845e02a9cc2f757c552a4"
}
}
}
+33 -52
View File
@@ -1,7 +1,11 @@
import React from 'react'
import { FaRss } from 'react-icons/fa'
import { Section } from '../shared'
import { SolidButton } from '../shared/styledComponents'
import { StyledHeading, StyledDesc, InputStyled, StyledAnchor } from './style'
import { siteMetadata } from '../../../gatsby-config'
const rssFeedUrl = `${siteMetadata.siteUrl}rss.xml`
const anchorStyles = {
color: '#888'
@@ -13,67 +17,44 @@ const Footer = () => (
<div className="col-md-3 me-md-5">
<StyledHeading>Data Controller</StyledHeading>
<StyledDesc>
Data Controller is a product of 4GL Apps, a brand of Bowe IO Ltd,
which is a UK company with a focus on SAS Software,{' '}
<StyledAnchor
href="https://sasapps.io"
target="_blank"
rel="noopener"
>
Apps
Data Controller is the product of a UK company with a singular focus
on <StyledAnchor href="https://sasapps.io">SAS Web Apps</StyledAnchor>
.
</StyledDesc>
<StyledDesc>
<StyledAnchor href="https://sasapps.io/cyber-essentials-certified/">
Cyber Essentials Certified
</StyledAnchor>
, and Services.
</StyledDesc>
</div>
<div className="col-md-3">
<StyledHeading>Newsletter</StyledHeading>
<form
className="kwes-form"
method="POST"
action="https://kwes.io/api/foreign/forms/mxKuyK4lxZWnG2WNH3ga"
>
<div className="mb-3">
<InputStyled
type="email"
name="email"
className="form-control"
aria-describedby="emailHelp"
placeholder="Email Address*"
required
/>
</div>
<div className="mb-3">
<InputStyled
type="text"
name="name"
className="form-control"
placeholder="First Name"
required
/>
</div>
<div className="mb-3">
<InputStyled
type="text"
name="lastName"
className="form-control"
placeholder="Last Name"
/>
</div>
<SolidButton>Subscribe</SolidButton>
</form>
<StyledHeading>Source Code</StyledHeading>
<StyledDesc>
Data Controller source is on our self-hosted{' '}
<StyledAnchor href="https://git.datacontroller.io">
Gitea Repository
</StyledAnchor>
; the underlying SASjs framework is on{' '}
<StyledAnchor href="https://github.com/sasjs">GitHub</StyledAnchor>.
</StyledDesc>
</div>
<div className="col-md-3">
<StyledHeading>Other Resources</StyledHeading>
<StyledDesc>
Visit our educational and fun SAS® software quiz{' '}
<StyledAnchor
href="https://sasensei.com"
target="_blank"
rel="noopener"
>
Sasensei
Connect on{' '}
<StyledAnchor href={siteMetadata.social.linkedin}>
LinkedIn
</StyledAnchor>
, read the{' '}
<StyledAnchor href="https://docs.datacontroller.io">
docs
</StyledAnchor>
, or subscribe to the{' '}
<StyledAnchor href={rssFeedUrl}>
<FaRss style={{ marginRight: '6px', verticalAlign: 'middle' }} />
RSS feed
</StyledAnchor>{' '}
and test your knowledge of SAS topics.
for updates.
</StyledDesc>
</div>
</div>
@@ -1,6 +1,8 @@
import React from 'react'
import * as React from 'react'
import styled from 'styled-components'
import type { StyledAnchorProps } from '../../types'
export const StyledHeading = styled.h6`
margin-bottom: 0.8rem;
text-transform: uppercase;
@@ -33,7 +35,10 @@ const Anchor = styled.a`
}
`
export const StyledAnchor = ({ children, href }) => (
export const StyledAnchor: React.FC<StyledAnchorProps> = ({
children,
href
}) => (
<Anchor href={href} target="_blank" rel="noopener">
{children}
</Anchor>
+15 -11
View File
@@ -1,21 +1,16 @@
import React from 'react'
import { PageProps, Link } from 'gatsby'
import { Link } from 'gatsby'
import { FaShieldAlt } from 'react-icons/fa'
import styled from 'styled-components'
import { Hero, HeroHeading, HeroDesc } from './style'
import { Hero, HeroHeading, HeroDesc, HeroBadge } from './style'
import { BottomSectionArrow, OutlineButton } from '../shared/styledComponents'
import { Container } from '../shared'
import { pathPrefix } from '../../../gatsby-config.js'
import { pathPrefix } from '../../../gatsby-config'
type DataProps = {
location: Location
heading: string
desc: string
}
import type { HeroSectionProps } from '../../types'
const HeroSection: React.FC<PageProps<DataProps>> = ({
const HeroSection: React.FC<HeroSectionProps> = ({
location,
heading,
desc
@@ -25,9 +20,18 @@ const HeroSection: React.FC<PageProps<DataProps>> = ({
<HeroHeading>{heading}</HeroHeading>
<HeroDesc>{desc}</HeroDesc>
{location.pathname === pathPrefix + '/' && (
<>
<HeroBadge
href="https://sasapps.io/cyber-essentials-certified/"
target="_blank"
rel="noopener"
>
<FaShieldAlt /> Cyber Essentials Certified
</HeroBadge>
<Link to="/contact/">
<OutlineButton>Try Data Controller</OutlineButton>
</Link>
</>
)}
</Container>
<BottomSectionArrow />
-23
View File
@@ -1,23 +0,0 @@
import styled from 'styled-components'
import background from '../../images/home_hero_bg.png'
export const Hero = styled.main`
position: relative;
padding: 50px 0;
color: white;
background-color: #314351;
background-repeat: no-repeat;
background-image: ${(props) => (props.bg ? `url(${background})` : 'none')};
background-attachment: scroll;
background-position: bottom right;
`
export const HeroHeading = styled.h1`
font-family: 'Montserrat', 'HelveticaNeue', 'Helvetica Neue', Helvetica, Arial,
sans-serif;
text-transform: uppercase;
`
export const HeroDesc = styled.p`
opacity: 0.8;
`
+36
View File
@@ -0,0 +1,36 @@
import styled from 'styled-components'
export const Hero = styled.main<{ bg?: boolean }>`
position: relative;
padding: 50px 0;
color: white;
background-color: #314351;
`
export const HeroHeading = styled.h1`
font-family:
'Montserrat', 'HelveticaNeue', 'Helvetica Neue', Helvetica, Arial,
sans-serif;
text-transform: uppercase;
`
export const HeroDesc = styled.p`
opacity: 0.8;
`
export const HeroBadge = styled.a`
display: block;
width: fit-content;
margin: 0.75rem 0 0;
padding: 0.375rem 1rem;
font-size: 0.75rem;
border: 2px solid rgba(255, 255, 255, 0.5);
border-radius: 0.25rem;
color: white;
text-decoration: none;
&:hover {
color: white;
border-color: white;
opacity: 0.9;
}
`
+2 -6
View File
@@ -1,16 +1,12 @@
import React, { useEffect } from 'react'
import { PageProps } from 'gatsby'
import Navibar from './navibar'
import HeroSection from './herosection'
import Footer from './footer'
type DataProps = {
children?: React.ReactNode
heroSection: boolean
}
import type { LayoutProps } from '../types'
const Layout: React.FC<PageProps<DataProps>> = ({
const Layout: React.FC<LayoutProps> = ({
location,
children,
heroSection = true,
+20 -39
View File
@@ -1,49 +1,34 @@
import React from 'react'
import { Link, PageProps } from 'gatsby'
import { Link } from 'gatsby'
import dcLogo from '../../images/dclogo.png'
import { Container } from '../shared'
import { logoStyles, CustomNavBar, ulStyles, Li, StyledLink } from './style'
import { pathPrefix } from '../../../gatsby-config.js'
import { pathPrefix } from '../../../gatsby-config'
const naviLinks = [
import type { NavibarProps } from '../../types'
interface NaviLink {
name: string
url: string
active: 'yes' | 'no'
}
const naviLinks: NaviLink[] = [
{ name: 'Home', url: '/', active: 'no' },
{ name: 'About', url: '/about/', active: 'no' },
{ name: 'Blog', url: '/blog/', active: 'no' },
{ name: 'Feed', url: '/feed/', active: 'no' },
{ name: 'FAQ', url: '/faq/', active: 'no' },
{
name: 'Home',
url: '/',
active: 'no'
},
{
name: 'About',
url: '/about/',
active: 'no'
},
{
name: 'Blog',
url: '/blog/',
active: 'no'
},
{
name: 'FAQ',
url: '/faq/',
active: 'no'
},
{
name: 'Documentation',
name: 'Docs',
url: 'https://docs.datacontroller.io/',
active: 'no'
},
{
name: 'Pricing',
url: '/pricing/',
active: 'no'
},
{
name: 'Book Demo',
url: '/contact/',
active: 'no'
},
{ name: 'Pricing', url: '/pricing/', active: 'no' },
{ name: 'Book Demo', url: '/contact/', active: 'no' },
{
name: 'Source Code',
url: 'https://git.datacontroller.io/dc/dc',
@@ -51,11 +36,7 @@ const naviLinks = [
}
]
type DataProps = {
location: Location
}
const Navibar: React.FC<PageProps<DataProps>> = ({ location }) => {
const Navibar: React.FC<NavibarProps> = ({ location }) => {
naviLinks.forEach((link) => (link.active = 'no'))
const currentLink = naviLinks.find(
(link) => pathPrefix + link.url === location?.pathname
@@ -1,8 +1,7 @@
import React from 'react'
import * as React from 'react'
import styled, { css } from 'styled-components'
import { Link } from 'gatsby'
// styles
export const logoStyles = {
height: '55px'
}
@@ -295,7 +294,6 @@ const LinkUnderlineStyles = css`
transition: opacity 0.3s ease;
`
// styled components
export const Li = styled.li`
position: relative;
@media (min-width: 992px) {
@@ -315,7 +313,14 @@ export const Li = styled.li`
}
}
`
export const StyledLink = styled((props) => <Link {...props} />)`
interface StyledLinkProps {
active?: string
}
export const StyledLink = styled(({ active, ...rest }: any) => (
<Link {...rest} />
))<StyledLinkProps>`
padding-right: 0.8rem !important;
padding-left: 0.8rem !important;
color: white !important;
+32 -29
View File
@@ -1,9 +1,15 @@
import * as React from 'react'
import PropTypes from 'prop-types'
import { Helmet } from 'react-helmet'
import { useStaticQuery, graphql } from 'gatsby'
const Seo = ({ description, lang, meta, title, previewImg = undefined }) => {
import type { SeoProps } from '../types'
const Seo: React.FC<SeoProps> = ({
description = '',
lang = 'en',
meta = [],
title = '',
previewImg
}) => {
const { site } = useStaticQuery(graphql`
query {
site {
@@ -31,50 +37,47 @@ const Seo = ({ description, lang, meta, title, previewImg = undefined }) => {
? `${siteUrl}${previewImg}`
: `${siteUrl}/img/data-controller.svg`
return (
<Helmet
htmlAttributes={{
lang
}}
title={pageTitle}
meta={[
const metaTags = (
[
{ name: 'author', property: 'author', content: author },
{
name: 'description',
property: 'og:description',
content: metaDescription
},
// { name: 'facebook:site', content: '', },
{ name: 'image', property: 'og:image', content: image },
{
name: `linkedin:site`,
content: site.siteMetadata?.social?.linkedin || ``
},
{ name: `twitter:card`, content: `summary` },
// { name: `twitter:creator`, content: site.siteMetadata?.social?.twitter || `` },
{ name: `twitter:description`, content: metaDescription },
// { name: 'twitter:site', content: `${site?.twitter}`, },
{ name: `twitter:title`, content: title },
// { name: 'youtube:site', content: `${site?.youtube}`, },
{ property: `og:title`, content: title },
{ property: `og:type`, content: `website` }
].concat(meta)}
] as Array<{ name?: string; property?: string; content?: string }>
).concat(meta)
return (
<>
<html lang={lang} />
<title>{pageTitle}</title>
<link
rel="alternate"
type="application/rss+xml"
title={`${defaultTitle} RSS Feed`}
href={`${siteUrl}rss.xml`}
/>
{metaTags.map((tag, index) => (
<meta
key={tag.name || tag.property || index}
name={tag.name}
property={tag.property}
content={tag.content}
/>
))}
</>
)
}
Seo.defaultProps = {
lang: `en`,
meta: [],
description: ``,
title: ``
}
Seo.propTypes = {
description: PropTypes.string,
lang: PropTypes.string,
meta: PropTypes.arrayOf(PropTypes.object),
title: PropTypes.string
}
export default Seo
+3 -6
View File
@@ -1,10 +1,7 @@
import React from 'react'
import * as React from 'react'
import styled from 'styled-components'
import { PageProps } from 'gatsby'
type DataProps = {
children?: React.ReactNode
}
import type { ContainerProps } from '../../types'
const StyledDiv = styled.div`
@media (min-width: 576px) {
@@ -13,6 +10,6 @@ const StyledDiv = styled.div`
}
`
export const Container: React.FC<PageProps<DataProps>> = ({ children }) => {
export const Container: React.FC<ContainerProps> = ({ children }) => {
return <StyledDiv className="container">{children}</StyledDiv>
}
+1 -1
View File
@@ -4,7 +4,7 @@ import styled from 'styled-components'
import { FaEnvelope } from 'react-icons/fa'
export const StyledLink = styled((props) => <Link {...props} />)`
export const StyledLink = styled(Link)`
color: rgb(255, 255, 255);
background-color: rgb(144, 196, 69);
border-radius: 0px;
+4 -10
View File
@@ -1,25 +1,19 @@
import React from 'react'
import * as React from 'react'
import styled from 'styled-components'
import { PageProps } from 'gatsby'
import { BottomSectionArrow } from './styledComponents'
import { Container } from './'
type DataProps = {
children?: React.ReactNode
color?: string
bgColor?: string
bottomArrow?: boolean
}
import type { SectionProps } from '../../types'
const StyledSection = styled.div`
const StyledSection = styled.div<{ color?: string; bgColor?: string }>`
position: relative;
padding: 50px 0;
color: ${(props) => props.color || 'white'};
background-color: ${(props) => props.bgColor || '#314351'};
`
export const Section: React.FC<PageProps<DataProps>> = ({
export const Section: React.FC<SectionProps> = ({
children,
bgColor,
color,
@@ -1,6 +1,8 @@
import React from 'react'
import * as React from 'react'
import styled from 'styled-components'
import type { SolidButtonProps, OutlineButtonProps } from '../../types'
const BottomArrow = styled.div`
width: 50px;
height: 50px;
@@ -9,9 +11,6 @@ const BottomArrow = styled.div`
background: inherit;
transform: translateX(-50%) rotate(45deg);
left: 50%;
// right: 0;
// margin-left: auto;
// margin-right: auto;
z-index: 10;
`
const BottomArrowWrapper = styled.div`
@@ -29,16 +28,17 @@ export const BottomSectionArrow = () => (
</BottomArrowWrapper>
)
export const SectionHeading = styled.h2`
export const SectionHeading = styled.h2<{ center?: string }>`
text-align: ${(props) => (props.center === 'no' ? 'left' : 'center')};
letter-spacing: 1px;
font-weight: 400;
font-family: 'Montserrat', 'HelveticaNeue', 'Helvetica Neue', Helvetica, Arial,
font-family:
'Montserrat', 'HelveticaNeue', 'Helvetica Neue', Helvetica, Arial,
sans-serif;
text-transform: uppercase;
`
export const SectionDesc = styled.p`
export const SectionDesc = styled.p<{ center?: string; opacity?: string }>`
text-align: ${(props) => (props.center === 'no' ? 'left' : 'center')};
opacity: ${(props) => props.opacity ?? 0.6};
a {
@@ -59,12 +59,12 @@ const StyledSolidButton = styled.button`
}
`
export const SolidButton = ({
export const SolidButton: React.FC<SolidButtonProps> = ({
children,
theme = 'light',
type = 'submit',
disabled = false,
onClick = undefined
onClick
}) => (
<StyledSolidButton
type={type}
@@ -83,7 +83,7 @@ const StyledOutlineButton = styled.button`
border-width: 2px;
`
export const OutlineButton = ({ children }) => (
export const OutlineButton: React.FC<OutlineButtonProps> = ({ children }) => (
<StyledOutlineButton type="button" className="btn btn-outline-light">
{children}
</StyledOutlineButton>
+6 -6
View File
@@ -34,7 +34,7 @@ export class NostrController extends EventEmitter {
/**
* Function will publish provided event to the provided relays
*/
publishEvent = async (event: Event, relays: string[]) => {
publishEvent = async (event: Event, relays: string[]): Promise<string[]> => {
const simplePool = new SimplePool()
const promises = simplePool.publish(relays, event)
@@ -47,7 +47,7 @@ export class NostrController extends EventEmitter {
})
if (publishedRelays.length === 0) {
const failedPublishes: any[] = []
const failedPublishes: Array<{ relay: string; error: string }> = []
const fallbackRejectionReason =
'Attempt to publish an event has been rejected with unknown reason.'
@@ -92,7 +92,7 @@ export class NostrController extends EventEmitter {
return Promise.resolve(signedEvent)
}
nip04Encrypt = async (receiver: string, content: string) => {
nip04Encrypt = async (receiver: string, content: string): Promise<string> => {
if (!this.generatedKeys) {
throw new Error(`Private & public key pair is not found.`)
}
@@ -113,7 +113,7 @@ export class NostrController extends EventEmitter {
* @param isSigner Boolean indicating whether the recipient is a signer or viewer.
* @param setAuthUrl Function to set the authentication URL in the component state.
*/
sendDM = async (pubkey: string, message: string) => {
sendDM = async (pubkey: string, message: string): Promise<string[]> => {
// Set up timeout promise to handle encryption timeout
const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => {
@@ -159,7 +159,7 @@ export class NostrController extends EventEmitter {
* @param hexKey hex private or public key
* @returns whether or not is key valid
*/
validateHex = (hexKey: string) => {
validateHex = (hexKey: string): RegExpMatchArray | null => {
return hexKey.match(/^[a-f0-9]{64}$/)
}
@@ -193,7 +193,7 @@ export class NostrController extends EventEmitter {
return { private: nip19.nsecEncode(nsec), public: getPublicKey(nsec) }
}
verifySignedEvent = (event: SignedEvent) => {
verifySignedEvent = (event: SignedEvent): void => {
const isGood = verifyEvent(event)
if (!isGood) {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 482 KiB

+3 -2
View File
@@ -20,7 +20,8 @@ Data Controller is available in four packages, with no limits on number of users
| | Community | Bronze | Silver | Gold |
| ------------------------ | --------------- | ---------- | ---------- | ---------- |
| Number of Users | Unlimited | Unlimited | Unlimited | Unlimited |
| Environments | Unlimited | Unlimited | Unlimited | Unlimited |
| Production Environments | Unlimited | 1 | 2 | Unlimited |
| Dev / Test Environments | Unlimited | Unlimited | Unlimited | Unlimited |
| Libraries | Unlimited | Unlimited | Unlimited | Unlimited |
| Tables (per library) | 35 | 100 | Unlimited | Unlimited |
| Rows (VIEW) | 15 | 100 | Unlimited | Unlimited |
@@ -34,7 +35,7 @@ Data Controller is available in four packages, with no limits on number of users
| Developer Days | ❌ | ❌ | ❌ | ✅ |
| Consulting Days | ❌ | ❌ | ❌ | ✅ |
| Perpetual Licence Option | ❌ | ❌ | ❌ | ✅ |
| Price | FREE | 12k EUR pa | 25k EUR pa | Contact Us |
| Price | FREE | 12k EUR pa | 21k EUR pa | Contact Us |
</br>
<i>Data Controller Development Days (to build new product features etc) are available for all plans at a price of 600 EUR per day. Consulting rates are published <a target="_blank" rel="noopener" href="https://sasapps.io/pricing">here</a>.</i>
+10 -4
View File
@@ -1,5 +1,4 @@
import React from 'react'
// import { Link } from 'gatsby'
import * as React from 'react'
import Layout from '../components/layout'
import Seo from '../components/seo'
@@ -7,9 +6,14 @@ import Seo from '../components/seo'
import { Section } from '../components/shared'
import { SideBar } from '../templates/sidebar'
const NotFound = ({ data, location, pageContext }) => (
import type { PageProps } from 'gatsby'
import type { PageContext } from '../types'
const NotFound: React.FC<PageProps<unknown, PageContext>> = ({
location,
pageContext
}) => (
<Layout location={location} heroSection={false}>
<Seo title="Page Not Found" />
<Section color="black" bgColor="white" bottomArrow={false}>
<SideBar
pageContext={pageContext}
@@ -21,3 +25,5 @@ const NotFound = ({ data, location, pageContext }) => (
)
export default NotFound
export const Head = () => <Seo title="Page Not Found" />
+6 -177
View File
@@ -1,5 +1,5 @@
import { PageProps, Link, graphql } from 'gatsby'
import React from 'react'
import { PageProps, Link } from 'gatsby'
import * as React from 'react'
import Layout from '../components/layout'
import Seo from '../components/seo'
@@ -10,118 +10,13 @@ import {
SectionDesc
} from '../components/shared/styledComponents'
import {
Card,
CardImg,
CardBody,
FeaturedImg,
FeaturedDesc
} from '../styledComponents/about'
import sasenseiLogo from '../images/sasapps/sasensei.png'
import rubyLogo from '../images/sasapps/ruby.png'
import pygrailLogo from '../images/sasapps/pygrail.png'
import pilotLogo from '../images/sasapps/pilot.png'
import yogaLogo from '../images/sasapps/yoga.png'
import jsLogo from '../images/sasapps/js.png'
import serbianLogo from '../images/sasapps/serbian.png'
import quizrLogo from '../images/sasapps/quizr.png'
import cdoquizLogo from '../images/sasapps/cdoquiz.png'
import insureLogo from '../images/sasapps/insure.png'
import sasenseiFeatured from '../images/sasensei_featured.png'
type DataProps = {
site: {
meta: {
title: string
description: string
social: { linkedin: string }
}
}
}
const SasApps = [
{
name: 'Sasensei',
desc: 'Sasensei. SAS Software Quiz, Challenge, Compete & Learn',
link: 'https://sasensei.com',
themeColor: '#2196F3',
logo: sasenseiLogo
},
{
name: 'Ruby',
desc: 'Ruby Cards - a Community Quiz Game for Rubyists',
link: 'https://ruby.cards',
themeColor: '#aa1502',
logo: rubyLogo
},
{
name: 'PyGrail',
desc: 'PyGrail - a Community Quiz Game for Monty Pythonistas',
link: 'https://pygrail.com',
themeColor: '#efbf2c',
logo: pygrailLogo
},
{
name: 'Pilot',
desc: 'Pilot Cards Leaderboard - How High Can You Climb?',
link: 'https://pilot.cards',
themeColor: '#446ff3',
logo: pilotLogo
},
{
name: 'Yoga.cards',
desc: 'Yoga Cards - a Community Quiz Game for Student Yogis',
link: 'https://yoga.cards',
themeColor: '#6d69fb',
logo: yogaLogo
},
{
name: 'JS.cards',
desc: 'JS Cards - a Community Quiz Game for JavaScript Ninjas',
link: 'https://js.cards',
themeColor: '#00cc99',
logo: jsLogo
},
{
name: 'Serbian.cards',
desc: 'Serbian Cards - How Much Serbian Do You Know?',
link: 'https://serbian.cards',
themeColor: '#c6363c',
logo: serbianLogo
},
{
name: 'Quizr',
desc: 'quizR - a community quiz game for useRs',
link: 'https://quizr.io',
themeColor: '#51904c',
logo: quizrLogo
},
{
name: 'CDOquiz',
desc: 'CDO Quiz - a Royally Challenging Data Adventure',
link: 'https://cdoquiz.com/',
themeColor: '#a54499',
logo: cdoquizLogo
},
{
name: 'Insure.cards',
desc: 'Insure Cards - Are You An Insurance Expert? Prove It',
link: 'https://insure.cards/',
themeColor: '#5f8ba5',
logo: insureLogo
}
]
const About: React.FC<PageProps<DataProps>> = ({ data, location }) => {
const About: React.FC<PageProps<unknown>> = ({ location }) => {
return (
<Layout
location={location}
heading="Behind Data Controller"
desc="Data Controller is a product of 4GL, a brand of Bowe IO Ltd"
>
<Seo title="About" />
<Section color="black" bgColor="white">
<div className="row">
<div className="offset-md-2 col-md-8">
@@ -140,81 +35,15 @@ const About: React.FC<PageProps<DataProps>> = ({ data, location }) => {
>
SAS Apps
</a>
, and the Macro Cards range of websites listed below:
.
</SectionDesc>
</div>
</div>
<div className="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 row-cols-xl-5">
{SasApps.map((app) => (
<div className="col">
<Card
className="card"
href={app.link}
target="_blank"
rel="noopener"
>
<CardImg
className="card-img-top"
src={app.logo}
alt={app.name}
title={app.name}
/>
<CardBody className="card-body">
<h5 className="card-title">{app.name}</h5>
<p className="card-text">{app.desc}</p>
</CardBody>
</Card>
</div>
))}
</div>
</Section>
<Section>
<FeaturedImg src={sasenseiFeatured} alt="Sasensei" title="Sasensei" />
<FeaturedDesc>
Sasensei is a free question-based learning system dedicated to various
aspects of the
<br />
SAS® software such as SAS® Programming, Administration, Risk
Dimensions, Viya, AF/SCL, and much more.
</FeaturedDesc>
<FeaturedDesc>
This SAS® quiz allows the player to test their knowledge, challenge
their peers and submit their own questions for others to enjoy.
<br />
Sasensei is constantly improving by adding new features which
contribute to the player experience and increase the educational
value.
</FeaturedDesc>
</Section>
<Section color="black" bgColor="#eaeaea" bottomArrow={false}>
<div className="row row-cols-1 row-cols-sm-2 row-cols-md-3 row-cols-lg-4 row-cols-xl-5">
<div className="col">
1000+
<br />
Active Players
</div>
<div className="col">
650+
<br />
Questions
</div>
<div className="col">
20+
<br />
New Players Every Day
</div>
<div className="col">
6<br />
Unique Features
</div>
<div className="col">
7<br />
Difficulty Levels
</div>
</div>
</Section>
</Layout>
)
}
export default About
export const Head = () => <Seo title="About" />
+13 -22
View File
@@ -1,5 +1,5 @@
import { PageProps, Link, graphql } from 'gatsby'
import React, { useState } from 'react'
import { PageProps } from 'gatsby'
import * as React from 'react'
import Layout from '../components/layout'
import Seo from '../components/seo'
@@ -22,25 +22,15 @@ import contactBg from '../images/contact_bg.jpg'
import '../styledComponents/contact.css'
import { NostrController } from '../controllers'
type DataProps = {
site: {
meta: {
title: string
description: string
social: { linkedin: string }
}
}
}
const Contact: React.FC<PageProps<DataProps>> = ({ data, location }) => {
const Contact: React.FC<PageProps<unknown>> = ({ location }) => {
const nostrController = NostrController.getInstance()
const [name, setName] = useState<string>()
const [email, setEmail] = useState<string>()
const [emailError, setEmailError] = useState<boolean>(false)
const [subject, setSubject] = useState<string>()
const [message, setMessage] = useState<string>()
const [notification, setNotification] = useState<string>()
const [name, setName] = React.useState<string>()
const [email, setEmail] = React.useState<string>()
const [emailError, setEmailError] = React.useState<boolean>(false)
const [subject, setSubject] = React.useState<string>()
const [message, setMessage] = React.useState<string>()
const [notification, setNotification] = React.useState<string>()
const handleEmailChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
@@ -68,7 +58,6 @@ const Contact: React.FC<PageProps<DataProps>> = ({ data, location }) => {
heading="Book a Demo"
desc="Schedule a demonstration of the Data Controller to see our latest features and explore use cases"
>
<Seo title="Contact" />
<Section color="black" bgColor="white" bottomArrow={false}>
<div className="row">
<div className="col-md-6">
@@ -98,7 +87,7 @@ Email: ${email}
Subject: ${subject}
Message: ${message}`
)
.catch((err) => {
.catch((err: unknown) => {
setNotification(
`Something went wrong. Please check the console for more information. Please try one more time.`
)
@@ -176,7 +165,7 @@ Message: ${message}`
className="form-control contactFormStyles"
id="mesage"
name="message"
rows="5"
rows={5}
rules="required|max:200"
onChange={(evt) => {
setMessage(evt.target.value)
@@ -203,3 +192,5 @@ Message: ${message}`
}
export default Contact
export const Head = () => <Seo title="Contact" />
+5 -14
View File
@@ -1,5 +1,5 @@
import { PageProps, Link, graphql } from 'gatsby'
import React from 'react'
import { PageProps, Link } from 'gatsby'
import * as React from 'react'
import Layout from '../components/layout'
import Seo from '../components/seo'
@@ -13,24 +13,13 @@ import {
import { FAQ } from '../styledComponents/faq'
type DataProps = {
site: {
meta: {
title: string
description: string
social: { linkedin: string }
}
}
}
const Faq: React.FC<PageProps<DataProps>> = ({ data, location }) => {
const Faq: React.FC<PageProps<unknown>> = ({ location }) => {
return (
<Layout
location={location}
heading="Data Controller – FAQ"
desc="Most common user questions and answers regarding the Data Controller"
>
<Seo title="FAQ" />
<Section color="black" bgColor="white">
<div className="row">
<div className="col-md-8">
@@ -77,3 +66,5 @@ const Faq: React.FC<PageProps<DataProps>> = ({ data, location }) => {
}
export default Faq
export const Head = () => <Seo title="FAQ" />
+18 -25
View File
@@ -1,5 +1,5 @@
import { Link, graphql } from 'gatsby'
import React from 'react'
import * as React from 'react'
import { Link, graphql, PageProps } from 'gatsby'
import Gallery from '@browniebroke/gatsby-image-gallery'
import { IGatsbyImageData } from 'gatsby-plugin-image'
@@ -34,31 +34,31 @@ import dcDesign from '../images/data-controller-design.png'
interface ImageSharpEdge {
node: {
name: string
childImageSharp: {
thumb: IGatsbyImageData
full: IGatsbyImageData
meta: {
originalName: string
}
}
}
}
interface PageProps {
data: {
interface IndexPageData {
images: {
edges: ImageSharpEdge[]
}
}
}
const Home: React.FC<PageProps> = ({ data, location }) => {
const CustomWrapper: React.FC<{
children?: React.ReactNode
onClick?: () => void
}> = ({ children, onClick }) => (
<ThumbnailWrapper onClick={onClick}>{children}</ThumbnailWrapper>
)
const Home: React.FC<PageProps<IndexPageData>> = ({ data, location }) => {
const images = data.images.edges.map(({ node }) => ({
...node.childImageSharp,
// Use original name as caption.
// The `originalName` is queried in a nested field,
// but the `Gallery` component expects `caption` at the top level.
caption: node.childImageSharp.meta.originalName
caption: node.name
}))
return (
@@ -67,7 +67,6 @@ const Home: React.FC<PageProps> = ({ data, location }) => {
heading="Flexible and Secure Data Modification"
desc="Data Controller for SAS® enables business users to update IT owned source tables, secured by review / approve workflow and full audit trail."
>
<Seo />
<Section color="black" bgColor="white">
<div className="row">
<div className="col-md-3">
@@ -231,13 +230,7 @@ const Home: React.FC<PageProps> = ({ data, location }) => {
</Section>
<Section color="black" bgColor="white">
<SectionHeading>See How it Looks</SectionHeading>
<Gallery
images={images}
gutter={0}
customWrapper={({ children, onClick }) => (
<ThumbnailWrapper onClick={onClick}>{children}</ThumbnailWrapper>
)}
/>
<Gallery images={images} gutter="0" customWrapper={CustomWrapper} />
</Section>
<ScheduleDemo />
</Layout>
@@ -250,10 +243,11 @@ export const pageQuery = graphql`
query ImagesForGallery {
images: allFile(
filter: { relativeDirectory: { eq: "gallery" } }
sort: { fields: name }
sort: { name: ASC }
) {
edges {
node {
name
childImageSharp {
thumb: gatsbyImageData(
width: 300
@@ -261,12 +255,11 @@ export const pageQuery = graphql`
placeholder: BLURRED
)
full: gatsbyImageData(layout: FULL_WIDTH)
meta: fixed {
originalName
}
}
}
}
}
}
`
export const Head = () => <Seo />
@@ -1,5 +1,5 @@
import React from 'react'
import { graphql } from 'gatsby'
import * as React from 'react'
import { graphql, PageProps } from 'gatsby'
import styled from 'styled-components'
import Layout from '../components/layout'
@@ -7,6 +7,8 @@ import Seo from '../components/seo'
import { Section } from '../components/shared'
import type { PostNode, PageContext } from '../types'
const StyledMarkdown = styled.div`
color: rgb(102, 102, 102);
h2,
@@ -18,20 +20,26 @@ const StyledMarkdown = styled.div`
h2 {
text-transform: uppercase;
letter-spacing: 1px;
font-family: 'Montserrat', 'HelveticaNeue', 'Helvetica Neue', Helvetica,
Arial, sans-serif;
font-family:
'Montserrat', 'HelveticaNeue', 'Helvetica Neue', Helvetica, Arial,
sans-serif;
}
`
export default function Template({ data, location }) {
const { frontmatter, html } = data?.markdownRemark
interface MarkdownPageData {
markdownRemark: PostNode
}
const MarkdownPageTemplate: React.FC<
PageProps<MarkdownPageData, PageContext>
> = ({ data, location }) => {
const { frontmatter, html } = data.markdownRemark
return (
<Layout
location={location}
heading={frontmatter.heading}
desc={frontmatter.desc}
>
<Seo title={frontmatter.title} />
<Section color="black" bgColor="white" bottomArrow={false}>
<StyledMarkdown dangerouslySetInnerHTML={{ __html: html }} />
</Section>
@@ -39,6 +47,12 @@ export default function Template({ data, location }) {
)
}
export default MarkdownPageTemplate
export const Head: React.FC<{ data: MarkdownPageData }> = ({ data }) => (
<Seo title={data.markdownRemark.frontmatter.title} />
)
export const pageQuery = graphql`
query ($id: String!) {
markdownRemark(id: { eq: $id }) {
File renamed without changes.
@@ -1,7 +1,8 @@
import styled from 'styled-components'
export const StyledHeading = styled.h5`
font-family: 'Montserrat', 'HelveticaNeue', 'Helvetica Neue', Helvetica, Arial,
font-family:
'Montserrat', 'HelveticaNeue', 'Helvetica Neue', Helvetica, Arial,
sans-serif;
text-transform: uppercase;
letter-spacing: 1px;
-17
View File
@@ -1,17 +0,0 @@
import styled from 'styled-components'
export const StyledHeading = styled.h3`
font-family: 'Montserrat', 'HelveticaNeue', 'Helvetica Neue', Helvetica, Arial,
sans-serif;
`
export const StyledLabel = styled.label`
margin-bottom: 0;
`
export const ContactBackground = styled.img.attrs((props) => ({
alt: props.info || '',
title: props.info || ''
}))`
max-width: 100%;
`
+20
View File
@@ -0,0 +1,20 @@
import styled from 'styled-components'
export const StyledHeading = styled.h3`
font-family:
'Montserrat', 'HelveticaNeue', 'Helvetica Neue', Helvetica, Arial,
sans-serif;
`
export const StyledLabel = styled.label`
margin-bottom: 0;
`
export const ContactBackground = styled.img.attrs<{ info?: string }>(
(props) => ({
alt: props.info || '',
title: props.info || ''
})
)`
max-width: 100%;
`
@@ -1,15 +1,18 @@
import React from 'react'
import * as React from 'react'
import styled from 'styled-components'
import { FcFaq } from 'react-icons/fc'
import type { FaqProps } from '../types'
const StyledFaq = styled.div`
margin-bottom: 20px;
`
const StyledQuestion = styled.h5`
color: #222;
font-family: 'Montserrat', 'HelveticaNeue', 'Helvetica Neue', Helvetica, Arial,
font-family:
'Montserrat', 'HelveticaNeue', 'Helvetica Neue', Helvetica, Arial,
sans-serif;
text-transform: uppercase;
`
@@ -33,7 +36,7 @@ const FaqDetails = styled.div`
margin-bottom: 20px;
`
export const FAQ = ({ question, answer }) => (
export const FAQ: React.FC<FaqProps> = ({ question, answer }) => (
<StyledFaq>
<IconHolderStyled>
<FcFaq size={40} />
@@ -1,6 +1,9 @@
import React from 'react'
import * as React from 'react'
import styled from 'styled-components'
import { ImArrowRight } from 'react-icons/im'
import type { IconType } from 'react-icons'
import type { ArtProps, ReasonProps, FeatureProps } from '../types'
export const CenteredText = styled.p`
text-align: center;
@@ -9,20 +12,19 @@ export const CenteredText = styled.p`
margin: 0 auto;
font-size: 1.2rem;
line-height: 1;
font-family: 'Montserrat', 'HelveticaNeue', 'Helvetica Neue', Helvetica, Arial,
font-family:
'Montserrat', 'HelveticaNeue', 'Helvetica Neue', Helvetica, Arial,
sans-serif;
`
export const Art = styled.img.attrs((props) => ({
export const Art = styled.img.attrs<ArtProps>((props) => ({
alt: props.info || '',
title: props.info || ''
}))`
max-width: 100%;
display: block;
margin: 0 auto;
padding: 20px;
height: auto;
`
const ArrowHolder = styled.div`
const ArrowHolder = styled.div<{ bgColor?: string }>`
display: inline-block;
border-radius: 50px;
padding: 15px 17px;
@@ -30,7 +32,7 @@ const ArrowHolder = styled.div`
background-color: ${(props) =>
props.bgColor === 'red' ? '#ed1c1c' : '#90c445'};
`
const RightArrow = ({ bgColor = '' }) => (
const RightArrow: React.FC<{ bgColor?: string }> = ({ bgColor = '' }) => (
<ArrowHolder bgColor={bgColor}>
<ImArrowRight size={24} />
</ArrowHolder>
@@ -46,7 +48,7 @@ const ReasonDesc = styled.p`
text-transform: uppercase;
`
export const Reason = ({ text, bgColor = '' }) => (
export const Reason: React.FC<ReasonProps> = ({ text, bgColor = '' }) => (
<>
<RightArrow bgColor={bgColor} />
<ReasonDesc>{text}</ReasonDesc>
@@ -62,7 +64,7 @@ const IconHolderStyled = styled.div`
vertical-align: top;
`
const IconHolder = ({ Icon }) => {
const IconHolder = ({ Icon }: { Icon: IconType }) => {
return (
<IconHolderStyled>
<Icon size={24} />
@@ -77,7 +79,8 @@ const FeatureDetails = styled.div`
margin-bottom: 20px;
`
const FeatureTitle = styled.h5`
font-family: 'Montserrat', 'HelveticaNeue', 'Helvetica Neue', Helvetica, Arial,
font-family:
'Montserrat', 'HelveticaNeue', 'Helvetica Neue', Helvetica, Arial,
sans-serif;
text-transform: uppercase;
`
@@ -86,7 +89,12 @@ const FeatureDesc = styled.p`
font-size: 0.9rem;
`
export const Feature = ({ title, desc, Icon, className }) => (
export const Feature: React.FC<FeatureProps> = ({
title,
desc,
Icon,
className
}) => (
<div className={className ?? 'col-md-4'}>
<IconHolder Icon={Icon} />
<FeatureDetails>
+35
View File
@@ -0,0 +1,35 @@
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: 300;
font-display: swap;
src: url('/fonts/Montserrat-300.woff2') format('woff2');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193,
U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/Montserrat-400.woff2') format('woff2');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193,
U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Montserrat';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('/fonts/Montserrat-500.woff2') format('woff2');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193,
U+2212, U+2215, U+FEFF, U+FFFD;
}
+27 -16
View File
@@ -1,5 +1,5 @@
import { Link, graphql } from 'gatsby'
import React from 'react'
import { Link, graphql, PageProps } from 'gatsby'
import * as React from 'react'
import kebabCase from 'lodash/kebabCase'
import styled from 'styled-components'
@@ -16,27 +16,39 @@ import {
import Post from './postpreview'
import { SideBar } from './sidebar'
const BlogListTemplate = ({ data, location, pageContext }) => {
import type { PostNode, PageContext } from '../types'
interface BlogListData {
remark: {
posts: Array<{ post: PostNode }>
}
}
const BlogListTemplate: React.FC<PageProps<BlogListData, PageContext>> = ({
data,
location,
pageContext
}) => {
const posts = data.remark.posts
const iniPath =
pageContext.page == 'index'
const iniPath: string =
pageContext.page === 'index'
? `/blog/`
: pageContext.page == 'year'
: pageContext.page === 'year'
? `/${pageContext.year}/`
: pageContext.page == 'category'
? `/category/${kebabCase(pageContext.tag)}/`
: null
: pageContext.page === 'category'
? `/category/${kebabCase(pageContext.tag ?? '')}/`
: `/blog/`
const pageInfo = `Page ${pageContext.currentPage} of ${pageContext.numPages}`
Array.from({ length: 5 }, (v, k) => k + 1)
const paginationJSX = Array.from(
{ length: pageContext.numPages },
{ length: pageContext.numPages ?? 0 },
(_, i) => i + 1
).map((pageIndex) => {
const link = pageIndex === 1 ? iniPath : `${iniPath}page/${pageIndex}`
return (
<Link
key={pageIndex}
to={link}
className={`btn btn-outline-dark btn-sm ${
pageIndex === pageContext.currentPage ? 'disabled' : ''
@@ -57,13 +69,12 @@ const BlogListTemplate = ({ data, location, pageContext }) => {
heading="Data Controller’s Knowledge Base"
desc="A section dedicated to news, updates and educational pieces."
>
<Seo title="Blog" />
<Section color="black" bgColor="white" bottomArrow={false}>
<div className="row">
<div className="col-md-7">
<div className="row">
{posts.map((data, i) => (
<Post key={i} post={data.post} />
{posts.map(({ post }, i) => (
<Post key={i} post={post} />
))}
</div>
<span className="float-start">{paginationJSX}</span>
@@ -90,7 +101,7 @@ export const pageQuery = graphql`
filter: $filter
limit: $limit
skip: $skip
sort: { fields: [frontmatter___date], order: DESC }
sort: { frontmatter: { date: DESC } }
) {
posts: edges {
post: node {
@@ -115,4 +126,4 @@ export const pageQuery = graphql`
}
`
// tags
export const Head = () => <Seo title="Blog" />
+22 -11
View File
@@ -1,5 +1,5 @@
import React from 'react'
import { Link, graphql } from 'gatsby'
import * as React from 'react'
import { graphql, PageProps } from 'gatsby'
import Layout from '../components/layout'
import Seo from '../components/seo'
@@ -8,19 +8,15 @@ import { Section } from '../components/shared'
import Post from './post'
import { SideBar } from './sidebar'
const BlogPostTemplate = ({ data, location, pageContext }) => {
import type { PostNode, PageContext } from '../types'
const BlogPostTemplate: React.FC<
PageProps<{ post: PostNode }, PageContext>
> = ({ data, location, pageContext }) => {
const { post } = data
const { previewImg } = post.frontmatter
return (
<Layout location={location} heroSection={false}>
<Seo
title={post?.frontmatter?.title}
description={post?.frontmatter?.description}
previewImg={
previewImg?.childImageSharp?.gatsbyImageData?.images?.fallback?.src
}
/>
<Section color="black" bgColor="white" bottomArrow={false}>
<div className="row">
<div className="col-md-7">
@@ -37,6 +33,21 @@ const BlogPostTemplate = ({ data, location, pageContext }) => {
export default BlogPostTemplate
export const Head: React.FC<{ data: { post: PostNode } }> = ({ data }) => {
const { post } = data
const { previewImg } = post.frontmatter
return (
<Seo
title={post.frontmatter.title}
description={post.frontmatter.description}
previewImg={
previewImg?.childImageSharp?.gatsbyImageData?.images?.fallback?.src
}
/>
)
}
export const pageQuery = graphql`
query PostByPath($id: String!) {
post: markdownRemark(id: { eq: $id }) {
+21 -10
View File
@@ -1,8 +1,6 @@
import { Link, graphql, useStaticQuery } from 'gatsby'
import React from 'react'
import kebabCase from 'lodash/kebabCase'
import { Link, graphql, useStaticQuery, PageProps } from 'gatsby'
import * as React from 'react'
import { useFlexSearch } from 'react-use-flexsearch'
import styled from 'styled-components'
import Layout from '../components/layout'
import Seo from '../components/seo'
@@ -17,7 +15,19 @@ import {
import Post from './postpreview'
import { SideBar } from './sidebar'
const BlogSearchTemplate = ({ location, pageContext }) => {
import type { PageContext, PostNode, SearchPost } from '../types'
interface SearchData {
localSearchBlog: {
index: string
store: Record<string, unknown>
}
}
const BlogSearchTemplate: React.FC<PageProps<SearchData, PageContext>> = ({
location,
pageContext
}) => {
const queryData = useStaticQuery(graphql`
query {
localSearchBlog {
@@ -34,12 +44,12 @@ const BlogSearchTemplate = ({ location, pageContext }) => {
query,
queryData.localSearchBlog.index,
queryData.localSearchBlog.store
)
const postsJSX = []
) as SearchPost[]
const postsJSX: React.ReactElement[] = []
if (query) {
posts.forEach((post, i) => {
const _post = {
const _post: PostNode = {
id: post.id,
html: post.html,
fields: {
@@ -48,7 +58,7 @@ const BlogSearchTemplate = ({ location, pageContext }) => {
frontmatter: {
title: post.title,
date: post.date,
previewImg: post.previewImg
previewImg: post.previewImg as PostNode['frontmatter']['previewImg']
}
}
@@ -61,7 +71,6 @@ const BlogSearchTemplate = ({ location, pageContext }) => {
heading="Data Controller’s Knowledge Base"
desc="A section dedicated to news, updates and educational pieces."
>
<Seo title="Blog" />
<Section color="black" bgColor="white" bottomArrow={false}>
<div className="row">
<div className="col-md-7">
@@ -86,3 +95,5 @@ const BlogSearchTemplate = ({ location, pageContext }) => {
}
export default BlogSearchTemplate
export const Head = () => <Seo title="Blog" />
+132
View File
@@ -0,0 +1,132 @@
import { Link, graphql, PageProps } from 'gatsby'
import * as React from 'react'
import kebabCase from 'lodash/kebabCase'
import Layout from '../components/layout'
import Seo from '../components/seo'
import { Section } from '../components/shared'
import {
SectionHeading,
SectionDesc,
SolidButton
} from '../components/shared/styledComponents'
import Post from './postpreview'
import { SideBar } from './sidebar'
import type { PostNode, PageContext } from '../types'
interface FeedListData {
remark: {
posts: Array<{ post: PostNode }>
}
}
const FeedListTemplate: React.FC<PageProps<FeedListData, PageContext>> = ({
data,
location,
pageContext
}) => {
const posts = data.remark.posts
const iniPath: string =
pageContext.page === 'index'
? `/feed/`
: pageContext.page === 'year'
? `/feed/${pageContext.year}/`
: pageContext.page === 'category'
? `/feed/category/${kebabCase(pageContext.tag ?? '')}/`
: `/feed/`
const pageInfo = `Page ${pageContext.currentPage} of ${pageContext.numPages}`
const paginationJSX = Array.from(
{ length: pageContext.numPages ?? 0 },
(_, i) => i + 1
).map((pageIndex) => {
const link = pageIndex === 1 ? iniPath : `${iniPath}page/${pageIndex}`
return (
<Link
key={pageIndex}
to={link}
className={`btn btn-outline-dark btn-sm ${
pageIndex === pageContext.currentPage ? 'disabled' : ''
}`}
style={{
borderRadius: '50%',
padding: '.375rem .75rem',
margin: '0.25rem'
}}
>
{pageIndex}
</Link>
)
})
return (
<Layout
location={location}
heading="Data Controller’s Feed"
desc="Announcements and social media style updates from the Data Controller team."
>
<Section color="black" bgColor="white" bottomArrow={false}>
<div className="row">
<div className="col-md-7">
<div className="row">
{posts.map(({ post }, i) => (
<Post key={i} post={post} />
))}
</div>
<span className="float-start">{paginationJSX}</span>
<span className="float-end">{pageInfo}</span>
</div>
<div className="col-md-5">
<SideBar
pageContext={pageContext}
location={location}
basePath="/feed"
/>
</div>
</div>
</Section>
</Layout>
)
}
export default FeedListTemplate
export const pageQuery = graphql`
query FeedListQuery(
$filter: MarkdownRemarkFilterInput!
$skip: Int!
$limit: Int!
) {
remark: allMarkdownRemark(
filter: $filter
limit: $limit
skip: $skip
sort: { frontmatter: { date: DESC } }
) {
posts: edges {
post: node {
html
fields {
slug
}
frontmatter {
title
date(formatString: "MMMM DD, YYYY")
author
authorLink
previewImg {
childImageSharp {
gatsbyImageData(layout: CONSTRAINED)
}
}
}
}
}
}
}
`
export const Head = () => <Seo title="Feed" />
+78
View File
@@ -0,0 +1,78 @@
import * as React from 'react'
import { graphql, PageProps } from 'gatsby'
import Layout from '../components/layout'
import Seo from '../components/seo'
import { Section } from '../components/shared'
import Post from './post'
import { SideBar } from './sidebar'
import type { PostNode, PageContext } from '../types'
const FeedPostTemplate: React.FC<
PageProps<{ post: PostNode }, PageContext>
> = ({ data, location, pageContext }) => {
const { post } = data
return (
<Layout location={location} heroSection={false}>
<Section color="black" bgColor="white" bottomArrow={false}>
<div className="row">
<div className="col-md-7">
<Post post={post} location={location} />
</div>
<div className="col-md-5">
<SideBar
pageContext={pageContext}
location={location}
basePath="/feed"
/>
</div>
</div>
</Section>
</Layout>
)
}
export default FeedPostTemplate
export const Head: React.FC<{ data: { post: PostNode } }> = ({ data }) => {
const { post } = data
const { previewImg } = post.frontmatter
return (
<Seo
title={post.frontmatter.title}
description={post.frontmatter.description}
previewImg={
previewImg?.childImageSharp?.gatsbyImageData?.images?.fallback?.src
}
/>
)
}
export const pageQuery = graphql`
query FeedPostByPath($id: String!) {
post: markdownRemark(id: { eq: $id }) {
id
html
fields {
slug
}
frontmatter {
title
description
date(formatString: "MMMM DD, YYYY")
author
authorLink
previewImg {
childImageSharp {
gatsbyImageData(layout: CONSTRAINED)
}
}
tags
}
}
}
`
+20 -7
View File
@@ -1,12 +1,14 @@
import React from 'react'
import * as React from 'react'
import { Link } from 'gatsby'
import kebabCase from 'lodash/kebabCase'
import { GatsbyImage } from 'gatsby-plugin-image'
import { GatsbyImage, IGatsbyImageData } from 'gatsby-plugin-image'
import styled from 'styled-components'
export const StyledLink = styled((props) => <Link {...props} />)`
import type { PostNode } from '../../types'
export const StyledLink = styled(Link)`
text-decoration: none;
color: black;
&:hover {
@@ -16,7 +18,8 @@ export const StyledLink = styled((props) => <Link {...props} />)`
`
const StyledTitle = styled.h5`
font-family: 'Montserrat', 'HelveticaNeue', 'Helvetica Neue', Helvetica, Arial,
font-family:
'Montserrat', 'HelveticaNeue', 'Helvetica Neue', Helvetica, Arial,
sans-serif;
margin-bottom: 0;
`
@@ -45,7 +48,8 @@ const StyledContent = styled.div`
padding: 15px 0;
color: #666666;
font-family: 'Montserrat', 'HelveticaNeue', 'Helvetica Neue', Helvetica, Arial,
font-family:
'Montserrat', 'HelveticaNeue', 'Helvetica Neue', Helvetica, Arial,
sans-serif;
a {
color: black;
@@ -116,7 +120,12 @@ const StyledContent = styled.div`
}
`
const Post = ({ post, location }) => {
interface PostProps {
post: PostNode
location?: Location
}
const Post: React.FC<PostProps> = ({ post }) => {
const tagsJSX = (post.frontmatter?.tags || []).map((tag, index) => (
<span key={index}>
{index > 0 && ', '}
@@ -134,14 +143,18 @@ const Post = ({ post, location }) => {
) : (
<span>{post.frontmatter.author}</span>
)
const previewImgData = post.frontmatter.previewImg?.childImageSharp
?.gatsbyImageData as IGatsbyImageData | undefined
return (
<div>
{previewImgData && (
<GatsbyImage
image={post.frontmatter.previewImg.childImageSharp.gatsbyImageData}
image={previewImgData}
style={{ width: '100%', marginBottom: '1rem' }}
imgStyle={{ objectFit: 'contain' }}
alt={post.frontmatter.title}
/>
)}
<StyledTitle>{post.frontmatter.title}</StyledTitle>
<StyledDate>
+21 -8
View File
@@ -1,13 +1,15 @@
import React from 'react'
import * as React from 'react'
import { Link } from 'gatsby'
import { GatsbyImage } from 'gatsby-plugin-image'
import { GatsbyImage, IGatsbyImageData } from 'gatsby-plugin-image'
import styled from 'styled-components'
import getDescription from '../shared/getDescription'
export const StyledLink = styled((props) => <Link {...props} />)`
import type { PostNode } from '../../types'
export const StyledLink = styled(Link)`
text-decoration: none;
color: black;
&:hover {
@@ -17,7 +19,8 @@ export const StyledLink = styled((props) => <Link {...props} />)`
`
const StyledTitle = styled.h5`
font-family: 'Montserrat', 'HelveticaNeue', 'Helvetica Neue', Helvetica, Arial,
font-family:
'Montserrat', 'HelveticaNeue', 'Helvetica Neue', Helvetica, Arial,
sans-serif;
font-size: 1rem;
margin-bottom: 0;
@@ -34,21 +37,31 @@ const StyledDesc = styled.p`
margin-bottom: 100px;
`
const Post = ({ post }) => (
interface PostPreviewProps {
post: PostNode
}
const PostPreview: React.FC<PostPreviewProps> = ({ post }) => {
const previewImgData = post.frontmatter.previewImg?.childImageSharp
?.gatsbyImageData as IGatsbyImageData | undefined
return (
<div className="col-md-6">
<StyledLink to={post.fields.slug}>
{previewImgData && (
<GatsbyImage
image={post.frontmatter.previewImg.childImageSharp.gatsbyImageData}
image={previewImgData}
style={{ minHeight: '150px', maxHeight: '200px' }}
imgStyle={{ objectFit: 'contain' }}
alt={post.frontmatter.title}
/>
)}
<StyledTitle>{post.frontmatter.title}</StyledTitle>
<StyledDate>{post.frontmatter.date}</StyledDate>
<StyledDesc>{getDescription(post.html)}</StyledDesc>
</StyledLink>
</div>
)
)
}
export default Post
export default PostPreview
+1 -1
View File
@@ -7,7 +7,7 @@ const extractContent = (s: string): string => {
return s
}
const getDescription = (content: string): string => {
return extractContent(content).substr(0, 100) + '...'
return extractContent(content).slice(0, 100) + '...'
}
export default getDescription
+66 -20
View File
@@ -1,12 +1,17 @@
import React, { useState, useEffect } from 'react'
import * as React from 'react'
import { Link, navigate } from 'gatsby'
import kebabCase from 'lodash/kebabCase'
import styled, { css } from 'styled-components'
import { pathPrefix } from '../../gatsby-config.js'
import { FaRss } from 'react-icons/fa'
import { pathPrefix, siteMetadata } from '../../gatsby-config'
import { StyledHeading } from '../styledComponents/blog'
import { SolidButton } from '../components/shared/styledComponents'
import type { PageContext, RecentPost, TagCount } from '../types'
const rssFeedUrl = `${siteMetadata.siteUrl}rss.xml`
const styles = css`
color: #314351;
opacity: 0.8;
@@ -29,10 +34,16 @@ const linkStyles = css`
text-decoration: underline;
}
`
const ArchiveLink = styled((props) => <Link {...props} />)`
const ArchiveLink = styled(Link)`
${linkStyles}
`
const RecentPostLink = styled((props) => <Link {...props} />)`
const RssAnchor = styled.a`
${linkStyles}
display: flex;
align-items: center;
gap: 6px;
`
const RecentPostLink = styled(Link)`
${linkStyles}
padding: 3px;
border-bottom: 3px solid #e1e1e1;
@@ -59,30 +70,44 @@ const StyledPara = styled.p`
${styles}
`
const Archives = ({ archives }) => (
interface ArchivesProps {
archives: Record<string, number>
basePath?: string
}
const Archives: React.FC<ArchivesProps> = ({ archives, basePath = '' }) => (
<>
{Object.keys(archives)
.sort()
.reverse()
.map((year, index) => (
<ArchiveLink key={year} to={`/${year}/`}>
.map((year) => (
<ArchiveLink key={year} to={`${basePath}/${year}/`}>
{year} ({archives[year]})
</ArchiveLink>
))}
</>
)
const Categories = ({ tags }) => (
interface CategoriesProps {
tags: TagCount[]
basePath?: string
}
const Categories: React.FC<CategoriesProps> = ({ tags, basePath = '' }) => (
<>
{tags.map((tag, i) => (
<ArchiveLink key={i} to={`/category/${kebabCase(tag.name)}/`}>
<ArchiveLink key={i} to={`${basePath}/category/${kebabCase(tag.name)}/`}>
{tag.name} ({tag.totalCount})
</ArchiveLink>
))}
</>
)
const RecentPosts = ({ posts }) => (
interface RecentPostsProps {
posts: RecentPost[]
}
const RecentPosts: React.FC<RecentPostsProps> = ({ posts }) => (
<>
{posts.map((post) => (
<RecentPostLink key={post.slug} to={post.slug}>
@@ -92,22 +117,34 @@ const RecentPosts = ({ posts }) => (
</>
)
export const SideBar = ({ pageContext, location, notFoundPage = false }) => {
interface SideBarProps {
pageContext: PageContext
location: Location
notFoundPage?: boolean
basePath?: string
}
export const SideBar: React.FC<SideBarProps> = ({
pageContext,
location,
notFoundPage = false,
basePath = ''
}) => {
const params = new URLSearchParams(location.search.substring(1))
const queryUrl = params?.get('s') || ''
const [query, setQuery] = useState(queryUrl)
useEffect(() => {
const [query, setQuery] = React.useState(queryUrl)
React.useEffect(() => {
setQuery(queryUrl)
}, [queryUrl])
const handleSubmit = (event) => {
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault()
navigate('/search?s=' + query)
}
const _handleKeyDown = (event) => {
const _handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
handleSubmit(event)
navigate('/search?s=' + query)
}
}
@@ -130,13 +167,16 @@ export const SideBar = ({ pageContext, location, notFoundPage = false }) => {
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => _handleKeyDown(e)}
onKeyDown={_handleKeyDown}
className="form-control"
placeholder="Search"
aria-label="Search"
aria-describedby="button-addon2"
/>
<SolidButton theme="dark" onClick={handleSubmit}>
<SolidButton
theme="dark"
onClick={() => navigate('/search?s=' + query)}
>
Search
</SolidButton>
<form onSubmit={handleSubmit}></form>
@@ -168,11 +208,17 @@ export const SideBar = ({ pageContext, location, notFoundPage = false }) => {
<>
<SideBarSection>
<StyledHeading>Archives</StyledHeading>
<Archives archives={pageContext.archives} />
<Archives archives={pageContext.archives} basePath={basePath} />
</SideBarSection>
<SideBarSection>
<StyledHeading>Categories</StyledHeading>
<Categories tags={pageContext.tags} />
<Categories tags={pageContext.tags} basePath={basePath} />
</SideBarSection>
<SideBarSection>
<StyledHeading>Subscribe</StyledHeading>
<RssAnchor href={rssFeedUrl} target="_blank" rel="noopener">
<FaRss /> RSS Feed
</RssAnchor>
</SideBarSection>
</>
)}
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more